Compare commits

...
4 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Fable 5 c5f90cdb4f feat(auth): session/proxy auth for Wiki.js buttons; drop browser API key
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m12s
The wikijs-integration.js embedded a full-privilege API key that was
served to every wiki visitor — it unlocked all 66 authenticated
endpoints, including page/vector deletes and index purges. That key
has been rotated out of service.

The two browser endpoints (/ingest/page, /entity-linking/link-page)
now authenticate via the NPM /library-desk/ proxy location instead of a
key: Authentik forward-auth for external users, LAN bypass for internal,
verified by a trusted proxy marker header. This is safe because
library-desk binds loopback-only, so NPM is the sole path that can set
that header. The browser holds no secret; the script calls same-origin
with credentials. Machine callers (the Scheduler) keep the Bearer key
on the container-network endpoints. verify_api_key now compares in
constant time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:02:02 +02:00
jpmschweitzerandClaude Fable 5 f5983c379f chore: release v1.8.1
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 37s
Network-migration release: .env.example service URL defaults moved to
docker-dataplane container names ahead of the Phase 4 port lockdown, CI
image pushes routed via git.schweitz.net, AGENTS.md health-check URL
corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:32:13 +02:00
jpmschweitzerandClaude Fable 5 90515e0f6d fix(config): use dataplane container names in service URL defaults
The homelab is retiring *.schweitz.internal and will rebind most
published container ports from 0.0.0.0 to 127.0.0.1 (Phase 4), so
host-IP:published-port URLs will stop working for container-to-container
traffic. Point the .env.example defaults at docker-dataplane container
names and INTERNAL ports instead: wiki:3000, neo4j:7687, searxng:8080
(internal port, not the 8087 host publish), paperless:8000, ollama:11434.
All names and ports verified against the running containers.

Also correct the AGENTS.md deploy health-check URL, which claimed the
service runs on port 8000; it runs on 8089.

