Commit Graph

39 Commits

Author SHA1 Message Date
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
e2f942c0ff Add document import UI in the sidebar (#64)
All checks were successful
CD / Build and push images (push) Successful in 3m43s
CI / Lint, typecheck, test (push) Successful in 2m56s
CI / Auth e2e pack (push) Successful in 3m53s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 11s
An "Import document" action in the pond sidebar: pick a .docx/.odt/.md file
(or several), upload with per-file progress, and open the new page. A
.docx/.odt polls the conversion job (queued → converting → done); a .md
imports directly and comes back already succeeded. Failures stay listed with
the localized error and a retry; concurrent imports all complete and appear.

- web apps/web/src/import/: useImport hook (upload via apiUploadFile → poll
  GET /jobs/:id → resolve the page slug → navigate; first success of a batch
  navigates, every success refreshes the sidebar) and ImportControl (hidden
  file input, accept from shared IMPORT_EXTENSIONS, per-file status list).
  Wired into Sidebar next to "new page"; `import` i18n namespace (de+en).
- api: ImportService accepts .md/.markdown and imports in-process (no job),
  returning a succeeded ConversionJobView with the created resultPageId
  ("Markdown imports directly"); the media+parse+create tail is now shared
  between the job path and the sync path (createPageFromMarkdown), and a
  conversion error on the sync path maps to an HTTP status. shared
  IMPORT_EXTENSIONS gains md/markdown.
- e2e apps/web/e2e/import.spec.ts + CI step: .docx corpus fixture opens the
  converted page (self-skips without a reachable pandoc sidecar — CI's e2e
  stack has none, same as #63; verified locally + on stage), .md opens
  directly, an unsupported .txt shows the localized error with no page
  created, and two concurrent .md imports both complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 09:38:31 +02:00
546e8279ac Import .docx and .odt documents as new pages (#63)
All checks were successful
CD / Build and push images (push) Successful in 3m19s
CI / Lint, typecheck, test (push) Successful in 2m55s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Uploading a Word/OpenOffice document to POST /ponds/:id/import enqueues a
conversion job (the #62 queue) that produces a new page in the pond; the
client polls GET /jobs/:id for the created resultPageId.

Pipeline (ImportService, ADR 0009): pandoc-server is stateless and hands
back a document's media no other way, so we convert in two passes —
docx/odt → html with embed-resources inlines every image as a data: URI,
then html → gfm produces clean structural Markdown with those data URIs
still inline. Embedded images are stored as pond files (with quota
accounting) and their references rewritten to file ids on the Markdown
text before parsing (the editor parser only admits png/jpeg/gif/webp data
URIs); an image whose bytes the upload pipeline rejects is dropped, not
fatal. The title comes from a leading top-level heading (removed from the
body) else the file name. The page is created from the resulting Yjs state.

The shared conversion worker routes import-kind jobs to the pipeline via a
token (breaking a module cycle), so import inherits the queue's locking,
retry, and restart-survival. Media stored during a failed attempt is rolled
back; a pond that runs out of storage fails the job with quota_exceeded.

- schema: ConversionJob gains pond_id / source_name / result_page_id
  (migration 20260710041215_import_pages_conversion); ConversionJobView
  gains resultPageId.
- PagesService.createWithState / yjs-content docToState build a page from a
  prepared document; FilesService.linkAttachmentsToPage links import media.
- fixtures/import/: representative .docx/.odt corpus (headings, lists,
  nested lists, tables, images, links, bold/italic) with expected-Markdown
  snapshots; scripts/gen-import-fixtures.mjs regenerates them.
- tests: import.service.db.test.ts drives the full pipeline with a fake
  converter (CI); import.fixtures.test.ts runs the real two-pass conversion
  over the corpus and a 50-page timing check against a reachable sidecar.
- i18n: import_unsupported_format (de+en). Limits documented (25 MiB input,
  60 s per pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 07:34:43 +02:00
4755c18ef5 Add conversion job queue and pandoc sidecar integration (#62)
All checks were successful
CD / Build and push images (push) Successful in 4m9s
CI / Lint, typecheck, test (push) Successful in 2m50s
CI / Auth e2e pack (push) Successful in 3m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 11s
Import/export conversions run asynchronously against an internal pandoc-server
sidecar with limits and graceful failure (ADR 0009). This is the plumbing;
the import (#63) and export (#65) features enqueue jobs onto it.

Sidecar & config:
- pandoc/core:3.6 in HTTP server mode added to the Compose stack, internal
  network only, with a wget healthcheck on /version; the api depends on it
  healthy and reaches it via the new PANDOC_URL env (default http://pandoc:3030).
- readyz gains a warning-level `converter` check: an unreachable sidecar
  degrades import/export but never flips the instance to unready (new `warn`
  status on ReadinessCheck).

Conversion flow (apps/api/src/import-export/):
- ConversionJob table (per-request work queue, distinct from the name-keyed
  maintenance Job table): owner, formats, input/result bytes, status, attempts,
  lockedAt. Migration + owner cascade.
- PandocConverter (abstract) + PandocServerConverter: POST / with
  {text,from,to,standalone}; binary input formats (docx/odt/…) are base64-encoded
  in `text`; 60 s AbortController timeout; input/output size caps. Failures map
  to distinct localized codes — converter_unavailable / converter_timeout
  (retryable) and conversion_failed (final).
- ConversionWorker: claims one job at a time with `FOR UPDATE SKIP LOCKED`
  (safe against overlapping sweeps and a second process), recovers a stale
  RUNNING lock, retries transient failures up to 3 attempts then fails. A 2 s
  sweep plus wake-on-enqueue means a queued job survives an API restart.
- ConversionJobService.enqueue (size-limited) + owner-scoped GET /jobs/:id
  (poll) and GET /jobs/:id/result (stream the output); a foreign/unknown id is
  404. ConversionJobView in @dorfteich/shared.

Tests:
- conversion-job.e2e.db.test.ts (fake converter injected via a new createTestApp
  override hook): enqueue→convert→poll→result; foreign/unknown job 404; a
  persisted PENDING job picked up by a fresh app's worker (restart survival);
  sidecar-down fails after 3 retries while the API stays healthy.
- pandoc.converter.test.ts: success, non-200→conversion_failed, refused→
  converter_unavailable, and a delay-injecting server→converter_timeout.
- Verified locally against a real pandoc/core:3.6 container: markdown→html,
  markdown→docx (valid PK/OOXML bytes), and a docx→markdown round-trip.

Local: typecheck, lint, i18n:check, build all green; api 193 tests
(9 new), shared 121, web 50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 04:06:27 +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
ac48e9e3ec Fix de users i18n key parity broken by a greedy translation (#59)
The `never`/`you` keys had value == key in English; the sed that produced
the German file rewrote the key too. Restores the keys so `pnpm i18n:check`
(the CI test gate) passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 00:43:01 +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
fc41c91003 Add public read access and server-rendered page HTML (#56)
All checks were successful
CD / Build and push images (push) Successful in 3m13s
CI / Lint, typecheck, test (push) Successful in 2m30s
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 1m13s
CD / Promote to Int (push) Successful in 11s
Anonymous visitors read what `public` grants allow, via the SPA and a
server-rendered HTML endpoint for crawlers / PDF export (ADR 0005/0009).

- api `public/`: `GET /public/:pondSlug/:pageSlug` returns a self-contained
  HTML document (content cache + minimal chrome + canonical link, no
  session-dependent content), and `…/content` returns JSON for the SPA. Both
  are `@Public()` and resolve the `public` subject through the shared resolver
  (PermissionService) — denied or missing → 404, so non-public pages never
  reveal their existence (security.md). Cached image nodes (`data-file-id`)
  are resolved to `/api/v1/media/:fileId` for the static render.
- media: `GET /media/:fileId` is `@Public()` too, so embedded images on a
  public page stream to anonymous visitors; the attachment guard still gates
  on the `public` grant (non-public → 404).
- web: a lightweight read-only `PublicPageView` at `/public/:pondSlug/:pageSlug`
  (outside the auth guard) renders the server HTML — deliberately without
  importing the collaborative editor, so anonymous readers load no editor
  bundle. New `public` i18n namespace (de+en).
- tests: `public.e2e.db.test.ts` (HTML + JSON served for a public page; a
  non-public page never resolves; removing the grant 404s both) and a browser
  `public` pack (anonymous reads a public page and its image via the SPA;
  a non-public page shows "not found") with its own CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 23:46:25 +02:00
406886c56c Add label- and page-scope access rules UI including deny (#55)
Some checks failed
CD / Build and push images (push) Successful in 3m5s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Failing after 2m0s
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 12s
Pond Admins configure the vision's fine-grained cases through a plain-language
surface, on top of the base roles from #54.

- shared: `AccessRuleView` (a grant enriched with subject/scope display names)
  and pure conflict helpers `scopeSpecificity`/`sameGrantSubject`/
  `isRuleShadowed` (unit-tested) for the client-side shadowed-rule hint. New
  `access` i18n namespace (de+en) with sentence templates (ADR 0012).
- api: `GET /ponds/:id/grants/access-rules` (Pond-Admin) returns the pond's
  grants enriched with each user's display name and each label/page scope's
  name, resolved in one batched query per kind.
- web `access/`: `AccessRulesManager` in Pond Settings — the pond's rules
  grouped by subject and rendered as readable de/en sentences ("Anna may not
  edit pages labeled “Confidential”"), an add form (subject = member or the
  `signed-in`/`public` pseudo-subjects; scope = label from the tree or a
  specific page; role; allow/deny) that warns when a rule would be shadowed by
  a more specific existing one (shared algorithm) and requires an explicit
  confirmation before granting anything to `public`. Semantics are the shared
  resolver's — the UI only reflects permissions.md.
- tests: shared `conflicts.test.ts`; an api db case for the enriched endpoint;
  a browser `access-rules` pack that configures BOTH vision patterns through
  the UI and verifies their effect end to end — "deny label X" (an editor
  loses a labelled page) and "only label Y" (a signed-in non-member, new
  `fixture-viewer`, reads only the labelled pages) — plus the shadow hint and
  the public confirmation, with its own CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 21:45:31 +02:00
7f1c49db53 Add pond member management UI (#54)
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m29s
CI / Auth e2e pack (push) Successful in 3m8s
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 12s
Pond Admins manage who participates in a pond, by role, with editor/reader
seat quotas — the member-facing layer over the grant model (#51/#52).

- shared: `MemberView`/`PondMembersView` + add/change-role schemas
  (`members.ts`), a `members` i18n namespace (de+en), and member error codes.
- api `members/`: a member-centric API over pond-scope user grants —
  `GET /ponds/:id/members` (any member, for transparency: list grouped by
  effective role + seat usage + `canManage`), `POST` (add by exact username or
  e-mail — no directory browsing), `PATCH :userId` (change role), `DELETE
  :userId` (remove), all Pond-Admin-gated by the guard. Editor/reader seats are
  enforced against `editors_per_pond`/`readers_per_pond` (#22) inside a
  per-pond advisory-locked transaction so counts cannot race; the owner's
  membership is protected, personal ponds refuse a second admin (shared grant
  rule), and the last Pond Admin cannot be dropped. Every change invalidates
  the pond permission cache and fires the access NOTIFY (#39/#53).
- web `members/`: `MemberManager` in Pond Settings — list grouped by role with
  a search filter and seat usage, add-by-identifier form (disabled with a
  localized explanation when the chosen role's seats are full), per-member role
  change and remove; read-only for non-admins; the personal-pond rule is
  surfaced. There is no invitation flow (v1): adding is immediate, and the copy
  says so.
- tests: `members.e2e.db.test.ts` (add/change/remove, seat exhaustion,
  personal-pond and owner rules, read-only transparency, last-admin) and a
  `members` browser pack (immediate second-browser access, quota disables the
  add action, non-admin read-only) with its own CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 20:22:52 +02:00
0c6494f209 Enforce permissions in API guards and retire interim access (#52)
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m25s
CI / Auth e2e pack (push) Successful in 2m58s
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 12s
Every route now declares its access rule explicitly and is enforced
through the shared resolution algorithm (permissions.md):

- PermissionGuard + decorators (@RequiresPondRole, @RequiresPagePermission,
  @RequiresAttachmentPermission, @AuthenticatedOnly) applied to every
  route; a route-enumeration test proves full coverage alongside
  @Public()/Site-Admin-guarded routes.
- 404/403 policy (documented in README conventions): denied reads answer
  404 (existence hiding), denied writes on readable things answer 403;
  trash views need write capability (ADR 0013).
- PermissionService resolves page/pond questions via the shared resolver,
  with an in-process pond-context cache (grants + label parents) that is
  invalidated on every grant/label-tree change and TTL-bounded as a
  multi-process safety net. Grant changes also fire pond_access_changed
  for collab revalidation (#39/#53).
- shared: pond-scope resolution (hasPondRole, canSeePond) next to the
  page resolver; grant wire schemas + GrantView.
- Owner Pond-Admin grants: migration backfill for all existing ponds,
  created transactionally with every new pond (shared + personal + seed).
- Grant CRUD under /ponds/:id/grants (pond_admin-gated) with structural
  and referential validation, last-admin protection, audit logs.
- InterimAccessService deleted; page lists, search, backlinks, phantom
  links, and trash listings are filtered per page through the resolver;
  collab tokens are now truly ro for readers.
- Fixture-matrix e2e (reader/editor/pond admin/foreign, label-deny,
  authenticated-subject, revoke-then-immediate-deny cache test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 16:31:41 +02:00
4d48d72c40 Add grant model and shared permission-resolution algorithm (#51)
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m21s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
The heart of the security model: one algorithm, implemented once, for API,
collab, and UI (permissions.md — authoritative).

- shared `permissions/`: pure resolution (`resolvePageCapability`) exactly per
  permissions.md — specificity page > label (incl. ancestor labels) > pond,
  deny wins within a level, default-closed, Site Admin bypass — plus the trash
  rule (`canAccessPage` / `canAccessTrashedPage`, ADR 0013). `grantValidationError`
  enforces the structural constraints. Documented, I/O-free signatures for
  API/collab reuse.
- prisma: `RoleGrant` (+ grant enums) per data-model.md, unique on
  (pond, subject, role, scope); migration adds a CHECK backstop that a
  POND_ADMIN grant is pond-scope + user-subject.
- api `grants/`: `GrantsService.createGrant` validates before insert (structural
  + no extra admin on a personal pond), rejects duplicates; `grantsForPond`
  returns the shared resolver model (what #52/#53 consume); enum mappers between
  the DB and the shared model. Interim "who may manage grants" stays until #52.
- tests: exhaustive table-driven resolver suite — every worked example from
  permissions.md §Resolution, edge cases (multi-label deny-wins, ancestor
  inheritance, anonymous/public, most-specific-allow-beats-less-specific-deny,
  trash) and a property test (a less-specific grant never overrides a
  more-specific decision); validation unit tests; grants db test proving
  write-time rejection of invalid grants.
- i18n: grant error codes (de + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 14:02:07 +02:00
19fb24c527 Add search UI with scoping and snippets (#50)
All checks were successful
CD / Build and push images (push) Successful in 3m24s
CI / Lint, typecheck, test (push) Successful in 2m16s
CI / Auth e2e pack (push) Successful in 2m52s
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
A search palette over the #49 full-text search.

- web:
  - `SearchPalette`: opened from a top-bar button or the global "/" shortcut
    (ignored while typing in a field). Scoped to the current pond by default
    with an "all my ponds" toggle and, when scoped, a label filter. Results
    list title, pond, label chips, and a highlighted snippet; recent searches
    (localStorage) show before typing; empty/error/hint states.
  - `HighlightedSnippet` renders the match — the api wraps hits in shared
    sentinels (private-use codepoints), split here into `<mark>` so no HTML
    from the content is interpreted.
  - Fully keyboard-operable: "/" opens, ↑/↓ move, Enter opens the page,
    Esc closes.
  - i18n `search` namespace (de + en); palette + result styles.
- api: a pondId scope test proves search narrows to one pond.
- e2e `search.spec.ts` (new CI pack): body content added via the editor is
  found and highlighted, the scope toggle keeps the result, and Enter opens
  the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 13:36:06 +02:00
6c38abc20c Add backlinks panel and phantom-pages view (#48)
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m15s
CI / Auth e2e pack (push) Successful in 2m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s
Make wikilink relations visible: what links here, and which linked pages
do not exist yet.

- shared: `BacklinkView` gains a plain-text `snippet` for context.
- api: `LinksService` includes a short snippet (from the content cache) with
  each backlink and phantom referrer.
- web:
  - `BacklinksPanel` below a page in read mode: a collapsible "Linked from"
    list (title + snippet, links to the source), hidden when empty. Appears
    on load from the #47 index.
  - `PhantomPagesView` in pond settings: wikilink targets that do not exist
    yet, each with its referrers and a create shortcut that makes the page
    under the phantom slug — resolving those links (#47) and navigating to it.
  - i18n `links` namespace (de + en); backlinks + missing-pages styles.
- e2e `backlinks.spec.ts` (new CI pack): a link created in the editor appears
  as a backlink on the target; the missing-pages view lists a phantom slug and
  creating it navigates to the new page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 13:05:32 +02:00
7244b89215 Add wikilink node with autocomplete (#46)
All checks were successful
CD / Build and push images (push) Successful in 3m2s
CI / Lint, typecheck, test (push) Successful in 2m15s
CI / Auth e2e pack (push) Successful in 2m42s
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
Introduce Obsidian-style `[[page links]]` (ADR 0004).

- shared: reserved `wikilink` inline atom in the editor schema (attrs
  `targetSlug`, optional `displayText`); markdown mapping `[[slug]]` /
  `[[slug|text]]` via a markdown-it inline rule + serializer node; plain-text
  and HTML derivation include the shown text. Round-trip + parse unit tests.
- web:
  - `Wikilink` node extension with a React NodeView: shows the explicit
    display text or the target's current title (so a rename updates the link),
    renders a missing target as a dashed phantom with a tooltip, navigates on
    click in read mode.
  - `[[` autocomplete popup (`WikilinkAutocomplete`), dependency-free: filters
    the pond's pages as you type with a create-new-page hint for misses,
    Enter/click inserts the node and removes the typed `[[query`; ↑/↓/Enter/Esc
    intercepted in the capture phase so ProseMirror does not act on them.
  - `WikilinkContext` provides the pond's pages (slug→title) for live
    resolution and the autocomplete, populated by the page editor.
  - i18n `editor.wikilink.*` (de + en); wikilink + phantom + popup styles.
- e2e `wikilink.spec.ts` (new CI pack): type `[[`, autocomplete filters and
  inserts a working link that resolves the target title and persists across a
  reload. Phantom → live resolution on page creation is verified in #47.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 12:42:01 +02:00
69b00fcf2f Add manual page ordering with drag-and-drop (#45)
All checks were successful
CD / Build and push images (push) Successful in 3m1s
CI / Lint, typecheck, test (push) Successful in 2m13s
CI / Auth e2e pack (push) Successful in 2m36s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Enable the third sidebar sort mode — a freely defined order.

- api: `PATCH /pages/:id/position` (before/after neighbour) recomputes only
  the moved page's fractional `sort_key`. Pure `sort-key.ts` helpers
  (`nextKeyOrRebalance`, `evenlySpacedKeys`) decide between the cheap
  single-key path and a full pond rebalance to evenly-spaced keys when a key
  would exceed MAX_SORT_KEY_LENGTH or the client's neighbours are stale;
  rebalance runs in one transaction. Order is server-authoritative.
- web: enable 'manual' in the sort-mode switch; in manual mode the owner can
  reorder via native drag-and-drop (drop above/below by pointer half) or the
  keyboard (per-row up/down buttons), each announced through an aria-live
  region. Reordering is hidden while a label filter narrows the list. New
  pages already append at the end (create uses generateKeyBetween(last, null)).
  Pure `reorder.ts` neighbour helpers, unit-tested.
- i18n: manual sort mode + reorder strings (de + en).
- tests: sort-key property test (10.000 adversarial reorders never collide or
  overflow — rebalance verified); reposition db test (persist, server-order,
  sort-mode switch keeps manual order); reorder e2e pack (keyboard reorder
  persists across reload + identical on a fresh read; aria-live announced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 12:18:44 +02:00
03e72242d3 Add label UI: tree management, page assignment, and sidebar filter (#44)
All checks were successful
CD / Build and push images (push) Successful in 2m53s
CI / Lint, typecheck, test (push) Successful in 2m8s
CI / Auth e2e pack (push) Successful in 2m31s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Build the M4 label experience on top of the #43 label API.

- shared: `flattenLabelTree` (tree → depth-first list) for chip lookup,
  filtering, and the picker; `PageListItemView` adds each page's `labelIds`
  to the sidebar list response.
- api: `GET /ponds/:id/pages` now includes `labelIds` per page (one grouped
  query), so the sidebar can render chips and filter without extra calls.
- web:
  - Pond settings page (`/p/:pondSlug/settings`) with a `LabelManager`
    tree: inline create, rename, recolour (`<input type=color>`), move via a
    parent picker that excludes the label's own subtree, and delete that
    confirms then force-detaches assigned pages. Every control is a native
    button/input/select — the tree is fully keyboard-operable.
  - `LabelPicker` panel on the page editor: searchable, hierarchy-indented
    multi-select that assigns/unassigns immediately and refreshes the page's
    labels and the sidebar.
  - Sidebar: colored label chips on page entries (readable text via a
    luminance-based contrast helper) and a descendant-inclusive label filter
    (selecting a parent matches pages tagged with its children, via the
    shared `collectSubtreeIds`). Owner link to pond settings.
  - i18n `labels` namespace (de + en).
- e2e `labels.spec.ts` (new CI pack): full lifecycle from the settings UI
  and picker-assign + parent-filter-includes-child. Selectors are
  language-independent because the UI language follows the user's locale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 11:41:42 +02:00
a3a012c41d Add hierarchical labels: model, CRUD API, and validation (#43)
All checks were successful
CD / Build and push images (push) Successful in 2m56s
CI / Lint, typecheck, test (push) Successful in 2m5s
CI / Auth e2e pack (push) Successful in 2m26s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
Introduce pond-scoped hierarchical labels as the foundation for M4
organization and, later, M5 label-scoped permissions.

- shared: `labels.ts` with the label schemas/views and the pure tree
  helpers (buildLabelTree, collectSubtreeIds, collectAncestorIds,
  labelDepth, subtreeHeight). These are the single hierarchy walk the
  label API and the future permission resolver both build on
  (permissions.md: a grant on a label applies to all its descendants).
- prisma: `Label` (self-referential parent_id, unique per (pond, parent,
  name), cascade to subtree) and `PageLabel` assignment table; migration.
- api: `LabelsService` + controller. Tree endpoint returns the hierarchy
  in one call; create/rename/recolor/move/delete and page assign/unassign.
  Validation: cycle prevention on move, depth limit 6, unique name per
  (pond, parent) — enforced under a per-pond advisory lock so root-label
  uniqueness holds despite Postgres treating NULL parents as distinct.
  Delete cascades the subtree and requires `?force=true` when pages are
  assigned. Assignment rejects labels from a different pond. Access gated
  through InterimAccessService on the owning pond.
- i18n: label error codes and the colour validation message (de + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 10:30:17 +02:00
1bda137ca4 Add version history UI: list, view, diff, restore (#42)
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m3s
CI / Auth e2e pack (push) Successful in 2m41s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
Users can see who changed what and restore old states (ADR 0013).

- shared: dependency-free word-level Markdown diff (diffMarkdown) with a
  unit test; PageVersionContentView; PAGE_RESTORE_CHANNEL + PageRestoreRequest.
- api: GET /pages/:id/versions (list), GET .../:versionId (read-only HTML +
  Markdown for diffing), POST .../:versionId/restore. Every route requires
  write access — viewing history is gated like editing (permissions.md).
  Restore checks permission, then emits the page_restore NOTIFY; history is
  append-only (the api never deletes a version).
- collab: a page_restore listener applies the restore on the live document via
  openDirectConnection — it snapshots the current state as a PRE_RESTORE
  version, then replaces the content in one transaction, so every connected
  client converges and the change persists like a normal edit.
- web: HistoryPanel (version list with time/trigger/label/contributors, a
  read-only render of a selected version, a Markdown diff against the current
  page, and a restore action), toggled from the page menu. de+en strings.

Tests: shared diff (added/removed/round-trip/edges); collab restore DB test
(a connected client converges on the restored content; a pre-restore snapshot
is appended alongside the original — append-only); api list/get/restore
(newest-first, rendered content, write-permission gate, restore returns the
target without mutating history).

This completes M3 (real-time collaboration & history, #33–#42).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 08:53:42 +02:00
fa7ae033b5 Add permission-revocation handling for live and offline sessions (#39)
All checks were successful
CD / Build and push images (push) Successful in 2m58s
CI / Lint, typecheck, test (push) Successful in 1m55s
CI / Auth e2e pack (push) Successful in 2m25s
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
Revoking write access must terminate live sessions and let a user with
pending offline edits export them rather than lose them silently.

Backend (generic, reused by M5 grants #53):
- packages/shared: POND_ACCESS_CHANGED_CHANNEL, the LISTEN/NOTIFY channel
  shared by api and collab.
- api: PondAccessNotifier emits pg_notify(pond_access_changed, pondId) on
  a permission-relevant change; the single generic seam for revocation.
  Wired into pond soft-delete as the interim trigger (see==modify until
  #53).
- collab: a dedicated-connection LISTEN listener (LISTEN is connection-
  bound, not pooled) that, on a notification, closes every open connection
  to the pond's open pages. Clients then reconnect and the api re-issues a
  token reflecting current access (downgrade to ro, or 403/404). Reconnects
  and re-LISTENs if its connection drops.

Frontend:
- use-collab-provider: a refused token (403/404) on (re)connect sets
  accessRevoked and stops the reconnect loop; exposes discardLocal.
- AccessRevokedDialog: keeps local content visible and offers Markdown
  copy/download (derived from the live editor doc, so offline edits are
  included) and an explicit discard that clears IndexedDB. de+en strings.

Tests: collab DB-backed integration test proves a direct NOTIFY closes a
live session within seconds (AC1) and leaves unrelated ponds untouched;
listener unit tests; api test asserts soft-delete fires the notifier;
web test for the export Markdown derivation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 07:01:46 +02:00
af81b50fa6 Add offline editing: local persistence, PWA shell, offline resolution (#38)
Some checks failed
CD / Build and push images (push) Successful in 2m59s
CI / Lint, typecheck, test (push) Successful in 2m3s
CI / Auth e2e pack (push) Failing after 2m18s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m32s
CD / Promote to Int (push) Successful in 12s
Editing continues without a connection and merges conflict-free on
reconnect (ADR 0003, realtime-collaboration.md §Offline).

- y-indexeddb mirrors every opened page's Y.Doc to IndexedDB, sharing the
  document with the collab provider. The local copy is discarded when the
  page is left after a successful server sync (bounding IndexedDB growth)
  and kept otherwise so offline edits survive to the next visit.
- vite-plugin-pwa service worker precaches the app shell (build assets only)
  with a navigation fallback; `/api` and `/collab` are denylisted and there
  is no runtime caching, so API responses are never cached or poisoned.
- Offline page resolution WITHOUT caching API responses: the app itself
  persists the small metadata it needs to reopen a visited page (page/pond
  ids + slugs, bounded LRU in localStorage) and the last signed-in user, so
  after an offline tab reload the app stays signed in, resolves the page, and
  restores its content from IndexedDB. Both are revalidated when the network
  returns (a 401 clears the cached user).
- Local-only UI: a banner when there are edits held only on this device
  (provider `onUnsyncedChanges`), de + en.

Tests: `page-cache` unit test (remember/recall + bounded eviction); a new
`offline` e2e pack (validated locally against the full stack and wired into
CI): edit, reload while offline (shell from the SW, content from IndexedDB),
assert an API call fails offline (no SW API caching), then reconnect and a
second client converges. The e2e static server serves `.webmanifest`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-08 22:08:55 +02:00
63fe6af6b0 Add remote cursors and a presence strip (#37)
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 1m58s
CI / Auth e2e pack (push) Successful in 2m10s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 11s
Seeing other participants live (ADR 0003/0004, realtime-collaboration.md
§Awareness):

- The collaboration-caret extension renders remote carets and selections
  with a name flag and a per-user colour. Colours come from a small,
  hand-picked palette hashed by user id (FNV-1a), so they are stable across
  sessions; a unit test asserts each palette colour clears WCAG AA contrast
  (4.5:1) against the white label text.
- A presence strip at the top of the page shows an avatar (initials) per
  connected participant, deduplicated by user id, with an overflow count.
  Read-only participants appear in the strip (with a marker) but broadcast
  no caret — the caret render suppresses read-only users — so the same
  awareness feed drives both cursors and presence. Own identity (id +
  display name) comes from the auth context into the awareness `user` field.
- Presence updates on every awareness change, so a disconnect drops the
  participant within seconds.

The collab e2e pack gains a test: two browsers see each other in the
presence strip, one participant's named caret appears in the other's editor,
and disconnecting removes them. Validated locally against the full stack.
de + en strings and cursor/presence styles added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-08 18:22:41 +02:00
7d04c0b594 Switch the editor to live collaboration (#36)
All checks were successful
CD / Build and push images (push) Successful in 2m59s
CI / Lint, typecheck, test (push) Successful in 2m0s
CI / Auth e2e pack (push) Successful in 2m10s
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
The editor now edits over the collaboration server instead of REST — the
moment Dorfteich becomes collaborative (ADR 0003, realtime-collaboration.md).

Web:
- New `useCollabProvider` hook binds a page's Y.Doc to a HocuspocusProvider.
  The document loads and persists through the collab server (#35); there is
  no REST autosave and no REST seed (a REST seed would fork the doc lineage
  and duplicate content). The collab token is fetched lazily on every
  (re)connect via an async token function, so an expired token is replaced
  transparently and a permission change takes effect on the next reconnect.
- Connection-state UI replaces the save indicator: connecting / connected
  ("Live") / reconnecting / offline, driven by provider status + navigator
  online state. Read-only (`ro`) tokens make the editor non-editable with a
  reason; an oversize-document stateless error (#35) surfaces a banner.
- Removed `use-page-autosave.ts` and `yjs-base64.ts` (no longer used).

API:
- `PUT /pages/:id/state` is retired and returns 410 `rest_state_write_retired`
  (the criterion deferred here from #35). Collab is the sole writer of page
  state; the read paths remain. Removed the now-dead `saveState` service.

e2e / CI:
- The e2e static server proxies the `/collab` WebSocket upgrade (mirrors
  Caddy); vite dev gains a `/collab` ws proxy. The auth-e2e CI job starts the
  collab server and runs a new collab pack.
- New `collab.spec.ts`: two browsers converge on one page (the milestone
  headline), and offline edits continue locally and sync on reconnect. The
  read-only live assertion is a `test.fixme` until real read-only grants
  exist — under interim access seeing and modifying coincide, so no `ro`
  token is issued yet (that arrives with #53). Reworked the api/trash tests
  and the content editor-basics test off the retired REST write path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-08 18:00:19 +02:00
a645763679 Add page trash: soft delete, restore, and purge job (#31)
All checks were successful
CD / Build and push images (push) Successful in 2m5s
CI / Lint, typecheck, test (push) Successful in 1m45s
CI / Auth e2e pack (push) Successful in 1m50s
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 10s
Backend: a generic maintenance-job scheduler (SchedulerService, `jobs`
table) that any later maintenance job registers with instead of
growing its own timer loop. Due-ness and the run-mutex both live in
the DB row (`lastRunAt` survives a restart; claiming a due job is one
atomic `UPDATE ... WHERE status != 'RUNNING'`), and an injectable
ClockService lets tests simulate retention elapsing without waiting or
faking the global clock.

Trash endpoints: GET /ponds/:id/trash (list), POST /pages/:id/restore,
DELETE /pages/:id/purge (manual, bypasses retention) — all sharing the
same purge logic as the scheduled daily job (default 30-day retention,
new trash.retentionDays instance setting). Purging deletes a page's
content cache, update log, and attachment files/quota; page_versions
is a placeholder until M3 exists. Direct navigation to a trashed page
now 404s with a distinguishable `page_trashed` code for editors (a
plain 404 for everyone else) instead of the generic not-found.

Attachment.pageId — added in #27 but never wired up — now gets set on
every page state save to whichever page's document currently embeds
the file, which is what lets purge find a page's files.

Frontend: a per-pond trash view (restore/purge), a "move to trash"
action with confirmation in the page menu, and a trash link in the
sidebar for pond owners. Also fixes react-query retrying 4xx responses
for several seconds by default, which was masking the trash-hint 404
in the UI (and would have affected any other not-found/permission
error the same way).

Closes #31
2026-07-08 12:48:17 +02:00
c9011cb44f Add Markdown copy, paste, and per-page export endpoint (#30)
All checks were successful
CD / Build and push images (push) Successful in 2m3s
CI / Lint, typecheck, test (push) Successful in 1m41s
CI / Auth e2e pack (push) Successful in 1m46s
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) Successful in 10s
Wires docToMarkdown/markdownToDoc into the editor clipboard: copying
selected content puts Markdown on text/plain alongside the browser's
own HTML (so pasting into a plain-text destination yields Markdown),
and pasting plain text that looks like a Markdown document converts it
to rich nodes; content with real HTML on the clipboard is left to
ProseMirror's normal HTML-based paste, and the heuristic requires two
or more distinct Markdown-shaped lines (or a fenced code block) so
ordinary prose is never mangled.

Both directions need the parsed/selected doc re-hydrated against
whichever schema instance is on the other side of the boundary: the
canonical editorSchema (packages/shared) for markdownToDoc's output
before inserting it into the live view, and the live view's schema
wrapped back into editorSchema before handing a slice to docToMarkdown
— they're structurally identical but not the same object, and
ProseMirror's content checks are identity-based.

Adds GET /pages/:id/export/markdown (downloads <slug>.md), serving the
already-derived page_content_cache.markdown (#23) rather than
re-decoding the Yjs state. "Copy as Markdown" and "Download as
Markdown" actions in the page header both read from that same
endpoint, so they always agree with each other and with the last saved
state.

Closes #30
2026-07-08 12:05:11 +02:00
b5cc4c34b8 Add link UX: edit URL and open in new tab (#29)
All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m41s
CI / Auth e2e pack (push) Successful in 1m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 9s
A bubble menu on link selection offers "edit URL", "open in new tab",
and "remove link"; Mod-k opens the same editor for the current
selection (creating a link if there isn't one yet), and the toolbar
button does the same. Invalid protocols (e.g. javascript:) show a
localized inline error instead of silently no-oping. Pasting a URL
over selected text links it instead of replacing the text.

Links always render with target="_blank" so read mode opens them in a
new tab by default; edit mode suppresses the resulting navigate-on-
click (Mod-click still follows it), since a plain click there should
place the cursor instead.

Closes #29
2026-07-08 11:50:34 +02:00
c8be3cd85e Add image paste and insert in the editor (#28)
All checks were successful
CD / Build and push images (push) Successful in 2m3s
CI / Lint, typecheck, test (push) Successful in 1m39s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
Paste and drag-and-drop of image files upload via the #27 API and insert
a real image node only once the upload succeeds; the in-flight state is
a ProseMirror decoration, not a document node, so a failed upload cannot
leave anything broken behind (it shows a transient inline error instead).
The toolbar's image button opens a native file picker into the same
upload path. Selecting an image reveals inline alt-text and width-preset
(small/medium/full) controls. Also fixes the image node's parseDOM,
which had no getAttrs and would drop the required fileId attribute on
internal copy/paste.

Closes #28
2026-07-08 11:30:25 +02:00
0fae699018 Add file storage service and image upload API (#27)
All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m43s
CI / Auth e2e pack (push) Successful in 1m48s
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) Successful in 10s
Implements the FileStorage abstraction (uploads/<pondId>/<fileId> on the
mounted volume), the attachments model, and POST /ponds/:id/files, GET
/media/:fileId, DELETE /files/:id. Uploads are validated by sniffing
magic bytes rather than trusting the client's Content-Type/filename
(catches a renamed .html-as-.png), checked against the max_file_bytes
and storage_bytes quotas, and served with nosniff + immutable caching.

Closes #27
2026-07-08 10:35:03 +02:00
49beb45b3e Add pond sidebar with page list, sort modes, and pond switcher (#26)
All checks were successful
CD / Build and push images (push) Successful in 1m59s
CI / Lint, typecheck, test (push) Successful in 1m38s
CI / Auth e2e pack (push) Successful in 1m48s
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) Successful in 9s
GET /ponds/:id/pages lists a pond's pages ordered by the pond's
persisted sidebarSort setting (alpha/created; manual arrives with #45).
The sidebar consumes it to show the page list with an active-page
highlight, an owner-only sort switch (persists via the existing
PATCH /ponds/:id), and an inline "new page" flow. The top bar gains a
pond switcher; a new /p/:pondSlug route gives it somewhere to land,
redirecting to the pond's first page once loaded. Sidebar collapse
gains a Ctrl/Cmd+\ shortcut and a slightly refined transition.

Closes #26
2026-07-06 12:37:44 +02:00
076883a9a6 Add TipTap page editor with REST persistence (#25)
All checks were successful
CD / Build and push images (push) Successful in 2m0s
CI / Lint, typecheck, test (push) Successful in 1m42s
CI / Auth e2e pack (push) Successful in 1m50s
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) Successful in 10s
TipTap is bound to the canonical ProseMirror schema (packages/shared,
#24) via a generic bridge (spec-utils.ts) that re-derives every
node/mark's attrs/parseDOM/toDOM from editorSchema instead of
duplicating them, so the editor's schema stays byte-for-byte identical
to what the api decodes Yjs states against — guarded by a schema-
fidelity + real Yjs round-trip test (@tiptap/y-tiptap client encoding
against y-prosemirror server decoding).

Route /p/:pondSlug/:pageSlug (RequireAuth) resolves the page via a new
GET /ponds/:pondId/pages/:slug endpoint, binds a local Y.Doc via
@tiptap/extension-collaboration (fragment "default"), and offers a
view/edit mode toggle (sidebar auto-hides in edit mode via a small
AppLayout context). Page state saves debounced to PUT /pages/:id/state
with a truthful saving/saved/error(retrying) indicator; title saves
separately via PATCH /pages/:id.

Toolbar covers headings, marks, lists, blockquote, code block, hr,
table (insert/row/column/header ops via prosemirror-tables), a minimal
link mark, and an image placeholder (real upload is #27/#28).

Closes #25

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 11:08:43 +02:00
98e159ab50 Add page CRUD and Yjs state persistence (#23)
All checks were successful
CD / Build and push images (push) Successful in 1m52s
CI / Lint, typecheck, test (push) Successful in 1m34s
CI / Auth e2e pack (push) Successful in 1m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m7s
CD / Promote to Int (push) Successful in 10s
Prisma models `pages`/`page_updates`/`page_content_cache` per
data-model.md. Endpoints: POST /ponds/:id/pages (title -> empty Yjs doc
state, seeded via y-prosemirror), GET /pages/:id (meta + base64 state),
PUT /pages/:id/state (client-encoded Yjs state, rejected above the 5 MiB
operations.md limit or if it doesn't decode into a valid document for
the schema), PATCH /pages/:id (title/slug — explicit slug changes
validate uniqueness per pond, title-only renames keep the slug),
DELETE (soft). Access follows InterimAccessService via the page's pond,
same 404-not-403 interim rule as ponds.

State saves decode the Yjs update with yjs + y-prosemirror and run it
through the #24 shared derivation functions (docToPlainText/
docToMarkdown/docToHtml/extractOutline) to refresh page_content_cache.
The Yjs XmlFragment name ("default") and the derivation call are
factored so the collab server's persistence hooks (#35) can reuse both.

Raised the API's JSON body limit to 8 MiB (main.ts and the e2e test
app) to fit base64-encoded page state.

Closes #23
2026-07-05 22:25:41 +02:00
64928f0ac0 Quota foundation: overrides, resolution, race-safe consumption (#22)
All checks were successful
CD / Build and push images (push) Successful in 1m46s
CI / Lint, typecheck, test (push) Successful in 1m17s
CI / Auth e2e pack (push) Successful in 1m39s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m6s
CD / Promote to Int (push) Successful in 10s
- quota_overrides + pond_usage models (BigInt values, unique per
  subject+key); migration 20260705185146_quotas
- instance-default quota keys in the settings registry (editors 5,
  readers 50, additional ponds 0, storage 1 GiB, max file 25 MiB)
- QuotaService: getEffective with pond → user → instance resolution
  (zero counts as a value, not a gap); assertCanCreateSharedPond and
  checkAndConsume serialize via pg_advisory_xact_lock inside the guarded
  write's transaction; release never drops below zero
- pond creation enforces additional_ponds (personal ponds don't count);
  quota errors carry code quota_exceeded + {quotaKey, limit}, localized
- seed grants fixtures an additional_ponds override (default is 0)
- table-driven resolution tests, parallel-consumption test, e2e for the
  pond-creation limit

Closes #22

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB
2026-07-05 20:55:59 +02:00
f0850eecd3 Ponds: data model, CRUD API, personal pond on verification (#21)
All checks were successful
CD / Build and push images (push) Successful in 1m46s
CI / Lint, typecheck, test (push) Successful in 1m19s
CI / Auth e2e pack (push) Successful in 1m42s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m5s
CD / Promote to Int (push) Successful in 10s
- Pond model with pond-level trash columns (ADR 0013) and settings jsonb
  holding only deviations from the defaults (sidebar sort, font slots per
  ADR 0016); migration 20260705090100_ponds
- shared: pond schemas/views and slugify (German transliteration,
  URL-safe, length-capped); deterministic -2/-3 suffixes for collisions
- InterimAccessService: single place answering pond access questions
  until the real role model lands in M5
- POST/GET /ponds, GET /ponds/:slug, PATCH/DELETE /ponds/:id, Site-Admin
  trash + restore; personal pond auto-created on e-mail verification and
  for active seed fixtures; personal ponds cannot be trashed
- e2e pack covering verify-flow pond creation, slug suffixes, rename,
  foreign-pond 404s, trash/restore; slugify unit tests

Closes #21

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB
2026-07-05 11:08:16 +02:00
0bc80c9f93 Add auth, settings, and admin UI to the SPA
The web app grows its account surface: login (with next-redirect,
unverified-hint + resend), signup (react-hook-form + shared Zod
schemas, field-level api errors, closed-registration state fed by the
new public GET /auth/registration), e-mail verification, forgot/reset
password; a settings page with profile (locale applies immediately),
password change, and active-session management; a Site-Admin page for
instance name, default locale, and registration mode. AuthProvider
holds /auth/me, applies the profile locale, and backs route guards
(RequireAuth/RequireAnonymous/RequireSiteAdmin); the top bar gains a
user menu. All strings ship in the new auth/settings namespaces (de+
en); the exception filter now preserves handler-specific error codes.
Verified live: signup → Mailpit → verify → login → profile through
the Vite proxy.

Closes #16
Closes #17
Closes #18
Closes #19

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 05:35:56 +02:00
f00fb19f32 Add mail outbox with SMTP delivery worker and templates
MailService renders transactional mails (verify-email, reset-password)
from the new de/en `mails` i18n namespace — text plus minimal HTML
with escaped interpolation — and enqueues them into mail_outbox.
MailWorker delivers pending rows every 15s through an injectable
transport (nodemailer; faked in tests) with quadratic backoff and a
permanent FAILED state after five attempts, logged as a warning.
SMTP_* and APP_BASE_URL join the environment schema with defaults
matching the new Mailpit container in the dev overlay.

Closes #12

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:46:12 +02:00
e855192d23 Add i18n with i18next, German and English, and a key-parity check
Translation resources live in packages/shared/i18n/<lang>/<ns>.json
(common, errors) and ship with de and en. The web app initializes
react-i18next with bundled resources (?lng= wins, then the browser
language); all shell components use useTranslation and the temporary
t() stub is gone. The api localizes its uniform error bodies via a
minimal i18next instance negotiated from Accept-Language. `pnpm
i18n:check` fails CI when any key is missing in any language, backed
by tested helpers in @dorfteich/shared.

Closes #5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:23:45 +02:00