feat(p4): web UI — PocketID OIDC with a fail-closed group check, review flows

Six pages (inbox, search, note, edit, browse, health), HTMX vendored, markdown
rendered with raw HTML disabled, CSP + origin check on every POST. The group
check rejects a missing scope, a missing claim and a wrong-typed claim
separately, each with its own test (§14.21). /healthz, /readyz and /metrics
stay open and carry no note content; readiness ignores Bifrost (§14.17).
This commit is contained in:
Markus committed 2026-09-12 08:07:14 +00:00
1 parent cc536b80ab
commit 5853b7b1fa
20 files changed
+1662 -2

No files matched your search

Binary file not shown.
+8 -2
View File
@@ -56,8 +56,12 @@ def utcnow() -> str:
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def parse(text: str) -> tuple[CommentedMap, str]:
"""Split a note into (frontmatter, body). Both are NFC-normalised."""
def parse(text: str, *, strict: bool = False) -> tuple[CommentedMap, str]:
"""Split a note into (frontmatter, body). Both are NFC-normalised.
strict=True re-raises broken YAML — used where a human just typed it. The
scanner stays lenient so one bad file cannot stop the whole vault indexing.
"""
text = nfc(text.replace("\r\n", "\n"))
m = _FM_RE.match(text)
if not m:
@@ -67,6 +71,8 @@ def parse(text: str) -> tuple[CommentedMap, str]:
try:
data = _yaml().load(raw)
except Exception: # a hand-edited file with broken YAML must not kill the scan
if strict:
raise
data = None
if data is None:
data = CommentedMap()
+1
View File
@@ -0,0 +1 @@
"""Web UI (§10)."""
+497
View File
@@ -0,0 +1,497 @@
"""Web UI on :8081 (§10).
Auth is in-app OIDC against PocketID with a fail-closed group check. /healthz,
/readyz and /metrics stay open — they are probed and scraped in-cluster — and
carry no note content.
"""
from __future__ import annotations
import asyncio
import difflib
import json
import logging
import secrets
from pathlib import Path
from typing import Any
from urllib.parse import urlencode, urlparse
from fastapi import FastAPI, Form, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from prometheus_client import CONTENT_TYPE_LATEST, CollectorRegistry, Gauge, generate_latest
from mdmem.errors import MdmemError, VersionConflict
from mdmem.notes import parse as parse_note
from mdmem.service import MemoryService
from mdmem.ui.auth import (
FLOW_COOKIE,
SESSION_COOKIE,
AuthError,
FlowCodec,
OidcClient,
Principal,
SessionCodec,
authorize_claims,
new_pkce,
)
from mdmem.ui.render import render_markdown
log = logging.getLogger("mdmem.ui")
HERE = Path(__file__).parent
CSP = (
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
"script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
)
OPEN_PATHS = ("/healthz", "/readyz", "/metrics")
OPEN_PREFIXES = ("/ui/auth/", "/ui/static/")
def build_ui_app(svc: MemoryService) -> FastAPI: # noqa: C901 - one place, many routes
settings = svc.settings
if settings.ui_auth_disabled:
# §10 allows shipping the UI without OIDC, but then it must not be reachable
# through an HTTPRoute — port-forward only (§14.19).
log.error(
"UI_AUTH_DISABLED is set: /ui is unauthenticated. Never put an HTTPRoute in "
"front of this listener — reach it with kubectl port-forward only (§10, §14.19)."
)
elif not settings.session_secret:
raise RuntimeError(
"SESSION_SECRET is not set and UI_AUTH_DISABLED is off; refusing to start a UI "
"whose sessions cannot survive a restart"
)
templates = Jinja2Templates(directory=str(HERE / "templates"))
templates.env.globals["render_markdown"] = render_markdown
session_secret = settings.session_secret or secrets.token_hex(32)
sessions = SessionCodec(session_secret, settings.session_ttl_hours)
flows = FlowCodec(session_secret)
oidc = OidcClient(settings)
app = FastAPI(title="mdmem", docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/ui/static", StaticFiles(directory=str(HERE / "static")), name="static")
registry = CollectorRegistry()
g_notes = Gauge("mdmem_notes", "notes by lifecycle state", ["state"], registry=registry)
g_chunks = Gauge("mdmem_chunks", "indexed chunks", registry=registry)
g_pending = Gauge("mdmem_vec_pending", "chunks waiting for an embedding", registry=registry)
g_embed_ok = Gauge("mdmem_embed_ok", "1 when the embedding backend answered", registry=registry)
g_embed_ms = Gauge("mdmem_embed_last_ms", "latency of the last embedding call", registry=registry)
g_searches = Gauge("mdmem_searches_total", "searches served", registry=registry)
g_writes = Gauge("mdmem_writes_total", "notes written", registry=registry)
g_unresolved = Gauge("mdmem_unresolved_links", "wikilinks with no target", registry=registry)
# -- middleware --------------------------------------------------------
@app.middleware("http")
async def security(request: Request, call_next): # type: ignore[no-untyped-def]
path = request.url.path
is_open = path in OPEN_PATHS or path.startswith(OPEN_PREFIXES)
if request.method in ("POST", "PUT", "DELETE", "PATCH") and not is_open:
origin = request.headers.get("origin")
if origin is not None and not _origin_allowed(origin, settings, request):
return JSONResponse({"error": "bad origin"}, status_code=403)
if not is_open:
principal = _principal(request)
if principal is None:
if settings.ui_auth_disabled:
request.state.principal = Principal(sub="local", name="local", email="")
else:
nxt = request.url.path
return RedirectResponse(f"/ui/auth/login?next={nxt}", status_code=302)
else:
request.state.principal = principal
response = await call_next(request)
response.headers["Content-Security-Policy"] = CSP
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Frame-Options"] = "DENY"
return response
def _principal(request: Request) -> Principal | None:
return sessions.read(request.cookies.get(SESSION_COOKIE))
def _ctx(request: Request, **kw: Any) -> dict[str, Any]:
who = getattr(request.state, "principal", None)
counts = svc.health().counts
return {"request": request, "user": who, "counts": counts, **kw}
# -- health / metrics --------------------------------------------------
@app.get("/healthz")
async def healthz() -> JSONResponse:
"""Deliberately dumb: a liveness probe must not depend on the index (§13a.3)."""
return JSONResponse({"status": "ok"})
@app.get("/readyz")
async def readyz() -> JSONResponse:
ok, checks = svc.ready()
return JSONResponse({"ready": ok, "checks": checks}, status_code=200 if ok else 503)
@app.get("/metrics")
async def metrics() -> Response:
h = svc.health()
for state, n in h.counts.items():
g_notes.labels(state=state).set(n)
g_chunks.set(h.chunks)
g_pending.set(h.vec_pending)
g_embed_ok.set(1 if h.embed_ok else 0)
g_embed_ms.set(h.embed_last_ms or 0)
g_searches.set(svc.metrics["search"])
g_writes.set(svc.metrics["writes"])
g_unresolved.set(h.unresolved_links)
return Response(generate_latest(registry), media_type=CONTENT_TYPE_LATEST)
# -- auth --------------------------------------------------------------
@app.get("/ui/auth/login")
async def login(request: Request, next: str = "/ui/inbox") -> Response:
if settings.ui_auth_disabled:
return RedirectResponse(next, status_code=302)
state = secrets.token_urlsafe(24)
verifier, challenge = new_pkce()
try:
url = await oidc.authorize_url(state, challenge)
except Exception as exc:
log.error("OIDC discovery failed", extra={"fields": {"error": str(exc)[:200]}})
return PlainTextResponse("identity provider unreachable", status_code=503)
resp = RedirectResponse(url, status_code=302)
resp.set_cookie(
FLOW_COOKIE,
flows.issue(state, verifier, next if next.startswith("/ui/") else "/ui/inbox"),
max_age=600,
httponly=True,
secure=True,
samesite="lax",
path="/ui/auth",
)
return resp
@app.get("/ui/auth/callback")
async def callback(request: Request, code: str = "", state: str = "") -> Response:
flow = flows.read(request.cookies.get(FLOW_COOKIE))
if not flow or not code or not secrets.compare_digest(flow["state"], state):
return PlainTextResponse("invalid login state; start again", status_code=400)
try:
tokens = await oidc.exchange(code, flow["verifier"])
claims = await oidc.claims(tokens)
principal = authorize_claims(claims, settings)
except AuthError as exc:
return PlainTextResponse(f"access denied: {exc.reason}", status_code=exc.status)
except Exception as exc:
log.error("login failed", extra={"fields": {"error": str(exc)[:200]}})
return PlainTextResponse("login failed", status_code=502)
resp = RedirectResponse(flow.get("next") or "/ui/inbox", status_code=302)
resp.set_cookie(
SESSION_COOKIE,
sessions.issue(principal),
max_age=settings.session_ttl_hours * 3600,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
resp.delete_cookie(FLOW_COOKIE, path="/ui/auth")
log.info("login", extra={"fields": {"sub": principal.sub}})
return resp
@app.get("/ui/auth/logout")
async def logout() -> Response:
resp = RedirectResponse("/ui/auth/login", status_code=302)
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
# -- pages -------------------------------------------------------------
@app.get("/")
async def root() -> Response:
return RedirectResponse("/ui/inbox", status_code=302)
@app.get("/ui/inbox", response_class=HTMLResponse)
async def inbox(request: Request) -> Response:
items = []
for row in svc.list_notes(state="inbox", limit=200, order="created")[::-1]:
note = svc.get(row["id"])
entry = {"note": note, "diff": None, "target": None, "duplicates": []}
if note.get("supersede_chain"):
entry["supersedes"] = note["supersede_chain"]
revises = svc.get_note(row["id"]).revises
if revises:
try:
target = svc.get(revises)
entry["target"] = target
entry["diff"] = _diff(target["body"], note["body"], target["title"])
except MdmemError:
entry["target"] = None
for dup_id in note["possible_duplicates"]:
try:
entry["duplicates"].append(svc.get(dup_id))
except MdmemError:
continue
items.append(entry)
return templates.TemplateResponse(request, "inbox.html", _ctx(request, items=items))
@app.post("/ui/n/{note_id}/approve")
async def approve(request: Request, note_id: str, body: str = Form(default="")) -> Response:
try:
await svc.approve(note_id, edited_body=body or None)
except MdmemError as exc:
return _error(request, templates, exc)
return RedirectResponse("/ui/inbox", status_code=303)
@app.post("/ui/n/{note_id}/reject")
async def reject(request: Request, note_id: str, reason: str = Form(default="")) -> Response:
try:
await svc.reject(note_id, reason)
except MdmemError as exc:
return _error(request, templates, exc)
return RedirectResponse("/ui/inbox", status_code=303)
@app.post("/ui/n/{note_id}/merge")
async def merge(request: Request, note_id: str, target: str = Form(...)) -> Response:
try:
await svc.merge_into(note_id, target)
except MdmemError as exc:
return _error(request, templates, exc)
return RedirectResponse("/ui/inbox", status_code=303)
@app.get("/ui/search", response_class=HTMLResponse)
async def search_page(
request: Request,
q: str = Query(default=""),
type: str = Query(default=""),
tag: str = Query(default=""),
state: str = Query(default=""),
since: str = Query(default=""),
until: str = Query(default=""),
superseded: bool = Query(default=False),
) -> Response:
resp = None
if q.strip():
resp = await svc.search(
q,
k=20,
types=[type] if type else None,
tags=[tag] if tag else None,
since=since or None,
until=until or None,
include_inbox=state != "active",
include_archive=state == "archive",
include_superseded=superseded,
debug=True,
max_chars=100_000,
)
return templates.TemplateResponse(
request,
"search.html",
_ctx(
request,
q=q,
resp=resp,
type=type,
tag=tag,
state=state,
since=since,
until=until,
superseded=superseded,
),
)
@app.get("/ui/n/{note_id}", response_class=HTMLResponse)
async def note_page(request: Request, note_id: str) -> Response:
try:
note = svc.get(note_id)
except MdmemError as exc:
return _error(request, templates, exc)
history = svc.vault.history(note_id)
resolve = {link["title"]: link["id"] for link in note["links"] if link.get("id")}
return templates.TemplateResponse(
request, "note.html", _ctx(request, note=note, history=history, resolve=resolve)
)
@app.get("/ui/n/{note_id}/history/{stamp}", response_class=HTMLResponse)
async def history_diff(request: Request, note_id: str, stamp: str) -> Response:
try:
old = svc.vault.read_history(note_id, stamp)
except Exception as exc:
return PlainTextResponse(str(exc), status_code=404)
current = svc.get_note(note_id).to_text()
return templates.TemplateResponse(
request,
"partial_diff.html",
{"request": request, "diff": _diff(old, current, f"{stamp} → jetzt")},
)
@app.get("/ui/n/{note_id}/edit", response_class=HTMLResponse)
async def edit_page(request: Request, note_id: str) -> Response:
try:
note = svc.get_note(note_id)
except MdmemError as exc:
return _error(request, templates, exc)
fm_text = note.to_text().split("---\n")[1] if note.to_text().startswith("---") else ""
return templates.TemplateResponse(
request,
"edit.html",
_ctx(request, note=note, version=note.file_hash, frontmatter=fm_text, conflict=None),
)
@app.post("/ui/n/{note_id}/edit", response_class=HTMLResponse)
async def edit_save(
request: Request,
note_id: str,
body: str = Form(...),
frontmatter: str = Form(default=""),
expected_version: str = Form(...),
) -> Response:
fm: dict[str, Any] | None = None
if frontmatter.strip():
try:
fm, _ = parse_note(f"---\n{frontmatter.strip()}\n---\n", strict=True)
except Exception as exc:
return PlainTextResponse(f"frontmatter is not valid YAML: {exc}", status_code=400)
try:
await svc.edit(note_id, body=body, expected_version=expected_version, frontmatter=fm)
except VersionConflict as exc:
current = svc.get_note(note_id)
return templates.TemplateResponse(
request,
"edit.html",
_ctx(
request,
note=current,
version=current.file_hash,
frontmatter=frontmatter,
conflict={
"message": exc.message,
"diff": _diff(current.body, body, "auf der Platte → dein Entwurf"),
"yours": body,
},
),
status_code=409,
)
except MdmemError as exc:
return _error(request, templates, exc)
return RedirectResponse(f"/ui/n/{note_id}", status_code=303)
@app.post("/ui/preview", response_class=HTMLResponse)
async def preview(request: Request, body: str = Form(default="")) -> Response:
return HTMLResponse(render_markdown(body))
@app.post("/ui/n/{note_id}/state")
async def set_state(request: Request, note_id: str, state: str = Form(...)) -> Response:
try:
await svc.set_state(note_id, state)
except MdmemError as exc:
return _error(request, templates, exc)
return RedirectResponse(f"/ui/n/{note_id}", status_code=303)
@app.post("/ui/n/{note_id}/delete")
async def delete(request: Request, note_id: str, confirm: str = Form(default="")) -> Response:
if confirm != note_id:
return PlainTextResponse("confirmation did not match the note id", status_code=400)
try:
await svc.hard_delete(note_id)
except MdmemError as exc:
return _error(request, templates, exc)
return RedirectResponse("/ui/browse", status_code=303)
@app.get("/ui/browse", response_class=HTMLResponse)
async def browse(
request: Request,
state: str = Query(default=""),
order: str = Query(default="updated"),
tag: str = Query(default=""),
) -> Response:
rows = svc.list_notes(state=state or None, limit=2000, order=order)
if tag:
rows = [r for r in rows if tag in r["tags"]]
cloud: dict[str, int] = {}
for r in svc.list_notes(limit=5000):
for t in r["tags"]:
cloud[t] = cloud.get(t, 0) + 1
return templates.TemplateResponse(
request,
"browse.html",
_ctx(
request,
rows=rows,
order=order,
state=state,
tag=tag,
cloud=sorted(cloud.items(), key=lambda kv: -kv[1]),
),
)
@app.get("/ui/health", response_class=HTMLResponse)
async def health_page(request: Request) -> Response:
return templates.TemplateResponse(
request,
"health.html",
_ctx(
request,
health=svc.health(),
unresolved=svc.unresolved_links()[:100],
calls=svc.audit.recent(100),
settings=settings,
),
)
@app.post("/ui/health/reindex")
async def reindex(request: Request, full: str = Form(default="")) -> Response:
await svc.reindex(full=bool(full))
return RedirectResponse("/ui/health", status_code=303)
@app.post("/ui/health/reembed")
async def reembed(request: Request) -> Response:
def _mark() -> None:
with svc.db.write() as w:
w.execute("UPDATE chunks SET vec_pending=1")
w.execute("DELETE FROM chunks_vec")
await asyncio.to_thread(_mark)
await svc.embed_pending()
return RedirectResponse("/ui/health", status_code=303)
return app
def _origin_allowed(origin: str, settings: Any, request: Request) -> bool:
allowed = set(settings.ui_allowed_origins)
if settings.ui_base_url:
allowed.add(settings.ui_base_url)
if not allowed: # not configured: fall back to same-host
host = request.headers.get("host", "")
return urlparse(origin).netloc == host
return origin.rstrip("/") in {a.rstrip("/") for a in allowed}
def _diff(old: str, new: str, label: str) -> str:
return "\n".join(
difflib.unified_diff(
(old or "").splitlines(),
(new or "").splitlines(),
fromfile=f"{label} (aktiv)",
tofile="Vorschlag",
lineterm="",
n=3,
)
)
def _error(request: Request, templates: Jinja2Templates, exc: MdmemError) -> Response:
status = {"NOT_FOUND": 404, "VERSION_CONFLICT": 409}.get(exc.code, 400)
return templates.TemplateResponse(
request,
"error.html",
{"request": request, "error": exc.as_dict(), "user": None, "counts": {}},
status_code=status,
)
def query_string(**kw: Any) -> str: # pragma: no cover - template helper
return urlencode({k: v for k, v in kw.items() if v})
def json_dumps(value: Any) -> str: # pragma: no cover - template helper
return json.dumps(value, ensure_ascii=False)
+225
View File
@@ -0,0 +1,225 @@
"""PocketID OIDC with a group check that fails closed (§10, §14.21).
There is no forward-auth middleware in this cluster, so the UI authenticates
itself, exactly like trek/karakeep/open-webui do. Authorisation is a group
claim: membership is managed in PocketID, so access changes need no redeploy.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
from dataclasses import dataclass
from typing import Any
import httpx
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from mdmem.config import Settings
log = logging.getLogger("mdmem.auth")
SESSION_COOKIE = "mdmem_session"
FLOW_COOKIE = "mdmem_oidc_flow"
FLOW_TTL_S = 600
class AuthError(Exception):
def __init__(self, reason: str, status: int = 403) -> None:
super().__init__(reason)
self.reason = reason
self.status = status
@dataclass(frozen=True)
class Principal:
sub: str
name: str
email: str
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def new_pkce() -> tuple[str, str]:
verifier = _b64(secrets.token_bytes(64))
challenge = _b64(hashlib.sha256(verifier.encode()).digest())
return verifier, challenge
def authorize_claims(claims: dict[str, Any], settings: Settings) -> Principal:
"""Fail closed: the only accepted outcome is 'the required group is present'.
A missing scope, a missing claim, or a claim of the wrong type are all
rejections — never 'the token is valid, so allow' (§14.21).
"""
sub = str(claims.get("sub") or "")
claim_name = settings.oidc_groups_claim
required = settings.oidc_required_group
seen = sorted(claims.keys())
if not required:
raise AuthError("OIDC_REQUIRED_GROUP is not configured; refusing to authorise")
if claim_name not in claims:
log.warning(
"login rejected: group claim absent",
extra={"fields": {"sub": sub, "claim": claim_name, "claims_seen": seen}},
)
raise AuthError(f"the {claim_name!r} claim is absent — is the groups scope configured?")
groups = claims.get(claim_name)
if isinstance(groups, str):
groups = [groups]
if not isinstance(groups, list) or not all(isinstance(g, str) for g in groups):
log.warning(
"login rejected: group claim has the wrong type",
extra={"fields": {"sub": sub, "claim": claim_name, "type": type(groups).__name__}},
)
raise AuthError(f"the {claim_name!r} claim is not a list of strings")
if required not in groups:
log.warning(
"login rejected: required group missing",
extra={"fields": {"sub": sub, "claim": claim_name, "required": required}},
)
raise AuthError(f"membership in {required!r} is required")
return Principal(
sub=sub,
name=str(claims.get("name") or claims.get("preferred_username") or sub),
email=str(claims.get("email") or ""),
)
class SessionCodec:
def __init__(self, secret: str, ttl_hours: int) -> None:
if not secret:
raise RuntimeError("SESSION_SECRET is not set; refusing to issue sessions")
self._s = URLSafeTimedSerializer(secret, salt="mdmem-session")
self.ttl_s = ttl_hours * 3600
def issue(self, principal: Principal) -> str:
return self._s.dumps(
{
"sub": principal.sub,
"name": principal.name,
"email": principal.email,
"group_ok": True,
"iat": int(time.time()),
}
)
def read(self, token: str | None) -> Principal | None:
if not token:
return None
try:
data = self._s.loads(token, max_age=self.ttl_s)
except (BadSignature, SignatureExpired):
return None
if not data.get("group_ok"):
return None
return Principal(sub=data.get("sub", ""), name=data.get("name", ""), email=data.get("email", ""))
class FlowCodec:
"""Signed, short-lived carrier for the PKCE verifier and CSRF state."""
def __init__(self, secret: str) -> None:
self._s = URLSafeTimedSerializer(secret, salt="mdmem-oidc-flow")
def issue(self, state: str, verifier: str, next_url: str) -> str:
return self._s.dumps({"state": state, "verifier": verifier, "next": next_url})
def read(self, token: str | None) -> dict[str, str] | None:
if not token:
return None
try:
return self._s.loads(token, max_age=FLOW_TTL_S)
except (BadSignature, SignatureExpired):
return None
class OidcClient:
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
self.settings = settings
self._client = client or httpx.AsyncClient(timeout=15.0)
self._meta: dict[str, Any] | None = None
self._jwks: dict[str, Any] | None = None
async def metadata(self) -> dict[str, Any]:
if self._meta is None:
url = f"{self.settings.oidc_issuer}/.well-known/openid-configuration"
r = await self._client.get(url)
r.raise_for_status()
self._meta = r.json()
return self._meta
async def jwks(self) -> dict[str, Any]:
if self._jwks is None:
meta = await self.metadata()
r = await self._client.get(meta["jwks_uri"])
r.raise_for_status()
self._jwks = r.json()
return self._jwks
async def authorize_url(self, state: str, challenge: str) -> str:
meta = await self.metadata()
from urllib.parse import urlencode
params = {
"response_type": "code",
"client_id": self.settings.oidc_client_id,
"redirect_uri": self.redirect_uri,
"scope": self.settings.oidc_scope,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
return f"{meta['authorization_endpoint']}?{urlencode(params)}"
@property
def redirect_uri(self) -> str:
return f"{self.settings.ui_base_url}/ui/auth/callback"
async def exchange(self, code: str, verifier: str) -> dict[str, Any]:
meta = await self.metadata()
r = await self._client.post(
meta["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"client_id": self.settings.oidc_client_id,
"client_secret": self.settings.oidc_client_secret,
"code_verifier": verifier,
},
headers={"content-type": "application/x-www-form-urlencoded"},
)
if r.status_code >= 400:
raise AuthError(f"token exchange failed: HTTP {r.status_code}", status=502)
return r.json()
async def claims(self, token_response: dict[str, Any]) -> dict[str, Any]:
from authlib.jose import JsonWebKey, JsonWebToken
id_token = token_response.get("id_token")
if not id_token:
raise AuthError("the token response carried no id_token", status=502)
keys = JsonWebKey.import_key_set(await self.jwks())
jwt = JsonWebToken(["RS256", "ES256"])
claims = jwt.decode(id_token, keys)
claims.validate(leeway=60)
data = dict(claims)
if data.get("iss", "").rstrip("/") != self.settings.oidc_issuer.rstrip("/"):
raise AuthError("id_token issuer mismatch")
aud = data.get("aud")
auds = aud if isinstance(aud, list) else [aud]
if self.settings.oidc_client_id not in auds:
raise AuthError("id_token audience mismatch")
return data
async def aclose(self) -> None:
await self._client.aclose()
+38
View File
@@ -0,0 +1,38 @@
"""Markdown rendering with raw HTML disabled — no stored XSS from note text (§10)."""
from __future__ import annotations
import html
import re
from markdown_it import MarkdownIt
_WIKILINK_RE = re.compile(r"\[\[([^\[\]|#]+?)(?:#([^\[\]|]*))?(?:\|([^\[\]]*))?\]\]")
# linkify is deliberately off: it needs linkify-it-py and would turn bare text in
# untrusted notes into clickable links.
_md = MarkdownIt("commonmark", {"html": False, "linkify": False, "typographer": False})
_md.enable("table")
_md.enable("strikethrough")
def _wikilinks_to_markdown(text: str, resolve: dict[str, str] | None = None) -> str:
resolve = resolve or {}
def sub(m: re.Match[str]) -> str:
target = m.group(1).strip()
label = (m.group(3) or target).strip()
note_id = resolve.get(target)
href = f"/ui/n/{note_id}" if note_id else f"/ui/search?q={html.escape(target)}"
safe_label = label.replace("]", "\\]").replace("[", "\\[")
return f"[{safe_label}]({href})"
return _WIKILINK_RE.sub(sub, text)
def render_markdown(text: str, resolve: dict[str, str] | None = None) -> str:
return _md.render(_wikilinks_to_markdown(text or "", resolve))
def render_inline(text: str) -> str:
return _md.renderInline(html.escape(text or ""))
+1
View File
@@ -0,0 +1 @@
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toStrinLine truncated
+72
View File
@@ -0,0 +1,72 @@
:root {
--bg: #fbfaf7; --fg: #1d1c1a; --muted: #6b675f; --line: #e2ded5;
--card: #ffffff; --accent: #2f5d50; --warn: #8a5a00; --danger: #8c2f2f;
--add: #1f6f43; --del: #8c2f2f;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16171a; --fg: #e8e6e1; --muted: #9b968c; --line: #2c2e33;
--card: #1d1f23; --accent: #7fbfab; --warn: #d0a24c; --danger: #e08585;
--add: #7fc7a0; --del: #e08585;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.55 -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
}
header {
display: flex; justify-content: space-between; align-items: center; gap: 1rem;
padding: .6rem 1.2rem; border-bottom: 1px solid var(--line); position: sticky; top: 0;
background: var(--bg); z-index: 5;
}
nav { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
nav a { color: var(--fg); text-decoration: none; }
nav a:hover { text-decoration: underline; }
.brand { font-weight: 700; letter-spacing: .02em; color: var(--accent); }
main { max-width: 62rem; margin: 0 auto; padding: 1.5rem 1.2rem 5rem; }
h1 { font-size: 1.5rem; } h2 { font-size: 1.15rem; }
h3 { font-size: .95rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
h1 small { color: var(--muted); font-weight: 400; font-size: .8rem; margin-left: .5rem; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.1rem; margin: 0 0 1.2rem; }
.meta, .hint { color: var(--muted); font-size: .82rem; }
.summary { font-style: italic; color: var(--muted); }
.snippet { white-space: pre-wrap; }
.pill { font-size: .72rem; padding: .1rem .45rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); vertical-align: middle; }
.pill.ok { color: var(--add); border-color: var(--add); }
.pill.warn { color: var(--warn); border-color: var(--warn); }
.badge { background: var(--line); border-radius: 999px; padding: 0 .4rem; font-size: .75rem; }
.tag { display: inline-block; font-size: .78rem; background: var(--line); border-radius: 4px; padding: .05rem .4rem; margin: 0 .25rem .25rem 0; color: var(--fg); text-decoration: none; }
.unresolved { color: var(--warn); text-decoration: underline dotted; }
.rel { color: var(--muted); font-size: .75rem; }
.empty { color: var(--muted); }
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; }
.cols ul, .history { list-style: none; padding: 0; margin: .2rem 0; }
form { display: inline; }
.filters, .editor { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-bottom: 1rem; }
.editor { flex-direction: column; align-items: stretch; }
.editor label { display: flex; flex-direction: column; gap: .3rem; font-size: .85rem; color: var(--muted); }
input, select, textarea, button, .button {
font: inherit; padding: .4rem .6rem; border-radius: 6px; border: 1px solid var(--line);
background: var(--card); color: var(--fg);
}
textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88rem; }
button, .button { cursor: pointer; text-decoration: none; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger, summary.danger { color: var(--danger); }
.actions { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-top: 1rem; }
.actions details { width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; display: block; overflow-x: auto; }
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--line); }
th a { color: var(--muted); text-decoration: none; }
tr.superseded td { opacity: .55; text-decoration: line-through; }
pre.diff { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .6rem; overflow-x: auto; font-size: .82rem; }
pre.diff .add { color: var(--add); } pre.diff .del { color: var(--del); } pre.diff .hunk { color: var(--muted); }
.body { overflow-wrap: anywhere; }
.body pre, .body code { background: var(--line); border-radius: 4px; padding: .05rem .25rem; }
.body pre { padding: .6rem; overflow-x: auto; }
.body.small { font-size: .85rem; }
.dupes { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .8rem; }
.dupe { border: 1px dashed var(--line); border-radius: 8px; padding: .6rem; }
.conflict { border: 1px solid var(--warn); border-radius: 8px; padding: .8rem; margin-bottom: 1rem; }
.cloud { line-height: 2; }
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}mdmem{% endblock %}</title>
<link rel="stylesheet" href="/ui/static/style.css">
<script src="/ui/static/htmx.min.js" defer></script>
</head>
<body>
<header>
<nav>
<a class="brand" href="/ui/inbox">mdmem</a>
<a href="/ui/inbox">Posteingang <span class="badge">{{ counts.get('inbox', 0) }}</span></a>
<a href="/ui/search">Suche</a>
<a href="/ui/browse">Alle <span class="badge">{{ counts.get('active', 0) }}</span></a>
<a href="/ui/health">Health</a>
</nav>
<div class="who">
{% if user %}<span>{{ user.name }}</span> <a href="/ui/auth/logout">abmelden</a>{% endif %}
</div>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Alle Notizen — mdmem{% endblock %}
{% block content %}
<h1>Alle Notizen <small>{{ rows|length }}</small></h1>
<p class="cloud">{% for t, n in cloud %}<a class="tag" href="/ui/browse?tag={{ t }}">#{{ t }} <span class="badge">{{ n }}</span></a>{% endfor %}</p>
<form method="get" class="filters">
<select name="state">
<option value="">alle Zustände</option>
{% for s in ['inbox','active','archive'] %}<option value="{{ s }}" {% if state == s %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag">
<button type="submit">Filtern</button>
</form>
<table>
<thead><tr>
<th><a href="/ui/browse?order=title&state={{ state }}&tag={{ tag }}">Titel</a></th>
<th><a href="/ui/browse?order=type&state={{ state }}&tag={{ tag }}">Typ</a></th>
<th><a href="/ui/browse?order=state&state={{ state }}&tag={{ tag }}">Zustand</a></th>
<th>Tags</th>
<th><a href="/ui/browse?order=updated&state={{ state }}&tag={{ tag }}">Aktualisiert</a></th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr class="{{ 'superseded' if r.superseded_by else '' }}">
<td><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>{% if r.revises %} <span class="pill warn">Revision</span>{% endif %}</td>
<td>{{ r.type }}</td>
<td>{{ r.state }}</td>
<td>{% for t in r.tags %}<span class="tag">#{{ t }}</span>{% endfor %}</td>
<td>{{ r.updated }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}{{ note.title }} bearbeiten — mdmem{% endblock %}
{% block content %}
<h1>{{ note.title }} bearbeiten</h1>
{% if conflict %}
<div class="conflict">
<h2>Konflikt</h2>
<p>{{ conflict.message }}</p>
{% set diff = conflict.diff %}
{% include "partial_diff.html" with context %}
<p>Speichere erneut, um deine Fassung über die aktuelle zu legen — das Formular trägt jetzt die neue Version.</p>
</div>
{% endif %}
<form method="post" action="/ui/n/{{ note.id }}/edit" class="editor">
<input type="hidden" name="expected_version" value="{{ version }}">
<label>Frontmatter (YAML)
<textarea name="frontmatter" rows="10" spellcheck="false">{{ frontmatter }}</textarea>
</label>
<label>Text
<textarea name="body" rows="18" spellcheck="false"
hx-post="/ui/preview" hx-trigger="keyup changed delay:400ms"
hx-target="#preview">{% if conflict %}{{ conflict.yours }}{% else %}{{ note.body }}{% endif %}</textarea>
</label>
<button class="primary" type="submit">Speichern</button>
<a class="button" href="/ui/n/{{ note.id }}">Abbrechen</a>
</form>
<h3>Vorschau</h3>
<div id="preview" class="body">{{ render_markdown(note.body, {}) | safe }}</div>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Fehler — mdmem{% endblock %}
{% block content %}
<h1>{{ error.error }}</h1>
<p>{{ error.message }}</p>
{% if error.details %}<pre>{{ error.details }}</pre>{% endif %}
<p><a href="/ui/inbox">zurück zum Posteingang</a></p>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Health — mdmem{% endblock %}
{% block content %}
<h1>Health</h1>
<div class="cols">
<section>
<h3>Bestand</h3>
<ul>
{% for s, n in health.counts.items() %}<li>{{ s }}: {{ n }}</li>{% endfor %}
<li>Chunks: {{ health.chunks }}</li>
<li>ohne Vektor: {{ health.vec_pending }}</li>
</ul>
</section>
<section>
<h3>Embeddings</h3>
<ul>
<li>Modell: {{ health.embed_model }}</li>
<li>Dimension: {{ health.embed_dim }}</li>
<li>Status: <span class="pill {{ 'ok' if health.embed_ok else 'warn' }}">{{ 'ok' if health.embed_ok else 'nur lexikalisch' }}</span></li>
<li>letzte Latenz: {{ health.embed_last_ms or '–' }} ms</li>
<li>Fehler: {{ health.embed_errors }}</li>
</ul>
</section>
<section>
<h3>Verlinkung</h3>
<ul>
<li>unaufgelöste Links: {{ health.unresolved_links }}</li>
<li>verwaiste Notizen: {{ health.orphans }}</li>
</ul>
</section>
</div>
{% if health.secrets_in_vault %}
<div class="conflict">
<h3>Mögliche Zugangsdaten im Vault</h3>
<ul>{% for f in health.secrets_in_vault %}<li>{{ f }}</li>{% endfor %}</ul>
</div>
{% endif %}
<div class="actions">
<form method="post" action="/ui/health/reindex"><button type="submit">Index auffrischen</button></form>
<form method="post" action="/ui/health/reindex"><input type="hidden" name="full" value="1"><button type="submit">Index neu bauen</button></form>
<form method="post" action="/ui/health/reembed"><button type="submit">Alles neu einbetten</button></form>
</div>
<h3>Unaufgelöste Links</h3>
<ul>{% for u in unresolved %}<li><a href="/ui/n/{{ u.src }}">{{ u.title }}</a> → <span class="unresolved">{{ u.target }}</span> ({{ u.rel }})</li>{% else %}<li class="empty">keine</li>{% endfor %}</ul>
<h3>Letzte MCP-Aufrufe</h3>
<table>
<thead><tr><th>Zeit</th><th>Tool</th><th>ok</th><th>ms</th><th>ids</th></tr></thead>
<tbody>
{% for c in calls %}
<tr><td>{{ c.ts }}</td><td>{{ c.tool }}</td><td>{{ c.get('ok', '') }}</td><td>{{ c.get('ms', '') }}</td>
<td>{% for i in c.get('ids', []) %}<a href="/ui/n/{{ i }}">{{ i[:8] }}</a> {% endfor %}</td></tr>
{% else %}<tr><td colspan="5" class="empty">keine</td></tr>{% endfor %}
</tbody>
</table>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Posteingang — mdmem{% endblock %}
{% block content %}
<h1>Posteingang <small>{{ items|length }} unbestätigt</small></h1>
{% if not items %}<p class="empty">Nichts zu prüfen.</p>{% endif %}
{% for item in items %}
{% set n = item.note %}
<article class="card" id="n-{{ n.id }}">
<h2><a href="/ui/n/{{ n.id }}">{{ n.title }}</a>
<span class="pill">{{ n.type }}</span>
{% if item.target %}<span class="pill warn">Revision von „{{ item.target.title }}“</span>{% endif %}
{% if n.supersede_chain %}<span class="pill warn">ersetzt {{ n.supersede_chain|length }}</span>{% endif %}
</h2>
<p class="meta">
beobachtet {{ n.observed_at or '–' }} ·
Quelle {{ n.source.get('harness', 'unbekannt') if n.source else 'unbekannt' }}
<span class="hint" title="selbstberichtet, nicht verifiziert">(selbstberichtet)</span>
</p>
{% if n.summary %}<p class="summary">{{ n.summary }}</p>{% endif %}
<div class="body">{{ render_markdown(n.body, {}) | safe }}</div>
{% if n.keywords %}<p class="kw">{% for k in n.keywords %}<span class="tag">{{ k }}</span>{% endfor %}</p>{% endif %}
{% if item.diff %}
<h3>Änderung gegenüber der aktiven Notiz</h3>
{% set diff = item.diff %}
{% include "partial_diff.html" with context %}
{% endif %}
{% if item.duplicates %}
<h3>Mögliche Duplikate</h3>
<div class="dupes">
{% for d in item.duplicates %}
<div class="dupe">
<a href="/ui/n/{{ d.id }}">{{ d.title }}</a>
<div class="body small">{{ render_markdown(d.body, {}) | safe }}</div>
<form method="post" action="/ui/n/{{ n.id }}/merge">
<input type="hidden" name="target" value="{{ d.id }}">
<button type="submit">in diese Notiz zusammenführen</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
<div class="actions">
<form method="post" action="/ui/n/{{ n.id }}/approve">
<button class="primary" type="submit">Übernehmen</button>
</form>
<details>
<summary>Bearbeiten &amp; übernehmen</summary>
<form method="post" action="/ui/n/{{ n.id }}/approve">
<textarea name="body" rows="8">{{ n.body }}</textarea>
<button class="primary" type="submit">Bearbeitet übernehmen</button>
</form>
</details>
<form method="post" action="/ui/n/{{ n.id }}/reject">
<input type="text" name="reason" placeholder="Grund (optional)">
<button class="danger" type="submit">Ablehnen</button>
</form>
</div>
</article>
{% endfor %}
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}{{ note.title }} — mdmem{% endblock %}
{% block content %}
<article class="card">
<h1>{{ note.title }}
<span class="pill">{{ note.type }}</span>
<span class="pill {% if note.state == 'active' %}ok{% elif note.state == 'inbox' %}warn{% endif %}">{{ note.state }}</span>
</h1>
<p class="meta">id {{ note.id }} · Version {{ note.version[:12] }} · aktualisiert {{ note.updated }}</p>
{% if note.summary %}<p class="summary">{{ note.summary }}</p>{% endif %}
<div class="body">{{ render_markdown(note.body, resolve) | safe }}</div>
{% if note.keywords %}<p class="kw">{% for k in note.keywords %}<span class="tag">{{ k }}</span>{% endfor %}</p>{% endif %}
{% if note.tags %}<p class="kw">{% for t in note.tags %}<a class="tag" href="/ui/browse?tag={{ t }}">#{{ t }}</a>{% endfor %}</p>{% endif %}
<div class="cols">
<section>
<h3>Ausgehende Links</h3>
<ul>{% for l in note.links %}
<li>{% if l.resolved %}<a href="/ui/n/{{ l.id }}">{{ l.title }}</a>{% else %}<span class="unresolved">{{ l.title }}</span>{% endif %} <span class="rel">{{ l.rel }}</span></li>
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
</section>
<section>
<h3>Backlinks</h3>
<ul>{% for l in note.backlinks %}
<li><a href="/ui/n/{{ l.id }}">{{ l.title }}</a> <span class="rel">{{ l.rel }}</span></li>
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
</section>
<section>
<h3>Ersetzungskette</h3>
<ul>{% for l in note.supersede_chain %}
<li><a href="/ui/n/{{ l.id }}">{{ l.title }}</a> <span class="rel">{{ l.rel }}</span></li>
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
</section>
</div>
{% if note.flags %}
<h3>Meldungen</h3>
<ul>{% for f in note.flags %}<li>{{ f.created }} · {{ f.source }} · {{ f.reason }}</li>{% endfor %}</ul>
{% endif %}
<h3>Verlauf</h3>
<ul class="history">
{% for h in history|reverse %}
<li><button hx-get="/ui/n/{{ note.id }}/history/{{ h }}" hx-target="#diff-{{ loop.index }}">{{ h }}</button>
<div id="diff-{{ loop.index }}"></div></li>
{% else %}<li class="empty">keine früheren Fassungen</li>{% endfor %}
</ul>
<div class="actions">
<a class="button" href="/ui/n/{{ note.id }}/edit">Bearbeiten</a>
{% if note.state != 'archive' %}
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="archive"><button type="submit">Archivieren</button></form>
{% else %}
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="active"><button type="submit">Wiederherstellen</button></form>
{% endif %}
<details>
<summary class="danger">Endgültig löschen</summary>
<form method="post" action="/ui/n/{{ note.id }}/delete">
<p>Datei, Verlauf und Index werden entfernt. Zum Bestätigen die id eingeben:</p>
<input type="text" name="confirm" placeholder="{{ note.id }}">
<button class="danger" type="submit">Löschen</button>
</form>
</details>
</div>
</article>
{% endblock %}
+2
View File
@@ -0,0 +1,2 @@
<pre class="diff">{% for line in diff.split('\n') %}<span class="{% if line.startswith('+') and not line.startswith('+++') %}add{% elif line.startswith('-') and not line.startswith('---') %}del{% elif line.startswith('@@') %}hunk{% endif %}">{{ line }}</span>
{% endfor %}</pre>
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Suche — mdmem{% endblock %}
{% block content %}
<h1>Suche</h1>
<form method="get" action="/ui/search" class="filters">
<input type="search" name="q" value="{{ q }}" placeholder="Suchbegriff" autofocus>
<select name="type">
<option value="">alle Typen</option>
{% for t in ['fact','preference','decision','procedure','entity','event','note'] %}
<option value="{{ t }}" {% if type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag">
<select name="state">
<option value="">inbox + aktiv</option>
<option value="active" {% if state == 'active' %}selected{% endif %}>nur aktiv</option>
<option value="archive" {% if state == 'archive' %}selected{% endif %}>inkl. Archiv</option>
</select>
<input type="text" name="since" value="{{ since }}" placeholder="seit (ISO)">
<input type="text" name="until" value="{{ until }}" placeholder="bis (ISO)">
<label><input type="checkbox" name="superseded" value="1" {% if superseded %}checked{% endif %}> ersetzte zeigen</label>
<button type="submit">Suchen</button>
</form>
{% if resp is not none %}
<p class="meta">{{ resp.results|length }} Treffer · Fusion {{ resp.fusion }}{% if resp.truncated %} · gekürzt{% endif %}</p>
{% for r in resp.results %}
<article class="card">
<h2><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>
<span class="pill">{{ r.type }}</span>
<span class="pill {% if r.verified %}ok{% else %}warn{% endif %}">{{ 'geprüft' if r.verified else 'ungeprüft' }}</span>
</h2>
<p class="snippet">{{ r.snippet }}</p>
<p class="meta" title="lex_rank/sem_rank/cosine/fused">
score {{ '%.4f'|format(r.score) }}
{% if r.debug %}
· via {{ r.debug.via }}
· lex {{ r.debug.lex_rank or '–' }} (bm25 {{ r.debug.bm25 or '–' }})
· sem {{ r.debug.sem_rank or '–' }} (cos {{ r.debug.cos or '–' }})
· fused {{ '%.6f'|format(r.debug.fused) }}
{% endif %}
· {{ r.updated or '' }}
</p>
</article>
{% endfor %}
{% if not resp.results %}<p class="empty">Keine Treffer.</p>{% endif %}
{% endif %}
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
from mdmem.ui.render import render_markdown
def test_raw_html_is_escaped_not_rendered():
out = render_markdown("<script>alert(1)</script> und <img src=x onerror=y>")
assert "<script>" not in out and "&lt;script&gt;" in out
assert "onerror" not in out or "&lt;img" in out
def test_html_block_is_escaped():
out = render_markdown("<div onclick='x'>hi</div>")
assert "<div" not in out
def test_markdown_still_works():
out = render_markdown("# Titel\n\n**fett** und `code`")
assert "<h1>" in out and "<strong>fett</strong>" in out and "<code>code</code>" in out
def test_wikilink_resolves_to_a_note_page():
out = render_markdown("siehe [[evo-x2]]", {"evo-x2": "01JABC"})
assert '<a href="/ui/n/01JABC">evo-x2</a>' in out
def test_unresolved_wikilink_points_at_search():
out = render_markdown("siehe [[nirgendwo]]")
assert "/ui/search?q=nirgendwo" in out
def test_wikilink_alias_and_injection_attempt():
out = render_markdown("[[target|<b>label</b>]]", {"target": "01J"})
assert "<b>label</b>" not in out
+205
View File
@@ -0,0 +1,205 @@
"""The group check must fail closed — the degradation paths are the risk (§10, §14.21, P4)."""
from dataclasses import replace
import httpx
import pytest
from mdmem.config import Settings
from mdmem.embed import FakeEmbedder
from mdmem.service import MemoryService
from mdmem.ui.app import build_ui_app
from mdmem.ui.auth import SESSION_COOKIE, AuthError, Principal, SessionCodec, authorize_claims
@pytest.fixture()
def auth_settings(settings) -> Settings:
return replace(
settings,
session_secret="x" * 32,
oidc_issuer="https://pocketid.example",
oidc_client_id="client-uuid",
oidc_groups_claim="groups",
oidc_required_group="mdmem_admins",
ui_base_url="https://memory.cloud.traunseenet.com",
ui_allowed_origins=("https://memory.cloud.traunseenet.com",),
)
def test_member_of_the_required_group_is_admitted(auth_settings):
p = authorize_claims({"sub": "u1", "name": "Markus", "groups": ["other", "mdmem_admins"]}, auth_settings)
assert isinstance(p, Principal) and p.sub == "u1"
def test_group_absent_is_rejected(auth_settings):
with pytest.raises(AuthError, match="mdmem_admins"):
authorize_claims({"sub": "u1", "groups": ["something_else"]}, auth_settings)
def test_claim_absent_is_rejected(auth_settings):
"""A missing groups scope must not degrade to authenticated-only."""
with pytest.raises(AuthError, match="absent"):
authorize_claims({"sub": "u1", "email": "a@b.c"}, auth_settings)
def test_claim_of_the_wrong_type_is_rejected(auth_settings):
with pytest.raises(AuthError, match="list of strings"):
authorize_claims({"sub": "u1", "groups": {"a": 1}}, auth_settings)
with pytest.raises(AuthError, match="list of strings"):
authorize_claims({"sub": "u1", "groups": [1, 2]}, auth_settings)
def test_a_single_string_claim_is_accepted_as_one_group(auth_settings):
assert authorize_claims({"sub": "u1", "groups": "mdmem_admins"}, auth_settings).sub == "u1"
def test_unconfigured_required_group_refuses_everything(auth_settings):
s = replace(auth_settings, oidc_required_group="")
with pytest.raises(AuthError, match="OIDC_REQUIRED_GROUP"):
authorize_claims({"sub": "u1", "groups": ["anything"]}, s)
def test_rejection_logs_the_sub_and_claim_names_but_not_the_values(auth_settings, caplog):
with caplog.at_level("WARNING"):
with pytest.raises(AuthError):
authorize_claims({"sub": "u1", "groups": ["secret_group_name"]}, auth_settings)
record = caplog.records[-1]
assert record.fields["sub"] == "u1"
assert "secret_group_name" not in str(record.fields)
def test_session_cookie_round_trip_and_expiry(auth_settings):
codec = SessionCodec(auth_settings.session_secret, auth_settings.session_ttl_hours)
token = codec.issue(Principal(sub="u1", name="M", email="m@x"))
assert codec.read(token).sub == "u1"
assert codec.read("garbage") is None
assert codec.read(None) is None
def test_a_session_signed_with_another_secret_is_refused(auth_settings):
other = SessionCodec("y" * 32, 12).issue(Principal(sub="u1", name="M", email=""))
assert SessionCodec(auth_settings.session_secret, 12).read(other) is None
def test_session_secret_is_mandatory():
with pytest.raises(RuntimeError, match="SESSION_SECRET"):
SessionCodec("", 12)
# -- routing ---------------------------------------------------------------
@pytest.fixture()
async def app_and_svc(auth_settings):
svc = MemoryService.build(auth_settings, embedder=FakeEmbedder(auth_settings.embed_dim))
await svc.start(watch=False)
yield build_ui_app(svc), svc
await svc.stop()
def _client(app):
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="https://memory.cloud.traunseenet.com"
)
@pytest.mark.parametrize("path", ["/ui/inbox", "/ui/search", "/ui/browse", "/ui/health", "/ui/n/x"])
async def test_every_ui_path_redirects_an_anonymous_visitor_to_login(app_and_svc, path):
app, _ = app_and_svc
async with _client(app) as c:
r = await c.get(path)
assert r.status_code == 302 and r.headers["location"].startswith("/ui/auth/login")
async def test_a_valid_session_gets_in(app_and_svc):
app, svc = app_and_svc
codec = SessionCodec(svc.settings.session_secret, 12)
cookie = codec.issue(Principal(sub="u1", name="Markus", email=""))
async with _client(app) as c:
r = await c.get("/ui/inbox", cookies={SESSION_COOKIE: cookie})
assert r.status_code == 200 and "Posteingang" in r.text
async def test_probes_and_metrics_stay_open(app_and_svc):
app, _ = app_and_svc
async with _client(app) as c:
for path in ("/healthz", "/readyz", "/metrics"):
assert (await c.get(path)).status_code == 200
async def test_metrics_carry_no_note_content(app_and_svc):
app, svc = app_and_svc
await svc.write_note(title="Geheimer Titel", body="sensibler Text", type="fact")
async with _client(app) as c:
body = (await c.get("/metrics")).text
assert "Geheimer Titel" not in body and "sensibler Text" not in body
assert "mdmem_notes" in body
async def test_readyz_is_green_while_embeddings_are_down(app_and_svc):
app, svc = app_and_svc
svc.embed_ok = False
async with _client(app) as c:
r = await c.get("/readyz")
assert r.status_code == 200
assert r.json()["checks"]["embeddings"].startswith("degraded")
async def test_security_headers_are_set(app_and_svc):
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
async with _client(app) as c:
r = await c.get("/ui/inbox", cookies={SESSION_COOKIE: cookie})
assert r.headers["content-security-policy"].startswith("default-src 'self'")
assert r.headers["x-frame-options"] == "DENY"
async def test_post_from_a_foreign_origin_is_refused(app_and_svc):
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
res = await svc.write_note(title="T", body="b", type="note")
async with _client(app) as c:
r = await c.post(
f"/ui/n/{res['id']}/approve",
headers={"origin": "https://evil.example.com"},
cookies={SESSION_COOKIE: cookie},
data={},
)
assert r.status_code == 403
assert svc.get_note(res["id"]).state == "inbox"
async def test_post_from_the_configured_origin_is_allowed(app_and_svc):
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
res = await svc.write_note(title="T", body="b", type="note")
async with _client(app) as c:
r = await c.post(
f"/ui/n/{res['id']}/approve",
headers={"origin": "https://memory.cloud.traunseenet.com"},
cookies={SESSION_COOKIE: cookie},
data={},
)
assert r.status_code in (303, 302)
assert svc.get_note(res["id"]).state == "active"
async def test_ui_refuses_to_start_without_a_session_secret(settings):
svc = MemoryService.build(replace(settings, session_secret=""), embedder=FakeEmbedder(settings.embed_dim))
try:
with pytest.raises(RuntimeError, match="SESSION_SECRET"):
build_ui_app(svc)
finally:
await svc.stop()
async def test_auth_disabled_is_loudly_logged_and_lets_requests_through(settings, caplog):
s = replace(settings, ui_auth_disabled=True, session_secret="")
svc = MemoryService.build(s, embedder=FakeEmbedder(s.embed_dim))
await svc.start(watch=False)
try:
with caplog.at_level("ERROR"):
app = build_ui_app(svc)
assert any("UI_AUTH_DISABLED" in r.message for r in caplog.records)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as c:
assert (await c.get("/ui/inbox")).status_code == 200
finally:
await svc.stop()
+247
View File
@@ -0,0 +1,247 @@
"""End-to-end review flows through the UI (§10, P4)."""
from dataclasses import replace
import httpx
import pytest
from mdmem.embed import FakeEmbedder
from mdmem.service import MemoryService
from mdmem.ui.app import build_ui_app
from mdmem.ui.auth import SESSION_COOKIE, Principal, SessionCodec
@pytest.fixture()
async def ui(settings):
s = replace(
settings,
session_secret="x" * 32,
ui_base_url="https://memory.test",
ui_allowed_origins=("https://memory.test",),
)
svc = MemoryService.build(s, embedder=FakeEmbedder(s.embed_dim))
await svc.start(watch=False)
app = build_ui_app(svc)
cookie = SessionCodec(s.session_secret, 12).issue(Principal("u1", "Markus", ""))
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="https://memory.test",
cookies={SESSION_COOKIE: cookie},
follow_redirects=False,
)
yield client, svc
await client.aclose()
await svc.stop()
async def test_inbox_lists_unreviewed_notes(ui):
client, svc = ui
await svc.write_note(title="Neue Erkenntnis", body="Inhalt", type="fact", summary="kurz")
r = await client.get("/ui/inbox")
assert r.status_code == 200
assert "Neue Erkenntnis" in r.text and "kurz" in r.text
async def test_approve_moves_the_note_to_active(ui):
client, svc = ui
res = await svc.write_note(title="Zu prüfen", body="Inhalt", type="fact")
r = await client.post(f"/ui/n/{res['id']}/approve", data={})
assert r.status_code == 303
assert svc.get_note(res["id"]).state == "active"
assert (await svc.search("Inhalt")).results[0].verified is True
async def test_edit_and_approve_saves_the_edited_body(ui):
client, svc = ui
res = await svc.write_note(title="Roh", body="alter Text", type="fact")
await client.post(f"/ui/n/{res['id']}/approve", data={"body": "geprüfter Text"})
note = svc.get_note(res["id"])
assert note.state == "active" and note.body.strip() == "geprüfter Text"
async def test_reject_archives_with_a_reason(ui):
client, svc = ui
res = await svc.write_note(title="Unsinn", body="nope", type="note")
await client.post(f"/ui/n/{res['id']}/reject", data={"reason": "falsch"})
note = svc.get_note(res["id"])
assert note.state == "archive"
assert note.fm["rejected"] is True and note.fm["rejected_reason"] == "falsch"
assert (await svc.search("nope")).results == []
async def test_revision_shows_a_diff_and_merges_into_the_target(ui):
client, svc = ui
base = await svc.write_note(title="Fakt", body="Der Wert ist 10", type="fact")
await svc.approve(base["id"])
version = svc.get_note(base["id"]).file_hash
proposal = await svc.update_note(base["id"], version, body="Der Wert ist 12")
page = await client.get("/ui/inbox")
assert "Revision von" in page.text
assert "-Der Wert ist 10" in page.text and "+Der Wert ist 12" in page.text
r = await client.post(f"/ui/n/{proposal['id']}/approve", data={})
assert r.status_code == 303
assert svc.get_note(base["id"]).body.strip() == "Der Wert ist 12"
assert svc.get_note(proposal["id"]).state == "archive"
async def test_duplicates_are_offered_for_merging(ui):
client, svc = ui
first = await svc.write_note(
title="Bifrost ist das Gateway", body="Bifrost ist das Gateway für Modelle", type="fact"
)
await svc.approve(first["id"])
second = await svc.write_note(
title="Bifrost ist das Gateway", body="Bifrost ist das Gateway für Modelle", type="fact"
)
assert second["possible_duplicates"], "an identical note should be flagged as a duplicate"
page = await client.get("/ui/inbox")
assert "Mögliche Duplikate" in page.text
await client.post(f"/ui/n/{second['id']}/merge", data={"target": first["id"]})
assert svc.get_note(second["id"]).state == "archive"
assert svc.get_note(first["id"]).body.count("Bifrost ist das Gateway") == 2
async def test_note_page_shows_links_backlinks_and_history(ui):
client, svc = ui
target = await svc.write_note(title="evo-x2", body="die Kiste", type="entity")
src = await svc.write_note(title="llama-swap", body="läuft auf [[evo-x2]]", type="fact")
await svc.edit(
src["id"], body="läuft auf [[evo-x2]] (v2)", expected_version=svc.get_note(src["id"]).file_hash
)
page = await client.get(f"/ui/n/{src['id']}")
assert "evo-x2" in page.text and "Backlinks" in page.text
assert "Verlauf" in page.text and "keine früheren Fassungen" not in page.text
back = await client.get(f"/ui/n/{target['id']}")
assert "llama-swap" in back.text
async def test_edit_conflict_shows_a_diff_and_keeps_both_versions(ui):
client, svc = ui
res = await svc.write_note(title="Konflikt", body="original", type="note")
stale = svc.get_note(res["id"]).file_hash
await svc.edit(res["id"], body="von woanders geändert", expected_version=stale)
r = await client.post(
f"/ui/n/{res['id']}/edit",
data={"body": "meine Fassung", "frontmatter": "", "expected_version": stale},
)
assert r.status_code == 409
assert "Konflikt" in r.text
assert "meine Fassung" in r.text and "von woanders geändert" in r.text
assert svc.get_note(res["id"]).body.strip() == "von woanders geändert"
async def test_edit_saves_body_and_frontmatter(ui):
client, svc = ui
res = await svc.write_note(title="Alt", body="alt", type="note", keywords=["a"])
version = svc.get_note(res["id"]).file_hash
page = await client.get(f"/ui/n/{res['id']}/edit")
assert page.status_code == 200 and "Frontmatter" in page.text
r = await client.post(
f"/ui/n/{res['id']}/edit",
data={
"body": "neuer Text",
"frontmatter": f"id: {res['id']}\ntitle: Neu\ntype: note\nkeywords: [a, b]\n",
"expected_version": version,
},
)
assert r.status_code == 303
note = svc.get_note(res["id"])
assert note.title == "Neu" and note.keywords == ["a", "b"] and note.body.strip() == "neuer Text"
async def test_invalid_frontmatter_is_rejected_with_400(ui):
client, svc = ui
res = await svc.write_note(title="T", body="b", type="note")
version = svc.get_note(res["id"]).file_hash
r = await client.post(
f"/ui/n/{res['id']}/edit",
data={"body": "x", "frontmatter": "title: [unclosed", "expected_version": version},
)
assert r.status_code == 400
async def test_live_preview_renders_markdown_without_html(ui):
client, _ = ui
r = await client.post("/ui/preview", data={"body": "**fett** <script>x</script>"})
assert "<strong>fett</strong>" in r.text and "<script>" not in r.text
async def test_archive_and_restore(ui):
client, svc = ui
res = await svc.write_note(title="Hin und her", body="x", type="note")
await svc.approve(res["id"])
await client.post(f"/ui/n/{res['id']}/state", data={"state": "archive"})
assert svc.get_note(res["id"]).state == "archive"
await client.post(f"/ui/n/{res['id']}/state", data={"state": "active"})
assert svc.get_note(res["id"]).state == "active"
async def test_hard_delete_requires_the_id_and_removes_everything(ui):
client, svc = ui
res = await svc.write_note(title="Weg", body="v1", type="note")
await svc.edit(res["id"], body="v2", expected_version=svc.get_note(res["id"]).file_hash)
bad = await client.post(f"/ui/n/{res['id']}/delete", data={"confirm": "nope"})
assert bad.status_code == 400 and svc.vault.find(res["id"]) is not None
ok = await client.post(f"/ui/n/{res['id']}/delete", data={"confirm": res["id"]})
assert ok.status_code == 303
assert svc.vault.find(res["id"]) is None
assert svc.vault.history(res["id"]) == []
assert svc.db.read().execute("SELECT count(*) FROM notes").fetchone()[0] == 0
async def test_browse_filters_by_tag_and_shows_the_cloud(ui):
client, svc = ui
a = await svc.write_note(title="Mit Tag", body="x", type="note", tags=["homelab"])
await svc.write_note(title="Ohne Tag", body="y", type="note")
r = await client.get("/ui/browse?tag=homelab")
assert "Mit Tag" in r.text and "Ohne Tag" not in r.text
assert "#homelab" in r.text
assert a["id"] in r.text
async def test_search_page_shows_debug_scores(ui):
client, svc = ui
await svc.write_note(title="Suchbar", body="Bifrost Gateway", type="fact")
r = await client.get("/ui/search?q=Bifrost")
assert "Suchbar" in r.text and "via" in r.text and "fused" in r.text
async def test_health_page_reports_state_and_reindexes(ui):
client, svc = ui
await svc.write_note(title="Eins", body="x", type="note")
r = await client.get("/ui/health")
assert "Embeddings" in r.text and svc.settings.embed_model in r.text
r2 = await client.post("/ui/health/reindex", data={"full": "1"})
assert r2.status_code == 303
r3 = await client.post("/ui/health/reembed", data={})
assert r3.status_code == 303
assert svc.db.read().execute("SELECT count(*) FROM chunks WHERE vec_pending=1").fetchone()[0] == 0
async def test_note_body_cannot_inject_script_into_the_page(ui):
client, svc = ui
await svc.write_note(
title="Böse", body="<script>alert('xss')</script>\n\n<img src=x onerror=alert(1)>", type="note"
)
r = await client.get("/ui/inbox")
assert "<script>alert" not in r.text
assert "<img" not in r.text # escaped, so no live tag reaches the DOM
assert "&lt;img src=x onerror=alert(1)&gt;" in r.text
async def test_unknown_note_returns_404(ui):
client, _ = ui
r = await client.get("/ui/n/01JZZZZZZZZZZZZZZZZZZZZZZZ")
assert r.status_code == 404 and "NOT_FOUND" in r.text