static/wikijs-integration.js is left unchanged: it already derives the
API base from its own script URL (document.currentScript.src, split on
/static/) and only uses the hardcoded IP:8089 as a last-resort fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:21:43 +02:00
jpmschweitzerandClaude Fable 5 83d9ade910 chore(ci): push images via git.schweitz.net registry
The .internal registry domain is being retired; git.schweitz.net now
serves the registry without SSO on /v2/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:09:52 +02:00
11 changed files with 148 additions and 25 deletions
+5 -5
View File
@@ -1,13 +1,13 @@
# Service URLs for local dev (pointing to your server)
TEST_HOST=192.168.86.149
WIKIJS_URL=http://192.168.86.149:8088
NEO4J_URI=bolt://192.168.86.149:7687
WIKIJS_URL=http://wiki:3000
NEO4J_URI=bolt://neo4j:7687
QDRANT_HOST=192.168.86.149
QDRANT_PORT=6333
OLLAMA_URL=http://192.168.86.149:11434
SEARXNG_URL=http://192.168.86.149:8080
OLLAMA_URL=http://ollama:11434
SEARXNG_URL=http://searxng:8080
REDIS_HOST=192.168.86.149
PAPERLESS_URL=http://192.168.86.149:8091
PAPERLESS_URL=http://paperless:8000
OLLAMA_LLM_MODEL=gemma4:e2b
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
+3 -3
View File
@@ -26,7 +26,7 @@ jobs:
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.schweitz.internal
registry: git.schweitz.net
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -36,8 +36,8 @@ jobs:
context: .
push: true
tags: |
git.schweitz.internal/jpmschweitzer/library-desk:latest
git.schweitz.internal/jpmschweitzer/library-desk:${{ github.ref_name }}
git.schweitz.net/jpmschweitzer/library-desk:latest
git.schweitz.net/jpmschweitzer/library-desk:${{ github.ref_name }}
- name: Trigger Watchtower update
if: success()
+1 -1
View File
@@ -47,7 +47,7 @@ When changes are ready for deployment:
5. **CI/CD triggers automatically**:
- Gitea CI builds Docker image on new tag
- Watchtower pulls and deploys to production
- Verify deployment: `curl http://192.168.86.149:8000/health`
- Verify deployment: `curl http://192.168.86.149:8089/health`
---
+19
View File
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.9.0] - 2026-07-20
### Security
- The Wiki.js integration buttons no longer embed an API key in the browser. The re-index and entity-link endpoints now authenticate via the NPM `/library-desk/` proxy (Authentik session for external users, LAN bypass for internal), verified by a trusted proxy marker header. The previously-embedded key was a full-privilege key served to every wiki visitor; it has been rotated out of service.
- `verify_api_key` now uses a constant-time comparison.
### Changed
- `static/wikijs-integration.js` calls library-desk same-origin (`/library-desk/...`) with `credentials: same-origin` and no `Authorization` header. Update the Wiki.js code-injection snippet to `<script src="/library-desk/static/wikijs-integration.js">`.
- Machine callers (the Scheduler) continue to use the Bearer API key against the container-network endpoints; only the two browser endpoints switched to proxy auth.
## [1.8.1] - 2026-07-19
### Changed
- Container images are now pushed via the `git.schweitz.net` registry endpoint (the `.internal` registry domain is being retired); no change to the image name consumed by Watchtower.
- `.env.example` service URL defaults now use docker-dataplane container names (`wiki:3000`, `neo4j:7687`, `searxng:8080`, `paperless:8000`, `ollama:11434`) instead of host IP + published port, ahead of the Phase 4 port lockdown; AGENTS.md deploy health-check URL corrected from port 8000 to 8089.
## [1.8.0] - 2026-07-14
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "library-desk"
version = "1.8.0"
version = "1.9.0"
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
readme = "README.md"
requires-python = ">=3.12"
+39 -2
View File
@@ -765,7 +765,8 @@ def get_volatile_cache_service() -> "VolatileCacheService":
# Authentication
from fastapi import Security, HTTPException
import secrets
from fastapi import Security, HTTPException, Request
from fastapi.security import HTTPBearer
security = HTTPBearer()
@@ -788,7 +789,7 @@ async def verify_api_key(
Raises:
HTTPException: If API key is invalid
"""
if credentials.credentials != settings.library_api_key:
if not secrets.compare_digest(credentials.credentials, settings.library_api_key):
raise HTTPException(
status_code=403,
detail="Invalid API key"
@@ -796,6 +797,42 @@ async def verify_api_key(
return credentials.credentials
# Header set by NPM only on the authenticated /library-desk/ proxy location.
# library-desk is bound to loopback (127.0.0.1:8089), so NPM is the only path
# that can reach it and set this header — a client cannot forge it. NPM also
# overwrites any client-supplied value via proxy_set_header.
_PROXY_MARKER_HEADER = "x-library-desk-proxy"
async def verify_browser_request(
request: Request,
settings: SettingsDep,
) -> str:
"""
Auth for browser-facing endpoints (the Wiki.js integration buttons).
Accepts the request when it arrives through the authenticated NPM proxy
location (Authentik session for external users, or the LAN bypass for
internal ones) — identified by the trusted proxy marker header. No secret
is carried in the browser. Machine callers may still authenticate with the
Bearer API key. Returns the acting user's identity.
"""
if request.headers.get(_PROXY_MARKER_HEADER) == "1":
# Authentik injects the identity for externally-authenticated users;
# on the LAN bypass these are empty and the endpoint falls back to the
# user supplied in the request body.
return request.headers.get("x-authentik-email") or "lan"
# Fallback: server-to-server Bearer API key.
auth = request.headers.get("authorization", "")
if auth.startswith("Bearer ") and secrets.compare_digest(
auth[len("Bearer "):], settings.library_api_key
):
return auth[len("Bearer "):]
raise HTTPException(status_code=401, detail="Unauthenticated")
# Service type aliases for FastAPI endpoint dependencies
# These are defined after the factory functions
from src.services.vector_service import VectorService
+2 -2
View File
@@ -18,7 +18,7 @@ from src.core.dependencies import (
get_wiki_service,
get_graph_service,
get_ingestion_service,
verify_api_key
verify_browser_request,
)
from src.services.wiki_service import WikiService
from src.services.graph_service import GraphService
@@ -57,7 +57,7 @@ async def link_entities_in_page(
wiki_service: WikiService = Depends(get_wiki_service),
graph_service: GraphService = Depends(get_graph_service),
ingestion_service: IngestionService = Depends(get_ingestion_service),
api_key: str = Depends(verify_api_key)
actor: str = Depends(verify_browser_request)
) -> EntityLinkingResult:
"""
Find and link entities mentioned in a wiki page.
+3 -2
View File
@@ -15,7 +15,8 @@ from src.models.ingestion import (
BatchIngestionResult
)
from src.core.dependencies import (
get_ingestion_service, verify_api_key, RequiredUserQuery, JobManagerDep
get_ingestion_service, verify_api_key, verify_browser_request,
RequiredUserQuery, JobManagerDep
)
from src.jobs.job_manager import JobManager, JobStatus, JobType
@@ -65,7 +66,7 @@ async def ingest_page(
request: IngestionRequest,
ingestion: IngestionService = Depends(get_ingestion_service),
job_manager: JobManagerDep = None,
api_key: str = Depends(verify_api_key)
actor: str = Depends(verify_browser_request)
):
"""
Ingest a single wiki page into the knowledge base.
+14 -7
View File
@@ -2,21 +2,28 @@
* Library Desk Integration for Wiki.js
* Combined re-index and entity linking buttons
*
* Usage: Add to Wiki.js Code Injection:
* <script src="http://192.168.86.149:8089/static/wikijs-integration.js"></script>
* Usage: Add to Wiki.js Code Injection (served same-origin behind Authentik):
* <script src="/library-desk/static/wikijs-integration.js"></script>
*
* Auth: none in the browser. Requests go same-origin through the NPM
* /library-desk/ location, which is gated by Authentik forward-auth with the
* LAN bypass — external users are authenticated, LAN users pass through, and
* library-desk trusts the proxy marker header. No API key is embedded here.
*/
(function() {
'use strict';
// Auto-detect Library Desk URL
// Same-origin base: the script is served from <origin>/library-desk/static/...,
// so strip '/static/...' to get the library-desk mount point on this origin.
const scriptTag = document.currentScript;
const scriptUrl = scriptTag ? scriptTag.src : '';
const libraryDeskUrl = scriptUrl ? scriptUrl.split('/static/')[0] : 'http://192.168.86.149:8089';
const libraryDeskUrl = scriptUrl
? scriptUrl.replace(/^https?:\/\/[^/]+/, '').split('/static/')[0]
: '/library-desk';
// Shared configuration
const CONFIG = window.LIBRARY_DESK_CONFIG || {
libraryDeskUrl: libraryDeskUrl,
apiKey: 'af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5',
user: 'jpmschweitzer',
buttonPosition: 'toolbar', // 'toolbar' or 'floating'
debug: true
@@ -229,8 +236,8 @@
// Re-index directly
const response = await fetch(CONFIG.libraryDeskUrl + '/ingest/page', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Authorization': 'Bearer ' + CONFIG.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -334,8 +341,8 @@
// Call entity linking endpoint
const response = await fetch(CONFIG.libraryDeskUrl + '/entity-linking/link-page', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Authorization': 'Bearer ' + CONFIG.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
+57
View File
@@ -0,0 +1,57 @@
"""
Tests for verify_browser_request — the session/proxy auth used by the
Wiki.js integration endpoints (no secret in the browser).
"""
import pytest
from types import SimpleNamespace
from fastapi import HTTPException
from starlette.requests import Request
from src.core.dependencies import verify_browser_request
def _request(headers: dict) -> Request:
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
return Request({"type": "http", "method": "POST", "path": "/ingest/page", "headers": raw})
SETTINGS = SimpleNamespace(library_api_key="server-secret-key")
@pytest.mark.asyncio
async def test_proxy_marker_with_authentik_identity_is_accepted():
req = _request({"X-Library-Desk-Proxy": "1", "X-Authentik-Email": "user@example.com"})
assert await verify_browser_request(req, SETTINGS) == "user@example.com"
@pytest.mark.asyncio
async def test_proxy_marker_on_lan_bypass_falls_back_to_lan():
# LAN bypass: proxy marker present, no Authentik identity headers.
req = _request({"X-Library-Desk-Proxy": "1"})
assert await verify_browser_request(req, SETTINGS) == "lan"
@pytest.mark.asyncio
async def test_valid_api_key_is_accepted_for_machine_callers():
req = _request({"Authorization": "Bearer server-secret-key"})
assert await verify_browser_request(req, SETTINGS) == "server-secret-key"
@pytest.mark.asyncio
async def test_no_marker_and_no_key_is_rejected():
with pytest.raises(HTTPException) as exc:
await verify_browser_request(_request({}), SETTINGS)
assert exc.value.status_code == 401
@pytest.mark.asyncio
async def test_forged_marker_value_is_rejected():
# Only the exact NPM-set value "1" is trusted.
with pytest.raises(HTTPException):
await verify_browser_request(_request({"X-Library-Desk-Proxy": "yes"}), SETTINGS)
@pytest.mark.asyncio
async def test_wrong_api_key_is_rejected():
with pytest.raises(HTTPException):
await verify_browser_request(_request({"Authorization": "Bearer wrong"}), SETTINGS)
+4 -2
View File
@@ -12,19 +12,21 @@ import pytest
from fastapi.testclient import TestClient
from src.main import app
from src.core.dependencies import verify_api_key
from src.core.dependencies import verify_api_key, verify_browser_request
@pytest.fixture(scope="module")
def client():
"""TestClient with API-key auth stubbed out (no lifespan startup)."""
"""TestClient with auth stubbed out (no lifespan startup)."""
app.dependency_overrides[verify_api_key] = lambda: "test-key"
app.dependency_overrides[verify_browser_request] = lambda: "test-user"
try:
# No context manager: startup/lifespan events are NOT triggered,
# so no connections to external services are attempted.
yield TestClient(app)
finally:
app.dependency_overrides.pop(verify_api_key, None)
app.dependency_overrides.pop(verify_browser_request, None)
QUERY_PARAM_ENDPOINTS = [