77542674a2
33 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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
|
|||
| 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
|
|||
| 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
|
|||
| 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
|
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| d4ebcfcfbe |
Add collaboration token issuance and connection authentication (#34)
All checks were successful
CD / Build and push images (push) Successful in 2m45s
CI / Lint, typecheck, test (push) Successful in 1m56s
CI / Auth e2e pack (push) Successful in 2m1s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 12s
The api mints a short-lived (60 s) HS256 JWT per page open after an interim
permission check; the collab server authenticates every connection with it
(ADR 0003/0007 — the only JWTs in the system).
- packages/shared: browser-safe token schema/types in `collab-token`, and the
Node `crypto` sign/verify in `token-crypto` behind its own subpath export
(`@dorfteich/shared/token-crypto`) so the web bundle never pulls in
`node:crypto`. Only HS256 is produced/accepted; the signature is checked in
constant time before any untrusted field is read.
- api: `GET /pages/:id/collab-token` (auth-required) returns
{token, mode, expiresInSeconds}; `mode` is rw/ro via the interim access
service; issuance is logged at debug level without the token value.
- collab: `onAuthenticate` verifies the token, checks the pageId matches the
document name, stores {userId, mode} context, and enforces `ro` via
Hocuspocus' read-only connection flag. Hocuspocus' own signal handling is
disabled so index.ts remains the single shutdown owner.
- Shared COLLAB_TOKEN_SECRET env for api + collab (compose, dev overlay,
.env.example, stage docs); a dev default keeps native dev/test/CI running.
Tests: shared token round-trip/rejection; api endpoint e2e (auth required,
claims, 404 for non-members/unknown ids); collab integration via
HocuspocusProvider (valid token connects; expired/tampered/mismatched-page/
wrong-secret rejected; read-only writes dropped, verified with two clients).
Closes #34
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|||
| 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> |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| b0d9a00c18 |
Fix Prettier formatting in editor-schema (#24)
All checks were successful
CD / Build and push images (push) Successful in 1m50s
CI / Lint, typecheck, test (push) Successful in 1m23s
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 1m4s
CD / Promote to Int (push) Successful in 10s
The #24 commit passed ESLint but not the repo's Prettier check (pnpm lint runs both) — CI caught it after the push. Formatting only, no behavior change. |
|||
| b89aa6bed0 |
Add editor document schema in packages/shared (#24)
Some checks failed
CD / Build and push images (push) Successful in 1m50s
CI / Lint, typecheck, test (push) Failing after 52s
CI / Auth e2e pack (push) Successful in 1m47s
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
ProseMirror schema (headings 1-4, lists incl. task lists, blockquote, code block, tables via prosemirror-tables, images, hard breaks; bold/ italic/code/strikethrough/link marks) plus docToMarkdown, markdownToDoc, docToPlainText, docToHtml, and extractOutline built on it. Markdown parsing extends markdown-it's default preset with a token-stream transform for GFM task lists and table-cell paragraph wrapping. docToHtml hand-rolls escaping and link-protocol allowlisting with zero DOM dependencies, so it runs in the API/collab server as well as the browser. Node names `wikilink` and `plugin_block` are reserved for later stories. Closes #24 |
|||
| 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
|
|||
| 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 |
|||
| 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> |
|||
| 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> |
|||
| f00fb19f32 |
Add mail outbox with SMTP delivery worker and templates
MailService renders transactional mails (verify-email, reset-password) from the new de/en `mails` i18n namespace — text plus minimal HTML with escaped interpolation — and enqueues them into mail_outbox. MailWorker delivers pending rows every 15s through an injectable transport (nodemailer; faked in tests) with quadratic backoff and a permanent FAILED state after five attempts, logged as a warning. SMTP_* and APP_BASE_URL join the environment schema with defaults matching the new Mailpit container in the dev overlay. Closes #12 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| c12acbdb2c |
Add NestJS API skeleton with config, logging, and /healthz
apps/api boots a NestJS application with: Zod-validated environment configuration (schema in @dorfteich/shared, fails fast listing every invalid variable), structured pino request logging via nestjs-pino (pretty in development, JSON otherwise, auth headers redacted), a global exception filter producing the uniform ApiErrorBody shape, and GET /api/v1/healthz. Vitest runs Nest through SWC for decorator metadata; supertest covers healthz and the 404 error shape. Closes #2 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
|||
| 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> |