Commit Graph

104 Commits

Author SHA1 Message Date
75ec7f6006 CSP: data:-Fonts erlauben (eingebettete Fonts in Excalidraw-SVGs)
Excalidraw bettet beim Speichern die verwendeten Schrift-Subsets als
data:-URIs ins Snapshot-SVG ein. Die Seiten-CSP (nginx) und die
Plugin-Frame-CSP erlaubten aber nur `font-src 'self'` bzw. den
Asset-Pfad — die Handschrift fiel in der öffentlichen Ansicht und im
Snapshot-Render auf Serifen zurück (auf Prod an der ersten Demo-Skizze
sichtbar). Fix: `data:` in beiden font-src-Direktiven. data:-Fonts
lösen keinerlei Netzwerk-Request aus — die Zero-Third-Party-Garantie
(security.md) bleibt unberührt; fonts.ts-Kommentar entsprechend
präzisiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 22:27:17 +02:00
5255cdce06 Fix latenten Flake in links.service.db.test (Phantom-Sortierung)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m24s
CI / Build container images (pull_request) Successful in 3m45s
CI / Auth e2e pack (pull_request) Successful in 6m48s
CI / Import/export fidelity gate (pull_request) Successful in 50s
CI / Lint, typecheck, test (push) Successful in 4m31s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 6m45s
CI / Import/export fidelity gate (push) Successful in 54s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
pondGraph-Test sortierte die EMPFANGENEN Phantom-Slugs, verglich aber
gegen ein UNsortiertes Literal. Da beide Slugs (ghost-<sfx>,
ghost-secret-<sfx>) den Zufalls-Suffix teilen, kippt ihre Sortierreihen-
folge auf ~1/5 der Suffixe (wenn sfx[0] > 's') → nicht-deterministischer
Fehlschlag. In CI-Lauf 372 traf es zu (Suffix „vpxwe…"). Fix: beide
Seiten sortieren. Vorbestehender Bug, unabhängig von M17–M19.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 06:12:55 +02:00
d5b895ada2 #135 Fix: /read-Route deklariert Zugriffsregel explizit (@AuthenticatedOnly)
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 4m20s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
route-permissions.e2e.db.test.ts (#52) verlangt, dass JEDE Route ihre
Zugriffsregel explizit deklariert (PERMISSION_KEY, @Public oder
SiteAdminGuard). Der neue GET /read/:pond/:slug hatte keinen Decorator
(verließ sich auf den Default-Guard) → Coverage-Test rot in CI.
@AuthenticatedOnly() ergänzt (Session erforderlich; per-Page-Recht prüft
weiterhin der Service via resolve→canAccessPage→404).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 06:01:45 +02:00
15376d4ac2 #135 Seiten-Einbettung ![[Seite]] (Transklusion) im Lesemodus
Obsidian-Syntax `![[slug]]` (optional `![[slug|Anzeige]]`) als Seiten-
Einbettung. Im Lese- und öffentlichen Modus wird der Inhalt der Zielseite
inline gerendert; im Editier-Modus zeigt die NodeView eine Platzhalter-
Karte (Titel + Öffnen-Link).

Shared (Vorbild plugin_block):
- Neuer Block-Atom-Node `transclusion` (targetSlug + optional displayText).
- Markdown: Block-Regel für eine reine `![[…]]`-Zeile (vor `paragraph`
  registriert; mitten im Absatz greift sie bewusst nicht), Token→Node-
  Mapping, Serializer — Round-Trip stabil.
- html.ts: Platzhalter `<div class="dt-transclusion" data-transclusion>`.
- extractWikilinkSlugs erfasst jetzt auch Transklusionen → Einbettung
  zählt als Backlink/Graph-Kante.

Backend (zentraler Render-Pfad):
- PublicService expandiert Platzhalter zur gerenderten Body-HTML der
  Zielseite: SELBER Pond, read-permission-geprüft, Tiefe ≤2 + Zyklen-
  Guard (visited); Fehlend/unlesbar/zyklisch → neutraler Wikilink. Medien
  werden EINMAL über den ganzen Baum aufgelöst (kein Doppel-Processing).
- Neuer authentifizierter Endpoint GET /read/:pondSlug/:pageSlug (nicht
  @Public) liefert dieselbe gerenderte HTML — für die NodeView im
  authentifizierten Lesemodus, auch bei nicht-öffentlichen Seiten.

Web:
- NodeView `transclusion.tsx`: Editier-Modus → Karte; Lesemodus → holt
  /read/:pond/:slug und rendert den (server-sanitisierten) Inhalt inline.
- WikilinkAutocomplete unterstützt `![[` → fügt einen Transklusions-Block
  ein (statt Wikilink).
- CSS für Karte (.dt-transclusion-card) und Embed (.dt-embed), i18n de+en.

Tests: shared Round-Trip-Unit (5), public-DB-Test um Embed-Expansion
(zyklus-sicher, Fehlend→Link) erweitert — grün. typecheck/lint/i18n grün.
Visuelle Editor-Verifikation folgt auf dem Test-Stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 04:01:18 +02:00
a84e9880bd #133 Fix: Typfehler im public-Kommentar-Test (noUncheckedIndexedAccess)
body.threads[0] ist unter noUncheckedIndexedAccess möglicherweise
undefined; per Destrukturierung + Non-null-Assertion nach dem
toHaveLength(1)-Check geglättet. (Der Fehler rutschte durch, weil der
#133-Commit nach dem Nachtragen des Tests nicht erneut getypecheckt
wurde.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 03:54:30 +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
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
1b9dd7d475 Give the zip-bomb rejection test headroom against loaded runners
All checks were successful
CD / Build and push images (push) Successful in 2m55s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CI / Lint, typecheck, test (push) Successful in 4m32s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 6m27s
CI / Import/export fidelity gate (push) Successful in 50s
Release / Build release images and notes (push) Successful in 1m9s
Release / Release-candidate operations QA (push) Successful in 42s
Prod deploy / Deploy the released images to Prod (push) Successful in 17s
Compressing and inflating the 257-MiB zero buffer is CPU-bound and
exceeded vitest's 5 s default on a busy CI runner (5.4 s — the M15 push
ran CI and CD concurrently). Flaky tests are defects (ADR 0014): the
test gets an explicit 30 s timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-15 13:45:47 +02:00
78a913c47b Vault import: decode ZIP names as UTF-8 and NFC-normalize them
Some checks failed
CD / Build and push images (push) Successful in 4m59s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Failing after 5m36s
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 1m21s
CD / Promote to Int (push) Successful in 11s
Real vault ZIPs broke umlauts in page titles ("Fußball zum Götzen" →
mojibake, slug fua-ball…goi-tzen): fflate honors only the ZIP UTF-8
flag, which common archivers omit, and decodes unflagged names as
Latin-1. That decoding is byte-lossless, so parseVaultZip now re-reads
any name whose chars all fit one byte as UTF-8 (a strict decoder —
genuine Latin-1 and flag-decoded precomposed chars fall back
unchanged), then NFC-normalizes: macOS zips store umlauts decomposed,
which silently broke slugify's ä→ae digraphs, wikilink matching, and
duplicate-basename detection. slugify itself also precomposes first as
defense in depth for NFD input from other paths.

Unit tests pin both cases: a hand-patched ZIP whose UTF-8 name bytes
carry no UTF-8 flag, and an NFD-named note that must come out
precomposed with an ueber- slug.

Pages already imported with garbled titles stay as they are — delete
the imported subtree and re-import after this lands (or rename by
hand).

Fixes #127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-15 13:24:58 +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
8ae010218e Obsidian vault import: endpoint, job orchestration, rollback (#117)
Some checks failed
CD / Build and push images (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m21s
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 1m22s
CD / Promote to Int (push) Successful in 11s
POST /ponds/:pondId/import/vault (pond-admin-gated; a vault import
creates a subtree, uploads files, and creates labels — administration,
not everyday editing) takes the ZIP plus a JSON options field
{parentPageId?, labelIds?, frontmatterMode}. The archive is parsed at
enqueue for fast 400s; the job (new kind import_vault, riding the
existing isImportKind worker routing) re-parses and runs the #116
transform, then: containers top-down → notes (asset placeholders →
uploaded pond files; non-images become page attachments) → tags to
labels (nested tags build a label hierarchy via LabelsService, so
locking and cache invalidation apply) plus the dialog labels.

All-or-nothing: any failure hard-deletes the created pages (children
first) and removes the stored files (quota restored), then surfaces as
import_vault_invalid_zip / import_vault_too_large / quota_exceeded /
conversion_failed — and makes the worker's retry policy safe.

Supporting changes:
- conversion_jobs gains a nullable options jsonb column; enqueue takes
  kind-specific options and a maxInputBytes override (the 25 MiB
  default protects the pandoc sidecar, which a vault never touches —
  vaults use the 64 MiB upload limit).
- insertPage accepts a pre-reserved slug (the batch reserves all slugs
  up front against pond ∪ batch).
- NEW: pages born with content seed their outgoing page_links rows
  (deriveContent now returns wikilinkSlugs) — imported pages would
  otherwise stay invisible to backlinks and the graph until their
  first collab save. Collab still rewrites the rows on every save, and
  the existing phantom resolution heals batch creation order.

import-vault.e2e.db.test.ts (4 tests, real worker drained): gating +
input rejection, the full fixture import (tree under a mount page,
collision suffixes, link rows incl. phantom, nested tag labels, extra
label everywhere, frontmatter stripped, image embedded + PDF attached),
complete quota rollback, and a clean re-import with fresh suffixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:13:11 +02:00
269b36c761 Fix the gates the #116 commit skipped past
Some checks failed
CD / Build and push images (push) Successful in 2m53s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m11s
CI / Lint, typecheck, test (push) Failing after 4m22s
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 / Promote to Int (push) Successful in 11s
The asset type lost its name field during the split into path+extension
(tsc error), and the fixture's .obsidian/app.json needed Prettier's
formatting. Lesson from the /srv move repeated within one day: the gate
chain must gate — never '; echo' past a failing typecheck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:53:11 +02:00
09abda3ada Obsidian vault transform module (#116)
Some checks failed
CI / Lint, typecheck, test (push) Failing after 52s
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) Failing after 1m36s
CD / Deploy to Test (push) Has been skipped
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
Pure functions from vault ZIP to import plan — no DB, no DI:

- parseVaultZip: fflate unzip with the plugin-package protections
  (zip-slip rejection, incremental unpacked ceiling 256 MiB,
  parameterized for tests); dot-directories like .obsidian/ skipped;
  deterministic ordering.
- extractFrontmatter: leading --- block, tags:/tag: in scalar, inline-
  array, and block-list forms; strip mode drops the block, preserve
  re-emits it as a fenced yaml code block.
- extractInlineTags: fence- and inline-code-aware #tag / #nested/tag
  extraction and removal (headings and pure numbers untouched).
- rewriteLinks: [[Name]], [[Name|Display]], [[Name#Heading]] (fragment
  stripped), [[folder/Name]] (path match beats basename) → the FINAL
  slug with the human name as display; unresolvable → slugified
  phantom; ![[img]] and relative ![](path) → vault-asset: placeholders
  the uploader resolves (#117); non-image embeds → italic filename +
  page attachment; SVG deliberately stays an attachment (never inline,
  security.md); note embeds degrade to plain wikilinks.
- planFolders: folder chains merged at the deepest levels to fit
  MAX_PAGE_DEPTH below the mount page (merged titles read c/d).
- planSlugs: -n suffixing against existing ∪ batch; duplicate
  basenames resolve to the lexicographically first vault path.
- planVaultImport ties it together into containers + notes + the
  referenced-asset set.

Fixture vault under fixtures/import/obsidian-vault/ (umlauts,
duplicate basenames, nested tags, deep folders, embeds, code traps);
13 unit tests colocated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:51:31 +02:00
ffcc337ed0 Page-tree parity for the public REST API and MCP (#110)
Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m16s
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 1m19s
CD / Promote to Int (push) Successful in 11s
The slug-based machine surfaces now see and shape the hierarchy:

- REST: page list/detail carry parent (the parent page's slug, nulled
  when the token's user may not read it — same no-leak rule as the
  internal list); create accepts parent; PATCH accepts parent
  (slug nests, null moves to the top level, appended at the end of the
  new sibling group via the new PagesService.moveToEnd). Cycle/depth
  refusals keep their regular error codes. OpenAPI updated.
- MCP: list_pages returns parent, create_page takes an optional parent
  slug, update_page moves with parent (slug|null); tool errors carry
  the api code (page_cycle covered in the e2e pack).
- ZIP export deliberately stays flat — noted in features.md; the
  hierarchy is organizational only.

e2e: REST pack covers nested create, list shape, move/root-move, 409
page_cycle, 404 unknown parent; MCP pack covers nested create, list
parent, and the cycle tool error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:49:52 +02:00
e957c2a28f Pond-wide wikilink graph endpoint (#111)
All checks were successful
CD / Build and push images (push) Successful in 4m0s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Successful in 4m21s
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) Successful in 5m41s
CI / Import/export fidelity gate (push) Successful in 47s
GET /ponds/:pondId/links returns the caller's readable slice of the
wikilink graph in one read: nodes (id, title, slug, labelIds for the
coloring), resolved edges deduplicated per direction (a rename can
leave several slugs pointing at one target), and phantom targets with
their referrer ids. An edge survives only when both endpoints are
readable; a phantom disappears entirely once its last readable referrer
is filtered — a hidden page's existence never leaks through any of the
three collections. Trashed pages and their links are excluded.

Shared PondGraphView types feed the knowledge-graph views (#112/#113).
DB tests cover the owner's full graph, the label-DENY reader slice,
edge dedup, trash exclusion, and labelIds on nodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:21:25 +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
12ff3c099f Delete modes and subtree trash semantics for the page tree (#107)
All checks were successful
CD / Build and push images (push) Successful in 3m59s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m20s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m42s
CI / Import/export fidelity gate (push) Successful in 47s
DELETE /pages/:id?mode=promote|subtree — promote (the default) moves
the page's live children up to its parent; subtree trashes every live
descendant with one timestamp and requires write permission on all of
them (no partial deletes; trash access is write capability, ADR 0013).

Trashed pages keep their parentId. Restore re-attaches to the nearest
live ancestor (else root), which makes restore order-independent:
restoring a parent afterwards never re-claims an already-restored
child. Purge promotes any remaining children to the purged page's
parent; the FK's SetNull stays as backstop only.

tree-trash.e2e.db.test.ts covers promote, subtree + one-timestamp,
the 403 descendant gate (label-DENY editor), order-independent
restore, and child promotion on purge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:05:10 +02:00
eb6b0d5d02 Page hierarchy: parentId, create-under-parent, reparent (#106)
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 3m53s
CD / Deploy to Test (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m16s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Has been cancelled
Pages form a tree via a nullable parent_id self-relation (SetNull
backstop; the real trash/purge semantics follow with #107). Slugs and
URLs stay flat and pond-unique, so moving a page never breaks links.

- Shared: generic parent-id tree helpers in tree.ts (labels re-export
  them; buildLabelTree keeps its name-sorted behavior), MAX_PAGE_DEPTH=6,
  parentId on PageView, createPageInputSchema.parentId (nullish),
  repositionPageInputSchema.parentId (optional; absent = keep parent).
- API: create validates the parent (same pond, live, depth);
  PATCH /pages/:id/position reparents atomically with the placement,
  rejecting cycles (page_cycle) and depth violations
  (page_depth_exceeded); GET /ponds/:id/pages nulls parentId when the
  caller may not read the parent, so hidden page ids never leak.
- New error codes translated de+en; hierarchy.db.test.ts covers create,
  404s, depth, cycle, atomic reparent, and the permission nulling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:57:47 +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
a7760335cb Remove an unused eslint-disable directive in main.ts
Some checks failed
CD / Promote to Int (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Failing after 1m11s
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 2m48s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Has been cancelled
no-console is not enforced for apps/api, so the directive on the
boot-time restore-wait log line was flagged as unused and failed the
lint gate (--report-unused-disable-directives). Keep the console.log
and its rationale as a plain comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 20:45:29 +02:00
97f94f247b draw.io reference plugin: fullscreen editing, inline SVG rendering
All checks were successful
CD / Build and push images (push) Successful in 3m54s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m9s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m36s
CI / Import/export fidelity gate (push) Successful in 47s
A new block plugin bundling the OFFICIAL draw.io editor — nothing ever
loads from diagrams.net; the sandbox CSP pins every request to the
plugin's own version-pinned asset path (zero-external-network verified
live via a request-capture run).

Plugin (packages/plugins/drawio):
- block data { xml, svg }: xml is the draw.io source (document of
  record), svg the rendered snapshot as raw markup — render mode,
  office/PDF exports (the existing fallback renderer already inlines
  data.svg) and the public view all show the diagram without running
  diagram code
- edit mode: snapshot + "edit in fullscreen" (an empty block opens the
  editor immediately); the bundled editor runs in a child iframe of the
  plugin's own assets and speaks draw.io's JSON embed protocol —
  Save & Exit exports xmlsvg, persists { xml, svg } via blockData, and
  drops back to the inline size
- build.mjs fetches the pinned release (v30.3.6) into a gitignored
  vendor/ cache (fonts-build pattern; skipped in CI — plugin.js still
  bundles, the installable ZIP needs a dev machine) and packs a trimmed
  webapp subset: no dev sources, no embed.diagrams.net integrations
  bundle, no standalone viewers, no MathJax/templates/PWA — 27 MiB ZIP,
  85 MiB unpacked, de+en editor languages

Host/SDK extensions (generic, not drawio-specific):
- new ui.enterFullscreen()/exitFullscreen(): the surface's frame becomes
  a viewport-covering overlay — same sandboxed iframe, only geometry
  changes; destroy removes the frame, so a vanished plugin can never
  leave the app covered
- sandbox CSP: connect-src/frame-src now allow the plugin's OWN asset
  path (was 'none') — bundled apps lazy-load their resources and run in
  a child frame, but the api and external hosts stay unreachable; HTML
  assets are served with the same CSP so a packaged page cannot widen
  the rules, and child frames inherit the sandbox attribute
- plugin size limits raised (ZIP 5→64 MiB, unpacked 20→256 MiB) for
  bundled-app plugins; content types for xml/txt/ico assets

Verified end to end against a local stack (9/9): install via dropzone
(85 MiB validation), block insert, fullscreen entry, bundled editor
boots inside the double sandbox (German UI), shape drawn, Save & Exit
persists, snapshot renders inline, survives reload, zero off-origin
requests throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 14:15:30 +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
6d51c0d099 Pin the comments/notifications semantics as a regression pack (#96)
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
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
The permission-matrix pack now covers comments: reading follows page
read, writing follows the pond's commentPolicy, label-restricted editors
cannot see a secret page's thread, all under the 404-vs-403 policy. A
new API-level social pack runs the two-user watch → notify flows: the
watcher is notified, the actor never, read-all sticks server-side, a
revoked watcher receives nothing new, and the digest job runs end to end
through the system panel's manual trigger. The digest mail's structure
is pinned by a normalized vitest snapshot (grouping, counts, actors,
unsubscribe framing) — changing the mail requires an explicit snapshot
update. Both packs ran flaky-free across five consecutive local rounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 00:00:34 +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
4549d6d13f Add threaded page comments: data model, API, and comment policy (#91)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m19s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 4m55s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m5s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s
New comments table (thread via parent_id to the root, optional document
anchor on roots, resolved_at/by; page purge cascades, trash hides) with a
CommentsService enforcing the permission model: reading follows page
read, writing follows the new pond setting commentPolicy (readers |
editors) — 404 hides unreadable pages, 403 marks a failed write policy.
Endpoints: threaded list per page with an open/resolved filter (resolved
threads arrive collapsed by default), create (root or reply — replies
attach to roots only and carry no anchor), edit own, delete own (roots
with replies are admin-only, cascade), resolve/unresolve on roots for
everyone who may comment. Bodies are Markdown rendered through the shared
sanitizing pipeline; smuggled markup arrives as escaped text (fixture
test). The UI lands with #92.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 21:42:29 +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
0ef96147e0 Extend readyz with backup freshness and a degraded status level (#85)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m11s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m44s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m13s
CI / Import/export fidelity gate (push) Successful in 46s
readyz now enumerates database/migrations (hard failures, HTTP 503),
converter/renderer, and a new backup check that reads the sidecar's
status.json from the read-only backups mount and warns when the last
successful backup is older than 26 h. Warning-level checks surface as
overall status "degraded" while staying HTTP 200 — monitors alert on
the body keyword, Docker healthchecks keep using the liveness endpoints
so a degraded instance is never restart-looped. The status.json shape
moved to @dorfteich/shared as the contract between the sidecar and its
readers (#85/#86); deploy/monitoring.md defines the Uptime-Kuma monitor
set per stage. The api image also pre-creates /data/backups node-owned
so the shared backups volume stays writable for the sidecar regardless
of which container initializes it, and the sidecar's scheduler survives
runs that cannot even record their status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 19:02:59 +02:00
8dbff86537 Add backup sidecar: nightly dump, volume archive, prune, status, restore (#83)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m9s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m25s
CI / Import/export fidelity gate (push) Successful in 45s
New apps/backup service (ADR 0015): nightly pg_dump -Fc plus one tar of the
uploads/plugins volumes as a consistent restore set on a new backups volume,
retention prune that never removes the newest complete set, atomic
status.json for the readiness/admin consumers (#85/#86), and a failure mail
sent directly via nodemailer (the api may be the broken part) with de/en
texts in the shared mails catalog. BACKUP_RUN_ONCE=1 gives the on-demand
path; deploy/backup/restore.sh automates the documented restore runbook.
The pure secret-store helpers moved to @dorfteich/shared so the sidecar
resolves the wizard-written SMTP relay exactly like the api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 17:29:15 +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
28aa04d5e4 Add the first-run setup wizard UI (#81)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m6s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m13s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m6s
CI / Import/export fidelity gate (push) Successful in 45s
The SPA now probes GET /setup on boot: while setup is pending it
renders only the wizard at /setup, the login page (to resume a started
wizard as the Site Admin), and a "setup pending" notice on every other
route — without the regular chrome, whose pond/search queries would
all 503. If the probe itself fails (offline reload), the app falls
through to the normal routes. Once completed, /setup just goes home.

The wizard walks six steps against the #80 api: welcome with language
choice (drives i18n immediately and pre-fills the admin/instance
locales), Site Admin account (signs in via the step-1 session cookie),
instance basics, SMTP with live-test-before-save plus an explicit skip,
registration mode, and a summary whose finish unlocks the app
logged-in-ready. Every step validates through the shared Zod schemas
before advancing; entered values live in the parent component, so Back
preserves them, and re-submitting a step on a second forward pass just
overwrites the same settings. A wizard someone else started shows a
sign-in hand-off instead of dead admin-gated steps. New `setup` i18n
namespace in de and en.

Two #80 touch-ups fell out of verifying this end to end: the SMTP
port's NaN case now maps to the translated required-message, and
GET /setup's smtpConfigured uses `||` instead of `??` so the empty
strings compose passes for unset vars fall through to the secret store.

The e2e pack (setup.spec.ts) needs an instance where setup is still
pending, so the CI job provisions a second api + static web against a
virgin database on their own ports and runs the full wizard journey
there, including the failing-relay path and both languages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 16:05:01 +02:00
224ae3e6db Fix boot crash on compose-passed empty setup variables
All checks were successful
CD / Build and push images (push) Successful in 2m35s
CD / Deploy to Test (push) Successful in 8s
CI / Lint, typecheck, test (push) Successful in 3m8s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 4m52s
CI / Import/export fidelity gate (push) Successful in 46s
main.ts parsed process.env raw, so the compose passthroughs
(SETUP_DEFAULT_LOCALE: "") failed the enum parse and crash-looped the
Test api. Route main.ts through the same loadApiEnv as AppConfig, which
overlays the secret store and drops empty strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 15:20:24 +02:00
f0a82bad20 Add the first-run setup wizard API with env-backed secret store (#80)
Some checks failed
CD / Build and push images (push) Successful in 3m16s
CI / Lint, typecheck, test (push) Successful in 3m5s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Failing after 3m35s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m6s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Build container images (push) Has been skipped
When the api runs against a database without the setup.completedAt
marker, a global SetupGuard answers every non-exempt route with 503
setup_required; only /setup/*, health probes, and the session routes
stay reachable. The wizard steps (POST /setup/admin|instance|smtp|
registration|complete) write straight to their production homes; the
Site Admin step signs its creator in, later steps require that session.
Completing sets the marker and locks every step permanently (410, also
across restarts, and not reopenable via PATCH /admin/settings).

SMTP entered in the wizard is verified with a live delivery test first
(failure blocks the step with the transport error as detail) and then
persisted to the new env-backed secret store: a mode-600 dotenv file on
the new `secrets` volume (SECRETS_FILE). Explicit container env always
wins over the store; empty compose-passed strings count as unset. The
mail transport now resolves lazily through SmtpConfigService so wizard
changes apply without a restart.

SETUP_ADMIN_* env pre-seeds the whole wizard at boot for automated
deploys; a backfill migration marks instances that already have a Site
Admin as completed, and seed/vitest global-setup do the same for
fixture databases. The setup e2e suite provisions its own fresh
database (CREATE DATABASE + migrate deploy) per run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 15:10:28 +02:00
9e8ebfe49c Degrade plugin content gracefully in HTML, PDF, and office exports (#79)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m57s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m2s
CI / Import/export fidelity gate (push) Successful in 46s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m10s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m12s
Completes M7: exports and the public read view no longer show raw plugin
placeholders (ADR 0008/0009).

- PluginFallbackRenderer (api): replaces each plugin-block placeholder in
  content-cache HTML with its best static form — the block's stored SVG
  snapshot (block data is author-controlled, so it passes the same
  DOMPurify sanitizer as uploaded SVG files before entering host HTML),
  else the manifest fallback from the stored snapshot (text, or an image
  inlined as a data URI so network-isolated renderers work; tombstone-safe
  for uninstalled plugins), else the literal '[plugin content]' marker.
- Office exports (docx/odt): the export markdown is degraded before
  pandoc — GFM knows neither the dorfteich-plugin fence nor the section
  fenced div, so blocks become their fallback text and sections plain
  quoted blocks (shared replacePluginNodesForExport, AST-level so nesting
  and embedded blocks inside sections survive).
- PDF export applies the HTML fallback pass before building the Gotenberg
  document — resolving the TODO left in #67.
- Public read view: the same fallback pass plus the pond's active
  section-style CSS inlined as a <style> block, so public pages show
  styled sections and static plugin content without any plugin runtime.
- Covered in export.service.db.test (snapshot SVG sanitized — hostile
  <script> stripped; manifest text; tombstone text; quoted sections and
  no fence artifacts in the pandoc input) and shared export-fallbacks
  tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 14:13:09 +02:00
ef1c31dd2c Add the Mermaid reference plugin (#78)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m55s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
CI / Import/export fidelity gate (push) Successful in 45s
CI / Auth e2e pack (push) Successful in 4m50s
The end-to-end proof of the code-block path: packages/plugins/mermaid
bundles the mermaid library (esbuild, ~3.4 MB unpacked — well under the
20 MiB install gate) so diagrams render entirely inside the sandbox; the
frame CSP forbids any network request (pinned by the e2e's off-origin
request assertion).

- Block data is `{ source, svg }`: the source text is the document of
  record, `svg` the last successfully rendered snapshot — persisted
  together on every good preview, so office/PDF exports can show the
  diagram without executing anything (#79).
- Edit mode: source textarea with a debounced live preview and inline
  error display; a failing source still persists (typed text never lost),
  paired with the last good snapshot.
- Render mode: renders the stored source; if that stops rendering, it
  falls back to the stored snapshot with a "stale" note — a bad edit
  never breaks render mode.
- mermaid leaves its scratch element (and, on parse errors, an error SVG)
  on document.body — the render helper removes both, so the surface only
  shows what the plugin inserts.
- e2e mermaid.spec.ts: flowchart renders + survives reload with zero
  off-origin requests, inline syntax errors with intact render mode, and
  a collaborator sees the diagram appear live. Wired into CI.
- seed.ts now heals a missing owner-admin grant on existing personal
  ponds: a dev database shared with the test suites can lose it to a
  cleanup, and the seed's contract is "idempotent", not "first run only".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 13:51:15 +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
48798d4247 Add capability-scoped plugin API endpoints (#74, read surface)
All checks were successful
CI / Import/export fidelity gate (push) Successful in 58s
CI / Build container images (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 3m2s
CD / Build and push images (push) Successful in 3m16s
CI / Auth e2e pack (push) Successful in 4m13s
CD / Smoke tests against Test (push) Successful in 1m6s
CD / Deploy to Test (push) Successful in 9s
CD / Promote to Int (push) Successful in 9s
CI / Warm action cache (push) Successful in 10s
Viewer-scoped data API behind /api/v1/plugin/, backing the SDK's
readCurrentPage and readPond capabilities:
- api: PluginApiController with GET plugin/ponds/:id/pages (listPages),
  plugin/pages/:id/{outline,content,meta}. Each reuses the existing
  @RequiresPondRole/@RequiresPagePermission guards and PagesService — no
  parallel permission logic — so a plugin sees exactly what its viewer
  could. New PagesService.outline/meta read the content cache.
- web: host-capabilities builds the host implementations from a per-
  surface context (the host holds the current page/pond ids; a plugin can
  only ask about "the current page" or "this pond"). Wired into the
  sandbox host and PluginFrame; ui.openPage/toast route to host callbacks.
- shared: PluginPageSummary/Meta/Content response types.
- tests: api db test proves a label-restricted reader gets a filtered
  listPages and 404s on the hidden page, non-members are hidden (404),
  anonymous is rejected (401); web unit test pins the endpoint mapping,
  id-encoding, and missing-context rejection.

readBlock/blockData land with the plugin_block node in #76 (block
addressing does not exist in the schema yet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 09:52:11 +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
f3938b7fdb Never crash boot on plugin dropzone setup; default PLUGINS_DIR in image (#71)
All checks were successful
CD / Build and push images (push) Successful in 2m41s
CI / Lint, typecheck, test (push) Successful in 3m20s
CI / Auth e2e pack (push) Successful in 4m9s
CI / Import/export fidelity gate (push) Successful in 54s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Has been skipped
The Test stage crash-looped: PluginWatcherService.onModuleInit did `mkdir`
on the default `./data/plugins` (→ /app/data, not writable by the non-root
user) and an unhandled EACCES aborted bootstrap. Two fixes:

- Harden the watcher: its dropzone is an optional convenience over the GUI
  upload, so a setup failure now logs a warning and disables drop-to-install
  instead of taking down the api.
- Bake writable defaults (UPLOADS_DIR/PLUGINS_DIR=/data/…) into the api image
  so it works out of the box even where compose does not set them; compose
  still mounts named volumes there for persistence.

Migrations applied cleanly ("No pending migrations"); this was purely the
boot-time directory permission.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 17:25:42 +02:00
d7aa1fb6da Build the plugin-sdk package in the api image (#71)
Some checks failed
CD / Build and push images (push) Successful in 3m19s
CI / Lint, typecheck, test (push) Successful in 3m22s
CI / Auth e2e pack (push) Successful in 4m3s
CI / Import/export fidelity gate (push) Successful in 54s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Failing after 3m40s
CD / Promote to Int (push) Has been skipped
The api now depends on @dorfteich/plugin-sdk, so the api Dockerfile must
copy and build it alongside shared — the CD "Build and push images" (and
CI "Build container images") step failed without it. Also pre-create
/data/plugins so the plugins named volume is writable by the non-root user,
mirroring /data/uploads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 16:59:29 +02:00
621aa47244 Add plugin storage, install API, and directory watcher (#71)
Some checks failed
CI / Auth e2e pack (push) Waiting to run
CI / Import/export fidelity gate (push) Waiting to run
CI / Build container images (push) Waiting to run
CD / Build and push images (push) Failing after 1m33s
CD / Deploy to Test (push) Has been skipped
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Has been cancelled
Backend for installing plugin ZIPs (ADR 0008, plugin-architecture.md
§Lifecycle, security.md §Plugins). Consumes the #70 SDK for validation.

- Schema: `plugins` (id, name, version, apiVersion, kind, mode, manifest
  jsonb, removedAt soft-delete) + `pond_plugins` (per-pond activation) +
  `PluginInstanceMode` enum; migration 20260710130000_plugins.
- `PluginPackageService`: pure, stateless ZIP → validated package via
  fflate — structure check, manifest validation (SDK), apiVersion gate,
  kind/bundle/styles rules, CSS sanitation (no @import / external url() /
  expression()), zip-slip and unpacked-size guards. Each failure carries a
  stable PluginErrorCode; manifest issues travel as ApiError details.
- `PluginStorageService`: on-disk layout `<PLUGINS_DIR>/<id>/<version>/`;
  atomic writeVersion (staging dir + rename, no 404 window mid-update),
  removeVersion/removePlugin, traversal-safe asset resolution, dropzone +
  quarantine dirs.
- `PluginsService`: install/update (update only to a strictly higher
  version, preserving the admin's instance mode; files land before the
  metadata pointer flips) / uninstall (refused while required; soft-delete
  + files removed + pond activations dropped) / list / get.
- `POST/GET/DELETE /admin/plugins` (SiteAdminGuard, multer memory upload),
  error→HTTP-status mapping. Public version-pinned static serving at
  `GET /plugins/:id/:version/*rest` with immutable cache + nosniff, only for
  the installed current version.
- `PluginWatcherService`: watches `<PLUGINS_DIR>/_dropzone/`, runs the same
  validation, installs valid drops and quarantines invalid ones with the
  error logged; inert under NODE_ENV=test (tests drive processDropped).
- SDK: `compareVersions`/`isHigherVersion`. shared: `PluginView`,
  `PluginInstanceMode`, `PLUGIN_ERROR_CODES`, `PLUGINS_DIR` env, plugin
  error i18n (de+en). Compose: `plugins` volume + `PLUGINS_DIR`.
- Tests: package unit test (valid + each invalid class) and an e2e DB test
  (GUI install + immutable serving, non-admin 403, invalid-manifest details,
  dropzone install + quarantine, atomic higher-only update, required-guarded
  uninstall that removes files and tombstones metadata).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 16:55:26 +02:00
aaa9a253ae Add import/export fidelity gate to CI (#69)
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
CD / Build and push images (push) Failing after 27m51s
CI / Lint, typecheck, test (push) Successful in 3m11s
CI / Auth e2e pack (push) Successful in 4m0s
CI / Import/export fidelity gate (push) Failing after 36s
CI / Build container images (push) Has been skipped
Make the "structure-true best effort" fidelity contract (ADR 0009) an
objective, pipeline-gated suite so "best effort" cannot erode silently.

- New CI job "Import/export fidelity gate" (.gitea/workflows/ci.yml) runs
  the corpus suites against the pinned sidecar images the stages use
  (pandoc/core:3.6, gotenberg/gotenberg:8), started via docker run and
  reached over the host gateway. Small and separate so it stays well under
  five minutes; the suites self-skip in the main checks job (no sidecars).
- Export fidelity: fixtures/export corpus + gen-export-fixtures.mjs +
  export.fidelity.test.ts — exports Markdown to docx/odt through the real
  pinned pandoc and reads it back, snapshotting the round trip so a writer
  drift (ours or a version bump) fails the gate.
- PDF smoke: pdf.fidelity.test.ts renders a page through real Gotenberg and
  asserts the extracted text and a sane page count (pdf-parse, dev-only).
- Fidelity contract doc: fixtures/README.md defines "corpus green = fidelity
  acceptable" and the fixture-first bug process; per-corpus READMEs updated.

Because the snapshots are byte-exact and generated with the pinned tools,
bumping a sidecar without regenerating shifts the output and fails the suite
(AC3). The import corpus (#63) is folded into the same gate.

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