Commit Graph

106 Commits

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 16:40:04 +02:00
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
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
784f21d805 Add editor section node; serialize pnpm CI jobs to fix runner flake
All checks were successful
CI / Auth e2e pack (push) Successful in 4m10s
CI / Import/export fidelity gate (push) Successful in 45s
CD / Build and push images (push) Successful in 1m0s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m8s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 2m48s
CI / Build container images (push) Has been skipped
Two corrective changes:
- Register the `section` node in the web TipTap editor (with
  wrapInSection/unwrapSection commands) so the editor schema matches the
  shared editorSchema again. The schema-drift guard
  (document-extensions.test) rightly failed after 2e96173 added `section`
  to the shared schema without the editor side — this restores it.
- Replace the ineffective warm-up gate with a serial chain of the pnpm
  CI jobs (checks -> auth-e2e -> fidelity). Warming the shared action
  cache did not help: the dependent jobs still started together and
  corrupted the cache during concurrent extraction. Serializing them is
  what actually prevents it; CD image builds still run in parallel.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 13:01:51 +02:00
8a68ef68e7 Add PDF export via Gotenberg (#67)
All checks were successful
CD / Build and push images (push) Successful in 4m3s
CI / Lint, typecheck, test (push) Successful in 3m5s
CI / Auth e2e pack (push) Successful in 4m7s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
Server-side PDF export for reading/sharing (ADR 0009), rendered by a new
internal Gotenberg (headless Chromium) sidecar.

- Sidecar: `gotenberg/gotenberg:8` in the compose stack (internal, pinned,
  healthcheck); api `GOTENBERG_URL` env; a `renderer` readyz check at
  warning-level (mirrors the converter) so PDF export degrades gracefully when
  Gotenberg is down without failing readyz.
- Export HTML: `buildPdfHtml` renders a self-contained document (no app chrome)
  — the page's content with images inlined as data URIs, the pond's fonts
  inlined as base64 `@font-face` + applied via CSS variables (ADR 0016), print
  CSS (A4, page-break rules, a title header), and page numbers from Gotenberg's
  footer. Plugin-block fallbacks are a marked TODO(#79) for M7.
- Fonts in the api image: the api Dockerfile now bakes the font catalog in
  (`build-fonts.mjs` with FONTS_OUT) so the exporter can read a pond's chosen
  WOFF2 and inline them; a missing file falls back to the system stack.
- Job flow: `POST /pages/:id/export {format: pdf}` builds the HTML (read
  permission checked by the guard) and enqueues an `export_pdf` job on the #62
  queue with the HTML as input; the worker branches `to === 'pdf'` to the
  `GotenbergRenderer` (html → pdf) instead of pandoc, retrying an unreachable
  sidecar and failing a refused render (`renderer_unavailable`/`render_failed`,
  de+en). The client polls and downloads `GET /jobs/:id/result`.
- Frontend: the page-menu PDF button is now a real export (PDF added to
  EXPORT_FORMATS; the disabled placeholder removed).
- Tests: export.service.db PDF cases (HTML has title/font-variable/inlined
  image; renderer-down fails with `render_failed`); e2e PDF export self-skips
  without a Gotenberg sidecar (like the .docx case). Verified locally against
  real Gotenberg — a valid PDF with the pond font embedded.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 10:31:19 +02:00
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
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
ae8cdd0e1e Add cross-feature permission hardening matrix (#60)
All checks were successful
CD / Build and push images (push) Successful in 3m13s
CI / Lint, typecheck, test (push) Successful in 2m36s
CI / Auth e2e pack (push) Successful in 3m38s
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
Permissions cut across every M2–M5 feature; this pack pins the security-relevant
subject × surface combinations so a weakened guard is caught.

- `apps/web/e2e/permission-matrix.spec.ts`: an API-level (the UI adds nothing
  over the resolved status code) parameterized suite over the subjects — site
  admin, pond admin/owner, editor, the same editor label-restricted by a
  `secret`-label deny, reader, public (anonymous), and the foreign user (new
  `fixture-outsider`, a member of nothing) — across the surfaces: page read,
  edit (collab-token `rw`/`ro`), sidebar list, search, versions, media, and the
  public HTML endpoint. It enforces the 404-vs-403 policy: an unauthorized read
  is 404 (existence hidden), an unauthorized write on something readable is 403.
- wired into the pipeline as its own CI step; documented in
  `apps/web/e2e/README.md` (with the subject/surface list) so later features
  extend the matrix rather than writing bespoke permission tests.
- seeded-regression check (acceptance criterion): temporarily forcing the
  collab-token to always `rw` (ignoring write permission) makes the pack go red
  on the "reader gets `ro`" and public/foreign cells — verified locally, then
  reverted. Runs in ~1 s (well under the 10-minute budget).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 00:43:16 +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
9d288b2ad0 Wire real permissions into collab tokens and revocation (#53)
All checks were successful
CD / Build and push images (push) Successful in 3m4s
CI / Lint, typecheck, test (push) Successful in 2m27s
CI / Auth e2e pack (push) Successful in 3m6s
CI / Build container images (push) Has been skipped
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
Live editing now obeys the same rules as REST: the collab-token mode comes
from the shared grant resolution, anonymous visitors can join public pages,
and revoking write access flips a running session to read-only within
seconds.

- Anonymous public tokens: `GET /pages/:id/collab-token` is `@Public()` but
  still permission-guarded, so a logged-out visitor gets an `ro` token where
  a `public` grant makes the page readable (404 otherwise). The token's
  `userId` is nullable (shared schema + collab context) for anonymous
  subjects.
- Prompt revocation: the pond-level NOTIFY (#39) now also fires on label
  tree/assignment changes (LabelsService move/remove/assign/unassign), and
  the collab server closes the *actual* WebSocket instead of only sending an
  application-level close message. Hocuspocus' `closeConnections` leaves the
  socket open so the client only re-checks on its ~30s message timeout;
  `closeDocumentConnections` drops the socket so the client reconnects and
  re-authenticates with a freshly-resolved token at once — the "within
  seconds" downgrade the milestone promises.
- Tests: the #52 fixture matrix gains anonymous cases (public grant → `ro`,
  none → 404); a collab db test proves an editor downgraded to reader goes
  read-only on reconnect (its post-downgrade edits no longer reach a peer);
  a new browser `collab-permissions` pack covers the read-only participant
  and the live downgrade end to end (new plain `fixture-editor` account).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 18:48:42 +02:00
b1f2105a2e Modernize stale editor e2e pack to live-collab semantics
All checks were successful
CD / Build and push images (push) Successful in 1m58s
CI / Lint, typecheck, test (push) Successful in 2m29s
CI / Auth e2e pack (push) Successful in 2m55s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
editor.spec.ts still asserted the M2 REST-autosave UI ("saved" indicator,
save-failure retry) that #36/#38 retired for live collaboration — the pack
(not part of CI) has been failing locally ever since. The tests now assert
the collab connection status via the language-neutral data-status
attribute: connected on entry, honest "offline" while disconnected, and
offline edits reaching the server after reconnect (proven across a reload).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 17:01:41 +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
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
41b259fae5 Harden the offline e2e for slower CI timing (#38)
Some checks failed
CD / Build and push images (push) Successful in 54s
CI / Lint, typecheck, test (push) Successful in 1m59s
CI / Auth e2e pack (push) Failing after 2m12s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
The offline pack passed locally but flaked in CI. Make its timing-sensitive
steps robust without changing the feature:

- Before going offline, wait until the service worker not only controls the
  page but has actually populated Cache Storage (app-shell precache complete),
  so the offline reload is guaranteed to be servable from cache.
- After coming back online, wait for the reloaded tab to reconnect (so it has
  pushed its local state) before checking a second client converges.
- Raise the service-worker-ready and convergence timeouts, and the IndexedDB
  flush wait, for headroom on slower runners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-08 22:24:29 +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
8316c617d2 Add collaboration server skeleton (Hocuspocus) with health, container, and CI/CD (#33)
All checks were successful
CD / Build and push images (push) Successful in 2m36s
CI / Lint, typecheck, test (push) Successful in 1m50s
CI / Auth e2e pack (push) Successful in 1m58s
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
Bootstrap apps/collab as a Hocuspocus WebSocket server (ADR 0003):
- pino JSON logging (service=collab) and shared Zod env validation
  (collabEnvSchema); structured connection open/close logs.
- /healthz endpoint (process liveness + PostgreSQL ping) served via the
  onRequest hook, matching the container-internal path and the proxied
  /collab/healthz path; any WebSocket handshake is accepted for now
  (authentication arrives with #34, persistence with #35).
- Dockerfile (ESM workspace build) and a compose service on the frontend
  and internal networks with a healthcheck; dev overlay service and a new
  COLLAB_PORT variable.
- CD builds, pushes, and promotes the collab image; CI builds it on PRs;
  the smoke suite asserts /collab/healthz through the reverse proxy.
- deployment.md/stages.md: proxy routing, per-stage COLLAB_PORT, checklist.

Closes #33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:53:44 +02:00
12035e2231 Add M2 fixtures and content regression pack (#32)
Some checks failed
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m44s
CI / Auth e2e pack (push) Failing after 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 1m8s
CD / Promote to Int (push) Successful in 10s
Seed script extends the fixture matrix with a shared "Content Fixtures"
pond (owned by fixture-user): an "Every Element" page covering every
editor schema node and mark (#24), and a "Fixture Image" page with one
real, servable uploaded image. "Every Element" loads a checked-in Yjs
snapshot (prisma/fixtures/content-page.yjs) generated from a
human-readable Markdown source (content-page.md) via a deterministic
regeneration script (pinned Y.Doc clientID; refuses to write a
snapshot that isn't a fixed point of the Markdown round-trip).

New apps/web/e2e/content.spec.ts consolidates the M2 content
regression pack: page lifecycle, editor basics, image paste, trash,
and — the pack's actual regression pin — a byte-for-byte comparison of
the fixture page's exported Markdown against the checked-in fixture.
Verified this catches regressions: temporarily mutated
docToMarkdown's heading serializer, rebuilt, re-seeded, confirmed the
comparison failed, then reverted.

This pack now runs in CI (a second step in the existing auth-e2e job,
reusing its already-built-and-seeded stack) alongside the existing
local-only feature packs.

Closes #32
2026-07-08 13:14:48 +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
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
d29e95462c Bind the CI dev server to all interfaces and surface login errors
Some checks failed
CD / Build and push images (push) Successful in 46s
CI / Lint, typecheck, test (push) Successful in 1m14s
CI / Auth e2e pack (push) Failing after 1m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m2s
CD / Promote to Int (push) Successful in 10s
Playwright's request context resolves localhost to ::1 while Vite in
the CI container listened on IPv4 only — the fixture-login helper got
ECONNREFUSED. Vite now starts with --host in the auth-e2e job. The
redirect test also reports the server's error message instead of a
bare URL mismatch when a login fails.

Part of #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:07:49 +02:00
7d10290389 Fire the e-mail verification exactly once per token
Some checks failed
CD / Build and push images (push) Successful in 39s
CI / Lint, typecheck, test (push) Successful in 1m12s
CI / Auth e2e pack (push) Failing after 1m51s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m4s
CD / Promote to Int (push) Successful in 10s
React StrictMode double-invokes effects in development; the second
POST consumed-token 400 could win the state race and show an error
for a successful verification (flaked in CI, passed locally). A ref
guards the single-use call; Playwright test-results are ignored.

Part of #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 05:59:25 +02:00
58d3a0b80f Define the reset-password form schema in shared
Some checks failed
CD / Build and push images (push) Successful in 1m42s
CI / Lint, typecheck, test (push) Successful in 1m13s
CI / Auth e2e pack (push) Failing after 40s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 7s
CD / Smoke tests against Test (push) Successful in 1m4s
CD / Promote to Int (push) Successful in 9s
Composing z.object() in the web app around a schema imported from
@dorfteich/shared mixes two zod type instances and breaks the
zodResolver overload on fresh installs (CI). Like the other forms,
the schema now lives in the shared package.

Part of #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 05:47:40 +02:00
1cea675983 Add auth e2e regression pack with fixtures and CI stack
Some checks failed
CD / Promote to Int (push) Blocked by required conditions
CD / Build and push images (push) Successful in 1m41s
CI / Lint, typecheck, test (push) Failing after 56s
CI / Auth e2e pack (push) Failing after 43s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Has been cancelled
The seed script now provisions the documented fixture matrix
(fixture-admin / fixture-user / fixture-pending, idempotent upserts,
rate-limit reset for disposable databases). A six-test Playwright pack
drives the real UI against a full local stack with Mailpit: complete
signup→mail→verify→first-login journey, wrong-password error, guarded
route redirect honoring ?next (race between the login page and the
anonymous guard fixed by teaching the guard about ?next), menu logout,
site-admin gating, and a profile rename reflected in the top bar. The
pack self-skips without E2E_MAILPIT_URL, so the CD smoke stage (now
pinned to smoke.spec.ts) stays untouched; a new CI job boots api +
web dev server against postgres/mailpit service containers and runs
the pack on every PR and push. Also fixed: the web api client choked
on empty 201 bodies. e2e/README.md documents targets and fixtures.

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 05:43:05 +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
61da1cc784 Keep Playwright specs out of Vitest collection
All checks were successful
CD / Build and push images (push) Successful in 37s
CI / Lint, typecheck, test (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 1m4s
CD / Promote to Int (push) Successful in 9s
apps/web now has a Vitest config excluding e2e/ — those specs run via
Playwright (pnpm e2e) against a deployed stage, not in unit test runs.

Part of #8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:28:28 +02:00
fb1422f56f Add CD workflow: build-push, deploy Test, smoke suite, promote Int
Some checks failed
CD / Build and push images (push) Failing after 5s
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) Failing after 7s
CI / Build container images (push) Has been skipped
On every push to main: build both images once (SHA + moving `test`
tag), push to the Gitea registry, SSH-deploy the Test stage, wait for
readiness, run the new Playwright smoke suite (SPA shell, web
liveness, api healthz/readyz) against https://test.dorfteich.cloud,
and on green retag the identical SHA images as `int` and deploy Int.
The CI image-build job becomes PR-only to avoid double builds on main.

Part of #8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:57:16 +02:00
6e5a632c31 Add production Dockerfiles and the Compose stack with dev overlay
Multi-stage images: web (workspace build baked into unprivileged
nginx with SPA fallback, asset caching, /healthz) and api (pnpm deploy
bundle with the prisma CLI for migrate-on-start, non-root, node-based
healthcheck). deploy/compose/docker-compose.yml defines the stage
stack (web, api, db) with frontend/internal networks, localhost-only
published ports for the host reverse proxy, log rotation, and named
volumes; .env.example documents every variable. compose.dev.yml layers
hot-reloading dev containers (or database-only usage) over the same
definition. Verified locally: full stack healthy, SPA fallback, readyz
green after automatic migration, db not reachable from outside.

Closes #6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:30:21 +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
300a418e85 Add React SPA shell with routing, layout, and API status
apps/web becomes a Vite + React application: React Router with home
and 404 routes, base layout (top bar, collapsible sidebar remembered
per user via localStorage, main area), CSS design tokens including the
three font slots from ADR 0016, TanStack Query, and a typed fetch
helper showing live API health on the home page. All UI strings go
through a t() stub that issue #5 replaces with i18next. The Vite dev
server proxies /api to the api dev port (3001).

Closes #4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:19:12 +02:00
b16d23297e Scaffold pnpm monorepo with lint, format, and test tooling
pnpm workspace with apps/web, apps/api, apps/collab, and
packages/shared; strict TypeScript base config, repo-wide ESLint (flat)
+ Prettier, Vitest per package, and root scripts lint/typecheck/test/
build. @dorfteich/shared ships a first health-response helper consumed
by apps/api to prove workspace linking. Existing markdown docs are
reformatted once by the new Prettier setup.

Closes #1

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