Commit Graph

86 Commits

Author SHA1 Message Date
5796b7a5dd #214: OIDC Authorization Code with PKCE, Keycloak as reference IdP
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 14s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Has been skipped
External authentication (ADR 0021) built on jose (#188's vetted library)
plus fetch — no new dependency enters the supply chain for a security
base function. Discovery-configured; ID tokens validate against the
IdP's JWKS under an explicit RS256/ES256 allowlist with issuer,
audience, expiry and nonce binding. State, nonce and the PKCE verifier
travel in a signed HttpOnly Lax cookie keyed by a dedicated HKDF
purpose (oidc-state, ADR 0020).

Deploy-level configuration (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/SCOPES/
PROVIDER_LABEL): who authenticates users is a platform decision. The
login page discovers the provider via GET /auth/methods and renders the
SSO button (i18n de+en).

Identities use the existing slot (provider oidc:<issuer>, subject from
the token). First login creates the account just-in-time — ACTIVE and
mail-verified only when the IdP asserts a verified address. An existing
local account is NEVER adopted silently by e-mail (account-takeover
path): login refuses with oidc_link_required and the owner links
explicitly via GET /auth/oidc/link (audited auth.identity_linked,
catalogue v1.3). Sessions come from the one existing session service.

Tests run the full flow against a protocol-faithful fake IdP: PKCE
verifier at the token endpoint, JIT creation incl. personal pond,
invalid state/nonce/signature/issuer/audience/expiry each rejected, the
linking refusal and the explicit link flow. Verified end-to-end against
a real Keycloak 26.0 (repeatable procedure documented in security.md
§External authentication).

Refs #214.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:44:52 +02:00
868b79c8bc #213: warn on uploads to classified pages; instance policy can block
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m15s
CI / Build container images (pull_request) Successful in 4m27s
CI / Auth e2e pack (pull_request) Successful in 9m10s
CI / Import/export fidelity gate (pull_request) Successful in 53s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 20s
CI / Lint, typecheck, test (push) Successful in 5m47s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m26s
CI / Import/export fidelity gate (push) Successful in 1m0s
The attachments panel of a classified page shows a persistent notice
naming the consequence (de+en): the file inherits the page's
classification but its content carries no marking (#212). The new
instance setting classification.uploadPolicy (default warn, documented;
the VS-NfD reference configuration blocks, #227) hardens the warning
into a server-side rejection (403 classified_upload_blocked) — enforced
in the upload service, not only in the UI. Tests: warning visible in the
local attachments pack; block enforced server-side with warn/block both
ways and open pages unaffected.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:33:34 +02:00
809e071f14 #207: print stylesheet with the classification on every printed sheet
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m56s
CI / Build container images (pull_request) Successful in 1m27s
CI / Auth e2e pack (pull_request) Successful in 9m18s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m5s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
First @media print support at all: page size/margins, navigation and
interactive chrome suppressed, break behaviour for headings, tables,
code blocks, figures and plugin blocks. The VS-NfD marking runs as
header AND footer on every sheet via a real-table PrintFrame whose
thead/tfoot browsers repeat per page — @page margin boxes are
unimplemented and position:fixed places unreliably in both engines
(verified empirically); on screen the table chain renders as plain
blocks, so nothing changes visually. Verified as PDF-from-browser in
Chromium 140 and Firefox 153 (2 markings on every page of a multi-page
document); the repeatable procedure is documented in
apps/web/e2e/README.md. Unclassified pages print without a marking.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:48:44 +02:00
adceca7358 #206: show the VS-NfD marking in web view header and footer
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m24s
CI / Build container images (pull_request) Successful in 4m24s
CI / Auth e2e pack (pull_request) Successful in 8m44s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Build and push images (push) Successful in 26s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m30s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 6m10s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m55s
CI / Import/export fidelity gate (push) Failing after 50s
ClassificationBanner renders the fixed ADR-0022 wording above and below
the content in reading view, editor and public page view; unclassified
pages show nothing. Announced to assistive tech via a localized hidden
prefix (de+en); styled from the plain text token only, so contrast holds
in both themes and under every accent with no new color pair. Public
content endpoint now carries the classification. New seed fixture
classified-note; a11y pack asserts banner top+bottom and axe-clean in
light and dark.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:18:47 +02:00
183faf7710 #204: classification as first-class page metadata (ADR 0022)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m42s
CI / Build container images (pull_request) Successful in 3m56s
CI / Auth e2e pack (pull_request) Successful in 8m17s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 24s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 6m17s
CD / Smoke tests against Test (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 8m20s
CI / Import/export fidelity gate (push) Successful in 55s
Enum field on Page (UNCLASSIFIED default, VS_NFD), migration backfills
existing pages. New pages take the instance-wide default from
classification.newPageDefault (admin-visible, de+en). The value rides in
every PageView, so no channel needs an extra request. The field is a
marking, not a protection mechanism: a test pins that permission
decisions are unchanged by it. The marking wording is fixed in ADR 0022
and sourced solely from classificationMarking() in @dorfteich/shared.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:01:50 +02:00
c4c84b33f9 #200: hard instance-wide plugins.enabled kill switch
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m38s
CI / Build container images (pull_request) Successful in 4m11s
CI / Auth e2e pack (pull_request) Successful in 8m55s
CI / Import/export fidelity gate (pull_request) Successful in 1m9s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Failing after 51s
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 5m37s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
plugins.enabled (instance setting, default on — plugins predate the
switch; the VS-NfD reference configuration turns it off) makes every
plugin surface answer 404 via a shared guard: Site-Admin
install/list/mode, pond activation and plugin list, the sandbox frame
and asset routes. The dropzone watcher quarantines drops instead of
installing. Deliberately NOT guarded: the authenticated
fallback-metadata route — it serves no plugin code and existing
plugin_block nodes need it to render their declared fallback (an image
fallback degrades to the neutral placeholder while off, because its
bytes live on the disabled asset surface). The editor offers no plugin
blocks because the pond plugin list is one of the 404ing surfaces.
Admin settings panel gets the toggle (i18n de+en) with the documented
api-restart note (in-process settings cache).

Answers "code execution inside the zone?" with one verifiable
off-switch instead of per-plugin trust machinery (#232, ADR 0025).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-31 04:42:42 +02:00
394d1c811d #192: deploy-level backup target allowlist
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m54s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m41s
CI / Import/export fidelity gate (push) Successful in 56s
BACKUP_ALLOWED_TARGETS (comma-separated destination hosts) constrains
where backups may go, enforced twice: the api rejects settings writes
and connection tests towards non-allowlisted hosts with admin-visible
error codes and resolves a non-allowlisted configured target to null,
and the sidecar enforces the same policy at the point of egress for the
WebDAV upload and the rsync mirror alike (shared policy helpers in
packages/shared/src/backup-target-policy.ts).

BREAKING: the empty default disables every remote target - backups stay
local only, the VS-NfD reference configuration (ADR 0026). Existing
deployments with a remote target must list its host or uploads and
mirror stop. The admin UI distinguishes unavailable-by-policy from
unconfigured (i18n de+en) and shows the permitted hosts.

Refs #192 (ADR 0026)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 12:17:14 +02:00
afef45732a #191: feeds.enabled instance switch, feed-token log masking
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m55s
CI / Auth e2e pack (pull_request) Successful in 7m52s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m55s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m34s
CI / Import/export fidelity gate (push) Successful in 58s
Chosen path: an instance master switch following the api.enabled/
mcp.enabled pattern — while off, both feed routes AND the feed-token
management answer 404 (existence hidden). Default ON: feeds predate the
switch, existing instances and their subscribed readers keep working;
the VS-NfD reference configuration (#227) turns it off. Admin UI gets
the toggle next to the API/MCP switches (i18n de+en).

Moving the token out of the query string is documented as rejected: a
path segment lands in the same proxy and request logs, and feed readers
cannot send headers — that is why the credential is in the URL at all.
What DID leak was our own request log (pino logs req.url): the req
serializer now masks ?token= values (common/mask-token-param.ts), so no
code path logs the credential.

Refs #191

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 11:34:43 +02:00
b5d2a436e0 #186: pond accent theming — scoped derivation, cascade pond > user > default
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m44s
CI / Build container images (pull_request) Successful in 4m2s
CI / Auth e2e pack (pull_request) Successful in 10m50s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 4m47s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Successful in 10m7s
CI / Import/export fidelity gate (push) Successful in 56s
Release / Build release images and notes (push) Successful in 1m11s
Release / Release-candidate operations QA (push) Successful in 1m0s
Prod deploy / Deploy the released images to Prod (push) Successful in 17s
pondSettingsSchema gains theme = { accent: '#rrggbb' | null } (null =
inherit the viewer's theme), exposed as a top-level key of the flat
updatePondInputSchema and included in the PondsService settings merge
(the known silent-no-op pitfall). The server validates only the hex;
conformance arises at render time: PondThemeScope (mounted around the
page content next to PondFontScope) derives the accent pair for the
EFFECTIVE mode via useEffectiveTheme and sets it as inline custom
properties — inline beats both tokens.css and the user-theme <style>,
which IS the cascade precedence pond > user > default.

Pond settings get a PondThemeSection (inherit | presets | custom color
with per-mode preview swatches, explicit save like the font manager);
AccentSwatches extracted for reuse; i18n de+en. The no-JS public shell
stays deliberately un-themed (ADR 0018 amendment).

Tests: pond DB test (theme merge keeps fonts, invalid hex 400), e2e
pond-theme.spec (scope boundary content vs. chrome, per-mode
re-derivation, axe on the pond settings page; resets the fixture pond).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-29 09:09:36 +02:00
83a2fe470e #184: user accent theming — presets and free color as one mechanism
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m41s
CI / Build container images (pull_request) Successful in 4m4s
CI / Auth e2e pack (pull_request) Successful in 11m40s
CI / Import/export fidelity gate (pull_request) Successful in 52s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
apply-theme.ts derives BOTH modes' accent tokens from the stored choice
(ui.theme.accent: preset id or {custom:'#hex'}) and writes them as
<style id="user-theme"> with :root:root + :root:root[data-theme='dark']
blocks — the doubled :root beats tokens.css regardless of document
order, since theme-init.js injects the ui.theme.css cache during <head>
parsing, before the bundle styles. The default preset means NO override
(hand-tuned tokens.css values stay). main.tsx re-derives from the
choice at startup, healing stale caches after app updates.

Settings: accent radiogroup inside the Appearance section (visible
names, color never the only cue) with per-mode preview swatches on
each mode's canonical background, plus a custom color input; i18n
de+en. The second fieldset made bare .settings-fieldset locators
ambiguous — theme specs now scope via input[name] (fence stays).

Tests: apply-theme unit pack, BASE_PALETTE<->tokens.css drift fence in
theme-contrast.test.ts, e2e theme-accent.spec (instant apply, pre-paint
persistence, default removes override, axe smoke with garish yellow in
both modes). ADR 0018 amendment documents the stage-B details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-29 08:59:27 +02:00
b799ad180b #182: top-bar theme toggle — cycle light/dark/system without a menu
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m40s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 8m30s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
An IconButton between the notifications bell and the user menu cycles
the theme mode in radio order (sun/moon/monitor mirror the CURRENT
choice). New useThemeMode() hook is the single write path (persist +
apply + same-document event), so the settings radios and the toggle
stay in sync; AppearanceSection now uses it too. Also rendered for
signed-out visitors — the mode is a device-local preference. i18n de+en;
unit tests for cycle/setter, theme.spec covers cycling, radio sync,
persistence, and the signed-out top bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-29 08:43:42 +02:00
2c571f9f5e #180: dark mode — Light/Dark/System setting with token-based dark palette
The dark palette lives as a single :root[data-theme='dark'] block in
tokens.css; theme.ts and the pre-paint public/theme-init.js (external file
because the prod CSP forbids inline scripts) always resolve the stored
ui.theme.mode to a concrete data-theme, so 'system' needs no @media
duplicate and follows live OS changes via matchMedia. color-scheme flips
per theme (native controls/scrollbars), paired theme-color metas track the
effective theme, and the new Appearance settings section offers the
three-way choice as native radios (device-local, like #170). Label chips
gain a chip-outline ring so arbitrary user colors stay separated on the
dark canvas; useEffectiveTheme() is exported for the later pond-scoped
theming stage (ADR 0018).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-28 20:40:47 +02:00
bdadfce6b9 #177: Admin-Personenliste — Aktions-Zelle bleibt Table-Cell, Flex auf Innen-Wrapper
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m39s
CI / Build container images (pull_request) Successful in 1m10s
CI / Auth e2e pack (pull_request) Successful in 7m43s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m50s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m24s
CI / Import/export fidelity gate (push) Successful in 55s
Release / Build release images and notes (push) Successful in 1m11s
Release / Release-candidate operations QA (push) Successful in 45s
Prod deploy / Deploy the released images to Prod (push) Successful in 16s
Die Trennlinie unter der Aktionen-Spalte endete auf Höhe der Icon-Reihe
statt am Zeilenende: display:flex direkt auf dem td nahm der Zelle ihr
table-cell-Verhalten, sie wuchs nicht mehr auf Zeilenhöhe. Das Flex-
Layout liegt jetzt auf einem Innen-Wrapper (.user-row__actions-inner);
gemessen: 0 px Bottom-Delta über alle Zellen jeder Zeile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 13:36:27 +02:00
3283affa67 #175: Admin-Personenliste — Aktions-Icons statt Textlinks, Reihenfolge Admin/Deaktivieren/Löschen
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m41s
CI / Build container images (pull_request) Successful in 1m9s
CI / Auth e2e pack (pull_request) Successful in 7m32s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m41s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m46s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m18s
CI / Import/export fidelity gate (push) Successful in 55s
Die Zeilen-Aktionen der Personenverwaltung sind jetzt IconButtons
(lucide): MailCheck (Bestätigung erneut senden, nur bei Ausstehend),
ShieldPlus/ShieldMinus (Zum Admin machen / Admin entfernen),
UserX/UserCheck (Deaktivieren/Aktivieren), Trash2 (Löschen) — in dieser
Reihenfolge. Das zweistufige Löschen bleibt: die Bestätigung ist
weiterhin ein roter Text-Button und erhält beim Umschalten den Fokus
(kein Fokusverlust, ADR 0017). Lokalisierte Namen kommen unverändert
aus users.json via IconButton (aria-label+title), Icons aria-hidden.
Der Admin-Bereich ist neu im a11y-CI-Pack (axe WCAG A/AA auf /admin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:41:26 +02:00
31b59f0fb6 #170: Statusmeldungen, Einzeltasten-Shortcuts, Bewegung
Toast-Standzeit 2,5s auf 6s (WCAG 2.2.1 — für Screenreader-/Zoom-Nutzer
kaum erfassbar). Neue Einstellungs-Sektion Bedienung mit dem Schalter
Einzeltasten-Kürzel deaktivieren (lokale Geräte-Einstellung); die
Handler von e und / prüfen sie beim Tastendruck (WCAG 2.1.4).
prefers-reduced-motion: CSS-Transitions kollabieren auf instant, die
Graph-Simulation rechnet ihr Layout synchron zu Ende statt zu animieren
(WCAG 2.2.2). settings-nav-Spec auf 8 Sektionen nachgeführt. Bewusst
KEIN zusätzliches role=status (legal.spec-Locator-Falle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:39:03 +02:00
8719b0ee1e #168: Formulare — Fehler-Verdrahtung und Namenslücken
Der Field-Baustein verdrahtet Hinweis/Fehler jetzt per aria-describedby
und aria-invalid mit dem Eingabefeld (cloneElement auf das einzelne
Kind; Fragmente bleiben unangetastet) — Screenreader nennen den Fehler
damit auch beim Feld-Fokus. Quota-Typ-Select mit Namen; die leeren
Aktions-/Erledigt-Spaltenköpfe in API-Tokens, Feed-Tokens, Sitzungen
und der Aufgabenübersicht (NodeView UND Server-Renderpfad) tragen
visually-hidden-Beschriftungen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:26:54 +02:00
2077d92c09 #166: Skip-Link, verstecktes Seiten-h1, Resizer in die Nav-Landmarke
Skip-Link als erster Tab-Stopp springt auf #main; die angemeldete
Seitenansicht bekommt ein visually-hidden h1 (der sichtbare Titel ist
ein Input, der jetzt auch ein aria-label trägt); der Sidebar-Resizer
wandert in die nav-Landmarke (absolut an der Kante positioniert), damit
kein Inhalt außerhalb von Landmarken liegt. Zwei e2e-Locator auf das
Sidebar-Formular gescoped — das Editor-Titelfeld matcht seit dem neuen
Label ebenfalls auf /title|titel/i.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:26:54 +02:00
057992faaf #164: ARIA-Semantik — Editorfläche, Autocomplete-Listboxen, Sidebar, Toolbar
Die Editorfläche bekommt einen lokalisierten zugänglichen Namen und ist
im Lesemodus role=document statt eines unbenannten Textfelds (setOptions
im selben Layout-Effekt wie setEditable). Eingeklappte Sidebar zusätzlich
inert (aria-hidden allein ließ fokussierbare Kinder im Tab-Weg). Die
li-Zwischenknoten der Listboxen (Wikilink-/Mention-Autocomplete,
Suchergebnisse) sind role=presentation, damit listbox→option wieder eine
gültige Eltern-Kind-Beziehung ist. Toolbar: Pfeiltasten-Navigation über
die Controls (native Selects behalten ihre Pfeiltasten) und ein
sprechendes Toolbar-Label statt des Absatz-Buttons-Labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:04:36 +02:00
418aafd5ec #163: Dokumentsprache und Seitentitel der SPA
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 7m14s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
i18n spiegelt die aktive Sprache auf <html lang> (Init + languageChanged;
der User-Locale-Wechsel in auth-context läuft über dasselbe Event). Neuer
useDocumentTitle-Hook setzt je Route einen sprechenden Titel
(Seite — Teich — Dorfteich), verdrahtet in allen Routen-Komponenten;
dynamische Titel folgen den geladenen Daten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 13:52:59 +02:00
1eca7c334c #162: Fokus-Management für Dialoge und Such-Palette
Gemeinsamer useModalFocus-Hook: Initialfokus in den Dialog, Tab/Shift-Tab
zyklisch gefangen, Fokus-Rückgabe an den Auslöser (bzw. returnFocusRef,
wenn der öffnende Menüpunkt mit dem Menü unmountet). Dialoge tragen jetzt
aria-labelledby auf ihre Überschrift und tabindex=-1 als Fokus-Fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 13:52:59 +02:00
7471fc70f7 #150: @-Mentions — Inline-Node, instanzweite User-Suche, Autocomplete
Neuer Inline-Atom mention {userId, username}: Markdown-Regel @username
(E-Mail-sicher über Wortgrenzen), Serializer, HTML-Span dt-mention,
Plain-Text für die Suche, Extraktor extractMentionUserIds. Neue
Endpoints GET /users/search (auth, min. 2 Zeichen, Limit 10,
Rate-Limit) und GET /users/brief (Batch-Auflösung für live
Anzeigenamen; gelöschte Nutzer → toter Chip). Editor: MentionView mit
Live-displayName, MentionAutocomplete (Klon des Wikilink-Musters),
Chip-CSS. 5 Unit-Tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:56:07 +02:00
7252bd16e0 #149: Atom-Feeds für Teiche und Seiten, privat via Feed-Token
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 7m12s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m35s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Failing after 5m14s
CI / Import/export fidelity gate (push) Has been skipped
GET /public/:pond/feed.xml (zuletzt geänderte Seiten) und
GET /public/:pond/:page/feed.xml (Versions-Historie), @Public mit
404-Semantik; öffentliche Teiche anonym, nicht-öffentliche über neues
read-only Feed-Token je Nutzer als ?token=dt_feed_… (neue Tabelle
feed_tokens + Migration, Verwaltung in den Nutzer-Einstellungen,
FeedTokensSection). Öffentliche HTML-Seiten annoncieren den Teich-Feed
per link rel=alternate. DB-Tests (anonym/privat/Token-Lifecycle) und
User-Guide-Doku en+de.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:49:53 +02:00
b4247f4832 #145: Einstellungsseiten mit Sektions-Sprungnavigation
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m27s
CI / Build container images (pull_request) Successful in 3m48s
CI / Auth e2e pack (pull_request) Successful in 6m58s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Failing after 4m47s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
Neue SettingsLayout-Komponente leitet die Navigation per
MutationObserver aus den section>h2-Blöcken ab (erfasst konditionale
und komponenten-eigene Sektionen ohne Verdrahtung), sticky Leiste
neben dem Inhalt, auf schmalen Viewports horizontale Chip-Leiste;
aktive Sektion über Scroll-Position, am Seitenende gewinnt die letzte.
Auf allen vier Einstellungsseiten verdrahtet; die Admin-Grundeinstel-
lungen bekommen dafür eine eigene Überschrift. Neuer CI-Pack
settings-nav.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:34:15 +02:00
4ba4ca7ba9 Editor: Auto-Fokus beim Wechsel in den Edit-Modus
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m50s
CI / Build container images (pull_request) Successful in 1m16s
CI / Auth e2e pack (pull_request) Successful in 6m44s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m32s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Failing after 5m29s
CI / Import/export fidelity gate (push) Has been skipped
Beim Umschalten in den Bearbeiten-Modus (Stift-Icon oder Shortcut „e")
landet der Cursor jetzt automatisch im Editor — bisher brauchte es einen
zusätzlichen Klick, der auf neuen/leeren Seiten zudem pixelgenau den
schmalen Inhaltsbereich treffen musste.

Effekt feuert, sobald der Editor editierbar wird (nach setEditable),
und überspringt den Fokus-Klau, wenn gerade ein Textfeld (z. B. der
Seitentitel) den Fokus hält — der Moduswechsel darf den Caret nicht aus
dem Titel reißen. TipTaps focus() stellt die letzte Auswahl wieder her
bzw. setzt den Caret an den Anfang einer leeren Seite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 11:36:17 +02:00
f014a61480 #133 Kommentare fest inline im Lesemodus (Slide-in-Panel ablösen)
Kommentare erscheinen jetzt fest im Lesefluss zwischen Backlinks und
lokalem Graph statt in einem ein-/ausblendbaren Panel. Der
Kopfleisten-Toggle (Icon + Unread-Badge) entfällt.

Frontend:
- CommentsPanel → CommentsSection (Inline-Sektion, ohne Panel-Chrome/
  Close-Knopf; markiert beim Sichtbarwerden als gelesen). Neue
  Read-only-Variante PublicComments für die anonyme öffentliche Ansicht.
- Umzug auf die äußere Ebene in PageEditorPage (view-Modus, zwischen
  BacklinksPanel und LocalGraphPanel). Das Schreibrecht (collab rw) wird
  per onWriteAccess aus dem inneren PageEditor hochgereicht, damit die
  äußere Ebene den Composer bei commentPolicy=editors korrekt zeigt/
  verbirgt.
- Deep-Link ?comments=1 scrollt jetzt zur Inline-Sektion statt ein Panel
  zu öffnen. Resolve/Unresolve-Knöpfe zusätzlich an mayComment gekoppelt
  (früher nur an isRoot) — Leser sehen keine 403-Knöpfe mehr; Read-only
  blendet alle Aktions-Controls aus.
- CSS comments-panel* → comments-section*; tote Unread-Badge-Regeln raus.

Backend:
- GET /public/:pondSlug/:pageSlug/comments (@Public), read-only. Nutzt den
  vorhandenen resolve()-Pfad (erzwingt ggf. anonymen Lesezugriff → nicht
  öffentliche Seiten 404en) und CommentsService.list. PublicModule
  importiert CommentsModule.

Tests: public.e2e.db.test.ts um anonymen Kommentar-Lesezugriff + 404-Fälle
ergänzt (grün gegen frische Test-DB); comments.spec.ts auf die Inline-UI
umgestellt. typecheck/lint/i18n:check grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 01:22:22 +02:00
c858f12592 #134 Statuszeile zwischen Navigation und Artikel
Neue schlanke Statuszeile (letzte Aktualisierung · Wortzahl · geschätzte
Lesezeit) zwischen Seitenkopf und Artikel — im authentifizierten
Lesemodus und in der öffentlichen Ansicht.

- Geteilte Komponente `PageStatusBar` (Datum via Intl in der aktiven
  Sprache, Lesezeit = ceil(Wörter/200), Singular/Plural, Lesezeit
  ausgeblendet bei 0 Wörtern).
- `countWords`/`htmlToText`-Helfer in lib/word-count.ts.
- Authentifiziert (`PageEditorPage`, nur Lesemodus): Wortzahl aus dem
  vorhandenen Markdown-Export (geteilter Query-Key ['page-markdown']),
  `updatedAt` direkt von `page.data`.
- Öffentlich (`PublicPageView`): Wortzahl aus dem server-gerenderten HTML
  per DOMParser — kein Editor-Bundle nötig; kein Backend-Change.
- i18n common.statusbar (de+en), CSS `.page-statusbar` (middot-getrennt,
  gedämpft). Gates grün (typecheck/lint/i18n:check); visuell verifiziert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 01:04:11 +02:00
6c98a71d34 Favorites: personal page stars, golden icons, sidebar filter (#132)
Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m32s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m51s
CI / Import/export fidelity gate (push) Has been skipped
Semantics changed from the issue during planning (documented there,
comment 1192): favorites are PERSONAL per user, not pond-wide — the
sys-fav label approach is dropped entirely. Storage is a page_favorites
table (userId+pageId, FK cascade); PUT/DELETE /pages/:id/favorite
toggles idempotently and needs read access only (#60 404 semantics —
a star is a note-to-self, not a page modification), GET
/ponds/:id/favorites lists the account's stars sliced to still-readable
pages. Trashed pages keep their rows, so restore keeps the star; purge
cascades it away.

Web: one shared ['favorites', pondId] query feeds the TopBar star
(between labels and history, golden when set), the golden tree icons in
the sidebar, and a latching "Favorites" filter button next to the view
switch that narrows either view (combinable with the label filter).
No public-API/MCP exposure — with the label approach gone, that parity
is no longer free; favorites stay UI-only for now.

New favorites e2e pack (star toggle, golden icon, filter, per-user
isolation) wired into CI; DB suite covers the round-trip, read gating,
and the trash/restore/purge lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-16 12:03:55 +02:00
48d4c60af7 Trash: checkbox multi-select with bulk restore and purge (#128)
Each trash row gets a checkbox, a toolbar above the list offers
"select all" (native indeterminate for partial selections) and the two
bulk actions; bulk purge confirms with the selection count (pluralized).
Processing is sequential on purpose — purge promotes leftover children
(#107), so concurrent tree mutations would race. Failures don't strand
the rest: the loop keeps going, failed pages stay selected for a retry,
and an alert banner reports the count. Single-row actions run through
the same path, which also fixes their previously unhandled rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-16 12:03:35 +02:00
36cdd4fbca Editor: confirm snapshots with a toast; wire ui.toast for plugins (#130)
Cmd/Ctrl+S used to snapshot silently. A new app-wide ToastProvider
(components/Toast.tsx) owns a bottom-center stack — permanent polite
live region, auto-dismiss after 2.5 s, click to dismiss early, error
variant. Both snapshot paths (the keyboard chords in PageEditorPage and
the save-version TopBar button) now confirm with the version name when
there is one, and their failure alert becomes an error toast.

The plugin host capability ui.toast (declared since #74, wired
nowhere) connects to the same stack: PluginBlockScope carries the
showToast handle, plugin-block passes it into the sandbox context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-16 12:03:17 +02:00
0428892ef2 Editor shortcuts: "e" edits, the platform chord+S snapshots versions
In reading mode a plain "e" (guarded against typing targets) switches
to edit mode. In edit mode the platform's native chord — Cmd on macOS,
Ctrl elsewhere — +S saves an unnamed manual snapshot in place, and
+Shift+S asks for a name and returns to reading mode; both always
swallow the browser's save dialog. The shared isTypingTarget guard
moves from TopBar into lib/keyboard.ts next to the new modifier helper.

Unnamed snapshots needed the API to accept them: the version label is
optional now (trigger stays MANUAL, label null), and the history list's
existing null-label fallback text becomes "Manueller Schnappschuss" /
"Manual snapshot" — it only ever shows for exactly those. DB test for
the label-less path, e2e coverage in the CI content pack.

Fixes #125

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-15 13:17:09 +02:00
704ebe48a6 Vault import dialog in the pond settings (#118)
Some checks failed
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Deploy to Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Failing after 1m0s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Has been cancelled
An admin-only 'Import an Obsidian vault' section on the pond settings
page opens a dialog with everything the #117 endpoint expects: the ZIP,
an indented mount-parent picker over the page tree (the MovePageDialog
pattern), a multi-select over the pond's label tree, and the
frontmatter radio (strip / keep as code block). Submit uploads and
polls the job with a vault-sized budget (600 x 1 s), then invalidates
pages, graph, phantom-links, and labels so the sidebar tree, graph, and
pickers show the import without a reload — and links to the mount page.

apiUploadFile now takes extra multipart fields (the options JSON);
existing callers are unchanged.

e2e import-vault.spec.ts: an admin imports the fixture vault through
the dialog and the app shows the folder tree under the mount page, a
rewritten Obsidian link navigates to the right page, the embedded image
renders, and the nested tag labels exist next to the dialog's extra
label; a plain editor gets no section at all. 3x flake-free locally
(CI wiring lands with #119).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:26:26 +02:00
64e21e9f94 Offer creating the page on the not-found screen (#115)
Some checks failed
CD / Build and push images (push) Successful in 4m20s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m38s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Has been cancelled
Following a phantom wikilink now ends with a way out instead of a dead
end: when the pond resolved and the page 404s as plain not_found, the
error screen offers creating the page in place. Title = the URL slug
(the PhantomPagesView mechanic), so every wikilink pointing at the
address resolves; the invalidated page query then mounts the editor on
the same URL. The affordance is deliberately ungated like the sidebar's
new-page button — the client cannot tell 'never existed' from 'not
readable' (#60), and a reader's POST surfaces as the regular 403
banner. The page_trashed branch (#31) is untouched.

Rides along: PhantomPagesView now also invalidates ['pond-links'] —
the graph views kept showing a just-created target as a phantom.

e2e pack create-missing-page.spec.ts (CI wiring lands with #119):
author a phantom link, follow it, create, backlink proves resolution;
reader path asserts the 403 banner and no editor mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:45:27 +02:00
a72cb1b1c5 Local neighborhood graph on the page (#113)
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 2m26s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m21s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
A collapsible 'Local graph' panel joins the backlinks below the page
content in read mode: the current page (highlight ring) with its
wikilink neighbors in both directions, switchable between direct
neighbors and two hops. Computed client-side by BFS over the cached
pond-wide graph response — no second endpoint; the TanStack query is
shared with the pond graph view. Phantom targets render dashed; a
click navigates to the neighbor; pages without any links show no
panel at all. Reuses the ForceGraph renderer from #112 unchanged.

Verified live: hop toggle reveals the second-hop page, ring on the
current page, click-through, and the panel's absence on a lonely page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:01:47 +02:00
0308bc712d Drag-onto reparent, Move-to dialog, and the delete decision (#109)
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 3m58s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Successful in 4m23s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Has been cancelled
Sidebar folder view: a row now has three drop bands — the edges keep
the within-group reorder, the middle band nests the dragged page under
the row (appended to its new sibling group, with a drop-into outline
cue). Cycle/depth refusals surface as a translated banner; successful
moves are announced for screen readers.

The overflow menu gains 'Move to…': a modal parent picker over the
page tree (top level first, the page's own subtree disabled) that works
in every sort mode. Delete now decides per case: childless pages keep
the plain confirm; pages with subpages open a dialog offering promote
(default wording: move subpages up) or subtree delete.

The children lookup reads the CACHED pages list on purpose: an async
fetch before window.confirm broke the click→confirm→DELETE rhythm the
content pack (and users) rely on, and a stale childless read errs
toward promote — never toward a silent subtree delete. Sidebar caret
labels deliberately exclude the page title: accessible names are
matched by substring in the specs (#101), and a title like 'Editor…'
collided with the edit-mode toggle.

Verified live: move dialog (subtree option disabled), promote and
subtree delete flows; content/trash/export packs green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:40:08 +02:00
15184876bd Sidebar folder view, label view, and the view toggle (#108)
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
The sidebar now presents pages as a collapsible tree built from parentId
(folder view) or grouped under the hierarchical label tree (label view,
read-only; multi-label pages appear under each label, untagged ones in
an 'unlabeled' group). The pond owner sets the default via a new
sidebarView pond setting (PATCH-merged like the other keys); every user
can override it locally (ui.sidebar.view.<pondId>), and the toggle sits
above the page list. Collapse state persists per pond.

New pages created while a page is open become its children — the inline
form says so and sends parentId. Reordering (buttons and drag-between)
now operates within one sibling group; the label filter stays a
folder-view feature and falls back to the flat list while active, so
the filtered order is never mistaken for a partial tree.

SidebarContent is keyed by pond id so the per-pond localStorage hooks
mount with the right key. e2e hooks (.sidebar__pages, .sidebar__page,
reorder buttons) kept; reorder/labels/content packs green locally, plus
a live smoke of nesting, collapse persistence, and both views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:17:36 +02:00
04abda5724 Keep the landing editor off the .legal-editor class
All checks were successful
CD / Deploy to Test (push) Successful in 10s
CD / Build and push images (push) Successful in 1m10s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m10s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m37s
CI / Import/export fidelity gate (push) Successful in 48s
Release / Build release images and notes (push) Successful in 1m6s
Release / Release-candidate operations QA (push) Successful in 41s
Prod deploy / Deploy the released images to Prod (push) Successful in 15s
The landing form reused LegalTextField, whose .legal-editor wrapper the
legal e2e selects by index (nth(1) = privacy policy). Placed before the
legal form it shifted those indices, so the test drove the imprint field
and the published privacy text never appeared. Generalize the component
to MarkdownTextField with a wrapperClass prop: legal keeps .legal-editor,
the landing editor uses .markdown-field. Verified locally: legal,
admin-users, admin-quotas packs green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-13 00:14:03 +02:00
abf49c7c0e Give the landing-page save button its own label
Some checks failed
CD / Build and push images (push) Successful in 3m53s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m12s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Failing after 2m56s
CI / Import/export fidelity gate (push) Has been skipped
The landing form reused the legal namespace's "Save legal pages" label,
so two buttons shared that text and the legal e2e's page-wide button
lookup hit a strict-mode violation. Use a dedicated settings-namespace
"Save landing page" label instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 23:59:59 +02:00
9c64166b10 Editable landing page for the Site Admin
Some checks failed
CD / Build and push images (push) Successful in 3m50s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m13s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m44s
CI / Import/export fidelity gate (push) Has been skipped
The public home page (/) now renders Markdown the Site Admin stores in
the new home.content instance setting, through the same sanitizing
pipeline as the legal pages; empty falls back to the built-in welcome
text. New public GET /home/content, an Admin → Settings editor with
live preview, and an e2e test covering default/configured/escaping/
admin-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 23:50:16 +02:00
627f128ab8 Pond lifecycle in the UI: create shared ponds, delete from settings
Some checks failed
CD / Build and push images (push) Successful in 3m54s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m8s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Failing after 11s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m40s
CI / Import/export fidelity gate (push) Successful in 54s
The pond switcher grows a "+ New pond" entry with an inline form
(name + optional description, quota errors surfaced translated); the
pond settings of shared ponds end in a danger section that moves the
pond to the site-level trash after typing its name to confirm.
Personal ponds keep hiding the section. .button--danger is now a
solid red button (also fixes the admin restore button, which showed
red text on the accent-green background). Manuals no longer call
these actions API-only; covered by a members-pack e2e test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 19:15:34 +02:00
c8ec549fa5 UI polish: frameless plugin blocks in read mode, sticky toolbar, pinned footer, icon uninstall
All checks were successful
CD / Build and push images (push) Successful in 1m11s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m5s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m35s
CI / Import/export fidelity gate (push) Successful in 47s
Release / Build release images and notes (push) Successful in 1m6s
Release / Release-candidate operations QA (push) Successful in 40s
Prod deploy / Deploy the released images to Prod (push) Successful in 15s
Three refinements from Stefan's review of the plugin work:

- read mode integrates plugin output like normal content: no border, no
  name bar, no selection outline around plugin blocks — same principle
  as the frameless reading shell (M10)
- the editor toolbar pins to the top of the scrolling content area on
  long articles instead of scrolling away (position: sticky within the
  main scroll container)
- the app footer (connection status + legal links) moved out of the
  scroll container into a main-column wrapper — always visible at the
  bottom edge of the window on every view
- the plugin uninstall buttons in the admin list are icon buttons now
  (Trash2, house pattern: aria-label keeps the accessible name)
- lint hygiene: eslint/prettier ignore packages/plugins/*/vendor —
  the unpacked drawio webapp drove eslint out of memory

Verified in the browser against a local stack (5/5 scripted checks:
icon buttons, toolbar sticky at scroll bottom, footer pinned in edit
and read mode, plugin block computed border/outline none in read mode)
plus 8 layout-sensitive e2e packs re-run individually, all green
(comments, collab, legal, content, plugin-admin, plugin-blocks,
page-tools, plugins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 15:33:32 +02:00
52192eb05f Backup mirror to BASEL: rsync of the sets after every successful run (#84)
All checks were successful
CD / Build and push images (push) Successful in 3m51s
CI / Lint, typecheck, test (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 11s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 12s
CI / Auth e2e pack (push) Successful in 5m52s
CI / Import/export fidelity gate (push) Successful in 47s
The operator-level extra beside the admin-configured Nextcloud target
(#103), unblocked now that the ONE→BASEL tunnel is stable again.

- sidecar: optional mirror step (mirror.ts) driven purely by env —
  BACKUP_MIRROR_TARGET (rsync-over-ssh), BACKUP_MIRROR_SSH_KEY (private
  key on the secrets volume, never in image or repo),
  BACKUP_MIRROR_SSH_PORT. Runs after the prune of every successful run,
  so --delete aligns the remote retention with the local one (the
  newest-complete-set guarantee carries over). Only set files travel
  (db-*.dump, files-*.tar.gz); status files and bundles stay local.
  Host key pinned via accept-new into .mirror_known_hosts on the backups
  volume; fixed remote modes (dirs 750, files 640, symbolic --chmod —
  octal needs rsync ≥ 3, macOS dev machines ship 2.6.9). rsync +
  openssh-client added to the sidecar image.
- status: additive `mirror` block in status.json (outcome, transferred
  count, lastSuccessAt carried across failures) — shown on the admin
  backup card; failures alert via a new backupMirrorFailed mail (de+en)
  while the local run still counts as succeeded.
- deploy/backup-basel.md: complete BASEL-side walkthrough — dedicated
  user dorfteich-backup with a /home/ home and a bash login shell,
  explicitly avoiding the Debian backup-user (UID 34) pitfalls
  (nologin shell rejects rsync sessions, /var/backups home), key
  placement through the api container onto the secrets volume, .env
  values, on-demand verification.
- tests: rsync-arg/stats-parsing units plus an integration suite against
  the real rsync binary (local target; skips where rsync is absent) —
  transfer, idempotent re-run (0 files), retention alignment, failure
  path carrying lastSuccessAt.

Verified live against the real BASEL host from a native sidecar run:
initial transfer, host-key pinning, retention alignment after a local
prune, idempotency, and the failure path (surfaced in status.json while
the local run stayed green). BASEL side provisioned per the doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 12:20:32 +02:00
04e21a0aac Built-in MCP endpoint (Streamable HTTP) on top of the public API (#105)
All checks were successful
CD / Build and push images (push) Successful in 3m50s
CI / Lint, typecheck, test (push) Successful in 4m2s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m37s
CI / Import/export fidelity gate (push) Successful in 47s
AI clients talk to the instance directly at /api/mcp — under the /api/
path (deviation from the issue's literal /mcp) so every existing reverse
proxy already routes it; no deployment changes anywhere.

- Transport: official @modelcontextprotocol/sdk server, STATELESS — each
  POST builds a fresh server+transport pair, no session store, replicas
  stay trivial; GET/DELETE answer 405. Auth per PAT bearer (#104 tokens),
  per-token rate limit (429 + Retry-After).
- Own switches, independent of REST: instance mcp.enabled (admin
  settings, default off; off = 404, feature invisible) + pond setting
  mcpEnabled (pond-settings toggle, default off) — pinned independent in
  both directions by tests.
- Tools (thin wrappers over the #104 services, same permission gates,
  audit-logged writes): list_ponds, list_pages, read_page, search,
  create_page, update_page (replace semantics through the collab-owned
  restore path — open editors converge), add_comment, list_labels,
  set_page_labels (exact replace), export_pond (link to the REST ZIP).
  Tool errors carry the api error codes; results carry stable slugs/ids.
  MCP resources stay the documented stage-2 stretch goal.
- Deliberately on the SDK's low-level Server API with a hand-written tool
  table (mcp-tools.ts): the typed registerTool generics drove tsc out of
  memory in a program this size; manual Zod validation keeps the wire
  behavior explicit.
- PublicApiService exposure filtering parameterized ('api' | 'mcp',
  shared pondFeatureEnabled helper) — one implementation, two switches.
- Docs: "Connect Claude Code / MCP clients" section in public-api.md
  (claude mcp add one-liner + mcp-remote bridge for stdio clients).

Verification: 8-test e2e pack driving the real MCP SDK client over
Streamable HTTP against a listening api (initialize + tools/list, switch
independence in both directions, anonymous/garbage 401, opt-in 404
semantics, page roundtrip incl. restore-NOTIFY, labels/comments, read
scope blocked from writes with scope_required); live check through the
web proxy against the seeded stack (tools list, create, read, update,
search — LIVE CHECK PASSED); full api suite 61/61 files green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 11:36:02 +02:00
0c85293830 Public REST API v1: personal access tokens, instance switch + per-pond opt-in (#104)
Some checks failed
CI / Lint, typecheck, test (push) Failing after 1m39s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m51s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 11s
Token-authenticated machine access at /api/public/v1 — the foundation for
the built-in MCP endpoint (#105).

Personal access tokens:
- api_tokens table (SHA-256 hash, scope read|write, optional pond
  restriction, expiry, revocation, throttled last-used) + migration;
  secrets are dt_pat_<random>, shown exactly once
- lifecycle endpoints under /users/me/api-tokens (session-only — a leaked
  token can never mint more tokens) with audit entries
  api.token_created/api.token_revoked
- settings UI section (create with scope/expiry/pond restriction,
  one-time reveal with copy, list with status + revoke), de+en

Activation (404 semantics per #60 on both levels):
- instance setting api.enabled (default off, admin settings switch)
- pond setting apiEnabled (default off, pond settings toggle; the
  PondsService settings-merge learned the key — the #92 lesson)

Surface (/api/public/v1, excluded from the SPA's global prefix):
- me, ponds, pages (list/read as Markdown+HTML, create from Markdown via
  the shared pipeline, PATCH title/content, DELETE to trash), search
  (permission-filtered + narrowed to exposed ponds, highlights as **…**),
  markdown ZIP export, labels (tree, create/rename/recolour/move/delete,
  assign/unassign), comments (threads, create, resolve/reopen)
- content replacement travels the collab-owned document path: the new
  state lands as a MANUAL version "API update", then the established
  restore NOTIFY applies it — open editors converge, history stays
  append-only, no second lineage (VersionsService.replaceContent)
- hand-maintained OpenAPI 3.1 document at /openapi.json, pinned to the
  controller by a route-coverage test in both directions

Enforcement:
- PublicApiGuard: instance switch → bearer PAT auth (request.user is the
  token's user) → per-token rate limit (429 + Retry-After) → scope
  (403 scope_required) → pond opt-in + token restriction
- the shared PermissionGuard then applies the unchanged permission model;
  PageParamSource gained pondSlugParam for the slug+slug routes
- no cookies anywhere → no CSRF surface (pinned by a hostile-Origin test)
- every write audit-logged as api.write with the token attributed

Tests/verification:
- 12-test e2e pack: lifecycle, switches, permission matrix
  (reader/editor/outsider × scopes), restriction, page roundtrip incl.
  restore-NOTIFY assertion, labels, comments incl. policy, search
  narrowing, ZIP export, rate limit; full api suite 60/60 green
  (quota fixture via per-user override — never the instance default)
- new collab-pack test proves an open editor converges onto an API
  content replacement (green against a local seeded stack)
- UI smoke against the built SPA: token create/reveal/revoke, pond
  opt-in persists, admin switch persists (10/10)
- docs/self-hosting/public-api.md + README link

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 11:17:03 +02:00
5cef359b8f Nextcloud backup target: admin-configured, manual + scheduled uploads, in-app restore (#103)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m45s
CD / Build and push images (push) Successful in 3m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m35s
CI / Import/export fidelity gate (push) Successful in 47s
Off-host backups for every self-hoster, configured entirely in the admin
UI — supersedes the host-specific mirror plan behind #84.

shared:
- webdav.ts (new package entry like token-crypto): minimal WebDAV client
  with basic auth — PROPFIND (tolerant multistatus parser), MKCOL, PUT
  (streamed), GET, DELETE; Nextcloud DAV path derived from the plain
  server URL, explicit DAV bases pass through
- backup-status.ts: additive remote-upload status in status.json, the
  restore-status.json contract (running/succeeded/failed + staleness
  bound), the backup_command/backup_maintenance NOTIFY channels, and the
  one-bundle-per-set naming (dorfteich-backup-<id>.tar.gz)
- backup-set.ts moved here from apps/backup (api lists local sets)

backup sidecar:
- reads the backup.* instance settings directly from the database (admin
  changes apply next run; local retention row overrides the env) and the
  app password from the secret store
- after each successful set: bundle dump + files archive + manifest into
  ONE self-contained tar.gz, upload via WebDAV per schedule
  (off/daily/weekly; manual runs always upload), prune remote bundles —
  never the newest — and record the outcome in status.json; upload
  failures alert via a new backupUploadFailed mail (de+en)
- command listener on backup_command (run / restore) with a serial queue
  against the nightly timer
- restore orchestrator: restore-status.json → maintenance NOTIFY →
  grace → (remote: download + manifest-verify bundle) → terminate other
  DB connections → shared perform-restore path (same code as restore.sh)
  → final status + maintenance exit

api:
- MaintenanceGuard (global, registered before the setup gate): 503
  maintenance_mode while restore-status says running; health endpoints
  and the new public GET /backup/restore-status stay exempt; a stale
  running state (crashed sidecar) unblocks after 30 min
- MaintenanceStateService watches the file and restarts the api after a
  successful restore (fresh caches, migrate-on-start for older dumps);
  main.ts refuses to touch the database while a restore runs — a
  container restarting mid-restore must not race pg_restore with
  migrate deploy
- worker sweeps (conversion, mail outbox, scheduler) catch transient
  database failures instead of dying on an unhandled rejection — the
  restore's connection termination crashed the api in verification
- backup admin endpoints under /admin/system/backup: settings (live
  connection test before save, password write-only into the secret
  store), nextcloud/test, sets (local via the ro backups mount + remote
  via WebDAV), run + restore (type-to-confirm backstop, source
  validation) — commands travel as NOTIFY payloads; audit actions
  backup.settings_changed/run_triggered/restore_requested
- readyz: new warning-level backup_remote check while a target is
  configured (26 h daily / 170 h weekly bound)

collab:
- maintenance listener: on enter, persist + close every live session and
  refuse new connections until exit (failsafe timeout 30 min) — no
  in-memory document may write pre-restore content back afterwards

web:
- Admin → System backup section: status card with remote facts and a
  "Back up now" button, the Nextcloud settings form with test button,
  and the restore picker (local + remote sets, type-to-confirm)
- global maintenance screen: any 503 maintenance_mode flips the SPA to a
  status page polling the exempt endpoint, reloading when the instance
  returns

Verified end-to-end against a live stack (fresh DB, native api + sidecar,
fake WebDAV server): configure → test → manual backup → bundle upload →
readyz/sets/status surfaces → remote restore with maintenance gate,
marker rollback and api restart; suites: shared 21, backup 9, collab 11,
api 58 files green, lint + i18n:check + typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 10:39:18 +02:00
83fa23bbf9 Polish round 2: content footer, dismissable menus, manual versions, substring search, icon actions in settings (M10 follow-up)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m35s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m44s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m36s
CI / Import/export fidelity gate (push) Successful in 46s
- content footer: the collab status is an icon (wifi/off/refresh, localized
  tooltip + visually-hidden text, class/data-status hooks kept for e2e) on
  the left, the legal links right-aligned; read mode drops the editor
  frame and its inner padding, edit mode keeps it
- menus (page overflow, user, notifications bell, pond switcher) close on
  outside click and Escape via a shared useDismissable hook; the bell got
  its missing tooltip
- side panels (labels, history) stack vertically in one column
- edit mode gains a Save-version icon (prompt for the name, POST
  /pages/:id/versions); the history panel lists contributors by display
  name — more than three collapse to two plus an expandable ellipsis
  (PageVersionView.contributors resolved server-side, deleted users drop
  out)
- search finds partial words via a LIKE fallback next to the tsquery
  (FTS matches still rank first; regression-pinned in the db pack), and
  the recent-searches list has a clear button
- pond owners create labels directly in the label picker (plus a
  permanent link to the full manager); add/remove/delete buttons across
  the pond settings (members, access rules, labels, files) and the
  watch/unwatch toggles in pond/user settings are icon buttons now —
  class hooks and accessible names unchanged for the e2e packs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 07:13:34 +02:00
e740ea6c01 Move live presence into the TopBar, signed-in only (#102)
All checks were successful
CD / Build and push images (push) Successful in 1m39s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 3m33s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m31s
CI / Import/export fidelity gate (push) Successful in 46s
- the TopBar registers a presence slot (only rendered for signed-in
  users) next to the page-actions slot; PageEditor portals the
  PresenceStrip into it — behavior unchanged (initials avatars, max 5 +
  overflow, viewer badge, hidden when empty, both view and edit mode)
- pinned guarantee: public.spec asserts the anonymous read path opens no
  /collab websocket and renders no presence data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 04:42:59 +02:00
65f30a5231 Move page actions into the TopBar as self-hosted icon buttons (#101)
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
- lucide-react (MIT, tree-shaken, compiled into the bundle — no runtime
  requests; fonts.spec's off-origin assertion covers the page route)
- page-actions slot: TopBar registers a DOM element via context, the
  active page portals its actions into it, TopBar stays page-agnostic
- PageActions: mode toggle, watch (WatchToggle icon variant), comments
  (unread badge kept), attachments, plugin page tools, labels, history
  as icon buttons with localized aria-label+tooltip (de+en), plus an
  overflow menu for markdown copy/download, docx/odt/pdf export and the
  destructive delete (confirm kept)
- page header keeps only the title; the editor-shell tools row is gone;
  panel state lives in PageEditorPage now
- hamburger/search/bell adopt the same icon set
- e2e: content/export open the overflow menu; class hooks
  (editor-page__mode-toggle, editor-shell__*-toggle,
  editor-page__labels-toggle, editor-page__export) kept stable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 04:39:42 +02:00
9674c0bae2 Batch notifications into localized e-mail digests with unsubscribe (#95)
Some checks failed
CI / Lint, typecheck, test (push) Successful in 3m41s
CD / Build and push images (push) Successful in 3m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m53s
CI / Import/export fidelity gate (push) Has been skipped
New per-user digestFrequency (hourly default | daily | off) on the
profile and in the settings UI. A scheduler job (15 min cadence) mails a
user once their oldest unread, unmailed notification exceeds the cadence
window: one localized mail per batch, grouped per pond then per page
with actor names and change/comment counts, enqueued through the mail
outbox. Sending marks the batch mailed — never read — and re-checks page
read permission per entry at send time; entries the user can no longer
read are dropped from the mail but still marked handled, so revoked
content cannot queue forever. Every mail carries a signed, single-purpose
unsubscribe link: it only flips the setting to off, renders a session-free
confirmation page, and sets no cookie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 23:37:26 +02:00
67fb01fe2b Notify watchers about page changes and comments, with an in-app center (#94)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m26s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m49s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m27s
CI / Import/export fidelity gate (push) Successful in 46s
New notifications table (payload denormalized for join-free rendering;
mailed_at already prepares the #95 digests). Generation fans page events
out to page and pond watchers, excluding the actors, and re-checks page
read permission per watcher at delivery time — a revoked watcher gets
nothing. Sources: named version snapshots (api), new comments (api), and
the collab server's automatic session-close snapshots — announced over a
new pg NOTIFY channel (the reverse of the established api→collab bus)
consumed by a dedicated LISTEN client in the api, since the collab server
has no permission resolution of its own. API: paginated list (unread
first via nulls-first ordering), mark read, mark all read. UI: bell with
unread badge in the top bar (30 s polling, no push in v1) and a dropdown
whose entries navigate and mark themselves read; comment notifications
deep-link with ?comments=1, which now opens the comments panel on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 23:20:25 +02:00
f4f27cbe78 Add watches: follow pages and ponds with auto-watch preferences (#93)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m23s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m28s
CI / Import/export fidelity gate (push) Successful in 46s
New watches table (polymorphic target, unique per user+target; page purge
removes its rows via the trash service, and the list endpoint drops
targets the user can no longer read). Endpoints: idempotent PUT/DELETE
/watches/{page|pond}/:id gated by read access (404 hides the target),
GET state for the header toggles, and GET /users/me/watches resolving
names and links. Auto-watch hooks: creating a page and commenting
subscribe the actor, each behind a new user preference
(autoWatchOwnPages / autoWatchOnComment, default on) editable via the
profile PATCH and surfaced as checkboxes in the settings. UI: watch
toggle on the page header and the pond settings header, watch list with
unwatch in the account settings; new watches i18n namespace (de+en).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 22:59:11 +02:00