Commit Graph

30 Commits

Author SHA1 Message Date
d73b120d06 #148: Seitenlisten filtern nach createdSince/updatedSince
Neues pageListQuerySchema (ISO 8601, Kulanz für Datum ohne Zeit),
Query-Parameter auf interner und Public-API-Seitenliste, Prisma-where
mit gte; neue Indizes (pondId, createdAt)/(pondId, updatedAt) als
Migration. OpenAPI-Parameter, MCP-Parität (list_pages
created_since/updated_since), Doku (api-guide, mcp-guide,
public-api.md), DB-Test inkl. 400 bei ungültigem Datum.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 04:06:27 +02:00
0c6494f209 Enforce permissions in API guards and retire interim access (#52)
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m25s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 12s
Every route now declares its access rule explicitly and is enforced
through the shared resolution algorithm (permissions.md):

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 14:02:07 +02:00
91dfccf226 Add SearchProvider interface with PostgreSQL FTS (#49)
All checks were successful
CD / Build and push images (push) Successful in 3m5s
CI / Lint, typecheck, test (push) Successful in 2m19s
CI / Auth e2e pack (push) Successful in 2m51s
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
Full-text search behind a swappable interface (ADR 0010).

- prisma: `page_content_cache.search_vector tsvector` (Unsupported column);
  migration adds it plus a GIN index (raw SQL — the index is a production
  perf optimization; correctness holds without it, so schema-pushed test DBs
  work unchanged).
- shared: `normalizeForSearch` (NFKD + strip diacritics + lowercase) folds
  both the indexed text and the query, so 'Baume' finds 'Bäume' without the
  Postgres `unaccent` extension; search query schema + result view + highlight
  sentinels.
- api search module:
  - abstract `SearchProvider` (DI token: indexPage / removePage / search /
    reindexAll) so an external engine can replace the binding — a fake proves
    the seam in a test.
  - `PostgresSearchProvider`: weighted vector (title A, labels B, body C),
    `websearch_to_tsquery`, `ts_headline` snippets, results filtered to the
    ponds the user may read; `GET /search?q=&pondId=&labels=`.
  - `search:reindex` CLI (rebuilds from the content cache, idempotent).
  - reindex hooks: page create/rename (title) and label assign/unassign/
    rename/delete (labels are weight-B).
- collab: the persistence hook maintains `search_vector` in the same
  transaction as the content cache (same weighting, normalized).
- tests: shared normalize/schema; api db (title ranks above body, highlight,
  diacritic-insensitive match, permission filter, idempotent reindex) and the
  fake-provider DI test; collab persistence already covers the write path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 13:25:05 +02:00
14e69b399c Add wikilink index, backlinks API, and phantom resolution (#47)
All checks were successful
CD / Build and push images (push) Successful in 3m2s
CI / Lint, typecheck, test (push) Successful in 2m16s
CI / Auth e2e pack (push) Successful in 2m44s
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
Maintain a server-side `page_links` index on every content change so
backlinks and missing-target ("phantom") links can be queried.

- prisma: `PageLink` (from_page_id, nullable to_page_id, target_slug;
  unique per (from, slug); cascade on source purge, set-null on target
  purge); migration.
- shared: `extractWikilinkSlugs(doc)` (distinct target slugs) and the
  `BacklinkView` / `PhantomLinkView` read shapes.
- collab: the persistence hook (#35) now rewrites the source page's outgoing
  links in the same transaction as the content cache — one row per distinct
  wikilink slug, resolved to a page in the same pond (null = phantom).
- api: `GET /pages/:id/backlinks` (permission-filtered — wikilinks resolve
  within a pond, so seeing the pond is the read right) and
  `GET /ponds/:id/phantom-links` (missing targets grouped with their
  referrers). Creating or renaming a page to a slug that pages already link
  to resolves those phantom rows; because links store the target's id,
  backlinks survive a later rename of the target's slug.
- tests: shared extraction unit test; collab persistence db test (store
  writes resolved + phantom rows and rewrites the index); api LinksService
  db test (backlinks, permission filter, phantom aggregation, create/rename
  resolution, id-based backlinks survive target rename).

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 10:30:17 +02:00
6fb6f6fce7 Add version snapshots: automatic, named, thinning (#41)
All checks were successful
CD / Build and push images (push) Successful in 2m53s
CI / Lint, typecheck, test (push) Successful in 2m1s
CI / Auth e2e pack (push) Successful in 2m24s
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
Version history is a core kickoff decision (ADR 0013). Snapshots are full,
self-contained encoded Yjs states, so restore never depends on the update
log and compaction (#40) cannot lose history.

(The page_versions / page_pending_contributors tables and base schema
landed a commit early, bundled into 3583a04; this commit completes #41.)

- schema: page_versions gains created_by (editor of manual/pre-restore
  versions; null for automatic snapshots). shared: PageVersionView,
  CreateVersionInput, PageVersionTrigger.
- collab: PostgresVersionStore tracks contributors per open doc (onChange),
  flushes them to the shared page_pending_contributors accumulator on store,
  creates an automatic snapshot on last-participant disconnect (only if
  something changed — no duplicate on a quick reconnect) and every 30
  active-editing minutes. Contributors and snapshot are consumed atomically.
- api: POST /pages/:id/versions creates a named version (write permission,
  label + creator, snapshot reconstructed from persisted state, consumes the
  same contributor accumulator). Daily version-thinning scheduler job keeps
  all versions for 90 days, then the newest auto snapshot per day; manual and
  pre-restore versions are never thinned. pre_restore trigger reserved for #42.

Tests: collab (one auto version on session end with the full two-author
contributor set, none when unchanged, no duplicate on reconnect, interval
snapshot); api (named version stores label+creator, contributor set consumed,
non-owner refused, thinning time-travel keeps newest-per-day beyond window).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 08:37:07 +02:00
3583a046a2 Fix compaction test Bytes typing for strict typecheck (#40)
All checks were successful
CD / Build and push images (push) Successful in 2m17s
CI / Lint, typecheck, test (push) Successful in 2m1s
CI / Auth e2e pack (push) Successful in 2m23s
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 11s
Prisma's Bytes input is Uint8Array<ArrayBuffer>; a Buffer (ArrayBufferLike)
does not satisfy it under strict types. The vitest run (esbuild, no
type-check) and nest build (excludes test files) both passed locally, so
`tsc --noEmit` in CI was the first to see it. Use a fresh Uint8Array copy in
the test fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 08:23:55 +02:00
acd1cc32b7 Add Yjs update-log compaction job (#40)
Some checks failed
CD / Promote to Int (push) Blocked by required conditions
CD / Build and push images (push) Successful in 2m56s
CI / Lint, typecheck, test (push) Failing after 1m17s
CI / Auth e2e pack (push) Successful in 2m24s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Has been cancelled
Update logs grow with every edit; compaction bounds storage and load time.

- prisma: `collab_open_sessions` table (page_id, heartbeat_at) — the
  live-session registry that lets the compaction job avoid pages being
  edited, decoupled from collab (no api↔collab network call) and self-
  healing (a crashed collab's rows age out of the freshness window).
- collab: `PostgresSessionRegistry` marks a page open on document load and
  closed on unload, and refreshes an every-30s heartbeat for all open docs;
  wired into the server hooks and started/stopped in index.ts.
- api: `CompactionService` runs hourly via the shared scheduler (#31). For
  pages with > 500 log rows and no fresh session it merges `page_updates`
  into `ydoc_state` and deletes the merged rows in one FOR UPDATE
  transaction — atomic, so a mid-run crash leaves the page untouched and the
  next run resumes. Content is unchanged (merged state = base + all updates),
  so the content cache is left as-is; updated_at is deliberately not bumped.
  Metric log line with pages compacted / rows / bytes removed.

Tests: DB-backed compaction test (content hash unchanged, below-threshold
skipped, active session skipped then picked up next run, stale heartbeat
ignored, idempotent); session-registry DB test (open/close, heartbeat
refresh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 08:15:55 +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
0fae699018 Add file storage service and image upload API (#27)
All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m43s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
Implements the FileStorage abstraction (uploads/<pondId>/<fileId> on the
mounted volume), the attachments model, and POST /ponds/:id/files, GET
/media/:fileId, DELETE /files/:id. Uploads are validated by sniffing
magic bytes rather than trusting the client's Content-Type/filename
(catches a renamed .html-as-.png), checked against the max_file_bytes
and storage_bytes quotas, and served with nosniff + immutable caching.

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

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

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

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

Closes #22

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

Closes #21

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB
2026-07-05 11:08:16 +02:00
36608177f6 Add user, identity, session, and auth-support data model
Prisma models per data-model.md: users (status enum, site-admin flag),
user_identities (password provider now, OIDC later — subject is the
stable user id), sessions (hashed ids), auth_tokens (hashed, single-
use), plus rate_limits and mail_outbox for the upcoming M1 stories.
UsersService creates accounts transactionally with Argon2id-hashed
password identities (OWASP parameters, rehash detection) and maps
uniqueness violations to field-level conflicts. Database-backed suites
run when TEST_DATABASE_URL is set — locally against the dev db, in CI
via a new postgres service container; shared auth schemas (username,
password policy incl. common-password blocklist) ship with tests.

Closes #10

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:42:22 +02:00
ca0f7cf4b1 Add Prisma with PostgreSQL, automatic migrations, and /readyz
apps/api gains Prisma (instance_settings as the first model) with the
initial migration applied automatically at startup via prisma migrate
deploy, a lazy-connecting PrismaService, and GET /api/v1/readyz
reporting named checks (database reachable, migrations applied) with
200/503. DATABASE_URL joins the validated environment schema;
MIGRATE_ON_START=false skips deploys for tests and tooling. An
idempotent seed script and a Compose dev overlay with PostgreSQL
(host port 5434 — 5433 is taken locally) complete the loop.

Closes #3

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