Commit Graph

71 Commits

Author SHA1 Message Date
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
e54aaf76f9 Add the comments panel, unread badge, and comment-policy setting (#92)
Some checks failed
CI / Lint, typecheck, test (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m43s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Failing after 3m1s
CI / Import/export fidelity gate (push) Has been skipped
New comments panel on the page (toggle next to attachments, unread badge
counting comments newer than the last localStorage-recorded visit):
threaded display with relative times and author names, a Markdown
composer with hints, edit/delete for authors, resolve moving threads
into a collapsed resolved <details> section with reopen, and a
permission-aware composer — hidden with a hint when the pond's policy
bars the viewer (readers always see the discussion). The pond settings
page gains the "who may comment" select. Fixes PondsService.update
silently dropping commentPolicy from the settings merge (found by the
new two-user Playwright pack; the DB test now exercises the real pond
PATCH). New comments i18n namespace (de+en); the pack runs as its own
CI step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 22:07:16 +02:00
c8aac13dfb Add Site-Admin system panel with persistent audit trail (#86)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m14s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s
New /admin/system panel (operations.md §Maintenance jobs): the maintenance
job list shows every registered job with truthful last-run data (new
Job.lastDurationMs recorded by the scheduler) and a manual trigger that
respects the run-mutex and is itself audit-logged; a backup card mirrors
the sidecar's status.json including the freshness verdict; an audit-log
viewer filters by actor, action, and time range with pagination; and a
storage overview lists the largest ponds. Auth events and admin actions
(grants, members, user/quota admin, plugins, settings, setup) now land in
a new audit_log table through a central AuditService — which keeps
emitting the established stdout log line — while content activity stays
log-only by design. All endpoints are Site-Admin-only; covered by API DB
tests and a Playwright pack in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 20:03:05 +02:00
fd2bdb3fb8 Add instance legal pages with public rendering and footer links (#82)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m9s
CI / Import/export fidelity gate (push) Successful in 45s
Imprint and privacy policy are two new Markdown instance settings
(legal.imprint, legal.privacyPolicy), edited by Site Admins in a new
"Legal pages" admin section with a toggleable rendered preview. The
preview uses the same shared pipeline the server renders with
(markdown → schema doc → escaped HTML), so stored markup can never
smuggle script to visitors.

The pages render publicly at /legal/imprint and /legal/privacy — as an
SPA route plus, like #56, a self-contained server-rendered HTML
document under /api/v1/legal/:kind. The endpoints are setup-exempt:
legal information stays reachable even while the first-run wizard is
pending. Unconfigured pages show a localized notice instead of 404ing,
and Site Admins additionally get a warning banner linking to the
settings. A new footer with both links appears on every SPA view
(editor, auth screens, public pages) and in the server-rendered
documents, whose shared shell moved to public/html-shell.ts and now
renders its chrome in the instance default locale (ADR 0012).

docs/self-hosting/legal-template.md ships imprint and privacy-policy
templates in English and German whose sections mirror Dorfteich's
actual processing activities (accounts, sessions, rate-limit IPs,
proxy logs, transactional mail, content, export, deletion, no
third-party requests), with a review checklist tied to security.md
§Privacy.

New `legal` i18n namespace (de+en); api and web e2e coverage including
a new CI legal pack (footer navigation, notice vs. admin banner, and
the admin form publishing a text end to end).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 16:40:04 +02:00
0003063c39 Add pageTool plugins with toc and page-index references (#77)
All checks were successful
CI / Auth e2e pack (push) Successful in 4m37s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Lint, typecheck, test (push) Successful in 2m54s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m8s
CD / Promote to Int (push) Successful in 9s
The read-only widget surface over page/pond data (ADR 0008 extension
point `pageTool`):

- Host: PageToolsPanel lists the pond's active pageTool surfaces behind
  disclosures — each sandbox iframe mounts lazily on first open and tears
  down on close. The same surfaces are insertable as plugin_block embeds
  (#76's insert picker now offers pageTool points too; the sandbox drives
  both through the same render lifecycle).
- New `ui.scrollToHeading(headingId)` capability: outline ids are derived
  from the doc and never stamped into the DOM, so the host resolves the id
  to its heading position via the shared extractOutline and scrolls the
  matching rendered heading.
- `readPond.listPages` now carries label *names* per summary
  (PagesService.pluginPageSummaries) — the page-index filter chips work on
  data the viewer could resolve anyway; per-page permission filtering
  stays in the service as before.
- Reference plugins packages/plugins/toc and packages/plugins/page-index:
  real SDK consumers (createPlugin + windowTransport), bundled with
  esbuild into the package ZIP; i18n de/en is inlined at build time — the
  sandbox CSP forbids runtime fetches, the i18n/ files stay the single
  source. The toc re-fetches its outline on a slow poll, so live heading
  edits appear once the collab server has re-derived the content cache.
- e2e page-tools.spec.ts covers the acceptance criteria: live outline
  updates after the persistence debounce, heading click scrolls, embedded
  page-index navigates via ui.openPage, and a label-restricted reader
  never sees the denied page in the index.
- CI: the auth-e2e job now runs the section-styles (missed in #75),
  plugin-blocks, and page-tools packs, with login-rate-limit resets.
- plugins.e2e.db.test clears the plugin registry up front: a local dev DB
  is shared with the e2e stack, whose installed real `toc` would otherwise
  collide with the fixture of the same id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 13:27:30 +02:00
923532f5f7 Add block plugins: plugin_block node with sandboxed rendering and editing (#76)
All checks were successful
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 3m56s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Lint, typecheck, test (push) Successful in 2m53s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Deploy to Test (push) Successful in 12s
The powerful end of the plugin spectrum (ADR 0008 extension point `block`):

- Shared schema: the reserved `plugin_block` node — a block atom carrying
  pluginId, blockType, and the block data as a JSON object. Its DOM shape
  round-trips the full state in data attributes (clipboard-safe), markdown
  maps to a reserved fence (```dorfteich-plugin <plugin>/<type> + data
  JSON body, fence-escalated when the payload contains backticks), and the
  content-cache HTML renders a data-carrying neutral placeholder until the
  export fallbacks land (#79).
- Editor: a React NodeView hosts the #73 sandbox — render lifecycle on
  mount, an edit affordance switching the frame to the plugin's edit mode,
  and the blockData capability persisting through node attrs (a normal
  editor transaction, so Yjs replicates it; writes are refused on read-only
  editors, and the plugin's own attr echo is suppressed so its edit UI
  never resets mid-typing). Collaborator changes re-invoke the current
  lifecycle, keeping frames live. The page surface (ids, openPage) flows
  through a React context like the wikilink pattern; the toolbar gets an
  insert picker fed from the active code plugins' block extension points.
- Fallback: GET /plugins/:id/fallback resolves the manifest fallback from
  the stored snapshot — it survives uninstall as a tombstone, image
  fallbacks degrade to neutral once assets are gone. Signed-in only.
- e2e plugin-blocks.spec.ts covers all four acceptance criteria: insert →
  edit → reload round-trip, live two-user collab, disable → fallback →
  re-enable without document mutation, and copy/paste within and across
  pages (the markdown clipboard carries the reserved fence).

getBlock (cross-page block embedding) stays deferred as in #74: the
schema has no per-block ids yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 12:45:14 +02:00
e32f961047 Complete section-style plugins: CSS gate, injection, picker, export (#75)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m54s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 3m57s
CI / Import/export fidelity gate (push) Successful in 43s
Second half of #75 on top of the section node (2e96173/784f21d):

- Install gate for section_style CSS (plugin-css.ts): every rule must be
  scoped under one of the plugin's own .dt-style-<pluginId>-<styleId>
  classes (enforced, not rewritten — grouping at-rules checked inside,
  @font-face/@keyframes exempt, statement at-rules rejected); positioning
  out of the content flow (anything but static/relative) is rejected as an
  overlay vector; "</style" is rejected as a breakout vector for inlined
  embedding. Hostile fixtures from the acceptance list are pinned in
  plugin-css.test.ts.
- Web: usePondPlugins loads the pond's active plugins once per visit;
  SectionStyleSheets links each active style plugin's immutable
  styles.css; SectionStyleMenu (toolbar) wraps/restyles/unwraps with a
  picker fed from the plugins' i18n titles. Sections show a faint dashed
  hint while editing so unstyled (plugin-disabled) sections stay findable.
- PDF export: PluginsService.sectionStyleCssForPond inlines the pond's
  active section-style CSS into the Gotenberg HTML, so styled sections
  survive the network-isolated render; covered in export.service.db.test.
- Reference plugin packages/plugins/section-styles-basic (callout, info,
  warning, colored-box; theme-neutral semi-transparent backgrounds), a
  workspace package whose tests validate it against the SDK schema and
  whose real files run through the api install gate.
- e2e section-styles.spec.ts: install → wrap → computed background in edit
  and read mode → unwrap → neutral fallback after disabling the plugin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 11:43:36 +02:00
46292c7447 Add plugin administration UI: instance modes and pond activation (#72)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m56s
CD / Build and push images (push) Successful in 3m11s
CD / Deploy to Test (push) Successful in 8s
CI / Auth e2e pack (push) Successful in 4m12s
CD / Smoke tests against Test (push) Successful in 1m6s
CD / Promote to Int (push) Successful in 13s
CI / Import/export fidelity gate (push) Successful in 42s
CI / Build container images (push) Has been skipped
- api: PATCH /admin/plugins/:id/mode (Site Admin) switches
  disabled/optional/required; new PluginPondController exposes
  GET /ponds/:id/plugins (effective list: required + optional-enabled,
  pond read access — the SPA loads it per pond), GET .../plugins/settings
  and PUT .../plugins/:pluginId (Pond Admin) to toggle optional plugins.
  Toggling a non-optional plugin is refused (plugin_not_optional).
  Install/uninstall/mode/toggle are audit-logged.
- web: PluginManager in the admin area lists installed plugins with their
  declared permissions surfaced prominently (security.md), an upload
  control that shows validation errors, a mode switch with an impact
  hint, and a link to the sandbox preview. PondPluginSettings adds a
  per-pond optional-plugin toggle section to pond settings.
- shared: PondPluginSetting, mode/toggle input schemas, plugin_not_optional
  error code + de/en messages, plugins i18n (admin/mode/pond).
- tests: api db test covers mode switching, per-pond activation, the
  required-everywhere and disabled-nowhere propagation, and the
  not-optional guard; e2e plugin-admin pack drives the admin list,
  permission display, mode switch, and pond toggle end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 09:32:16 +02:00
0875e2a087 Add the sandbox host runtime for plugin iframes (#73)
All checks were successful
CI / Build container images (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 2m57s
CI / Import/export fidelity gate (push) Successful in 46s
CD / Build and push images (push) Successful in 3m16s
CD / Deploy to Test (push) Successful in 8s
CI / Auth e2e pack (push) Successful in 4m8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
Implements the security core of the plugin system: code-plugin surfaces
run in opaque-origin iframes (sandbox="allow-scripts", never
allow-same-origin) with a capability-filtered RPC bridge.

- api: serve a per-plugin sandbox frame document at
  /plugins/:id/:version/frame with a CSP that pins every load to the
  plugin's own asset path (built from APP_BASE_URL, not the request Host,
  so a Host-rewriting proxy cannot break it) and forbids network access
  (connect-src 'none'). Plugin assets get Access-Control-Allow-Origin: *
  so the null-origin frame can load its own module bundle.
- web: sandbox-host creates the frame, wires the SDK host bridge over a
  source-filtered postMessage transport, drives render under a 5 s
  deadline (hung/failed plugin -> placeholder, never a frozen page), and
  tears down on unmount. PluginFrame/PluginPreviewPage surface it; the
  built-in ui.resize handler clamps plugin-requested heights.
- plugin-sdk: host bridge reports gate violations via onViolation and
  registers a gated handler for every v1 method, so an undeclared
  capability is rejected with capability_not_permitted (not
  unknown_method).
- tests: SDK gate unit test; web sandbox unit tests (opaque origin,
  source filtering, timeout); and the e2e security pack with a permanent
  malicious fixture plugin proving no escape (DOM/cookies/storage/fetch/
  undeclared capability all blocked) plus well-behaved and hung cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 09:16:30 +02:00
462eca9699 Add self-service GDPR data export (#68)
All checks were successful
CD / Build and push images (push) Successful in 10m39s
CI / Lint, typecheck, test (push) Successful in 3m12s
CI / Auth e2e pack (push) Successful in 4m9s
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
A signed-in account can export all of its own data — profile, a list of
its memberships/grants, and the Markdown of its personal pond plus the
shared ponds it owns — as one ZIP. Foreign content never appears: only
owned ponds are bundled and the per-page read filter (reused from #65)
runs for each.

- Reuse the conversion-job queue as the async carrier: a `data_export`
  job whose worker branch resolves DataExportService via a token (no DI
  cycle), builds the ZIP, and stores it with an `expiresAt`. The download
  link 404s past expiry and an hourly scheduled purge drops the bytes
  (data minimization, security.md §Privacy).
- Extract ExportService.appendPondMarkdown so the pond ZIP (#65) and the
  data export share one read-filtered pond archiver.
- Rate-limit requests per account (RateLimitService); POST
  /users/me/data-export enqueues, GET /jobs/:id(/result) poll/download.
- Settings UI "Export my data" (de+en); web share pollJob/downloadJobResult
  between the document and data export hooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 13:01:51 +02:00
f500198c5d Add per-pond fonts: catalog, build, application, and admin UI (#66)
All checks were successful
CD / Build and push images (push) Successful in 3m24s
CI / Lint, typecheck, test (push) Successful in 3m6s
CI / Auth e2e pack (push) Successful in 4m8s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Self-hosted Google Fonts with per-pond selection (ADR 0016), the GDPR
"zero external requests" posture (security.md, CSP `font-src 'self'`).

- Catalog: a curated 15-family OFL/Apache list in shared (family, weights,
  category, license, google-webfonts-helper id). `deploy/fonts/build-fonts.mjs`
  validates every entry has license info (fails the build otherwise),
  downloads the WOFF2 weights into apps/web/public/fonts/ (gitignored), and
  generates the @font-face stylesheet — run at image build time from the web
  Dockerfile (with retries), never from a visitor's browser.
- Application: PondFontScope sets --font-heading/body/mono (+ weights) from
  pond.settings.fonts on the editor + read view; the existing global CSS
  already reads those custom properties, so headings/body/code re-resolve to
  the pond's fonts. A pond with no settings arrives with the defaulted values
  (Roboto 400 / Roboto 200 / Fira Code), so the vision defaults always render.
- Admin UI: pond-settings 'Appearance' section — three slots (family + weight)
  with a live preview, Pond-Admin-gated (fonts added to updatePondInputSchema
  and merged in PondsService.update); a font catalog attribution page (/fonts)
  listing families and licenses. New `font` i18n namespace (de+en).
- CSP: strict Content-Security-Policy in nginx.conf (default-src 'self';
  font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; …) —
  the app's scripts are all external files, inline styles cover CSS variables.
- Tests: shared catalog-integrity unit test (the invariant the build enforces);
  e2e fonts pack — no request leaves the origin when rendering a pond (the GDPR
  network assertion), a font choice applies to a page and persists, and a pond
  without settings renders the defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 11:16:29 +02:00
699c003d04 Add pond ZIP + per-page docx/odt export (#65)
All checks were successful
CD / Build and push images (push) Successful in 3m57s
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Auth e2e pack (push) Successful in 3m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
Two export paths, both permission-aware (permissions.md):

- `GET /ponds/:id/export/markdown` streams a ZIP of the pond's readable
  pages as Markdown (one `<slug>.md` per page, a `media/` directory,
  wikilinks rewritten to relative `[text](slug.md)` links, image sources to
  `media/<id>.<ext>`). The `reader` guard is "may see the pond"; the service
  filters to the pages the requester may actually read, so a label-restricted
  reader gets only their slice. Media is appended as read streams and pages as
  small strings, so memory stays bounded for a large pond (500-page test).
- `POST /pages/:id/export {format: docx|odt}` enqueues a `markdown → pandoc →
  file` conversion job (the #62 queue): embedded images are inlined as data
  URIs so the sidecar embeds them, wikilinks flatten to text. The client polls
  `GET /jobs/:id` and downloads `GET /jobs/:id/result`.

Frontend: office-export buttons in the page menu (`.docx`/`.odt` run the job
and download the result; PDF is a disabled placeholder for Gotenberg, #67) and
a "Download pond as ZIP" link in pond settings. New `export` i18n namespace
(de+en). Markdown copy/download stay as-is (#30).

Robustness: the pond ZIP skips an attachment whose bytes are missing on disk
(data drift) rather than letting an unhandled read-stream error crash the api;
`FileStorageService.exists` gates inclusion, with a defensive stream error
handler. The per-page export drops an unreadable image the same way.

- shared: EXPORT_FORMATS + pageExportInputSchema; export-markdown transform
  helpers (image/wikilink rewrites, MIME→extension).
- deps: archiver (streaming ZIP; v7 for CommonJS compat), fflate (dev, reads
  ZIPs in tests).
- tests: export-markdown unit + export.service.db (ZIP contents & relative
  links, label-restricted omission, docx job with inlined images, 500-page
  streaming, missing-media skip); e2e export pack (ZIP download; `.docx`
  self-skips without a pandoc sidecar, as in the import pack, #64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 10:31:19 +02:00
30891f99cf Add non-image attachments with allowlist, SVG policy, and file managers (#61)
All checks were successful
CD / Build and push images (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 2m46s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 12s
Extend uploads (#27, ADR 0011) beyond images to a configurable general
attachment allowlist, plus the page attachments section and the Pond Admin
file manager.

Backend:
- Two instance settings: `upload.allowedExtensions` (lowercase, dot-stripped,
  images always allowed regardless) and `upload.svgPolicy` (reject | sanitize).
- FilesService.resolveUpload: raster images still decided by magic bytes; SVG
  is sanitized with DOMPurify (scripts, event handlers, foreignObject stripped)
  or rejected per policy; everything else is admitted only if its extension is
  on the allowlist. A sanitized SVG's stored bytes are re-accounted so
  pond_usage matches disk.
- Downloads set `Content-Disposition: attachment` for every non-raster type
  (office files, PDFs, SVG) with `nosniff`, so they can never execute inline;
  raster images stay inline for page embeds.
- New endpoints: `GET /ponds/:id/files` (pond_admin: all files + usage + orphan
  flag), `POST /pages/:id/files` and `GET /pages/:id/files` (page-write/read:
  the attachments section). New error code `upload_type_not_allowed` (de+en).

Frontend:
- Page attachments section (AttachmentsPanel): upload, list with type glyph,
  size, and uploader, insert-as-link into the document (an internal media link
  that downloads, never renders inline), and delete. Toggled in the editor.
- Pond file manager (PondFileManager) in pond settings for Pond Admins: every
  file with its referencing page (or an orphan flag) and storage usage.
- Admin uploads settings form (allowlist + SVG policy). New `files` i18n
  namespace (de+en).

Tests:
- files.e2e.db.test.ts: allowlisted non-image accepted and served as a
  download; disallowed extension rejected; renamed-.html-as-.png still fails;
  SVG sanitized (scripts/handlers stripped) and reject-mode rejects; page
  attachment listing; pond file manager usage/orphan; non-admin denied.
- New e2e pack apps/web/e2e/attachments.spec.ts (+ CI step): upload → list →
  insert link (verified attachment disposition + nosniff), disallowed-type
  error, pond file manager usage/orphan.

Local: typecheck, lint, i18n:check, build all green; api-db 184, shared 121,
web 50; attachments pack 3/3, members 3/3, content 5/5. Adds dompurify + jsdom
to the api for server-side SVG sanitization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 02:52:40 +02:00
42e97b9df2 Add Site-Admin user management (#59)
Some checks failed
CD / Build and push images (push) Successful in 3m12s
CI / Lint, typecheck, test (push) Failing after 2m29s
CI / Auth e2e pack (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
Instance operators get basic user administration for support, abuse handling,
and GDPR groundwork (security.md §Privacy).

- api `admin/`: Site-Admin-gated `/admin/users` — a searchable, paginated list
  (username, e-mail, status, role, pond count, last login) plus lifecycle
  actions: disable/enable (a disabled user is logged out everywhere and login
  is refused with the distinct `account_disabled`), resend verification, delete,
  and grant/revoke Site Admin. Guards: you cannot act on your own account
  (`cannot_modify_self`) and the last Site Admin cannot be dropped
  (`last_site_admin`). Every action is audit-logged with the actor.
- `PseudonymizationService`: account deletion scrubs the PII, removes all login
  identities + sessions, and trashes the personal pond — the kept row is what
  authorship references, so shared content the user authored shows as "Deleted
  user" (no orphaned/cascaded content).
- web: the Admin area gains a 'Users' surface — search, pagination, and the
  actions (destructive ones behind an inline two-step confirm; self-actions
  hidden). New `users` i18n namespace (de+en).
- tests: `user-admin.e2e.db.test.ts` (disable → logout + login blocked; delete
  → pseudonymized authorship + personal pond trashed + credentials gone; last
  Site Admin and self protected; Site-Admin gating); a non-destructive browser
  `admin-users` pack proving disable-in-UI blocks login and enable restores it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 00:31:41 +02:00
6d3db7db38 Add Site-Admin quota override management UI (#58)
All checks were successful
CD / Build and push images (push) Successful in 3m17s
CI / Lint, typecheck, test (push) Successful in 2m34s
CI / Auth e2e pack (push) Successful in 3m27s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
Site Admins tune quotas per user and per pond on the three-level ladder
(pond override → user override → instance default, data-model.md §Quotas).

- api `admin/`: a `QuotaAdminService` + Site-Admin-gated endpoints under
  `/admin/quotas` — look up a user (username/e-mail) or pond (slug), list every
  quota's override / instance default / effective value (resolved through the
  existing QuotaService, the single consumption path, so a change takes effect
  immediately) plus current usage, and set/clear a per-subject override. Every
  change is audit-logged. A pond's effective values resolve on its own override
  then its owner's, matching the consumption checks.
- web: the Admin area gains a 'Quotas' surface — the instance defaults move
  into a proper number-input form (was raw settings, #19), and a per-subject
  panel looks a user/pond up, shows the ladder with usage, flags subjects over
  their effective limit, and sets/clears overrides. New `quotas` i18n
  namespace (de+en).
- tests: `quota-admin.e2e.db.test.ts` (override → effective changes at once and
  QuotaService sees it; clear → falls back to the default; lookup; Site-Admin
  gating); a browser `admin-quotas` pack proving an override raised in the UI
  immediately lets a user create another shared pond (issue #22 consumption).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 00:14:28 +02:00
f5f1310eb2 Add effective-permissions inspector (#57)
All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s
Pond Admins can answer "what can X actually see/do here?" — load-bearing for
trust in the grant system (permissions.md §UI obligations).

- shared: `explainPageCapability` — the resolver's decision chain (deciding
  level + the single deciding grant), sharing one code path with the boolean
  `resolvePageCapability` (now a thin wrapper), so the trace can never diverge
  from real access. Unit-tested against the permissions.md worked examples.
- api: `GET /ponds/:id/effective-permissions?subjectType=&subjectId=&pageId=`
  (Pond-Admin-gated, one pond only) resolves as the chosen subject (a user with
  their real Site-Admin flag, all signed-in users, or the public), optionally
  against a page, and returns the read + write outcome with the deciding rule
  enriched with subject/scope names.
- web: `EffectivePermissionsInspector` in Pond Settings — pick a subject and
  optionally a page → see the resolved read/edit verdict, the level that
  decided it, and the deciding rule spelled out as a de/en sentence (reusing
  the #55 sentence renderer). Hidden from non-admins.
- tests: explain-mode unit tests (worked examples + trace-matches-boolean);
  `inspector.e2e.db.test.ts` (deciding rule on a labelled page, pond-level base
  capability, public default-closed, Pond-Admin gating); a browser assertion in
  the access-rules pack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 00:00:27 +02:00