diff --git a/.gitignore b/.gitignore index c2c797642..b236139d2 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ docs/superpowers/ .factory/ .trae/ .windsurf/ +*.fuse_hidden* diff --git a/CLAUDE.md b/CLAUDE.md index 61d190068..cf58f4eb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,10 +38,11 @@ Read [`docs/architecture.md`](docs/architecture.md) for the system overview, [`d - **Runtime:** Bun (server + tooling). Use Bun, not Node. - **Language:** TypeScript everywhere. -- **Frontend:** React 19 with the **React Compiler enabled** (Babel preset in `vite.config.ts`) + Vite, Zustand + Mutative for state (via `zustand-mutative`; patch-based undo history uses Mutative `create({ enablePatches })` — `immer` is banned), CodeMirror for code-editing UI, `@dnd-kit/core` for drag-and-drop. The compiler auto-memoizes — do not hand-write `useMemo`/`useCallback`/`memo`. See "React Compiler and memoization". Store mutations use draft-mutation style (`set((s) => { s.x = … })`); a recipe that returns a partial must wrap it in `rawReturn(...)` or Mutative emits a perf warning. +- **Frontend:** React 19 with the **React Compiler enabled** (Babel preset in `vite.config.ts`) + Vite, Zustand + Mutative for state (via `zustand-mutative`; `immer` is banned), CodeMirror for code-editing UI, `@dnd-kit/core` for drag-and-drop. The compiler auto-memoizes — do not hand-write `useMemo`/`useCallback`/`memo`. See "React Compiler and memoization". Store mutations use draft-mutation style (`set((s) => { s.x = … })`); a recipe that returns a partial must wrap it in `rawReturn(...)` or Mutative emits a perf warning. - **Server:** `Bun.serve` with a hand-written router (`server/router.ts`). CMS modules at `server/{repositories,handlers/cms,auth,plugins,publish}/`. Deep dive: [`docs/server.md`](docs/server.md). - **Database:** Postgres (`Bun.sql`) OR SQLite (`bun:sqlite`), selected by `DATABASE_URL`. One `DbClient` interface, two adapters, two migration files with identical IDs. Rules: [`docs/reference/database-dialects.md`](docs/reference/database-dialects.md). - **Content model:** All content lives in `data_tables` + `data_rows`. The four system tables (`posts`, `pages`, `components`, `layouts`) are seeded and locked from rename/delete. There are no separate `pages` or `page_versions` tables. +- **Real-time co-editing:** Yjs CRDT engine. One Y doc per row (`page:`, `component:`, `layout:`) + one site-shell doc, multiplexed over `/admin/api/cms/site-socket`. The editor store stays the render source of truth: local mutations apply directly AND translate to Y ops (`@core/collab`); remote/undo changes project back. The server relay (`server/collab/`) persists continuously (blob + derived row JSON) — there is NO client-side save pipeline, no autosave, no Cmd+S. Undo is per-editor per-doc `Y.UndoManager`. Feature doc: [`docs/features/site-shell.md`](docs/features/site-shell.md) → "Real-time co-editing". - **Validation:** TypeBox at every untyped boundary. Schemas are source of truth (`type Foo = Static`, never a parallel `interface`). `zod` is banned repo-wide (the AI drivers pass TypeBox schemas through as JSON Schema, so no typebox→zod adapter is needed). Helpers + patterns: [`docs/reference/typebox-patterns.md`](docs/reference/typebox-patterns.md). - **Sanitization:** DOMPurify at the publisher boundary (`src/core/sanitize.ts`). - **Plugins:** Zip packages with a `plugin.json` manifest, lifecycle hooks. Server entrypoints and canvas module packs run inside a **QuickJS-WASM sandbox** — no Node/Bun ambient access, network gated by `network.outbound` permission + `networkAllowedHosts`. The VM bootstrap (SDK factory + `__run*` dispatchers) is authored as typed TS in `server/plugins/quickjs/bootstrap/src/` and bundled to committed string artifacts in `bootstrap/generated/` — after editing the source run `bun run bootstrap:sync` (gated by `plugin-bootstrap-fresh.test.ts`). Permission enforcement everywhere (VM, host, editor) validates against `grantedPermissions`, never the declared `permissions` array. Feature doc: [`docs/features/plugin-system.md`](docs/features/plugin-system.md). diff --git a/bun.lock b/bun.lock index 5c908ad2d..c37f99ff0 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,7 @@ "fflate": "^0.8.2", "happy-dom": "^20.9.0", "html-to-image": "^1.11.13", + "lib0": "^0.2.117", "lru-cache": "^11.3.5", "marked": "^18.0.3", "mutative": "^1.3.0", @@ -47,6 +48,8 @@ "semver": "^7.7.4", "sharp": "^0.35.0", "uqr": "^0.1.3", + "y-protocols": "^1.0.7", + "yjs": "^13.6.31", "zustand": "^5.0.12", "zustand-mutative": "^1.3.1", }, @@ -1155,6 +1158,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + "istextorbinary": ["istextorbinary@9.5.0", "", { "dependencies": { "binaryextensions": "^6.11.0", "editions": "^6.21.0", "textextensions": "^6.11.0" } }, "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1199,6 +1204,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1685,10 +1692,14 @@ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yjs": ["yjs@13.6.31", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/docs/architecture.md b/docs/architecture.md index 3c4b67c7a..5c4ea327a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,6 +89,7 @@ The repo is organized by responsibility, not by feature. Every file has one reas | HTTP & routing | `server/router.ts`, `server/http.ts` | Request dispatch, body parsing, error envelopes | | CMS endpoints | `server/handlers/cms/*.ts` | Per-resource handlers (pages, posts, components, media, plugins, …) | | Auth & sessions | `server/auth/*` | Session validation, capability checks, login flow | +| Real-time co-editing | `src/core/collab/`, `server/collab/*` | CRDT co-editing (Yjs): one Y doc per row + the site shell, multiplexed over the `/admin/api/cms/site-socket` WebSocket; the server relay persists continuously and resets docs on out-of-relay writes | | Repositories | `server/repositories/*.ts` | Database access; dialect-naive ANSI SQL only | | Database adapters | `server/db/postgres.ts`, `sqlite.ts` | Engine-specific `DbClient` implementation | | Migrations | `server/db/migrations-*.ts` | Schema in both dialects, parity-gated | diff --git a/docs/assets/pr/data-workspace-publishing.png b/docs/assets/pr/data-workspace-publishing.png new file mode 100644 index 000000000..38d882f0a Binary files /dev/null and b/docs/assets/pr/data-workspace-publishing.png differ diff --git a/docs/assets/pr/data-workspace-repeater-gallery.png b/docs/assets/pr/data-workspace-repeater-gallery.png new file mode 100644 index 000000000..ae5dc40d5 Binary files /dev/null and b/docs/assets/pr/data-workspace-repeater-gallery.png differ diff --git a/docs/assets/pr/data-workspace-schema-builder.png b/docs/assets/pr/data-workspace-schema-builder.png new file mode 100644 index 000000000..585f1ba40 Binary files /dev/null and b/docs/assets/pr/data-workspace-schema-builder.png differ diff --git a/docs/collab-hardening-log.md b/docs/collab-hardening-log.md new file mode 100644 index 000000000..8f2532c33 --- /dev/null +++ b/docs/collab-hardening-log.md @@ -0,0 +1,138 @@ +# Collab hardening log + +Cross-run ledger for the autonomous review-and-harden loop on real-time +co-editing. Each run: pick the top open finding (or next priority area), break +it, prove it, fix it at source, gate it with a mutation-checked test, log it +here. Keep this file honest — it is the only memory between runs. + +## Verified solid (don't re-review) + +- **Y-level Y.Text merge granularity** — `src/__tests__/collab/merge.test.ts` + pins character-level convergence for concurrent edits on pre-synced + replicas, and `applyTextDiff`'s minimal-splice semantics when its `oldValue` + matches the doc content. (2026-07-27) +- **Co-typing one text node end-to-end (canvas surface)** — remote Y.Text + edits merge into the live contentEditable mid-session with caret transform; + next local keystroke preserves both intents. Gated by + `src/__tests__/collab/inlineEditRemoteMerge.test.tsx` (canvas mount, real + NodeRenderer wiring) + the wiring source gate in + `src/__tests__/canvas/inlineTextEditingWiring.test.ts`. (2026-07-27) +- **Browser event ordering protects the store-level text diff** — remote + updates apply in WS message macrotasks and the projection flushes on a + microtask (`scheduleProjection` → `queueMicrotask`), so the store is always + projected before the next input-event macrotask reads it. The stale party + was the *DOM surface*, not the store (analysis 2026-07-27; the store-level + path is now additionally guarded against drift). (2026-07-27, hardened + 2026-07-29) +- **Patch-to-Y text drift cannot corrupt the live document** — + `applySitePatchesToDocs` detects a projected pre-value that differs from the + authoritative Y.Text and falls back to a safe diff from the actual value. + Gated by `src/__tests__/collab/applyPatches.test.ts`. (2026-07-29) +- **Whole-node replacement does not freeze an inline session** — + `attachInlineEditRemoteMerge` observes the tree deeply and re-resolves the + current Y.Text after every non-local transaction, so replacing a node map + and its nested text instance keeps merging. Removing the text invalidates + the session. Gated by + `src/__tests__/collab/inlineEditRemoteMerge.test.tsx`. (2026-07-29) +- **RESET closes an inline session only for its active document** — the store + ends the contentEditable session before rebinding the fresh CRDT lineage; + unrelated document resets leave it alone. Gated by + `src/__tests__/collab/awareness.test.tsx`. (2026-07-29) +- **Properties-panel textarea follows the projected value** — it is a + controlled React input with immediate `onChange`; a remote projection + rewrites any not-yet-dispatched native DOM value before the next local input + event, so the stale DOM snapshot cannot clobber the peer. Gated by + `src/__tests__/collab/propertiesPanelTextarea.test.tsx`. (2026-07-29) +- **Reconnect and reset cannot merge a dead CRDT lineage** — reconnect catches + up edits missed in either direction, while a stale generation receives a + RESET instead of being applied. Gated over real WebSockets by + `src/__tests__/server/collabRelayIntegration.test.ts`. (2026-07-29) +- **Partial-role relay writes use the same category policy as HTTP saves** — + direct guard tests cover content/style/structure separation for page and + site docs, roster membership, and the structure-only component/layout rule. + Gated by `src/__tests__/server/collabUpdateGuard.test.ts` plus the real-socket + refused-write/reset path. (2026-07-29) +- **Transient persistence failure cannot silently evict an accepted edit** — + a dirty zero-reference doc remains resident, retries automatically, and is + evicted only after persistence succeeds. Explicit publish/reset flushes fail + rather than continuing with stale derived JSON. Gated by + `src/__tests__/server/collabRelay.test.ts`. Normal shutdown, last-client + release, and publish all flush synchronously; a hard process/host crash can + still lose at most the default 800 ms debounce window (an explicit bounded + recovery-point tradeoff, not an unbounded dirty state). (2026-07-29) +- **Offline/backlogged transport blocks edits visibly once per episode** — + heartbeat timeout and buffered-byte pressure both close the write gate; the + first refused edit toasts and snaps inline editing back, repeats stay quiet, + and recovery re-arms the notice. Gated by + `src/__tests__/collab/provider.test.ts` and + `src/__tests__/collab/collabNotices.test.ts`. (2026-07-29) +- **Provider teardown is terminal** — destroy detaches socket callbacks before + close, clears timers/listeners, and removes every Y.Doc update handler; a + socket that finishes opening late cannot resurrect the heartbeat. Gated by + `src/__tests__/collab/provider.test.ts`. (2026-07-29) +- **PostgreSQL collaboration persistence parity** — on a disposable Postgres + 16 database, the migrations applied from scratch, relay persistence wrote + both `collab_documents` state and derived row JSON, and two real WebSocket + clients edited concurrently, converged, and persisted. The same focused + tests also pass on SQLite. (2026-07-29) + +## Open findings (ranked) + +None from the release-hardening brief. The bounded hard-crash recovery point +(at most the default 800 ms persistence debounce) is documented above. + +## Done this run + +### 2026-07-29 — release integration and text-surface hardening + +- Merged current `origin/main`; the only conflicts were the newer removal of + the publish success callout. The resolution keeps collab sync gating and + adopts the newer global error-toast behavior. +- Guarded patch-to-Y text translation against a stale projected pre-value. +- Reworked inline remote merge to follow replacement Y.Text instances and + invalidate when the edited text disappears. +- Closed active inline editing before a reset rebinds that document. +- Proved the controlled Properties-panel textarea adopts remote projection + before the next local input event. +- Hardened failed persistence: automatic retry, no dirty-doc eviction, and + explicit flush failure instead of stale publish/reset continuation. +- Added adversarial per-document capability-guard coverage. +- Verified real-socket reconnect, offline-authored edit recovery, reset overlap, + stale-generation refusal, and publish flush behavior. +- Verified migrations, continuous persistence, and two-client convergence on + a disposable PostgreSQL 16 instance as well as SQLite. +- Closed provider late-open/listener teardown and pinned the offline notice + latch across outage/recovery episodes. +- Production build and lint pass; the full suite passes 6,419/6,419 tests, + including the real-WebSocket collaboration integration and architecture + gates. + +### 2026-07-27 — co-typing one text node deleted the peer's characters + +- **Defect** (priority area 1, the headline promise): the inline-edit + contentEditable was seeded once per session and never received remote + Y.Text changes; every keystroke committed the element's whole string via + the snapshot diff. With a peer's characters in the doc but not in the + frozen surface, the next local keystroke's diff read them as a local + deletion and removed them from the CRDT. +- **Proof**: canvas-mount repro (real NodeRenderer, iframe portal, real + store + detached collab docs, remote replica at the Y level): peer typed + `" world"` into `"hello"`; store projected `"hello world"`; surface stayed + `"hello"`; one local keystroke `"!"` converged BOTH replicas to + `"hello!"` — the peer's edit silently destroyed everywhere. +- **Fix at source**: `src/admin/pages/site/collab/inlineEditRemoteMerge.ts` — + observe the edited prop's Y.Text for the session's lifetime; fold every + non-local change into the DOM via the same seeding writer; restore the + local caret at an index transformed through the Yjs delta (insert-at-caret + pushes right, matching relative-position association); defer rewrites + during IME composition to `compositionend` (caret then transformed through + a synthesized single-splice delta). Wired in `NodeRenderer`'s session + layout effect. Dedup: `nodeTextOf` moved to `@core/collab` schema (was a + private helper in `caretPositions.ts`). +- **Mutation check**: reverted the NodeRenderer wiring → both canvas + behavioral tests fail (frozen surface, `"hello"` ≠ `"hello world"`) and + the wiring source gate fails; restored → all green. +- **Tests**: `src/__tests__/collab/inlineEditRemoteMerge.test.tsx` (2 canvas + end-to-end incl. convergence back to the peer replica, 3 pure caret + transform, 4 surface-level: `
` caret preservation, LOCAL_ORIGIN + no-rewrite, composition deferral, detach). diff --git a/docs/e2e/feature-validation.tsv b/docs/e2e/feature-validation.tsv index 176c1e3a1..eca98484c 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -30,7 +30,7 @@ SPOT-002 Spotlight scoped commands and pending actions As an admin user, I want SPOT-003 Spotlight recents and destructive confirmation As an admin user, I want recent commands and protection for destructive commands so speed does not compromise safety. Recent commands persist and appear on reopen; destructive commands require two Enter confirmations and timeout collapse after configured duration. Timer expiry; command disappears after state change; repeated Enter double-fire; recents dedupe. Recent store validates local storage; destructive command state owned in Spotlight state; no native confirm dialogs. src/admin/spotlight/recentStore.ts; src/admin/spotlight/state.ts; src/admin/spotlight/SpotlightResults.tsx Confirm timeout is now Playwright-covered; OS accessibility variants remain observational. Happy: recent command appears. Error: destructive command cancel/timeout no mutation. Boundary: confirmation at exactly timeout. Invalid: corrupted recent store. Permission: destructive command still capability gated. Performance: recents load instantly. Mobile: confirm row visible. SPOT-005 destructive confirm-timeout Playwright regression and full command-palette E2E file passed 2026-06-22; broader context-ranking and accessibility exploratory pending 0 None SPOT-005 promoted in `command-palette.e2e.ts`: first Enter arms Delete current page, timeout clears the prompt, and the throwaway page remains. Verification: focused timeout E2E, full `tests/e2e/command-palette.e2e.ts`, and `bun run lint` passed. 2026-06-22 SITE-001 Site document load/save As a site editor, I want the draft site document to load and save so page, style, dependency, and runtime changes persist. Site editor loads /site into store, tracks dirty state, saves PUT /site with granular diff authorization, validates site document, and refreshes active document. Concurrent saves; stale data; corrupted stored site; partial capability user saving only allowed diff; network failure. Site document boundary uses TypeBox validateSite; server diff requires site.structure/content/style capabilities by path; CSRF gate applies. src/admin/pages/site/store; server/handlers/cms/site.ts; server/handlers/cms/siteDiff.ts; src/core/persistence/validate.ts Autosave/manual save behaviour is store-controlled; exact UI feedback from toolbar/canvas. Happy: edit then save/reload. Error: save API failure. Boundary: no-op save. Invalid: corrupted site payload rejected. Permission: content-only cannot save style/structure diff. Performance: save completes without freezing. Mobile: save controls reachable. Manual and automated CAP-002 content/style/structure personas passed after DEF-20260622-001 and DEF-20260622-002 fixes 2026-06-22 0 None DEF-20260622-001 resolved: content-only Text edit saved and survived reload after page-row diff validation plus no-op component/layout save allowance. Style persona set inline Font size 22px, saved, and reload preserved it. Structure persona added a Text layer, saved, and reload preserved the third layer. Automated CAP-002 Playwright regression created separate content/style/structure roles and users, verified each allowed edit saved and survived reload, and verified blocked controls stayed read-only/absent. Verification: bun test src/__tests__/server/capabilityRouteMatrix.test.ts, bun test src/__tests__/property-controls/PropertyControlRenderer.test.tsx, manual browser CAP-002 persona save/reload checks, and `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "CAP-002"`. Run log: docs/e2e/runs/2026-06-22-cap002-automated-edit-boundaries.md. 2026-06-22 SITE-002 Site explorer tree As a site editor, I want pages, components, layouts, and files organized in an explorer so I can manage site assets. SiteExplorerPanel renders structured entries, folders, context menus, create/rename/delete settings dialogs, and DnD organization for supported targets. Empty folders; duplicate paths/slugs; system rows locked; deleting active page/component; file path collisions. Create dialogs normalize item names/paths; server page/component/layout routes validate row shapes; deletion confirmation used. src/admin/pages/site/panels/SiteExplorerPanel; src/admin/shared/dialogs/SiteCreateDialog; server/handlers/cms/pages.ts; server/handlers/cms/components.ts; server/handlers/cms/layouts.ts System tables pages/components/layouts are backed by data_rows. Happy: create and open explorer item. Error: duplicate slug/path message. Boundary: nested folders. Invalid: unsafe path. Permission: pages.edit/site.structure required. Performance: large tree remains responsive. Mobile: explorer panel usable. Automated baseline, build, lint, bundle, and Playwright E2E passed; manual exploratory pending 0 None 2026-06-21 -SITE-003 Page management As a site editor, I want to create, rename, open, and delete pages so public site structure can change. New pages get body tree defaults, title/slug, selectable active document; rename updates slug/title; delete removes row and selection moves safely; unsaved edits remain visible when switching pages and persist after explicit save/reload. Home/root page handling; duplicate slug; deleting current page; unsaved edits before navigation; invalid slug characters. Pages endpoint requires page payload validation; UI slugifies names; capabilities include site.structure.edit/pages.edit. src/admin/pages/site/panels/SiteExplorerPanel; server/handlers/cms/pages.ts; src/core/data/pageFromRow.ts; src/core/page-tree/page.ts Page rows live in data_tables/pages, not legacy pages table. Happy: create/rename/delete page. PAGE-004: edit first page, verify Unsaved draft, switch to another page, return and verify the edit remains, then save/reload and verify persistence. Error: duplicate slug rejected. Boundary: first/last/active page. Invalid: blank title. Permission: read-only cannot mutate. Performance: page switch fast after prewarm. Mobile: dialogs fit. PAGE-004 unsaved page-switch Playwright regression passed 2026-06-22; PAGE-001 through PAGE-004 now have page-management E2E coverage 0 None PAGE-004 promoted in `page-management.e2e.ts`: the test creates two pages, edits Text on the first page, verifies the user-visible Unsaved draft state, switches to the second page and back without losing the in-memory draft, then saves, reloads, and verifies the edit persists. Verification: TSV and diff guards passed; focused `bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts -g "PAGE-004"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts` passed 5/5; bun run lint passed; bun run build passed; bun test passed 5496/5496. Run log: docs/e2e/runs/2026-06-22-page004-unsaved-page-switch.md. 2026-06-22 +SITE-003 Page management As a site editor, I want to create, rename, open, and delete pages so public site structure can change. New pages get body tree defaults, title/slug, selectable active document; rename updates slug/title; delete removes row and selection moves safely; edits remain visible when switching pages and persist across reload. Home/root page handling; duplicate slug; deleting current page; in-flight edits before navigation; invalid slug characters. Pages endpoint requires page payload validation; UI slugifies names; capabilities include site.structure.edit/pages.edit. src/admin/pages/site/panels/SiteExplorerPanel; server/handlers/cms/pages.ts; src/core/data/pageFromRow.ts; src/core/page-tree/page.ts Page rows live in data_tables/pages, not legacy pages table. Happy: create/rename/delete page. PAGE-004: edit first page, verify Draft synced, switch to another page, return and verify the edit remains, then reload and verify persistence. Error: duplicate slug rejected. Boundary: first/last/active page. Invalid: blank title. Permission: read-only cannot mutate. Performance: page switch fast after prewarm. Mobile: dialogs fit. PAGE-004 unsaved page-switch Playwright regression passed 2026-06-22; PAGE-001 through PAGE-004 now have page-management E2E coverage 0 None PAGE-004 promoted in `page-management.e2e.ts`: the test creates two pages, edits Text on the first page, verifies the user-visible Unsaved draft state, switches to the second page and back without losing the in-memory draft, then saves, reloads, and verifies the edit persists. Verification: TSV and diff guards passed; focused `bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts -g "PAGE-004"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts` passed 5/5; bun run lint passed; bun run build passed; bun test passed 5496/5496. Run log: docs/e2e/runs/2026-06-22-page004-unsaved-page-switch.md. 2026-06-22 SITE-004 Visual editor canvas selection and rendering As a site editor, I want to see and select page nodes on a canvas so edits map to visible output. Canvas renders active page/VC/template in iframe frames, overlays selection/hover/ladder, suppresses public form controls, and syncs selected node to panels. Missing module definition; locked/hidden nodes; iframe load race; node inside visual component slot; canvas zoom/pan. Renderer validates node props through module engine; canvas queries only iframe documents; readonly regions enforced. src/admin/pages/site/canvas; src/core/module-engine; src/core/page-tree; src/modules/base Browser evidence needed for overlay geometry and iframe behaviour. Happy: select visible node and panel updates. Error: missing module placeholder. Boundary: hidden/locked nodes. Invalid: bad node props fallback. Permission: read-only can select but not edit. Performance: large tree still responsive. Mobile: canvas controls visible. Manual core lifecycle passed 2026-06-22; automated/build/lint/bundle/Playwright E2E passed; broader exploratory pending 0 None Manual run 2026-06-22 core-owner-lifecycle passed; see docs/e2e/runs/2026-06-22-core-owner-lifecycle.md. 2026-06-22 SITE-005 Module insertion and picker As a site editor, I want to search, navigate, and insert modules from a picker so I can build pages beyond the three canvas-notch favorites. The Add to canvas dialog opens from the canvas notch, focuses Search modules, lists visible registry modules plus saved layouts, visual components, and recent insertions, filters by item search text, supports Enter insertion from the search zone, supports pointer-drag insertion onto measured canvas targets, writes successful insertions to local recent history, lets authors switch between grid and list view, persists that view in localStorage, and closes after successful insertion. Inserted modules are added through the active tree insertion target and selected in the editor. No selected insert target; incompatible parent; hidden internal modules; disabled template-only outlet module; corrupted localStorage prefs; recent refs for deleted items; favorites server preference unavailable; plugin modules with missing dependencies; saved layout invalid tree or VC cycle; search returning no matches; narrow viewport category labels. Module defaults come from module definitions; insertion resolves click/keyboard targets and canvas drag targets through shared tree insertion helpers; hidden/disabled item availability is derived from active document context; recent prefs are TypeBox-validated with fallback; server favorites are TypeBox-validated and default to Container/Text/Image; successful insert tracks a deduped recent ref; category buttons retain accessible names when labels are visually hidden. src/admin/pages/site/module-picker/ModuleInserterDialog.tsx; src/admin/pages/site/module-picker/moduleInserterModel.ts; src/admin/pages/site/module-picker/moduleInserterPrefs.ts; src/admin/pages/site/module-picker/useModuleInserterPreference.ts; src/admin/pages/site/hooks/useInsertModule.ts; src/admin/pages/site/hooks/useInsertInserterItem.ts; src/core/module-engine; src/core/page-tree/mutations.ts; tests/e2e/visual-builder.e2e.ts Base modules register on admin import; Recent and view mode are local browser preferences while notch favorites are a server-backed user preference; saved layouts and visual components have dedicated feature rows for full management/publish behavior. Happy: search for Button, insert it with Enter, verify Button layer appears, reopen picker, verify Button appears in Recent, switch to List view, close/reopen, and verify List view remains active. Error: search no matches shows empty state in lower-level coverage. Boundary: corrupted localStorage falls back to grid/no recents; recent refs dedupe and unresolved refs are ignored; template-only outlet disabled reasons covered lower-level. Invalid: hidden internal modules and malformed saved layout refs cannot be picked. Permission: structure edit required to mutate the active tree. Performance: search/filter and recent rendering complete within E2E timeout. Drag: drag Text from the filtered picker into the center of an existing Container, verify the inside drop preview, edit the inserted Text, and verify it renders inside that Container. Mobile: at 390x844, the dialog is viewport-contained without page-level horizontal overflow, Modules/Recent and Search modules remain reachable, Button can be searched and inserted with Enter, and the inserted Button appears in Layers; category accessible names are covered by SITE-019. SITE-005 Playwright regression passed 2026-06-23; focused module-inserter model, favorites, preference, and dropdown tests cover localStorage fallback, recent dedupe, server favorites, category accessible names, disabled/hidden module availability, and keyboard scaffolding; no product defects found in this slice; plugin-provided module insertion remains residual risk; picker drag-drop into a canvas Container and phone-width picker flow passed 0 None SITE-005 promoted in `visual-builder.e2e.ts`: create a fresh page, open Add to canvas, verify Grid view is active, search `button`, verify only the Button module item remains among checked module ids, press Enter to insert it, verify the Button layer appears in the Page element tree, reopen the picker, switch to Recent and verify Button appears, switch to List view, close/reopen, and verify List view persisted. Focused verification: `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-005"` passed 4/4 including setup. Drag verification: `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-005 drag"` passed 2/2 including setup and verified inside-drop preview plus nested canvas rendering. Mobile verification: `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-005 mobile"` passed 2/2 including setup and verified the phone-width dialog/search/item containment before keyboard insertion. Run logs: docs/e2e/runs/2026-06-23-site005-module-picker.md; docs/e2e/runs/2026-06-23-site005-mobile-module-picker.md; docs/e2e/runs/2026-06-23-site005-picker-drag-drop.md. 2026-06-23 SITE-006 Properties panel and property controls As a site editor, I want to edit selected node content, style, dynamic bindings, HTML attributes, and module-specific settings. PropertiesPanel derives selected node/module schema, renders typed property controls, updates props/breakpoint overrides/classes/custom props/html attrs, and auto-opens on selection. Multi-selection; no selection; controls hidden by condition; invalid module schema; content-only/style-only permission split. Property schema is TypeBox-like module schema; controls use UI primitives; updates route through mutateActiveTree and capability gates. src/admin/pages/site/panels/PropertiesPanel; src/admin/pages/site/property-controls; src/core/module-engine/propertySchema.ts; src/admin/access.ts Some controls are content category, others style/structure. Happy: edit text/image/link props. Error: invalid value message. Boundary: conditional controls. Invalid: unsafe URL/CSS value. Permission: content-only cannot style. Performance: panel scroll remains usable. Mobile: no clipped controls. Manual and automated CAP-002 content/style/structure personas passed after DEF-20260622-001 and DEF-20260622-002 fixes; BUILDER-006 style-controls Playwright regression passed 2026-06-22 0 None DEF-20260622-001 resolved: content-only Text property edit saved and survived reload. DEF-20260622-002 resolved: structure-only persona no longer gets enabled content controls; PropertyControlRenderer now treats content and structure categories separately, with regression coverage for structure-only content controls and layout controls. Automated CAP-002 Playwright regression verified content-only could edit Text but saw style/structure controls read-only or absent, style-only could create a class and set Font size while Text content was disabled, and structure-only could insert a Text layer while Text content was disabled. BUILDER-006 Playwright run created a class on a Button, set Font size 24px, Background color #00aa55, and Padding top 12px through the Properties panel controls, saved/reloaded, published, and verified canvas/public computed CSS. Verification: bun test src/__tests__/property-controls/PropertyControlRenderer.test.tsx, manual browser CAP-002 properties checks, `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "CAP-002"`, `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "spacing, color"`, and publishing-group Playwright regression. Run logs: docs/e2e/runs/2026-06-22-cap002-automated-edit-boundaries.md; docs/e2e/runs/2026-06-22-builder006-style-controls.md. 2026-06-22 @@ -44,8 +44,8 @@ SITE-013 Code editor for site files As a site editor, I want to create, preview, SITE-014 Site dependencies and runtime package resolution As a site editor, I want to declare dependencies so plugin modules and site code can use runtime packages. Dependencies panel edits package metadata; POST /runtime/dependencies/resolve installs/resolves package import map; published pages serve cached runtime packages under /_instatic/runtime/cache. Invalid package.json; network/install failure; stale hash; package not found; runtime path 404 under namespace. Resolve body accepts unknown packageJson and normalizes to safe runtime dependencies only; devDependencies are not resolved into runtime importmaps; client validates dependencyLock/packageImportmap envelopes; runtime package paths require a 24-hex hash and reject traversal. src/admin/pages/site/panels/DependenciesPanel; src/admin/pages/site/hooks/useAutoResolveDependencies.ts; src/core/persistence/cmsRuntime.ts; server/handlers/cms/runtime.ts; server/publish/runtime/dependencyResolver.ts; server/publish/runtime/dependencyCache.ts; server/publish/runtime/packageImportmap.ts; server/publish/runtime/packageServer.ts Package installs may need network; deterministic tests use mocked registry/install/cache roots and UI fetch stubs. Happy: dependency panel detects imports, adds missing dependency, resolves lock/importmap, preview/build consumes runtime deps, package server serves cached package assets, public importmap points at hashed cache URLs, browser can load the emitted package asset, and mobile code authoring exposes the missing-dependency Add action without horizontal overflow. Error: resolve API failure surfaces in store; network/install timeout/cap/partial cache handled; missing package asset 404. Boundary: no dependencies, stale lock, importmap missing, concurrent resolves, cache sentinel, relative RUNTIME_CACHE_DIR. Invalid: unsafe package names, malformed lock envelope, bad runtime hash, traversal path. Permission: runtime.dependencies/site.read caps. Performance: install cache reuse and concurrent install dedupe. Mobile: Code Editor authoring and dependency panel controls stay reachable at 390px. Passed 2026-06-23: deterministic SITE-014 coverage (75 tests), cache layout regression (8 tests), Site Explorer layering invariant (33 tests), focused browser E2E (3 tests including setup), full bun test (5695 pass), lint, and build. 0 None Browser E2E now covers authoring a script import, Dependencies-panel missing package Add, live canvas-confetti registry/cache resolution, save/publish, public marker output, importmap emission, browser loading of /_instatic/runtime/cache package URLs, and a 390px mobile path for missing left-pad import analysis, dependency panel containment, and Add reachability. DEF-20260623-SITE014-01 closed: Code Editor floated above docked sidebars and intercepted Dependencies Add; fixed by raising site sidebars above floating editor panels and updating the layering invariant. DEF-20260623-SITE014-02 closed: relative RUNTIME_CACHE_DIR leaked relative paths to esbuild nodePaths and blocked publish with Could not resolve canvas-confetti despite an installed cache; fixed by absolute-normalizing cacheRootDir. DEF-20260623-SITE014-03 closed: mobile Code Editor authoring was blocked by fixed desktop panel sizing, a horizontal settings rail over the editor, and sidebar interception; fixed with responsive Code Editor viewport clamps, stacked settings panes on narrow screens, and a mobile overlay layer for the active Code Editor. Remaining: live registry/install failure UX permutations. 2026-06-23 SITE-015 Site editor media explorer and picker As a site editor, I want to browse, upload, reuse, inspect, edit, and apply media from inside the site editor so image, video, SVG, and background/media controls can use CMS assets without leaving the authoring workflow. MediaLibraryControl renders library and URL modes for image/video props, lazy-loads MediaPickerModal on Browse, filters by media kind, updates the prop with the picked asset publicPath, supports clearing, and opens MediaViewerWindow for the selected CMS asset. MediaExplorerPanel is a docked left-rail panel that lists CMS assets grouped as Images, Videos, and Other, supports search and list/grid view persistence, uploads assets through CMS media APIs, opens the shared viewer, exposes context menu actions for Copy URL, Rename, Delete, and conditionally Use in selected image/video when the selected canvas node matches the asset kind. Published pages render selected local media through /uploads URLs. Empty library shows bucket empty states; image/video/other assets are bucketed by MIME type; selected image/video actions only appear for matching module and asset kind; unsupported uploads return an alert through the media upload queue; deleted or missing selected paths fall back to saved-path labels; URL mode accepts local /uploads paths plus http/https URLs and rejects invalid image/video URLs; SVG uploads are sanitized before serving; viewer edits/removal update local asset state; upload queue and media viewer can be closed without leaving the editor. CMS media responses are validated by @core/persistence/cmsMedia; uploads are server magic-byte/type/size checked and routed through the media presentation pipeline; MediaLibraryControl validates URL mode before calling onChange; MediaExplorerPanel applies assets only to selected base.image src or base.video videoUrl props; media routes require the relevant media capabilities; publisher escapes/render-validates media URLs and visitor pages must not include admin chrome. src/admin/pages/site/panels/MediaExplorerPanel/MediaExplorerPanel.tsx; src/admin/pages/site/panels/MediaExplorerPanel/mediaExplorerUtils.ts; src/admin/pages/site/property-controls/MediaLibraryControl.tsx; src/admin/pages/site/property-controls/ImageControl.tsx; src/admin/pages/site/property-controls/BackgroundImageControl.tsx; src/admin/pages/media/components/MediaPickerModal/MediaPickerModal.tsx; src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx; src/admin/pages/media/hooks/useStandaloneMediaEditor.ts; src/core/persistence/cmsMedia.ts; server/handlers/cms/media.ts; server/handlers/cms/mediaUpload.ts; src/modules/base/image; src/modules/base/video The docked Media Explorer and the property-control picker intentionally share CMS media and viewer primitives with the Media workspace; direct background-image picker publishing remains covered by lower-level style/publisher tests rather than a dedicated browser journey; the new SITE-015 browser regression uses disposable SQLite/uploads and verifies public media output as a visitor. Happy: upload an image through the property picker, select it for an image module, save, publish, and verify public /uploads image decoding; reuse the same library asset on a second image without re-upload; upload an image through the docked Media Explorer, use its context menu to apply it to the selected image module, save, publish, and verify public /uploads image decoding. Error: unsupported upload shows specific rejection feedback; media API errors surface inline or restore optimistic state. Boundary: empty library, search filters, list/grid view, image/video/other grouping, selected image/video context action gating, metadata rename/edit/reload persistence, replace/delete/restore/purge lifecycle, and sanitized SVG serving. Invalid: bad URL-mode values are rejected before prop update; unsafe SVG script/event/style content is stripped. Permission/security: media APIs require media capabilities and public visitor pages show only uploaded media, not admin chrome. Performance: MediaPickerModal lazy-loads only after Browse; media panel fetches assets when opened. Mobile/responsive: media viewer, replace dialog, trash restore, and storage panel have mobile containment coverage; docked editor Media Explorer mobile remains a residual exploratory check. SITE-015 browser regression passed 2026-06-23 with direct docked Media Explorer apply-to-selected-image coverage; no open defects; docked Media Explorer mobile exploratory remains pending 0 None Added SITE-015 coverage to `tests/e2e/media.e2e.ts`: create a page, insert/select an image module, open the Media panel, upload an image through the docked panel, use the asset context menu `Use in selected image`, save, publish, and verify the public page serves/decodes the uploaded image. Existing coverage in `media.e2e.ts` covers picker upload/select/publish, asset reuse, unsupported upload rejection, metadata persistence, mobile metadata viewer containment, replace/delete/restore/purge lifecycle, mobile lifecycle containment, storage panel state/mobile containment, and SVG sanitization. Focused component coverage in `siteExplorerPanel.test.tsx` covers Media Explorer grouping, search/list/grid, copy URL, selected image/video apply actions, viewer opening, rename, and delete. Verification this slice: TSV integrity guard passed; focused SITE-015 unit/component/API `bun test` passed 102/102; focused `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts -g "SITE-015"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts` passed 12/12 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run log: docs/e2e/runs/2026-06-23-site015-media-explorer.md. 2026-06-23 SITE-016 Preview overlay and live-page opening As a site editor, I want to preview the current draft and open the live route so I can compare draft and published output. Preview page is exposed from the publish-actions menu; PreviewOverlay mounts only when previewOpen, requires an active site and active page, posts the current in-memory site and active page to the CMS runtime-preview endpoint and renders the server-built document in a sandboxed iframe srcDoc, shows the active page title, closes by Close button/Escape/backdrop, and restores focus. OpenLivePageButton is globally mounted in the toolbar, reads adminUi.activeLivePath, and opens that path in a new noopener/noreferrer tab or falls back to the site root. useActiveLivePath publishes regular page paths, template preview targets, post-type preview permalinks, or /404 for not-found templates. No active site/page renders no overlay; unpublished saved drafts can preview without changing public output; live route shows last published artefact until publish; activeLivePath null opens root; template pages are not directly routable and resolve to their preview target; popup uses the current dev/admin origin but Vite proxies public routes; narrow viewport must keep preview reachable and document width contained. Preview state is owned by uiSlice openPreview/closePreview; PreviewOverlay sends the validated in-memory draft to the site.read-gated runtime-preview endpoint, which prefetches loop and media data before the publisher boundary sanitizes emitted HTML; iframe uses sandbox="" with no allow flags; OpenLivePageButton receives the already-resolved public path from adminUi; save/publish remain capability and step-up gated by surrounding toolbar flows. src/admin/pages/site/toolbar/PublishButton.tsx; src/admin/pages/site/toolbar/PublishActionGroup.tsx; src/admin/pages/site/preview/PreviewOverlay.tsx; src/admin/pages/site/store/slices/uiSlice.ts; src/admin/pages/site/hooks/useActiveLivePath.ts; src/admin/shared/OpenLivePageButton/OpenLivePageButton.tsx; src/core/persistence/cmsRuntime.ts; server/handlers/cms/runtime.ts; server/publish/runtime/previewRuntime.ts; src/core/publisher; src/core/page-tree/page.ts Public live routes intentionally show the last published version, not the saved draft; this slice covers regular pages, while template/content-entry live-path permutations remain covered lower-level or future browser coverage. Happy: create page, publish version A, save draft version B, open Preview page and verify iframe shows B, open live page and verify popup route shows A without admin chrome. Error: no-site/no-active-page overlay and close behaviours covered by component tests. Boundary: activeLivePath root fallback, home path, content entry path, template target resolver, and mobile 390px preview reachability. Invalid: missing runtime preview body remains covered by server runtime tests, not this client overlay. Permission/security: publish/save capability and step-up gates surround the flow; public popup has no admin chrome. Performance: preview lazy-loads the overlay and starts one abortable runtime-preview request only when opened. Mobile: overlay opens at 390px without document overflow. SITE-016 browser verification passed 2026-07-22 after issue #234 restored loop parity in Preview page; draft-vs-live comparison and mobile preview reachability remain covered; template/content live-path browser permutations remain residual risk 0 None Added `tests/e2e/preview-live.e2e.ts`: publish a disposable page with text A, save draft text B without publishing, verify Preview page iframe shows B and not A, verify toolbar Open live page popup shows A and not B without editor chrome, then reopen Preview page at 390x844 and verify no document overflow. Initial focused run failed because the new spec selected a layer while the Site Explorer was open; root cause was a spec precondition, fixed by opening the Layers panel before selecting the Text node. Verification this slice: TSV integrity guard passed; focused preview/live unit suite passed 52/52; focused `bun run test:e2e -- --project=e2e tests/e2e/preview-live.e2e.ts -g "SITE-016"` passed 2/2 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run logs: docs/e2e/runs/2026-06-23-site016-preview-live.md and docs/e2e/runs/2026-07-22-issue-234-preview-loop-retest.md. Issue #234 was reproduced with a `site.pages` loop visible in the canvas and public route but missing from Preview page. PreviewOverlay now delegates current-draft rendering to the server runtime-preview boundary, which prefetches loop and media data before publishing. Verification passed: same-flow Chromium retest plus live-route check, focused runtime/preview tests 44/44, full `bun test` 6237/6237, `bun run lint`, and `bun run build`. 2026-07-22 -SITE-017 Visual components and slots As a site editor, I want to componentize authored page content, add reusable slots, fill those slots on a page, and publish the resulting page as clean visitor HTML. Componentize converts the selected page node into a Visual Component row with a base.body definition root, replaces the original page node with a base.visual-component-ref, and switches the editor into VC mode. Adding a base.slot-outlet to the VC definition creates a locked base.slot-instance child on the page ref when returning to the page. The locked slot row hides destructive structural actions but accepts inserted child content. Save writes component rows before page rows so newly-created component refs validate; publish inlines the component definition and slot fill into the public artefact without editor slot labels or component names. Duplicate or blank component names; conversion attempted from VC mode, body/root, or an existing component ref; recursive refs; unknown or missing componentId; slot outlet rename/reorder/delete; empty or default slots; nested refs; page save racing a new component save; save/publish/reload after slot fill insertion. Component names and recursion use typed VisualComponent errors; VC/page shapes are TypeBox-validated through component/page adapters; syncSlotInstances materializes locked slot instances from slot outlets; dirty tracking marks both edited page and created component; CmsAdapter writes components before pages; publisher renderVisualComponentRef resolves refs, expands slot-instance children at matching outlets, and sanitizes emitted HTML. src/admin/pages/site/panels/PropertiesPanel/ConvertToComponentButton.tsx; src/admin/pages/site/store/slices/visualComponentsSlice.ts; src/admin/pages/site/store/slices/site/dirtyTracking.ts; src/core/persistence/cms.ts; server/handlers/cms/pages.ts; server/handlers/cms/components.ts; src/core/visualComponents; src/modules/base/visualComponentRef; src/modules/base/slotOutlet; src/modules/base/slotInstance; src/core/publisher/renderVisualComponentRef.ts Visual Components persist as rows in the components system table; page rows can reference a component only after that component row is stored; E2E uses disposable local SQLite/uploads and fresh owner login because publish rotates the session. Happy: componentize Text, create a slot outlet, return to page, insert Text into the locked slot, save, publish, and verify anonymous public page contains component body plus slot fill. Error: adapter ordering regression prevented new component refs from validating during page save. Boundary: locked slot row remains operable while hiding Rename/Duplicate/Cut/Delete; empty/default/nested/unknown component behavior covered by lower-level suites. Invalid: blank/duplicate names and recursive refs rejected by focused tests. Permission: publish step-up exercised; fine-grained capability variants remain lower-level/future browser coverage. Performance: save ordering is sequential only where required, layouts remain independent. Mobile: VC publish journey desktop-covered; mobile VC editing remains residual. SITE-017 browser regression passed 2026-06-23 after DEF-20260623-SITE017-001 fix; no open high/critical defects for this feature slice; mobile and permission permutations remain residual 0 None DEF-20260623-SITE017-001 fixed: publishing a freshly componentized page could emit an empty body because CmsAdapter saved pages and components in parallel, and the pages endpoint stripped the new base.visual-component-ref as dangling when it validated before the component row committed. Fix: write /admin/api/cms/components before starting /admin/api/cms/pages. Added `tests/e2e/visual-builder.e2e.ts` SITE-017 public publish journey, `src/__tests__/persistence/cmsAdapter.test.ts` ordering regression, and `src/__tests__/editor-store/dirtyTracking.test.ts` page+component dirty-mark guard. Verification passed: TSV integrity guard, focused VC suite 243/243, cmsAdapter 11/11, dirtyTracking 29/29, Playwright SITE-017 2/2 including setup, `bun run lint`, `bun run build`, and full `bun test` 5699/5699. Run log: docs/e2e/runs/2026-06-23-site017-visual-components.md. 2026-06-23 -SITE-018 Templates and dynamic bindings As a site editor/content author, I want page templates and bindings so data rows render through site pages. Site Explorer creates template pages through Template settings, stores enabled template target and priority on the page row, opens the template in canvas mode, and shows synthetic preview data for postType templates. DynamicBindingControl auto-scopes string props on postTypes templates to the targeted table, inserts `{currentEntry.title}` tokens into text props, and `base.outlet` implicitly binds currentEntry.body. Save Draft persists template state, Publish snapshots it, and public `/posts/:slug` routes render the highest-priority matching published template with the published row title and body. No matching template or row; competing template priority/tie order; deleted target table/field; no compatible fields; empty/null title/body values; unpublished template or row; duplicate template slug; invalid binding path; missing outlet; loops nested inside templates. TemplateSettingsDialog requires nonblank title, unique normalized slug, numeric priority, and at least one selected post type when target kind is postTypes. PageTemplateConfig is TypeBox-parsed from page rows; binding picker filters fields by control compatibility; token interpolation and dynamic prop resolution tolerate unknown/empty fields; `base.outlet` body HTML is sanitized at the publisher boundary; public entry routes use published rows and published site snapshots only. src/core/page-tree/pageTemplate.ts; src/core/templates/templateMatching.ts; src/core/templates/templatePreviewData.ts; src/core/templates/dynamicBindings.ts; src/admin/shared/dialogs/TemplateSettingsDialog/TemplateSettingsDialog.tsx; src/admin/pages/site/canvas/TemplateModeControl.tsx; src/admin/pages/site/property-controls/DynamicBindingControl; src/modules/base/outlet; tests/e2e/visual-builder.e2e.ts; src/__tests__/templates; src/__tests__/server/cmsTemplateRoutes.test.ts The Posts table is seeded but entry templates are author-owned; SITE-018 publishes its own preview row and creates a priority-300 Posts template so it wins over lower-priority content fixtures deterministically. Content row publishing is step-up gated and runs from a fresh session. Happy: publish a post, create a Posts template, select that row as Preview source, bind title/body, save/publish, and verify the public route. Error: no matching row/template falls back or 404s in lower-level route tests. Boundary: the explicitly selected real preview row wins over newer unrelated posts, and the priority-300 template wins over lower-priority fixtures. Invalid: empty postTypes selection, invalid priority/slug, incompatible binding fields. Permission: site edit plus content publish capabilities required; persona splits remain pending. Performance: focused E2E completes template preview and publish within Playwright timeouts. Mobile: template controls still need narrow-viewport authoring coverage. SITE-018 focused Playwright regression passed 2026-07-11; real-row preview selection and competing-template priority are deterministic; mobile authoring and permission personas remain residual risk 0 None The release-gate regression now publishes a disposable post first, creates a priority-300 Posts template, explicitly selects that post through Preview source, verifies its title/body in canvas, publishes the template, and verifies the anonymous route without unresolved tokens. The 2026-07-11 sequential focus run passed SITE-018 together with lower-priority content-template fixtures. 2026-07-11 +SITE-017 Visual components and slots As a site editor, I want to componentize authored page content, add reusable slots, fill those slots on a page, and publish the resulting page as clean visitor HTML. Componentize converts the selected page node into a Visual Component row with a base.body definition root, replaces the original page node with a base.visual-component-ref, and switches the editor into VC mode. Adding a base.slot-outlet to the VC definition creates a locked base.slot-instance child on the page ref when returning to the page. The locked slot row hides destructive structural actions but accepts inserted child content. Save writes component rows before page rows so newly-created component refs validate; publish inlines the component definition and slot fill into the public artefact without editor slot labels or component names. Duplicate or blank component names; conversion attempted from VC mode, body/root, or an existing component ref; recursive refs; unknown or missing componentId; slot outlet rename/reorder/delete; empty or default slots; nested refs; page save racing a new component save; save/publish/reload after slot fill insertion. Component names and recursion use typed VisualComponent errors; VC/page shapes are TypeBox-validated through component/page adapters; syncSlotInstances materializes locked slot instances from slot outlets; dirty tracking marks both edited page and created component; CmsAdapter writes components before pages; publisher renderVisualComponentRef resolves refs, expands slot-instance children at matching outlets, and sanitizes emitted HTML. src/admin/pages/site/panels/PropertiesPanel/ConvertToComponentButton.tsx; src/admin/pages/site/store/slices/visualComponentsSlice.ts; src/admin/pages/site/store/slices/site/collabBinding.ts; src/core/collab/applyPatches.ts; server/handlers/cms/pages.ts; server/handlers/cms/components.ts; src/core/visualComponents; src/modules/base/visualComponentRef; src/modules/base/slotOutlet; src/modules/base/slotInstance; src/core/publisher/renderVisualComponentRef.ts Visual Components persist as rows in the components system table; page rows can reference a component only after that component row is stored; E2E uses disposable local SQLite/uploads and fresh owner login because publish rotates the session. Happy: componentize Text, create a slot outlet, return to page, insert Text into the locked slot, save, publish, and verify anonymous public page contains component body plus slot fill. Error: adapter ordering regression prevented new component refs from validating during page save. Boundary: locked slot row remains operable while hiding Rename/Duplicate/Cut/Delete; empty/default/nested/unknown component behavior covered by lower-level suites. Invalid: blank/duplicate names and recursive refs rejected by focused tests. Permission: publish step-up exercised; fine-grained capability variants remain lower-level/future browser coverage. Performance: save ordering is sequential only where required, layouts remain independent. Mobile: VC publish journey desktop-covered; mobile VC editing remains residual. SITE-017 browser regression passed 2026-06-23 after DEF-20260623-SITE017-001 fix; no open high/critical defects for this feature slice; mobile and permission permutations remain residual 0 None DEF-20260623-SITE017-001 fixed: publishing a freshly componentized page could emit an empty body because CmsAdapter saved pages and components in parallel, and the pages endpoint stripped the new base.visual-component-ref as dangling when it validated before the component row committed. Fix: write /admin/api/cms/components before starting /admin/api/cms/pages. Added `tests/e2e/visual-builder.e2e.ts` SITE-017 public publish journey, `src/__tests__/persistence/cmsAdapter.test.ts` ordering regression, and `src/__tests__/editor-store/dirtyTracking.test.ts` page+component dirty-mark guard. Verification passed: TSV integrity guard, focused VC suite 243/243, cmsAdapter 11/11, dirtyTracking 29/29, Playwright SITE-017 2/2 including setup, `bun run lint`, `bun run build`, and full `bun test` 5699/5699. Run log: docs/e2e/runs/2026-06-23-site017-visual-components.md. 2026-06-23 +SITE-018 Templates and dynamic bindings As a site editor/content author, I want page templates and bindings so data rows render through site pages. Site Explorer creates template pages through Template settings, stores enabled template target and priority on the page row, opens the template in canvas mode, and shows synthetic preview data for postType templates. DynamicBindingControl auto-scopes string props on postTypes templates to the targeted table, inserts `{currentEntry.title}` tokens into text props, and `base.outlet` implicitly binds currentEntry.body. The collab relay persists template state continuously, Publish snapshots it, and public `/posts/:slug` routes render the highest-priority matching published template with the published row title and body. No matching template or row; competing template priority/tie order; deleted target table/field; no compatible fields; empty/null title/body values; unpublished template or row; duplicate template slug; invalid binding path; missing outlet; loops nested inside templates. TemplateSettingsDialog requires nonblank title, unique normalized slug, numeric priority, and at least one selected post type when target kind is postTypes. PageTemplateConfig is TypeBox-parsed from page rows; binding picker filters fields by control compatibility; token interpolation and dynamic prop resolution tolerate unknown/empty fields; `base.outlet` body HTML is sanitized at the publisher boundary; public entry routes use published rows and published site snapshots only. src/core/page-tree/pageTemplate.ts; src/core/templates/templateMatching.ts; src/core/templates/templatePreviewData.ts; src/core/templates/dynamicBindings.ts; src/admin/shared/dialogs/TemplateSettingsDialog/TemplateSettingsDialog.tsx; src/admin/pages/site/canvas/TemplateModeControl.tsx; src/admin/pages/site/property-controls/DynamicBindingControl; src/modules/base/outlet; tests/e2e/visual-builder.e2e.ts; src/__tests__/templates; src/__tests__/server/cmsTemplateRoutes.test.ts The Posts table is seeded but entry templates are author-owned; SITE-018 publishes its own preview row and creates a priority-300 Posts template so it wins over lower-priority content fixtures deterministically. Content row publishing is step-up gated and runs from a fresh session. Happy: publish a post, create a Posts template, select that row as Preview source, bind title/body, publish, and verify the public route. Error: no matching row/template falls back or 404s in lower-level route tests. Boundary: the explicitly selected real preview row wins over newer unrelated posts, and the priority-300 template wins over lower-priority fixtures. Invalid: empty postTypes selection, invalid priority/slug, incompatible binding fields. Permission: site edit plus content publish capabilities required; persona splits remain pending. Performance: focused E2E completes template preview and publish within Playwright timeouts. Mobile: template controls still need narrow-viewport authoring coverage. SITE-018 focused Playwright regression passed 2026-07-11; real-row preview selection and competing-template priority are deterministic; mobile authoring and permission personas remain residual risk 0 None The release-gate regression now publishes a disposable post first, creates a priority-300 Posts template, explicitly selects that post through Preview source, verifies its title/body in canvas, publishes the template, and verifies the anonymous route without unresolved tokens. The 2026-07-11 sequential focus run passed SITE-018 together with lower-priority content-template fixtures. 2026-07-11 SITE-019 Saved layouts As a site editor, I want to save and reuse layouts so common structures can be inserted quickly. Layouts are stored in the layouts system table; the Save as layout dialog rejects blank and duplicate names; the module inserter lists saved layouts under Layouts; inserting a saved layout clones the captured subtree and style rules with fresh node ids; renaming or deleting the saved layout does not affect already-inserted page content. Duplicate layout names; blank names; saved layout source on page root; stale selections after page creation; mobile/narrow inserter category labels; context-menu z-index inside the spotlight inserter; deleted modules/VC refs inside a layout; invalid tree; deleting a saved layout already used by a page has no page effect. Layout route validates layout document; clone remaps node ids/scoped classes; system table locked from rename/delete; addPage clears stale canvas selection; module inserter category buttons keep explicit accessible names when labels hide responsively; saved-layout manage menu renders above the spotlight layer. server/handlers/cms/layouts.ts; src/admin/pages/site/dialogs/LayoutNameDialog.tsx; src/admin/pages/site/module-picker/ModuleInserterDialog.tsx; src/admin/pages/site/module-picker/SavedLayoutManageMenu.tsx; src/admin/pages/site/store/slices/layoutsSlice.ts; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/data/layoutFromRow.ts Layouts are not separate DB tables; selector selection can persist across page switches, but stale node selection must not. Happy: save styled container subtree as layout, insert it on a new page, save/reload/publish, verify public output. Error: blank and duplicate names render inline errors. Boundary: narrow 390px inserter exposes Layouts. Invalid: stale node selection is cleared on addPage. Permission/security: structure edit and system table gates remain server-covered. Performance: picker remains responsive with saved item. Manage: rename and delete saved layout; inserted content persists. SITE-019 Playwright browser regression passed 2026-06-23 after DEF-20260623-SITE019-001/002/003 fixes; store selection and toolbar category accessibility regressions passed; no open high/critical defects in this slice; plugin-pack grouping, VC-mode cycle blocking, invalid/dangling layout snapshots, permission personas, and real touch/long-press mobile management remain residual risk 0 None DEF-20260623-SITE019-001 fixed: creating a new page via addPage left selectedNodeId/hover/inline-edit state from the previous page, keeping the right inspector expanded with no valid selected element and blocking narrow-width canvas controls. Fix: addPage reuses clearCanvasSelectionDraft; regression `src/__tests__/editor-store/pageActionsSelection.test.ts`. DEF-20260623-SITE019-002 fixed: module inserter category buttons lost accessible names at <=720px because visible labels were display:none and icons were aria-hidden, leaving count-only buttons. Fix: explicit aria-label on category buttons; regression in `src/__tests__/toolbar/modulePickerDropdown.test.tsx`. DEF-20260623-SITE019-003 fixed: saved-layout Rename/Delete context menu rendered at z-index 1000 under the spotlight inserter at z-index 9000, making menu items visible to accessibility APIs but unclickable. Fix: saved-layout manage menu uses zIndex 10000. Added `visual-builder.e2e.ts` SITE-019 browser journey covering blank/duplicate validation, mobile Layouts category reachability, insert, style preservation, rename, delete, save/reload, publish, and anonymous public output. Verification: focused `bun test src/__tests__/editor-store/pageActionsSelection.test.ts`, `bun test src/__tests__/toolbar/modulePickerDropdown.test.tsx`, and `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-019"` passed. Run log: docs/e2e/runs/2026-06-23-site019-saved-layouts.md. 2026-06-23 SITE-020 HTML import modal As a site editor, I want to paste/import HTML so existing markup becomes editable page nodes. ImportHtmlModal parses HTML, strips unsafe content, maps elements to page nodes/modules, and inserts fragment into active tree. SVG mapping; malformed HTML; unsupported tags; script/style stripping; text normalization. HTML import uses parser/stripUnsafe rules and module mapping; insertion still uses tree mutation legality. src/admin/modals/ImportHtml; src/core/htmlImport Imported styles may need manual class cleanup. Happy: paste simple HTML becomes nodes. Error: malformed HTML feedback. Boundary: empty fragment. Invalid: script tag stripped. Permission: structure edit. Performance: large paste bounded. Mobile: modal usable. Automated baseline, build, lint, bundle, and Playwright E2E passed; manual exploratory pending 0 None 2026-06-21 SITE-021 Super Import site/bundle wizard As an operator, I want to import static-site files or CMS bundles so I can move content into Instatic. SiteImport modal accepts drops, analyzes files/bundles, shows conflicts/review, supports import strategies, applies asset rewrites/stylesheets/fonts, and emits refresh events. Unsupported archive; path traversal; conflicting slugs/files; replace strategy destructive; invalid bundle schema. Import preview/import routes validate SiteBundle and import strategy; replace/import requires data.import and sometimes content.manage/step-up; path traversal tests gate. src/admin/modals/SiteImport; server/handlers/cms/importPreview.ts; server/handlers/cms/import.ts; src/core/siteImport; src/core/data/bundleSchema.ts Use disposable DB for replace import. Happy: import small CMS bundle. Error: invalid zip/bundle. Boundary: merge-add vs merge-overwrite vs replace. Invalid: traversal path. Permission: data.import/content.manage and step-up. Performance: progress shown. Mobile: wizard scrolls. CMS-bundle replace import step-up Playwright regression and SiteImportModal unit suite passed 2026-06-22; static-file import exploratory still pending 0 None DEF-20260622-012 resolved: replace import hit the server step-up gate but useCmsBundleImport caught `step_up_required` as a generic import failure toast, leaving fresh sessions unable to complete destructive imports. Fix wraps importSiteBundle in runStepUp, keeps cancel non-destructive/non-error, and retries after successful step-up. Reverified 2026-06-22 and normalized stale high-severity accounting: destructive site import step-up E2E passed 2/2 including setup, and the focused auth/site-import/data unit bundle passed 79/79. Static-file import exploratory remains untested risk, not a known open high defect. Verification: `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "destructive site import requires successful step-up"` and `bun test src/__tests__/admin/siteImport/SiteImportModal.test.tsx`. Run logs: docs/e2e/runs/2026-06-22-cap003-destructive-import-step-up.md; docs/e2e/runs/2026-06-22-high-severity-defect-accounting.md. 2026-06-22 @@ -121,7 +121,7 @@ PERF-002 Moderately complex publish completion As a site editor, I want publishi PAGE-001 Create and open a site page As a site editor, I want to create a new page from the Site Explorer so the page becomes available for editing in the visual canvas. The Site Explorer New page action opens the page creation dialog, accepts a name and slug, creates the page, hides the dialog, shows an Open page tree item with the new name, and selecting that item marks it selected in the editor tree. Duplicate slugs; reserved slugs; missing or empty names; stale tree state after dialog close; failed create request; existing homepage collision; page created but not openable. The New page dialog uses page slug validation, duplicate-slug checks, shared dialog inputs, and the site/page store creation action; create and reload data flow through validated CMS persistence APIs before the tree item is rendered. tests/e2e/page-management.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/shared/dialogs/SiteCreateDialog/SiteCreateDialog.tsx; src/admin/pages/site/panels/SiteExplorerPanel; src/admin/pages/site/store/slices/site/pageActions.ts; server/handlers/cms/pages.ts; src/core/page-tree/slugs.ts The automated browser regression creates uniquely named disposable pages in a local SQLite E2E database; deeper invalid slug and duplicate handling are covered by validation/error rows and lower-level tests. Happy: create a unique page and open it in the canvas. Error: failed create should leave recoverable dialog feedback. Boundary: unique generated slug. Invalid: reserved/empty/duplicate slug blocked by dialog validation. Permission: requires authenticated Site page creation access. Performance: tree item appears within E2E timeout. Mobile: page creation on narrow editor chrome remains future exploratory coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-001 scenario creates a unique About page, verifies the tree item named Open page appears, clicks it, and asserts aria-selected=true. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 PAGE-002 Rename and reopen a site page As a site editor, I want to rename a page from the Site Explorer so the navigation tree and editable page context reflect the new title. A page tree context-menu Rename action opens a rename textbox labelled with the current page name, Enter commits the new name, the new tree item becomes visible and openable, and the old tree item disappears. Name-only rename with unchanged slug; duplicate or invalid path edits; context menu on wrong row; stale selected page after rename; old tree item lingering; renamed page not openable. Explorer rename uses page slug/name validation paths and page mutation/persistence actions; the browser regression verifies user-visible tree state and selection rather than direct API state. tests/e2e/page-management.e2e.ts; src/admin/pages/site/explorer-actions/ExplorerRenameDialog.tsx; src/admin/pages/site/panels/SiteExplorerPanel; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/page-tree/slugs.ts The covered flow renames the display name through the context menu; public slug/open-route rename semantics beyond the visible tree selection are lower-level or future browser coverage. Happy: rename page and open renamed item. Error: invalid rename should keep feedback in dialog. Boundary: one rename immediately after create. Invalid: duplicate/reserved slug blocked by rename validation. Permission: requires Site page management access. Performance: tree updates without reload. Mobile: context-menu rename on narrow viewport remains future exploratory coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-002 scenario creates a Pricing page, opens Rename from the page tree context menu, enters a Plans name, verifies the renamed row is visible, verifies the original row count is zero, and opens the renamed row. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 PAGE-003 Delete a site page safely As a site editor, I want page deletion to require an explicit confirmation so accidental destructive actions are visible before the page is removed. A disposable page can be deleted from the Site Explorer context menu only after the Delete page? alert dialog is shown and the Delete page button is clicked; after confirmation the page tree item is removed. Deleting the selected page; deleting homepage/system pages; cancelling the confirmation; stale selection after removal; double-submit; failed delete request; page references remaining after delete. The browser flow requires an alertdialog confirmation before the destructive action; page deletion goes through editor page actions and validated CMS persistence rather than direct tree mutation. tests/e2e/page-management.e2e.ts; src/admin/pages/site/explorer-actions/ExplorerItemContextMenu.tsx; src/admin/pages/site/panels/SiteExplorerPanel; src/admin/pages/site/store/slices/site/pageActions.ts; server/handlers/cms/pages.ts The regression deletes a freshly created non-home page; homepage/system-page protections and delete-cancel variants remain covered elsewhere or future exploratory coverage. Happy: create page, choose Delete, confirm, and verify row removal. Error: failed delete should keep recoverable UI. Boundary: newly created empty page. Invalid: protected page deletion rejected. Permission: requires page delete capability. Performance: tree item removal visible promptly. Mobile: delete confirmation on narrow viewport remains future exploratory coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-003 scenario creates a Disposable page, opens the row context menu, clicks Delete, verifies the Delete page? alert dialog, confirms Delete page, and verifies the row count becomes zero. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 -PAGE-004 Switch pages without losing unsaved edits As a site editor, I want to switch between pages after making an unsaved edit so I can compare pages without losing in-memory draft work before I save. After inserting and editing Text on one page, the toolbar/status exposes Unsaved draft, switching to a second page hides that first page text, switching back shows the unsaved text again, and saving plus reloading preserves the text. Unsaved edits on multiple pages; save failure after page switching; stale active page after reload; missing selected page tree item; browser reload before save; session expiry; conflicting edits from another tab. Editor state keeps page drafts by page id until save; Save Draft persists through validated site/page APIs; canvas assertions read the active page iframe after each page selection. tests/e2e/page-management.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/pages/site/store/slices/saveTrackingSlice.ts; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/persistence/validate.ts; server/handlers/cms/pages.ts The automated case covers one unsaved text edit across two disposable pages, then explicit save and reload; crash recovery and multi-tab conflict handling remain separate reliability coverage. Happy: unsaved text survives page switch and persists after save/reload. Error: save failure should not falsely report persistence. Boundary: one page switch away and back. Invalid: corrupted page tree rejected by validation. Permission: authenticated Site edit access required. Performance: page switching and canvas updates complete within E2E timeout. Mobile: narrow editor page switching remains future coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-004 scenario creates two pages, edits the first with unique Text, sees Unsaved draft, opens the second and verifies the text is absent, reopens the first and verifies the text plus Unsaved draft, then saves, reloads, reopens the page, and verifies the text persists. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 +PAGE-004 Switch pages without losing live edits As a site editor, I want to switch between pages after an edit so I can compare pages without losing work; edits stream to the collab relay, so there is no save step. After inserting and editing Text on one page, the toolbar/status exposes Draft synced, switching to a second page hides that first page text, switching back shows the text again, and reloading preserves it. Unsaved edits on multiple pages; save failure after page switching; stale active page after reload; missing selected page tree item; browser reload before save; session expiry; conflicting edits from another tab. Local mutations apply to the editor store and translate to Y operations that stream to the relay, which persists continuously; canvas assertions read the active page iframe after each page selection. tests/e2e/page-management.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/pages/site/store/slices/site/collabBinding.ts; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/collab/project.ts; server/collab/relay.ts The automated case covers one text edit across two disposable pages, then reload; crash recovery and multi-tab conflict handling remain separate reliability coverage. Happy: text survives page switch and persists across reload. Error: a relay disconnect should not falsely report a synced draft. Boundary: one page switch away and back. Invalid: corrupted page tree rejected by validation. Permission: authenticated Site edit access required. Performance: page switching and canvas updates complete within E2E timeout. Mobile: narrow editor page switching remains future coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-004 scenario creates two pages, edits the first with unique Text, sees Unsaved draft, opens the second and verifies the text is absent, reopens the first and verifies the text plus Unsaved draft, then saves, reloads, reopens the page, and verifies the text persists. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 EDIT-002 Button label and link authoring As a site editor, I want to add a button, set its label and destination, and publish it so visitors can follow the intended call-to-action link. The module picker can insert base.button, the Properties panel exposes label and href controls, Save Draft persists the values, Publish completes through the toolbar flow, and the visitor page renders a semantic link with the authored label and href. Missing href; invalid or unsafe URL; button inserted but wrong node selected; label cleared; publish step-up required; visitor anchor missing or rendered as a non-link button; stale public output. Module picker inserts a registered base.button node; property controls validate schema-backed props; publish uses the step-up-gated toolbar flow; public assertions verify rendered HTML in a fresh visitor context. tests/e2e/visual-builder.e2e.ts; tests/e2e/helpers/editor.ts; tests/e2e/helpers/public.ts; src/modules/base/button; src/admin/pages/site/property-controls/PropertyControlRenderer.tsx; server/publish/publicRenderer.ts The regression uses https://example.com as the destination and checks published output; deeper invalid URL copy, target/rel variants, and permission-persona coverage are tracked separately. Happy: insert button, set label and href, save, publish, and verify visitor anchor. Error: publish or save failure should surface through toolbar state. Boundary: one external https URL. Invalid: unsafe URL rejected or sanitized by module/publisher rules. Permission: editing and publish capabilities plus step-up. Performance: publish completes within E2E timeout. Mobile: public mobile button layout remains separate responsive coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The EDIT-002 scenario logs in from an anonymous state, creates a disposable page, inserts base.button, sets label Visit Example and href https://example.com, saves, publishes, then opens the visitor route and verifies a visible link with an href matching example.com. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 BUILDER-001 Insert common visual modules As a site editor, I want to add common modules from the canvas controls so I can build a page with layout, text, media, and interactive elements. On a fresh disposable page, the canvas notch inserts Container, Text, and Image modules, and the Layers tree shows each module row after insertion. The full picker can also insert Button by search/keyboard path, proving non-favorite modules remain reachable. Module favorites missing from notch; module picker unavailable; insert target ambiguous; no selected node after insertion; empty image placeholder; capability-hidden insert controls; tablet-width insertion edge; picker search or keyboard insert mismatch. Canvas notch buttons and picker items dispatch registered module inserts through the editor store; inserted nodes must satisfy module schema defaults and render in the Page element tree. tests/e2e/visual-builder.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/pages/site/canvas/CanvasInsertModuleButton.tsx; src/admin/pages/site/module-picker/ModuleInserterDialog.tsx; src/admin/pages/site/hooks/useInsertModule.ts; src/modules/base/container; src/modules/base/text; src/modules/base/image; src/modules/base/button The primary automated case covers the three notch favorites; SITE-005 covers Button insertion through picker search/keyboard; the same visual-builder run separately proves tablet-width Text insertion. Happy: insert Container, Text, Image, and picker-searched Button and verify layer rows. Error: insert failure should leave no false row. Boundary: empty fresh page and non-favorite module path. Invalid: unknown module ids blocked by picker/store. Permission: Site structure edit required. Performance: rows appear within E2E timeout. Mobile: tablet-width insertion is covered by the responsive BUILDER-003 check; phone-width non-favorite picker insertion is covered by SITE-005. Focused page-management and visual-builder Playwright regression passed 2026-06-23; SITE-005 desktop/mobile picker-search regressions passed 2026-06-23 0 None Verification: `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-005"` passed 3/3 including setup, and full `visual-builder.e2e.ts` passed 22/22 including setup. BUILDER-001 uses canvas notch buttons for Container/Text/Image; SITE-005 searches and keyboard-inserts Button through the full picker on desktop and at 390px. Run logs: docs/e2e/runs/2026-06-23-page-builder-management.md; docs/e2e/runs/2026-06-23-site005-module-picker.md; docs/e2e/runs/2026-06-23-site005-mobile-module-picker.md. 2026-06-23 BUILDER-002 Select canvas nodes and edit properties As a site editor, I want selection to drive the Properties panel so changing a field updates the intended node and not a previously selected element. After inserting Text and setting an initial headline, inserting Image moves the Properties panel to the image src control; selecting the Text row from Layers restores text controls, editing text updates the canvas, and the old headline disappears. Selection lost after insertion; wrong tree row selected; property panel still bound to previous node; duplicate layer names; hidden/locked nodes; stale iframe text after update. Layer selection updates the editor selection store; property controls are rendered for the selected module schema; text prop edits mutate the active tree and rerender the canvas iframe. tests/e2e/visual-builder.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/pages/site/panels/DomPanel; src/admin/pages/site/store/slices/selectionSlice.ts; src/admin/pages/site/property-controls/PropertyControlRenderer.tsx; src/modules/base/text The regression uses the first Text layer name and one Image insertion to prove selection moves; multi-select and locked-region behavior are covered by other builder/editor tests. Happy: select Text from Layers and edit text. Error: missing property control fails visibly. Boundary: two modules with selection handoff. Invalid: schema-invalid property values rejected by controls/store. Permission: edit capability required. Performance: canvas text updates promptly. Mobile: selection on narrow editor remains future exploratory coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The BUILDER-002 scenario inserts Text, sets Selectable headline, inserts Image and sees the src property control, selects Text in the Layers tree, sets Edited headline, verifies edited text is visible, and verifies the old headline is gone. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 diff --git a/docs/editor.md b/docs/editor.md index 56e29380d..f10e1d0bf 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -451,6 +451,7 @@ Why this matters: selection rings and the floating selection toolbar are portale | AgentPanel (floating) | 50 | `panels/AgentPanel/AgentPanel.module.css` | | PanelRail | 55 | `sidebars/PanelRail/PanelRail.module.css` | | LeftSidebar, RightSidebar | 85 | `sidebars/{Left,Right}Sidebar/` | +| Undocked left-panel host | 90 | `sidebars/LeftSidebar/LeftSidebar.module.css` | | CodeEditorPanel (floats over sidebars)| 95 | `code-editor/CodeEditorPanel.module.css` | | Toolbar popovers / dropdowns | 201 | `toolbar/Toolbar.module.css` | | PreviewOverlay | 400–401 | `preview/PreviewOverlay.module.css` | @@ -533,6 +534,21 @@ Opens the rail-selected panel: - `PluginEditorPanel` — plugin-provided editor panels - `AgentPanel` — AI assistant +Explorer, Selectors, Framework, Dependencies, and plugin panels use the shared +`Panel` header contract and can be unpinned into one draggable canvas window. +Switching among those rail items while unpinned replaces the window content +without redocking it; the same header action docks the active panel back into +the left sidebar. A keyboard-accessible bottom-right handle resizes the +floating window in both axes (arrow keys resize by 10px; Shift+arrow by 40px). +`leftSidebarMode`, the shared floating position, and its user-set width and +height are persisted through `siteEditorLayoutPersistence` / +`workspaceLayoutStorage`. + +The AI Assistant is an independent draggable and resizable floating window, so +it can stay open beside Explorer/Layers or any other hosted panel. Its position, +dimensions, and open state persist separately across reloads. Properties +follows the same independent floating-window interaction contract on the right. + ### Right sidebar (`RightSidebar`) `src/admin/pages/site/sidebars/RightSidebar/RightSidebar.tsx`. Accepts a `mode` prop (`'site' | 'hidden'`): @@ -602,7 +618,7 @@ Site-specific controls that were previously sections of this modal (Pages roster **Data source — `useSiteSettingsController`** (`src/admin/modals/Settings/useSiteSettingsController.ts`): the General and Publishing sections edit fields of the persisted `SiteDocument` (`name`, `settings.*`, framework preferences), but where that document lives depends on the route. The modal is global, so a section cannot just read the editor store — that store is only hydrated on the Site editor (`AdminCanvasLayout`). The controller hides the split behind one uniform shape: -- **Site editor** (editor store holds a live draft): delegate to the editor-store mutations. Settings edits join the unsaved draft and persist through the editor's autosave / Save pipeline alongside page-tree edits — never clobbered. +- **Site editor** (editor store holds the live document): delegate to the editor-store mutations. Settings edits ride the collab write path alongside page-tree edits — streamed live to every peer and persisted by the server relay. - **Every other admin page** (no in-memory draft): a standalone Zustand store loads the document once via `cmsAdapter`, edits a local copy, and persists immediately with a shell-only `saveSite` (empty dirty sets, so pages / components / layouts are left untouched). After each save it refreshes the `adminUi` site summary and fires `CMS_SITE_RELOAD_EVENT` so the toolbar brand and `useSiteSummary` re-sync. There is no Save button on those pages, so writes commit on blur / toggle. Because the controller is imported only by the lazy section components, the editor-store import it carries stays inside the `SettingsModal` chunk and never enters the eager graph of the lightweight layouts. This is what makes the modal *actually* global — before it, General and Publishing rendered a permanent skeleton anywhere outside the Site editor. diff --git a/docs/features/content-storage.md b/docs/features/content-storage.md index 48aef17ce..283f5ec16 100644 --- a/docs/features/content-storage.md +++ b/docs/features/content-storage.md @@ -116,6 +116,7 @@ Users can add their own custom fields to system tables. | `email` | `string \| null` | | | `media` | single: `mediaId \| null`; multi: `string[]` | References `media_assets` | | `relation` | single: `rowId \| null`; multi: `string[]`| Relates to rows in another `data_table` | +| `repeater` | ordered `{ id, cells }[]` | One-level structured item collection | | `pageTree` | `NodeTree` JSON | The visual tree (pages, VC trees) | | `fieldSchema` | JSON describing fields | Used by VCs to declare `params` | @@ -130,6 +131,7 @@ readStringCell(cells, 'title') // → string ('' fallback) readNumberCell(cells, 'price') // → number | null readBooleanCell(cells, 'featured') // → boolean readStringArrayCell(cells, 'tags') // → string[] +readRepeaterCell(cells, 'gallery') // → { id, cells }[] readTitleCell(cells) // → string (reads 'title') readSlugCell(cells) // → string (reads 'slug') readBodyCell(cells) // → string (reads 'body') @@ -139,6 +141,11 @@ readNodeTreeCell(cells, 'body') // → NodeTree | null readFieldSchemaCell(cells, 'params') // → DataField[] | null ``` +Repeater definitions store their item schema in `field.fields`. +`RepeaterItemFieldSchema` excludes `repeater`, `pageTree`, and `fieldSchema`, +so v1 repeaters are one level deep. Stable item ids make reorder and duplicate +operations deterministic; nested values remain keyed by their item field ids. + These do the boundary validation — handlers and modules read through them rather than typing `cells.foo as string`. To compute the denormalized, URL-normalized slug for a row (empty string when the table has no `slug` field): diff --git a/docs/features/data-workspace.md b/docs/features/data-workspace.md index 62b921fce..48383b34f 100644 --- a/docs/features/data-workspace.md +++ b/docs/features/data-workspace.md @@ -13,9 +13,11 @@ The Data workspace lets operators define and edit table schemas (field types, ro - **DataGrid:** read-only spreadsheet over `data_rows` — cells display via `CellDisplayRenderer`, editing opens in the inspector. Owns search, status filter, sort, selection, group collapse, and column resize state. Sub-components handle toolbar, header row, group headers, skeleton loading, empty state, and bulk actions. - **DataInspector:** right panel — switches between `RowDetail` (cell editor) and `TableSettings` (schema editor) based on row selection. - **Context menus:** `DataTableContextMenu` handles table-list actions; `DataRowContextMenu` handles grid-row actions. Both use the shared `ContextMenu` primitive. -- **TableSettings** owns field management via `FieldsSection`, which is split into `FieldRow`, `FieldEditForm`, `fieldGuards`, and `fieldEditState`. +- **NewTableDialog** creates collection identity and its full field schema in one atomic request. +- **FieldSchemaComposer** is shared by new-table setup and `TableSettings`; `NewFieldDialog` also uses the same definitions for repeater item schemas. +- **TableSettings** adapts persisted-table locks and warnings into the shared composer through `FieldsSection`. - Field classification: three tiers — mandatory built-ins (locked), optional built-ins (editable/deletable with badge), custom fields (fully editable/deletable). -- Field edit state uses a flat `FieldEditState` draft that `fieldToEditState` / `applyEditState` convert to/from the persisted `DataField`. +- `RepeaterCell` authors ordered structured items with add, duplicate, reorder, and delete actions. A media-only repeater delegates to `MediaRepeaterGallery`, which reuses the Media workspace asset rows/tiles and shared grid/list preference. - Mutations to system `page` and `component` rows request a retained Site-editor reload through `requestCmsSiteReload()` so `/admin/site` sees Data-created pages and Visual Components even when the editor store was already hydrated. --- @@ -45,11 +47,13 @@ DataPage.tsx └── DataInspector.tsx ← right-hand inspector panel ├── RowDetail.tsx ← row selected: cell-by-cell editor └── TableSettings.tsx ← no row selected: schema + metadata editor - └── FieldsSection.tsx ← field list: DnD reorder, inline edit, delete, add - ├── FieldRow.tsx ← presentational field row - ├── FieldEditForm.tsx ← inline field edit form - ├── fieldGuards.ts ← pure field classification - └── fieldEditState.ts ← draft state shape + conversions + └── FieldsSection.tsx ← persisted-table adapter + └── FieldSchemaComposer + +NewTableDialog.tsx +└── FieldSchemaComposer ← local schema draft submitted with table identity + ├── FieldRow.tsx ← ordered field summary + └── NewFieldDialog.tsx ← add/edit definition and repeater sub-fields ``` --- @@ -71,19 +75,28 @@ DataPage.tsx ## TableSettings and field management -`TableSettings.tsx` renders collapsible sections (General, Routing, Display, Fields, Kind, Danger zone). The **Fields** section delegates to `FieldsSection`. +`TableSettings.tsx` renders collapsible sections (General, Routing, Schema, Kind, Danger zone). The **Schema** section delegates to `FieldsSection`; each eligible field row carries a star action for assigning the primary field directly where the structure is defined. -**System tables** (`posts`/`pages`/`components`/`layouts`) render a **reduced** panel: only **Display** (primary field) and **Fields** are shown, gated by `data.system.tables.manage`. General / Routing / Kind / Danger zone are hidden because a system table's identity is frozen (the server's `assertSystemTableUpdateAllowed` rejects identity + built-in-field changes for everyone). Managers can still add/manage **custom** fields and change the primary field. +**System tables** (`posts`/`pages`/`components`/`layouts`) render a **reduced** panel containing only **Schema**, gated by `data.system.tables.manage`. General / Routing / Kind / Danger zone are hidden because a system table's identity is frozen (the server's `assertSystemTableUpdateAllowed` rejects identity + built-in-field changes for everyone). Managers can still add/manage **custom** fields and change the primary field from an eligible field row. -### FieldsSection +### Shared schema composer -`FieldsSection.tsx` owns all field-list state: +`FieldSchemaComposer.tsx` owns the reusable field-list interaction: - **Drag-and-drop reorder** — native HTML5 drag API; `handleDrop` reorders `table.fields` and calls `onUpdateTable`. -- **Inline edit** — `editingFieldId` + `editState` (`FieldEditState`) track the open editor. State is owned here; `FieldEditForm` is purely presentational. +- **Edit** — opens `NewFieldDialog` with the author-facing label first and the immutable machine id/type beside each other. - **Delete** — via `useConfirmDelete`; calls `onUpdateTable` with the field removed. - **New field** — via `NewFieldDialog`. +For a new field, `NewFieldDialog` derives the machine ID from the label until +the author edits the ID manually. Existing IDs and types remain fixed so saved +row values keep stable keys. + +`FieldsSection.tsx` computes table-specific lock, label, built-in, and delete +sets with `fieldGuards.ts`, then passes them into the composer. +`NewTableDialog.tsx` gives the same composer a local field array and submits +that array with collection identity in the single create request. + ### Field classification — `fieldGuards.ts` Tiers enforced by the guard functions: @@ -107,22 +120,24 @@ deleteTooltip(field, table) // disabled-button tooltip text, or undefine Built-in field **values** (row cells) are additionally read-only on the *structural* system tables (pages/components/layouts) via `isBuiltInValueLocked` (`@core/data/systemTableGuard`); `posts` built-in values stay editable. The same predicate backs the server's row-write rejection (`lockedBuiltInCellKey`). -`FIELD_TYPE_LABELS` maps every `DataFieldType` to a human-readable string and is shared by `FieldRow` and `FieldEditForm`. - -### Draft/commit pattern — `fieldEditState.ts` - -Field editing uses a flat draft object to keep all form inputs controlled: - -```ts -fieldToEditState(field: DataField): FieldEditState // persisted → editable draft -applyEditState(field, state, labelLocked): DataField // draft → persisted -``` +`FIELD_TYPE_LABELS` maps every `DataFieldType` to a human-readable string and +is shared by `FieldRow` and `FieldSchemaComposer`. -`FieldEditState` flattens all type-specific options to primitives (numeric constraints as `string`, select options as `DraftOption[]`). `applyEditState` converts them back and reconstructs the correct `DataField` discriminant via a fully-exhaustive `switch (field.type)`. +### Repeater authoring -### React Compiler — async helper extraction +`RepeaterCell.tsx` reads values through `readRepeaterCell`, initializes nested +cells through `emptyCellValue`, and writes the complete ordered value through +the ordinary row draft. Nested relation and media fields reuse their shared +pickers. Multi-media fields place `MediaPickerModal` in true multi-selection +mode: plain clicks toggle assets and the footer commits the entire selection. -`FieldsSection.tsx` and `TableSettings.tsx` extract async save handlers to **module-level functions** (`saveFieldEdit`, `saveTableField`, `savePrimaryField`). This is required because `async/await` with `try/catch` nested inside a component function forces the React Compiler to bail out of auto-memoization for that component. Extracting the async body to module scope lets the compiler memoize the component normally. +When a repeater contains exactly one single-value media field, +`MediaRepeaterGallery.tsx` replaces the generic structured-item cards with the +Media workspace presentation. It renders the shared `AssetTile` / `AssetRow` +components, uses the same persisted grid/list switcher, fills empty slots from +a multi-selection picker, and keeps replace, reorder, and remove actions on +each asset. Removing an item never deletes the underlying Media library file. +Repeaters with additional fields continue to use the generic card editor. --- @@ -202,8 +217,9 @@ Both actions are opened from `DataSidebar`. | Reimplementing title copy naming or slug collision logic when duplicating rows | Use `buildDuplicateRowCells` from `src/core/data/duplicateRow.ts` | | Comparing field classification inline | Import from `fieldGuards.ts` | | Adding a `kind === 'postType'` branch inside `FieldsSection` | Classification belongs in `fieldGuards.ts`; `FieldsSection` reads `isMandatoryField`, `isOptionalBuiltIn`, etc. | -| Editing a field's `type` after creation | Type is immutable; `FieldEditForm` shows it read-only with "(cannot be changed)" | -| Writing manual `useMemo`/`useCallback` in any of these components | React Compiler auto-memoizes; the only exception is the async helper extraction pattern above | +| Editing a field's type or machine id after creation | `NewFieldDialog` disables both while editing so stored row values keep stable keys | +| Allowing a repeater inside a repeater | `RepeaterItemFieldSchema` excludes `repeater`, `pageTree`, and `fieldSchema` | +| Writing manual `useMemo`/`useCallback` in any of these components | React Compiler auto-memoizes; use only the repository-level documented exceptions | | Putting filter / sort / group logic in `DataGrid.tsx` | That logic lives in `dataGridRows.ts` (pure, side-effect free). `DataGrid.tsx` only holds interaction state and wires sub-components. | | Treating the DataGrid as an inline cell editor | The grid is read-only. `CellEditorRenderer.tsx` belongs to the inspector (`RowDetail.tsx`), not to the grid. | | Adding a "Table settings" shortcut to the `DataPage` toolbar | `TableSettings` is reached by deselecting a row — the inspector switches automatically. A duplicate toolbar affordance was removed; `src/__tests__/admin/data/dataPageToolbar.test.ts` prevents it from returning. | diff --git a/docs/features/editor-preferences.md b/docs/features/editor-preferences.md index 01e3dc267..cec9814b5 100644 --- a/docs/features/editor-preferences.md +++ b/docs/features/editor-preferences.md @@ -1,6 +1,8 @@ # Editor Preferences -Local UI preferences for the editor — auto-save behaviour, hover-preview gating, admin theme, UI text size, density, layers panel options, etc. Stored in `localStorage`, scoped to the device, never written to the site file. +Local UI preferences for the editor — hover-preview gating, admin theme, UI text size, density, layers panel options, etc. Stored in `localStorage`, scoped to the device, never written to the site file. + +There are no auto-save preferences: the collab relay persists continuously (see [docs/features/site-shell.md](site-shell.md) → "Real-time co-editing"), so there is nothing to schedule. The feature is **catalog-driven**: one declarative array drives the schema, the runtime defaults, and the Settings → Preferences UI. Adding a preference is two lines. @@ -9,8 +11,8 @@ The feature is **catalog-driven**: one declarative array drives the schema, the ## TL;DR - Source of truth: `PREFERENCE_CATALOG` in `src/admin/pages/site/preferences/catalog.ts`. -- Read from React: `useEditorPreference('autoSave')` / `useEditorSelectPreference('density')` / `useEditorAppearancePreferences()`. -- Read from non-React: `readEditorPreference('autoSave')` + `subscribeToEditorPrefsChanged(listener)`. +- Read from React: `useEditorPreference('hoverPreview')` / `useEditorSelectPreference('density')` / `useEditorAppearancePreferences()`. +- Read from non-React: `readEditorPreferenceBool('hoverPreview')` + `subscribeToEditorPrefsChanged(listener)`. - Settings UI renders automatically from the catalog — no per-preference wiring. - Storage: `localStorage["instatic-editor-prefs"]` (`EDITOR_PREFS_KEY`). `additionalProperties: true` on the schema keeps forward / backward compatibility silent. @@ -58,11 +60,11 @@ A single `PREFERENCE_CATALOG` array lists every preference. Each entry declares ```ts export const PREFERENCE_CATALOG = [ { - id: 'autoSave', + id: 'hoverPreview', type: 'boolean', category: 'editor', - label: 'Auto-save', - description: 'Automatically save the site every 30 seconds.', + label: 'Hover preview', + description: 'Preview classes and tokens on the canvas while hovering them.', default: true, }, // … @@ -120,7 +122,7 @@ Both go through `parseJsonWithFallback(EditorPrefsSchema, …)` so corrupt or pa **3. Event bus + React hooks** ```ts -// Event bus — for non-React consumers (e.g. usePersistence's auto-save scheduler) +// Event bus — for non-React consumers export function subscribeToEditorPrefsChanged(listener: () => void): () => void export function notifyEditorPrefsChanged(): void @@ -197,35 +199,30 @@ Because the effect keys on the document id, it also re-centers when the active d ## Reading preferences from non-React code -Some call sites are not React components — `usePersistence.ts`'s auto-save scheduler is one example. They use the imperative API: +Some call sites are not React components. They use the imperative API: ```ts import { - readAutoSavePreference, - readAutoSaveDelayMs, + readEditorPreferenceBool, readEditorSelectPreference, subscribeToEditorPrefsChanged, } from '@site/preferences/editorPreferences' // Read once at setup time -const enabled = readAutoSavePreference() -const delayMs = readAutoSaveDelayMs() +const hoverPreview = readEditorPreferenceBool('hoverPreview') +const breakpoint = readEditorSelectPreference('defaultBreakpoint') // React to changes const unsub = subscribeToEditorPrefsChanged(() => { - scheduleAutoSave() + reapplyPreferences() }) ``` -Named convenience wrappers (`readAutoSavePreference`, `readHoverPreviewPreference`, `readAutoSaveDelayMs`) sit on top of the generic getters for one reason: - -They self-document at the call site — `readAutoSavePreference()` reads better than `readEditorPreference('autoSave')`. - -When a new preference needs an imperative reader, add a similarly-named wrapper in `editorPreferences.ts`. They're one-liners. +Two generic getters cover every preference — `readEditorPreferenceBool(id)` for booleans and `readEditorSelectPreference(id)` for select / select-dynamic. Both are typed against the catalog, so a typo'd id is a compile error. There are no per-preference convenience wrappers: a wrapper per preference is a second place to keep in sync for no gain. ### Imperative settings via `setEditorPreference` / `setEditorSelectPreference` -Both setters dispatch the change event so all hook consumers re-render and the bus listeners (`usePersistence.ts`) re-evaluate. They're available outside React for migration scripts, plugin defaults, or one-shot programmatic toggles, but the typical setter path is the Settings UI. +Both setters dispatch the change event so all hook consumers re-render and bus listeners re-evaluate. They're available outside React for migration scripts, plugin defaults, or one-shot programmatic toggles, but the typical setter path is the Settings UI. --- @@ -234,7 +231,6 @@ Both setters dispatch the change event so all hook consumers re-render and the b ```jsonc // localStorage["instatic-editor-prefs"] { - "autoSave": true, "hoverPreview": false, "theme": "light", "density": "comfortable", @@ -266,8 +262,6 @@ The Settings → Preferences screen renders this list automatically from the cat | Category | Id | Type | Default | Wired in | |------------------|-----------------------------|----------------------|-------------|------------------------------------------------| -| Editor | `autoSave` | boolean | `true` | `usePersistence.ts` | -| Editor | `autoSaveDelay` | select (5s/15s/30s/60s/5min) | `'30'` | `usePersistence.ts` (`readAutoSaveDelayMs`) | | Editor | `hoverPreview` | boolean | `true` | `ClassPicker.tsx`, `SpacingBoxControl.tsx` | | Editor | `confirmBeforeDelete` | boolean | `false` | `ConfirmDeleteProvider` | | Editor | `theme` | select (dark / light) | `'dark'` | `data-editor-theme` on the document + layout roots | diff --git a/docs/features/loops.md b/docs/features/loops.md index 63353ad56..88dd46306 100644 --- a/docs/features/loops.md +++ b/docs/features/loops.md @@ -10,6 +10,7 @@ Loop sources are pluggable: built-in sources (`data.rows`, `site.pages`, `site.m - Loop source registry: `loopSourceRegistry` in `src/core/loops/registry.ts`. First-party sources self-register from `src/core/loops/sources/index.ts` at boot. - `LoopEntitySource` shape: `{ id, label, fields, filterSchema?, orderByOptions?, fetch, preview? }` in `src/core/loops/types.ts`. +- `entry.field` is the contextual exception: it resolves an array on the closest `currentEntry` separately for every outer iteration, so a Project's multi-media `gallery` field can drive an inner loop. - The `base.loop` module's children are **variants** — different per-item layouts (e.g. "Card", "Featured"). The walker round-robins across them as it iterates. - At publish time, `loopPrefetch.ts` calls each loop's `fetch()` and stores results on the render context. The walker is then purely synchronous. - Each iteration renders against a fresh `entryStack` snapshot (`[...baseStack, item]`) carried in a child `RenderConfig`; nodes inside the loop resolve `currentEntry.` against that item via dynamic bindings. The stack is never mutated in place. @@ -26,6 +27,7 @@ src/core/loops/ └── sources/ ├── index.ts — register the three built-ins at boot ├── dataRows.ts — data.rows (any data_table) + ├── entryField.ts — entry.field (array field on currentEntry) ├── sitePages.ts — site.pages (+ shared helpers re-exported via barrel) └── siteMedia.ts — site.media @@ -144,6 +146,30 @@ Used by galleries. Its author-facing `fields` list exposes filename, path/URL/source URL, MIME type, and upload date. Internal uploader ids stay in `LoopItem.fields` for code that needs them, but they are not binding-picker rows. +### `entry.field` + +Iterates an array-valued field on the closest enclosing entry. It is resolved +inside the synchronous render walk rather than prefetched once by loop node id, +because the value can differ for every outer iteration: + +```text +Projects loop + Project A → gallery [a1, a2] → inner loop renders a1, a2 + Project B → gallery [b1] → inner loop renders b1 +``` + +The Properties panel offers collection fields from the current entry table: +multi-media, multi-relation, and multi-select fields. Primitive items are +available as `currentEntry.value`; object-array members are exposed by key. +Media ids are resolved through the publisher's batched media prefetch and +provide `currentEntry.src`, `url`, `path`, `altText`, `mimeType`, `width`, and +`height`. + +Contextual loops preserve authored order by default, can reverse/slice with +direction/offset/limit, and do not support infinite pagination. Infinite +fragments are independent requests and cannot recover an arbitrary outer +entry stack safely. + ### Plugin-registered sources A plugin with `loops.register` registers a custom source via the SDK at activation. The source runs inside the **QuickJS sandbox** — it can use `api.cms.storage.collection(...)` to fetch plugin-owned data or `fetch(...)` (with `network.outbound` permission) for external APIs. @@ -243,7 +269,10 @@ See [docs/features/publisher.md](publisher.md) → "renderLoop" for the broader ## Prefetch -The walker is **purely synchronous** — async data (loop sources, media) is resolved up-front so the publisher doesn't have to `await` per node. +The walker is **purely synchronous**. Async data (prefetched loop sources and +media) is resolved up-front so the publisher doesn't have to `await` per node. +`entry.field` stays synchronous by deriving its items from the already-present +entry stack. `server/publish/loopPrefetch.ts`: @@ -305,6 +334,18 @@ Subscription granularity: the hook never subscribes to the whole `site` document - Add nodes inside: a heading bound to `currentEntry.title`, content bound to `currentEntry.body`, an image bound to `currentEntry.featuredMedia`. 6. Publish. Each iteration renders the variant with the item's fields substituted. +### Build a per-project media gallery + +1. Add a Media field named `gallery` to the Projects post type and enable + **Allow multiple**. +2. On the Project entry template, insert a Loop. +3. Set Source to **Current entry field** and Field to **Gallery**. +4. Add an Image as the loop's child template. +5. Bind the Image source to **Current entry field → Media source** and, + optionally, its alt text to **Alt text**. +6. Publish the site and a Project entry. Each project route renders only that + project's gallery items, in the field's authored order. + ### Build a loop with the AI agent The site-scope AI agent stays on the HTML-native edit surface. It calls `list_loop_sources` to get valid source ids, table ids, order options, and `{currentEntry.field}` tokens, then inserts an `` marker through `insertHtml` / `replaceNodeHtml`: diff --git a/docs/features/mcp-connectors.md b/docs/features/mcp-connectors.md index 46baeee8e..5bc123313 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -135,9 +135,9 @@ MCP exposes the full deduplicated tool catalog, filtered by the connection's cap Server-resolved tools work without an editor open. They include content reads, `get_context`, `site_list_documents`, `site_read_styles`, `site_list_breakpoints`, and explicit `site_publish`. Publishing requires `ai.tools.write` plus `pages.publish`, runs the canonical full-site pipeline, swaps the static slot atomically, and records the connection id in the publish audit event. -Browser tools run against the connection owner's live workspace. Site structure, HTML/CSS, page lifecycle, design-token, content mutation, code-asset, and live-DOM tools route to the matching open Site or Content workspace. If that workspace is not open, the tool returns a scope-specific error while headless tools remain available. +Browser tools run against the connection owner's live workspace. Site structure, HTML/CSS, page lifecycle, design-token, content mutation, code-asset, and live-DOM tools route to the matching open Site or Content workspace. If that workspace is not open, the tool returns a scope-specific error while headless tools remain available. `tools/list` states that requirement in each browser tool's description, so a client learns the precondition when it picks the tool rather than from a failed call. -There is intentionally no headless page-tree mutation path. The open editor store is the single source of truth for draft edits; a second DB mutation path would desynchronize node state and risk autosave overwrites. Successful relayed edits flush the draft before returning, so a following headless read or explicit publish sees the saved result. +There is intentionally no headless page-tree mutation path. The open editor store is the single source of truth for draft edits; a second DB mutation path would desynchronize node state and overwrite the live document. Relayed edits need no post-tool save step: store mutations stream to the collab relay the moment they land, and every headless read (plus `site_publish`) flushes the relay server-side before it touches the DB — so a following read or publish always observes the edit. There is no client-side save flush, and no window in which the MCP caller can see stale data. Writes remain drafts. Clients should finish and verify an edit sequence, then call `site_publish` once only when deployment was requested. diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index 7bd0a856f..a4e4f94a7 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -744,7 +744,7 @@ const snap = await api.cms.content.getPublishedSnapshot(entryId) const { count } = await api.cms.content.republishAll() ``` -`tables.create(input)` accepts the plugin-facing field projection, then maps it to the host's canonical `DataField` schema before storage. `richText` fields default to Markdown format, `select` / `multiSelect` option `value`s become stable option IDs, and `relation.targetTableSlug` must resolve to an existing table slug. +`tables.create(input)` accepts the plugin-facing field projection, then maps it to the host's canonical `DataField` schema before storage. `richText` fields default to Markdown format, `select` / `multiSelect` option `value`s become stable option IDs, and `relation.targetTableSlug` must resolve to an existing table slug. `repeater` accepts a one-level `fields` schema made from ordinary authorable fields; nested relation slugs are resolved through the same gate, while recursive repeaters, `pageTree`, and `fieldSchema` item fields are rejected by the boundary schema. `republishAll` fires the full publish pipeline (`publish.before` → `publish.html` → `publish.after`), so other plugins' filters and listeners participate. diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 0ecf80cfd..4019d8b85 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -373,14 +373,22 @@ Pages and VCs follow the same principle: `validateVisualComponents` silently dro ## Saving the site +**The editor does not save over HTTP anymore** — it persists continuously +through the real-time co-editing relay (see "Real-time co-editing" below). +The transactional endpoint remains the write path for every **standalone +HTTP writer**: the Settings modal outside the editor, onboarding's +framework import, Super Import, and the fresh-install bootstrap. + The whole document saves through ONE endpoint, in ONE server transaction: ``` PUT /admin/api/cms/site-document -{ mode, site, // shell — always written +{ mode, site, // shell — written only when its content changed changedPages, deletedPageIds, changedComponents, deletedComponentIds, - changedLayouts, deletedLayoutIds } + changedLayouts, deletedLayoutIds, + baseSeqs, // rowId → last-synchronized seq (conflict check) + shellBaseSeq } // the shell's counterpart ``` Two modes: @@ -395,22 +403,9 @@ Two modes: the server derives deletions as stored − shipped. `deleted*Ids` must be empty. -Saves are **incremental**: the editor store derives which pages/VCs/layouts -changed — and which were deleted — from the same Mutative patches that power -undo (`src/admin/pages/site/store/slices/site/dirtyTracking.ts`; deletions -come from a pre/post membership diff, robust to any recipe style), and -`usePersistence.ts` ships only those — a one-prop edit uploads one page, not -the site. Delete-then-recreate within one save window nets to a plain write -at snapshot time; the server 400s any id in both the changed and deleted -sets as a backstop. Anything the tracker can't attribute marks `all` and -ships as a replace-mode full save. Granular write gates -(`SITE_WRITE_CAPABILITIES`) enforce what each role can actually change -inside the diff. - -`usePersistence` runs saves through a **single-flight queue**: at most one -save on the wire and one queued follow-up that reads the latest store state -— autosave, Cmd+S, save-request events, the MCP bridge, and the unmount -flush can never interleave requests. +Granular write gates (`SITE_WRITE_CAPABILITIES`) enforce what each role can +actually change inside the shell/page diffs. The server 400s any id in both +the changed and deleted sets as a backstop. The save is **atomic and fail-closed**: shell + components + layouts + pages commit in one transaction (`server/handlers/cms/siteDocument.ts`), so one @@ -442,14 +437,178 @@ reject with a 400 instead of dying on the index. Every save allocates a **site-global sync sequence number** (`server/repositories/syncSequence.ts`) inside the transaction and stamps it -on the shell and every written or deleted row (`data_rows.seq`); the -response returns it (`{ ok: true, seq }`). The seq is the substrate for -multi-admin conflict detection and delta reconciliation (live-sync plan) — -informational to the client until that lands. +on every written or deleted row (`data_rows.seq`) — and on the shell, but +**only when the shell content actually changed** (`shellsEqual` in +`siteDiff.ts` gates the shell write; the shell ships with every save, so an +unconditional stamp would make the shell seq useless as a conflict signal). +The response returns the seq (`{ ok: true, seq }`). + +### Conflict detection (HTTP writers) + +Editors co-edit through the CRDT relay and never conflict; this check guards +the remaining HTTP writers against each other (two Settings modals open on +two admin tabs, onboarding racing an import). Incremental saves carry +**base seqs**: for every changed *and* deleted row, +the stored seq the client last synchronized with (`baseSeqs`), plus the +shell's (`shellBaseSeq`). Inside the transaction — *after* `allocateSiteSeq`, +whose counter-row lock serializes concurrent saves on both dialects, making +the check exact — the handler compares each shipped row's STORED seq against +its base: + +- stored seq **newer** than the base → another admin changed (or deleted — + soft-deleted rows are visible to the check via `listDataRowSeqs`) the row + since this client synchronized; +- **no base entry** for a row that exists in storage → the client doesn't + know the row at all, so its write would be a blind overwrite; +- the shell is checked only when the incoming shell differs from the stored + one (one coarse seq for the whole shell — accepted v1 granularity). + +Any hit throws `SaveConflictError` (`@core/persistence/saveConflict` — the +same class the client adapter re-throws), rolling the transaction back into +a **409** with `{ error, conflicts: [{ table, rowId, seq }] }`. Nothing is +written. Client-created rows have no stored counterpart and pass by +construction; **replace-mode saves skip the check** (imports replace +deliberately). + +Writers OUTSIDE the transactional save (plugin pack installs via +`saveDraftSite`/`saveDataRowDraft`, data-workspace row edits) do not stamp +seqs, so this check cannot see them — but every repository write fires +`notifyRowWrite`/`notifyShellWrite` (`server/repositories/rowWriteEvents.ts`), +which makes the collab relay RESET the affected docs: connected editors +rebind and pick the external change up live. + +One subtlety: the editor bumps `site.updatedAt` on EVERY historic mutation, +so `shellsEqual` (`@core/persistence/shellsEqual`, shared by the server's +shell-skip and the client's echo detection) deliberately ignores it, and the +dirty tracker never marks the shell for `updatedAt`-only patches — otherwise +every page edit would read as a shell change and destroy the shell seq as a +conflict signal. + +### Real-time co-editing (CRDT) + +Every open editor is a live peer on the same document — edits merge +granularly (two admins can restyle two nodes of the same page, or co-type +one text node, simultaneously), presence is visible, and there is **no save +UI at all**: the server persists continuously. + +**Document model** (`src/core/collab/`): one Yjs doc per logical row — +`page:`, `component:`, `layout:` — plus one `site:default` +doc for the shell and the roster order. Page/component trees map to +`getMap('tree')` (`rootNodeId` + a `nodes` Y.Map of per-node Y.Maps: `props` +as a Y.Map with the module's inline-text prop as Y.Text, nested +`breakpointOverrides` Y.Maps, `children` as Y.Array; `parentId` is derived, +never stored). Layout snapshots are whole-value LWW. The shell keeps +`settings` / `styleRules` / `explorer` as per-entry Y.Maps and everything +else plain. Deterministic reconciles (`integrity.ts` tree repair, roster +order) run identically on every peer. + +**Editor write path** (`src/admin/pages/site/store/slices/site/collabBinding.ts`): +local mutations keep applying directly to the Zustand store (the hot path is +untouched), and their Mutative patches translate into targeted Y operations +(`@core/collab` `applySitePatchesToDocs`) — text via minimal Y.Text splices, +children via array diffs, roster membership via pre/post id-set diffs; +anything unattributable repopulates the doc (the conservative escape hatch). +Remote/undo/reconcile changes flow the OTHER way: a per-doc projection +replaces the affected row or shell in the store. + +One surface needs more than the projection: the inline text editor is a +contentEditable React does not own, and every keystroke commits the element's +WHOLE string back through the snapshot diff. A frozen surface would therefore +make the next local keystroke delete a peer's concurrent characters from the +CRDT (the snapshot doesn't contain them, so the diff reads them as a local +deletion). During a session, `attachInlineEditRemoteMerge` +(`src/admin/pages/site/collab/inlineEditRemoteMerge.ts`, wired by +`NodeRenderer`'s session effect) observes the edited prop's Y.Text and folds +every non-local change into the DOM — content rewritten through the same +seeding writer, local caret restored at an index transformed through the Yjs +delta (insert-at-caret pushes right, matching relative-position association). +IME composition defers the rewrite to `compositionend`. This is what makes +co-typing ONE text node intent-preserving, not just convergent — gated +end-to-end by `src/__tests__/collab/inlineEditRemoteMerge.test.tsx`. + +**Undo** is per-editor and per-doc: Y.UndoManagers track only +`LOCAL_ORIGIN` (a peer's edits are never undone by your Cmd+Z), coalescing +reproduces the old `coalesceKey` typing-burst semantics, and multi-doc +mutations (convert-to-component, roster ops, Super Import) undo as ONE step +across all their docs via a routing-group stack. + +**Server relay** (`server/collab/relay.ts`): owns the authoritative docs, +seeds them from the stored JSON (the server is the ONLY seeder — fixed seed +clientID, so two clients can never build divergent initial histories), +persists each doc's update blob to `collab_documents` AND the derived row +JSON to `data_rows`/site on a short debounce (~800 ms), applies +roster-driven soft-deletes, and RESETS docs whose row was written outside +the relay (`rowWriteEvents.ts`) — clients rebind and reseed. The publish +endpoint flushes the relay first so the baked snapshot includes edits still +inside the debounce window. A transient persistence failure keeps the dirty +doc resident and retries; explicit publish/reset flushes fail instead of +continuing against stale derived JSON. Normal shutdown and final-client +release flush synchronously. A hard process/host crash can still lose the +bounded debounce window (at most ~800 ms with the default). + +**Wire** (`server/collab/socket.ts` + `@core/collab/protocol`): binary +frames `docId | frameType | payload` multiplex every doc over ONE WebSocket +at `/admin/api/cms/site-socket` (y-protocols sync + awareness + reset). +Upgrade is gated by a session with `site.read` and `originAllowed` (CSWSH +defense). A read-only connection's update frames are dropped server-side +(its awareness/presence frames still relay — viewers are visible peers), +and PARTIAL writers' update frames run through the per-category guard +(`server/collab/updateGuard.ts`): fork the doc, apply, project both sides, +and reuse the HTTP path's `validateSiteWriteDiff`/`validatePageWriteDiff` — +one enforcement vocabulary on both transports (the validators live in +`server/writePolicy/` for exactly that reason). Rejected updates never touch +the authoritative doc; the sender gets a targeted reset that reverts its +local fork. Two more socket-level defenses: per-frame payload caps (64 KB +awareness / 4 MB sync, plus the transport `maxPayloadLength`) drop oversized +frames before any decode work, and every awareness frame is decoded and +checked against the session — a state claiming another user's identity +(`state.user.id !== session user`) is dropped, so presence can't be spoofed. + +**Client transport** (`src/admin/pages/site/collab/collabProvider.ts`): one +socket, every bound doc multiplexed; local transactions send updates the +moment they commit (no flush window to lose on unload); reconnect uses +exponential backoff, and each (re)connect re-runs syncStep1 so Yjs state +vectors pull exactly the missed delta. `usePersistence` HTTP-loads the +document once for first paint, then connects the provider — edits gate on +each doc's first sync so an unseeded doc can never receive local ops. + +In production the socket is same-origin. Under `vite dev` it is NOT: the +socket dials the CMS port directly, bypassing the Vite proxy +(`src/admin/pages/site/collab/socketUrl.ts`). `scripts/vite.ts` runs Vite +inside Bun, and Bun's `node:http` ClientRequest never emits `'upgrade'`, so a +proxied 101 takes the non-upgrade fallback: the browser socket hangs in +`readyState 0` forever — never opening, never closing, so the provider's +reconnect path is never even reached — and when that connection later ends, +the proxy's `socket.destroySoon()` call (an API Bun's socket lacks) throws +uncaught and kills the whole dev process. Only the PORT is swapped; the +hostname is preserved, because the session cookie is `SameSite=Lax` and +`localhost` ↔ `127.0.0.1` is a cross-site handshake that would drop it. +`devWorkflow.test.ts` gates the proxy against re-enabling `ws` forwarding. + +**Presence** (`src/admin/pages/site/collab/awarenessState.ts`; per-frame +publishers in `collab/framePresencePublishers.ts`, rendering in +`PeerPresenceOverlay`): every editor publishes identity (deterministic HSL +color from the user id + the same upload→Gravatar avatar fields every admin +surface uses), active doc, selection, inline-edit state, a pointer, and — +during an inline text session — the caret/selection as **Y.Text relative +positions** (`collab/caretPositions.ts`; pinned to CRDT items, so they stay +correct while concurrent edits shift the text). Peers render selection +rings, name tags, avatar cursors, a blinking character-precise caret with +selection highlight inside the edited text, and a toolbar avatar stack +(`PeerAvatarStack`). The pointer ships at 10 Hz with a movement deadband +and a trailing flush; the receiving side eases the rendered cursor toward +each sparse sample every animation frame (exponential smoothing, snap on +oversized jumps), so motion stays glassy at a fraction of the wire rate. +Peer states are wire data — validated with TypeBox before rendering. + +MCP note: headless MCP reads hit the DB, so every headless read and +`site_publish` runs the server-side relay flush before touching persisted +rows. A browser-relayed write is therefore immediately ordered before the +following MCP read/publish without a client-side save step. ### Atomic diff validation -The save handler validates the shell diff before applying — e.g. a user with only `site.content.edit` can't change a class definition (style-edit) or rename a breakpoint (structure-edit). The shell diff validator is `validateSiteWriteDiff` (`server/handlers/cms/siteDiff.ts`); per-page category diffs run through `validatePageWriteDiff` (`server/handlers/cms/pageDiff.ts`). +The save handler validates the shell diff before applying — e.g. a user with only `site.content.edit` can't change a class definition (style-edit) or rename a breakpoint (structure-edit). The shell diff validator is `validateSiteWriteDiff` (`server/writePolicy/siteDiff.ts`); per-page category diffs run through `validatePageWriteDiff` (`server/writePolicy/pageDiff.ts`). The same two validators back the collab relay's update guard (`server/collab/updateGuard.ts`) — `server/writePolicy/` is the one write-policy module shared by both transports. --- diff --git a/docs/proposals/visitor-auth/ARCHITECTURE.md b/docs/proposals/visitor-auth/ARCHITECTURE.md new file mode 100644 index 000000000..f03ba09d9 --- /dev/null +++ b/docs/proposals/visitor-auth/ARCHITECTURE.md @@ -0,0 +1,314 @@ +# Architecture Decisions: Instatic Members + +**Project:** Instatic Members — Visitor Authentication Fork +**Date:** 2026-07-22 +**Source:** `Instatic/` at commit `960fdaa` (v0.0.11) +**PRD:** `PRD.md` | **Review:** `PRD-REVIEW.md` + +--- + +## Resolved Design Decisions + +These decisions are **final** — they were validated against the Instatic source code and the PRD review. If any needs revisiting, it requires a new review cycle. + +### D1: Middleware insertion point — inside the try/catch in `server/index.ts` + +**Context:** The PRD originally showed the middleware running before `handleServerRequest` outside the try/catch. PRD review finding C1 identified this as a crash risk. + +**Decision:** Insert the visitor auth middleware call **inside** the existing `try` block, immediately before `handleServerRequest`. This ensures: +- Middleware errors get caught by the existing error handler (generic 500 + security headers) +- The 302 redirect response gets CORS headers and `applySecurityHeaders` +- Consistent with every other response in the server + +**Code sketch (`server/index.ts`):** +```typescript +try { + const authResponse = await visitorAuthMiddleware(req, pathname, db) + if (authResponse) return applySecurityHeaders(authResponse, pathname) + + const res = await handleServerRequest(req, { db, staticDir, uploadsDir, databaseUrl }) + for (const [k, v] of Object.entries(cors)) { res.headers.set(k, v) } + return applySecurityHeaders(res, pathname) +} catch (err) { ... } +``` + +**Impact:** +3 lines (import, call, early return) instead of the original +10 estimate. Simpler and safer. + +--- + +### D2: Visitor auth routes go at position 4 in the routes array + +**Context:** PRD review finding H1. The routing table is a flat `routes[]` array with first-match-wins semantics. Visitor API routes (`/api/visitor/*`) must be reachable without hitting admin auth gates. + +**Decision:** Insert `tryServeVisitorRoutes` at position 4 in `server/router.ts`, immediately after `tryServeAi` and before `tryServeCmsApi`. + +**Rationale:** While `/api/visitor/*` wouldn't match the `/admin/api/cms/*` prefix checked by `tryServeCmsApi`, placing visitor routes early ensures they're always reachable regardless of future admin route changes. Defense in depth. + +**Code sketch (`server/router.ts`):** +```typescript +const routes: readonly RouteHandler[] = [ + tryServeHealth, + tryServeMcp, + tryServeAi, + tryServeVisitorRoutes, // ← NEW (position 4) + tryServeCmsApi, + // ... rest unchanged +] +``` + +--- + +### D3: Shared utility imports are acceptable — gate test bans admin auth *state*, not utilities + +**Context:** PRD review finding H2. The PRD said "visitor auth never imports from `server/auth/*`" but also wanted to reuse `hashSessionToken()` and `RateLimiter`. + +**Decision:** Visitor auth MAY import these specific functions/classes from `server/auth/`: +- `hashSessionToken` from `server/auth/tokens.ts` +- `createSessionToken` from `server/auth/tokens.ts` +- `sessionExpiry` from `server/auth/tokens.ts` +- `RateLimiter` from `server/auth/rateLimit.ts` +- `isStateChangingMethod` from `server/auth/security.ts` +- `originAllowed` from `server/auth/security.ts` + +The architecture gate test verifies that visitor auth **never imports**: +- `server/auth/sessions.ts` (admin session management) +- `server/auth/middleware.ts` (admin auth middleware) +- `server/auth/index.ts` (admin auth barrel — pulls in everything) +- `server/repositories/users.ts` (admin user repository) +- Any file under `server/handlers/cms/` that handles admin login + +**Rationale:** `hashSessionToken`, `RateLimiter`, and security header utilities are pure functions with no admin-specific state. They're utility code that happens to live in the auth directory. Importing them doesn't create a runtime coupling to the admin auth system. + +--- + +### D4: Prefix matching (not glob) for `protectedPrefixes` + +**Context:** PRD review finding H4. The PRD said "glob matching" but Instatic uses simple prefix matching everywhere. + +**Decision:** `protectedPrefixes` entries are path prefixes. A prefix `/members` matches: +- `/members` (exact) +- `/members/` (trailing slash) +- `/members/dashboard` (sub-path) +- `/membership` ← **also matches** (prefix, not segment) + +To protect only an exact path, the prefix must end with a segment boundary. The simplest approach: the prefix `/members/` only matches paths starting with `/members/` (not `/membership`). Document this in the admin UI tooltip. + +**Implementation:** `pathname.startsWith(prefix) || pathname === prefix` (handles trailing slash edge case). + +--- + +### D5: In-memory session cache with 5-minute TTL + +**Context:** PRD review finding H5. Every protected page request would otherwise hit the DB for session validation. + +**Decision:** Add an in-memory `Map` with a 5-minute TTL. On session validation: +1. Check cache by `id_hash` (the cookie token's SHA-256 hash) +2. Cache hit + not expired → return cached session (no DB query) +3. Cache miss → DB query → store in cache + +On session revoke (logout, password change, admin action): delete from cache. + +**Trade-off:** Stale revocation takes up to 5 minutes. Acceptable for member areas (not banking). Document in security considerations. + +**Impact on performance table:** Protected page (warm session) drops from ~1.5ms to ~0.8ms (cache lookup + disk read, no DB). + +--- + +### D6: Login page — server-rendered fallback when no custom page exists + +**Context:** PRD review finding C2. The chicken-and-egg problem of redirecting to `/login` when no login page exists. + +**Decision:** The middleware's 302 redirect target is configurable (`site.settings.visitorAuth.loginPath`, default `/login`). Two-tier login page resolution: + +1. **Primary:** Check if a published page exists at the login path. If yes, serve it normally through `renderPublicResolution` (Layer A/B/C). +2. **Fallback:** If no published page exists, the middleware serves a built-in HTML login page directly (inline HTML, ~3KB, minimal styling). This is NOT a redirect — the middleware returns a 200 Response with the login form HTML. + +The built-in page includes the auth runtime script tag (``) and a form with `data-instatic-auth="login"`. + +**Why not auto-create pages in the database?** Because pages in Instatic are snapshots with tree structures, not simple rows. Auto-creating a properly-structured page requires understanding the full publisher pipeline. The server-rendered fallback is simpler and doesn't pollute the content model. + +--- + +### D7: Visitor auth runtime JS — served from `/_instatic/visitor-auth.js` + +**Context:** PRD review finding C2. Login/register forms need client-side JavaScript. + +**Decision:** A single JS file (~2-3KB) served from the existing `/_instatic/` asset namespace. It: +- Finds all forms with `data-instatic-auth` attribute +- Intercepts `submit` events +- Calls the appropriate `/api/visitor/*` endpoint +- Handles errors (displays them in a `.instatic-auth-error` element within the form) +- On success, redirects to the `redirect` query param or `/` +- Updates the page state (shows/hides elements based on `data-instatic-auth-show` / `data-instatic-auth-hide`) + +This follows the same pattern as the hole runtime (`/_instatic/hole-runtime.js`) — a small enhancement script that makes static HTML interactive. + +--- + +### D8: `site.settings.visitorAuth` schema + +**Context:** PRD review finding H3. The PRD didn't specify the settings structure. + +**Decision:** +```json +{ + "visitorAuth": { + "enabled": false, + "protectedPrefixes": [], + "loginPath": "/login", + "registrationOpen": true, + "defaultRole": "member" + } +} +``` + +- `enabled`: Global toggle. When false, middleware passes through, `/api/visitor/*` returns 403. +- `protectedPrefixes`: Array of path prefix strings (see D4). +- `loginPath`: Where unauthenticated visitors are redirected. Default `/login`. +- `registrationOpen`: When false, `POST /api/visitor/register` returns 403. +- `defaultRole`: Role ID assigned to new registrations. + +This is read from the database on each request (cached in-memory, refreshed on publish like other site settings). + +--- + +### D9: Logout returns 204 with cookie clear + +**Context:** PRD review finding H7. + +**Decision:** +``` +POST /api/visitor/logout +→ 204 No Content +→ Set-Cookie: instatic_visitor_session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax +``` +No response body. Client-side JS handles navigation. + +--- + +### D10: Cookie path is `Path=/` — isolation via cookie name, not path + +**Context:** PRD review finding C3. The PRD claimed cookie path differences as a security mechanism. + +**Decision:** Visitor session cookie uses `Path=/` because visitors need it on all public pages. Admin session cookie may use `Path=/` or `Path=/admin` (to be verified). Isolation is guaranteed by **different cookie names** (`instatic_visitor_session` vs `instatic_admin_session`), not by cookie path. + +The PRD's security table (§7) should say: "Visitor and admin sessions use different cookie names and are validated by different middleware. Admin routes (`/admin/*`) only read `instatic_admin_session`; visitor middleware only reads `instatic_visitor_session`." Remove any claim that cookie `Path` provides isolation. + +--- + +### D11: Auth-gated hole endpoint at position 8 in routes array + +**Context:** PRD review finding M7. Phase 2 `/_instatic/gate/` would conflict with `tryServeStaticAsset`. + +**Decision:** `tryServeGate` (Phase 2) goes at position 8, right after `tryServeHole`. This keeps hole-related endpoints together and before the static asset handler. + +--- + +### D12: Migration uses separate `CREATE INDEX` for partial unique constraint + +**Context:** PRD review finding M4. `UNIQUE ... WHERE ...` as a table constraint doesn't work on SQLite. + +**Decision:** Both migration files use: +```sql +-- Table constraint (non-unique, allows the index to do the work) +email_normalized TEXT NOT NULL, + +-- Separate index (works on both PG and SQLite) +CREATE UNIQUE INDEX visitor_users_email_active_idx + ON visitor_users (email_normalized) + WHERE deleted_at IS NULL; +``` + +--- + +## File Map + +### New Files (Phase 1) + +``` +server/visitor-auth/ +├── middleware.ts # Route protection + session validation + fallback login page +├── sessions.ts # Session CRUD + in-memory cache (D5) +├── handlers.ts # API endpoint handlers +├── repositories.ts # DB operations (visitor_users, visitor_sessions, visitor_login_attempts) +├── rateLimits.ts # RateLimiter instances +└── types.ts # TypeScript interfaces + +server/publish/ +└── visitorAuthRuntime.ts # Builds the visitor-auth.js asset (D7) +``` + +### Modified Files (Phase 1) + +``` +server/index.ts # +3 lines (D1) +server/router.ts # +5 lines (D2) +server/db/migrations-pg.ts # +60 lines (D12) +server/db/migrations-sqlite.ts # +60 lines (D12) +server/publish/publicRouter.ts # +5 lines (pass visitor info to render context) +``` + +### New Files (Phase 2) + +``` +server/handlers/cms/gate.ts # /_instatic/gate/ endpoint (D11) +server/visitor-auth/gateHelpers.ts # Session validation for gate +``` + +### Modified Files (Phase 2) + +``` +server/router.ts # +1 line (tryServeGate at position 8) +src/core/publisher/dynamicDetection.ts # +15 lines (Rule 5: authGate) +src/core/publisher/renderNode.ts # +20 lines (renderGatedPlaceholder) +server/publish/holeRuntime.ts # +10 lines (instatic-gated observer) +src/modules/base/container/index.ts # +5 lines (authGate prop) +``` + +--- + +## Request Flow (Updated) + +``` +Browser → Bun.serve.fetch + │ + ├─ stampSocketIp(req, ...) + ├─ OPTIONS → 204 + CORS headers + │ + └─ try { + │ + ├─ visitorAuthMiddleware(req, pathname, db) ← NEW (D1) + │ ├─ visitorAuth.enabled === false? → null (pass through) + │ ├─ skip /_instatic/, /admin/, /health, /api/visitor/, /uploads/ + │ ├─ no protectedPrefix match? → null (public page, pass through) + │ ├─ no session cookie? → 302 /login?redirect= (or fallback HTML) + │ └─ valid session (cache D5 or DB)? → null (pass through) + │ + └─ handleServerRequest(req, runtime) + ├─ [4] tryServeVisitorRoutes ← NEW (D2) + │ └─ /api/visitor/* → visitor auth handlers + ├─ tryServeCmsApi → /admin/api/cms/* (admin auth) + ├─ ... existing routes ... + ├─ [18] tryServePublicRoute + │ ├─ Layer A: disk artefact (static HTML) + │ ├─ Layer B: LRU cache + │ └─ Layer C: live render + └─ tryServeNotFoundPage + } catch (err) → 500 +``` + +--- + +## Upstream Rebase Conflict Surface + +Files that could conflict during an upstream rebase: + +| File | Conflict Risk | Resolution Strategy | +|------|---------------|-------------------| +| `server/index.ts` | Low — adding 3 lines in a stable area (inside try, before handleServerRequest) | Manual merge, obvious placement | +| `server/router.ts` | Low — inserting one entry in routes array | Insert at position 4 after rebase | +| `server/db/migrations-pg.ts` | Medium — upstream may add migration 021 | Rename to 022+ if upstream collides | +| `server/db/migrations-sqlite.ts` | Medium — same as PG | Same as PG | +| `server/publish/publicRouter.ts` | Low — small addition to render context | Manual merge | + +All other changes are in **new files** — zero conflict risk. diff --git a/docs/proposals/visitor-auth/PER-VISITOR-DATA-SPEC.md b/docs/proposals/visitor-auth/PER-VISITOR-DATA-SPEC.md new file mode 100644 index 000000000..b76c51298 --- /dev/null +++ b/docs/proposals/visitor-auth/PER-VISITOR-DATA-SPEC.md @@ -0,0 +1,197 @@ +# Per-Visitor Data Framework — Implementation Spec (FINAL) + +**Status:** Finalized (all 6 requirement questions answered 2026-07-24). +**Target branch:** `feat/visitor-data-framework` (new, from `main`). +**Intent:** The framework to display **per-user data on portal pages** — the Instatic equivalent of WordPress + Crocoblock/JetEngine "current user" dynamic tags and "current-user-owned" listing grids. View-only from the portal. + +Findings: `PER-VISITOR-DATA.md`. Earlier draft superseded by this document. + +--- + +## Confirmed requirements (from Darren) + +| # | Question | Answer | +|---|---|---| +| Q1 | Ownership / access model | **Page access stays group-level (already built).** Per-user is a *separate display layer*: a page may show per-user content alongside group-gated access. | +| Q2 | Read vs write | **View-only from the portal.** Visitors do not create/edit/delete data via the portal. | +| Q3 | Lifecycle | **Flat fields, no status/versioning.** | +| Q4 | Staff two-way updates | Out of scope for this framework. | +| Q5 | Linking | **Implicit auto-stamp** for owned data; custom profile fields are explicit config. | +| Q6 | CRM sync | **Deferred.** Framework only; automated linking later. | + +**Two distinct display use-cases to support:** + +- **A. Current-visitor profile** — "Welcome back, {name}", "Your school: {schoolName}". Includes **custom visitor profile fields** (e.g. school name) added by the site builder. +- **B. Owned data rows** — a listing of data rows that belong to the logged-in visitor (e.g. "Your tenders"), captured via forms. + +--- + +## Architecture — three pillars + +``` +PILLAR 1 (foundation): visitor custom profile fields + ├─ field DEFINITIONS → site.settings.visitorAuth.profileFields (DataField[] shape) + ├─ field VALUES → visitor_users.profile_fields_json (new column, mirrors fields_json) + └─ admin edit → Members workspace visitor editor exposes the fields + +PILLAR 2: `visitor.current` loop source (use-case A) + └─ perVisitor source → resolves visitor from session cookie → exposes + {id, email, displayName, roleName, ...customProfileFields} + +PILLAR 3: `visitor.ownedRows` loop source (use-case B) + ├─ migration 003 → data_rows.visitor_user_id (+ index) + data_tables.capturesVisitorOwner flag + ├─ form stamping → server/forms/handler.ts stamps visitor on submit + └─ perVisitor source → filtered to the resolved visitor's rows +``` + +Pillars 2 and 3 are both `perVisitor: true` (cookie-derived, `Cache-Control: no-store`, never baked into static HTML). Pillar 2 depends on Pillar 1; Pillar 3 is independent. + +--- + +## Pillar 1 — Visitor custom profile fields + +### Schema (`DataField` mirror) +Reuse Instatic's existing `DataField` discriminated union (`src/core/data/schemas.ts:246`) so field types, validation, and admin controls are identical to data-table fields. A visitor profile field set is a `DataField[]` with `id`/`label`/`type`/`required` — e.g. `{ id: 'schoolName', label: 'School name', type: 'text' }`. + +### Migration `003_visitor_profile_fields` (PG + SQLite) +```sql +alter table visitor_users + add column profile_fields_json text not null default '{}'; +``` +Stores the per-visitor VALUES keyed by field id, mirroring how `data_tables.fields_json` stores field DEFINITIONS. JSON extraction uses existing helpers (`server/db/jsonExtract.ts`). + +### Field definitions location +Extend the existing `visitorAuth` site-settings block (`server/visitor-auth/config.ts → DEFAULT_VISITOR_AUTH_CONFIG`): +```jsonc +"visitorAuth": { + "enabled": false, + "protectedPrefixes": [], + "loginPath": "/login", + "registrationOpen": true, + "defaultRole": "member", + "profileFields": [] // ← DataField[] — NEW +} +``` +No new table needed; site-builder configures fields in the visitor-auth settings UI (Members section). + +### Admin UI +Members workspace visitor editor (`src/admin/pages/members/`) gains edit controls for each configured `profileFields` entry, persisting to `profile_fields_json`. The `VisitorsTable` also gains the **copyable Member ID** column (the CRM identifier — see security note in `PER-VISITOR-DATA.md`). + +### Registration (optional, deferred in first increment) +The register endpoint (`server/visitor-auth/handlers.ts`) schema can later accept profile field values. First increment: staff enter them in admin. (View-only portal means no self-edit required now.) + +--- + +## Pillar 2 — `visitor.current` loop source (use-case A) + +New file `src/core/loops/sources/visitorCurrent.ts`: +```ts +export const VisitorCurrentSource: LoopEntitySource = { + id: 'visitor.current', + label: 'Current visitor', + perVisitor: true, // cookie-dependent, no-store, never baked + filterSchema: {}, // no filters — identity is always the session + orderByOptions: [], + fields: [ + { id: 'id', label: 'Member ID' }, + { id: 'displayName', label: 'Display name' }, + { id: 'email', label: 'Email' }, + { id: 'roleName', label: 'Role' }, + // + one field per configured profileField (built dynamically) + ], + async fetch(ctx): Promise { + const visitor = await resolveVisitorFromCookie(ctx.db, ctx.request?.cookies) + if (!visitor) return { items: [], totalItems: 0 } // anonymous → nothing renders + return { + items: [{ + id: visitor.id, + fields: { + id: visitor.id, + displayName: visitor.displayName, + email: visitor.email, + roleName: visitor.roleName, + ...visitor.profileFields, // spread custom fields: schoolName, etc. + }, + }], + totalItems: 1, + } + }, + preview() { return [] }, +} +``` +Usage in a loop: `…{currentEntry.schoolName}…`. For single-value display ("Welcome, {name}"), wrap in a loop with one child. + +--- + +## Pillar 3 — `visitor.ownedRows` loop source (use-case B) + +### Migration `004_visitor_owned_data` (PG + SQLite) +```sql +alter table data_rows + add column visitor_user_id text references visitor_users(id) on delete set null; +create index if not exists data_rows_table_visitor_idx + on data_rows (table_id, visitor_user_id, updated_at desc); +alter table data_tables + add column captures_visitor_owner integer not null default 0; +``` +`captures_visitor_owner` is a per-table opt-in (not every table stamps visitors). + +### Form stamping (`server/forms/handler.ts`) +After validation, before `createDataRow`: +```ts +const visitor = await resolveVisitorFromCookie(db, req.headers.get('cookie')) +const stamp = table.capturesVisitorOwner && visitor ? { visitorUserId: visitor.id } : {} +const row = await createDataRow(db, { tableId: table.id, cells, slug: '', ...stamp }) +``` + +### Loop source +New file `src/core/loops/sources/visitorOwnedRows.ts` — mirrors `dataRows.ts` but the SQL `WHERE` adds `and data_rows.visitor_user_id = `. `perVisitor: true`. Render inside an auth-gated hole. + +--- + +## Security invariants (architecture-gate tested) + +New test `src/__tests__/architecture/visitor-data-isolation.test.ts`: + +1. Both visitor loop sources obtain visitor identity **exclusively** from a session-cookie resolver — never from `ctx.request.query`, `ctx.request.path`, or any filter value. +2. No visitor-facing handler/endpoint accepts `visitorUserId`/`userId`/`ownerId`/`id` as a request parameter that influences a read filter. +3. `createDataRow` (form path) stamps `visitorUserId` only from the resolved session, never from the submitted body. +4. Admin handlers reading cross-visitor are explicitly allowlisted. + +(Defence-in-depth: visitor IDs are nanoid — 21 chars, ~126 bits, unguessable, non-sequential — but cookie-derived identity is the load-bearing IDOR guard.) + +--- + +## File changes + +### New files +- `src/core/loops/sources/visitorCurrent.ts` — Pillar 2 source. +- `src/core/loops/sources/visitorOwnedRows.ts` — Pillar 3 source. +- `server/visitor-auth/visitorData.ts` — `resolveVisitorFromCookie`, profile-field read helpers. +- `src/__tests__/architecture/visitor-data-isolation.test.ts` — security gate. + +### Modified files +- `server/db/migrations-{pg,sqlite}.ts` — migrations `003` + `004`. +- `server/visitor-auth/config.ts` — `profileFields` in default config + schema. +- `server/visitor-auth/types.ts` — `VisitorUser.profileFields`. +- `server/visitor-auth/repositories.ts` — read/write `profile_fields_json`. +- `src/admin/pages/members/` — profile-field editor + Member ID column. +- `src/core/loops/sources/index.ts` — register the two new sources. +- `server/forms/handler.ts` — stamp visitor on submit (+~15 lines). +- `server/repositories/data/rows/mutations.ts` — `createDataRow` accepts optional `visitorUserId`. +- `src/core/data/schemas.ts` — `DataRow.visitorUserId`, `DataTable.capturesVisitorOwner`. + +**Estimate: ~250 lines across 4 new + ~10 modified files.** All additive; no changes to existing behaviour (anonymous form submits still work; sites without profile fields are unaffected). + +--- + +## Implementation order + +1. **Branch** `feat/visitor-data-framework` from `main`. ✅ +2. **Pillar 1:** migration `025` + `profileFields` config + type/repository plumbing + admin editor + Member ID column. ✅ migration/types/repositories/config done + typechecks; admin editor + Member ID column pending. +3. **Pillar 2:** `visitor.current` source + register it. ✅ done + typechecks + registered (visible via MCP `site_list_loop_sources`). +4. **Pillar 3:** migration `026` + form stamping + `visitor.ownedRows` source. ⏳ next. +5. **Security gate test** (covers pillars 2 & 3). ⏳ pending. +6. Build + typecheck + existing tests green. ✅ `tsc -b` passes after each pillar. + +Committed: `29f4595 feat(visitor-data): Pillars 1 & 2`. diff --git a/docs/proposals/visitor-auth/PRD-REVIEW.md b/docs/proposals/visitor-auth/PRD-REVIEW.md new file mode 100644 index 000000000..b53f95917 --- /dev/null +++ b/docs/proposals/visitor-auth/PRD-REVIEW.md @@ -0,0 +1,454 @@ +# PRD Review: Instatic Members + +**Reviewer:** GLM-5.2 sub-agent + Sinclaira synthesis +**PRD:** `PRD.md` +**Source:** `Instatic/` at commit `960fdaa` (v0.0.11) +**Date:** 2026-07-22 + +--- + +## Summary + +The PRD is **technically sound in its core architecture** — the separate-cookie, separate-table, middleware-before-router approach is the right call and aligns with how Instatic actually works. However, the review identified **3 Critical, 7 High, 8 Medium, 4 Low, and 2 Info** findings that need resolution before Phase 1 implementation begins. + +The critical findings cluster around: (1) the middleware placement assumption, (2) the login page bootstrapping gap, and (3) cookie scoping subtleties. All are fixable with small PRD amendments. + +--- + +## Findings + +### CRITICAL + +#### C1: Middleware must run INSIDE the try/catch, not before `handleServerRequest` + +**Severity:** Critical +**PRD Section:** §4.1, §5 (Phase 1 file changes) + +The PRD shows the middleware running before `handleServerRequest` in the flow diagram and says `server/index.ts` gets +10 lines adding the call before `handleServerRequest`. Looking at the actual `server/index.ts`: + +```typescript +// Lines 80-92 +try { + const res = await handleServerRequest(req, { db, staticDir, uploadsDir, databaseUrl }) + for (const [k, v] of Object.entries(cors)) { res.headers.set(k, v) } + return applySecurityHeaders(res, pathname) +} catch (err) { + console.error('[server] Unhandled request error:', err) + return applySecurityHeaders( + new Response(JSON.stringify({ error: 'Internal server error' }), { ... }), + pathname, + ) +} +``` + +If the middleware runs *before* the try/catch, any error in visitor auth (DB timeout, bad config) becomes an unhandled Bun.serve rejection — no JSON 500, no security headers, no logging via the established pattern. + +**Fix:** Insert the middleware call *inside* the try block, before `handleServerRequest`: + +```typescript +try { + const authResponse = await visitorAuthMiddleware(req, pathname, db) + if (authResponse) return applySecurityHeaders(authResponse, pathname) + + const res = await handleServerRequest(req, { ... }) + ... +} +``` + +This adds CORS headers and security headers to the 302 redirect, consistent with every other response. + +--- + +#### C2: Login page bootstrapping is completely unaddressed + +**Severity:** Critical +**PRD Section:** §4.6, §9 (Open Questions) + +The PRD says: "Login/register pages are published Instatic pages — designed in the visual editor, backend via `/api/visitor/*` routes." But it never answers: + +1. **Who creates `/login` and `/register` pages?** There's no auto-provisioning step. When a site builder enables visitor auth (story A1), do login/register pages appear automatically? Must the builder create them manually? If manual, how do they know to include the right HTML form structure? + +2. **What's the form contract?** The PRD defines JSON API endpoints but zero HTML form specification. A published Instatic page is static HTML — it needs a `
` that POSTs to `/api/visitor/login`. The PRD doesn't specify: + - Required form fields and their `name` attributes + - The JavaScript that intercepts the form submission and calls `fetch('/api/visitor/login', ...)` + - Error display patterns + - Where this JS comes from (a runtime asset like hole runtime? Inline? A new `/api/visitor/auth-runtime.js`?) + +3. **The chicken-and-egg problem:** The middleware redirects unauthenticated visitors to `/login`. But `/login` is a published page. If the builder hasn't created it yet, visitors get a 404 loop. If they have created it, but the auth runtime JS isn't loaded, the form is dead HTML. + +**Fix (PRD amendment):** + +- Add §4.9 "Login Page Contract" specifying: + - Default pages auto-created when visitor auth is first enabled (seeded like system roles) + - A server-rendered fallback `/login` page when no custom page exists (like `tryServeNotFoundPage` — serve a built-in HTML page, not a redirect) + - The auth runtime JS: a small asset served from `/_instatic/visitor-auth.js` (~2KB) that enhances any form with `data-instatic-auth="login"` or `data-instatic-auth="register"` + - The form field contract: `email`, `password`, `confirm-password` (register only), `redirect` (hidden) + +--- + +#### C3: Cookie `Path=/` creates an implicit trust boundary violation + +**Severity:** Critical +**PRD Section:** §4.4 + +The PRD specifies `Path=/` for the visitor session cookie. The admin cookie uses `Path=/admin` (confirmed: `SESSION_COOKIE_NAME = 'instatic_admin_session'` in `server/auth/tokens.ts`; the cookie path is set elsewhere but the admin namespace is `/admin/*`). + +The concern: a visitor session cookie sent to `/admin/*` means the browser transmits it on every request to the admin namespace. While the admin auth middleware should ignore it (different cookie name), this is unnecessary data leakage. + +More importantly: the PRD says "admin cookie is `Path=/admin`, visitor cookie is `Path=/`" as a security argument. But looking at the actual code, the admin cookie path needs verification — if it's actually `Path=/` (common in session cookies), both cookies are sent everywhere and the PRD's scoping argument is wrong. + +**Fix:** + +- Verify the admin session cookie's actual `Path` attribute (check `server/auth/sessions.ts` or wherever `set-cookie` is constructed) +- If admin cookie is `Path=/`: the PRD should acknowledge this and explain why different cookie *names* alone provide sufficient isolation (they do — middleware only reads the cookie it knows about) +- If admin cookie is `Path=/admin`: keep `Path=/` for visitor cookie (visitors need it on public pages), but remove the comparative security claim since it's about namespace separation, not cookie path + +--- + +### HIGH + +#### H1: `tryServeVisitorRoutes` position in the routes array is wrong + +**Severity:** High +**PRD Section:** §5 ("before `tryServePublicRoute`") + +The PRD says to add `tryServeVisitorRoutes` to the `routes[]` array before `tryServePublicRoute`. Looking at the actual routing table order: + +```typescript +const routes: readonly RouteHandler[] = [ + tryServeHealth, // 1 + tryServeMcp, // 2 + tryServeAi, // 3 + tryServeCmsApi, // 4 — /admin/api/cms/* + tryServeLoopRuntimeAsset,// 5 + tryServeLoop, // 6 + tryServeHoleRuntimeAsset,// 7 + tryServeHole, // 8 + tryServeModuleJsAsset, // 9 + tryServePublicForm, // 10 + tryServeRuntimeAsset, // 11 + tryServeRuntimePackageNamespace, // 12 + tryServeSiteCssNamespace,// 13 + tryServeMediaRedirect, // 14 + tryServeStaticAsset, // 15 — /_instatic/* + tryServeUpload, // 16 + tryServeAdminApp, // 17 + tryServePublicRoute, // 18 + trySetupRedirect, // 19 + tryServeNotFoundPage, // 20 +] +``` + +`tryServePublicRoute` is at position 18. But `tryServeStaticAsset` (position 15) serves `/_instatic/*`, which includes the visitor auth runtime JS. The visitor API routes (`/api/visitor/*`) must be matched BEFORE the admin CMS API routes (position 4) to avoid the admin auth gate blocking them. + +**Fix:** Insert `tryServeVisitorRoutes` at position **4** (before `tryServeCmsApi`), not position 17. The visitor API has its own auth (visitor session cookie), and the admin API gate should never see these requests. + +Wait — actually, re-reading: `tryServeCmsApi` checks for the `/admin/api/cms/*` prefix. `/api/visitor/*` won't match that prefix. So position before `tryServePublicRoute` (position 18) *would* work functionally. But for clarity and to avoid future admin API prefix changes, position 4 (right after `tryServeAi`) is better. + +**Revised fix:** Insert at position 4 for defense-in-depth. Document why. + +--- + +#### H2: The PRD reuses `hashSessionToken` and `RateLimiter` but doesn't account for Bun's module caching + +**Severity:** High +**PRD Section:** §4.2 ("Reuse patterns, not code") + +The PRD says: "We instantiate the same `RateLimiter` class and call the same `hashSessionToken()` function, but visitor auth lives in its own directory." + +Importing from `server/auth/tokens.ts` and `server/auth/rateLimit.ts` means visitor auth code has a compile-time dependency on the admin auth directory. The PRD's own architecture gate test (§6) says: "verify visitor auth never imports from `server/auth/*`". **These are contradictory.** + +**Fix:** Three options, pick one in the PRD: + +1. **Copy the functions** (~30 lines total: `hashSessionToken`, `createSessionToken`, `sessionExpiry`, the `RateLimiter` class). This is the purest isolation but creates maintenance drift. +2. **Extract shared utilities** into `server/auth/shared/` (or `server/utils/`) that both admin auth and visitor auth import from. The architecture gate test checks for imports from `server/auth/tokens.ts` and `server/auth/rateLimit.ts` specifically, not the shared module. +3. **Accept the import and update the gate test.** The `RateLimiter` class and `hashSessionToken` are pure utility functions with no admin-specific state. Importing them doesn't create a runtime dependency on admin auth. The gate test should verify "visitor auth never imports admin session validation, admin middleware, or admin repositories from `server/auth/*`" — not a blanket ban. + +**Recommendation:** Option 3. The PRD already notes these are pattern reuse. The gate test should be specific about what it bans. + +--- + +#### H3: No specification for how `visitorAuth.enabled` is stored or read + +**Severity:** High +**PRD Section:** §4.6 + +The middleware reads `site.settings.visitorAuth` to check if auth is enabled. But: + +- Instatic's site settings are stored in the database, likely in a `settings` JSON column on a site/config table +- The PRD doesn't specify the schema for this setting +- The PRD doesn't specify how it's surfaced in the admin UI (story A1 says "enable/disable visitor auth globally in site settings" but there's no UI spec) +- If visitor auth is disabled, should existing sessions be invalidated? Should the middleware still serve `/api/visitor/*` routes? + +**Fix:** Add a `site_settings` sub-section specifying: + +```json +{ + "visitorAuth": { + "enabled": true, + "protectedPrefixes": ["/members", "/dashboard"], + "defaultRedirect": "/members", + "registrationOpen": true + } +} +``` + +And specify: when `enabled` is toggled OFF, active sessions are not revoked (visitors keep browsing until session expires), but the middleware stops enforcing redirects. The `/api/visitor/*` routes return 403 when auth is disabled. + +--- + +#### H4: `protectedPrefixes` glob matching is under-specified + +**Severity:** High +**PRD Section:** §4.6 + +The middleware step 4 says "Match pathname against `protectedPrefixes[]` (glob matching)". But: + +- What glob library? Bun doesn't have built-in glob matching for URL paths. The admin router uses exact prefix matching (`pathname.startsWith(...)`). +- Does `/members/*` protect `/members/settings` but not `/memberships`? Prefix matching says yes/no depending on trailing slash. +- What about query strings? The PRD doesn't mention them. +- What about exact page paths vs. prefix paths? A builder might want to protect `/vip-page` (exact) and `/members/*` (prefix). + +**Fix:** Replace "glob matching" with exact prefix matching (consistent with the rest of Instatic's router). Each entry in `protectedPrefixes` is a path prefix. `/members` protects `/members`, `/members/`, `/members/anything`. To protect only `/vip-page` exactly, the prefix is `/vip-page` and the middleware adds a trailing-slash check (or the builder uses a directory structure). + +--- + +#### H5: Session DB lookup on every protected-page request breaks the "0.1ms overhead" claim + +**Severity:** High +**PRD Section:** §8 (Performance Impact) + +The PRD claims protected pages add ~1.5ms (cookie + session DB lookup + disk read). A single indexed DB query for a session row by SHA-256 hash should indeed be ~0.5ms on SQLite and ~1ms on PG. But: + +- The PRD doesn't mention connection pooling or connection cost. If Instatic uses a single connection (likely for SQLite), the query is sequential. For PG, if there's a connection pool, the lookup is a pool round-trip. +- The `last_seen_at` debounce (30s) requires a read-then-conditional-write, which is two queries unless done cleverly. +- On high-traffic member areas, every page view = 1-2 DB queries. This is the exact pattern Instatic was designed to avoid. + +**Fix:** Add a session cache (in-memory Map, 5-minute TTL) that avoids the DB lookup on repeated requests from the same visitor. The cache key is `id_hash`. This brings the per-request cost back to ~0.1ms for warm sessions. The PRD should acknowledge the trade-off: stale session revocation takes up to 5 minutes to propagate (acceptable for member areas, not for banking). + +--- + +#### H6: Missing CSRF protection specification for visitor auth endpoints + +**Severity:** High +**PRD Section:** §4.5, §7 + +The PRD mentions "Origin check (reuse existing `isStateChangingMethod` + `originAllowed` pattern)" in §7. But `isStateChangingMethod` and `originAllowed` are in `server/auth/security.ts` — which is in the admin auth directory. See H2: importing from here conflicts with the isolation principle. + +More importantly, `originAllowed` checks against `configurePublicOrigins(config.publicOrigins)`. If a site is behind a reverse proxy, the public origin must be configured. The PRD doesn't mention this prerequisite. + +**Fix:** + +- Extract `isStateChangingMethod` and `originAllowed` to `server/utils/security.ts` (they're origin utilities, not auth-specific) +- Or duplicate the ~20 lines of logic (they're simple string comparisons) +- Document that visitor auth endpoints require `PUBLIC_ORIGIN` to be configured, same as admin auth + +--- + +#### H7: No logout API response specification + +**Severity:** High +**PRD Section:** §4.5 + +The `POST /api/visitor/logout` endpoint revokes the session and clears the cookie. But: + +- What's the response body? Empty? `{ "ok": true }`? +- What status code? 200? 204? +- After logout, should the response redirect to the home page? The client-side JS needs to know what to do. + +**Fix:** Specify: `204 No Content` with `Set-Cookie: instatic_visitor_session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`. The client-side JS handles redirect via `window.location = '/'`. + +--- + +### MEDIUM + +#### M1: `visitor_login_attempts` table has no TTL/cleanup strategy + +**Severity:** Medium +**PRD Section:** §4.3 + +The `visitor_login_attempts` table grows unbounded. Admin login attempts aren't stored in a separate table (the admin system uses `failed_login_count` on the user row + in-memory rate limits). The visitor system adds a full audit table. + +**Fix:** Add a periodic cleanup job (daily, delete rows older than 90 days) or use the heartbeat system's 30-minute tick to prune. + +--- + +#### M2: `visitor_roles.capabilities_json` uses a JSON string column instead of a junction table + +**Severity:** Medium +**PRD Section:** §4.3 + +Storing capabilities as a JSON array in a text column works for simple cases but makes querying "which users have capability X?" require scanning all roles. The admin system uses a proper capabilities table. + +However, for a v1 with 2-3 roles and ~10 capabilities, this is fine. Flag for Phase 2 if role queries become complex. + +**Fix:** Acknowledge in PRD as a known simplification. Add a comment in the migration. + +--- + +#### M3: Missing password strength requirements + +**Severity:** Medium +**PRD Section:** §4.5 (register handler) + +The PRD doesn't specify minimum password length, complexity requirements, or whether Bun.password.hash's argon2id parameters are sufficient alone. + +**Fix:** Add minimum 8 characters. Argon2id handles the hashing strength. The frontend should enforce minimum length before submission. + +--- + +#### M4: `email_normalized` UNIQUE constraint with partial index may not work on SQLite + +**Severity:** Medium +**PRD Section:** §4.3 + +The PRD specifies: `UNIQUE (email_normalized) WHERE deleted_at IS NULL`. This is a partial unique index, which PostgreSQL supports natively. SQLite also supports partial indexes (since 3.8.0), but the syntax must be `CREATE UNIQUE INDEX ... ON visitor_users (email_normalized) WHERE deleted_at IS NULL` — not a table-level constraint. + +**Fix:** Specify the index as a separate `CREATE INDEX` statement in both migrations, not a table constraint. + +--- + +#### M5: No specification for visitor account deletion (GDPR/right to be forgotten) + +**Severity:** Medium +**PRD Section:** (missing) + +The schema has `deleted_at` for soft delete, but no endpoint or story for a visitor to delete their own account. GDPR requires this for EU visitors. + +**Fix:** Add user story V8: "As a visitor, I can delete my account and all my data." Add `DELETE /api/visitor/me` endpoint (hard delete after anonymising login_attempts). This can be Phase 2. + +--- + +#### M6: `display_name` field has no length constraint + +**Severity:** Medium +**PRD Section:** §4.3 + +Without a length limit, a visitor could submit a multi-megabyte display name. The admin system likely has similar gaps, but since we're adding new tables, we should set an example. + +**Fix:** Add `CHECK (length(display_name) <= 200)` in the migration. + +--- + +#### M7: Auth-gated holes endpoint `/_instatic/gate/` conflicts with static asset namespace + +**Severity:** Medium +**PRD Section:** §4.7 + +The `/_instatic/*` namespace is served by `tryServeStaticAsset` (position 15 in the router). A `/_instatic/gate/` endpoint would need to be added BEFORE `tryServeStaticAsset` in the routes array. The PRD mentions the endpoint but doesn't specify its router position. + +**Fix:** Add to the routes array at position 8 (after `tryServeHole`, since gated holes are a sibling feature to regular holes). + +--- + +#### M8: Missing OpenAPI / endpoint documentation + +**Severity:** Medium +**PRD Section:** §4.5 + +The PRD lists endpoints but doesn't specify request bodies, response schemas for all cases, or error codes beyond a single example. For example: + +- `POST /api/visitor/register`: What if the email is already taken? 409? 422? +- `PATCH /api/visitor/me`: What fields are updatable? All? Just `display_name`? +- `GET /api/visitor/me` when session is invalid: 401? What's the response body? + +**Fix:** Add a request/response table for each endpoint with success and error cases. + +--- + +### LOW + +#### L1: No specification for visitor avatar/upload support + +**Severity:** Low +**PRD Section:** §4.3 (visitor_users table) + +Story V4 mentions "avatar" but the `visitor_users` table has no avatar field. This is probably a future enhancement, but the story should be marked as Phase 2+ or the avatar reference removed. + +--- + +#### L2: `device_label` in `visitor_sessions` has no derivation logic + +**Severity:** Low +**PRD Section:** §4.3 + +The field exists in the schema but no logic is specified for how to populate it. User-Agent parsing is non-trivial. Consider using a simple UA string truncation or deferring to Phase 2. + +--- + +#### L3: No rate limit for password reset emails + +**Severity:** Low +**PRD Section:** §4.4 + +The rate limits table covers login, per-IP login, and registration, but not `POST /api/visitor/forgot`. An attacker could spam password reset emails to harass a user. + +**Fix:** Add `visitorForgotPerIpRateLimit`: 3 per hour per IP, 1 per 15 min per email. + +--- + +#### L4: Migration numbering assumes no upstream migrations collide + +**Severity:** Low +**PRD Section:** §5 + +The PRD adds migration 021. If upstream adds migration 021 before the next rebase, there's a conflict. This is a general fork maintenance concern, not specific to this PRD. + +**Fix:** Use a namespace prefix in migration filenames (e.g., `021_visitor_auth_*`) and document the rebase conflict resolution process. + +--- + +### INFO + +#### I1: The PRD's Layer A performance claim of ~0.6ms is conservative + +**Severity:** Info + +Actual `readArtefact` is a Bun.file() read which is well under 1ms on SSD/NVMe. The 0.6ms figure in the PRD is fine as a conservative estimate. With the cookie parse overhead, 0.7ms for public pages is realistic. + +--- + +#### I2: Consider adding a `visitor_sessions` purge job + +**Severity:** Info + +Expired and revoked sessions should be periodically purged from the database. The admin system likely has a similar job. Align with whatever pattern the admin uses. + +--- + +## Resolution Matrix + +| ID | Severity | PRD Change Required? | Resolution | +|----|----------|---------------------|------------| +| C1 | Critical | Yes | Move middleware inside try/catch (§4.1 diagram + §5) | +| C2 | Critical | Yes | Add §4.9 login page contract | +| C3 | Critical | Yes | Verify admin cookie Path, update §4.4 and §7 | +| H1 | High | Yes | Specify route position as position 4, not "before tryServePublicRoute" | +| H2 | High | Yes | Choose option 3 (accept utility imports, narrow gate test) | +| H3 | High | Yes | Add site_settings schema in §4.6 | +| H4 | High | Yes | Replace "glob" with prefix matching, clarify semantics | +| H5 | High | Yes | Add in-memory session cache (5-min TTL) | +| H6 | High | Yes | Resolve CSRF utility import (see H2) + document PUBLIC_ORIGIN prereq | +| H7 | High | Yes | Specify 204 + Set-Cookie for logout | +| M1 | Medium | Minor | Add cleanup strategy note | +| M2 | Medium | No | Acknowledge as known simplification | +| M3 | Medium | Minor | Add password min length | +| M4 | Medium | Yes | Separate CREATE INDEX in migrations | +| M5 | Medium | Defer | Add as Phase 2 story | +| M6 | Medium | Minor | Add CHECK constraint | +| M7 | Medium | Yes | Specify route position for gate endpoint | +| M8 | Medium | Yes | Add full request/response specs | +| L1 | Low | Minor | Clarify V4 avatar as Phase 2 | +| L2 | Low | Minor | Defer device_label population | +| L3 | Low | Minor | Add forgot rate limit | +| L4 | Low | No | Document in fork maintenance §6 | +| I1 | Info | No | No change | +| I2 | Info | Minor | Note in §4.3 | + +--- + +## Verdict + +**Proceed with implementation after addressing C1-C3 and H1-H7.** The core architecture (separate cookies, separate tables, middleware interception, auth-gated holes) is validated against the source code and sound. The findings are specification gaps, not fundamental design flaws. + +Estimated PRD amendment effort: ~2 hours. diff --git a/docs/proposals/visitor-auth/PRD.md b/docs/proposals/visitor-auth/PRD.md new file mode 100644 index 000000000..70442109e --- /dev/null +++ b/docs/proposals/visitor-auth/PRD.md @@ -0,0 +1,635 @@ +# PRD: Instatic Members — Visitor Authentication & Member Areas for Instatic CMS + +**Project:** Instatic Members +**Fork of:** [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic) (v0.0.11) +**Date:** 2026-07-22 +**Status:** Draft v2 (amended per PRD-REVIEW findings C1–C3, H1–H7, M1–M8, L1–L4) +**Owner:** Darren / dazzakiller + +--- + +## 1. Problem Statement + +Instatic is a self-hosted visual CMS that publishes clean, fast static HTML/CSS. Its authentication system is exclusively for **CMS administrators** — visitors cannot log in, register, or access protected content. This limits Instatic to brochure/marketing/blog/portfolio sites and excludes any use case requiring user accounts, member areas, or protected content. + +WordPress, which Instatic aims to improve upon, supports these use cases natively via user registration, role-based access, and member-only content. For Instatic to be a viable replacement for a broader set of sites, it needs visitor-facing authentication. + +### What This PRD Does NOT Cover + +- Admin auth changes (the existing 38-capability system is untouched) +- E-commerce, payments, or subscriptions (separate concern) +- OAuth/social login providers (Phase 3+) +- Plugin-visitor auth (Phase 3+) +- Replacing or modifying the static publisher for protected content baking + +--- + +## 2. Goals + +- **G1:** Site visitors can register, log in, and manage their own accounts +- **G2:** Entire pages can be marked as members-only via the admin UI +- **G3:** Sections within a page can be gated by visitor role (public teaser, members-only body) +- **G4:** Authenticated visitor identity is available for personalisation ("Welcome back, Darren") +- **G5:** Visitor auth is completely separate from admin auth — zero runtime coupling +- **G6:** 95%+ of published pages remain static files served directly from disk (no perf regression) +- **G7:** The fork minimises core changes to ease upstream rebasing + +### Non-Goals + +- Multi-factor authentication for visitors (future enhancement) +- Visitor self-service password reset via email (Phase 2 — requires email config) +- Per-user content rendering (beyond what gated holes provide) +- Integration with external identity providers (Auth0, Clerk, Supabase) +- Visitor account deletion / GDPR right-to-be-forgotten (Phase 2 — separate user story V8) +- Visitor avatars (Phase 2 — story V4 clarified below) + +--- + +## 3. User Stories + +### Visitor (Public-Facing) + +| ID | Story | Phase | +|----|-------|-------| +| V1 | As a visitor, I can register with email + password | 1 | +| V2 | As a visitor, I can log in and log out | 1 | +| V3 | As a visitor, I see a "Sign in" prompt when accessing members-only content | 1 | +| V4 | As a logged-in visitor, I can see my name on the site (avatar deferred to Phase 2) | 1 | +| V5 | As a logged-in visitor, I can access members-only pages | 1 | +| V6 | As a logged-in visitor, I can see gated sections that unauthenticated visitors cannot | 2 | +| V7 | As a visitor, I can request a password reset | 2 | +| V8 | As a visitor, I can delete my account and all my data | 2 | + +### Site Builder (Admin-Facing) + +| ID | Story | Phase | +|----|-------|-------| +| A1 | As a site builder, I can enable/disable visitor auth globally in site settings | 1 | +| A2 | As a site builder, I can mark specific pages as "members only" | 1 | +| A3 | As a site builder, I can design the login/register pages in the visual editor | 1 | +| A4 | As a site builder, I can view and manage visitor user accounts | 1 | +| A5 | As a site builder, I can create visitor roles (member, admin, custom) | 1 | +| A6 | As a site builder, I can wrap any page section with an "auth gate" and choose which roles can see it | 2 | +| A7 | As a site builder, I can see how many registered visitors the site has | 1 | + +--- + +## 4. Technical Architecture + +### 4.1 Overview + +``` +Browser → Bun.serve.fetch + │ + ├─ stampSocketIp(req, ...) + ├─ OPTIONS → 204 + CORS headers + │ + └─ try { + │ + ├─ visitorAuthMiddleware(req, pathname, db) ← NEW (inside try/catch) + │ ├─ read site.settings.visitorAuth (in-memory cached) + │ ├─ enabled === false? → null (pass through) + │ ├─ skip /_instatic/, /admin/, /health, /api/visitor/, /uploads/ + │ ├─ no protectedPrefix match? → null (public page) + │ ├─ check session (in-memory cache, 5-min TTL → DB fallback) + │ ├─ no session? → 302 /login?redirect= (or built-in fallback HTML) + │ └─ valid session? → null (pass through) + │ + └─ handleServerRequest(req, runtime) + ├─ [4] tryServeVisitorRoutes ← NEW + │ └─ /api/visitor/* → visitor auth handlers + ├─ tryServeCmsApi → /admin/api/cms/* (admin auth, unchanged) + ├─ ... existing routes ... + ├─ tryServeStaticAsset → /_instatic/* (CSS, JS, hole runtime) + ├─ tryServePublicRoute + │ ├─ Layer A: disk artefact (static HTML, ~0.7ms) + │ ├─ Layer B: LRU cache + │ └─ 404 / not-found template + └─ tryServeNotFoundPage + } catch (err) → 500 (with security headers) +``` + +**Key change from v1:** The middleware runs **inside** the existing `try` block in `server/index.ts`, before `handleServerRequest`. This ensures middleware errors are caught by the existing error handler (generic 500 + security headers), and the 302 redirect gets `applySecurityHeaders` + CORS treatment like every other response. + +### 4.2 Design Principles + +1. **Separate systems by cookie name, not cookie path.** Visitor auth uses cookie `instatic_visitor_session`; admin auth uses `instatic_admin_session`. These are different names — admin routes only read the admin cookie, visitor middleware only reads the visitor cookie. The admin cookie has no explicit `Path=` attribute (it defaults to `/`); the visitor cookie also uses `Path=/` because visitors need it on all public pages. Isolation is guaranteed by **different cookie names and different middleware**, not by cookie path scoping. + +2. **Reuse utility functions, ban admin state imports.** Visitor auth MAY import these specific stateless utilities from `server/auth/`: + - `hashSessionToken`, `createSessionToken`, `sessionExpiry` from `server/auth/tokens.ts` + - `RateLimiter` class from `server/auth/rateLimit.ts` + - `isStateChangingMethod`, `originAllowed`, `publicOriginIsHttps` from `server/auth/security.ts` + + Visitor auth MUST NOT import from: `server/auth/sessions.ts`, `server/auth/middleware.ts`, `server/auth/index.ts`, `server/repositories/users.ts`, or any CMS handler. The architecture gate test enforces this specific ban list. + +3. **Auth is request-time.** The publisher (`src/core/publisher/`) is not modified. Auth-gated content uses the existing hole mechanism. Static pages remain static files on disk. + +4. **Minimal core diff.** Core files are modified by ~20 lines total in Phase 1 (down from the original +25 estimate thanks to the simpler middleware insertion). All visitor auth logic is in new files. + +### 4.3 Database Schema + +#### `visitor_users` + +| Column | Type | Constraints | Notes | +|--------|------|------------|-------| +| `id` | text | PK, nanoid | | +| `email` | text | NOT NULL | | +| `email_normalized` | text | NOT NULL | Lowercase, trimmed. Unique via separate index (see below) | +| `password_hash` | text | NOT NULL | Argon2id via Bun.password.hash | +| `display_name` | text | NOT NULL DEFAULT '', CHECK(length <= 200) | | +| `role_id` | text | NOT NULL, FK→visitor_roles(id) | | +| `status` | text | NOT NULL DEFAULT 'active' | CHECK: 'active' \| 'suspended' | +| `failed_login_count` | integer | NOT NULL DEFAULT 0 | | +| `locked_until` | text | NULL | ISO 8601 | +| `created_at` | text | NOT NULL | ISO 8601 | +| `updated_at` | text | NOT NULL | ISO 8601 | +| `deleted_at` | text | NULL | Soft delete | + +**Indexes:** Separate `CREATE UNIQUE INDEX visitor_users_email_active_idx ON visitor_users (email_normalized) WHERE deleted_at IS NULL` — not a table constraint, because partial unique indexes must be separate `CREATE INDEX` statements for SQLite compatibility. + +#### `visitor_sessions` + +| Column | Type | Constraints | Notes | +|--------|------|------------|-------| +| `id_hash` | text | PK | SHA-256 of session token | +| `user_id` | text | NOT NULL, FK→visitor_users(id) CASCADE | | +| `created_at` | text | NOT NULL | | +| `last_seen_at` | text | NOT NULL | Debounced 30s (mirrors admin session pattern) | +| `expires_at` | text | NOT NULL | 90-day absolute expiry | +| `revoked_at` | text | NULL | | +| `ip_address` | text | NULL | | +| `user_agent` | text | NULL | | +| `device_label` | text | NOT NULL DEFAULT '' | UA parsing deferred to Phase 2; empty string is valid | + +**Indexes:** `visitor_sessions_user_idx` on `(user_id, last_seen_at DESC)` + +#### `visitor_roles` + +| Column | Type | Constraints | Notes | +|--------|------|------------|-------| +| `id` | text | PK | | +| `name` | text | NOT NULL UNIQUE | e.g., 'member', 'admin', 'editor' | +| `capabilities_json` | text | NOT NULL DEFAULT '[]' | JSON array. Known simplification (M2): junction table deferred until role queries become complex. | +| `is_system` | integer | NOT NULL DEFAULT 0 | | +| `created_at` | text | NOT NULL | | +| `updated_at` | text | NOT NULL | | + +**Seeded roles:** `member` (default for new registrations), `admin` (full access) + +#### `visitor_login_attempts` + +| Column | Type | Constraints | Notes | +|--------|------|------------|-------| +| `id` | text | PK | | +| `attempted_at` | text | NOT NULL | | +| `email_normalized` | text | NULL | | +| `ip_address` | text | NULL | | +| `user_agent` | text | NULL | | +| `user_id` | text | NULL, FK→visitor_users(id) SET NULL | | +| `result` | text | NOT NULL | CHECK: 'success' \| 'bad_password' \| 'no_user' \| 'locked' \| 'rate_limited' | + +**Indexes:** `visitor_login_attempts_ip_idx`, `visitor_login_attempts_email_idx` + +**Cleanup (M1):** Rows older than 90 days are purged by a periodic job (daily, piggybacks on the heartbeat tick or a dedicated cron). + +### 4.4 Session & Security + +**Cookie:** `instatic_visitor_session` +- `Path=/; HttpOnly; Secure; SameSite=Lax` +- `Secure` flag set when `publicOriginIsHttps()` returns true (reuses existing `server/auth/security.ts` function) +- Token: 32 random bytes (base64url-encoded) +- Storage: SHA-256 hash of token as `id_hash` (never store raw token) +- Expiry: 90-day absolute, 30-day idle +- Rotation: on password change + +**In-memory session cache (H5):** +- `Map` keyed by `id_hash`, 5-minute TTL +- On session validation: cache hit → return immediately (no DB); cache miss → DB query → cache result +- On session revoke/logout: delete from cache +- **Trade-off:** Stale revocation takes up to 5 minutes to propagate. Acceptable for member areas (not banking). + +**Rate limits (reuse `RateLimiter` class from `server/auth/rateLimit.ts`):** + +| Limiter | Key | Limit | Window | +|---------|-----|-------|--------| +| `visitorLoginRateLimit` | `\|` | 5 | 15 min | +| `visitorLoginPerIpRateLimit` | `` | 30 | 10 min | +| `visitorRegisterPerIpRateLimit` | `` | 3 | 60 min | +| `visitorForgotPerIpRateLimit` | `` | 3 | 60 min | +| `visitorForgotPerEmailRateLimit` | `` | 1 | 15 min | + +**Lockout (reuse `evaluateFailedAttempt` pattern from `server/auth/lockout.ts`):** +- 5 failed attempts → exponential backoff (15min → 30min → 1h → 2h → 4h → 8h → ... → 24h cap) +- Per-account, not per-IP + +**Password hashing:** `Bun.password.hash(password, { algorithm: 'argon2id' })` — same as admin + +**Password policy (M3):** Minimum 8 characters. Frontend enforces before submission; server validates and rejects shorter passwords with `422`. + +### 4.5 API Endpoints + +All mounted at `/api/visitor/*` via a new `tryServeVisitorRoutes` handler in the router at **position 4** (after `tryServeAi`, before `tryServeCmsApi`). Defense-in-depth: placing visitor routes before the admin CMS API ensures they're reachable regardless of future admin route prefix changes. + +**CSRF protection (H6):** All state-changing endpoints (POST/PATCH/DELETE) apply `isStateChangingMethod` + `originAllowed` from `server/auth/security.ts` (same pattern as `handleCmsRequest` in `server/handlers/cms/index.ts`). `PUBLIC_ORIGIN` must be configured for production deployments (same prerequisite as admin auth). Safe methods (GET) are not checked. + +#### `POST /api/visitor/register` + +Create a visitor account. + +| | | +|---|---| +| **Auth?** | None | +| **CSRF** | Origin check | +| **Rate limit** | 3/hour per IP | + +**Request:** +```json +{ "email": "user@example.com", "password": "secret123", "displayName": "Darren" } +``` + +| Field | Required | Validation | +|-------|----------|------------| +| `email` | Yes | Valid email, max 254 chars | +| `password` | Yes | Min 8 chars | +| `displayName` | No | Max 200 chars, defaults to '' | + +**Responses:** + +| Status | Body | When | +|--------|------|------| +| 201 | `{ "id": "...", "email": "...", "displayName": "...", "role": "member" }` | Success | +| 403 | `{ "error": "registration_closed" }` | `visitorAuth.registrationOpen === false` | +| 409 | `{ "error": "email_taken" }` | Email already registered (active, non-deleted) | +| 422 | `{ "error": "validation_failed", "details": { "password": "Password must be at least 8 characters" } }` | Validation error | +| 429 | `{ "error": "rate_limited", "retryAfterMs": 3600000 }` | Rate limit exceeded | + +#### `POST /api/visitor/login` + +Authenticate and create a session. + +| | | +|---|---| +| **Auth?** | None | +| **CSRF** | Origin check | +| **Rate limit** | 5/15min per (ip,email), 30/10min per IP | + +**Request:** +```json +{ "email": "user@example.com", "password": "secret123" } +``` + +**Responses:** + +| Status | Body | When | +|--------|------|------| +| 200 | `{ "id": "...", "email": "...", "displayName": "...", "role": "member", "capabilities": [...] }` | Success (sets `Set-Cookie: instatic_visitor_session=...`) | +| 401 | `{ "error": "invalid_credentials" }` | Bad email or password | +| 401 | `{ "error": "account_locked", "retryAfterMs": 900000 }` | Account locked (exponential backoff) | +| 401 | `{ "error": "account_suspended" }` | User status is 'suspended' | +| 403 | `{ "error": "visitor_auth_disabled" }` | `visitorAuth.enabled === false` | +| 429 | `{ "error": "rate_limited", "retryAfterMs": 600000 }` | Rate limit exceeded | + +#### `POST /api/visitor/logout` + +Revoke session and clear cookie. + +| | | +|---|---| +| **Auth?** | Visitor session | +| **CSRF** | Origin check | + +**Responses:** + +| Status | Body | When | +|--------|------|------| +| 204 | (empty) | Success. `Set-Cookie: instatic_visitor_session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax` | +| 401 | `{ "error": "unauthorized" }` | No valid session | + +Client-side JS handles post-logout navigation (`window.location = '/'`). + +#### `GET /api/visitor/me` + +Return current visitor's profile. + +| | | +|---|---| +| **Auth?** | Visitor session | + +**Response (200):** +```json +{ "id": "abc123", "email": "user@example.com", "displayName": "Darren", "role": "member", "capabilities": ["content.read"] } +``` + +**Error (401):** `{ "error": "unauthorized" }` + +#### `PATCH /api/visitor/me` + +Update display name or email. + +| | | +|---|---| +| **Auth?** | Visitor session | +| **CSRF** | Origin check | + +**Request:** +```json +{ "displayName": "New Name" } +``` + +| Field | Updatable | Notes | +|-------|-----------|-------| +| `displayName` | Yes | Max 200 chars | +| `email` | Yes (Phase 2) | Requires email verification | + +**Responses:** 200 (updated profile), 401, 422 (validation) + +#### `POST /api/visitor/forgot` + +Request a password reset email. (Phase 2 — requires email config.) + +| | | +|---|---| +| **Auth?** | None | +| **CSRF** | Origin check | +| **Rate limit** | 3/hour per IP, 1/15min per email | + +**Request:** `{ "email": "user@example.com" }` + +**Responses:** 200 (always — prevents email enumeration), 429 + +#### `POST /api/visitor/reset` + +Reset password with token. (Phase 2.) + +| | | +|---|---| +| **Auth?** | None (token in body) | +| **CSRF** | Origin check | + +**Request:** `{ "token": "...", "password": "newpass123" }` + +**Responses:** 200 (success), 401 (invalid/expired token), 422 + +#### `GET /api/visitor/gate/` + +Check if visitor can access gated content. (Phase 2.) + +| | | +|---|---| +| **Auth?** | Optional (anonymous gets fallback) | + +**Response:** HTML fragment (real content if authorized, fallback/teaser if not). `Cache-Control: no-store`. + +### 4.6 Site Settings Schema + +Visitor auth configuration lives in the existing `site.settings_json` column (JSONB on PG, text on SQLite). The `visitorAuth` key is read by the middleware on each request (in-memory cached, refreshed on publish like other site settings). + +```json +{ + "visitorAuth": { + "enabled": false, + "protectedPrefixes": [], + "loginPath": "/login", + "registrationOpen": true, + "defaultRole": "member" + } +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | boolean | `false` | Global toggle. When false, middleware passes through all requests. `/api/visitor/*` routes return 403. Active sessions are NOT revoked (visitors keep browsing until session expires). | +| `protectedPrefixes` | string[] | `[]` | Path prefixes that require authentication. Prefix matching, not glob (see §4.6.1). | +| `loginPath` | string | `"/login"` | Where unauthenticated visitors are redirected. | +| `registrationOpen` | boolean | `true` | When false, `POST /api/visitor/register` returns 403. | +| `defaultRole` | string | `"member"` | Role ID assigned to new registrations. | + +#### 4.6.1 Prefix Matching Semantics + +Each entry in `protectedPrefixes` is a path prefix. The matching rule is: + +``` +pathname === prefix || pathname.startsWith(prefix + '/') || pathname.startsWith(prefix + '?') +``` + +This means: +- `/members` protects `/members` (exact) and `/members/anything` (sub-paths) +- `/members` does NOT protect `/membership` (no `/` separator) +- `/vip-page` protects ONLY `/vip-page` (exact) and `/vip-page?query=1` +- To protect a directory tree, use a trailing slash: `/members/` protects `/members/`, `/members/dashboard`, etc. + +No glob library needed — consistent with Instatic's existing `pathname.startsWith(...)` pattern used throughout the router. + +### 4.7 Middleware (`server/visitor-auth/middleware.ts`) + +``` +visitorAuthMiddleware(req, pathname, db): + 1. Read visitor auth config from site.settings.visitorAuth (in-memory cache) + 2. If visitorAuth.enabled === false → return null (pass through) + 3. Skip if pathname starts with: /_instatic/, /admin/, /health, /api/visitor/, /uploads/ + 4. Match pathname against protectedPrefixes[] (prefix matching per §4.6.1) + 5. If no match → return null (public page, pass through) + 6. Parse instatic_visitor_session cookie + 7. If no cookie or invalid/expired/revoked session: + a. Check if a published page exists at visitorAuth.loginPath + b. If yes → 302 redirect to /login?redirect= + c. If no published page → return built-in fallback login HTML (200 Response) + 8. If valid session (from in-memory cache or DB) → return null (authenticated, pass through) +``` + +### 4.8 Auth-Gated Holes (Phase 2) + +**New node property:** `authGate` on `base.container` module +- Values: `none` (default), `member`, `admin`, or any visitor role name +- Set in the editor Properties panel when a container is selected + +**Publisher extension:** +1. New rule in `dynamicDetection.ts`: if `node.props.authGate && node.props.authGate !== 'none'` → mark as gated +2. New render path in `renderNode.ts`: emit `` placeholder (not ``) +3. The placeholder contains the "fallback" content (baked at publish time — this is what unauthenticated visitors see) +4. The `data-instatic-roles` attribute lists required roles + +**Server endpoint:** `/_instatic/gate/?v=` +- Route handler `tryServeGate` inserted at **position 8** in the routes array (after `tryServeHole`, before `tryServeModuleJsAsset`) — keeps hole-related endpoints together and before `tryServeStaticAsset` which would otherwise claim the `/_instatic/` namespace +- Parse `instatic_visitor_session` cookie +- If no valid session → return fallback HTML (`Cache-Control: no-store`) +- If valid session, check user's role against `data-instatic-roles` +- If authorised → render the full node subtree (reusing hole rendering pipeline) +- If unauthorised → return fallback HTML (`Cache-Control: no-store`) + +**Runtime extension (~200 bytes):** The existing hole runtime gains a second `querySelectorAll('instatic-gated[data-instatic-gate]')` observer. Same fetch/swap logic as holes, different endpoint URL (`/_instatic/gate/?v=`). The gated runtime is part of the same `hole-runtime.js` file — no separate script tag needed. + +**CSP impact:** None. The runtime is served from `self` (same-origin fetch). Gated holes are `no-store` so no caching concerns. + +### 4.9 Login Page Contract (C2) + +**Problem:** The middleware redirects unauthenticated visitors to `/login`, but that page must exist. There's a chicken-and-egg problem when visitor auth is first enabled and no login page has been created yet. + +**Two-tier resolution:** + +1. **Primary:** Check if a published page exists at the configured `loginPath` (default `/login`). If yes, serve it normally through the existing `renderPublicResolution` path (Layer A/B/C). The builder designs this page in the visual editor with any layout they want. + +2. **Fallback:** If no published page exists at the login path, the middleware returns a **built-in login page** directly as a 200 Response (~3KB HTML, minimal styling). This is NOT a redirect — it's served inline. The built-in page includes: + - A login form with `data-instatic-auth="login"` + - A link to `/register` (if registration is open) + - A link to `/forgot` (Phase 2) + - The auth runtime script tag: `` + +**Why a built-in fallback instead of auto-creating a published page?** Pages in Instatic are snapshots with tree structures stored in `data_rows`. Auto-creating a properly-structured page requires understanding the full publisher pipeline. The server-rendered fallback is simpler and doesn't pollute the content model. + +### 4.10 Auth Runtime JS (`/_instatic/visitor-auth.js`) + +A small client-side script (~2-3KB) served from the `/_instatic/` asset namespace. Served by `tryServeStaticAsset` (existing handler) or baked into the publish slot. Follows the same pattern as the hole runtime (`hole-runtime.ts` — a self-contained IIFE with zero dependencies). + +**Behaviour:** +1. Find all forms with `data-instatic-auth` attribute +2. Intercept `submit` events via `addEventListener('submit', ...)` +3. Prevent default, validate client-side (min password length), call the appropriate `/api/visitor/*` endpoint via `fetch` +4. On error: display in a `.instatic-auth-error` element within the form +5. On success: redirect to the `redirect` query param or `/` +6. On page load: scan for `[data-instatic-auth-show]` and `[data-instatic-auth-hide]` elements; show/hide based on whether the visitor has a valid session (checked via `GET /api/visitor/me` with `catch` fallback) + +**Form field contract:** + +| Attribute | Required fields | +|-----------|---------------| +| `data-instatic-auth="login"` | `email`, `password`, hidden `redirect` | +| `data-instatic-auth="register"` | `email`, `password`, `confirm-password`, `displayName` (optional), hidden `redirect` | + +### 4.11 Visitor Personalisation via perVisitor Loops + +Instatic already supports `perVisitor: true` loop sources with `ctx.request.cookies`. After Phase 1, a loop source can read the `instatic_visitor_session` cookie, validate it, and return visitor-specific data. This enables: +- "Welcome back, {name}" on any page +- Role-specific navigation items +- "Your recent orders" sections + +This requires NO changes to the loop system — it already works. A custom plugin or a built-in `visitor.data` loop source would use `ctx.request.cookies['instatic_visitor_session']` to look up the visitor and return personalised fields. + +--- + +## 5. File Changes + +### Phase 1 (Page-Level Auth) + +#### New Files + +| File | Purpose | +|------|---------| +| `server/visitor-auth/middleware.ts` | Route protection + session validation + built-in fallback login page | +| `server/visitor-auth/sessions.ts` | Session CRUD + in-memory cache (5-min TTL, mirrors admin's debounce pattern) | +| `server/visitor-auth/handlers.ts` | API endpoint handlers (register, login, logout, me, forgot, reset) | +| `server/visitor-auth/repositories.ts` | DB operations for visitor_users, visitor_sessions, visitor_login_attempts | +| `server/visitor-auth/rateLimits.ts` | RateLimiter instances for visitor auth | +| `server/visitor-auth/types.ts` | TypeScript interfaces (VisitorUser, VisitorSession, VisitorRole) | +| `server/publish/visitorAuthRuntime.ts` | Builds the `visitor-auth.js` asset string (follows `HOLE_RUNTIME_JS` pattern) | + +#### Modified Files + +| File | Change | Approx Lines | +|------|--------|-------------| +| `server/index.ts` | Add `import` + `visitorAuthMiddleware()` call inside try block, before `handleServerRequest` | +3 | +| `server/router.ts` | Add `tryServeVisitorRoutes` at position 4 in `routes[]` array | +5 | +| `server/db/migrations-pg.ts` | Add migration `002_visitor_auth`: create 4 tables + indexes + seed roles | +65 | +| `server/db/migrations-sqlite.ts` | Same migration for SQLite dialect | +65 | +| `server/publish/publicRouter.ts` | Pass visitor user info through to render context (for perVisitor personalisation) | +5 | + +**Total core diff: ~143 lines across 5 files (all additions, no modifications to existing logic)** + +### Phase 2 (Auth-Gated Holes) + +#### New Files + +| File | Purpose | +|------|---------| +| `server/handlers/cms/gate.ts` | `/_instatic/gate/` endpoint | +| `server/visitor-auth/gateHelpers.ts` | Session validation for gate endpoint | + +#### Modified Files + +| File | Change | Approx Lines | +|------|--------|-------------| +| `server/router.ts` | Add `tryServeGate` at position 8 | +1 | +| `src/core/publisher/dynamicDetection.ts` | Add Rule 5: `authGate` property classification | +15 | +| `src/core/publisher/renderNode.ts` | Add `renderGatedPlaceholder()` branch | +20 | +| `server/publish/holeRuntime.ts` | Add `` observer to runtime (~200 bytes) | +10 | +| `src/modules/base/container/index.ts` | Add `authGate` prop definition | +5 | +| `src/admin/pages/site/canvas/properties/` | Auth gate UI in Properties panel | +50 | + +**Total core diff: ~101 lines across 6 files** + +--- + +## 6. Migration Strategy (Upstream Rebase) + +Instatic is v0.0.11 and APIs shift before 1.0. Our fork strategy: + +1. **Pin to v0.0.11** — create a `fork/v0.0.11` branch from current main +2. **Isolate changes** — all visitor auth code in `server/visitor-auth/`, all new files, minimal core edits +3. **Track upstream** — add CoreBunch/Instatic as `upstream` remote, rebase quarterly +4. **Conflict surface** — only `server/index.ts`, `server/router.ts`, and migration files can conflict +5. **Architecture gate tests** — add tests in `src/__tests__/architecture/visitor-auth-isolation.test.ts` that verify visitor auth never imports from: `server/auth/sessions.ts`, `server/auth/middleware.ts`, `server/auth/index.ts`, `server/repositories/users.ts`, or any CMS handler + +**Migration numbering (L4):** Our migration is named `002_visitor_auth` (not `021` as in v1 — the upstream uses a single `001_baseline` consolidated migration, so appending `002_` is the correct pattern). If upstream adds their own `002_*` migration before our next rebase, we rename to `003_visitor_auth` and update the migration ID. + +--- + +## 7. Security Considerations + +| Concern | Mitigation | +|---------|------------| +| Session token in DB | SHA-256 hash stored, raw token only in cookie (same as admin) | +| Password storage | Argon2id (same as admin) | +| Brute force | Rate limiting (5/15min per email, 30/10min per IP) + exponential lockout | +| CSRF on login/register | `isStateChangingMethod` + `originAllowed` from `server/auth/security.ts` — same pattern as admin CMS handler. `PUBLIC_ORIGIN` must be configured for production. | +| Session fixation | Regenerate session token on login (don't accept pre-existing tokens) | +| Cookie theft | HttpOnly, Secure (when `publicOriginIsHttps()`), SameSite=Lax | +| Visitor→admin escalation | Different cookie names, different middleware, different DB tables. Visitor middleware only reads `instatic_visitor_session`. Admin routes only read `instatic_admin_session`. No shared session lookup code. | +| Gated content in HTML source | Auth-gated holes are server-rendered — content is NOT in the static HTML. The baked placeholder is the fallback/teaser. Real content only exists in server memory. | +| Cache poisoning | Gated holes and auth'd pages always use `Cache-Control: no-store` | +| Static export bypass | Document that member areas require the Bun server process. Static export (`published/` dir) has no auth. | +| Password reset spam | Rate limited: 3/hour per IP, 1/15min per email. Always returns 200 to prevent email enumeration. | + +--- + +## 8. Performance Impact + +| Path | Before | After | +|------|--------|-------| +| Public page (no auth enabled, 95% of traffic) | ~0.6ms (disk read) | ~0.6ms (middleware reads cached config → enabled=false → null, negligible) | +| Public page (auth enabled, page not protected) | ~0.6ms | ~0.7ms (+ config lookup + cookie parse, returns null) | +| Protected page (authenticated, warm session cache) | N/A | ~0.8ms (cache lookup + disk read) | +| Protected page (authenticated, cold cache) | N/A | ~1.5ms (DB session lookup + disk read) | +| Protected page (unauthenticated) | N/A | 302 redirect (~0.3ms) or 200 fallback HTML (~0.5ms) | +| Gated hole (authenticated) | N/A | ~5-10ms (first request, hole render) | +| Gated hole (unauthenticated) | N/A | ~0ms (baked placeholder, no fetch) | +| Visitor API (/api/visitor/login) | N/A | ~50ms (argon2id verify) | + +**Key insight:** 95% of pages remain Layer A static files served directly from disk. Auth adds ~0.1ms to the hot path (cached config lookup + cookie parse that returns null for unprotected pages). The in-memory session cache means most authenticated requests skip the DB entirely. + +--- + +## 9. Open Questions + +1. **Email delivery for password reset?** Phase 2 needs email config. Use site settings for SMTP, or defer to Phase 3 with plugin integration. +2. **Admin UI for visitor management?** Phase 1 provides API only. Admin UI (list users, edit roles, revoke sessions) is Phase 1 stretch goal or Phase 2. +3. **Social login (Google, GitHub)?** Deferred to Phase 3. The separate-cookie architecture makes this straightforward to add later. +4. **Should visitor roles be editable in the admin UI?** Yes, Phase 1 — simple CRUD on the `visitor_roles` table. +5. **Merge upstream?** Long-term, if CoreBunch is interested, we can propose this as a PR. Short-term, maintain as a fork. + +--- + +## 10. Success Metrics + +- [ ] Visitor can register, log in, log out via API +- [ ] Protected pages return 302 redirect when unauthenticated +- [ ] Protected pages serve normally when authenticated +- [ ] Built-in fallback login page works when no custom login page is published +- [ ] Auth-gated sections show fallback for unauthenticated, real content for authenticated +- [ ] Public pages have zero measurable performance regression (<0.1ms) +- [ ] Admin auth system is completely untouched (all existing tests pass) +- [ ] New visitor auth code passes its own test suite +- [ ] Architecture gate test confirms visitor auth never imports banned admin auth modules +- [ ] Lockout and rate limiting work correctly for visitor login +- [ ] Session is properly scoped (visitor session cookie never read by admin middleware) +- [ ] In-memory session cache reduces DB queries for repeated authenticated requests +- [ ] CSRF origin check works on all state-changing visitor endpoints diff --git a/docs/proposals/visitor-auth/README.md b/docs/proposals/visitor-auth/README.md new file mode 100644 index 000000000..7fe5c1aba --- /dev/null +++ b/docs/proposals/visitor-auth/README.md @@ -0,0 +1,15 @@ +# Visitor authentication + per-visitor data — proposal + +This folder contains the design rationale for the visitor-auth + per-visitor-data +contribution (see the issue/PR for the summary). Read in this order: + +| Doc | What it is | +|---|---| +| **PRD.md** | The product spec — problem statement, user stories, the full technical architecture (endpoints, schema, security, request flow). Start here. | +| **PRD-REVIEW.md** | The review cycle that shaped the architecture — findings (critical/high/medium/low) and how each was resolved. Read if you want the "why" behind design decisions. | +| **ARCHITECTURE.md** | The resolved design decisions (final, validated against the codebase) + the file map + rebase conflict surface. | +| **PER-VISITOR-DATA-SPEC.md** | The per-visitor-data framework spec (custom profile fields, the `visitor.current` / `visitor.owned-rows` loop sources, IDOR model). | + +These are proposal docs, not part of the canonical Instatic docs tree — they +document the rationale for this contribution and can be removed if the proposal +is declined (or folded into the main docs if adopted). diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index a6abc0d24..d625185cf 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -35,6 +35,18 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- `SITE_WRITE_CAPABILITIES` is the convenience set `['site.structure.edit', 'site.content.edit', 'site.style.edit']` — defined locally in `server/handlers/cms/siteDocument.ts` and `src/admin/access.ts` at each point of use, not in a shared capabilities module. The transactional site-document save (`PUT /admin/api/cms/site-document`) accepts any site writer, then diff-validates the batch by category: page deletions, page metadata, topology, module identity, non-content props, and dynamic bindings require `site.structure.edit`; content-category props (and site-wide SEO copy on the shell) require `site.content.edit`; inline styles/classes/breakpoint overrides and style rules require `site.style.edit`. Empty change sets are no-op saves any site writer may perform, but changed/deleted components and layouts remain structural work (`site.structure.edit`). +**Co-editing enforcement:** relay writes are held to the SAME per-category +rules as the HTTP save. Full site-writers (all three capabilities) skip +validation; every update frame from a PARTIAL writer runs through +`server/collab/updateGuard.ts`, which forks the authoritative doc, applies +the update to the fork, projects both sides back to JSON, and reuses +`validateSiteWriteDiff` / `validatePageWriteDiff` — component/layout +changes and roster changes are structural wholesale. A rejected update is +never applied; the server sends the offender a targeted reset so their +diverged local doc reseeds from the authoritative state. Read-only +connections may not write docs at all, but their AWARENESS frames relay — +presence is not a doc write, so viewers are visible peers. + ### Page publishing | Capability | Grants | Roles | diff --git a/docs/reference/editor-history.md b/docs/reference/editor-history.md index 79e9735ab..c8713c715 100644 --- a/docs/reference/editor-history.md +++ b/docs/reference/editor-history.md @@ -1,179 +1,81 @@ -# Editor Undo/Redo History - -How the visual editor captures, stores, and applies undo/redo history using Mutative patch pairs. - -Every undoable mutation captures a `HistoryEntry` — a pair of Mutative patch arrays scoped to the `SiteDocument`. Undo applies the `inverse` patches; redo applies the `forward` patches. Cost is O(change): only the paths the recipe touches are drafted and copied. - ---- - -## TL;DR - -- History is `_historyPast: HistoryEntry[]` and `_historyFuture: HistoryEntry[]` on the editor store. Max depth: `MAX_HISTORY` (50). -- Each `HistoryEntry` holds `{ inverse, forward, coalesceKey }` — patch arrays, not full-site clones. -- `runHistoricMutation` is the single entry point. All six `mutate*` helpers delegate to it. -- Continuous-input bursts (per-keystroke text/number edits) fold into one entry via `commitHistory` coalescing. -- Patches are scoped to `site` (`state.site.*`) — editor-local state (selection, zoom, panel visibility) is not undoable. -- History is in-memory session state — never serialized. - ---- - -## Performance - -Per-mutation wall time is flat at ~0.25–0.4 ms regardless of site size: - -| Nodes | Patch-based | structuredClone (old) | Speedup | -|--------|-------------|----------------------|---------| -| 500 | 0.25 ms | 0.76 ms | 3× | -| 5,000 | 0.28 ms | 8.8 ms | 31× | -| 20,000 | 0.32 ms | 34 ms | 106× | -| 50,000 | 0.40 ms | 98 ms | ~245× | - -A full 50-deep history stores ~240 small patches (KB total) instead of 50 whole-site clones (hundreds of MB). - ---- - -## Data model - -`src/admin/pages/site/store/slices/site/types.ts`: - -```ts -import type { Patches } from 'mutative' - -export interface HistoryEntry { - /** Patches that revert this transaction. Applied on undo. */ - inverse: Patches - /** Patches that re-apply this transaction. Applied on redo. */ - forward: Patches - /** Coalescing burst identity, or null. */ - coalesceKey: string | null -} -``` - -The store holds: - -```ts -_historyPast: HistoryEntry[] // stack — most recent last -_historyFuture: HistoryEntry[] // entries available for redo -canUndo: boolean -canRedo: boolean -_historyCoalesceKey: string | null // identity of the in-progress burst -``` - ---- - -## How patches are captured - -`runHistoricMutation` in `helpers.ts` is the core engine: - -```ts -function runHistoricMutation(recipe, coalesceKey) { - const [next, patches, inverse] = create(cur, (draft) => { - result = recipe(draft) - if (result !== false) draft.site.updatedAt = Date.now() - }, { enablePatches: true }) - - // History stores patches relative to `site` (strip the leading path segment) - const siteForward = patches .filter(p => p.path[0] === 'site').map(p => ({ ...p, path: p.path.slice(1) })) - const siteInverse = inverse.filter(p => p.path[0] === 'site').map(p => ({ ...p, path: p.path.slice(1) })) - - set(state => { - // Apply all changed fields to the live store (site + any editor fields) - for (const key of touched) live[key] = produced[key] - if (siteForward.length > 0) commitHistory(state, { inverse: siteInverse, forward: siteForward, coalesceKey }) - state.hasUnsavedChanges = true - }) -} -``` - -`create(cur, recipe, { enablePatches: true })` returns `[next, forwardPatches, inversePatches]`. Only `site`-prefixed patches go into the history entry. Editor-only fields (selection, zoom) are applied live but never recorded. - ---- - -## The six `mutate*` helpers - -All six helpers in `SiteSliceHelpers` delegate to `runHistoricMutation`: - -| Helper | Recipe receives | Coalescing | -|---|---|---| -| `mutateSite(fn, opts?)` | `SiteDocument` draft | `opts.coalesceKey` | -| `mutateSiteWithExplorerReconcile(fn)` | `SiteDocument` draft; calls `reconcileSiteExplorerInPlace` after | none | -| `mutatePage(fn)` | Active `Page` draft | none | -| `mutateActiveTree(fn, opts?)` | Active `NodeTree` draft; routes page vs. VC | `opts.coalesceKey` | -| `mutateActiveTreeAndSite(fn)` | Active `NodeTree` + `SiteDocument` drafts | none | -| `mutateAllPagesAndSite(fn)` | `SiteDocument` + `SuperImportHelpers` | none | - -`mutateActiveTree` is the only place that branches on page-mode vs. VC-mode. Gated by `no-vc-mode-branches-in-mutations.test.ts`. - ---- - -## Coalescing - -Per-keystroke mutations (text edits, number sliders) pass a stable `coalesceKey` such as `props::`. While the incoming key matches `_historyCoalesceKey`, `commitHistory` folds the new entry into the existing top entry **per patch path** (`foldIntoCoalescedEntry` in `helpers.ts`): - -- **inverse**: the OLDEST patch per path wins (undo restores the pre-burst value); new paths append. -- **forward**: the NEWEST patch's value per path wins (redo replays the final value), preserving the oldest patch's op (an `add` stays an `add` so redo works from the post-undo state where the prop is absent). - -A whole typing burst becomes one undo step holding at most one inverse + one forward patch per touched path — a 2,000-keystroke burst retains 2 paths' worth of patches, not 4,000 progressively-longer string snapshots. - -Any non-coalescing mutation, `undo`, `redo`, or a site (re)load resets `_historyCoalesceKey` to `null`. - ---- - -## Undo / redo apply - -`undoRedoActions.ts` uses `apply` from Mutative: - -```ts -// undo -const restored = apply(site, entry.inverse) -const packageJson = clonePackageJson(restored.packageJson) -const siteRuntime = cloneSiteRuntimeConfig(restored.runtime) -set(state => { - state._historyPast.pop() - state._historyFuture.push(entry) - state._historyCoalesceKey = null - state.site = { ...restored, packageJson, runtime: siteRuntime } - state.packageJson = packageJson - state.siteRuntime = siteRuntime - // re-derive mirrors; keep activePageId valid -}) -``` - -`redo` is symmetric: pops from `_historyFuture`, applies `entry.forward`, pushes back onto `_historyPast`. - ---- - -## Auto-freeze - -The Zustand store is created with `mutative({ enableAutoFreeze: true })`. That keeps a dev guard against accidental external mutation, and existing code already tolerates frozen state. `apply()` and `create()` handle frozen bases correctly. - ---- - -## What is NOT undoable - -- Selection, hover, zoom, pan — editor-local UI state, not in the `site` document. -- `mutateSiteState` — the recipe may write editor fields (e.g. `activeDocument`) alongside a `site` mutation; the editor fields go live but only the `site` patches enter history (parity with the prior snapshot model). -- History stacks themselves — resetting to `[]` on `clearSite` is a lifecycle operation, not a mutation. - ---- - -## Forbidden patterns - -- `structuredClone(site)` for history — the old snapshot model is gone. Never re-introduce it. -- Calling `set(state => { state.site = ... })` directly on a mutation — go through a `mutate*` helper so patches are captured. -- Returning a value from a `create` recipe — Mutative treats it as a full replacement. Capture no-op signals in a closure variable and return `false`. - ---- - -## Related - -- `src/admin/pages/site/store/slices/site/helpers.ts` — `runHistoricMutation`, `commitHistory`, all six `mutate*` helpers -- `src/admin/pages/site/store/slices/site/undoRedoActions.ts` — `undo`, `redo` -- `src/admin/pages/site/store/slices/site/types.ts` — `HistoryEntry`, `SiteSliceHelpers` -- `src/admin/pages/site/store/slices/site/defaults.ts` — `MAX_HISTORY` -- `docs/editor.md` — editor store overview -- `docs/reference/page-tree.md` — the `NodeTree` primitive mutations operate on -- Gate tests: - - `src/__tests__/architecture/centralized-site-mutation-history.test.ts` - - `src/__tests__/architecture/no-vc-mode-branches-in-mutations.test.ts` - - `src/__tests__/editor-store/undo-redo.test.ts` +# Editor undo/redo + +How Cmd+Z works in the visual editor since real-time co-editing landed. + +## Where history lives + +History does **not** live in the Zustand store anymore. It lives in the +collab binding (`src/admin/pages/site/store/slices/site/collabBinding.ts`) +as one **`Y.UndoManager` per collab document** — one per page, Visual +Component, and layout, plus one for the site shell/rosters. The store only +mirrors availability flags (`canUndo` / `canRedo`) and exposes the `undo` / +`redo` actions, which delegate to the binding (`site/undoRedoActions.ts`). + +Because each manager tracks **`LOCAL_ORIGIN` only**, undo is per-editor by +construction: your Cmd+Z reverts *your* edits, never a peer’s — the +co-editing invariant. + +## The mutation path + +Every store mutation still runs through `runHistoricMutation` +(`site/helpers.ts`): the recipe mutates a Mutative draft, the resulting +site-relative patches are handed to `applyLocalSitePatches` (the binding), +which translates them into Y operations on the touched docs +(`@core/collab` `applySitePatchesToDocs`). The Y transaction is what the +UndoManager captures — the undo stack IS the CRDT edit history. + +Undoing pops the doc’s stack; the resulting doc change projects back into +the store through the binding’s projection path (the same path remote +peers’ edits use), synchronously flushed so undo repaints immediately. + +## Coalescing (typing bursts) + +Per-keystroke mutations (text edits, number sliders) pass a stable +`coalesceKey` such as `props::` through the `mutate*` helpers. +The managers run with an infinite `captureTimeout`; the binding calls +`stopCapturing()` exactly when the incoming key differs from the previous +one — so consecutive same-key edits merge into ONE undo step, and any +non-coalescing mutation, undo/redo, or inline-edit session boundary +(`collabBreakCoalescing`) starts a fresh step. Typing a word is one Cmd+Z. + +## Multi-doc undo groups + +A single mutation can touch several docs (convert-to-component writes the +page, the new component, and the site roster; Super Import touches +everything). The binding records each undoable step as a **group of docIds** +whose managers captured a new stack item, and `undo()` reverts the whole +group — one Cmd+Z, one logical mutation, across all its documents. The site +doc always sorts first in a group so roster reverts project after row-level +reverts. + +## Lifecycle + +`createSite` / `loadSite` / `clearSite` call `resetCollabDocsFromSite`, +which rebuilds the doc world for the new document and clears every undo +manager — history never survives a document swap. In detached mode (tests, +the pre-connect window) docs seed locally from the loaded site; in connected +mode every doc rebinds through the provider and the server seeds it. + +Editor-local state (selection, zoom, panel visibility) is not undoable — +only document content flows through the docs. + +It is, however, **reconciled**. A projection can remove nodes the editor is +still pointing at — an undo reverting an insertion, or a peer deleting the +subtree you had selected. The projection path therefore runs +`pruneCanvasSelectionDraft` after the new site lands, exactly as a local +`deleteNode` does: selections are pruned by tree-membership (survivors keep +theirs, the anchor re-syncs, descendants swept with a subtree drop out), and +an inline-edit session whose node vanished is closed. This is why selection +state has one pruning implementation rather than one per write path. + +## Key files + +- `src/admin/pages/site/store/slices/site/collabBinding.ts` — undo managers, + routing groups, coalescing, projection +- `src/admin/pages/site/store/slices/site/helpers.ts` — `runHistoricMutation` + and the six `mutate*` helpers (unchanged recipe API) +- `src/admin/pages/site/store/slices/site/undoRedoActions.ts` — the store’s + `undo`/`redo` delegates +- `src/core/collab/applyPatches.ts` — patch → Y translation +- Tests: `src/__tests__/editor-store/undo-redo.test.ts` (behavioral + contract), `src/__tests__/collab/*` (round-trip identity) diff --git a/docs/reference/persistence-keys.md b/docs/reference/persistence-keys.md index 855bc7345..ded530988 100644 --- a/docs/reference/persistence-keys.md +++ b/docs/reference/persistence-keys.md @@ -24,7 +24,7 @@ Catalog of every `localStorage` / `sessionStorage` key the admin app writes, and | `instatic-clipboard-v1` | The editor clipboard (copy / cut / paste of layer subtrees) | `src/admin/pages/site/store/clipboard/clipboardStorage.ts` → `CLIPBOARD_STORAGE_KEY` | | `instatic-class-usage` | Recently-used classes in the ClassPicker autocomplete | `src/admin/pages/site/preferences/classUsage.ts` → `CLASS_USAGE_STORAGE_KEY` | | `instatic-data-grid-primary-widths-v1` | Per-table primary-column widths in the Data workspace grid | `src/admin/pages/data/components/DataGrid/usePrimaryColumnWidth.ts` | -| `instatic-media-page-view-mode` | Media workspace view mode (grid / list / large thumbs) | `src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx` | +| `instatic-media-page-view-mode` | Shared Media workspace and media-gallery view mode (grid / list) | `src/admin/pages/media/utils/viewMode.ts` | | `instatic-media-explorer-view-mode` | Media Explorer panel view mode (site workspace) | `src/admin/pages/site/panels/MediaExplorerPanel/mediaExplorerUtils.ts` → `VIEW_MODE_STORAGE_KEY` | | `instatic-module-inserter-v1` | Module inserter view mode and recent inserts | `src/admin/pages/site/module-picker/moduleInserterPrefs.ts` | | `instatic-onboarding-dismissed` | Dashboard onboarding panel: dismissed / open per-device | `src/admin/pages/dashboard/hooks/useOnboardingState.ts` | diff --git a/docs/server.md b/docs/server.md index 00b62ded4..ede700c57 100644 --- a/docs/server.md +++ b/docs/server.md @@ -14,6 +14,7 @@ The server is a single `Bun.serve` process that boots the DB, runs migrations, a - **Auth:** session cookie (`SESSION_COOKIE_NAME`) → `findUserBySessionHash` → `requireCapability(req, db, 'site.read')`. Every state-changing handler starts with one of these guards. - **DB:** one `DbClient` interface (`server/db/client.ts`) — tagged-template callable returning `{ rows, rowCount }`. Two adapters: `postgres.ts` (via `Bun.sql`) and `sqlite.ts` (via `bun:sqlite`). Selected by `DATABASE_URL`. - **Repositories** (`server/repositories/`) hold all SQL. Handlers never write SQL directly. +- **Write policy** (`server/writePolicy/`) — pure per-capability diff validators (`validateSiteWriteDiff`, `validatePageWriteDiff`) shared by BOTH write transports: the HTTP save handler (`server/handlers/cms/siteDocument.ts`) and the collab relay's CRDT update guard (`server/collab/updateGuard.ts`). No DB, no HTTP — plain data in, verdict out. - **Plugins:** `server/plugins/runtime.ts` activates installed plugins at boot. Server entrypoints run in per-plugin Bun workers that host QuickJS-WASM (`server/plugins/pluginWorker.ts`, `server/plugins/host/workerPool.ts`, `server/plugins/quickjs/vm.ts`); module packs use `server/plugins/modulePackVm.ts` for server-side evaluation. - **Published pages and content rows** are served by `tryServePublicRoute`, which delegates resolution + render to `server/publish/publicRouter.ts`. A warm Layer B cache entry is served before any DB work; on a miss the live render reads the published `SiteDocument` from `site_snapshots` (stored once per publish, referenced by `data_row_versions.site_snapshot_id`, memoised per publish version). Uploads + admin SPA assets are served from disk by `tryServeUpload` and `tryServeStaticAsset`. @@ -38,7 +39,17 @@ server/index.ts ├─→ mediaStorageRegistry.configureLocalDisk({ uploadsDir }) ← register local-disk media adapter ├─→ activateInstalledServerPlugins(db, uploadsDir) ← run plugin lifecycle: activate │ - └─→ Bun.serve({ fetch: req => handleServerRequest(req, runtime) }) + ├─→ createCollabRelay(db) ← server/collab/relay.ts: live Y docs, + │ debounced persistence, reset protocol + ├─→ createCollabSocketLayer(relay) ← server/collab/socket.ts: multiplexed + │ y-protocols wire + awareness + ├─→ Bun.serve({ fetch: req => handleServerRequest(req, runtime), + │ websocket: collabSocket.handlers }) + │ (the co-editing socket `/admin/api/cms/site-socket` upgrades at this + │ boundary — see server/collab/socket.ts; everything else goes + │ through the router) + │ + └─→ collabSocket.setPublisher(server) ← wire the collab fan-out to Bun pub/sub ``` Boot is sequential and fail-fast. If migrations fail, the process exits. If a plugin's `activate` throws, the host logs `[plugin:]` and continues — one bad plugin doesn't bring the server down. @@ -347,6 +358,7 @@ All SQL lives in `server/repositories/`. Each file owns one resource: | File | Owns | |----------------------------|---------------------------------------------------| | `audit.ts` | Audit log writes and queries | +| `collabDocuments.ts` | Persisted CRDT document blobs (`collab_documents`) — the co-editing relay's durable state | | `data/` | `data_tables` + `data_rows` (the universal store) | | `fonts.ts` | Font assets | | `loginAttempts.ts` | Failed-login records for lockout | @@ -358,11 +370,12 @@ All SQL lives in `server/repositories/`. Each file owns one resource: | `plugins.ts` | Installed plugins + lifecycle state | | `publish.ts` | Published-page roster: snapshot getters + the transactional publish write (orchestration lives in `server/publish/publishSite.ts`) | | `roles.ts` | System and custom roles | +| `rowWriteEvents.ts` | In-process out-of-relay write notifications (repositories notify; the collab relay resets affected docs) | | `runtimeAsset.ts` | Published runtime assets (JS, CSS, fonts) | | `sessions.ts` | User sessions | | `setup.ts` | Setup wizard state (`isSetup`, first-run owner) | | `site.ts` | The single site shell row | -| `syncSequence.ts` | Site-global sync sequence counter (multi-admin sync substrate — stamped on every row the site-document save writes or deletes) | +| `syncSequence.ts` | Site-global sync sequence counter (multi-admin sync substrate — stamped on every row the site-document save writes or deletes, and on the shell only when its content changed; the save's conflict check compares client base seqs against these inside the transaction) | | `userPreferences.ts` | Per-user editor preferences | | `users.ts` | Users + auth fields | diff --git a/package.json b/package.json index 57771a0ee..e551b4ded 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "fflate": "^0.8.2", "happy-dom": "^20.9.0", "html-to-image": "^1.11.13", + "lib0": "^0.2.117", "lru-cache": "^11.3.5", "marked": "^18.0.3", "mutative": "^1.3.0", @@ -100,6 +101,8 @@ "semver": "^7.7.4", "sharp": "^0.35.0", "uqr": "^0.1.3", + "y-protocols": "^1.0.7", + "yjs": "^13.6.31", "zustand": "^5.0.12", "zustand-mutative": "^1.3.1" }, diff --git a/scripts/dev.ts b/scripts/dev.ts index 505f8ae5a..974e4aaa1 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -253,6 +253,11 @@ const processes: DevProcess[] = [ { name: 'vite', command: viteCommand('--host', '127.0.0.1', '--port', String(VITE_PORT), '--strictPort'), + // vite.config.ts reads PORT for both the proxy target and the collab + // socket's dev port. Inheriting it from the developer's shell happened to + // work only because CMS_PORT's default matches the config's — pass it + // explicitly so the two can't drift. `scripts/e2e-dev.ts` already does. + env: { PORT: String(CMS_PORT) }, }, ] diff --git a/server/ai/mcp/server.test.ts b/server/ai/mcp/server.test.ts index f58d8708b..2874b7352 100644 --- a/server/ai/mcp/server.test.ts +++ b/server/ai/mcp/server.test.ts @@ -95,6 +95,23 @@ describe('mcp server', () => { await client.close() }) + it('advertises the open-workspace requirement on browser tools, not on headless ones', async () => { + const client = await connectClient(db, ['ai.chat', 'ai.tools.write', 'site.structure.edit', 'content.manage']) + const { tools } = await client.listTools() + + const sitePage = tools.find((t) => t.name === 'site_add_page') + expect(sitePage?.description).toContain('Requires the Instatic Site editor to be open') + + const contentTool = tools.find((t) => t.name === 'content_set_document_field') + expect(contentTool?.description).toContain('Requires the Instatic Content workspace to be open') + + // Headless tools must stay free of the hint — they work with no editor open. + const headless = tools.find((t) => t.name === 'site_read_styles') + expect(headless?.description).not.toContain('Requires the Instatic') + + await client.close() + }) + it('routes Site and Content browser tools to their matching workspace bridges', async () => { const userId = 'u1-scoped-workspaces' const siteCtrl = new AbortController() diff --git a/server/ai/mcp/server.ts b/server/ai/mcp/server.ts index b2a29a36f..fb11868ca 100644 --- a/server/ai/mcp/server.ts +++ b/server/ai/mcp/server.ts @@ -25,6 +25,7 @@ import { getEditorBridgeForUser, type EditorBridgeScope, } from './editorBridge' +import { runPublishFlush } from '../../publish/publishFlush' export interface McpServerContext { db: DbClient @@ -46,6 +47,29 @@ const NO_WORKSPACE_MESSAGE: Record = { content: 'This tool runs in the Instatic Content workspace. Open the Content workspace in a browser (signed in as the connector owner) and try again.', } +/** + * Same requirement as `NO_WORKSPACE_MESSAGE`, stated up front. + * + * An MCP client picks its tools from `tools/list` and nothing else, so a + * browser tool that reads like a headless one gets called blind: the model + * only learns the editor has to be open by burning a turn on the failure. That + * is the wrong end of the loop to teach it from — it can neither open the + * editor itself nor tell from the error whether retrying is worthwhile, so it + * retries anyway. Advertising the precondition alongside the description lets + * the model ask the user to open the workspace before it spends anything. + */ +const BROWSER_WORKSPACE_REQUIREMENT: Record = { + site: 'Requires the Instatic Site editor to be open in a browser, signed in as the connector owner; this tool edits that live workspace and cannot run headlessly.', + content: 'Requires the Instatic Content workspace to be open in a browser, signed in as the connector owner; this tool edits that live workspace and cannot run headlessly.', +} + +/** Tool description as advertised over MCP — browser tools carry their precondition. */ +function advertisedDescription(tool: AiTool): string { + if (tool.execution !== 'browser') return tool.description + if (tool.scope !== 'site' && tool.scope !== 'content') return tool.description + return `${tool.description}\n\n${BROWSER_WORKSPACE_REQUIREMENT[tool.scope]}` +} + export function buildMcpServer(ctx: McpServerContext): Server { const server = new Server( { name: 'instatic', version: '1.0.0' }, @@ -63,7 +87,7 @@ export function buildMcpServer(ctx: McpServerContext): Server { server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: tools.map((t) => ({ name: t.name, - description: t.description, + description: advertisedDescription(t), // Our TypeBox object schema IS a valid JSON-Schema tool definition. The // `AiTool.inputSchema` field is the general `TSchema`, so we adapt it to // the SDK's object-schema shape (a type-level adaptation, not a runtime @@ -112,6 +136,12 @@ export function buildMcpServer(ctx: McpServerContext): Server { }, } : live + } else { + // Headless reads hit the DB directly, but live co-editing persists on an + // ~800 ms debounce — flush the relay first so a headless read reflects + // edits still in flight in an open editor. Cheap: a clean doc's flush is + // a no-op (persistNow early-returns when not dirty). + await runPublishFlush() } const controller = new AbortController() diff --git a/server/ai/tools/site/writeTools.ts b/server/ai/tools/site/writeTools.ts index 384d1398c..d7edc0881 100644 --- a/server/ai/tools/site/writeTools.ts +++ b/server/ai/tools/site/writeTools.ts @@ -56,7 +56,7 @@ import type { AiTool } from '../types' // --------------------------------------------------------------------------- // Capability requirements (ANY-OF) — mirror the editor's change-class model -// (structure / content / style — see server/handlers/cms/siteDiff.ts and the +// (structure / content / style — see server/writePolicy/siteDiff.ts and the // `site.structure.edit` gate on PUT /admin/api/cms/pages). Selection-time // gating only: persistence is independently re-validated server-side. // `site_get_node_html`, `site_read_document`, `site_open_document`, and `site_render_snapshot` are diff --git a/server/collab/relay.ts b/server/collab/relay.ts new file mode 100644 index 000000000..380a1e3c3 --- /dev/null +++ b/server/collab/relay.ts @@ -0,0 +1,569 @@ +/** + * Collab relay — the server half of real-time co-editing. + * + * Owns a registry of live Y documents (one per collab doc id): + * - `openDoc` hydrates the CRDT blob from `collab_documents`, or — first + * ever open — SEEDS the doc deterministically from the current persisted + * JSON (fixed SEED_CLIENT_ID; the server is the ONLY seeder, so two + * clients can never build divergent initial histories). A doc with + * neither blob nor row starts empty: that is the client-created-row + * flow, whose content arrives as ordinary updates. + * - every doc carries a `generation` — its CRDT lineage id, minted on seed + * and returned with the doc so the socket can refuse frames from a dead + * lineage (see @core/collab/protocol). + * - every local doc update fans out to `subscribeUpdates` listeners (the + * socket layer broadcasts to the other connections). + * - persistence writes BOTH the CRDT blob (source of truth for editing) + * and the derived JSON into `data_rows` / `site` — the publisher and all + * non-editor reads stay untouched. Derived-JSON writes are tagged + * `collabInternal` so the row-write reset seam ignores them. + * - rows deleted from the site doc's roster are soft-deleted on persist + * (publish version bumped when a published page goes). + * - `resetDocs` (and the row-write listener wired in `attachResetSources`) + * drop CRDT state whose backing JSON was rewritten OUT-of-relay (plugin + * pack installs, HTTP site saves, data-workspace edits): blob deleted, + * doc evicted, reset broadcast — clients rebind and the doc reseeds from + * the fresh JSON. + * + * Single Bun process by product definition — an in-memory registry is + * correct, not a shortcut (multi-process would need a shared bus; out of + * scope, documented in docs/features/site-shell.md). + */ +import * as Y from 'yjs' +import { nanoid } from 'nanoid' +import { + encodeCollabDocId, + parseCollabDocId, + projectComponentDoc, + projectLayoutDoc, + projectPageDoc, + projectSiteDoc, + seedComponentDoc, + seedLayoutDoc, + seedPageDoc, + seedSiteDocFromParts, + SITE_DOC_ID, + type CollabDocKind, +} from '@core/collab' +import '@modules/base' // registry population — inline-text props seed as Y.Text +import type { SiteShell } from '@core/page-tree' +import { pageFromRow, pageToCells } from '@core/data/pageFromRow' +import { visualComponentFromRow, visualComponentToCells } from '@core/data/componentFromRow' +import { savedLayoutFromRow, savedLayoutToCells } from '@core/data/layoutFromRow' +import { vcSlugFromName } from '@core/visualComponents' +import { layoutSlugFromName } from '@core/layouts' +import { validateSite } from '@core/persistence/validate' +import type { DbClient } from '../db/client' +import { + getDataRow, + listDataRowIdSlugs, + softDeleteDataRow, + upsertDataRowDraft, +} from '../repositories/data' +import { getDraftSite, saveDraftSite } from '../repositories/site' +import { + deleteCollabDocuments, + getCollabDocumentState, + putCollabDocumentState, +} from '../repositories/collabDocuments' +import { + registerRowWriteListener, + registerShellWriteListener, +} from '../repositories/rowWriteEvents' +import { bumpPublishVersionSerialized } from '../publish/publishState' +import { registerPublishFlush } from '../publish/publishFlush' + +const KIND_TABLE: Record, string> = { + page: 'pages', + component: 'components', + layout: 'layouts', +} +const TABLE_KIND: Record> = { + pages: 'page', + components: 'component', + layouts: 'layout', +} + +interface RelayEntry { + doc: Y.Doc + /** This doc's CRDT lineage id — see @core/collab/protocol. */ + generation: string + refs: number + dirty: boolean + persistTimer: ReturnType | null + /** Serializes persists per doc so row writes never overlap. */ + persistChain: Promise + detachUpdateHandler: () => void +} + +type DerivedWrite = 'written' | 'incomplete' | 'invalid' +type PersistOutcome = 'clean' | 'retry' | 'invalid' + +export type RelayUpdateListener = ( + docId: string, + update: Uint8Array, + origin: unknown, + generation: string, +) => void +export type RelayResetListener = (docId: string) => void + +/** A doc plus the CRDT lineage the caller must stamp on its frames. */ +export interface RelayDoc { + doc: Y.Doc + generation: string +} + +export interface CollabRelay { + openDoc(docId: string): Promise + retain(docId: string): Promise + release(docId: string): void + subscribeUpdates(listener: RelayUpdateListener): () => void + onReset(listener: RelayResetListener): () => void + resetDocs(docIds: readonly string[]): Promise + /** Flush every dirty doc now (tests + shutdown). */ + flushAll(): Promise + /** Detach the row-write reset sources and drop all docs (tests). */ + destroy(): Promise +} + +export function createCollabRelay( + db: DbClient, + opts: { persistDebounceMs?: number } = {}, +): CollabRelay { + const persistDebounceMs = opts.persistDebounceMs ?? 800 + const entries = new Map() + const opening = new Map>() + /** + * Docs mid-eviction or mid-reset. `openDoc` waits these out, so it can never + * hand back a doc that is about to be destroyed, nor resurrect one whose + * blob a reset is still deleting. + */ + const settling = new Map>() + // Last roster set the site-doc persist actually swept, so shell-field-only + // persists skip the three full-table scans. Cleared when the site doc resets. + let lastSweptRostersKey: string | null = null + const updateListeners = new Set() + const resetListeners = new Set() + + // ── Seeding ─────────────────────────────────────────────────────────────── + + async function seedFromJson(docId: string, doc: Y.Doc): Promise { + const parsed = parseCollabDocId(docId) + if (!parsed) return + if (parsed.kind === 'site') { + const shell = await getDraftSite(db) + if (!shell) return // pre-setup — nothing to seed + const [pages, components, layouts] = await Promise.all([ + listDataRowIdSlugs(db, 'pages'), + listDataRowIdSlugs(db, 'components'), + listDataRowIdSlugs(db, 'layouts'), + ]) + seedSiteDocFromParts(doc, shell as unknown as Record, { + pages: pages.map((r) => r.id), + components: components.map((r) => r.id), + layouts: layouts.map((r) => r.id), + }) + return + } + const row = await getDataRow(db, parsed.rowId) + if (!row || row.tableId !== KIND_TABLE[parsed.kind]) return // client-created flow + if (parsed.kind === 'page') { + seedPageDoc(doc, pageFromRow(row)) + } else if (parsed.kind === 'component') { + const vc = visualComponentFromRow(row) + if (vc) seedComponentDoc(doc, vc) + } else { + const layout = savedLayoutFromRow(row) + if (layout) seedLayoutDoc(doc, layout) + } + } + + // ── Persistence ─────────────────────────────────────────────────────────── + + /** + * Outcome of a derived-JSON write. `invalid` is the one that MUST be + * retried: the blob was written but the JSON the publisher (and a reseed) + * read is now stale, so leaving the doc clean is how an accepted edit + * silently disappears. `incomplete` is a doc that is simply not assembled + * yet — retrying that would spin forever. + */ + async function persistDerivedJson(docId: string, doc: Y.Doc): Promise { + const parsed = parseCollabDocId(docId) + if (!parsed) return 'incomplete' + if (parsed.kind === 'site') { + const projected = projectSiteDoc(doc) + if (Object.keys(projected.shell).length === 0) return 'incomplete' // never seeded + let shell: SiteShell + try { + // `id` and `updatedAt` are deliberately NOT collaborative (fixed row / + // per-mutation noise) — inject them at the persistence boundary. + shell = validateSite({ + ...projected.shell, + id: 'default', + updatedAt: + typeof projected.shell.updatedAt === 'number' ? projected.shell.updatedAt : Date.now(), + }) + } catch (err) { + // The blob stays authoritative; JSON write is skipped until the doc + // heals — never persist an invalid shell for the publisher to read. + console.error('[collab] projected shell failed validation — JSON write skipped:', err) + return 'invalid' + } + await saveDraftSite(db, shell, null, { collabInternal: true }) + + // Roster-driven deletions: live rows missing from the roster are gone. + // The three full-table scans below are wasted when only a shell FIELD + // changed (settings/styleRules edits, the common case) and the rosters + // are identical to the last sweep — a heavy edit session would otherwise + // run them on every debounced persist. Skip when the roster is unchanged; + // out-of-relay deletions reset the doc, so a stale roster can't linger. + const rostersKey = + projected.rosters.pages.join(',') + '|' + + projected.rosters.components.join(',') + '|' + + projected.rosters.layouts.join(',') + if (rostersKey === lastSweptRostersKey) return 'written' + let deletedPublished = false + for (const [table, ids] of [ + ['pages', projected.rosters.pages], + ['components', projected.rosters.components], + ['layouts', projected.rosters.layouts], + ] as const) { + const live = await listDataRowIdSlugs(db, table) + const keep = new Set(ids) + for (const row of live) { + if (keep.has(row.id)) continue + const deleted = await softDeleteDataRow(db, row.id, null, { collabInternal: true }) + if (deleted?.status === 'published') deletedPublished = true + } + } + lastSweptRostersKey = rostersKey + if (deletedPublished) await bumpPublishVersionSerialized() + return 'written' + } + + const table = KIND_TABLE[parsed.kind] + let cells: Record + let slug: string + if (parsed.kind === 'page') { + const page = projectPageDoc(doc, parsed.rowId) + if (!page.rootNodeId) return 'incomplete' // never seeded / still assembling + cells = pageToCells(page) + slug = page.slug + } else if (parsed.kind === 'component') { + const vc = projectComponentDoc(doc, parsed.rowId) + if (!vc.tree.rootNodeId || typeof vc.name !== 'string' || vc.name === '') return 'incomplete' + cells = visualComponentToCells(vc) + slug = vcSlugFromName(vc.name) + } else { + const layout = projectLayoutDoc(doc, parsed.rowId) + if (!layout.rootNodeId || layout.name === '') return 'incomplete' + cells = savedLayoutToCells(layout) + slug = layoutSlugFromName(layout.name) + } + + // upsert = update live / resurrect soft-deleted / create fresh. A row the + // roster sweep soft-deleted and a peer then restored (Cmd+Z of a page + // delete) still holds its primary key, so a plain insert would conflict + // forever — the upsert revives it in place instead. + await upsertDataRowDraft( + db, + { id: parsed.rowId, tableId: table, cells, slug }, + null, + { collabInternal: true }, + ) + return 'written' + } + + async function persistNow(docId: string): Promise { + const entry = entries.get(docId) + if (!entry || !entry.dirty) return 'clean' + entry.dirty = false + try { + await putCollabDocumentState(db, docId, Y.encodeStateAsUpdate(entry.doc), entry.generation) + const derived = await persistDerivedJson(docId, entry.doc) + // A shell that failed validation MUST be retried: the blob is fresh but + // the derived JSON is stale, and a reset reseeds from that JSON. Leaving + // the doc clean here is how an accepted page silently disappears. + if (derived === 'invalid') { + entry.dirty = true + return 'invalid' + } + return 'clean' + } catch (err) { + entry.dirty = true + console.error(`[collab] persist failed for ${docId}:`, err) + return 'retry' + } + } + + function schedulePersist(docId: string): void { + const entry = entries.get(docId) + if (!entry) return + entry.dirty = true + if (entry.persistTimer) return + entry.persistTimer = setTimeout(() => { + entry.persistTimer = null + const persist = entry.persistChain.then(() => persistNow(docId)) + entry.persistChain = persist.then(() => undefined) + void persist.then((outcome) => { + if (outcome === 'retry') { + schedulePersist(docId) + } else if (outcome === 'clean' && entry.refs <= 0) { + // A final persist may have failed after the last editor disconnected. + // Keep the doc resident until a retry succeeds, then finish eviction. + void evict(docId, { persist: false }).catch((err) => { + console.error(`[collab] eviction after retry failed for ${docId}:`, err) + }) + } + }) + }, persistDebounceMs) + } + + // ── Registry ────────────────────────────────────────────────────────────── + + async function openDoc(docId: string): Promise { + const existing = entries.get(docId) + if (existing) return { doc: existing.doc, generation: existing.generation } + const inFlight = opening.get(docId) + if (inFlight) return inFlight + + const open = (async () => { + // Never step on a doc that is being torn down: an eviction still has a + // persist in flight (whose blob write would resurrect state a reset is + // deleting), and a reset has not finished deleting the blob we would + // otherwise hydrate from — keeping the dead generation alive. + await settling.get(docId) + const doc = new Y.Doc() + const stored = await getCollabDocumentState(db, docId) + let generation: string + let minted: boolean + if (stored) { + Y.applyUpdate(doc, stored.state, 'hydrate') + // Rows written before migration 023 carry ''. + minted = stored.generation === '' + generation = minted ? nanoid() : stored.generation + } else { + await seedFromJson(docId, doc) + generation = nanoid() + minted = true + } + const updateHandler = (update: Uint8Array, origin: unknown) => { + for (const listener of updateListeners) listener(docId, update, origin, generation) + schedulePersist(docId) + } + doc.on('update', updateHandler) + entries.set(docId, { + doc, + generation, + refs: 0, + dirty: false, + persistTimer: null, + persistChain: Promise.resolve(), + detachUpdateHandler: () => doc.off('update', updateHandler), + }) + if (minted) { + // Persist the mint IMMEDIATELY rather than through the debounce. A doc + // that hydrated cleanly is not dirty, so a mint riding the debounce + // would never reach the DB — the next open would mint a DIFFERENT id + // and reset every bound client for a byte-identical lineage. + await putCollabDocumentState(db, docId, Y.encodeStateAsUpdate(doc), generation) + } + return { doc, generation } + })() + + opening.set(docId, open) + try { + return await open + } finally { + opening.delete(docId) + } + } + + async function evict(docId: string, opts2: { persist: boolean }): Promise { + const entry = entries.get(docId) + if (!entry) return + const run = (async () => { + if (entry.persistTimer) { + clearTimeout(entry.persistTimer) + entry.persistTimer = null + } + // Await the chain even when NOT persisting: a persist already past its + // `dirty` check would otherwise resolve after `resetDocs` deleted the + // row and re-insert the dead blob via upsert, undoing the reset. + const persist = entry.persistChain.then(() => + opts2.persist ? persistNow(docId) : Promise.resolve('clean'), + ) + entry.persistChain = persist.then(() => undefined) + const outcome = await persist + if (outcome !== 'clean') { + if (outcome === 'retry') schedulePersist(docId) + throw new Error( + outcome === 'invalid' + ? `cannot evict ${docId}: collaborative state does not project to valid persisted JSON` + : `cannot evict ${docId}: collaborative state persistence failed`, + ) + } + // An update may have landed during that await and scheduled a new timer. + if (entry.persistTimer) { + clearTimeout(entry.persistTimer) + entry.persistTimer = null + } + entry.detachUpdateHandler() + entry.doc.destroy() + entries.delete(docId) + })() + settling.set(docId, run.then(() => undefined, () => undefined)) + try { + await run + } finally { + settling.delete(docId) + } + } + + async function resetDocs(docIds: readonly string[]): Promise { + const affected = docIds.filter((id) => parseCollabDocId(id) !== null) + if (affected.length === 0) return + // The site doc reseeds from the DB on next bind — force a full roster + // sweep on its first persist afterwards. + if (affected.includes(SITE_DOC_ID)) lastSweptRostersKey = null + + // Flush the docs we are NOT resetting first. The site doc reseeds its + // rosters from `listDataRowIdSlugs`, so a page whose row-doc JSON is still + // inside the debounce window would not exist in the DB, would vanish from + // the reseeded roster, and would then be soft-deleted by the next sweep. + // The reset docs themselves are deliberately NOT flushed: the out-of-relay + // write that triggered the reset already committed and must win. + for (const docId of [...entries.keys()]) { + if (affected.includes(docId)) continue + const entry = entries.get(docId) + if (!entry?.dirty) continue + const persist = entry.persistChain.then(() => persistNow(docId)) + entry.persistChain = persist.then(() => undefined) + const outcome = await persist + if (outcome !== 'clean') { + if (outcome === 'retry') schedulePersist(docId) + throw new Error(`cannot reset ${affected.join(', ')}: failed to flush ${docId}`) + } + } + + const heldRefs = new Map() + for (const docId of affected) { + const refs = entries.get(docId)?.refs ?? 0 + if (refs > 0) heldRefs.set(docId, refs) + await evict(docId, { persist: false }) + } + + // Hold the door shut across the delete: an in-flight frame from a still + // connected editor would otherwise re-open the doc from the not-yet-deleted + // blob and the delete would land on a live doc. + const deletion = deleteCollabDocuments(db, affected).then(() => undefined) + for (const docId of affected) settling.set(docId, deletion) + try { + await deletion + } finally { + for (const docId of affected) settling.delete(docId) + } + + // Re-register the ref counts the eviction dropped. Without this the next + // `openDoc` starts at 0 while N connections still list the doc in + // `boundDocs`, so the first close drives refs negative and evicts a doc + // other editors are actively writing. + for (const [docId, refs] of heldRefs) { + await openDoc(docId) + const reopened = entries.get(docId) + if (reopened) reopened.refs = refs + } + for (const docId of affected) { + for (const listener of resetListeners) listener(docId) + } + } + + // ── Out-of-relay write sources → resets ─────────────────────────────────── + + const detachRowListener = registerRowWriteListener((event) => { + const kind = TABLE_KIND[event.tableId] + if (!kind) return + const docIds = event.rowIds.map((rowId) => encodeCollabDocId({ kind, rowId })) + // Creations/deletions also change the roster — the site doc must reseed. + if (event.kind !== 'update') docIds.push(SITE_DOC_ID) + void resetDocs(docIds).catch((err) => { + console.error('[collab] reset after out-of-relay row write failed:', err) + }) + }) + const detachShellListener = registerShellWriteListener(() => { + void resetDocs([SITE_DOC_ID]).catch((err) => { + console.error('[collab] reset after out-of-relay shell write failed:', err) + }) + }) + + async function flushAll(): Promise { + for (const docId of [...entries.keys()]) { + const entry = entries.get(docId) + if (!entry) continue + if (entry.persistTimer) { + clearTimeout(entry.persistTimer) + entry.persistTimer = null + } + const persist = entry.persistChain.then(() => persistNow(docId)) + entry.persistChain = persist.then(() => undefined) + const outcome = await persist + if (outcome !== 'clean') { + if (outcome === 'retry') schedulePersist(docId) + throw new Error( + outcome === 'invalid' + ? `cannot flush ${docId}: collaborative state does not project to valid persisted JSON` + : `cannot flush ${docId}: collaborative state persistence failed`, + ) + } + } + } + + // Every publish path flushes the relay first (see publishFlush.ts) so the + // baked output includes edits still inside the persist debounce window. + const detachPublishFlush = registerPublishFlush(flushAll) + + return { + openDoc, + retain: async (docId) => { + // A reset can evict between `openDoc` resolving and the registry read, + // which would both crash on a non-null assertion and hand back a doc + // that was just destroyed. Re-open until the doc we return is the one + // whose refs we incremented. + for (;;) { + await openDoc(docId) + const entry = entries.get(docId) + if (!entry) continue + entry.refs += 1 + return { doc: entry.doc, generation: entry.generation } + } + }, + release: (docId) => { + const entry = entries.get(docId) + if (!entry) return + entry.refs -= 1 + if (entry.refs <= 0) { + void evict(docId, { persist: true }).catch((err) => { + console.error(`[collab] final persist for ${docId} failed:`, err) + }) + } + }, + subscribeUpdates: (listener) => { + updateListeners.add(listener) + return () => updateListeners.delete(listener) + }, + onReset: (listener) => { + resetListeners.add(listener) + return () => resetListeners.delete(listener) + }, + resetDocs, + flushAll, + destroy: async () => { + detachRowListener() + detachShellListener() + detachPublishFlush() + for (const docId of [...entries.keys()]) { + await evict(docId, { persist: true }) + } + }, + } +} diff --git a/server/collab/socket.ts b/server/collab/socket.ts new file mode 100644 index 000000000..7e0dd53e0 --- /dev/null +++ b/server/collab/socket.ts @@ -0,0 +1,490 @@ +/** + * Collab socket — the WebSocket endpoint of real-time co-editing. + * + * GET /admin/api/cms/site-socket → WebSocket upgrade + * + * Auth happens at upgrade time: the session cookie must resolve to a user + * with `site.read`, and the Origin header must pass `originAllowed` — the + * browser always sends Origin on WebSocket handshakes, so this closes + * cross-origin WebSocket hijacking (CSWSH): cookies ride the handshake, but + * a foreign origin is rejected before the socket opens. Whether the user may + * WRITE is resolved once at upgrade (any site-write capability) — update + * frames from read-only connections are dropped server-side. + * + * One socket multiplexes many docs (frames in @core/collab/protocol): + * - FRAME_SYNC: y-protocols sync messages per doc. A connection's first + * sync frame for a doc retains it in the relay and subscribes the socket + * to that doc's fan-out topic; close releases everything. + * - FRAME_AWARENESS: one site-wide awareness channel (PRESENCE_DOC_ID) — + * cursors/selections; per-connection clientIDs are tracked so a closing + * socket's peers disappear immediately. + * - FRAME_RESET: server → client only; broadcast when the relay drops a + * doc whose backing JSON was rewritten out-of-relay. + * + * NOTE on fan-out: relay updates publish to every topic subscriber including + * the originator — Yjs update application is idempotent, so the echo is a + * cheap no-op and the code stays free of per-connection exclusion plumbing. + */ +import type { ServerWebSocket, WebSocketHandler } from 'bun' +import * as Y from 'yjs' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' +import * as syncProtocol from 'y-protocols/sync' +import * as awarenessProtocol from 'y-protocols/awareness' +import { + decodeCollabFrame, + encodeCollabFrame, + encodeResetPayload, + FRAME_AWARENESS, + FRAME_PING, + FRAME_PONG, + FRAME_RESET, + FRAME_SYNC, + parseCollabDocId, + PRESENCE_DOC_ID, + SITE_SOCKET_PATH, + type CollabFrame, + type ResetReason, +} from '@core/collab' +import { requireCapability, userHasCapability } from '../auth/authz' +import { safeParseValue, Type } from '@core/utils/typeboxHelpers' +import type { CoreCapability } from '@core/capabilities' +import { validateGuardedUpdate } from './updateGuard' +import { originAllowed } from '../auth/security' +import type { DbClient } from '../db/client' +import { jsonResponse } from '../http' +import type { CollabRelay, RelayDoc } from './relay' + +export { SITE_SOCKET_PATH } + +const SITE_WRITE_CAPABILITIES = ['site.structure.edit', 'site.content.edit', 'site.style.edit'] as const + +/** y-protocols/sync message types (the payload's first varUint). */ +const SYNC_STEP_1 = 0 + +// Frame-size ceilings (belt and braces on top of Bun's maxPayloadLength): +// presence states are a few hundred bytes; doc updates can legitimately be +// large (a big paste, a whole-doc repopulate) but never tens of megabytes. +const MAX_AWARENESS_PAYLOAD_BYTES = 64 * 1024 +const MAX_SYNC_PAYLOAD_BYTES = 4 * 1024 * 1024 + +/** The canonical presence identity a connection is allowed to publish. */ +export interface CollabPresenceIdentity { + id: string + name: string + avatarUrl: string | null + gravatarHash: string +} + +// Validate (not `as`-cast) the identity block of a decoded awareness state — +// TypeBox at the JSON.parse boundary, per the boundary-validation rule. +const PresenceUserSchema = Type.Object( + { + user: Type.Object({ + id: Type.String(), + name: Type.String(), + avatarUrl: Type.Union([Type.String(), Type.Null()]), + gravatarHash: Type.Union([Type.String(), Type.Null()]), + }), + }, + { additionalProperties: true }, +) + +/** + * Why a client's awareness frame was refused — the relay drops all three, but + * only two of them mean anything. + * + * `foreignClear` is ROUTINE, not an attack. Every y-protocols client runs + * `checkOutdatedAwarenessStates` and broadcasts a removal for any peer whose + * heartbeat it stopped hearing, so clients constantly try to clear clientIDs + * they do not own. The relay refuses — presence is cleared only by its owner or + * by that owner's disconnect, or one slow peer could erase another for everyone + * — but this is expected protocol chatter and must never be logged as a + * security event. + * + * `impersonation` and `malformed` are the ones worth hearing about. + */ +type AwarenessRefusal = 'impersonation' | 'foreignClear' | 'malformed' + +/** + * A client may only publish presence that matches ITS OWN session, and may only + * clear clientIDs it contributed. Decode the awareness update (varUint count, + * then per client: varUint id, varUint clock, varString stateJSON) and: + * - refuse any non-null state whose full identity (id + name + avatar + + * gravatar) differs from the session — pinning id alone let a peer keep its + * own id but paint another admin's name/avatar in every UI; + * - refuse a `null` (clear) for a clientID this connection never announced. + * + * Returns `null` when the frame is legitimate and may be applied. + */ +function reviewAwarenessUpdate( + payload: Uint8Array, + data: Pick, +): AwarenessRefusal | null { + try { + const decoder = decoding.createDecoder(payload) + const count = decoding.readVarUint(decoder) + for (let i = 0; i < count; i++) { + const clientId = decoding.readVarUint(decoder) + decoding.readVarUint(decoder) // clock + const raw = decoding.readVarString(decoder) + if (raw === 'null') { + if (!data.awarenessClients.has(clientId)) return 'foreignClear' + continue + } + const parsed = safeParseValue(PresenceUserSchema, JSON.parse(raw)) + if (!parsed.ok) return 'malformed' + const u = parsed.value.user + if ( + u.id !== data.identity.id || + u.name !== data.identity.name || + u.avatarUrl !== data.identity.avatarUrl || + u.gravatarHash !== data.identity.gravatarHash + ) { + return 'impersonation' + } + } + return null + } catch { + // Undecodable update — refuse rather than relay garbage. + return 'malformed' + } +} + +export interface CollabSocketData { + userId: string + /** The session identity this connection may publish over presence. */ + identity: CollabPresenceIdentity + /** + * True when the user holds ALL of SITE_WRITE_CAPABILITIES — the common + * case, which skips the per-update capability guard entirely. Every other + * connection — partial writers (e.g. content-only editors) AND read-only + * viewers (zero write capabilities) — pays a fork+diff validation per + * update frame instead (see ./updateGuard.ts). A viewer's edit has no + * matching capability for any category, so the guard refuses it and resets + * the sender, exactly like a partial writer straying out of its lane. + */ + fullSiteWriter: boolean + /** The user's granted capabilities — the guard's validation input. */ + capabilities: readonly CoreCapability[] + /** Doc ids this connection retained in the relay (released on close). */ + boundDocs: Set + /** + * Docs this connection has already been asked to hand over its state for. + * Per-connection by design: a reconnect brings a fresh socket, which is + * exactly when the recovery probe must run again. + */ + probedDocs: Set + /** Awareness clientIDs contributed by this connection. */ + awarenessClients: Set +} + +interface UpgradeCapableServer { + upgrade(req: Request, options: { data: CollabSocketData }): boolean +} + +/** + * Gate + upgrade the socket request. Returns `null` when the connection was + * upgraded (the caller must then return `undefined` from `fetch`), or an + * error `Response` (401/403/426) to send instead. + */ +export async function handleCollabSocketUpgrade( + req: Request, + db: DbClient, + server: UpgradeCapableServer, +): Promise { + // CSWSH defense — a cookie-bearing cross-origin handshake is rejected + // before auth even runs. + if (!originAllowed(req)) { + return jsonResponse({ error: 'Origin not allowed' }, { status: 403 }) + } + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + const fullSiteWriter = SITE_WRITE_CAPABILITIES.every((cap) => userHasCapability(user, cap)) + const upgraded = server.upgrade(req, { + data: { + userId: user.id, + identity: { + id: user.id, + name: user.displayName, + avatarUrl: user.avatarUrl, + gravatarHash: user.gravatarHash, + }, + fullSiteWriter, + capabilities: user.capabilities, + boundDocs: new Set(), + probedDocs: new Set(), + awarenessClients: new Set(), + }, + }) + if (!upgraded) { + return jsonResponse({ error: 'WebSocket upgrade required' }, { status: 426 }) + } + return null +} + +const docTopic = (docId: string): string => `collab:${docId}` + +interface CollabPublisher { + publish(topic: string, data: Uint8Array): number +} + +/** + * Wire the relay's fan-out to Bun pub/sub. Call once after `Bun.serve` + * returns (the server handle is the publisher). Also owns the site-wide + * awareness instance. + */ +export function createCollabSocketLayer(relay: CollabRelay) { + let publisher: CollabPublisher | null = null + const presenceDoc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(presenceDoc) + // The server never contributes its own presence state. + awareness.setLocalState(null) + + relay.subscribeUpdates((docId, update, _origin, generation) => { + if (!publisher) return + const encoder = encoding.createEncoder() + syncProtocol.writeUpdate(encoder, update) + publisher.publish( + docTopic(docId), + encodeCollabFrame(docId, generation, FRAME_SYNC, encoding.toUint8Array(encoder)), + ) + }) + relay.onReset((docId) => { + // The lineage this frame refers to is already gone, so it carries none. + publisher?.publish( + docTopic(docId), + encodeCollabFrame(docId, '', FRAME_RESET, encodeResetPayload('rewritten')), + ) + }) + + /** Tell one connection its doc was dropped, and why. */ + function sendReset( + ws: ServerWebSocket, + docId: string, + reason: ResetReason, + ): void { + ws.send(encodeCollabFrame(docId, '', FRAME_RESET, encodeResetPayload(reason))) + } + + /** + * Dispatch one decoded frame. Extracted so `message` can wrap it in a + * single try/catch — a malformed frame or a projection crash inside the + * guard must NOT escape as an unhandled rejection. + */ + async function dispatchFrame( + ws: ServerWebSocket, + frame: CollabFrame, + ): Promise { + // Liveness first: a ping carries no doc and must never reach + // parseCollabDocId or the relay. Deliberately ungated — a read-only + // viewer needs to know its socket is alive exactly as much as a writer. + if (frame.frameType === FRAME_PING) { + ws.send(encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_PONG, new Uint8Array())) + return + } + + if (frame.frameType === FRAME_AWARENESS) { + // Presence is NOT a doc write — read-only viewers are visible peers. + if (frame.payload.byteLength > MAX_AWARENESS_PAYLOAD_BYTES) return + const refusal = reviewAwarenessUpdate(frame.payload, ws.data) + if (refusal !== null) { + // A foreign clear is ordinary y-protocols timeout chatter (see + // AwarenessRefusal) — drop it without crying wolf in the log. + if (refusal !== 'foreignClear') { + console.warn(`[collab] dropped ${refusal} awareness frame from ${ws.data.userId}`) + } + return + } + // Track which clientIDs this connection contributes so its peers + // vanish immediately on close. + const before = new Set(awareness.getStates().keys()) + awarenessProtocol.applyAwarenessUpdate(awareness, frame.payload, ws) + for (const clientId of awareness.getStates().keys()) { + if (!before.has(clientId)) ws.data.awarenessClients.add(clientId) + } + publisher?.publish( + docTopic(PRESENCE_DOC_ID), + encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_AWARENESS, frame.payload), + ) + // publish() excludes nobody server-side; the sender's own state is + // already local — awareness re-application is idempotent. + ws.send(encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_AWARENESS, frame.payload)) + return + } + + if (frame.frameType !== FRAME_SYNC) return + if (!parseCollabDocId(frame.docId)) return + if (frame.payload.byteLength > MAX_SYNC_PAYLOAD_BYTES) { + console.warn( + `[collab] oversize ${frame.docId} frame (${frame.payload.byteLength}B) from ${ws.data.userId}`, + ) + // Silently dropping this diverges the client permanently: it holds + // structs the server will never receive, every later update queues + // behind them as pending, and the screen shows edits that can never + // publish. A visible revert is strictly better. + sendReset(ws, frame.docId, 'oversize') + return + } + + const messageType = decoding.readVarUint(decoding.createDecoder(frame.payload)) + + let bound: RelayDoc + if (ws.data.boundDocs.has(frame.docId)) { + bound = await relay.openDoc(frame.docId) + } else { + // Claim the doc SYNCHRONOUSLY before awaiting retain — otherwise two + // concurrent frames for the same not-yet-bound doc both take this + // branch and double-increment refs (close releases only once, leaking + // the entry). A racing frame now sees boundDocs and takes openDoc. + ws.data.boundDocs.add(frame.docId) + ws.subscribe(docTopic(frame.docId)) + try { + bound = await relay.retain(frame.docId) + } catch (err) { + ws.data.boundDocs.delete(frame.docId) + ws.unsubscribe(docTopic(frame.docId)) + throw err + } + } + const { doc, generation } = bound + + // LINEAGE CHECK — before readSyncMessage, before the guard, before any + // applyUpdate. A reset reseeds at the fixed SEED_CLIENT_ID, so a client + // that missed the reset (it was offline; FRAME_RESET is a broadcast) + // holds structs at coordinates the live lineage now occupies. Answering + // its step1 is enough to corrupt: encodeStateAsUpdate(doc, staleSV) + // omits exactly the structs it "already has" and ships the full delete + // set, so both ends silently diverge. + if (frame.generation === '') { + // '' means "I hold no server state for this doc". Always fine for a + // read. For a WRITE it is only safe when there is nothing to collide + // with — the client-created-row flow populates a doc at bind time, + // before any inbound frame has taught it a generation. + const serverIsEmpty = Y.encodeStateVector(doc).byteLength === 1 + if (messageType !== SYNC_STEP_1 && !serverIsEmpty) { + sendReset(ws, frame.docId, 'stale') + return + } + } else if (frame.generation !== generation) { + console.warn( + `[collab] stale generation for ${frame.docId} from ${ws.data.userId}`, + ) + sendReset(ws, frame.docId, 'stale') + return + } + + // Every non-full-writer — partial-capability editors AND read-only + // viewers — passes each update through the category guard BEFORE it + // touches the authoritative doc, the same structure/content/style rules + // the HTTP save enforces. A read-only viewer holds no write capability, + // so the guard refuses any real change and the reset below reverts it on + // the sender's own screen; a viewer's empty handshake step2 diffs to + // nothing and passes as a no-op. Both non-step1 message types (step2 + // replies and updates) carry `varUint8Array update` after the type + // varUint, so one extraction covers whatever a client might send. + if (messageType !== SYNC_STEP_1 && !ws.data.fullSiteWriter) { + const guardDecoder = decoding.createDecoder(frame.payload) + decoding.readVarUint(guardDecoder) + const update = decoding.readVarUint8Array(guardDecoder) + const verdict = validateGuardedUpdate(frame.docId, doc, update, ws.data.capabilities) + if (!verdict.ok) { + console.warn(`[collab] rejected ${frame.docId} update from ${ws.data.userId}: ${verdict.reason}`) + // The sender's local doc holds the forbidden change — a TARGETED + // reset makes their client rebind and reseed from the server, + // reverting it everywhere (including their own screen). + sendReset(ws, frame.docId, 'refused') + return + } + Y.applyUpdate(doc, update, ws) + return + } + + // RECOVERY — ask a full writer what IT holds that we do not. + // + // The client's step1 only pulls the server's delta; nothing ever pulls + // the client's. So anything committed locally while the socket was down + // (or black-holed, before liveness noticed) never reached the relay, + // even after reconnect. y-protocols already answers a step1 with exactly + // the missing delta, so originating one here needs no client change at + // all. + // + // Gated on `fullSiteWriter` deliberately. A partial writer's reply is a + // single update carrying its whole missing state, and + // `validateGuardedUpdate` accepts or rejects that as ONE unit — a single + // forbidden op inside it would discard every legitimate edit alongside. + // Losing their recovery window is bad; silently destroying the rest of + // their session to attempt it is worse. + if (messageType === SYNC_STEP_1 && ws.data.fullSiteWriter && !ws.data.probedDocs.has(frame.docId)) { + ws.data.probedDocs.add(frame.docId) + const probe = encoding.createEncoder() + syncProtocol.writeSyncStep1(probe, doc) + ws.send(encodeCollabFrame(frame.docId, generation, FRAME_SYNC, encoding.toUint8Array(probe))) + } + + const decoder = decoding.createDecoder(frame.payload) + const encoder = encoding.createEncoder() + syncProtocol.readSyncMessage(decoder, encoder, doc, ws) + if (encoding.length(encoder) > 0) { + ws.send(encodeCollabFrame(frame.docId, generation, FRAME_SYNC, encoding.toUint8Array(encoder))) + } + } + + const handlers: WebSocketHandler = { + // Transport-level ceiling — the per-frame-type caps in `message` are the + // fine-grained guards; this stops oversized frames before they buffer. + maxPayloadLength: MAX_SYNC_PAYLOAD_BYTES + 1024, + + open(ws: ServerWebSocket) { + ws.subscribe(docTopic(PRESENCE_DOC_ID)) + // Late joiners need the current presence roster. + const known = [...awareness.getStates().keys()] + if (known.length > 0) { + const update = awarenessProtocol.encodeAwarenessUpdate(awareness, known) + ws.send(encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_AWARENESS, update)) + } + }, + + async message(ws: ServerWebSocket, raw: string | Buffer) { + if (typeof raw === 'string') return // binary protocol only + let frame: CollabFrame | null = null + try { + frame = decodeCollabFrame(new Uint8Array(raw)) + await dispatchFrame(ws, frame) + } catch (err) { + console.error('[collab] socket message handler failed:', err) + // A sync-write frame whose guard/apply threw left the sender's local + // doc diverged from the authoritative one — reset it so their client + // rebinds and reseeds. Awareness/malformed frames just get dropped. + if (frame && frame.frameType === FRAME_SYNC && parseCollabDocId(frame.docId)) { + try { + sendReset(ws, frame.docId, 'refused') + } catch (_sendErr) { + // Socket already closing — nothing to recover. + } + } + } + }, + + close(ws: ServerWebSocket) { + for (const docId of ws.data.boundDocs) relay.release(docId) + ws.data.boundDocs.clear() + if (ws.data.awarenessClients.size > 0) { + awarenessProtocol.removeAwarenessStates(awareness, [...ws.data.awarenessClients], 'disconnect') + const update = awarenessProtocol.encodeAwarenessUpdate(awareness, [...ws.data.awarenessClients]) + publisher?.publish( + docTopic(PRESENCE_DOC_ID), + encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_AWARENESS, update), + ) + } + }, + } + + return { + handlers, + setPublisher(next: CollabPublisher): void { + publisher = next + }, + awareness, + } +} diff --git a/server/collab/updateGuard.ts b/server/collab/updateGuard.ts new file mode 100644 index 000000000..f86d087ed --- /dev/null +++ b/server/collab/updateGuard.ts @@ -0,0 +1,120 @@ +/** + * Per-category capability enforcement for relay writes. + * + * The HTTP save path diff-validates every batch by category (structure / + * content / style — `validateSiteWriteDiff` + `validatePageWriteDiff`). + * Relay writes arrive as opaque Yjs updates, so the guard makes them + * diff-able: fork the authoritative doc, apply the update to the fork, + * project BOTH sides back to the JSON domain, and run the SAME validators + * the HTTP path uses. One enforcement vocabulary, two transports. + * + * Full site-writers (all three capabilities) skip the guard entirely — the + * fork+project cost is only paid by partial-capability connections, whose + * human-scale edit rate makes it negligible. + * + * Component and layout docs follow the HTTP rule wholesale: ANY change to + * them is structural work. On the site doc, roster membership/order changes + * are structural; the shell diff-validates per field. + */ +import * as Y from 'yjs' +import type { SiteShell } from '@core/page-tree' +import { deepEqual } from '@core/utils/deepEqual' +import { + parseCollabDocId, + projectComponentDoc, + projectLayoutDoc, + projectPageDoc, + projectSiteDoc, +} from '@core/collab' +import type { CoreCapability } from '@core/capabilities' +import { ForbiddenSiteChangeError, validateSiteWriteDiff } from '../writePolicy/siteDiff' +import { validatePageWriteDiff } from '../writePolicy/pageDiff' + +export type UpdateGuardVerdict = { ok: true } | { ok: false; reason: string } + +function hasStructure(capabilities: readonly CoreCapability[]): boolean { + return capabilities.includes('site.structure.edit') +} + +/** Projections omit the non-collaborative shell fields — pin them for the diff. */ +function asShell(shell: Record): SiteShell { + return { ...shell, id: 'default', updatedAt: 0 } as unknown as SiteShell +} + +/** + * Validate one incoming update against the connection's capabilities. + * Never mutates `doc` — the caller applies the update only on `ok: true`. + */ +export function validateGuardedUpdate( + docId: string, + doc: Y.Doc, + update: Uint8Array, + capabilities: readonly CoreCapability[], +): UpdateGuardVerdict { + const parsed = parseCollabDocId(docId) + if (!parsed) return { ok: false, reason: `unknown doc id ${docId}` } + + const fork = new Y.Doc() + Y.applyUpdate(fork, Y.encodeStateAsUpdate(doc)) + try { + Y.applyUpdate(fork, update) + } catch (err) { + return { ok: false, reason: `malformed update: ${err instanceof Error ? err.message : String(err)}` } + } + + try { + if (parsed.kind === 'page') { + const previous = projectPageDoc(doc, parsed.rowId) + const next = projectPageDoc(fork, parsed.rowId) + validatePageWriteDiff({ + // An unseeded previous (no root yet) means this update CREATES the + // page content — validatePageWriteDiff treats a missing previous as + // a structural page creation. + previousPages: previous.rootNodeId ? [previous] : [], + changedPages: [next], + deletedPageIds: new Set(), + capabilities, + }) + return { ok: true } + } + + if (parsed.kind === 'component' || parsed.kind === 'layout') { + const previous = + parsed.kind === 'component' + ? projectComponentDoc(doc, parsed.rowId) + : projectLayoutDoc(doc, parsed.rowId) + const next = + parsed.kind === 'component' + ? projectComponentDoc(fork, parsed.rowId) + : projectLayoutDoc(fork, parsed.rowId) + if (!deepEqual(previous, next) && !hasStructure(capabilities)) { + return { + ok: false, + reason: `${parsed.kind} changes require site.structure.edit`, + } + } + return { ok: true } + } + + // Site doc: roster membership/order is structural; the shell diff + // validates per category, exactly like the HTTP path. + const previous = projectSiteDoc(doc) + const next = projectSiteDoc(fork) + if (!deepEqual(previous.rosters, next.rosters) && !hasStructure(capabilities)) { + return { ok: false, reason: 'roster changes require site.structure.edit' } + } + validateSiteWriteDiff( + // An empty previous shell = unseeded doc being populated — the diff + // validator treats a null previous as full-shell creation. + Object.keys(previous.shell).length === 0 ? null : asShell(previous.shell), + asShell(next.shell), + capabilities, + ) + return { ok: true } + } catch (err) { + if (err instanceof ForbiddenSiteChangeError) { + return { ok: false, reason: err.message } + } + throw err + } +} diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 6fad59d68..f9cc72c76 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1120,4 +1120,413 @@ export const pgMigrations: Migration[] = [ on ai_mcp_oauth_tokens (connector_id); `, }, + { + // Real-time co-editing (Yjs): one CRDT state blob per collab document + // (site shell, page, component, layout — doc_id is ':'). + // The blob is the live-editing source of truth; derived JSON keeps + // flowing into data_rows/site for the publisher and non-editor reads. + // `seq` counts persists (future delta APIs / diagnostics). + id: '022_collab_documents', + sql: ` + create table if not exists collab_documents ( + doc_id text primary key, + state_blob bytea not null, + seq bigint not null default 0, + updated_at timestamptz not null default now() + ); + `, + }, + { + // Per-doc CRDT lineage id. A reset deletes the blob and the doc reseeds at + // the fixed SEED_CLIENT_ID, so the new lineage reuses the old one's struct + // coordinates and a client that missed the reset would hand back structs + // from a dead lineage at live coordinates. Rows written by 022 carry '' and + // have a generation minted on their next open (see relay.openDoc). + id: '023_collab_document_generation', + sql: ` + alter table collab_documents add column generation text not null default ''; + `, + }, + { + // Visitor authentication & member areas (Phase 1 of docs/PRD.md §4). + // Five tables, fully decoupled from the admin auth tables: separate + // cookie name (`instatic_visitor_session`), separate middleware, separate + // repositories. Order is dictated by FK dependencies: + // 1. visitor_roles — no FKs; seeded with two system rows + // 2. visitor_users — FK visitor_roles (on delete restrict) + // 3. visitor_sessions — FK visitor_users (on delete cascade) + // 4. visitor_login_attempts — FK visitor_users (on delete set null) + // 5. visitor_auth_config — single-row config, no FKs + // + // D12 (docs/ARCHITECTURE.md): the unique constraint on a visitor's + // ACTIVE email is a SEPARATE `CREATE UNIQUE INDEX ... WHERE deleted_at + // IS NULL` rather than a table-level UNIQUE. PG could express it as a + // table constraint, but both dialects emit the identical statement for + // parity (SQLite cannot declare a partial UNIQUE inline). + // + // visitor_auth_config replaces PRD §4.6's proposal to store `visitorAuth` + // inside site.settings_json: SiteSettingsSchema is a closed Type.Object + // whose parse silently drops unknown keys, so a visitorAuth key would not + // survive a publish/parse round-trip. A dedicated single-row config + // table is fully testable and keeps the core settings type untouched. + id: '024_visitor_auth', + sql: ` + -- ─── visitor_roles ──────────────────────────────────────────────── + + create table if not exists visitor_roles ( + id text primary key, + name text not null unique, + capabilities_json jsonb not null default '[]'::jsonb, + is_system boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ); + + -- First-boot seed only: ON CONFLICT (id) DO NOTHING preserves any + -- operator edits made via the admin UI on later boots (the migration + -- runner never re-runs a recorded migration; the guard keeps the seed + -- idempotent if it ever is). Fixed string ids ('member'/'admin') so + -- the defaultRole / repository code can reference them by id. + insert into visitor_roles (id, name, capabilities_json, is_system) + values + ('member', 'member', '[]'::jsonb, true), + ('admin', 'admin', '["content.read","content.write"]'::jsonb, true) + on conflict (id) do nothing; + + -- ─── visitor_users ──────────────────────────────────────────────── + + create table if not exists visitor_users ( + id text primary key, + email text not null, + email_normalized text not null, + password_hash text not null, + display_name text not null default '', + role_id text not null references visitor_roles(id) on delete restrict, + status text not null default 'active', + failed_login_count integer not null default 0, + locked_until timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz, + constraint visitor_users_display_name_check check (length(display_name) <= 200), + constraint visitor_users_status_check check (status in ('active', 'suspended')) + ); + + -- D12: partial unique index as a separate CREATE statement, mirroring + // the SQLite dialect verbatim. A soft-deleted visitor can re-register + // with the same email. + create unique index if not exists visitor_users_email_active_idx + on visitor_users (email_normalized) + where deleted_at is null; + + -- ─── visitor_sessions ───────────────────────────────────────────── + + create table if not exists visitor_sessions ( + id_hash text primary key, + user_id text not null references visitor_users(id) on delete cascade, + created_at timestamptz not null default now(), + last_seen_at timestamptz not null default now(), + expires_at timestamptz not null, + revoked_at timestamptz, + ip_address text, + user_agent text, + device_label text not null default '' + ); + + create index if not exists visitor_sessions_user_idx + on visitor_sessions (user_id, last_seen_at desc); + + -- ─── visitor_login_attempts ─────────────────────────────────────── + + create table if not exists visitor_login_attempts ( + id text primary key, + attempted_at timestamptz not null default now(), + email_normalized text, + ip_address text, + user_agent text, + user_id text references visitor_users(id) on delete set null, + result text not null + constraint visitor_login_attempts_result_check + check (result in ('success', 'bad_password', 'no_user', 'locked', 'rate_limited', 'account_disabled')) + ); + + create index if not exists visitor_login_attempts_ip_idx + on visitor_login_attempts (ip_address); + + create index if not exists visitor_login_attempts_email_idx + on visitor_login_attempts (email_normalized); + + -- ─── visitor_auth_config (single-row config table) ──────────────── + + create table if not exists visitor_auth_config ( + id text primary key default 'default', + enabled boolean not null default false, + protected_prefixes_json jsonb not null default '[]'::jsonb, + login_path text not null default '/login', + registration_open boolean not null default true, + default_role text not null default 'member', + updated_at timestamptz not null default now() + ); + + -- Seed the single default-config row on first boot. ON CONFLICT (id) + -- DO NOTHING preserves operator edits on later boots. + insert into visitor_auth_config ( + id, enabled, protected_prefixes_json, login_path, + registration_open, default_role, updated_at + ) + values ('default', false, '[]'::jsonb, '/login', true, 'member', now()) + on conflict (id) do nothing; + `, + }, + { + id: '025_visitor_password_reset', + sql: ` + -- ─── visitor_password_reset_tokens ──────────────────────────────── + -- + -- One-shot password-reset tokens. The raw token is generated in JS + -- (randomBytes(32) base64url) and handed to the email transport; the + -- DB only ever holds the SHA-256 hex of it (token_hash). A token is + -- consumed exactly once: consumePasswordResetToken sets used_at, and + -- the unique hash index keeps the lookup O(log n). expires_at carries + -- a 1-hour TTL (VISITOR_PASSWORD_RESET_TTL_MS). + + create table if not exists visitor_password_reset_tokens ( + id text primary key, + user_id text not null references visitor_users(id) on delete cascade, + token_hash text not null, + expires_at timestamptz not null, + used_at timestamptz, + created_at timestamptz not null default now() + ); + + create unique index if not exists visitor_password_reset_tokens_hash_idx + on visitor_password_reset_tokens (token_hash); + + create index if not exists visitor_password_reset_tokens_user_idx + on visitor_password_reset_tokens (user_id, created_at desc); + `, + }, + { + id: '026_member_groups', + sql: ` + -- ─── Member groups (Phase 3 — D13/D14/D15) ───────────────────────── + -- + -- A group is a content-segmentation segment used for page-level access + -- (D14) and login-redirect landing resolution (D15). Orthogonal to + -- visitor_roles (D13): a role answers "what can a member DO"; a group + -- answers "what can a member SEE / where do they land". A visitor + -- belongs to 0..N groups via visitor_user_groups, with one designated + -- primary group (visitor_users.primary_group_id, added below). + -- + -- No seed rows: admins create groups. PG dialect mirror of SQLite 023. + + create table if not exists visitor_groups ( + id text primary key, + name text not null unique, + slug text not null, + landing_path text not null default '/', + description text not null default '', + is_system boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ); + + create index if not exists visitor_groups_slug_idx + on visitor_groups (slug); + + create table if not exists visitor_user_groups ( + id text primary key, + user_id text not null references visitor_users(id) on delete cascade, + group_id text not null references visitor_groups(id) on delete cascade, + created_at timestamptz not null default now(), + unique (user_id, group_id) + ); + + create index if not exists visitor_user_groups_user_idx + on visitor_user_groups (user_id); + + create index if not exists visitor_user_groups_group_idx + on visitor_user_groups (group_id); + + -- D15 primary group (nullable). ON DELETE SET NULL so deleting a + -- visitor's primary group simply clears the pointer (the visitor then + -- falls back to the configured default landing path on login) rather + -- than cascading the delete onto the user row. + alter table visitor_users + add column primary_group_id text references visitor_groups(id) on delete set null; + `, + }, + { + id: '027_page_access', + sql: ` + -- ─── Retire protected-prefixes; add default landing path (Phase 3) ──── + -- + -- Page access now lives on the Page object (a Page.access field in + -- cells_json, tolerant-parsed → public by default), so NO schema change + -- is needed for the access field itself. This migration: + -- 1. ADDs visitor_auth_config.default_landing_path (D15 fallback). + -- 2. Best-effort backfills the old Phase-1/2 protected-prefix config + -- onto matching pages as per-page access against a single + -- synthesized 'members' group (mirrors the Phase-1 "protected = + -- logged-in member" intent). A page is matched when its slug equals + -- the prefix (sans leading '/') or sits beneath it (prefix/...). + -- 3. DROPs the now-dead protected_prefixes_json column. + -- + -- The backfill updates BOTH the draft data_rows.cells_json AND the + -- published site_snapshots.site_json so the middleware gates correctly + -- immediately after upgrade. Both use the Postgres JSON manipulation + -- functions (jsonb_set / jsonb_agg / jsonb_array_elements). Pages that + -- fail to parse are skipped (best-effort; a re-publish will heal any miss). + + alter table visitor_auth_config + add column default_landing_path text not null default '/'; + + -- 1. Create the synthesized 'members' group when there is at least one + -- prefix. ON CONFLICT (id) DO NOTHING keeps the insert idempotent. + insert into visitor_groups (id, name, slug, landing_path, description, is_system) + select + 'vis_group_members_backfill', 'members', 'members', '/', + 'Synthesized from protected-prefix config during the Phase-3 migration.', false + from visitor_auth_config + where id = 'default' + and jsonb_array_length( + case + when jsonb_typeof(protected_prefixes_json) = 'array' then protected_prefixes_json + else '[]'::jsonb + end + ) > 0 + on conflict (id) do nothing; + + -- 2. Stamp access onto draft pages (data_rows.cells_json) whose slug + -- matches any configured prefix. + update data_rows + set cells_json = jsonb_set( + cells_json::jsonb, + '{access}', + '{"level":"groups","groups":["vis_group_members_backfill"]}'::jsonb + )::text + where table_id = 'pages' + and deleted_at is null + and exists ( + select 1 + from visitor_auth_config c + cross join lateral jsonb_array_elements( + case when jsonb_typeof(c.protected_prefixes_json) = 'array' + then c.protected_prefixes_json else '[]'::jsonb end + ) as prefix + where c.id = 'default' + and ( + data_rows.slug = substr(prefix.value#>>'{}', 2) + or data_rows.slug like substr(prefix.value#>>'{}', 2) || '/%' + ) + ); + + -- 3. Stamp access onto published pages inside every site snapshot + -- (site_snapshots.site_json). Rebuild the pages array, setting access + -- on every page whose slug matches a prefix. jsonb_agg walks every + -- page in the snapshot so non-matching pages pass through unchanged. + update site_snapshots + set site_json = sub.pages_json + from ( + select + s.id as snap_id, + jsonb_set( + s.site_json, + '{pages}', + coalesce(( + select jsonb_agg( + case + when exists ( + select 1 + from visitor_auth_config c + cross join lateral jsonb_array_elements( + case when jsonb_typeof(c.protected_prefixes_json) = 'array' + then c.protected_prefixes_json else '[]'::jsonb end + ) as prefix + where c.id = 'default' + and ( + page.value->>'slug' = substr(prefix.value#>>'{}', 2) + or page.value->>'slug' like substr(prefix.value#>>'{}', 2) || '/%' + ) + ) + then jsonb_set( + page.value, + '{access}', + '{"level":"groups","groups":["vis_group_members_backfill"]}'::jsonb + ) + else page.value + end + ) + from jsonb_array_elements(s.site_json->'pages') as page + ), s.site_json->'pages') + ) as pages_json + from site_snapshots s + ) sub + where site_snapshots.id = sub.snap_id + and exists ( + select 1 + from visitor_auth_config c + cross join lateral jsonb_array_elements(site_snapshots.site_json->'pages') as p + cross join lateral jsonb_array_elements( + case when jsonb_typeof(c.protected_prefixes_json) = 'array' + then c.protected_prefixes_json else '[]'::jsonb end + ) as prefix + where c.id = 'default' + and ( + p.value->>'slug' = substr(prefix.value#>>'{}', 2) + or p.value->>'slug' like substr(prefix.value#>>'{}', 2) || '/%' + ) + ); + + -- 4. Drop the now-dead column. Read nowhere after this migration. + alter table visitor_auth_config + drop column protected_prefixes_json; + `, + }, + { + id: '028_visitor_profile_fields', + sql: ` + -- ─── Visitor custom profile fields (per-visitor-data framework) ──── + -- + -- Adds a JSONB column to visitor_users storing the VALUES of site- + -- builder-defined custom profile fields (e.g. schoolName), keyed by + -- field id. The field DEFINITIONS live in visitor_auth_config + -- (profile_fields_json, added below) and mirror the DataField[] shape + -- used by data_tables.fields_json. Same pattern as data_tables, smaller + -- surface. Values default to '{}'::jsonb so every visitor row is valid + -- immediately. + + alter table visitor_users + add column profile_fields_json jsonb not null default '{}'::jsonb; + + -- Store the site-builder-configured profile field DEFINITIONS on the + -- single visitor_auth_config row. A DataField[] JSONB array; default + -- '[]' = no custom profile fields (the pre-framework behaviour). + alter table visitor_auth_config + add column profile_fields_json jsonb not null default '[]'::jsonb; + `, + }, + { + id: '029_visitor_owned_data', + sql: ` + -- ─── Per-visitor owned data (visitor-data framework, Pillar 3) ──────── + -- + -- Links a data row to the visitor who owns it (e.g. a tender they + -- submitted). FK visitor_users with ON DELETE SET NULL so deleting a + -- visitor account retains the row for audit but unlinks it. Indexed + -- for efficient per-visitor queries. data_tables.captures_visitor_owner + -- is a per-table opt-in flag (0/1) — the form handler stamps + -- visitor_user_id only when the target table opts in, so unrelated + -- tables are untouched. + + alter table data_rows + add column visitor_user_id text references visitor_users(id) on delete set null; + + create index if not exists data_rows_table_visitor_idx + on data_rows (table_id, visitor_user_id, updated_at desc); + + alter table data_tables + add column captures_visitor_owner integer not null default 0; + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index 40d5ba57b..09f187642 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1184,4 +1184,437 @@ export const sqliteMigrations: Migration[] = [ on ai_mcp_oauth_tokens (connector_id); `, }, + { + // Real-time co-editing (Yjs): one CRDT state blob per collab document + // (site shell, page, component, layout — doc_id is ':'). + // The blob is the live-editing source of truth; derived JSON keeps + // flowing into data_rows/site for the publisher and non-editor reads. + // `seq` counts persists (future delta APIs / diagnostics). + id: '022_collab_documents', + sql: ` + create table if not exists collab_documents ( + doc_id text primary key, + state_blob blob not null, + seq integer not null default 0, + updated_at text not null default current_timestamp + ); + `, + }, + { + // Per-doc CRDT lineage id. A reset deletes the blob and the doc reseeds at + // the fixed SEED_CLIENT_ID, so the new lineage reuses the old one's struct + // coordinates and a client that missed the reset would hand back structs + // from a dead lineage at live coordinates. Rows written by 022 carry '' and + // have a generation minted on their next open (see relay.openDoc). + id: '023_collab_document_generation', + sql: ` + alter table collab_documents add column generation text not null default ''; + `, + }, + { + // Visitor authentication & member areas (Phase 1 of docs/PRD.md §4). + // Five tables, fully decoupled from the admin auth tables: separate + // cookie name (`instatic_visitor_session`), separate middleware, separate + // repositories. Order is dictated by FK dependencies: + // 1. visitor_roles — no FKs; seeded with two system rows + // 2. visitor_users — FK visitor_roles (on delete restrict) + // 3. visitor_sessions — FK visitor_users (on delete cascade) + // 4. visitor_login_attempts — FK visitor_users (on delete set null) + // 5. visitor_auth_config — single-row config, no FKs + // + // D12 (docs/ARCHITECTURE.md): the unique constraint on a visitor's + // ACTIVE email is a SEPARATE `CREATE UNIQUE INDEX ... WHERE deleted_at + // IS NULL` rather than a table-level UNIQUE — partial unique + // constraints cannot be declared as table constraints on SQLite, so + // both dialects emit the identical statement (see migrations-pg.ts). + // + // visitor_auth_config replaces PRD §4.6's proposal to store `visitorAuth` + // inside site.settings_json: SiteSettingsSchema is a closed Type.Object + // whose parse silently drops unknown keys, so a visitorAuth key would not + // survive a publish/parse round-trip. A dedicated single-row config + // table is fully testable and keeps the core settings type untouched. + // + // SQLite dialect translations (mirror migrations-pg.ts): + // jsonb → text (auto-parsed on read via the _json suffix) + // timestamptz → text (ISO 8601) + // boolean → integer (0/1; repos use Boolean(row.enabled)) + // default now() → default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + id: '024_visitor_auth', + sql: ` + -- ─── visitor_roles ──────────────────────────────────────────────── + + create table if not exists visitor_roles ( + id text primary key, + name text not null unique, + capabilities_json text not null default '[]', + is_system integer not null default 0, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + -- First-boot seed only: ON CONFLICT (id) DO NOTHING preserves any + -- operator edits made via the admin UI on later boots (the migration + -- runner never re-runs a recorded migration; the guard keeps the seed + -- idempotent if it ever is). Fixed string ids ('member'/'admin') so + -- the defaultRole / repository code can reference them by id. + insert into visitor_roles (id, name, capabilities_json, is_system) + values + ('member', 'member', '[]', 1), + ('admin', 'admin', '["content.read","content.write"]', 1) + on conflict (id) do nothing; + + -- ─── visitor_users ──────────────────────────────────────────────── + + create table if not exists visitor_users ( + id text primary key, + email text not null, + email_normalized text not null, + password_hash text not null, + display_name text not null default '', + role_id text not null references visitor_roles(id) on delete restrict, + status text not null default 'active', + failed_login_count integer not null default 0, + locked_until text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + deleted_at text, + constraint visitor_users_display_name_check check (length(display_name) <= 200), + constraint visitor_users_status_check check (status in ('active', 'suspended')) + ); + + -- D12: partial unique index as a separate CREATE statement (SQLite + -- cannot declare a partial UNIQUE as a table constraint). A soft- + -- deleted visitor can re-register with the same email. + create unique index if not exists visitor_users_email_active_idx + on visitor_users (email_normalized) + where deleted_at is null; + + -- ─── visitor_sessions ───────────────────────────────────────────── + + create table if not exists visitor_sessions ( + id_hash text primary key, + user_id text not null references visitor_users(id) on delete cascade, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + last_seen_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at text not null, + revoked_at text, + ip_address text, + user_agent text, + device_label text not null default '' + ); + + create index if not exists visitor_sessions_user_idx + on visitor_sessions (user_id, last_seen_at desc); + + -- ─── visitor_login_attempts ─────────────────────────────────────── + + create table if not exists visitor_login_attempts ( + id text primary key, + attempted_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + email_normalized text, + ip_address text, + user_agent text, + user_id text references visitor_users(id) on delete set null, + result text not null + constraint visitor_login_attempts_result_check + check (result in ('success', 'bad_password', 'no_user', 'locked', 'rate_limited', 'account_disabled')) + ); + + create index if not exists visitor_login_attempts_ip_idx + on visitor_login_attempts (ip_address); + + create index if not exists visitor_login_attempts_email_idx + on visitor_login_attempts (email_normalized); + + -- ─── visitor_auth_config (single-row config table) ──────────────── + + create table if not exists visitor_auth_config ( + id text primary key default 'default', + enabled integer not null default 0, + protected_prefixes_json text not null default '[]', + login_path text not null default '/login', + registration_open integer not null default 1, + default_role text not null default 'member', + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + -- Seed the single default-config row on first boot. ON CONFLICT (id) + -- DO NOTHING preserves operator edits on later boots. + insert into visitor_auth_config ( + id, enabled, protected_prefixes_json, login_path, + registration_open, default_role, updated_at + ) + values ('default', 0, '[]', '/login', 1, 'member', strftime('%Y-%m-%dT%H:%M:%fZ','now')) + on conflict (id) do nothing; + `, + }, + { + id: '025_visitor_password_reset', + sql: ` + -- ─── visitor_password_reset_tokens ──────────────────────────────── + -- + -- One-shot password-reset tokens. The raw token is generated in JS + -- (randomBytes(32) base64url) and handed to the email transport; the + -- DB only ever holds the SHA-256 hex of it (token_hash). A token is + -- consumed exactly once: consumePasswordResetToken sets used_at, and + -- the unique hash index keeps the lookup O(log n). expires_at is an + -- ISO 8601 string with a 1-hour TTL (VISITOR_PASSWORD_RESET_TTL_MS). + + create table if not exists visitor_password_reset_tokens ( + id text primary key, + user_id text not null references visitor_users(id) on delete cascade, + token_hash text not null, + expires_at text not null, + used_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + create unique index if not exists visitor_password_reset_tokens_hash_idx + on visitor_password_reset_tokens (token_hash); + + create index if not exists visitor_password_reset_tokens_user_idx + on visitor_password_reset_tokens (user_id, created_at desc); + `, + }, + { + id: '026_member_groups', + sql: ` + -- ─── Member groups (Phase 3 — D13/D14/D15) ───────────────────────── + -- + -- A group is a content-segmentation segment used for page-level access + -- (D14) and login-redirect landing resolution (D15). Orthogonal to + -- visitor_roles (D13): a role answers "what can a member DO"; a group + -- answers "what can a member SEE / where do they land". A visitor + -- belongs to 0..N groups via visitor_user_groups, with one designated + -- primary group (visitor_users.primary_group_id, added below). + -- + -- No seed rows: admins create groups. SQLite mirror of PG 023: + -- jsonb → n/a (no json columns here) + -- timestamptz → text (ISO 8601) + -- boolean → integer (1/0) + -- default now() → default (strftime(...)) + + create table if not exists visitor_groups ( + id text primary key, + name text not null unique, + slug text not null, + landing_path text not null default '/', + description text not null default '', + is_system integer not null default 0, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + create index if not exists visitor_groups_slug_idx + on visitor_groups (slug); + + create table if not exists visitor_user_groups ( + id text primary key, + user_id text not null references visitor_users(id) on delete cascade, + group_id text not null references visitor_groups(id) on delete cascade, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + unique (user_id, group_id) + ); + + create index if not exists visitor_user_groups_user_idx + on visitor_user_groups (user_id); + + create index if not exists visitor_user_groups_group_idx + on visitor_user_groups (group_id); + + -- D15 primary group (nullable). ON DELETE SET NULL so deleting a + -- visitor's primary group simply clears the pointer (the visitor then + -- falls back to the configured default landing path on login) rather + -- than cascading the delete onto the user row. + alter table visitor_users + add column primary_group_id text references visitor_groups(id) on delete set null; + `, + }, + { + id: '027_page_access', + sql: ` + -- ─── Retire protected-prefixes; add default landing path (Phase 3) ──── + -- + -- Page access now lives on the Page object (a Page.access field in + -- cells_json, tolerant-parsed → public by default), so NO schema change + -- is needed for the access field itself. This migration: + -- 1. ADDs visitor_auth_config.default_landing_path (D15 fallback). + -- 2. Best-effort backfills the old Phase-1/2 protected-prefix config + -- onto matching pages as per-page access against a single + -- synthesized 'members' group (mirrors the Phase-1 "protected = + -- logged-in member" intent). A page is matched when its slug equals + -- the prefix (sans leading '/') or sits beneath it (prefix/...). + -- 3. DROPs the now-dead protected_prefixes_json column. + -- + -- The backfill updates BOTH the draft data_rows.cells_json AND the + -- published site_snapshots.site_json so the middleware gates correctly + -- immediately after upgrade (without waiting for a re-publish). Both use + -- SQLite json_* functions (the migration tracker guarantees this runs + -- exactly once; the _json-suffix adapter auto-parse does NOT apply inside + -- raw migration SQL, so protected_prefixes_json / site_json are treated + -- as raw text and parsed explicitly via json_each / json_extract). + -- Pages that fail to parse are skipped (best-effort; logged nowhere — + -- the migration runs once and a re-publish will heal any miss). + + alter table visitor_auth_config + add column default_landing_path text not null default '/'; + + -- ─── Backfill (only when prefixes were configured) ────────────────── + -- + -- Read the single 'default' config row's protected_prefixes_json. When + -- it is a non-empty array, synthesize one 'members' group and stamp + -- access onto every prefix-matching page (draft + published). The group + -- id is the fixed VISITOR_BACKFILL_MEMBERS_GROUP_ID constant. + + -- 1. Create the synthesized 'members' group when there is at least one + -- prefix. ON CONFLICT (id) DO NOTHING keeps the insert a no-op if a + -- group with this fixed id somehow already exists. + insert into visitor_groups (id, name, slug, landing_path, description, is_system) + select + 'vis_group_members_backfill', 'members', 'members', '/', + 'Synthesized from protected-prefix config during the Phase-3 migration.', 0 + from visitor_auth_config + where id = 'default' + and json_array_length( + case + when json_valid(protected_prefixes_json) then protected_prefixes_json + else '[]' + end + ) > 0 + on conflict (id) do nothing; + + -- 2. Stamp access onto draft pages (data_rows.cells_json) whose slug + -- matches any configured prefix. The page access cell is a JSON + -- object {level:'groups', groups:[]}. A page's slug + -- matches prefix P when slug = P (sans leading '/') or slug LIKE P/%% + -- (the same semantics the old prefix middleware used, minus the + -- leading slash the slug never carries). + update data_rows + set cells_json = json_set( + cells_json, + '$.access', + json('{"level":"groups","groups":["vis_group_members_backfill"]}') + ) + where table_id = 'pages' + and deleted_at is null + and exists ( + select 1 + from visitor_auth_config c, json_each( + case when json_valid(c.protected_prefixes_json) then c.protected_prefixes_json else '[]' end + ) as prefix + where c.id = 'default' + and ( + data_rows.slug = substr(prefix.value, 2) + or data_rows.slug like substr(prefix.value, 2) || '/%' + ) + ); + + -- 3. Stamp access onto published pages inside every site snapshot + -- (site_snapshots.site_json). Rebuild the $.pages array, setting + -- $.pages[i].access on every page whose slug matches a prefix. Only + -- snapshots that actually contain a matching page are touched. The + -- correlated json_group_array walks every page in the snapshot so + -- non-matching pages pass through unchanged. + update site_snapshots + set site_json = ( + select json_set( + site_snapshots.site_json, + '$.pages', + json_group_array( + case + when exists ( + select 1 + from visitor_auth_config c, json_each( + case when json_valid(c.protected_prefixes_json) then c.protected_prefixes_json else '[]' end + ) as prefix + where c.id = 'default' + and ( + json_extract(page.value, '$.slug') = substr(prefix.value, 2) + or json_extract(page.value, '$.slug') like substr(prefix.value, 2) || '/%' + ) + ) + then json_set( + page.value, + '$.access', + json('{"level":"groups","groups":["vis_group_members_backfill"]}') + ) + else page.value + end + ) + ) + from json_each(site_snapshots.site_json, '$.pages') as page + ) + where exists ( + select 1 + from visitor_auth_config c, + json_each(site_snapshots.site_json, '$.pages') as p, + json_each( + case when json_valid(c.protected_prefixes_json) then c.protected_prefixes_json else '[]' end + ) as prefix + where c.id = 'default' + and ( + json_extract(p.value, '$.slug') = substr(prefix.value, 2) + or json_extract(p.value, '$.slug') like substr(prefix.value, 2) || '/%' + ) + ); + + -- 4. Drop the now-dead column. SQLite >= 3.35 supports ALTER TABLE + -- DROP COLUMN; the baseline targets a recent SQLite, and the + -- migration-parity test only checks ids (the SQL may differ between + -- dialects). If a future older engine lacks DROP COLUMN this ALTER is + -- the one statement to special-case; the column is read nowhere after + -- this migration, so leaving it would be a harmless dead column. + alter table visitor_auth_config + drop column protected_prefixes_json; + `, + }, + { + id: '028_visitor_profile_fields', + sql: ` + -- ─── Visitor custom profile fields (per-visitor-data framework) ──── + -- + -- Adds a JSON column to visitor_users storing the VALUES of site- + -- builder-defined custom profile fields (e.g. schoolName), keyed by + -- field id. The field DEFINITIONS live in visitor_auth_config + -- (profile_fields_json, added below) and mirror the DataField[] shape + -- used by data_tables.fields_json. This mirrors how data_tables stores + -- field definitions (fields_json) — same pattern, smaller surface. + -- + -- Values default to '{}' so every visitor row is valid immediately. + -- Extraction at read time uses the json_extract() helpers; the + -- _json-suffix adapter auto-parses the column into an object on read. + + alter table visitor_users + add column profile_fields_json text not null default '{}'; + + -- Store the site-builder-configured profile field DEFINITIONS on the + -- single visitor_auth_config row. A DataField[] JSON array; default + -- '[]' = no custom profile fields (the pre-framework behaviour). + alter table visitor_auth_config + add column profile_fields_json text not null default '[]'; + `, + }, + { + id: '029_visitor_owned_data', + sql: ` + -- ─── Per-visitor owned data (visitor-data framework, Pillar 3) ──────── + -- + -- Links a data row to the visitor who owns it (e.g. a tender they + -- submitted). FK visitor_users with ON DELETE SET NULL so deleting a + -- visitor account retains the row for audit but unlinks it. Indexed + -- for efficient per-visitor queries. data_tables.captures_visitor_owner + -- is a per-table opt-in flag (0/1) — the form handler stamps + -- visitor_user_id only when the target table opts in, so unrelated + -- tables are untouched. + + alter table data_rows + add column visitor_user_id text references visitor_users(id) on delete set null; + + create index if not exists data_rows_table_visitor_idx + on data_rows (table_id, visitor_user_id, updated_at desc); + + alter table data_tables + add column captures_visitor_owner integer not null default 0; + `, + }, ] diff --git a/server/email/transport.ts b/server/email/transport.ts new file mode 100644 index 000000000..94f1f4b88 --- /dev/null +++ b/server/email/transport.ts @@ -0,0 +1,72 @@ +/** + * Email transport seam — a single process-wide singleton the visitor-auth + * `/forgot` flow (and, later, any other outbound mail) hands a message to. + * + * The default is `ConsoleEmailTransport`, which logs the message (reset link + * included) to stdout. That is enough to run the reset flow end-to-end in dev + * and in a self-hosted install without an external mail provider — the + * operator can copy the logged link directly. + * + * A real transport (SMTP / Resend / Postmark / SES) is a follow-up: wire it + * at boot via `configureEmailTransport(new SmtpTransport(...))`. No caller + * under `server/visitor-auth/` reaches past this seam, so swapping the + * transport never touches the visitor-auth code. + * + * The interface is intentionally minimal (a single `send`): no queue, no + * retries, no templates. The visitor-auth handler owns the subject/body; the + * transport owns delivery. A `send` rejection is the transport's problem to + * surface — callers log the failure and continue (never exposing it to the + * client; see the `/forgot` handler). + */ + +/** The payload a caller hands to a transport. */ +export interface SendEmailInput { + to: string + subject: string + text: string + /** Optional HTML body. Transports that can't render HTML fall back to `text`. */ + html?: string +} + +/** + * Minimal transport contract. `name` is surfaced in logs so an operator can + * confirm which transport is active without reading boot code. + */ +export interface EmailTransport { + readonly name: string + send(input: SendEmailInput): Promise +} + +/** + * Default transport — logs the message to stdout. The reset link (or any + * other URL embedded in `text`) is printed verbatim so a dev/operator can + * copy/paste it. No network, no config — it always works. + */ +export class ConsoleEmailTransport implements EmailTransport { + readonly name = 'console' + + async send(input: SendEmailInput): Promise { + // One block, newline-separated, prefixed with the recipient so it's + // greppable in a shared log stream. + console.log( + `[email:${input.to}] ${input.subject}\n${input.text}` + + (input.html ? `\n[html] ${input.html}` : ''), + ) + } +} + +let activeTransport: EmailTransport = new ConsoleEmailTransport() + +/** The active transport — visitors-auth `/forgot` reads through this. */ +export function getEmailTransport(): EmailTransport { + return activeTransport +} + +/** + * Replace the active transport. Intended for boot-time wiring of a real + * transport (SMTP / Resend / Postmark / SES) and for tests that want to + * capture sent messages. Not concurrency-guarded — set once at boot. + */ +export function configureEmailTransport(transport: EmailTransport): void { + activeTransport = transport +} diff --git a/server/forms/handler.ts b/server/forms/handler.ts index 6e121d979..59de793b6 100644 --- a/server/forms/handler.ts +++ b/server/forms/handler.ts @@ -10,6 +10,8 @@ import { readValidatedBody, } from '../http' import { createDataRow, getDataTable } from '../repositories/data' +import { validateVisitorSession } from '../visitor-auth/sessions' +import { findVisitorUserById } from '../visitor-auth/repositories' import { getLatestPublishedSiteSnapshot } from '../repositories/publish' import { PublicFormChallengeBodySchema, @@ -133,10 +135,24 @@ async function handleSubmit(req: Request, db: DbClient): Promise { return jsonResponse({ error: 'Invalid form values', errors: validation.errors }, { status: 400 }) } + // Per-visitor-data framework (Pillar 3): when the target table opts in, + // stamp the row with the submitting visitor's id — resolved SOLELY from the + // validated session cookie (IDOR-safe). No visitor id is ever read from the + // form body. Anonymous submits (no opt-in or no session) are unchanged. + let visitorUserId: string | undefined + if (table.capturesVisitorOwner) { + const session = await validateVisitorSession(db, req) + if (session) { + const visitor = await findVisitorUserById(db, session.userId) + if (visitor) visitorUserId = visitor.id + } + } + const row = await createDataRow(db, { tableId: table.id, cells: validation.cells, slug: '', + ...(visitorUserId ? { visitorUserId } : {}), }) return jsonResponse({ ok: true, rowId: row.id }) } diff --git a/server/handlers/cms/components.ts b/server/handlers/cms/components.ts index 0b000830f..7e48f7df7 100644 --- a/server/handlers/cms/components.ts +++ b/server/handlers/cms/components.ts @@ -1,10 +1,14 @@ /** * Visual Components read endpoint backed by `data_rows` (table_id = 'components'). * - * GET /admin/api/cms/components — list all non-deleted component rows as - * DataRow[] (gated by `site.read`). The client - * adapter converts these to VisualComponent[] - * via visualComponentFromRow + validateVisualComponents. + * GET /admin/api/cms/components — list all non-deleted component rows + * as DataRow[] (gated by `site.read`). + * The client adapter converts these to + * VisualComponent[] via + * visualComponentFromRow + validateVisualComponents. + * GET /admin/api/cms/components?id=X — the single row X (empty `rows` when + * deleted or not a component) — the + * conflict banner's "Load theirs" fetch. * * The response returns raw DataRow objects (not VisualComponent objects) so * the client adapter can reconstruct VCs via visualComponentFromRow without a @@ -16,9 +20,8 @@ */ import type { DbClient } from '../../db/client' import { requireCapability } from '../../auth/authz' -import { listDataRows } from '../../repositories/data' -import { jsonResponse, methodNotAllowed } from '../../http' -import { CMS_API_PREFIX } from './shared' +import { methodNotAllowed } from '../../http' +import { CMS_API_PREFIX, siteCollectionRowsResponse } from './shared' export async function handleComponentsRoutes(req: Request, db: DbClient): Promise { const url = new URL(req.url) @@ -28,6 +31,5 @@ export async function handleComponentsRoutes(req: Request, db: DbClient): Promis const user = await requireCapability(req, db, 'site.read') if (user instanceof Response) return user - const rows = await listDataRows(db, 'components') - return jsonResponse({ rows }) + return siteCollectionRowsResponse(db, 'components') } diff --git a/server/handlers/cms/dashboard/index.ts b/server/handlers/cms/dashboard/index.ts index b63601c2c..6c7af081a 100644 --- a/server/handlers/cms/dashboard/index.ts +++ b/server/handlers/cms/dashboard/index.ts @@ -3,6 +3,7 @@ * * GET /admin/api/cms/dashboard/pages * GET /admin/api/cms/dashboard/posts + * GET /admin/api/cms/dashboard/members * GET /admin/api/cms/dashboard/media * GET /admin/api/cms/dashboard/plugins * GET /admin/api/cms/dashboard/storage @@ -45,6 +46,7 @@ import { CMS_API_PREFIX, type CmsHandlerOptions } from '../shared' import { resolveTimeZone } from '../../../time' import { readPagesStats } from './pages' import { readPostsStats } from './posts' +import { readMembersStats } from './members' import { readMediaStats } from './media' import { readPluginsStats } from './plugins' import { readPublishLineup } from './publishLineup' @@ -83,10 +85,11 @@ interface DashboardEndpoint { // a new widget is "new reader file + one row here". The capability // rules: // -// pages / posts / publish-lineup / storage +// pages / posts / publish-lineup / storage / members // Non-sensitive totals or paths the visitor could // hit on the public site anyway. Any authenticated -// user can read. +// user can read. (`members` is a registered-visitor +// count — a pure total with no PII, same policy.) // // media Library thumbnails are part of the asset surface; // gate matches `/media` list (`media.read`). @@ -103,6 +106,7 @@ interface DashboardEndpoint { const DASHBOARD_READERS: Record = { 'pages': { reader: readPagesStats, capability: null }, 'posts': { reader: readPostsStats, capability: null }, + 'members': { reader: readMembersStats, capability: null }, 'media': { reader: readMediaStats, capability: 'media.read' }, 'plugins': { reader: readPluginsStats, capability: 'plugins.read' }, 'storage': { reader: readStorageStats, capability: null }, diff --git a/server/handlers/cms/dashboard/members.ts b/server/handlers/cms/dashboard/members.ts new file mode 100644 index 000000000..5cd9132e7 --- /dev/null +++ b/server/handlers/cms/dashboard/members.ts @@ -0,0 +1,127 @@ +/** + * Members widget reader — total active registered visitors + a dense + * 28-day registration histogram (one bucket per calendar day of new + * sign-ups) plus the visitor-auth toggle states so the widget can show + * whether auth / registration are currently on. + * + * Mirrors `readPostsStats`'s shape (count + trailing-28-day histogram) + * but against the `visitor_users` table instead of `data_rows`. + * + * Self-contained on purpose: the `visitor_*` tables are owned by the + * visitor-auth module (`server/visitor-auth/*`), which another agent + * edits in parallel. Importing its repository layer would couple this + * dashboard reader to a file that may move under us, so the three small + * queries here hit `visitor_users` / `visitor_auth_config` directly. The + * column names are stable (migration-locked snake_case), and the bucket + * math reuses the same timezone-aware helpers as `posts.ts`. + */ +import type { DbClient } from '../../../db/client' +import { localDayKeyFactory } from '../../../time' +import { coerceCount } from './shared' +import type { DashboardRequestContext, MembersStats } from './types' + +const TWENTY_EIGHT_DAYS_MS = 28 * 24 * 60 * 60 * 1000 +const DAY_MS = 24 * 60 * 60 * 1000 +const HISTOGRAM_DAYS = 28 + +/** + * `visitor_auth_config` ships a single seeded `'default'` row; a missing + * row (fresh install before the seed migration, or a hand-deleted row) + * resolves to "disabled / registration open" — the same defaults the + * visitor-auth reader falls back to (`DEFAULT_VISITOR_AUTH_CONFIG`). + * Kept inline here so this reader has zero runtime coupling to the + * visitor-auth module's in-memory config cache. + */ +const MISSING_CONFIG = { authEnabled: false, registrationOpen: true } as const + +export async function readMembersStats( + db: DbClient, + _options: unknown, + ctx: DashboardRequestContext, +): Promise { + const dayKeyOf = localDayKeyFactory(ctx.timeZone) + const sinceIso = new Date(Date.now() - TWENTY_EIGHT_DAYS_MS).toISOString() + + // Total count + the histogram source rows + the auth config read are + // independent — fan them out in parallel so the endpoint resolves at + // the slowest query, not their sum. + const [total, registrations, config] = await Promise.all([ + readActiveVisitorTotal(db), + readRecentRegistrations(db, sinceIso), + readAuthConfig(db), + ]) + + // Bin each `created_at` into the viewer's local calendar day, then + // densify into [28] oldest-first — identical to `readPostsStats`'s + // trailing-28-day fill so the widget renders bars without gaps and + // "today" lines up with the operator's day, not UTC's. + const counts = new Map() + for (const createdAt of registrations) { + const day = dayKeyOf(createdAt) + counts.set(day, (counts.get(day) ?? 0) + 1) + } + const daily28 = Array.from({ length: HISTOGRAM_DAYS }, (_, i) => { + const d = new Date(Date.now() - (HISTOGRAM_DAYS - 1 - i) * DAY_MS) + return counts.get(dayKeyOf(d)) ?? 0 + }) + + const { authEnabled, registrationOpen } = config ?? MISSING_CONFIG + return { total, daily28, authEnabled, registrationOpen } +} + +/** + * Count active (non-soft-deleted) visitors — the widget's headline number. + * Mirrors the `deleted_at is null` guard every visitor-auth read uses, so + * soft-deleted / GDPR-anonymized rows never inflate the total. + */ +async function readActiveVisitorTotal(db: DbClient): Promise { + const { rows } = await db<{ count: number | string }>` + select count(*) as count + from visitor_users + where deleted_at is null + ` + return coerceCount(rows[0]?.count) +} + +/** + * Raw `created_at` rows for every active visitor who registered inside the + * 28-day window. The caller bins them per local calendar day client-side — + * the day boundary depends on the viewer's timezone, which the database + * can't know (see `server/time.ts` for the full rationale). Cardinality is + * bounded by the trailing-28-day window, comfortably small. + */ +async function readRecentRegistrations( + db: DbClient, + sinceIso: string, +): Promise> { + const { rows } = await db<{ created_at: string | Date }>` + select created_at + from visitor_users + where deleted_at is null + and created_at >= ${sinceIso} + ` + return rows.map((r) => r.created_at) +} + +/** + * Read the visitor-auth toggles straight from the single config row. + * Returns `null` when the row is absent so the caller falls back to the + * "disabled" default. SQLite stores booleans as 0/1 ints; both adapters + * may hand back a number here, so coerce via `Boolean()` the same way the + * visitor-auth reader's `rowToConfig` does. + */ +async function readAuthConfig( + db: DbClient, +): Promise<{ authEnabled: boolean; registrationOpen: boolean } | null> { + const { rows } = await db<{ enabled: boolean | number; registration_open: boolean | number }>` + select enabled, registration_open + from visitor_auth_config + limit 1 + ` + const row = rows[0] + if (!row) return null + return { + authEnabled: Boolean(row.enabled), + registrationOpen: Boolean(row.registration_open), + } +} diff --git a/server/handlers/cms/dashboard/types.ts b/server/handlers/cms/dashboard/types.ts index 4aec69af6..a7302beba 100644 --- a/server/handlers/cms/dashboard/types.ts +++ b/server/handlers/cms/dashboard/types.ts @@ -54,6 +54,31 @@ export interface PostsStats { daily28: number[] } +// --------------------------------------------------------------------------- +// Members (registered visitors) +// --------------------------------------------------------------------------- + +/** + * Members widget payload. Mirrors the Posts shape (count + trailing-28-day + * histogram) but against `visitor_users` registrations, plus the two + * visitor-auth toggles so the widget can show whether auth / registration + * are currently on. + * + * • `total` — active (non-soft-deleted) visitor count. + * • `daily28` — new registrations per local calendar day for + * the last 28 days, oldest first. + * • `authEnabled` — `visitor_auth_config.enabled`; `false` when the + * row is absent (disabled default). + * • `registrationOpen` — `visitor_auth_config.registration_open`; `true` + * when the row is absent (open default). + */ +export interface MembersStats { + total: number + daily28: number[] + authEnabled: boolean + registrationOpen: boolean +} + // --------------------------------------------------------------------------- // Media // --------------------------------------------------------------------------- diff --git a/server/handlers/cms/data/preview.ts b/server/handlers/cms/data/preview.ts index 57644952a..01e0de87b 100644 --- a/server/handlers/cms/data/preview.ts +++ b/server/handlers/cms/data/preview.ts @@ -103,20 +103,21 @@ export async function handleRowPreview( // resolution, etc.) operate against this seed. const draftPublishedRow: PublishedDataRow = synthesisePublishedRow(row, table, draftCells) - const [loopData, mediaAssets] = await Promise.all([ - prefetchLoopData(merged, snapshot.site, db), - prefetchMediaAssets(merged, snapshot.site, registry, db), - ]) - const cssBundle = buildSiteCssBundle(snapshot.site, registry, merged, { mediaAssets }) - const publicPath = buildEntryPublicPath(table.routeBase, draftPublishedRow.slug) const syntheticUrl = new URL(`http://localhost${publicPath}`) + const templateContext = { + entryStack: [publishedDataRowToLoopItem(draftPublishedRow)], + route: buildRouteFrame(syntheticUrl.toString()), + } + const loopData = await prefetchLoopData(merged, snapshot.site, db) + const mediaAssets = await prefetchMediaAssets(merged, snapshot.site, registry, db, { + templateContext, + loopData, + }) + const cssBundle = buildSiteCssBundle(snapshot.site, registry, merged, { mediaAssets }) const published = publishPage(merged, snapshot.site, registry, { - templateContext: { - entryStack: [publishedDataRowToLoopItem(draftPublishedRow)], - route: buildRouteFrame(syntheticUrl.toString()), - }, + templateContext, runtimeAssets: snapshot.runtimeAssets, runtimePackageImportmap: snapshot.runtimePackageImportmap, cssEmission: 'external', diff --git a/server/handlers/cms/data/rows.ts b/server/handlers/cms/data/rows.ts index b9cf7a2ce..bf06bb15b 100644 --- a/server/handlers/cms/data/rows.ts +++ b/server/handlers/cms/data/rows.ts @@ -36,9 +36,9 @@ import { updateDataRowTable, } from '../../../repositories/data' import { publishDataRow, removeDataRowArtefact } from '../../../publish/publishRow' +import { runPublishFlush } from '../../../publish/publishFlush' import { findUserById } from '../../../repositories/users' import { slugForTable } from '@core/data/cells' -import { lockedBuiltInCellKey } from '@core/data/systemTableGuard' import { badRequest, jsonResponse, readValidatedBody } from '../../../http' import { bumpPublishVersionSerialized } from '../../../publish/publishState' import type { CmsHandlerOptions } from '../shared' @@ -168,16 +168,6 @@ async function handleRowItemPatch( const table = await getDataTable(db, currentRow.tableId) if (!table) return rowNotFound() - // Built-in field values on structural system tables (pages/components/ - // layouts) are editor-managed — reject hand-edits through the Data grid. - // The site editor writes those trees via its own endpoints, not here. - if (body.cells) { - const locked = lockedBuiltInCellKey(table, body.cells) - if (locked) { - return badRequest(`The "${locked}" field is managed by the editor and can't be edited here.`) - } - } - const rawCells = body.cells ?? currentRow.cells // Run the `content.entry.cells` filter pipeline before persistence so // plugins can validate / normalize / auto-fill cells — the same shared @@ -266,6 +256,12 @@ async function handleRowSchedulePost( const user = await requireDataPublisher(req, db) if (user instanceof Response) return user + // Flush the collab relay before reading the row, exactly as `publishDataRow` + // does. A page created or edited in the visual editor lives in the relay's + // in-memory doc until the persist debounce elapses, so scheduling one right + // after creating it would otherwise 404 with "Data row not found". + await runPublishFlush() + const currentRow = await loadRowForAccess(db, rowId, user, canPublishDataRow) if (currentRow instanceof Response) return currentRow diff --git a/server/handlers/cms/data/tables.ts b/server/handlers/cms/data/tables.ts index 6fa24c678..7f7d3ba9a 100644 --- a/server/handlers/cms/data/tables.ts +++ b/server/handlers/cms/data/tables.ts @@ -59,7 +59,10 @@ import { requireDataCreator, requireDataTablesRead, } from './access' -import { assertSystemTableUpdateAllowed, lockedBuiltInCellKey } from '@core/data/systemTableGuard' +import { + assertSystemTableUpdateAllowed, + protectedBuiltInCreateCellKey, +} from '@core/data/systemTableGuard' import { requireStepUp } from '../../../auth/authz' // --------------------------------------------------------------------------- @@ -308,7 +311,7 @@ async function handleTableRows( // Editor-managed built-in values can't be set through the Data grid. if (body.cells) { - const locked = lockedBuiltInCellKey(table, body.cells) + const locked = protectedBuiltInCreateCellKey(table, body.cells) if (locked) { return badRequest(`The "${locked}" field is managed by the editor and can't be set here.`) } diff --git a/server/handlers/cms/gate.ts b/server/handlers/cms/gate.ts new file mode 100644 index 000000000..5ad98c83f --- /dev/null +++ b/server/handlers/cms/gate.ts @@ -0,0 +1,176 @@ +/** + * `/_instatic/gate/` endpoint — Layer C auth-gated server island. + * + * A `base.container` whose `authGate` prop is a non-empty group-id list + * (Phase 3 / D16) publishes as an `` placeholder carrying + * the module's `staticPlaceholder` fallback + the required group list. The + * gate runtime lazy-fetches this endpoint; it renders the FULL subtree only + * for visitors whose session is a member of at least one of the required + * groups and returns the baked fallback for everyone else. + * + * Mirrors `server/handlers/cms/hole.ts` closely — same snapshot lookup, same + * version check, same fragment renderer (`renderHoleFragment`). Differences: + * + * - Reads the visitor session cookie via `gateHelpers.checkGateAccess`. + * - Determines `requiredGroups` from `node.props.authGate` (an array of + * group ids; an empty/missing array defaults to no gate — fail-open so a + * stale published node with the old role-string shape is treated as + * not-gated rather than locking out everyone). + * - UNAUTHORISED → returns the sanitised `staticPlaceholder` fallback (the + * same string baked into the placeholder). Never renders the subtree. + * - Always `Cache-Control: no-store` — the response is per-visitor, so it + * can never be shared-cached (unlike shared holes that go through Layer B). + * + * Version-awareness matches hole.ts: a `?v=` mismatch returns the same stale + * sentinel so the next page load picks up the new version. + */ + +import type { DbClient } from '../../db/client' +import type { PageNode } from '@core/page-tree' +import { registry } from '@core/module-engine' +import { sanitizeRichtext } from '@core/sanitize' +import { buildPageFrame, buildRouteFrame } from '@core/templates/contextFrames' +import { renderHoleFragment } from './hole' +import { findPageForNodeId, getPublishedNodeIndexForVersion } from '../../publish/publishedSnapshotCache' +import { getPublishVersion } from '../../publish/publishState' +import { checkGateAccess } from '../../visitor-auth/gateHelpers' + +export const GATE_PATH_PREFIX = '/_instatic/gate/' + +interface GateHandlerContext { + db: DbClient +} + +/** + * Render a single auth-gated node subtree for Layer C gate hydration. + * + * GET `/_instatic/gate/?v=&u=` → HTML fragment. + */ +export async function handleGateRequest( + req: Request, + url: URL, + ctx: GateHandlerContext, +): Promise { + if (req.method !== 'GET') { + return new Response('Method not allowed', { + status: 405, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + + const nodeId = decodeURIComponent(url.pathname.slice(GATE_PATH_PREFIX.length)) + if (!nodeId) { + return new Response('Missing node id', { + status: 400, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + + // Version check — identical to hole.ts. A mismatch returns the lightweight + // stale sentinel so the next full page load (carrying the new version) + // hydrates cleanly. + const requestVersion = url.searchParams.get('v') ?? '' + const currentVersion = getPublishVersion() + if (requestVersion !== String(currentVersion)) { + return new Response('', { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + } + + // Load (memoised) snapshot for this version and find the node's page in O(1). + const snap = await getPublishedNodeIndexForVersion(ctx.db, currentVersion) + if (!snap) { + return new Response('Site not published', { + status: 404, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + const found = findPageForNodeId(snap, nodeId) + if (!found) { + return new Response('Node not found', { + status: 404, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + // Template composition prefixes node ids (c0_/t_) on wrapped pages; + // use the effective (non-composed) id for node lookup + rendering. + const foundPage = found.page + const effectiveNodeId = found.effectiveNodeId + const node: PageNode | undefined = foundPage.nodes[effectiveNodeId] + if (!node) { + return new Response('Node not found', { + status: 404, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + + // Resolve the required groups off the published node. The dynamic-detection + // rule only routes here when the prop is a non-empty group-id array, so a + // missing/non-array prop on the published node implies a stale shape + // (the Phase-2 role-string value) — treat it as not-gated (fail-open) so a + // half-migrated publish doesn't lock out every visitor. + const requiredGroups = Array.isArray(node.props.authGate) + ? node.props.authGate.filter((g): g is string => typeof g === 'string') + : [] + + // Authorisation decision — single source of truth lives in gateHelpers. + const auth = await checkGateAccess(ctx.db, req, requiredGroups) + + // Reconstruct the originating page URL forwarded by the runtime (`u`). Falls + // back to the page's own permalink when absent (older runtime / direct hit). + const pageUrlRaw = url.searchParams.get('u') ?? buildPageFrame(foundPage).permalink + let pageUrl: URL + try { + pageUrl = new URL(pageUrlRaw, url.origin) + } catch { + pageUrl = new URL(buildPageFrame(foundPage).permalink, url.origin) + } + + // Resolve the request-time route frame (path / slug / query) off the + // originating page URL — same construction hole.ts uses so any + // `route.query.*` / `route.slug` bindings inside the gated subtree resolve. + const route = buildRouteFrame(pageUrl.toString()) + + // UNAUTHORISED — bake the fallback. This is the SAME string the placeholder + // carries at publish time (the module's `staticPlaceholder`, sanitised), so + // the swap is a no-op visually for unauthorised visitors with JS, and the + // no-JS path already shows it. Never render the gated subtree. + if (!auth.authorized) { + const def = registry.get(node.moduleId) + const rawFallback = def?.staticPlaceholder?.(node.props as never) ?? '' + const fallback = rawFallback ? sanitizeRichtext(rawFallback) : '' + return new Response(fallback, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + } + + // AUTHORISED — render the full subtree at request time via the shared + // fragment renderer (same path holes use). The visitor's query string seeds + // the route frame so any `route.query.*` bindings inside the gated subtree + // resolve; cookies are intentionally empty for the shared render path + // (mirrors hole.ts's shared-hole contract — gated subtrees render the SAME + // fragment for every authorised visitor of a role, so no per-visitor data + // leaks between caches). + const query: Record = Object.fromEntries(pageUrl.searchParams) + const html = await renderHoleFragment(effectiveNodeId, foundPage, snap.site, ctx.db, pageUrl, { + query, + path: route.path, + slug: route.slug, + cookies: {}, + }) + + return new Response(html, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} diff --git a/server/handlers/cms/hole.ts b/server/handlers/cms/hole.ts index 79736e7db..446a7394b 100644 --- a/server/handlers/cms/hole.ts +++ b/server/handlers/cms/hole.ts @@ -43,7 +43,7 @@ import { renderNode, type RenderConfig, type RenderAccumulators } from '@core/pu import { buildPageFrame, buildRouteFrame, buildSiteFrame } from '@core/templates/contextFrames' import { prefetchLoopData } from '../../publish/loopPrefetch' import { getOrRender } from '../../publish/renderCache' -import { getPublishedNodeIndexForVersion } from '../../publish/publishedSnapshotCache' +import { findPageForNodeId, getPublishedNodeIndexForVersion } from '../../publish/publishedSnapshotCache' import { getPublishVersion } from '../../publish/publishState' import { HOLE_RUNTIME_JS } from '../../publish/holeRuntime' import { stampFormPageTokens } from '../../forms/formRuntime' @@ -115,8 +115,13 @@ function isPerVisitorHole(node: PageNode): boolean { * Render one node subtree at request time. Builds the same named frames the * full-page publisher builds (route/page/site) plus pre-fetched loop data for * loops INSIDE this subtree, then renders fully (no `` recursion). + * + * Shared by the hole endpoint (`/_instatic/hole/`) and the auth-gate + * endpoint (`/_instatic/gate/`) — both render a request-time fragment + * for a single node subtree, so the frame/prefetch/render/stamp pipeline is + * identical. */ -async function renderHoleFragment( +export async function renderHoleFragment( nodeId: string, page: Page, site: SiteDocument, @@ -203,14 +208,18 @@ export async function handleHoleRequest( headers: { 'content-type': 'text/plain; charset=utf-8' }, }) } - const foundPage = snap.nodeIndex.get(nodeId) - if (!foundPage) { + const found = findPageForNodeId(snap, nodeId) + if (!found) { return new Response('Node not found', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' }, }) } - const node = foundPage.nodes[nodeId]! + // Template composition prefixes node ids (c0_/t_) on wrapped pages; + // use the effective (non-composed) id for node lookup + rendering. + const foundPage = found.page + const effectiveNodeId = found.effectiveNodeId + const node = foundPage.nodes[effectiveNodeId]! // Reconstruct the originating page URL forwarded by the runtime (`u`). Falls // back to the page's own permalink when absent (older runtime / direct hit). @@ -236,7 +245,7 @@ export async function handleHoleRequest( slug: route.slug, cookies: parseCookies(req.headers.get('cookie')), } - const html = await renderHoleFragment(nodeId, foundPage, snap.site, ctx.db, pageUrl, request) + const html = await renderHoleFragment(effectiveNodeId, foundPage, snap.site, ctx.db, pageUrl, request) return new Response(html, { status: 200, headers: { @@ -261,7 +270,7 @@ export async function handleHoleRequest( queryString: `v=${currentVersion}&${normalizeQuery(pageUrl.searchParams)}`, }, async () => { - const html = await renderHoleFragment(nodeId, foundPage, snap.site, ctx.db, pageUrl, request) + const html = await renderHoleFragment(effectiveNodeId, foundPage, snap.site, ctx.db, pageUrl, request) return { body: html, headers: { 'content-type': 'text/html; charset=utf-8' }, diff --git a/server/handlers/cms/index.ts b/server/handlers/cms/index.ts index 145868b4a..0bbb00f84 100644 --- a/server/handlers/cms/index.ts +++ b/server/handlers/cms/index.ts @@ -35,6 +35,7 @@ import { handleAuthRoutes } from './auth' import { handleMeRoutes } from './me' import { handleUserPreferencesRoutes } from './userPreferences' import { handleUsersRoutes } from './users' +import { handleVisitorAuthAdminRoutes } from './visitorAuth' import { handleRolesRoutes } from './roles' import { handleAuditRoutes } from './audit' import { handleSiteRoutes } from './site' @@ -85,6 +86,10 @@ export async function handleCmsRequest( // account" surface. Routes mount under `/admin/api/cms/me/preferences/`. ?? (await handleUserPreferencesRoutes(req, db)) ?? (await handleUsersRoutes(req, db)) + // Visitor-auth admin management (config + visitor user table). Gated by + // `users.manage`; runs right after the admin users routes so the + // `/visitor-auth/users/*` namespace stays adjacent to admin users. + ?? (await handleVisitorAuthAdminRoutes(req, db)) ?? (await handleRolesRoutes(req, db)) ?? (await handleAuditRoutes(req, db)) ?? (await handleSiteRoutes(req, db)) diff --git a/server/handlers/cms/layouts.ts b/server/handlers/cms/layouts.ts index 6bb3c8b92..cb42d825e 100644 --- a/server/handlers/cms/layouts.ts +++ b/server/handlers/cms/layouts.ts @@ -1,10 +1,14 @@ /** * Saved-layout read endpoint backed by `data_rows` (table_id = 'layouts'). * - * GET /admin/api/cms/layouts — list all non-deleted layout rows as - * DataRow[] (gated by `site.read`). The client - * adapter converts these to SavedLayout[] - * via savedLayoutFromRow + validateSavedLayouts. + * GET /admin/api/cms/layouts — list all non-deleted layout rows as + * DataRow[] (gated by `site.read`). The + * client adapter converts these to + * SavedLayout[] via savedLayoutFromRow + + * validateSavedLayouts. + * GET /admin/api/cms/layouts?id=X — the single row X (empty `rows` when + * deleted or not a layout) — the conflict + * banner's "Load theirs" fetch. * * The response returns raw DataRow objects (not SavedLayout objects) so the * client adapter can reconstruct layouts via savedLayoutFromRow without a @@ -16,9 +20,8 @@ */ import type { DbClient } from '../../db/client' import { requireCapability } from '../../auth/authz' -import { listDataRows } from '../../repositories/data' -import { jsonResponse, methodNotAllowed } from '../../http' -import { CMS_API_PREFIX } from './shared' +import { methodNotAllowed } from '../../http' +import { CMS_API_PREFIX, siteCollectionRowsResponse } from './shared' export async function handleLayoutsRoutes(req: Request, db: DbClient): Promise { const url = new URL(req.url) @@ -28,6 +31,5 @@ export async function handleLayoutsRoutes(req: Request, db: DbClient): Promise { const url = new URL(req.url) @@ -28,6 +31,5 @@ export async function handlePagesRoutes(req: Request, db: DbClient): Promise { + return jsonResponse({ rows: await listDataRows(db, tableId) }) +} + export function mutationErrorResponse(err: unknown): Response { - if (err instanceof UserMutationError || err instanceof RoleMutationError) { + if ( + err instanceof UserMutationError || + err instanceof RoleMutationError || + err instanceof VisitorRoleMutationError || + err instanceof VisitorGroupMutationError + ) { return jsonResponse({ error: err.message }, { status: err.status }) } throw err diff --git a/server/handlers/cms/site.ts b/server/handlers/cms/site.ts index 8e61982a6..b14f7c5ec 100644 --- a/server/handlers/cms/site.ts +++ b/server/handlers/cms/site.ts @@ -2,8 +2,10 @@ * Draft-site shell read endpoint. * * GET /admin/api/cms/site — load the draft site shell (gated by `site.read`). - * Returns the SiteShell without pages; the client - * adapter fetches pages separately via GET /pages. + * Returns `{ site, seq }`: the SiteShell without + * pages plus the shell's sync seq (the client's + * conflict-detection base). Pages are fetched + * separately via GET /pages. * * Writes go through the transactional site-document save * (PUT /admin/api/cms/site-document — see ./siteDocument.ts), which persists @@ -11,7 +13,7 @@ */ import type { DbClient } from '../../db/client' import { requireCapability } from '../../auth/authz' -import { getDraftSite } from '../../repositories/site' +import { getDraftSite, getDraftSiteSeq } from '../../repositories/site' import { jsonResponse, methodNotAllowed } from '../../http' export async function handleSiteRoutes(req: Request, db: DbClient): Promise { @@ -24,5 +26,5 @@ export async function handleSiteRoutes(req: Request, db: DbClient): Promise @@ -173,6 +209,13 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom const shell = validateSite(body.site) validateSiteWriteDiff(previousShell, shell, user.capabilities) + // The shell ships with EVERY save, changed or not. Detecting "actually + // changed" here (CPU work, outside the transaction) lets phase 2 skip the + // shell write + seq stamp on row-only saves — which in turn keeps the + // shell seq an honest conflict signal (an unconditional stamp would 409 + // every concurrent save pair on the shell). + const shellChanged = previousShell === null || !shellsEqual(previousShell, shell) + const hasAllSiteCaps = user.capabilities.includes('site.structure.edit') && user.capabilities.includes('site.content.edit') && @@ -307,9 +350,50 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom let seq = 0 let deletedPublishedPage = false await db.transaction(async (tx) => { + // Allocate FIRST: the counter-row UPDATE takes a row lock, so two + // concurrent save transactions serialize here (on Postgres as well as + // SQLite's fully-serialized chain) — which makes the conflict reads + // below exact, not best-effort. seq = await allocateSiteSeq(tx) - await saveDraftSite(tx, shell, user.id) - await stampDraftSiteSeq(tx, seq) + + // Conflict check (incremental mode): any shipped row whose STORED seq + // is newer than the client's base — or that the client has no base + // entry for — would be a silent overwrite of another admin's work. + // Throwing rolls the transaction back, so nothing is written on 409. + if (body.mode === 'incremental') { + const conflicts: SaveConflict[] = [] + if (shellChanged) { + const storedShellSeq = await getDraftSiteSeq(tx) + if (storedShellSeq > body.shellBaseSeq) { + conflicts.push({ table: 'site', rowId: 'default', seq: storedShellSeq }) + } + } + const rowChecks = [ + { table: 'pages', ids: [...changedPageIdsRaw, ...pageDeleteIds] }, + { table: 'components', ids: [...changedComponentIds, ...componentDeleteIds] }, + { table: 'layouts', ids: [...changedLayoutIds, ...layoutDeleteIds] }, + ] as const + for (const { table, ids } of rowChecks) { + // listDataRowSeqs sees soft-deleted rows too: a remote deletion is + // a newer write, not absence. Rows with no stored counterpart are + // client creations and pass by construction (absent from the result). + for (const stored of await listDataRowSeqs(tx, table, ids)) { + const base = body.baseSeqs[stored.id] + if (base === undefined || stored.seq > base) { + conflicts.push({ table, rowId: stored.id, seq: stored.seq }) + } + } + } + if (conflicts.length > 0) throw new SaveConflictError(conflicts) + } + + // Shell write + seq stamp only when the shell content actually changed + // — see the shellChanged comment in phase 1. + if (shellChanged) { + // In-transaction — collab listeners are notified post-commit below. + await saveDraftSite(tx, shell, user.id, { collabInternal: true }) + await stampDraftSiteSeq(tx, seq) + } // Empty change sets skip their table entirely — a shell-only save // issues no row queries inside the transaction. if (componentWrites.length > 0 || componentDeleteIds.size > 0) { @@ -337,10 +421,25 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom // Deleting a published page retracts its public route — invalidate the // render cache AFTER the transaction commits (never inside it: the bump // serializes against the publish lock, which itself waits on the - // transaction chain). The multi-admin live-sync plan emits its site - // events from this point too. + // transaction chain). if (deletedPublishedPage) await bumpPublishVersionSerialized() + // Collab invalidation — this save wrote rows/shell OUTSIDE the relay, so + // affected CRDT documents must reset (post-commit; see rowWriteEvents). + if (shellChanged) notifyShellWrite() + const writtenGroups: Array<[string, Iterable, RowWriteKind]> = [ + ['pages', changedPageIdsRaw, 'update'], + ['pages', pageDeleteIds, 'delete'], + ['components', changedComponentIds, 'update'], + ['components', componentDeleteIds, 'delete'], + ['layouts', changedLayoutIds, 'update'], + ['layouts', layoutDeleteIds, 'delete'], + ] + for (const [tableId, ids, kind] of writtenGroups) { + const rowIds = [...ids] + if (rowIds.length > 0) notifyRowWrite({ tableId, rowIds, kind }) + } + return jsonResponse({ ok: true, seq }) } catch (err) { if (err instanceof SiteValidationError) return badRequest(err.message) @@ -350,6 +449,9 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom { status: 403 }, ) } + if (err instanceof SaveConflictError) { + return jsonResponse({ error: err.message, conflicts: err.conflicts }, { status: 409 }) + } throw err } } diff --git a/server/handlers/cms/visitorAuth.ts b/server/handlers/cms/visitorAuth.ts new file mode 100644 index 000000000..90bba339f --- /dev/null +++ b/server/handlers/cms/visitorAuth.ts @@ -0,0 +1,626 @@ +/** + * Visitor-auth admin endpoints (gated by `users.manage`). + * + * Lets an admin enable/disable visitor auth, configure protected prefixes, + * and manage registered visitor accounts (list, suspend/activate, change + * role, soft-delete). All under `/admin/api/cms/visitor-auth/*`. + * + * GET /admin/api/cms/visitor-auth/config — current visitor-auth config + * PUT /admin/api/cms/visitor-auth/config — save (merge-patch) the config + * GET /admin/api/cms/visitor-auth/roles — list visitor roles (for the + * role-picker in the users table) + * POST /admin/api/cms/visitor-auth/roles — create a custom visitor role + * PATCH /admin/api/cms/visitor-auth/roles/:id — rename / re-cap a visitor role + * DELETE /admin/api/cms/visitor-auth/roles/:id — delete a custom visitor role + * GET /admin/api/cms/visitor-auth/users — paginated visitor list + * (?search=&limit=&offset=) + * PATCH /admin/api/cms/visitor-auth/users/:id — update status / role / displayName + * DELETE /admin/api/cms/visitor-auth/users/:id — soft delete (+ revoke sessions) + * + * CSRF: the CMS entry point (`./index.ts`) already rejects state-changing + * requests whose Origin doesn't match before any route group runs, so the + * PUT/PATCH/DELETE handlers here are CSRF-protected by the time they execute. + * + * Capability reuse: visitor management is gated by `users.manage` (the same + * capability that gates admin user management). Adding a dedicated + * `visitorAuth.manage` capability is deferred — it would require editing the + * core capability list + every SYSTEM_ROLE grant, and for Phase 1 "can manage + * users → can manage visitor users" is the right call. + * + * Audit: role CRUD (POST/PATCH/DELETE /roles) is intentionally NOT audited + * in this task — the `AuditAction` union (`server/repositories/audit.ts`) + * has no `visitor_auth.role_*` actions and widening it is out of scope here. + * Visitor-user mutations (`visitor_auth.user_updated` / `_deleted`) are still + * audited below. Adding dedicated `visitor_auth.role_created` / + * `_updated` / `_deleted` actions is a tracked follow-up. + */ +import type { DbClient } from '../../db/client' +import { requireCapability } from '../../auth/authz' +import { createAuditEvent } from '../../repositories/audit' +import { badRequest, jsonResponse, readValidatedBody } from '../../http' +import { Type } from '@core/utils/typeboxHelpers' +import { + CMS_API_PREFIX, + UserStatusSchema, + mutationErrorResponse, + requestAuditContext, +} from './shared' +import { runRouteTable, type Route, type RouteParams } from './routeTable' +import { + getVisitorAuthConfig, + saveVisitorAuthConfig, +} from '../../visitor-auth/config' +import { + countVisitorUsers, + findVisitorUserById, + listVisitorUsers, + revokeAllVisitorSessionsForUser, + setVisitorUserStatus, + softDeleteVisitorUser, + updateVisitorUserDisplayName, + updateVisitorUserProfileFields, + updateVisitorUserRole, +} from '../../visitor-auth/repositories' +import { + createVisitorRole, + deleteVisitorRole, + findVisitorRoleById, + listVisitorRoles, + updateVisitorRole, +} from '../../visitor-auth/roles' +import { + addGroupsToVisitor, + createVisitorGroup, + deleteVisitorGroup, + findVisitorGroupById, + listGroupsForVisitor, + listMembershipsForGroup, + listVisitorGroups, + removeVisitorFromGroup, + setVisitorPrimaryGroup, + updateVisitorGroup, + type VisitorMembershipView, +} from '../../visitor-auth/groups' +import type { VisitorUser } from '../../visitor-auth/types' + +const PREFIX = `${CMS_API_PREFIX}/visitor-auth` + +// ─── Config ────────────────────────────────────────────────────────────────── + +const VisitorProfileFieldSchema = Type.Object({ + id: Type.String({ maxLength: 100 }), + label: Type.String({ maxLength: 200 }), + type: Type.Union([ + Type.Literal('text'), + Type.Literal('longText'), + Type.Literal('select'), + Type.Literal('boolean'), + ]), + required: Type.Optional(Type.Boolean()), + options: Type.Optional(Type.Array(Type.Object({ + value: Type.String({ maxLength: 100 }), + label: Type.String({ maxLength: 200 }), + }))), +}) + +const VisitorAuthConfigPatchSchema = Type.Partial(Type.Object({ + enabled: Type.Boolean(), + // D15: default landing path for a logged-in visitor with no primary-group + // landing. Replaces the retired Phase-1/2 `protectedPrefixes` (page access + // is now per-page, D14). A legacy `protectedPrefixes` field sent by an + // older admin UI is ignored silently — see handlePutConfig. + defaultLandingPath: Type.String({ maxLength: 200 }), + loginPath: Type.String({ maxLength: 200 }), + registrationOpen: Type.Boolean(), + defaultRole: Type.String({ maxLength: 100 }), + // Per-visitor-data framework: site-builder-defined custom profile field + // DEFINITIONS (DataField[]-shape). Stored on visitor_auth_config and + // mirrored on each visitor's profile_fields_json by id. Passed straight + // through to saveVisitorAuthConfig (config.ts normalises/validates). + profileFields: Type.Array(VisitorProfileFieldSchema), +})) + +/** + * Validate + clean a landing path. Returns the trimmed value, or `null` when + * it doesn't start with `/` (keeps the login-redirect resolver sane; a bare + * "members" would be treated as a relative path). Only call this when the + * caller has already confirmed `defaultLandingPath !== undefined`. + */ +function normalizeLandingPath(raw: string): string | null { + const cleaned = raw.trim() + if (!cleaned.startsWith('/')) return null + return cleaned +} + +async function handleGetConfig(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const config = await getVisitorAuthConfig(db) + return jsonResponse({ config }) +} + +async function handlePutConfig(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + + const body = await readValidatedBody(req, VisitorAuthConfigPatchSchema) + if (!body) return badRequest('invalid_request') + + // D14 retired `protectedPrefixes` (page access is now per-page). An older + // admin UI may still send it briefly — ignore it silently with a one-line + // deprecation rather than erroring, so the upgrade path is smooth. + if ((body as Record).protectedPrefixes !== undefined) { + console.warn('[visitor-auth] ignoring deprecated protectedPrefixes in config save') + } + + const patch = { ...body } + // `defaultRole` must resolve to a real visitor role when supplied. + if (body.defaultRole !== undefined) { + const byName = await listVisitorRoles(db) + const exists = byName.some((r) => r.id === body.defaultRole || r.name === body.defaultRole) + if (!exists) return badRequest('unknown defaultRole') + } + if (body.defaultLandingPath !== undefined) { + const landing = normalizeLandingPath(body.defaultLandingPath) + if (landing === null) { + return badRequest('defaultLandingPath must start with "/"') + } + patch.defaultLandingPath = landing + } + const config = await saveVisitorAuthConfig(db, patch) + + await createAuditEvent(db, { + actorUserId: actor.id, + action: 'visitor_auth.config_updated', + targetType: 'site', + targetId: 'default', + // AuditMetadata values are primitives/string[]; nest the config object as JSON. + metadata: { config: JSON.stringify(config) }, + ...requestAuditContext(req), + }) + + return jsonResponse({ config }) +} + +// ─── Roles ─────────────────────────────────────────────────────────────────── + +async function handleListRoles(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const roles = await listVisitorRoles(db) + return jsonResponse({ roles }) +} + +const VisitorRoleCreateBodySchema = Type.Object({ + name: Type.String({ maxLength: 100 }), + capabilities: Type.Array(Type.String()), +}) + +const VisitorRolePatchBodySchema = Type.Partial(Type.Object({ + name: Type.String({ maxLength: 100 }), + capabilities: Type.Array(Type.String()), +})) + +/** + * Create a custom visitor role. Rejects a duplicate name with 409 (handled + * via `mutationErrorResponse` from the `VisitorRoleMutationError` thrown by + * the repository). Not audited — see the module header. + */ +async function handleCreateRole(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const body = await readValidatedBody(req, VisitorRoleCreateBodySchema) + if (!body) return badRequest('Invalid visitor role payload') + try { + const role = await createVisitorRole(db, { + name: body.name, + capabilities: body.capabilities, + }) + return jsonResponse({ role }, { status: 201 }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +/** + * Update a visitor role's name and/or capabilities. System roles are + * editable (the repository permits it — only `delete` is gated on + * `is_system`). Not audited — see the module header. + */ +async function handleUpdateRole( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const body = await readValidatedBody(req, VisitorRolePatchBodySchema) + if (!body) return badRequest('Invalid visitor role payload') + try { + const role = await updateVisitorRole(db, params.id, { + name: body.name, + capabilities: body.capabilities, + }) + if (!role) return jsonResponse({ error: 'Visitor role not found' }, { status: 404 }) + return jsonResponse({ role }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +/** + * Delete a visitor role. System roles (409) and roles still assigned to + * visitors (409) are refused by the repository. Not audited — see the + * module header. + */ +async function handleDeleteRole( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + try { + const deletedRole = await deleteVisitorRole(db, params.id) + if (!deletedRole) return jsonResponse({ error: 'Visitor role not found' }, { status: 404 }) + return jsonResponse({ role: deletedRole }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +// ─── Groups (Phase 3 — D13/D14/D15) ──────────────────────────────── + +const VisitorGroupCreateBodySchema = Type.Object({ + name: Type.String({ maxLength: 100 }), + landingPath: Type.Optional(Type.String({ maxLength: 200 })), + description: Type.Optional(Type.String({ maxLength: 500 })), +}) + +const VisitorGroupPatchBodySchema = Type.Partial(Type.Object({ + name: Type.String({ maxLength: 100 }), + landingPath: Type.Optional(Type.String({ maxLength: 200 })), + description: Type.Optional(Type.String({ maxLength: 500 })), +})) + +async function handleListGroups(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const groups = await listVisitorGroups(db) + return jsonResponse({ groups }) +} + +async function handleCreateGroup(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const body = await readValidatedBody(req, VisitorGroupCreateBodySchema) + if (!body) return badRequest('Invalid visitor group payload') + try { + const group = await createVisitorGroup(db, { + name: body.name, + landingPath: body.landingPath, + description: body.description, + }) + return jsonResponse({ group }, { status: 201 }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +async function handleUpdateGroup( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const body = await readValidatedBody(req, VisitorGroupPatchBodySchema) + if (!body) return badRequest('Invalid visitor group payload') + try { + const group = await updateVisitorGroup(db, params.id, { + name: body.name, + landingPath: body.landingPath, + description: body.description, + }) + if (!group) return jsonResponse({ error: 'Visitor group not found' }, { status: 404 }) + return jsonResponse({ group }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +async function handleDeleteGroup( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + try { + const deletedGroup = await deleteVisitorGroup(db, params.id) + if (!deletedGroup) return jsonResponse({ error: 'Visitor group not found' }, { status: 404 }) + return jsonResponse({ group: deletedGroup }) + } catch (err) { + return mutationErrorResponse(err) + } +} + +/** Shape of a membership row joined with the visitor for the group-members list. */ +interface GroupMemberRow { + userId: string + groupId: string + isPrimary: boolean + joinedAt: string +} + +/** + * GET /groups/:id/members — list the visitors in a group. Joins the junction + * to visitor_users for display, flagging each row whose primary group is this + * group. Returns 404 for an unknown group id. + */ +async function handleListGroupMembers( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const group = await findVisitorGroupById(db, params.id) + if (!group) return jsonResponse({ error: 'Visitor group not found' }, { status: 404 }) + const memberships = await listMembershipsForGroup(db, params.id) + const users = await Promise.all( + memberships.map((m) => findVisitorUserById(db, m.userId)), + ) + const members: GroupMemberRow[] = [] + for (const [i, m] of memberships.entries()) { + const user = users[i] + if (!user) continue // soft-deleted between the two reads — skip + members.push({ + userId: user.id, + groupId: group.id, + isPrimary: user.primaryGroupId === group.id, + joinedAt: m.createdAt, + }) + } + return jsonResponse({ group, members }) +} + +const VisitorUserGroupsBodySchema = Type.Object({ + groupIds: Type.Array(Type.String()), + primaryGroupId: Type.Optional(Type.Union([Type.String(), Type.Null()])), +}) + +/** + * PUT /users/:id/groups — set a visitor's group memberships (replaces the + * existing set) and optionally their primary group. `primaryGroupId: null` + * clears the primary; an omitted `primaryGroupId` leaves it untouched. Setting + * a primary group that is NOT in the (post-write) membership set is rejected + * (409) by the repository. + */ +async function handlePutUserGroups( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const user = await findVisitorUserById(db, params.id) + if (!user) return jsonResponse({ error: 'visitor not found' }, { status: 404 }) + + const body = await readValidatedBody(req, VisitorUserGroupsBodySchema) + if (!body) return badRequest('invalid_request') + + // Reconcile memberships: remove groups no longer in the set, add new ones. + const desired = new Set(body.groupIds) + const current = await listGroupsForVisitor(db, params.id) + for (const membership of current) { + if (!desired.has(membership.group.id)) { + await removeVisitorFromGroup(db, params.id, membership.group.id) + } + } + await addGroupsToVisitor(db, params.id, body.groupIds) + + if (body.primaryGroupId !== undefined) { + try { + await setVisitorPrimaryGroup(db, params.id, body.primaryGroupId) + } catch (err) { + return mutationErrorResponse(err) + } + } + + await createAuditEvent(db, { + actorUserId: actor.id, + action: 'visitor_auth.user_updated', + targetType: 'user', + targetId: params.id, + metadata: { email: user.email, changes: JSON.stringify({ groups: body.groupIds, primaryGroupId: body.primaryGroupId ?? null }) }, + ...requestAuditContext(req), + }) + + const memberships: VisitorMembershipView[] = await listGroupsForVisitor(db, params.id) + return jsonResponse({ userId: params.id, memberships }) +} + +async function handleGetUserGroups( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const user = await findVisitorUserById(db, params.id) + if (!user) return jsonResponse({ error: 'visitor not found' }, { status: 404 }) + const memberships: VisitorMembershipView[] = await listGroupsForVisitor(db, params.id) + return jsonResponse({ userId: params.id, memberships }) +} + +// ─── Users ────────────────────────────────────────────────────────────── + +/** The admin-facing visitor shape — never exposes `passwordHash`. */ +function toAdminVisitor(db: DbClient, user: VisitorUser): Promise<{ + id: string + email: string + displayName: string + roleId: string + roleName: string + status: string + failedLoginCount: number + lockedUntil: string | null + createdAt: string + // Per-visitor-data framework: custom profile field VALUES. + profileFields: Record +}> { + return findVisitorRoleById(db, user.roleId).then((role) => ({ + id: user.id, + email: user.email, + displayName: user.displayName, + roleId: user.roleId, + roleName: role?.name ?? user.roleId, + status: user.status, + failedLoginCount: user.failedLoginCount, + lockedUntil: user.lockedUntil, + createdAt: user.createdAt, + profileFields: user.profileFields, + })) +} + +async function handleListUsers(req: Request, db: DbClient): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + const url = new URL(req.url) + const limit = Number(url.searchParams.get('limit') ?? '50') + const offset = Number(url.searchParams.get('offset') ?? '0') + const search = url.searchParams.get('search') ?? undefined + const [users, total] = await Promise.all([ + listVisitorUsers(db, { + limit: Number.isFinite(limit) ? limit : undefined, + offset: Number.isFinite(offset) ? offset : undefined, + search: search || undefined, + }), + countVisitorUsers(db, { search: search || undefined }), + ]) + const items = await Promise.all(users.map((u) => toAdminVisitor(db, u))) + return jsonResponse({ users: items, total }) +} + +const VisitorUserPatchSchema = Type.Partial(Type.Object({ + status: UserStatusSchema, + roleId: Type.String(), + displayName: Type.String({ maxLength: 200 }), + // Per-visitor-data framework: custom profile field VALUES (object keyed by + // field id). Stored whole — callers merge against existing values. The id + // keys should match the configured VisitorProfileField defs, but the server + // doesn't enforce that (allows ad-hoc values during config transitions). + profileFields: Type.Record(Type.String(), Type.Unknown()), +})) + +async function handlePatchUser( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + + const current = await findVisitorUserById(db, params.id) + if (!current) return jsonResponse({ error: 'visitor not found' }, { status: 404 }) + + const body = await readValidatedBody(req, VisitorUserPatchSchema) + if (!body) return badRequest('invalid_request') + + // Validate roleId if supplied. + if (body.roleId !== undefined && body.roleId !== current.roleId) { + const role = await findVisitorRoleById(db, body.roleId) + if (!role) return badRequest('unknown roleId') + } + + if (body.displayName !== undefined && body.displayName !== current.displayName) { + await updateVisitorUserDisplayName(db, params.id, body.displayName) + } + if (body.roleId !== undefined && body.roleId !== current.roleId) { + await updateVisitorUserRole(db, params.id, body.roleId) + } + if (body.profileFields !== undefined) { + // Merge the patch onto existing values so a partial update (one field) + // doesn't wipe the others. The handler is the only admin write path, so + // this is the single merge point. + const merged = { ...current.profileFields, ...body.profileFields } + await updateVisitorUserProfileFields(db, params.id, merged) + } + if (body.status !== undefined && body.status !== current.status) { + await setVisitorUserStatus(db, params.id, body.status) + // Suspending a visitor invalidates their active sessions so they can't + // keep browsing on a cookie issued before the suspension. + if (body.status === 'suspended') { + await revokeAllVisitorSessionsForUser(db, params.id) + } + } + + const updated = await findVisitorUserById(db, params.id) + await createAuditEvent(db, { + actorUserId: actor.id, + action: 'visitor_auth.user_updated', + targetType: 'user', + targetId: params.id, + metadata: { email: current.email, changes: JSON.stringify(body) }, + ...requestAuditContext(req), + }) + + return jsonResponse({ user: updated ? await toAdminVisitor(db, updated) : null }) +} + +async function handleDeleteUser( + req: Request, + db: DbClient, + params: RouteParams, +): Promise { + const actor = await requireCapability(req, db, 'users.manage') + if (actor instanceof Response) return actor + + const current = await findVisitorUserById(db, params.id) + if (!current) return jsonResponse({ error: 'visitor not found' }, { status: 404 }) + + // Revoke sessions first, then soft-delete. Order matters: once deleted_at + // is set the session-validation lookup (active + non-deleted) stops matching + // anyway, but explicit revoke also clears the in-memory cache path. + await revokeAllVisitorSessionsForUser(db, params.id) + await softDeleteVisitorUser(db, params.id) + + await createAuditEvent(db, { + actorUserId: actor.id, + action: 'visitor_auth.user_deleted', + targetType: 'user', + targetId: params.id, + metadata: { email: current.email }, + ...requestAuditContext(req), + }) + + return jsonResponse({ ok: true }) +} + +// ─── Route table + dispatcher ──────────────────────────────────────────────── + +const VISITOR_AUTH_ROUTES: readonly Route<[]>[] = [ + { method: 'GET', pattern: `${PREFIX}/config`, handler: handleGetConfig }, + { method: 'PUT', pattern: `${PREFIX}/config`, handler: handlePutConfig }, + { method: 'GET', pattern: `${PREFIX}/roles`, handler: handleListRoles }, + { method: 'POST', pattern: `${PREFIX}/roles`, handler: handleCreateRole }, + { method: 'PATCH', pattern: new RegExp(`^${PREFIX}/roles/(?[^/]+)$`), handler: handleUpdateRole }, + { method: 'DELETE',pattern: new RegExp(`^${PREFIX}/roles/(?[^/]+)$`), handler: handleDeleteRole }, + { method: 'GET', pattern: `${PREFIX}/groups`, handler: handleListGroups }, + { method: 'POST', pattern: `${PREFIX}/groups`, handler: handleCreateGroup }, + { method: 'PATCH', pattern: new RegExp(`^${PREFIX}/groups/(?[^/]+)$`), handler: handleUpdateGroup }, + { method: 'DELETE',pattern: new RegExp(`^${PREFIX}/groups/(?[^/]+)$`), handler: handleDeleteGroup }, + { method: 'GET', pattern: new RegExp(`^${PREFIX}/groups/(?[^/]+)/members$`), handler: handleListGroupMembers }, + { method: 'GET', pattern: `${PREFIX}/users`, handler: handleListUsers }, + { method: 'PATCH', pattern: new RegExp(`^${PREFIX}/users/(?[^/]+)$`), handler: handlePatchUser }, + { method: 'DELETE',pattern: new RegExp(`^${PREFIX}/users/(?[^/]+)$`), handler: handleDeleteUser }, + { method: 'GET', pattern: new RegExp(`^${PREFIX}/users/(?[^/]+)/groups$`), handler: handleGetUserGroups }, + { method: 'PUT', pattern: new RegExp(`^${PREFIX}/users/(?[^/]+)/groups$`), handler: handlePutUserGroups }, +] + +export async function handleVisitorAuthAdminRoutes(req: Request, db: DbClient): Promise { + const { pathname } = new URL(req.url) + if (!pathname.startsWith(`${PREFIX}/`)) return null + return runRouteTable(req, db, VISITOR_AUTH_ROUTES) +} diff --git a/server/index.ts b/server/index.ts index e4f3675b0..dfa749a0c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4,12 +4,16 @@ import { syncSystemRoles } from './repositories/roles' import { readServerConfig } from './config' import { DEV_ORIGIN_ALLOWLIST, configurePublicOrigins, configureTrustedProxyCidrs, stampSocketIp } from './auth/security' import { applySecurityHeaders } from './securityHeaders' +import { visitorAuthMiddleware } from './visitor-auth/middleware' import { startConversationPurgeTick } from './ai/boot' await import('./richtextSanitizer') const { handleServerRequest } = await import('./router') const { activateInstalledServerPlugins } = await import('./plugins/runtime') const { mediaStorageRegistry } = await import('@core/plugins/mediaStorageRegistry') +const { createCollabRelay } = await import('./collab/relay') +const { SITE_SOCKET_PATH, createCollabSocketLayer, handleCollabSocketUpgrade } = + await import('./collab/socket') const config = readServerConfig() configureTrustedProxyCidrs(config.trustedProxyCidrs) @@ -29,6 +33,11 @@ await activateInstalledServerPlugins(db, config.uploadsDir) // AI runtime: start the nightly conversation-purge tick. Operators add // their own provider credentials via /admin/ai/providers on first install. startConversationPurgeTick(db) +// Real-time co-editing: the relay owns live Y documents, their persistence, +// and the reset protocol for out-of-relay writes. The socket layer speaks +// the multiplexed y-protocols wire (see server/collab/socket.ts). +const collabRelay = createCollabRelay(db) +const collabSocket = createCollabSocketLayer(collabRelay) /** * Build the CORS response headers for an incoming request. @@ -58,7 +67,7 @@ function corsHeaders(origin: string | null): Record { } } -Bun.serve({ +const server = Bun.serve({ port: config.port, // Disable Bun's default 10-second idle timeout. The agent endpoint streams @@ -88,7 +97,26 @@ Bun.serve({ ) } + // Real-time co-editing socket — a WebSocket upgrade is a different + // protocol lifecycle from the request/response router, so it dispatches + // here at the `Bun.serve` boundary (the only place `server.upgrade` is + // available). Returning `undefined` hands the connection to the + // `websocket` handlers below. + if (pathname === SITE_SOCKET_PATH) { + const rejection = await handleCollabSocketUpgrade(req, db, server) + if (rejection === null) return undefined + return applySecurityHeaders(rejection, pathname) + } + try { + // Visitor-auth route protection (D1). Runs before handleServerRequest so + // it gates every public-site request; returns null for pass-through. + const authResponse = await visitorAuthMiddleware(req, db) + if (authResponse) { + for (const [k, v] of Object.entries(cors)) authResponse.headers.set(k, v) + return applySecurityHeaders(authResponse, pathname) + } + const res = await handleServerRequest(req, { db, staticDir: config.staticDir, @@ -116,10 +144,36 @@ Bun.serve({ } }, + websocket: collabSocket.handlers, + error(err: Error) { console.error('[server] Unhandled error:', err) return new Response('Internal Server Error', { status: 500 }) }, }) +// The collab fan-out publishes through Bun pub/sub — register the live +// server handle now that `Bun.serve` returned. +collabSocket.setPublisher(server) + +// Graceful shutdown: the relay persists on an 800 ms debounce, so a redeploy +// (SIGTERM) or Ctrl-C (SIGINT) mid-window would drop the un-persisted edits +// the old transactional save made durable on ack. Flush every dirty doc +// before exiting. Idempotent + guarded so a double signal can't double-run. +let shuttingDown = false +async function shutdown(signal: string): Promise { + if (shuttingDown) return + shuttingDown = true + console.log(`[server] ${signal} received — flushing collab docs before exit`) + try { + await collabRelay.destroy() // final-persists every live doc, detaches sources + } catch (err) { + console.error('[server] collab flush on shutdown failed:', err) + } + server.stop() + process.exit(0) +} +process.on('SIGTERM', () => void shutdown('SIGTERM')) +process.on('SIGINT', () => void shutdown('SIGINT')) + console.log(`[server] Listening on http://localhost:${config.port}`) diff --git a/server/plugins/host/contentFieldMapping.ts b/server/plugins/host/contentFieldMapping.ts index dea51ec22..6b5b37b53 100644 --- a/server/plugins/host/contentFieldMapping.ts +++ b/server/plugins/host/contentFieldMapping.ts @@ -1,20 +1,69 @@ import type { ContentTableSchema as ContentTableSchemaShape } from '@core/plugin-sdk/contentSchemas' -import type { DataField } from '@core/data/schemas' +import type { PluginRepeaterItemField } from '@core/plugin-sdk/types/content' +import type { DataField, RepeaterItemField } from '@core/data/schemas' import { listDataTables } from '../../repositories/data' import type { DbClient } from '../../db/client' -type PluginContentFieldForCreate = ContentTableSchemaShape['fields'][number] - -function pluginFieldCommon(field: PluginContentFieldForCreate): { +function pluginFieldCommon(field: { id: string; label: string; required?: boolean }): { id: string label: string required?: boolean } { - const withRequired = field as { required?: boolean } return { id: field.id, label: field.label, - ...(withRequired.required !== undefined ? { required: withRequired.required } : {}), + ...(field.required !== undefined ? { required: field.required } : {}), + } +} + +function pluginRepeaterItemFieldToDataField( + field: PluginRepeaterItemField, + tableIdBySlug: Map, +): RepeaterItemField { + switch (field.type) { + case 'text': + case 'longText': + case 'number': + case 'boolean': + case 'date': + case 'dateTime': + case 'url': + case 'email': + return { ...pluginFieldCommon(field), type: field.type } + case 'richText': + return { ...pluginFieldCommon(field), type: field.type, format: 'markdown' } + case 'select': + case 'multiSelect': + return { + ...pluginFieldCommon(field), + type: field.type, + options: field.options.map((option) => ({ + id: option.value, + value: option.value, + label: option.label, + })), + } + case 'media': + return { + ...pluginFieldCommon(field), + type: field.type, + mediaKind: field.mediaKind, + allowMultiple: field.allowMultiple, + } + case 'relation': { + const targetTableId = tableIdBySlug.get(field.targetTableSlug) + if (!targetTableId) { + throw new Error( + `Relation field "${field.id}" targets unknown table "${field.targetTableSlug}"`, + ) + } + return { + ...pluginFieldCommon(field), + type: field.type, + targetTableId, + allowMultiple: field.allowMultiple, + } + } } } @@ -70,6 +119,15 @@ export function pluginContentFieldsToDataFields( }) break } + case 'repeater': + out.push({ + ...pluginFieldCommon(field), + type: field.type, + fields: field.fields.map((itemField) => + pluginRepeaterItemFieldToDataField(itemField, tableIdBySlug)), + itemLabelFieldId: field.itemLabelFieldId, + }) + break } } diff --git a/server/plugins/host/handlers/contentProjection.test.ts b/server/plugins/host/handlers/contentProjection.test.ts new file mode 100644 index 000000000..b5076877e --- /dev/null +++ b/server/plugins/host/handlers/contentProjection.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'bun:test' +import { Value } from '@sinclair/typebox/value' +import { ContentTableSchemaSchema } from '@core/plugin-sdk/contentSchemas' +import type { DataTable } from '@core/data/schemas' +import { tableSchema } from './contentProjection' + +describe('content projection', () => { + it('projects repeater item fields across the plugin boundary', () => { + const table: DataTable = { + id: 'projects-id', + name: 'Projects', + slug: 'projects', + kind: 'postType', + singularLabel: 'Project', + pluralLabel: 'Projects', + routeBase: '/projects', + primaryFieldId: 'title', + fields: [ + { type: 'text', id: 'title', label: 'Title' }, + { + type: 'repeater', + id: 'gallery', + label: 'Gallery', + itemLabelFieldId: 'caption', + fields: [ + { type: 'text', id: 'caption', label: 'Caption', required: true }, + { + type: 'media', + id: 'images', + label: 'Images', + mediaKind: 'image', + allowMultiple: true, + }, + { + type: 'relation', + id: 'credit', + label: 'Credit', + targetTableId: 'people-id', + }, + ], + }, + ], + system: false, + createdByUserId: null, + updatedByUserId: null, + createdAt: '2026-07-28T00:00:00.000Z', + updatedAt: '2026-07-28T00:00:00.000Z', + } + + const projected = tableSchema( + table, + 2, + new Map([['people-id', 'people']]), + ) + + expect(projected.fields[1]).toEqual({ + type: 'repeater', + id: 'gallery', + label: 'Gallery', + required: undefined, + itemLabelFieldId: 'caption', + fields: [ + { + type: 'text', + id: 'caption', + label: 'Caption', + required: true, + }, + { + type: 'media', + id: 'images', + label: 'Images', + mediaKind: 'image', + allowMultiple: true, + }, + { + type: 'relation', + id: 'credit', + label: 'Credit', + targetTableSlug: 'people', + allowMultiple: undefined, + }, + ], + }) + expect(Value.Check(ContentTableSchemaSchema, projected)).toBe(true) + }) +}) diff --git a/server/plugins/host/handlers/contentProjection.ts b/server/plugins/host/handlers/contentProjection.ts index 0527d2de2..7e619a790 100644 --- a/server/plugins/host/handlers/contentProjection.ts +++ b/server/plugins/host/handlers/contentProjection.ts @@ -11,7 +11,13 @@ import type { ContentTableSchema as ContentTableSchemaShape, ContentTableSummary, } from '@core/plugin-sdk/contentSchemas' -import type { DataField, DataRow, DataTable } from '@core/data/schemas' +import type { PluginRepeaterItemField } from '@core/plugin-sdk/types/content' +import type { + DataField, + DataRow, + DataTable, + RepeaterItemField, +} from '@core/data/schemas' import type { DbClient } from '../../../db/client' import { getDataTableBySlug, listDataTables } from '../../../repositories/data' @@ -24,6 +30,60 @@ import { getDataTableBySlug, listDataTables } from '../../../repositories/data' * `tableSlugById` maps the host's internal `targetTableId` to the * public-facing slug so the plugin boundary never leaks DB ids. */ +function projectRepeaterItemField( + field: RepeaterItemField, + tableSlugById: Map, +): PluginRepeaterItemField | null { + switch (field.type) { + case 'text': + case 'longText': + case 'richText': + case 'number': + return { + type: field.type, + id: field.id, + label: field.label, + required: field.required, + } + case 'select': + case 'multiSelect': + return { + type: field.type, + id: field.id, + label: field.label, + options: (field.options ?? []).map((option) => ({ + value: option.value, + label: option.label, + })), + } + case 'media': + return { + type: field.type, + id: field.id, + label: field.label, + mediaKind: field.mediaKind, + allowMultiple: field.allowMultiple, + } + case 'relation': { + const targetTableSlug = tableSlugById.get(field.targetTableId) + if (targetTableSlug === undefined) return null + return { + type: field.type, + id: field.id, + label: field.label, + targetTableSlug, + allowMultiple: field.allowMultiple, + } + } + case 'boolean': + case 'date': + case 'dateTime': + case 'url': + case 'email': + return { type: field.type, id: field.id, label: field.label } + } +} + function projectFields( fields: DataField[], tableSlugById: Map, @@ -62,6 +122,22 @@ function projectFields( targetTableSlug: tableSlugById.get(f.targetTableId) ?? '', }) break + case 'repeater': { + const fields = f.fields + .map((field) => projectRepeaterItemField(field, tableSlugById)) + .filter((field) => field !== null) + out.push({ + type: 'repeater', + id: f.id, + label: f.label, + required: f.required, + fields, + itemLabelFieldId: fields.some((field) => field.id === f.itemLabelFieldId) + ? f.itemLabelFieldId + : undefined, + }) + break + } case 'pageTree': out.push({ type: 'pageTree', id: f.id, label: f.label }) break diff --git a/server/publish/holeRuntime.ts b/server/publish/holeRuntime.ts index da6317dba..9cb501ff9 100644 --- a/server/publish/holeRuntime.ts +++ b/server/publish/holeRuntime.ts @@ -58,6 +58,43 @@ export function runInstaticHoleRuntime(): void { const box = el.firstElementChild; if (box) { io.observe(box); } else { instaticFetchHole(el); } } + + // Auth-gated containers — mirror the hole logic but fetch the real subtree + // from /_instatic/gate/. The endpoint renders the subtree only for + // authorised visitors and returns the baked fallback for everyone else, so + // unauthorised visitors keep seeing the placeholder content. + function instaticFetchGate(el: HTMLElement): void { + const id = el.dataset.instaticGate || ''; + const version = el.dataset.instaticVersion || ''; + const u = location.pathname + location.search; + fetch('/_instatic/gate/' + encodeURIComponent(id) + '?v=' + encodeURIComponent(version) + '&u=' + encodeURIComponent(u)) + .then(function(r) { return r.text(); }) + .then(function(html) { el.outerHTML = html; }) + .catch(function() {}); + } + const gated = document.querySelectorAll('instatic-gated[data-instatic-gate]'); + // Only construct the gate IntersectionObserver when there are gated elements + // to observe — avoids creating a second observer (and clobbering any + // test/caller that captures the last-constructed IntersectionObserver) on + // pages that only use holes. + if (gated.length > 0) { + const gateIo = new IntersectionObserver(function(entries) { + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (!e.isIntersecting) continue; + gateIo.unobserve(e.target); + const gate = e.target.closest('instatic-gated[data-instatic-gate]') as HTMLElement | null; + if (gate) instaticFetchGate(gate); + } + }, { rootMargin: '200px 0px' }); + for (let i = 0; i < gated.length; i++) { + const el = gated[i] as HTMLElement; + // is display:contents (no box) — observe its placeholder + // child, which has a box. Gates without a placeholder are fetched eagerly. + const box = el.firstElementChild; + if (box) { gateIo.observe(box); } else { instaticFetchGate(el); } + } + } } export const HOLE_RUNTIME_JS = `(${runInstaticHoleRuntime.toString()})();` diff --git a/server/publish/loopPrefetch.ts b/server/publish/loopPrefetch.ts index 01416b508..e769ec457 100644 --- a/server/publish/loopPrefetch.ts +++ b/server/publish/loopPrefetch.ts @@ -12,9 +12,9 @@ import type { Page, PageNode, SiteDocument } from '@core/page-tree' import type { - LoopEntitySource, LoopFetchResult, LoopItem, + PrefetchedLoopEntitySource, SourceFetchContext, SourceRequestContext, } from '@core/loops/types' @@ -25,6 +25,29 @@ import { publicDataUserFromParts } from '@core/data/publicDataUser' import type { PublishedDataRow } from '@core/data/schemas' import type { DbClient } from '../db/client' import { walkRenderTree } from './renderTreeWalk' +import { resolveVisitorFromCookie } from '../visitor-auth/visitorData' +import type { SourceVisitorContext } from '@core/loops/types' + +/** + * Resolve the current visitor from the per-visitor cookie map and project it + * to the core `SourceVisitorContext` shape (no server types leak into + * `src/core`). Returns `undefined` for anonymous requests — perVisitor + * sources then render their empty state. Derived solely from the cookie. + */ +async function resolveVisitorForLoop( + db: DbClient, + cookies: Record | undefined, +): Promise { + const visitor = await resolveVisitorFromCookie(db, cookies) + if (!visitor) return undefined + return { + id: visitor.id, + displayName: visitor.displayName, + email: visitor.email, + roleName: visitor.roleName ?? null, + profileFields: visitor.profileFields, + } +} /** * Resolved loop data for a single loop node on a page. @@ -219,7 +242,7 @@ function readPageNumber(url: URL | undefined, loopNodeId: string): number { */ async function resolveOneLoop( node: PageNode, - source: LoopEntitySource, + source: PrefetchedLoopEntitySource, ctx: { db: DbClient; site: SiteDocument; url?: URL; request?: SourceRequestContext }, ): Promise { const props = readLoopProps(node) @@ -243,6 +266,12 @@ async function resolveOneLoop( // Request context — present only when rendering inside a Layer C hole. // Built-in publish-time sources ignore it. request: ctx.request, + // Resolved visitor — populated ONLY for perVisitor sources with cookies, + // so built-in sources read `ctx.visitor` without importing session code. + // Derived solely from the cookie (IDOR-safe — never from loop input). + visitor: source.perVisitor && ctx.request + ? await resolveVisitorForLoop(ctx.db, ctx.request.cookies) + : undefined, } try { @@ -288,7 +317,7 @@ export async function prefetchLoopData( nodes.map(async (node) => { const props = readLoopProps(node) const source = props.sourceId ? loopSourceRegistry.get(props.sourceId) : undefined - if (!source) { + if (!source || source.kind === 'contextual') { return [ node.id, { items: [], totalItems: 0, pageNumber: 1, hasMore: false }, diff --git a/server/publish/mediaPrefetch.ts b/server/publish/mediaPrefetch.ts index accc79412..53a8055e2 100644 --- a/server/publish/mediaPrefetch.ts +++ b/server/publish/mediaPrefetch.ts @@ -19,7 +19,12 @@ import type { Page, SiteDocument } from '@core/page-tree' import type { IModuleRegistry } from '@core/module-engine' -import { collectNodeBackgroundImagePaths, collectSiteStyleBackgroundImagePaths } from '@core/publisher' +import { + collectNodeBackgroundImagePaths, + collectSiteStyleBackgroundImagePaths, + type ResolvedLoopRenderData, +} from '@core/publisher' +import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' import { walkRenderTree } from './renderTreeWalk' import type { DbClient } from '../db/client' import type { MediaAsset } from '../repositories/media' @@ -33,6 +38,11 @@ import { materializeAssetMapForClient } from './mediaPresentation' /** Map keyed by the asset's `public_path` for O(1) lookup at render time. */ type MediaAssetMap = Map +interface MediaPrefetchOptions { + templateContext?: TemplateRenderDataContext + loopData?: ReadonlyMap +} + /** * Collect every `/uploads/...` path referenced by an image/media-typed prop * across the page tree. @@ -72,32 +82,62 @@ export async function prefetchMediaAssets( site: SiteDocument, registry: IModuleRegistry, db: DbClient, + options: MediaPrefetchOptions = {}, ): Promise { const map = new Map() const paths = collectMediaPaths(page, site, registry) - if (paths.size === 0) return map + const entryReferences = collectEntryArrayReferences(options) + if (paths.size === 0 && entryReferences.size === 0) return map - // `collectMediaPaths` already returns a Set, so the paths are unique. + // `collectMediaPaths` and `collectEntryArrayReferences` both return Sets, + // so the lookup values are unique. Entry references are queried against + // both `id` and `public_path`: multi-media cells store ids, while plugin or + // imported entry arrays may already carry public paths. const pathsToFetch = [...paths] - const placeholders = pathsToFetch.map((_, i) => - db.dialect === 'postgres' ? `$${i + 1}` : '?' - ).join(', ') + const entryReferencesToFetch = [...entryReferences] + const allReferences = [...new Set([...pathsToFetch, ...entryReferencesToFetch])] // Bespoke batched-by-`public_path` SELECT (the render path resolves by stored // URL, not asset id, and legitimately skips the folder-id join). It maps // through the SAME canonical `mapMediaAssetRow` as the repository, so the // published page and the admin see one identical asset shape — including // storageAdapterId, externallyHosted, and the variants' storagePath / // storageAdapterId derivation. - const { rows } = await db.unsafe( - `select ${MEDIA_ASSET_COLUMNS} - from media_assets - where public_path in (${placeholders}) and deleted_at is null`, - pathsToFetch, - ) + let rows: MediaAssetRow[] + if (entryReferencesToFetch.length === 0) { + const placeholders = pathsToFetch.map((_, i) => + db.dialect === 'postgres' ? `$${i + 1}` : '?' + ).join(', ') + const result = await db.unsafe( + `select ${MEDIA_ASSET_COLUMNS} + from media_assets + where public_path in (${placeholders}) and deleted_at is null`, + pathsToFetch, + ) + rows = result.rows + } else { + const pathPlaceholders = allReferences.map((_, i) => + db.dialect === 'postgres' ? `$${i + 1}` : '?' + ).join(', ') + const idPlaceholders = allReferences.map((_, i) => + db.dialect === 'postgres' ? `$${allReferences.length + i + 1}` : '?' + ).join(', ') + const result = await db.unsafe( + `select ${MEDIA_ASSET_COLUMNS} + from media_assets + where (public_path in (${pathPlaceholders}) or id in (${idPlaceholders})) + and deleted_at is null`, + [...allReferences, ...allReferences], + ) + rows = result.rows + } const byPath = new Map(rows.map(r => [r.public_path, r])) - for (const path of pathsToFetch) { - const row = byPath.get(path) - if (row) map.set(path, mapMediaAssetRow(row)) + const byId = new Map(rows.map(r => [r.id, r])) + for (const reference of allReferences) { + const row = byPath.get(reference) ?? byId.get(reference) + if (!row) continue + const asset = mapMediaAssetRow(row) + map.set(reference, asset) + map.set(asset.publicPath, asset) } // Apply the `media.url.transform` filter chain to every asset's URLs // (publicPath + variants[*].path). The map KEY stays the page tree's @@ -106,3 +146,29 @@ export async function prefetchMediaAssets( // on the published page AND the editor preview iframe in one place. return materializeAssetMapForClient(map) } + +function collectEntryArrayReferences(options: MediaPrefetchOptions): Set { + const references = new Set() + const visit = (value: unknown, insideArray: boolean): void => { + if (typeof value === 'string') { + if (insideArray && value) references.add(value) + return + } + if (Array.isArray(value)) { + for (const item of value) visit(item, true) + return + } + if (!value || typeof value !== 'object') return + for (const child of Object.values(value as Record)) { + visit(child, insideArray) + } + } + + for (const entry of options.templateContext?.entryStack ?? []) { + visit(entry.fields, false) + } + for (const data of options.loopData?.values() ?? []) { + for (const item of data.items) visit(item.fields, false) + } + return references +} diff --git a/server/publish/publicRenderer.ts b/server/publish/publicRenderer.ts index a35e01772..21058b07d 100644 --- a/server/publish/publicRenderer.ts +++ b/server/publish/publicRenderer.ts @@ -93,10 +93,11 @@ async function renderMergedTemplate( ): Promise<{ html: string; jsModuleIds: string[]; publishVersion: number; cssBundle: SiteCssBundle }> { const publishVersion = ctx.publishVersion ?? getPublishVersion() const moduleJsMap = buildPublishedSiteModuleJsMap(snapshot.site, registry) - const [loopData, mediaAssets] = await Promise.all([ - prefetchLoopData(merged, snapshot.site, ctx.db, ctx.url), - prefetchMediaAssets(merged, snapshot.site, registry, ctx.db), - ]) + const loopData = await prefetchLoopData(merged, snapshot.site, ctx.db, ctx.url) + const mediaAssets = await prefetchMediaAssets(merged, snapshot.site, registry, ctx.db, { + templateContext, + loopData, + }) const cssBundle = buildPublishedSiteCssBundle(snapshot.site, registry, merged, publishVersion, { mediaAssets }) const published = publishPage(merged, snapshot.site, registry, { templateContext, diff --git a/server/publish/publicRouter.ts b/server/publish/publicRouter.ts index 65e30e123..2f1725b4d 100644 --- a/server/publish/publicRouter.ts +++ b/server/publish/publicRouter.ts @@ -62,6 +62,7 @@ import type { DbClient } from '../db/client' import type { PublishedPageSnapshot } from '../repositories/publish' import type { PublishedDataRow } from '@core/data/schemas' import { isTemplatePage, resolveNotFoundTemplate } from '@core/templates' +import { resolvePageAccess } from '@core/page-tree' import { getDataRowRedirectByRoute, getPublishedDataRowByRoute, @@ -184,6 +185,51 @@ async function resolvePublicRoute( return { kind: 'not-found' } } +/** + * Does a directly-routable published page exist at `pathname`? + * + * Mirrors the判定 inside `resolvePublicRoute` for the page-slug branch: a + * real page (NOT a template page) at the normalised slug. Used by the + * visitor-auth middleware to decide between a 302 to the operator's + * published login page and the built-in fallback login page — a template + * page is never a valid login target, so it must not count as "exists". + * + * Reuses the LOCAL `publicSlugFromPath` + `getPublishedPageBySlug` so this + * helper never drifts from how the public router itself resolves a slug. + */ +export async function publishedPageExistsAtPath(db: DbClient, pathname: string): Promise { + const snap = await getPublishedPageBySlug(db, publicSlugFromPath(pathname)) + if (!snap) return false + const page = snap.site.pages.find((p) => p.id === snap.pageRowId) + return Boolean(page && !isTemplatePage(page)) +} + +/** + * Resolved access level for a published (non-template) page at `pathname`. + * + * Returns `null` when no published page exists at the path (the caller lets + * the request fall through to a downstream 404). Otherwise returns the page's + * normalised access shape — `{ level: 'public', groups: [] }` by default, + * `{ level: 'groups', groups: [...] }` when the page is restricted to a set + * of member groups (D14). Reads `page.access` tolerantly (missing/corrupt → + * public) via {@link resolvePageAccess} so a pre-Phase-3 snapshot never + * crashes the gate. + * + * The visitor-auth middleware uses this to decide per-page gating (D17): + * public → pass through; restricted + anonymous → login; restricted + + * logged-in-but-not-in-group → the built-in "no access" page. + */ +export async function getPublishedPageAccessForPath( + db: DbClient, + pathname: string, +): Promise<{ level: 'public' | 'groups'; groups: string[] } | null> { + const snap = await getPublishedPageBySlug(db, publicSlugFromPath(pathname)) + if (!snap) return null + const page = snap.site.pages.find((p) => p.id === snap.pageRowId) + if (!page || isTemplatePage(page)) return null + return resolvePageAccess((page as { access?: unknown }).access) +} + // --------------------------------------------------------------------------- // Resolution → Response // --------------------------------------------------------------------------- diff --git a/server/publish/publishFlush.ts b/server/publish/publishFlush.ts new file mode 100644 index 000000000..975310500 --- /dev/null +++ b/server/publish/publishFlush.ts @@ -0,0 +1,25 @@ +/** + * Publish-flush seam — EVERY publish path awaits this before it reads rows, + * so the baked output includes collab edits still inside the relay's persist + * debounce window ("publish bakes exactly what the admins see"). + * + * The collab relay registers its flush here at boot; publish just calls + * `runPublishFlush()`. This keeps the dependency one-way (publish must not + * import the relay) and makes the flush INTRINSIC to publishing rather than + * bolted onto the one HTTP route that happened to have the relay handle — + * per-row publish, the scheduled-publish tick, and plugin-host publish all + * ride the same guarantee now. + */ +let flush: (() => Promise) | null = null + +export function registerPublishFlush(fn: () => Promise): () => void { + flush = fn + return () => { + if (flush === fn) flush = null + } +} + +/** Flush the collab relay (no-op before the relay registers, e.g. in tests). */ +export async function runPublishFlush(): Promise { + await flush?.() +} diff --git a/server/publish/publishRow.ts b/server/publish/publishRow.ts index 4e699d72d..5feb28ede 100644 --- a/server/publish/publishRow.ts +++ b/server/publish/publishRow.ts @@ -30,6 +30,7 @@ import { renderPublishedDataRowTemplate } from './publicRenderer' import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' import { removeArtefactInPlace, updateArtefactInPlace } from './staticArtefact' import { bumpPublishVersion, getPublishVersion, withPublishLock } from './publishState' +import { runPublishFlush } from './publishFlush' export interface PublishDataRowResult { row: DataRow @@ -47,6 +48,10 @@ export async function publishDataRow( publisherUserId: string | null, uploadsDir?: string, ): Promise { + // Flush the collab relay before reading the row — a page/component/row doc + // edited live may still hold un-persisted changes inside the debounce + // window, and per-row publish must bake exactly what the admins see. + await runPublishFlush() // Serialize against every other publish so the version read→bake→bump window // can't interleave and mis-stamp baked hole shells (ISS-038). return withPublishLock(() => publishDataRowLocked(db, rowId, publisherUserId, uploadsDir)) diff --git a/server/publish/publishSite.ts b/server/publish/publishSite.ts index 925249bc9..0c39dd896 100644 --- a/server/publish/publishSite.ts +++ b/server/publish/publishSite.ts @@ -50,6 +50,7 @@ import { import { buildPublishedSiteCssBundle } from './siteCssBundle' import { bakePublishedDataRowArtefacts } from './bakeDataRows' import { bumpPublishVersion, getPublishVersion, withPublishLock } from './publishState' +import { runPublishFlush } from './publishFlush' interface PublishResult { publishedPages: number @@ -81,6 +82,10 @@ export async function publishDraftSite( adminUserId: string, uploadsDir?: string, ): Promise { + // Flush the collab relay so the published snapshot includes edits still + // inside the debounce window (publish bakes exactly what the admins see). + // Intrinsic to publishing now, not bolted onto the HTTP route. + await runPublishFlush() // Serialize against every other publish so the version read→bake→bump window // can't interleave and mis-stamp baked hole shells (ISS-038). return withPublishLock(() => publishDraftSiteLocked(db, adminUserId, uploadsDir)) diff --git a/server/publish/publishedSnapshotCache.ts b/server/publish/publishedSnapshotCache.ts index 7037f1fda..4b7c1a819 100644 --- a/server/publish/publishedSnapshotCache.ts +++ b/server/publish/publishedSnapshotCache.ts @@ -58,6 +58,45 @@ interface PublishedNodeIndex { const nodeIndexMemo = createVersionedSingleFlight() +/** + * Compose prefixes that `spliceIntoOutlet` (templateCompose.ts) prepends to a + * terminal page's node ids when it splices the page into an everywhere / + * postTypes template outlet: `c0_` for the terminal page, `t_` for each + * outer template in the chain. The published hole/gate placeholder attribute + * carries the COMPOSED (prefixed) id; the nodeIndex here is built from the raw + * (non-composed) snapshot pages, so a lookup by the composed id misses. This + * strips one known compose prefix so hole/gate requests on template-wrapped + * pages resolve. Returns the original id when no prefix is present. + */ +const COMPOSE_PREFIX = /^(?:c0_|t\d+_)/ +function stripComposePrefix(nodeId: string): string { + return nodeId.replace(COMPOSE_PREFIX, '') +} + +/** + * Compose-aware node → page lookup for the hole/gate endpoints. Tries the id + * as-is first (fast path: pages NOT wrapped by a template, or authored node + * ids that happen to be passed directly), then falls back to the compose- + * prefix-stripped id (template-wrapped pages). Returns the page and the + * EFFECTIVE node id (the form that actually exists in the page's node map — + * callers use this for `page.nodes[effectiveId]` and rendering, since the + * page's nodes are keyed on the non-composed id). Returns undefined when no + * form matches. + */ +export function findPageForNodeId( + index: PublishedNodeIndex, + nodeId: string, +): { page: Page; effectiveNodeId: string } | undefined { + if (index.nodeIndex.has(nodeId)) { + return { page: index.nodeIndex.get(nodeId)!, effectiveNodeId: nodeId } + } + const stripped = stripComposePrefix(nodeId) + if (stripped !== nodeId && index.nodeIndex.has(stripped)) { + return { page: index.nodeIndex.get(stripped)!, effectiveNodeId: stripped } + } + return undefined +} + /** * The published site plus a `nodeId → page` index for `version`, so the hole * endpoint locates a fragment's page in O(1) instead of scanning all pages. diff --git a/server/publish/runtime/previewRuntime.ts b/server/publish/runtime/previewRuntime.ts index 670d33db6..1a06292bb 100644 --- a/server/publish/runtime/previewRuntime.ts +++ b/server/publish/runtime/previewRuntime.ts @@ -76,12 +76,15 @@ export async function buildRuntimePreviewDocument( runtimePackageImportmap = { body: serialized.body, sha256: serialized.sha256 } } } - const [loopData, mediaAssets] = input.db - ? await Promise.all([ - prefetchLoopData(input.page, input.site, input.db), - prefetchMediaAssets(input.page, input.site, input.registry, input.db), - ]) - : [undefined, undefined] + const loopData = input.db + ? await prefetchLoopData(input.page, input.site, input.db) + : undefined + const mediaAssets = input.db + ? await prefetchMediaAssets(input.page, input.site, input.registry, input.db, { + templateContext: input.templateContext, + loopData, + }) + : undefined const baseHtml = publishPage(input.page, input.site, input.registry, { breakpointId: input.breakpointId, templateContext: input.templateContext, diff --git a/server/publish/visitorAuthRuntime.ts b/server/publish/visitorAuthRuntime.ts new file mode 100644 index 000000000..61c6acb20 --- /dev/null +++ b/server/publish/visitorAuthRuntime.ts @@ -0,0 +1,478 @@ +/** + * Browser runtime for visitor auth (login / register) + auth-state reveal. + * + * Self-contained ES module — no dependencies, no framework. The middleware + * injects a ` + + +` + +/** + * Built-in fallback register page — the sibling of the login fallback. + * + * Served inline by the visitor-auth middleware on a direct GET to `/register` + * when no published page exists there (mirrors the login fallback in D6). + * Same minimal self-contained styling as the login page; the form is wired to + * the visitor-auth runtime via `data-instatic-auth="register"`. + */ +export const BUILT_IN_REGISTER_PAGE_HTML = ` + + + + + Create your account + + + +
+
+

Create your account

+ +
+ + +
+
+ + +
+
+ + +
At least 8 characters.
+
+
+ + +
+ + + + + +
+
+ + + +` + +/** + * Built-in fallback "no access" page (D17). + * + * Served inline by the visitor-auth middleware when a LOGGED-IN visitor hits a + * page restricted to a group they are NOT a member of. Anonymous visitors get + * the login redirect instead (they may simply need to sign in) — this page is + * specifically for the authenticated-but-unauthorized case, so it links to + * login + register + home rather than redirecting (they're already signed in) + * or 404-ing (don't hide the page's existence). + * + * Same minimal self-contained styling as the login/register fallbacks; no + * form, so no visitor-auth runtime script is needed. + */ +export const BUILT_IN_NO_ACCESS_PAGE_HTML = ` + + + + + No access + + + +
+
+

You don't have access to this page

+

You're signed in, but this page is restricted to a member group you're not part of.

+ +
+
+ +` diff --git a/server/repositories/audit.ts b/server/repositories/audit.ts index f56a9a4b6..e64cc6a19 100644 --- a/server/repositories/audit.ts +++ b/server/repositories/audit.ts @@ -51,6 +51,22 @@ const AuditActionSchema = Type.Union([ Type.Literal('ai.chat.failed'), Type.Literal('ai.mcp_connector.created'), Type.Literal('ai.mcp_connector.revoked'), + // Visitor-auth admin actions (see server/handlers/cms/visitorAuth.ts). + Type.Literal('visitor_auth.config_updated'), + Type.Literal('visitor_auth.user_updated'), + Type.Literal('visitor_auth.user_deleted'), + // Visitor self-service auth actions (see server/visitor-auth/handlers.ts). + // The actor is the visitor themselves; `actor_user_id` is always NULL + // here because that column FK-references admin `users(id)` and visitor + // ids live in `visitor_users`. The visitor's identity is carried in + // `target_id` (free-text, no FK) + `metadata.email`. + Type.Literal('visitor.login.success'), + Type.Literal('visitor.login.failure'), + Type.Literal('visitor.login.locked'), + Type.Literal('visitor.logout'), + Type.Literal('visitor.register'), + Type.Literal('visitor.password.reset'), + Type.Literal('visitor.account.deleted'), ]) const AuditMetadataSchema = Type.Record( diff --git a/server/repositories/collabDocuments.ts b/server/repositories/collabDocuments.ts new file mode 100644 index 000000000..804d97e99 --- /dev/null +++ b/server/repositories/collabDocuments.ts @@ -0,0 +1,64 @@ +/** + * Collab document storage — one Yjs CRDT state blob per collab doc + * (`:`, see @core/collab). The blob is the live-editing source + * of truth; the relay (server/collab) derives JSON into `data_rows` / `site` + * on every persist so the publisher and non-editor reads never touch CRDT + * state. `seq` counts persists (diagnostics + future delta APIs). + * + * `generation` is the doc's CRDT LINEAGE id (see @core/collab/protocol). A + * reset deletes the row, so the next open mints a fresh one — which is exactly + * what lets both ends refuse a frame from a dead lineage. + */ +import { placeholder, type DbClient } from '../db/client' + +export interface StoredCollabDocument { + state: Uint8Array + /** '' for rows written before migration 023 — the relay mints one on open. */ + generation: string +} + +export async function getCollabDocumentState( + db: DbClient, + docId: string, +): Promise { + const { rows } = await db<{ state_blob: Uint8Array; generation: string }>` + select state_blob, generation from collab_documents + where doc_id = ${docId} + limit 1 + ` + const row = rows[0] + if (!row?.state_blob) return null + return { + state: row.state_blob instanceof Uint8Array ? row.state_blob : new Uint8Array(row.state_blob), + generation: typeof row.generation === 'string' ? row.generation : '', + } +} + +export async function putCollabDocumentState( + db: DbClient, + docId: string, + state: Uint8Array, + generation: string, +): Promise { + await db` + insert into collab_documents (doc_id, state_blob, seq, generation) + values (${docId}, ${state}, 1, ${generation}) + on conflict (doc_id) do update + set state_blob = excluded.state_blob, + seq = collab_documents.seq + 1, + generation = excluded.generation, + updated_at = current_timestamp + ` +} + +export async function deleteCollabDocuments( + db: DbClient, + docIds: readonly string[], +): Promise { + if (docIds.length === 0) return + const placeholders = docIds.map((_, i) => placeholder(db.dialect, i + 1)).join(', ') + await db.unsafe( + `delete from collab_documents where doc_id in (${placeholders})`, + [...docIds], + ) +} diff --git a/server/repositories/data/index.ts b/server/repositories/data/index.ts index 4d3e91a84..3e55ba96c 100644 --- a/server/repositories/data/index.ts +++ b/server/repositories/data/index.ts @@ -31,6 +31,8 @@ export { export { listDataRows, listDataRowIdSlugs, + listDataRowSeqs, + listChangedDataRowRefsSince, listDataRowsWithFilter, searchDataRows, getDataRow, @@ -41,6 +43,7 @@ export { createDataRow, createDataRowMany, saveDataRowDraft, + upsertDataRowDraft, updateDataRowDraftCells, saveDataRowDraftMany, softDeleteDataRow, diff --git a/server/repositories/data/rows/__tests__/mutations.test.ts b/server/repositories/data/rows/__tests__/mutations.test.ts index ab2b840ca..756560983 100644 --- a/server/repositories/data/rows/__tests__/mutations.test.ts +++ b/server/repositories/data/rows/__tests__/mutations.test.ts @@ -3,7 +3,7 @@ import { createSqliteClient } from '../../../../db/sqlite' import { sqliteMigrations } from '../../../../db/migrations-sqlite' import { runMigrations } from '../../../../db/runMigrations' import type { DbClient } from '../../../../db/client' -import { softDeleteDataRow } from '../mutations' +import { softDeleteDataRow, upsertDataRowDraft } from '../mutations' import { getDataRow } from '../read' const USER_ID = 'user-author' @@ -71,3 +71,51 @@ describe('softDeleteDataRow', () => { expect(await softDeleteDataRow(db, 'missing', USER_ID)).toBeNull() }) }) + +describe('upsertDataRowDraft', () => { + let db: DbClient + beforeEach(async () => { + db = await freshDb() + }) + + it('updates a live row in place', async () => { + await seedRow(db, 'post-1') + await upsertDataRowDraft( + db, + { id: 'post-1', tableId: 'posts', cells: { title: 'Updated' }, slug: 'updated' }, + USER_ID, + ) + const row = await getDataRow(db, 'post-1') + expect(row?.cells.title).toBe('Updated') + expect(row?.slug).toBe('updated') + }) + + it('creates a fresh row when the id is unknown', async () => { + await upsertDataRowDraft( + db, + { id: 'post-new', tableId: 'posts', cells: { title: 'Fresh' }, slug: 'fresh' }, + USER_ID, + ) + expect((await getDataRow(db, 'post-new'))?.cells.title).toBe('Fresh') + }) + + it('RESURRECTS a soft-deleted row instead of hitting its primary key', async () => { + // The undo-of-delete flow: a row the roster sweep soft-deleted, then a + // peer restored. getDataRow filters soft-deleted rows, so a plain insert + // would conflict on the still-present primary key forever. + await seedRow(db, 'post-1') + await softDeleteDataRow(db, 'post-1', USER_ID) + expect(await getDataRow(db, 'post-1')).toBeNull() // soft-deleted, hidden + + await upsertDataRowDraft( + db, + { id: 'post-1', tableId: 'posts', cells: { title: 'Revived' }, slug: 'revived' }, + USER_ID, + ) + + const revived = await getDataRow(db, 'post-1') + expect(revived).not.toBeNull() + expect(revived?.cells.title).toBe('Revived') + expect(revived?.deletedAt).toBeNull() + }) +}) diff --git a/server/repositories/data/rows/apply.ts b/server/repositories/data/rows/apply.ts index f3d91272e..a7c1d664e 100644 --- a/server/repositories/data/rows/apply.ts +++ b/server/repositories/data/rows/apply.ts @@ -103,7 +103,7 @@ export async function applyDataRowChangesInTx( // Already-deleted / unknown ids no-op for the same reason (idempotent). for (const rowId of deleteIds) { if (!existingSlugById.has(rowId)) continue - const deleted = await softDeleteDataRow(tx, rowId, actorUserId) + const deleted = await softDeleteDataRow(tx, rowId, actorUserId, { collabInternal: true }) if (!deleted) continue await stampDataRowSeq(tx, rowId, seq) if (deleted.status === 'published') deletedPublished = true @@ -132,7 +132,15 @@ export async function applyDataRowChangesInTx( await resurrectDataRow(tx, write.id, { cells: write.cells, slug: '' }, actorUserId) parked.push(write) } else { - await createDataRow(tx, { id: write.id, tableId, cells: write.cells, slug: write.slug }, actorUserId) + await createDataRow( + tx, + { id: write.id, tableId, cells: write.cells, slug: write.slug }, + actorUserId, + null, + // In-transaction: the caller notifies row-write listeners post-commit + // (a mid-transaction notification would fire even on rollback). + { collabInternal: true }, + ) } await stampDataRowSeq(tx, write.id, seq) } diff --git a/server/repositories/data/rows/index.ts b/server/repositories/data/rows/index.ts index c4d9fa393..0d452f16a 100644 --- a/server/repositories/data/rows/index.ts +++ b/server/repositories/data/rows/index.ts @@ -19,6 +19,8 @@ export { listDataRows, listDataRowIdSlugs, + listDataRowSeqs, + listChangedDataRowRefsSince, getDataRow, getDataRowMany, getDataRowBySlug, @@ -35,6 +37,7 @@ export { listDataRowsWithFilter } from './filter' export { createDataRow, saveDataRowDraft, + upsertDataRowDraft, updateDataRowDraftCells, softDeleteDataRow, updateDataRowTable, diff --git a/server/repositories/data/rows/mapper.ts b/server/repositories/data/rows/mapper.ts index 5b637f81f..ae8dee46b 100644 --- a/server/repositories/data/rows/mapper.ts +++ b/server/repositories/data/rows/mapper.ts @@ -39,6 +39,13 @@ export interface InsertDataRowInput { * tables that have no slug field. */ slug: string + /** + * Visitor who owns this row (per-visitor-data framework, Pillar 3). + * Stamped by the form handler from the validated session cookie when the + * target table opts in (`capturesVisitorOwner`). Omitted/NULL for ordinary + * rows and anonymous submits. + */ + visitorUserId?: string | null } export interface UpdateDataRowDraftInput { @@ -56,7 +63,9 @@ interface DataRowRow extends UserJoinColumns { cells_json: Record slug: string status: DataRowStatus + seq: number author_user_id: string | null + visitor_user_id: string | null created_by_user_id: string | null updated_by_user_id: string | null published_by_user_id: string | null @@ -78,7 +87,9 @@ function mapRow(row: DataRowRow): DataRow { cells: row.cells_json, slug: row.slug, status: row.status, + seq: Number(row.seq), authorUserId: row.author_user_id ?? null, + visitorUserId: row.visitor_user_id ?? null, createdByUserId: row.created_by_user_id ?? null, updatedByUserId: row.updated_by_user_id ?? null, publishedByUserId: row.published_by_user_id ?? null, @@ -114,7 +125,9 @@ const DATA_ROW_COLUMNS = `data_rows.id, data_rows.cells_json, data_rows.slug, data_rows.status, + data_rows.seq, data_rows.author_user_id, + data_rows.visitor_user_id, data_rows.created_by_user_id, data_rows.updated_by_user_id, data_rows.published_by_user_id, diff --git a/server/repositories/data/rows/mutations.ts b/server/repositories/data/rows/mutations.ts index c4e49ea32..109400c49 100644 --- a/server/repositories/data/rows/mutations.ts +++ b/server/repositories/data/rows/mutations.ts @@ -23,6 +23,7 @@ import { bumpPublishVersionSerialized } from '../../../publish/publishState' import { type InsertDataRowInput, type UpdateDataRowDraftInput } from './mapper' import { isoDateOrNull } from '@core/utils/isoDate' import { getDataRow } from './read' +import { notifyRowWrite } from '../../rowWriteEvents' type UpdateDataRowTableResult = | { ok: true; row: DataRow } @@ -33,7 +34,46 @@ export async function createDataRow( input: InsertDataRowInput, actorUserId: string | null = null, pluginActorId: string | null = null, + opts: { collabInternal?: boolean } = {}, ): Promise { + // `visitor_user_id` is written ONLY when the caller resolved a visitor + // from the validated session (per-visitor-data framework, Pillar 3). When + // it is absent the column is omitted entirely so it takes its NULL default + // — anonymous submits and non-opt-in tables are bit-for-bit identical to + // the pre-framework insert. + if (input.visitorUserId) { + const { rows } = await db<{ id: string }>` + insert into data_rows ( + id, + table_id, + cells_json, + slug, + status, + author_user_id, + created_by_user_id, + updated_by_user_id, + plugin_actor_id, + visitor_user_id + ) + values ( + ${input.id ?? nanoid()}, + ${input.tableId}, + ${input.cells}, + ${input.slug}, + ${'draft'}, + ${actorUserId}, + ${actorUserId}, + ${actorUserId}, + ${pluginActorId}, + ${input.visitorUserId} + ) + returning id + ` + const created = await getDataRow(db, rows[0].id) + if (!created) throw new Error('data row was created but could not be re-read') + return created + } + const { rows } = await db<{ id: string }>` insert into data_rows ( id, @@ -61,6 +101,11 @@ export async function createDataRow( ` const created = await getDataRow(db, rows[0].id) if (!created) throw new Error('data row was created but could not be re-read') + // Out-of-relay creations invalidate collab state (roster + row doc) — see + // rowWriteEvents. The relay's own persistence opts out. + if (!opts.collabInternal) { + notifyRowWrite({ tableId: created.tableId, rowIds: [created.id], kind: 'create' }) + } return created } @@ -70,9 +115,14 @@ export async function saveDataRowDraft( input: UpdateDataRowDraftInput, actorUserId: string | null = null, pluginActorId: string | null = null, + opts: { collabInternal?: boolean } = {}, ): Promise { const updated = await updateDataRowDraftCells(db, rowId, input, actorUserId, pluginActorId) - return updated ? getDataRow(db, rowId) : null + const row = updated ? await getDataRow(db, rowId) : null + if (row && !opts.collabInternal) { + notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'update' }) + } + return row } /** @@ -126,6 +176,40 @@ export async function resurrectDataRow( ` } +/** + * Idempotent draft write by id — update a live row, RESURRECT a soft-deleted + * one, or create it fresh. The three-way decision the collab relay needs: a + * row the roster sweep soft-deleted and a peer then restored (undo of a page + * delete) still occupies its primary key, so a plain insert would conflict — + * exactly the flow `apply.ts` handles for HTTP batches, here for one row. + */ +export async function upsertDataRowDraft( + db: DbClient, + input: InsertDataRowInput & { id: string }, + actorUserId: string | null = null, + opts: { collabInternal?: boolean } = {}, +): Promise { + const draft = { cells: input.cells, slug: input.slug } + const updated = await updateDataRowDraftCells(db, input.id, draft, actorUserId) + if (updated) { + if (!opts.collabInternal) { + notifyRowWrite({ tableId: input.tableId, rowIds: [input.id], kind: 'update' }) + } + return + } + const { rows } = await db<{ id: string }>` + select id from data_rows where id = ${input.id} and deleted_at is not null + ` + if (rows.length > 0) { + await resurrectDataRow(db, input.id, draft, actorUserId) + } else { + await createDataRow(db, input, actorUserId, null, { collabInternal: true }) + } + if (!opts.collabInternal) { + notifyRowWrite({ tableId: input.tableId, rowIds: [input.id], kind: 'create' }) + } +} + /** * Slug-only write — the second phase of the roster reconcile's two-phase * slug update (see rows/reconcile.ts). The row's cells and audit columns were @@ -158,6 +242,7 @@ export async function softDeleteDataRow( db: DbClient, rowId: string, actorUserId: string | null = null, + opts: { collabInternal?: boolean } = {}, ): Promise { const { rows } = await db<{ id: string @@ -176,6 +261,9 @@ export async function softDeleteDataRow( ` const row = rows[0] if (!row) return null + if (!opts.collabInternal) { + notifyRowWrite({ tableId: row.table_id, rowIds: [row.id], kind: 'delete' }) + } return { id: row.id, tableId: row.table_id, diff --git a/server/repositories/data/rows/read.ts b/server/repositories/data/rows/read.ts index c0be6c707..d0a9cb407 100644 --- a/server/repositories/data/rows/read.ts +++ b/server/repositories/data/rows/read.ts @@ -54,10 +54,10 @@ interface DataRowIdSlug { } /** - * Lightweight (id, slug) projection of a table's non-deleted rows. The roster - * reconcilers (PUT /pages, PUT /components) need exactly this — the reap diff - * and the cross-row slug-uniqueness check — so they must not pay the hydrated - * SELECT's full `cells_json` parse per row per save. + * Lightweight (id, slug) projection of a table's non-deleted rows. The + * transactional site-document save needs exactly this — the replace-mode reap + * diff and the cross-row slug-uniqueness check — so it must not pay the + * hydrated SELECT's full `cells_json` parse per row per save. */ export async function listDataRowIdSlugs( db: DbClient, @@ -89,6 +89,68 @@ export async function listSoftDeletedDataRowIds( return rows.map((r) => r.id) } +export interface DataRowSeq { + id: string + seq: number +} + +/** + * Lean (id, seq) projection for a set of row ids in one table — the + * transactional save's conflict check. Deliberately NO `deleted_at` filter: + * a remote soft-delete stamps the row's seq, and a stale client editing that + * row must see it as a conflict, not as absence. Runs INSIDE the save + * transaction (after `allocateSiteSeq` — the counter-row lock serializes + * concurrent saves, so the read is exact). + */ +export async function listDataRowSeqs( + db: DbClient, + tableId: string, + rowIds: ReadonlyArray, +): Promise { + if (rowIds.length === 0) return [] + const placeholders = rowIds.map((_, i) => placeholder(db.dialect, i + 2)).join(', ') + const { rows } = await db.unsafe( + `select id, seq from data_rows + where table_id = ${placeholder(db.dialect, 1)} and id in (${placeholders})`, + [tableId, ...rowIds], + ) + return rows.map((r) => ({ id: r.id, seq: Number(r.seq) })) +} + +export interface ChangedDataRowRef { + id: string + tableId: string + seq: number + deleted: boolean +} + +/** + * Lean refs of every row in the given tables whose seq is PAST the client's + * cursor — the reconnect delta of the live-sync channel. Soft-deleted rows + * included (a deletion the client missed must surface as a `rows-deleted` + * hint, not as silence). O(delta) via `data_rows_table_seq_idx`. + */ +export async function listChangedDataRowRefsSince( + db: DbClient, + tableIds: ReadonlyArray, + cursor: number, +): Promise { + if (tableIds.length === 0) return [] + const placeholders = tableIds.map((_, i) => placeholder(db.dialect, i + 2)).join(', ') + const { rows } = await db.unsafe<{ id: string; table_id: string; seq: number; deleted_at: string | null }>( + `select id, table_id, seq, deleted_at from data_rows + where seq > ${placeholder(db.dialect, 1)} and table_id in (${placeholders}) + order by seq asc`, + [cursor, ...tableIds], + ) + return rows.map((row) => ({ + id: row.id, + tableId: row.table_id, + seq: Number(row.seq), + deleted: row.deleted_at !== null, + })) +} + export async function getDataRow( db: DbClient, rowId: string, diff --git a/server/repositories/data/tables.ts b/server/repositories/data/tables.ts index 8f052ef9b..386b1c8ba 100644 --- a/server/repositories/data/tables.ts +++ b/server/repositories/data/tables.ts @@ -66,6 +66,12 @@ interface DataTableRow { * `0`/`1`, Postgres as a boolean — `mapTable` coerces both via `Boolean`. */ system: number | boolean + /** + * Per-table opt-in for the visitor-data framework (Pillar 3). `not null + * default 0` on both dialects; `mapTable` coerces via `Boolean` (same as + * `system`). True when the form handler should stamp `visitor_user_id`. + */ + captures_visitor_owner: number | boolean created_by_user_id: string | null updated_by_user_id: string | null /** @@ -88,6 +94,7 @@ function mapTable(row: DataTableRow): DataTable { primaryFieldId: row.primary_field_id, fields: normalizeDataTableFields(row.fields_json), system: Boolean(row.system), + capturesVisitorOwner: Boolean(row.captures_visitor_owner), createdByUserId: row.created_by_user_id ?? null, updatedByUserId: row.updated_by_user_id ?? null, createdAt: isoDate(row.created_at), @@ -98,7 +105,7 @@ function mapTable(row: DataTableRow): DataTable { export async function listDataTables(db: DbClient): Promise { const { rows } = await db` select id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at from data_tables where deleted_at is null @@ -126,7 +133,7 @@ export async function listDataTables(db: DbClient): Promise { export async function listDataTablesWithCounts(db: DbClient): Promise { const { rows } = await db` select t.id, t.name, t.slug, t.kind, t.route_base, t.singular_label, t.plural_label, - t.primary_field_id, t.fields_json, t.system, + t.primary_field_id, t.fields_json, t.system, t.captures_visitor_owner, t.created_by_user_id, t.updated_by_user_id, t.created_at, t.updated_at, coalesce( (select count(*) from data_rows r where r.table_id = t.id and r.deleted_at is null), @@ -153,7 +160,7 @@ export async function listDataTablesWithCounts(db: DbClient): Promise { const { rows } = await db` select id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at from data_tables where id = ${tableId} @@ -172,7 +179,7 @@ export async function getDataTable(db: DbClient, tableId: string): Promise { const { rows } = await db` select id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at from data_tables where slug = ${slug} @@ -215,7 +222,7 @@ export async function createDataTable( ${input.updatedByUserId ?? input.createdByUserId ?? null} ) returning id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at ` // NOTE: table creation is pure data access. Entry templates are ordinary @@ -244,7 +251,7 @@ export async function updateDataTable( where id = ${tableId} and deleted_at is null returning id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at ` return rows[0] ? mapTable(rows[0]) : null @@ -322,7 +329,7 @@ export async function softDeleteDataTable( where id = ${tableId} and deleted_at is null returning id, name, slug, kind, route_base, singular_label, plural_label, - primary_field_id, fields_json, system, + primary_field_id, fields_json, system, captures_visitor_owner, created_by_user_id, updated_by_user_id, created_at, updated_at ` return rows[0] ? mapTable(rows[0]) : null diff --git a/server/repositories/rowWriteEvents.ts b/server/repositories/rowWriteEvents.ts new file mode 100644 index 000000000..b50413146 --- /dev/null +++ b/server/repositories/rowWriteEvents.ts @@ -0,0 +1,57 @@ +/** + * Row-write notification seam — repositories announce writes; interested + * layers subscribe. Exists so the collab relay (server/collab) can reset + * CRDT documents when a row is written OUTSIDE the relay (plugin pack + * installs, HTTP site saves, data-workspace edits) WITHOUT repositories + * importing upward into server/collab. + * + * The relay's own persistence passes `collabInternal: true` through the + * repository write functions, which then skip the notification — otherwise + * every relay persist would reset the very documents it just persisted. + */ + +export type RowWriteKind = 'create' | 'update' | 'delete' + +export interface RowWriteEvent { + tableId: string + rowIds: readonly string[] + kind: RowWriteKind +} + +type RowWriteListener = (event: RowWriteEvent) => void + +const listeners = new Set() + +export function registerRowWriteListener(listener: RowWriteListener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function notifyRowWrite(event: RowWriteEvent): void { + for (const listener of listeners) { + try { + listener(event) + } catch (err) { + console.error('[rowWriteEvents] listener failed:', err) + } + } +} + +/** The shell (site row) equivalent — same seam, no table id. */ +type ShellWriteListener = () => void +const shellListeners = new Set() + +export function registerShellWriteListener(listener: ShellWriteListener): () => void { + shellListeners.add(listener) + return () => shellListeners.delete(listener) +} + +export function notifyShellWrite(): void { + for (const listener of shellListeners) { + try { + listener() + } catch (err) { + console.error('[rowWriteEvents] shell listener failed:', err) + } + } +} diff --git a/server/repositories/site.ts b/server/repositories/site.ts index 0d3cff389..6850954ff 100644 --- a/server/repositories/site.ts +++ b/server/repositories/site.ts @@ -22,6 +22,7 @@ import { validateSite } from '@core/persistence/validate' import { normalizeSitePackageJson } from '@core/site-dependencies/manifest' import { normalizeSiteRuntimeConfig } from '@core/site-runtime' import type { DbClient } from '../db/client' +import { notifyShellWrite } from './rowWriteEvents' import type { SiteRow } from '../types' const CMS_SITE_SCHEMA_VERSION = 1 @@ -85,6 +86,7 @@ export async function saveDraftSite( db: DbClient, shell: SiteShell, _actorUserId: string | null = null, + opts: { collabInternal?: boolean } = {}, ): Promise { await db` insert into site (id, name, settings_json) @@ -94,13 +96,35 @@ export async function saveDraftSite( settings_json = excluded.settings_json, updated_at = current_timestamp ` + // Out-of-relay shell writes invalidate the site collab doc — see + // rowWriteEvents. The relay's own persistence (and the transactional save, + // which notifies post-commit) opt out. + if (!opts.collabInternal) notifyShellWrite() +} + +/** + * The shell's current sync seq — 0 before setup (no site row) and for + * installs that predate the sync-sequence migration. The transactional save + * reads this inside the transaction for the shell conflict check; the GET + * shell endpoint returns it so clients can seed their base seq. + */ +export async function getDraftSiteSeq(db: DbClient): Promise { + const { rows } = await db<{ seq: number }>` + select seq from site + where id = 'default' + limit 1 + ` + return rows[0] ? Number(rows[0].seq) : 0 } /** * Stamp the site-global sync seq on the draft-site row. The transactional * site-document save calls this right after `saveDraftSite` inside the same - * transaction, so shell changes participate in delta reconciliation exactly - * like row changes (see repositories/syncSequence.ts). + * transaction — but ONLY when the shell content actually changed. An + * unconditional stamp would advance the shell seq on every row-only save and + * make the shell conflict check fire on every concurrent save pair; a + * conditional stamp keeps the shell seq an honest "shell content changed" + * signal (see repositories/syncSequence.ts). */ export async function stampDraftSiteSeq(db: DbClient, seq: number): Promise { await db` diff --git a/server/router.ts b/server/router.ts index 635ccb120..98e74a3ed 100644 --- a/server/router.ts +++ b/server/router.ts @@ -12,8 +12,11 @@ import { getSetupStatusCached } from './repositories/setup' import { getPublishedRuntimeAsset } from './repositories/runtimeAsset' import { handleLoopRequest, isLoopRuntimeAssetPath, serveLoopRuntimeAsset } from './handlers/cms/loop' import { handleHoleRequest, isHoleRuntimeAssetPath, serveHoleRuntimeAsset } from './handlers/cms/hole' +import { handleGateRequest, GATE_PATH_PREFIX } from './handlers/cms/gate' import { handleModuleJsAssetRequest, isModuleJsAssetPath } from './handlers/cms/moduleJs' import { handlePublicFormRequest } from './forms/handler' +import { handleVisitorRoutes } from './visitor-auth/handlers' +import { VISITOR_AUTH_RUNTIME_JS } from './publish/visitorAuthRuntime' import { isRuntimePackagePath, tryServeRuntimePackage } from './publish/runtime/packageServer' import { jsonResponse } from './http' import { binaryResponse, toArrayBuffer } from './binary' @@ -77,12 +80,20 @@ const routes: readonly RouteHandler[] = [ // `/admin/api/agent/tool-result` were deleted in Phase 3 of the AI // runtime rewrite. The site editor now POSTs `/admin/api/ai/chat/site`. tryServeAi, + // Visitor-auth API — `/api/visitor/*`. Sits before the CMS admin API so + // its distinct namespace (no `/admin/` prefix) is consumed here and never + // falls through to the admin dispatcher. See `server/visitor-auth/`. + tryServeVisitorRoutes, tryServeCmsApi, tryServeLoopRuntimeAsset, tryServeLoop, tryServeHoleRuntimeAsset, tryServeHole, + tryServeGate, tryServeModuleJsAsset, + // Visitor-auth browser runtime — `/_instatic/visitor-auth.js`. Kept next + // to the other runtime-asset handlers so the fixed CMS assets stay grouped. + tryServeVisitorAuthRuntimeAsset, tryServePublicForm, tryServeRuntimeAsset, tryServeRuntimePackageNamespace, @@ -169,6 +180,18 @@ function tryServeCmsApi(req: Request, runtime: ServerRuntime, _url: URL, pathnam }) } +/** + * Visitor-auth API namespace — `/api/visitor/*`. Lives outside `/admin/api/` + * because visitors are not admins: a separate cookie + * (`instatic_visitor_session`), separate tables, separate code. + * `handleVisitorRoutes` owns the entire namespace and emits its own 404 for + * unknown sub-paths so they never fall through to the public route renderer. + */ +function tryServeVisitorRoutes(req: Request, runtime: ServerRuntime, _url: URL, pathname: string): Promise | null { + if (!pathname.startsWith('/api/visitor/')) return null + return handleVisitorRoutes(req, runtime.db) +} + /** * The loop runtime is a fixed CMS asset, served before the per-site * runtime asset lookup so the request never falls through. @@ -193,6 +216,24 @@ function tryServeHoleRuntimeAsset(req: Request, _runtime: ServerRuntime, _url: U return serveHoleRuntimeAsset() } +/** + * Visitor-auth browser runtime — `/_instatic/visitor-auth.js`. A fixed CMS + * asset (form interception + auth-state reveal) served before the public + * route renderer so the exact path is consumed here. Mirrors the hole + * runtime asset handler. + */ +function tryServeVisitorAuthRuntimeAsset(req: Request, _runtime: ServerRuntime, _url: URL, pathname: string): Response | null { + if (req.method !== 'GET' || pathname !== '/_instatic/visitor-auth.js') return null + return new Response(VISITOR_AUTH_RUNTIME_JS, { + headers: { + 'content-type': 'application/javascript; charset=utf-8', + // 1 hour — the path is a well-known fixed CMS asset that only changes + // on a CMS version bump. Use deploy-time cache-busting if you need longer. + 'cache-control': 'public, max-age=3600', + }, + }) +} + /** * Layer C hole fragment endpoint — `/_instatic/hole/`. * Renders a dynamic node subtree on-demand and caches the result via Layer B. @@ -202,6 +243,17 @@ function tryServeHole(req: Request, runtime: ServerRuntime, url: URL, pathname: return handleHoleRequest(req, url, { db: runtime.db }) } +/** + * Layer C auth-gate fragment endpoint — `/_instatic/gate/`. Renders a + * gated `base.container` subtree only for authorised visitors and returns the + * baked fallback for everyone else. Kept next to `tryServeHole` so the + * hole-related endpoints stay grouped and before `tryServeStaticAsset` (D11). + */ +function tryServeGate(req: Request, runtime: ServerRuntime, url: URL, pathname: string): Promise | null { + if (!pathname.startsWith(GATE_PATH_PREFIX)) return null + return handleGateRequest(req, url, { db: runtime.db }) +} + /** * Per-module published JS — `/_instatic/module-js/.js`. Prefix- * namespaced: unknown paths under the prefix 404 inside the handler rather diff --git a/server/visitor-auth/__tests__/roles.test.ts b/server/visitor-auth/__tests__/roles.test.ts new file mode 100644 index 000000000..c11f4d1b6 --- /dev/null +++ b/server/visitor-auth/__tests__/roles.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, beforeEach } from 'bun:test' +import { createSqliteClient } from '../../db/sqlite' +import { sqliteMigrations } from '../../db/migrations-sqlite' +import { runMigrations } from '../../db/runMigrations' +import type { DbClient } from '../../db/client' +import { createVisitorUser } from '../repositories' +import { + VisitorRoleMutationError, + createVisitorRole, + deleteVisitorRole, + findVisitorRoleById, + listVisitorRoles, + updateVisitorRole, +} from '../roles' + +async function freshDb(): Promise { + const db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + return db +} + +describe('visitor role CRUD', () => { + let db: DbClient + + beforeEach(async () => { + db = await freshDb() + }) + + it('seeds the two system roles (member/admin)', async () => { + const roles = await listVisitorRoles(db) + const names = roles.map((r) => r.name) + expect(names).toContain('member') + expect(names).toContain('admin') + expect(roles.filter((r) => r.isSystem)).toHaveLength(2) + }) + + it('creates a custom role and lists it after the system roles', async () => { + const role = await createVisitorRole(db, { name: 'editor', capabilities: ['content.read'] }) + expect(role.name).toBe('editor') + expect(role.capabilities).toEqual(['content.read']) + expect(role.isSystem).toBe(false) + expect(role.id).toBeTruthy() + + const roles = await listVisitorRoles(db) + // System roles sort first (is_system desc), then custom by name. + expect(roles.map((r) => r.name)).toEqual(['admin', 'member', 'editor']) + // GET /roles still returns the new custom role (acceptance #4). + expect(roles.some((r) => r.id === role.id)).toBe(true) + }) + + it('rejects a duplicate name with a 409 mutation error', async () => { + await createVisitorRole(db, { name: 'editor', capabilities: [] }) + await expect( + createVisitorRole(db, { name: 'editor', capabilities: [] }), + ).rejects.toMatchObject({ name: 'VisitorRoleMutationError', status: 409 }) + }) + + it('updates a custom role name and capabilities', async () => { + const created = await createVisitorRole(db, { name: 'editor', capabilities: ['content.read'] }) + const updated = await updateVisitorRole(db, created.id, { + name: 'senior-editor', + capabilities: ['content.read', 'content.write'], + }) + expect(updated?.name).toBe('senior-editor') + expect(updated?.capabilities).toEqual(['content.read', 'content.write']) + }) + + it('allows editing a system role (capabilities) — system roles are editable', async () => { + const updated = await updateVisitorRole(db, 'member', { capabilities: ['content.read'] }) + expect(updated?.name).toBe('member') + expect(updated?.isSystem).toBe(true) + expect(updated?.capabilities).toEqual(['content.read']) + }) + + it('rejects renaming a role onto an existing name (409)', async () => { + await createVisitorRole(db, { name: 'editor', capabilities: [] }) + const writer = await createVisitorRole(db, { name: 'writer', capabilities: [] }) + await expect(updateVisitorRole(db, writer.id, { name: 'editor' })).rejects.toMatchObject({ + name: 'VisitorRoleMutationError', + status: 409, + }) + }) + + it('updateVisitorRole returns null for a missing role', async () => { + expect(await updateVisitorRole(db, 'nope', { name: 'x' })).toBeNull() + }) + + it('deletes a custom role when unassigned', async () => { + const created = await createVisitorRole(db, { name: 'editor', capabilities: [] }) + const deleted = await deleteVisitorRole(db, created.id) + expect(deleted?.id).toBe(created.id) + expect(await findVisitorRoleById(db, created.id)).toBeNull() + }) + + it('refuses to delete a system role (409)', async () => { + await expect(deleteVisitorRole(db, 'member')).rejects.toMatchObject({ + name: 'VisitorRoleMutationError', + status: 409, + }) + await expect(deleteVisitorRole(db, 'admin')).rejects.toMatchObject({ + name: 'VisitorRoleMutationError', + status: 409, + }) + // The system role is still there. + expect(await findVisitorRoleById(db, 'member')).not.toBeNull() + }) + + it('refuses to delete a custom role that still has visitors assigned (409)', async () => { + const role = await createVisitorRole(db, { name: 'editor', capabilities: [] }) + await createVisitorUser(db, { + id: 'v_1', + email: 'v@example.com', + emailNormalized: 'v@example.com', + passwordHash: 'hash', + displayName: 'V', + roleId: role.id, + }) + // An ACTIVE visitor referencing the role blocks deletion (mirrors the + // admin `deleteCustomRole` guard — count of active assignees). The + // `visitor_users.role_id` FK is `on delete restrict`, so a referencing + // active visitor must be removed first. + await expect(deleteVisitorRole(db, role.id)).rejects.toMatchObject({ + name: 'VisitorRoleMutationError', + status: 409, + }) + // Reassigning the visitor to a different role frees this one for deletion. + await db`update visitor_users set role_id = 'member', updated_at = current_timestamp where id = 'v_1'` + const deleted = await deleteVisitorRole(db, role.id) + expect(deleted?.id).toBe(role.id) + }) + + it('deleteVisitorRole returns null for a missing role', async () => { + expect(await deleteVisitorRole(db, 'nope')).toBeNull() + }) +}) + +describe('VisitorRoleMutationError', () => { + it('carries a status field defaulting to 400', () => { + const err = new VisitorRoleMutationError('boom') + expect(err.status).toBe(400) + expect(err.message).toBe('boom') + expect(err.name).toBe('VisitorRoleMutationError') + }) +}) diff --git a/server/visitor-auth/config.ts b/server/visitor-auth/config.ts new file mode 100644 index 000000000..76e617509 --- /dev/null +++ b/server/visitor-auth/config.ts @@ -0,0 +1,154 @@ +/** + * Visitor-auth configuration — read/write the single `visitor_auth_config` row. + * + * NOT stored in `site.settings_json`. `SiteSettingsSchema` is a closed + * `Type.Object` and `parseSiteSettings` silently drops unknown keys, so a + * `visitorAuth` field would not survive a publish/parse round-trip without + * invasive core-type changes. A dedicated single-row config table is + * cleaner, fully testable, and avoids touching the core settings type + * system. See `docs/impl/SPEC-1-migrations.md` (deviation note). + * + * An in-memory cache front-ends the read: Phase-1 visitor auth is not + * publish-versioned, so a manual reset on write (`resetVisitorAuthConfigCache`, + * called by `saveVisitorAuthConfig`) is sufficient to keep every worker's + * view consistent within one process. + * + * Phase 3 (D14/D15): the retired Phase-1/2 `protectedPrefixes` model is gone — + * page access is now per-page (a `Page.access` field, see + * `src/core/page-tree/page.ts`). The config now carries `defaultLandingPath` + * (D15 fallback landing for a logged-in visitor with no primary-group landing). + * The `024_page_access` migration added this column and backfilled the old + * prefix config onto matching pages, then dropped `protected_prefixes_json`. + */ +import type { DbClient } from '../db/client' +import { + DEFAULT_VISITOR_AUTH_CONFIG, + VISITOR_AUTH_CONFIG_ROW_ID, + type VisitorAuthConfig, + type VisitorAuthConfigRow, + type VisitorProfileField, +} from './types' + +function rowToConfig(row: VisitorAuthConfigRow | undefined | null): VisitorAuthConfig { + if (!row) return { ...DEFAULT_VISITOR_AUTH_CONFIG } + return { + enabled: Boolean(row.enabled), + defaultLandingPath: + typeof row.default_landing_path === 'string' && row.default_landing_path + ? row.default_landing_path + : DEFAULT_VISITOR_AUTH_CONFIG.defaultLandingPath, + loginPath: + typeof row.login_path === 'string' && row.login_path + ? row.login_path + : DEFAULT_VISITOR_AUTH_CONFIG.loginPath, + registrationOpen: Boolean(row.registration_open), + defaultRole: + typeof row.default_role === 'string' && row.default_role + ? row.default_role + : DEFAULT_VISITOR_AUTH_CONFIG.defaultRole, + profileFields: normalizeProfileFieldDefs(row.profile_fields_json), + } +} + +/** + * Coerce a raw profile_fields_json config cell into a VisitorProfileField[]. + * Defensive against string | array | undefined and malformed entries so a + * corrupt row never breaks config reads — bad entries are dropped. + */ +function normalizeProfileFieldDefs(raw: VisitorAuthConfigRow['profile_fields_json']): VisitorProfileField[] { + let arr: unknown + if (raw == null) return [] + if (typeof raw === 'string') { try { arr = JSON.parse(raw) } catch { return [] } } else { arr = raw } + if (!Array.isArray(arr)) return [] + return arr.filter((f): f is VisitorProfileField => + !!f && typeof f === 'object' && typeof (f as VisitorProfileField).id === 'string' + ) +} + +let cached: VisitorAuthConfig | null = null + +/** Drop the cached config — call whenever the row is written. */ +export function resetVisitorAuthConfigCache(): void { + cached = null +} + +/** + * Read the visitor-auth config. The single `'default'` row is the source of + * truth; a missing row (fresh install before the migration seeds, or a hand- + * deleted row) resolves to `DEFAULT_VISITOR_AUTH_CONFIG` (disabled). The + * parsed result is cached for the process; `saveVisitorAuthConfig` resets + * the cache after a write. + */ +export async function getVisitorAuthConfig(db: DbClient): Promise { + if (cached) return cached + const { rows } = await db` + select id, enabled, default_landing_path, login_path, registration_open, default_role, profile_fields_json, updated_at + from visitor_auth_config + where id = ${VISITOR_AUTH_CONFIG_ROW_ID} + limit 1 + ` + cached = rowToConfig(rows[0]) + return cached +} + +/** + * UPSERT the config row: read current (or default), merge the patch, write + * back, reset the cache, return the new config. The single row keeps the + * write path simple — no partial-column UPDATE logic, just a full overwrite + * of the merged value. + * + * Legacy `protectedPrefixes` is no longer a config field (retired in Phase 3); + * callers that still send it are ignored silently by the schema. + */ +export async function saveVisitorAuthConfig( + db: DbClient, + patch: Partial, +): Promise { + const current = await getVisitorAuthConfig(db) + const next: VisitorAuthConfig = { + ...current, + ...patch, + // Defensive: never let a caller blank out a required string field. + loginPath: patch.loginPath?.trim() ? patch.loginPath.trim() : current.loginPath, + defaultRole: patch.defaultRole?.trim() ? patch.defaultRole.trim() : current.defaultRole, + defaultLandingPath: + patch.defaultLandingPath !== undefined + ? resolveLandingPath(patch.defaultLandingPath) + : current.defaultLandingPath, + } + + // UPSERT against the fixed `'default'` id. The row is seeded by the + // migration, but a hand-deleted row must still be re-creatable here so + // `saveVisitorAuthConfig` is the single write entrypoint. + await db` + insert into visitor_auth_config (id, enabled, default_landing_path, login_path, registration_open, default_role, profile_fields_json, updated_at) + values ( + ${VISITOR_AUTH_CONFIG_ROW_ID}, + ${next.enabled}, + ${next.defaultLandingPath}, + ${next.loginPath}, + ${next.registrationOpen}, + ${next.defaultRole}, + ${JSON.stringify(next.profileFields ?? [])}, + current_timestamp + ) + on conflict (id) do update + set enabled = excluded.enabled, + default_landing_path = excluded.default_landing_path, + login_path = excluded.login_path, + registration_open = excluded.registration_open, + default_role = excluded.default_role, + profile_fields_json = excluded.profile_fields_json, + updated_at = current_timestamp + ` + // Update the cache so a concurrent reader sees the just-persisted value + // without a round-trip. + cached = next + return next +} + +/** Resolve a landing path: fall back to the configured default when blank. */ +function resolveLandingPath(path: string): string { + const trimmed = path.trim() + return trimmed || DEFAULT_VISITOR_AUTH_CONFIG.defaultLandingPath +} diff --git a/server/visitor-auth/gateHelpers.ts b/server/visitor-auth/gateHelpers.ts new file mode 100644 index 000000000..2864122b5 --- /dev/null +++ b/server/visitor-auth/gateHelpers.ts @@ -0,0 +1,58 @@ +/** + * Session + authorisation helper for the `/_instatic/gate/` endpoint. + * + * A gated container declares the member group ids that may view its subtree + * (`base.container.authGate` — a `string[]` since Phase 3 / D16). The gate + * endpoint reads the visitor session off the request cookie and asks this + * helper whether the holder is a member of at least one of `requiredGroups`. + * + * Authorisation rule (Phase 3 / D16 — pure group-based; the Phase-2 + * role-based gate semantics are retired): + * - No session / expired / revoked session → NOT authorised (`anonymous`). + * - Session present but the visitor is not a member of ANY of the required + * groups → NOT authorised (`wrong_group`). + * - Otherwise → authorised (`ok`). + * + * Imports are read-only against the visitor-auth surface (sessions + + * repositories + groups); this file owns no DB writes and mutates no visitor + * state. + */ +import type { DbClient } from '../db/client' +import { validateVisitorSession } from './sessions' +import { listGroupIdsForVisitor } from './groups' + +export interface GateAuthResult { + authorized: boolean + reason: 'anonymous' | 'wrong_group' | 'ok' +} + +/** + * Resolve whether the request's visitor session satisfies `requiredGroups`. + * + * Returns `{ authorized: true, reason: 'ok' }` when the session holder is a + * member of at least one of the required groups. Returns + * `{ authorized: false, reason: 'anonymous' }` when there is no valid + * session, and `{ authorized: false, reason: 'wrong_group' }` when the + * session exists but the visitor is in none of the required groups. + * + * A missing visitor record (deleted between login + request) is treated as + * `anonymous` rather than thrown — there is no longer a session to honour. + * An empty `requiredGroups` list authorises everyone (a not-gated container + * never reaches the gate endpoint, but this keeps the helper total). + */ +export async function checkGateAccess( + db: DbClient, + req: Request, + requiredGroups: string[], +): Promise { + if (requiredGroups.length === 0) return { authorized: true, reason: 'ok' } + + const session = await validateVisitorSession(db, req) + if (!session) return { authorized: false, reason: 'anonymous' } + + const userGroupIds = await listGroupIdsForVisitor(db, session.userId) + const allowed = requiredGroups.some((g) => userGroupIds.includes(g)) + return allowed + ? { authorized: true, reason: 'ok' } + : { authorized: false, reason: 'wrong_group' } +} diff --git a/server/visitor-auth/groups.ts b/server/visitor-auth/groups.ts new file mode 100644 index 000000000..62cb5b121 --- /dev/null +++ b/server/visitor-auth/groups.ts @@ -0,0 +1,399 @@ +/** + * Visitor member-group + membership DB CRUD (Phase 3 — D13/D14/D15). + * + * Extracted as its own module mirroring `./roles.ts` (which itself mirrors + * the admin `server/repositories/roles.ts` split): this file owns the + * `visitor_groups` table and the `visitor_user_groups` junction ONLY. Visitor + * roles live in `./roles.ts`; users/sessions/etc. live in `./repositories.ts`. + * + * A group is a content-segmentation segment (page-level access — D14 — and + * login-redirect landing — D15), orthogonal to a role (capabilities — D13). + * + * All SQL is ANSI-standard (no Postgres-isms — see `repositories.ts` header + * for the full dialect rules) so the same statements run unchanged against + * both the Postgres and SQLite adapters. `current_timestamp` is the portable + * "now" expression. Rows map to the camelCase `VisitorGroup` / `VisitorUserGroup` + * domain shapes through `rowTo*`; column lists are inlined into each tagged + * template (matching `server/repositories/roles.ts`) because a + * `db\`select ${COLS}\`` would interpolate the constant as a bind parameter, + * not as SQL text. + * + * Policy: a group is always deletable — the junction CASCADEs, so deleting a + * group also removes every membership. A visitor's `primary_group_id` is + * `ON DELETE SET NULL`, so deleting the primary group simply clears the + * pointer (the visitor then falls back to the default landing path on login). + */ +import { nanoid } from 'nanoid' +import type { DbClient } from '../db/client' +import { placeholder } from '../db/client' +import type { + VisitorGroup, + VisitorGroupRow, + VisitorUserGroup, + VisitorUserGroupRow, +} from './types' + +/** + * Typed mutation error for visitor-group writes. Carries an HTTP `status` + * (default 400) so the shared `mutationErrorResponse` handler can translate + * it into the `{ error }` JSON envelope. Mirrors `VisitorRoleMutationError`. + */ +export class VisitorGroupMutationError extends Error { + readonly status: number + + constructor(message: string, status = 400) { + super(message) + this.name = 'VisitorGroupMutationError' + this.status = status + } +} + +/** + * Derive a lowercase-kebab slug from a group name (e.g. "Founders!" → + * "founders"). Non-alphanumeric runs collapse to a single hyphen; the result + * is trimmed of leading/trailing hyphens. The slug is NOT unique (only + * `name` is) — it is a human-friendly identifier for diagnostics / future + * deep links. + */ +export function slugifyGroupName(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function rowToGroup(row: VisitorGroupRow): VisitorGroup { + return { + id: row.id, + name: row.name, + slug: row.slug, + landingPath: row.landing_path, + description: row.description, + isSystem: Boolean(row.is_system), + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + } +} + +function rowToUserGroup(row: VisitorUserGroupRow): VisitorUserGroup { + return { + id: row.id, + userId: row.user_id, + groupId: row.group_id, + createdAt: new Date(row.created_at).toISOString(), + } +} + +// --------------------------------------------------------------------------- +// Group CRUD +// --------------------------------------------------------------------------- + +export async function findVisitorGroupById(db: DbClient, id: string): Promise { + const { rows } = await db` + select id, name, slug, landing_path, description, is_system, created_at, updated_at + from visitor_groups + where id = ${id} + limit 1 + ` + return rows[0] ? rowToGroup(rows[0]) : null +} + +export async function findVisitorGroupByName(db: DbClient, name: string): Promise { + const { rows } = await db` + select id, name, slug, landing_path, description, is_system, created_at, updated_at + from visitor_groups + where name = ${name} + limit 1 + ` + return rows[0] ? rowToGroup(rows[0]) : null +} + +export async function listVisitorGroups(db: DbClient): Promise { + const { rows } = await db` + select id, name, slug, landing_path, description, is_system, created_at, updated_at + from visitor_groups + order by is_system desc, name asc + ` + return rows.map(rowToGroup) +} + +/** + * Pre-check name uniqueness (excluding the row currently being edited, if + * any) so a duplicate name surfaces a clean 409 instead of a dialect-specific + * DB constraint violation. The `visitor_groups.name` UNIQUE constraint is the + * backstop; this is the friendly, portable front line — same shape as the + * role-name pre-check in `./roles.ts`. + */ +async function assertVisitorGroupNameAvailable( + db: DbClient, + name: string, + currentGroupId?: string, +): Promise { + const existing = await findVisitorGroupByName(db, name) + if (existing && existing.id !== currentGroupId) { + throw new VisitorGroupMutationError('Visitor group name is already in use', 409) + } +} + +/** Resolve a landing path: fall back to '/' when blank. */ +function resolveLandingPath(path: string | undefined): string { + const trimmed = (path ?? '').trim() + return trimmed || '/' +} + +export interface CreateVisitorGroupInput { + name: string + landingPath?: string + description?: string +} + +/** + * Create a visitor group. The slug is derived from the name; `landingPath` + * defaults to `/`. A duplicate name is rejected up front via + * {@link assertVisitorGroupNameAvailable} so the caller gets a 409, not a raw + * constraint error. Custom groups are always created with `is_system = false`. + */ +export async function createVisitorGroup( + db: DbClient, + input: CreateVisitorGroupInput, +): Promise { + const name = input.name.trim() + if (!name) throw new VisitorGroupMutationError('Visitor group name is required') + await assertVisitorGroupNameAvailable(db, name) + + const id = nanoid() + const slug = slugifyGroupName(name) + const landingPath = resolveLandingPath(input.landingPath) + const description = (input.description ?? '').trim() + const { rows } = await db` + insert into visitor_groups (id, name, slug, landing_path, description, is_system) + values (${id}, ${name}, ${slug}, ${landingPath}, ${description}, ${false}) + returning id, name, slug, landing_path, description, is_system, created_at, updated_at + ` + return rowToGroup(rows[0]!) +} + +export interface UpdateVisitorGroupInput { + name?: string + landingPath?: string + description?: string +} + +/** + * Update a visitor group's name and/or landing path and/or description. The + * slug is re-derived when the name changes. A rename that collides with + * another group's name is rejected with a 409. + */ +export async function updateVisitorGroup( + db: DbClient, + groupId: string, + input: UpdateVisitorGroupInput, +): Promise { + const current = await findVisitorGroupById(db, groupId) + if (!current) return null + + const name = input.name === undefined ? current.name : input.name.trim() + if (!name) throw new VisitorGroupMutationError('Visitor group name is required') + await assertVisitorGroupNameAvailable(db, name, current.id) + // Re-derive the slug whenever the name changes so it never drifts from the + // (possibly renamed) name. Kept as a stored column purely for diagnostics. + const slug = input.name === undefined ? current.slug : slugifyGroupName(name) + const landingPath = input.landingPath === undefined ? current.landingPath : resolveLandingPath(input.landingPath) + const description = input.description === undefined ? current.description : input.description.trim() + + const { rows } = await db` + update visitor_groups + set name = ${name}, + slug = ${slug}, + landing_path = ${landingPath}, + description = ${description}, + updated_at = current_timestamp + where id = ${groupId} + returning id, name, slug, landing_path, description, is_system, created_at, updated_at + ` + return rows[0] ? rowToGroup(rows[0]) : null +} + +/** + * Delete a visitor group. The `visitor_user_groups` junction CASCADEs, so + * deleting the group also removes every membership. A visitor whose primary + * group is the deleted row keeps browsing (the `visitor_users.primary_group_id` + * FK is `ON DELETE SET NULL`); their next login simply falls back to the + * configured default landing path. Returns the deleted group, or `null` when + * no row matched `groupId`. + */ +export async function deleteVisitorGroup(db: DbClient, groupId: string): Promise { + const current = await findVisitorGroupById(db, groupId) + if (!current) return null + await db`delete from visitor_groups where id = ${groupId}` + return current +} + +// --------------------------------------------------------------------------- +// Membership (visitor_user_groups junction) +// --------------------------------------------------------------------------- + +/** + * The membership-set view returned by {@link listGroupsForVisitor}: the groups + * a visitor belongs to plus whether one of them is their designated primary. + */ +export interface VisitorMembershipView { + group: VisitorGroup + isPrimary: boolean +} + +/** + * Add a visitor to one or more groups. Idempotent: the junction's + * UNIQUE(user_id, group_id) means a pre-existing membership is left in place + * (no error). Unknown group ids are silently skipped (defensive — the caller + * is expected to have validated them, but a race-free guarantee isn't worth a + * round-trip per id). Returns the FULL current membership set after the add. + */ +export async function addGroupsToVisitor( + db: DbClient, + userId: string, + groupIds: string[], +): Promise { + const uniqueIds = [...new Set(groupIds)] + if (uniqueIds.length > 0) { + // Resolve which of the requested ids actually exist so we never insert a + // dangling membership row (the FK would reject it anyway — this gives a + // clean set to INSERT without per-row error handling). The shared + // `DbClient` tagged-template form can't expand a JS array into a SQL IN + // list, so we build the placeholder list explicitly through `placeholder()` + // (mirrors `loadFolderIdsForAssets` in server/repositories/media.ts). + const placeholders = uniqueIds.map((_, i) => placeholder(db.dialect, i + 1)).join(', ') + const { rows: existing } = await db.unsafe<{ id: string }>( + `select id from visitor_groups where id in (${placeholders})`, + uniqueIds, + ) + const existingIds = existing.map((r) => r.id) + for (const groupId of existingIds) { + // ON CONFLICT keeps this idempotent across re-applies. + await db` + insert into visitor_user_groups (id, user_id, group_id) + values (${nanoid()}, ${userId}, ${groupId}) + on conflict (user_id, group_id) do nothing + ` + } + } + return listGroupsForVisitor(db, userId) +} + +/** + * Remove a visitor from a single group. Returns true when a row was removed. + * Removing the visitor's primary group does NOT clear `primary_group_id` + * here — callers that want the pointer cleared should call + * {@link setVisitorPrimaryGroup} with `null`, or rely on the + * `ON DELETE CASCADE` path when the whole group is deleted. + */ +export async function removeVisitorFromGroup( + db: DbClient, + userId: string, + groupId: string, +): Promise { + const result = await db` + delete from visitor_user_groups + where user_id = ${userId} and group_id = ${groupId} + ` + return result.rowCount > 0 +} + +/** + * List every group a visitor belongs to, flagging the one (if any) that is + * their designated primary group. Used by the middleware (page-access check) + * and the admin membership endpoints. + */ +export async function listGroupsForVisitor( + db: DbClient, + userId: string, +): Promise { + const { rows } = await db` + select g.id, g.name, g.slug, g.landing_path, g.description, g.is_system, g.created_at, g.updated_at, + case when u.primary_group_id = g.id then 1 else 0 end as is_primary + from visitor_groups g + join visitor_user_groups m on m.group_id = g.id + join visitor_users u on u.id = m.user_id + where m.user_id = ${userId} + and u.deleted_at is null + order by g.name asc + ` + return rows.map((r) => ({ + group: rowToGroup(r), + isPrimary: Boolean(r.is_primary), + })) +} + +/** + * Resolve a visitor to the flat list of group ids they belong to. The page- + * access middleware's hot path — kept separate from {@link listGroupsForVisitor} + * so it never builds the richer `VisitorMembershipView` objects. + */ +export async function listGroupIdsForVisitor(db: DbClient, userId: string): Promise { + const { rows } = await db<{ group_id: string }>` + select group_id from visitor_user_groups where user_id = ${userId} + ` + return rows.map((r) => r.group_id) +} + +/** + * List every membership row in a group (the junction side). Returns the raw + * membership — callers join to `visitor_users` for display via the handler. + */ +export async function listMembershipsForGroup( + db: DbClient, + groupId: string, +): Promise { + const { rows } = await db` + select id, user_id, group_id, created_at + from visitor_user_groups + where group_id = ${groupId} + order by created_at asc + ` + return rows.map(rowToUserGroup) +} + +/** + * Set a visitor's designated primary group (D15). Pass `null` to clear it (the + * visitor then falls back to the configured default landing path on login). + * Setting a primary group the visitor is NOT a member of is rejected (409) — + * the primary group must be one of the visitor's memberships. + */ +export async function setVisitorPrimaryGroup( + db: DbClient, + userId: string, + groupId: string | null, +): Promise { + if (groupId !== null) { + const { rows } = await db<{ count: number }>` + select count(*) as count + from visitor_user_groups + where user_id = ${userId} and group_id = ${groupId} + ` + if (Number(rows[0]?.count ?? 0) === 0) { + throw new VisitorGroupMutationError( + 'Primary group must be one of the visitor\'s memberships', + 409, + ) + } + } + await db` + update visitor_users + set primary_group_id = ${groupId}, + updated_at = current_timestamp + where id = ${userId} + and deleted_at is null + ` +} + +/** Read a visitor's primary group id (or `null` when unset / user missing). */ +export async function getVisitorPrimaryGroupId( + db: DbClient, + userId: string, +): Promise { + const { rows } = await db<{ primary_group_id: string | null }>` + select primary_group_id from visitor_users where id = ${userId} and deleted_at is null + ` + return rows[0]?.primary_group_id ?? null +} diff --git a/server/visitor-auth/handlers.ts b/server/visitor-auth/handlers.ts new file mode 100644 index 000000000..51540bf8d --- /dev/null +++ b/server/visitor-auth/handlers.ts @@ -0,0 +1,696 @@ +/** + * Visitor-auth HTTP endpoints — `/api/visitor/*`. + * + * `handleVisitorRoutes` owns the entire `/api/visitor/` namespace: CSRF + * gating, dispatch, and a terminal 404 for any unknown sub-path (so an + * unknown `/api/visitor/whatever` never falls through to the public route + * renderer). The visitor namespace is intentionally isolated from the admin + * auth surface (`/admin/api/cms/*`) — different cookie, different tables, + * different code (D3 import whitelist). + * + * Phase-1 endpoints (full): POST /register, POST /login, POST /logout, + * GET /me, PATCH /me. + * Phase-2 endpoints (full): POST /forgot, POST /reset, DELETE /me. + * + * The parameterized gate endpoint lives at `/_instatic/gate/` and is + * owned by the gate renderer — there is intentionally no `/api/visitor/gate/*` + * route here (PRD §4.8 / D11). + * + * Login mirrors `server/handlers/cms/auth.ts` closely: per-IP then per- + * (ip,email) rate limits, constant-time dummy-hash verify on the no-user + * branch (so email enumeration via timing is impossible), per-account + * lockout checked AFTER the constant-time verify, login-attempt log via + * `recordVisitorLoginAttempt`, plus `visitor.*` audit events emitted from + * each auth outcome (register/login/logout/reset/delete) so member sign-in + * activity is visible in the admin audit feed. + * + * Audit note: every visitor event uses `actorUserId: null` + + * `targetType: 'visitor_user'`. The `actor_user_id` column FK-references the + * admin `users(id)` table, so it cannot hold a visitor id (visitors live in + * `visitor_users`). The visitor's identity is carried in `target_id` + * (free-text, no FK) + `metadata.email`. + */ +import type { DbClient } from '../db/client' +import { + createSessionToken, + hashPassword, + hashSessionToken, + sessionExpiry, + verifyPassword, +} from '../auth/tokens' +import { evaluateFailedAttempt, evaluateLockState } from '../auth/lockout' +import { clientIp, isStateChangingMethod, originAllowed } from '../auth/security' +import { jsonResponse, readValidatedBody, setCookieHeader } from '../http' +import { Type } from '@core/utils/typeboxHelpers' +import { getErrorMessage } from '@core/utils/errorMessage' +import { createAuditEvent } from '../repositories/audit' +import { requestAuditContext } from '../handlers/cms/shared' +import { nanoid } from 'nanoid' +import { + VISITOR_PASSWORD_MIN, + type VisitorLoginAttemptResult, + type VisitorUser, +} from './types' +import { + visitorForgotPerEmailRateLimit, + visitorForgotPerIpRateLimit, + visitorLoginPerIpRateLimit, + visitorLoginRateLimit, + visitorRegisterPerIpRateLimit, +} from './rateLimits' +import { getVisitorAuthConfig } from './config' +import { getEmailTransport } from '../email/transport' +import { + consumePasswordResetToken, + createPasswordResetToken, + createVisitorSession, + createVisitorUser, + findValidPasswordResetToken, + findVisitorUserByEmailNormalized, + findVisitorUserById, + hardDeleteVisitorUser, + markVisitorUserLoggedIn, + recordVisitorFailedLogin, + recordVisitorLoginAttempt, + revokeAllVisitorSessionsForUser, + revokeVisitorSessionByHash, + updateVisitorUserDisplayName, + updateVisitorUserPassword, +} from './repositories' +import { findVisitorRoleById, findVisitorRoleByName } from './roles' +import { resolveLoginRedirect } from './redirect' +import { + clearVisitorSessionCookie, + invalidateVisitorSessionCache, + validateVisitorSession, + visitorSessionCookie, +} from './sessions' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * A fixed argon2id hash, computed once per process. Used by the login + * handler as the verification target when the supplied email doesn't match + * any visitor user — keeping the response time constant prevents an attacker + * from learning which emails are registered via timing analysis. The hashed + * plaintext is not a real password and never grants access. + * + * Local to this module (mirrors `getDummyPasswordHash` in + * `server/handlers/cms/session.ts`, but isolated so the visitor system owns + * its own instance — the admin and visitor surfaces do not share state). + */ +const dummyVisitorPasswordHashCache: Promise = hashPassword( + 'not-a-real-visitor-account-placeholder', +) +function getDummyVisitorPasswordHash(): Promise { + return dummyVisitorPasswordHashCache +} + +/** 429 envelope — body carries `retryAfterMs` (the runtime maps it to copy). */ +function rateLimitedResponse(retryAfterMs: number): Response { + return jsonResponse({ error: 'rate_limited', retryAfterMs }, { status: 429 }) +} + +/** 423 envelope — body carries `retryAfterMs`. */ +function accountLockedResponse(retryAfterMs: number): Response { + return jsonResponse({ error: 'account_locked', retryAfterMs }, { status: 423 }) +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +/** Basic email shape check — full validation is the email service's job. */ +function looksLikeEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) +} + +/** The public profile shape returned by /register, /login, /me, PATCH /me. */ +interface VisitorProfile { + id: string + email: string + displayName: string + role: string + capabilities: string[] +} + +async function profileFor(db: DbClient, user: VisitorUser): Promise { + const role = await findVisitorRoleById(db, user.roleId) + return { + id: user.id, + email: user.email, + displayName: user.displayName, + role: role?.name ?? user.roleId, + capabilities: role?.capabilities ?? [], + } +} + +// --------------------------------------------------------------------------- +// POST /register +// --------------------------------------------------------------------------- + +const RegisterBodySchema = Type.Object({ + email: Type.String({ maxLength: 254 }), + password: Type.String(), + displayName: Type.Optional(Type.String({ maxLength: 200 })), +}) + +async function handleRegister(req: Request, db: DbClient): Promise { + const body = await readValidatedBody(req, RegisterBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + const email = normalizeEmail(body.email) + if (!email || email.length > 254 || !looksLikeEmail(email)) { + return jsonResponse({ error: 'invalid_email' }, { status: 422 }) + } + const password = body.password ?? '' + if (password.length < VISITOR_PASSWORD_MIN) { + return jsonResponse( + { error: 'invalid_password', details: { password: `Password must be at least ${VISITOR_PASSWORD_MIN} characters.` } }, + { status: 422 }, + ) + } + const displayName = (body.displayName ?? '').trim().slice(0, 200) + + const config = await getVisitorAuthConfig(db) + if (!config.enabled) { + return jsonResponse({ error: 'visitor_auth_disabled' }, { status: 403 }) + } + if (!config.registrationOpen) { + return jsonResponse({ error: 'registration_closed' }, { status: 403 }) + } + + const ip = clientIp(req) + if (ip) { + const decision = visitorRegisterPerIpRateLimit.consume(ip) + if (!decision.ok) return rateLimitedResponse(decision.retryAfterMs) + } + + const existing = await findVisitorUserByEmailNormalized(db, email) + if (existing) return jsonResponse({ error: 'email_taken' }, { status: 409 }) + + // Resolve the default role by name; fall back to the seeded 'member' role + // id if the configured name doesn't match a row (defensive against a + // hand-edited config.default_role). + const defaultRoleName = config.defaultRole || 'member' + let role = await findVisitorRoleByName(db, defaultRoleName) + if (!role) role = await findVisitorRoleByName(db, 'member') + if (!role) { + // The migration seeds 'member' and 'admin' system rows; reaching here + // means the install hasn't run the visitor migration. Surface it + // clearly rather than crashing on a FK violation below. + return jsonResponse({ error: 'visitor_auth_not_initialized' }, { status: 503 }) + } + + const passwordHash = await hashPassword(password) + const user = await createVisitorUser(db, { + id: nanoid(), + email, + emailNormalized: email, + passwordHash, + displayName, + roleId: role.id, + }) + + // Self-registration: there is no authenticated actor, so actorUserId is + // null. The visitor's identity rides in targetId (free-text) + metadata. + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.register', + targetType: 'visitor_user', + targetId: user.id, + metadata: { email }, + ...requestAuditContext(req), + }) + + return jsonResponse(await profileFor(db, user), { status: 201 }) +} + +// --------------------------------------------------------------------------- +// POST /login +// --------------------------------------------------------------------------- + +const LoginBodySchema = Type.Object({ + email: Type.String({ maxLength: 254 }), + password: Type.String(), +}) + +async function handleLogin(req: Request, db: DbClient): Promise { + const config = await getVisitorAuthConfig(db) + if (!config.enabled) { + return jsonResponse({ error: 'visitor_auth_disabled' }, { status: 403 }) + } + + const body = await readValidatedBody(req, LoginBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + const email = normalizeEmail(body.email) + const password = body.password ?? '' + const ip = clientIp(req) + const userAgent = req.headers.get('user-agent') + + // Layer 1 — per-IP rate limit. Blanket cap so one attacker IP cannot grind + // through many visitor accounts. Skipped when no IP is surfaced; the + // per-(ip,email) limiter still applies. + if (ip) { + const ipDecision = visitorLoginPerIpRateLimit.consume(ip) + if (!ipDecision.ok) { + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: null, + result: 'rate_limited', + }) + return rateLimitedResponse(ipDecision.retryAfterMs) + } + } + + // Layer 2 — per-(ip,email) tuple. Defends a single account across many IPs. + const rateLimitKey = `${ip ?? 'unknown'}|${email}` + const tupleDecision = visitorLoginRateLimit.consume(rateLimitKey) + if (!tupleDecision.ok) { + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: null, + result: 'rate_limited', + }) + return rateLimitedResponse(tupleDecision.retryAfterMs) + } + + // Constant-time path: ALWAYS run argon2id verify, even when the email + // doesn't match a user. Without this, "user not found" returns in ~5ms + // while "user found, wrong password" takes ~100ms — a timing oracle for + // email enumeration. Verify against a fixed dummy hash on the no-user + // branch; the result is always false but the latency profile matches. + const user = await findVisitorUserByEmailNormalized(db, email) + const verifiedHash = user?.passwordHash ?? (await getDummyVisitorPasswordHash()) + const passwordOk = await verifyPassword(password, verifiedHash) + + // Layer 3 — per-account lockout. Checked AFTER the constant-time verify so + // the locked-vs-not-locked latency profile doesn't leak whether the email + // exists. + if (user) { + const lockState = evaluateLockState(user.lockedUntil) + if (lockState.locked) { + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: user.id, + result: 'locked', + }) + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.login.locked', + targetType: 'visitor_user', + targetId: user.id, + metadata: { email, lockedUntil: user.lockedUntil ?? '' }, + ...requestAuditContext(req), + }) + return accountLockedResponse(lockState.retryAfterMs) + } + } + + if (!user || user.status !== 'active' || !passwordOk) { + const failureReason: VisitorLoginAttemptResult = !user + ? 'no_user' + : user.status !== 'active' + ? 'account_disabled' + : 'bad_password' + + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: user?.id ?? null, + result: failureReason, + }) + + // Bump the per-account counter ONLY for the bad-password-against-active + // branch. A "no such user" attempt is bound to the IP layer + the + // login_attempts log; a suspended account doesn't need its counter raised. + let lockedUntilIso: string | null = null + if (user && user.status === 'active' && failureReason === 'bad_password') { + const lockout = evaluateFailedAttempt(user.failedLoginCount) + await recordVisitorFailedLogin(db, user.id, lockout.lockedUntil) + if (lockout.triggered && lockout.lockedUntil) { + lockedUntilIso = lockout.lockedUntil.toISOString() + } + } + + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.login.failure', + targetType: 'visitor_user', + targetId: user?.id ?? null, + metadata: { email, reason: failureReason }, + ...requestAuditContext(req), + }) + + if (lockedUntilIso) { + return accountLockedResponse(Math.max(0, Date.parse(lockedUntilIso) - Date.now())) + } + return jsonResponse({ error: 'invalid_credentials' }, { status: 401 }) + } + + // Success — mint session, record it, clear the rate buckets. + visitorLoginRateLimit.reset(rateLimitKey) + if (ip) visitorLoginPerIpRateLimit.reset(ip) + + const token = createSessionToken() + const expiresAt = sessionExpiry() + await createVisitorSession(db, { + idHash: await hashSessionToken(token), + userId: user.id, + expiresAt, + ipAddress: ip, + userAgent, + }) + await recordVisitorLoginAttempt(db, { + emailNormalized: email || null, + ipAddress: ip, + userAgent, + userId: user.id, + result: 'success', + }) + await markVisitorUserLoggedIn(db, user.id) + + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.login.success', + targetType: 'visitor_user', + targetId: user.id, + metadata: { email }, + ...requestAuditContext(req), + }) + + const profile = await profileFor(db, user) + // D15: resolve the post-login landing path server-side (explicit redirect → + // primary-group landing → default) and hand it to the browser runtime via + // the response body so the runtime stays dumb. + const redirect = await resolveLoginRedirect(db, req, user.id, config.defaultLandingPath) + return setCookieHeader( + jsonResponse({ ...profile, redirect }, { status: 200 }), + visitorSessionCookie(req, token, expiresAt), + ) +} + +// --------------------------------------------------------------------------- +// POST /logout +// --------------------------------------------------------------------------- + +async function handleLogout(req: Request, db: DbClient): Promise { + const session = await validateVisitorSession(db, req) + if (!session) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + await revokeVisitorSessionByHash(db, session.idHash) + invalidateVisitorSessionCache(session.idHash) + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.logout', + targetType: 'visitor_user', + targetId: session.userId, + ...requestAuditContext(req), + }) + // PRD D9: 204 no body. The Set-Cookie must still ride on the response. + return setCookieHeader(new Response(null, { status: 204 }), clearVisitorSessionCookie(req)) +} + +// --------------------------------------------------------------------------- +// GET /me +// --------------------------------------------------------------------------- + +async function handleMe(req: Request, db: DbClient): Promise { + const session = await validateVisitorSession(db, req) + if (!session) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + const user = await findVisitorUserById(db, session.userId) + if (!user) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + return jsonResponse(await profileFor(db, user), { status: 200 }) +} + +// --------------------------------------------------------------------------- +// PATCH /me +// --------------------------------------------------------------------------- + +const PatchMeBodySchema = Type.Object( + { + displayName: Type.Optional(Type.String({ maxLength: 200 })), + email: Type.Optional(Type.String()), + }, + // Reject unknown keys defensively — Phase 1 only accepts displayName. + { additionalProperties: false }, +) + +async function handlePatchMe(req: Request, db: DbClient): Promise { + const session = await validateVisitorSession(db, req) + if (!session) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + const body = await readValidatedBody(req, PatchMeBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + // Email change is deferred to Phase 2 — surface it explicitly so the + // client knows it wasn't ignored. + if (body.email !== undefined) { + return jsonResponse({ error: 'email_change_not_supported' }, { status: 422 }) + } + + const current = await findVisitorUserById(db, session.userId) + if (!current) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + + const displayName = + body.displayName === undefined ? current.displayName : body.displayName.trim().slice(0, 200) + const updated = await updateVisitorUserDisplayName(db, session.userId, displayName) + if (!updated) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + return jsonResponse(await profileFor(db, updated), { status: 200 }) +} + +// --------------------------------------------------------------------------- +// POST /forgot (V7 — password reset request) +// --------------------------------------------------------------------------- + +const ForgotBodySchema = Type.Object({ + email: Type.String({ maxLength: 254 }), +}) + +/** + * Request a password reset. ALWAYS returns 200 `{ ok: true }` — whether or + * not the email matches a visitor — so the endpoint cannot be used for email + * enumeration (PRD §7). On a real match a one-shot reset token is minted and + * the reset link is handed to the email transport; on no match nothing is + * sent. Transport failures are logged but never surfaced to the caller (a + * delivery outage must not leak that the account exists). + */ +async function handleForgot(req: Request, db: DbClient): Promise { + const body = await readValidatedBody(req, ForgotBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + const email = normalizeEmail(body.email) + + // Layer 1 — per-IP. A single attacker IP cannot flood reset requests + // (the primary email-enumeration / annoyance vector). Skipped when no IP + // is surfaced; the per-email limiter still applies. + const ip = clientIp(req) + if (ip) { + const ipDecision = visitorForgotPerIpRateLimit.consume(ip) + if (!ipDecision.ok) return rateLimitedResponse(ipDecision.retryAfterMs) + } + + // Layer 2 — per normalized email. Caps reset-mail volume to one address, + // even across many IPs. Always consumed (even for unknown emails) so an + // attacker cannot probe by observing whether the limiter tripped. + const emailDecision = visitorForgotPerEmailRateLimit.consume(email || 'unknown') + if (!emailDecision.ok) return rateLimitedResponse(emailDecision.retryAfterMs) + + const user = await findVisitorUserByEmailNormalized(db, email) + if (user) { + const rawToken = await createPasswordResetToken(db, user.id) + const config = await getVisitorAuthConfig(db) + const origin = new URL(req.url).origin + const resetLink = `${origin}${config.loginPath}?reset=${encodeURIComponent(rawToken)}` + try { + await getEmailTransport().send({ + to: user.email, + subject: 'Reset your password', + text: resetLink, + html: `

Reset your password: ${resetLink}

`, + }) + } catch (err) { + // Never reveal delivery failure — log and continue as if it succeeded. + console.error('[visitor-auth] forgot-password transport error:', err) + } + } + + return jsonResponse({ ok: true }, { status: 200 }) +} + +// --------------------------------------------------------------------------- +// POST /reset (V7 — consume reset token, set new password) +// --------------------------------------------------------------------------- + +const ResetBodySchema = Type.Object({ + token: Type.String(), + password: Type.String(), +}) + +/** + * Consume a one-shot reset token and set a new password. The raw token is + * hashed (SHA-256) before any DB lookup, so the raw value never touches the + * query layer. A token is valid only when it exists, is unused, and has not + * expired; `consumePasswordResetToken` performs the atomic one-shot consume + * (a concurrent reset race resolves to exactly one winner). On success every + * existing session for the user is revoked, forcing a fresh login. + */ +async function handleReset(req: Request, db: DbClient): Promise { + const body = await readValidatedBody(req, ResetBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + const token = body.token ?? '' + const password = body.password ?? '' + if (password.length < VISITOR_PASSWORD_MIN) { + return jsonResponse( + { error: 'invalid_password', details: { password: `Password must be at least ${VISITOR_PASSWORD_MIN} characters.` } }, + { status: 422 }, + ) + } + + const tokenHash = await hashSessionToken(token) + const resetToken = await findValidPasswordResetToken(db, tokenHash) + if (!resetToken) { + return jsonResponse({ error: 'invalid_or_expired_token' }, { status: 401 }) + } + + // Atomic one-shot consume — guards against a concurrent double-spend of + // the same token (e.g. two tabs submitting at once). rowCount 0 means + // another caller already consumed it. + const consumed = await consumePasswordResetToken(db, tokenHash) + if (!consumed) { + return jsonResponse({ error: 'invalid_or_expired_token' }, { status: 401 }) + } + + const newPasswordHash = await hashPassword(password) + await updateVisitorUserPassword(db, resetToken.userId, newPasswordHash) + // Force re-login everywhere — every prior session (including the one this + // request arrived on, if any) is dead. + await revokeAllVisitorSessionsForUser(db, resetToken.userId) + + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.password.reset', + targetType: 'visitor_user', + targetId: resetToken.userId, + ...requestAuditContext(req), + }) + + return jsonResponse({ ok: true }, { status: 200 }) +} + +// --------------------------------------------------------------------------- +// DELETE /me (V8 — GDPR self-service account deletion) +// --------------------------------------------------------------------------- + +const DeleteMeBodySchema = Type.Object({ + password: Type.String(), +}) + +/** + * Delete the caller's own visitor account + PII, gated on a fresh + * re-verification of their current password (so a stolen session cookie + * alone cannot delete the account). On success every session is revoked, + * PII is anonymized in place, and the cookie is cleared. The row is kept + * (soft-delete) for FK/audit integrity — see `hardDeleteVisitorUser`. + */ +async function handleDeleteMe(req: Request, db: DbClient): Promise { + const session = await validateVisitorSession(db, req) + if (!session) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + + const body = await readValidatedBody(req, DeleteMeBodySchema) + if (!body) return jsonResponse({ error: 'invalid_request' }, { status: 400 }) + + const user = await findVisitorUserById(db, session.userId) + if (!user) return jsonResponse({ error: 'unauthorized' }, { status: 401 }) + + // Re-verify the password — the session alone is not enough authority to + // destroy the account. A wrong password is a flat 401 (no retry guidance; + // the client treats it the same as a generic auth failure). + const passwordOk = await verifyPassword(body.password ?? '', user.passwordHash) + if (!passwordOk) { + return jsonResponse({ error: 'invalid_password' }, { status: 401 }) + } + + // Tear down every session first (including this one), then wipe the row. + await revokeAllVisitorSessionsForUser(db, user.id) + await hardDeleteVisitorUser(db, user.id) + invalidateVisitorSessionCache(session.idHash) + + await createAuditEvent(db, { + actorUserId: null, + action: 'visitor.account.deleted', + targetType: 'visitor_user', + targetId: user.id, + metadata: { email: user.email }, + ...requestAuditContext(req), + }) + + return setCookieHeader( + jsonResponse({ ok: true }, { status: 200 }), + clearVisitorSessionCookie(req), + ) +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +type RouteHandler = (req: Request, db: DbClient) => Promise + +interface Route { + method: string + /** Exact-match pathname. Parameterized visitor paths live under `/_instatic/`. */ + pattern: string + handler: RouteHandler +} + +const routes: readonly Route[] = [ + { method: 'POST', pattern: '/api/visitor/register', handler: handleRegister }, + { method: 'POST', pattern: '/api/visitor/login', handler: handleLogin }, + { method: 'POST', pattern: '/api/visitor/logout', handler: handleLogout }, + { method: 'GET', pattern: '/api/visitor/me', handler: handleMe }, + { method: 'PATCH', pattern: '/api/visitor/me', handler: handlePatchMe }, + { method: 'DELETE', pattern: '/api/visitor/me', handler: handleDeleteMe }, + { method: 'POST', pattern: '/api/visitor/forgot', handler: handleForgot }, + { method: 'POST', pattern: '/api/visitor/reset', handler: handleReset }, +] + +/** + * Dispatch every `/api/visitor/*` request. CSRF gating runs first for + * state-changing methods; a path-matched / wrong-method request resolves to + * 405; any unknown path under the prefix resolves to a terminal 404 so it + * never falls through to the public route renderer. + */ +export async function handleVisitorRoutes(req: Request, db: DbClient): Promise { + if (isStateChangingMethod(req.method) && !originAllowed(req)) { + return jsonResponse({ error: 'forbidden_origin' }, { status: 403 }) + } + + const { pathname } = new URL(req.url) + let pathMatched = false + for (const route of routes) { + if (pathname !== route.pattern) continue + pathMatched = true + if (req.method !== route.method) continue + try { + return await route.handler(req, db) + } catch (err) { + console.error('[visitor-auth] handler error:', err) + return jsonResponse( + { error: getErrorMessage(err, 'Internal server error') }, + { status: 500 }, + ) + } + } + + if (pathMatched) return jsonResponse({ error: 'method_not_allowed' }, { status: 405 }) + return jsonResponse({ error: 'not_found' }, { status: 404 }) +} diff --git a/server/visitor-auth/middleware.ts b/server/visitor-auth/middleware.ts new file mode 100644 index 000000000..b71e8c3c0 --- /dev/null +++ b/server/visitor-auth/middleware.ts @@ -0,0 +1,131 @@ +/** + * Visitor-auth route protection middleware (D1, D4, D6, D14, D17). + * + * Runs inside the server's main request try-block BEFORE `handleServerRequest`, + * so it gates every public-site request (not just published-page renders). + * Two jobs: + * + * 1. Built-in auth pages on direct visit. When visitor auth is enabled and a + * visitor GETs the configured `loginPath` (default `/login`) or the + * sibling `/register` path AND no published page exists at that path, the + * middleware serves the built-in login/register HTML inline. A published + * page always wins (so a builder who designs their own login page keeps + * it). This closes the "clicked a Login/Register link → 404" gap that the + * redirect-only fallback alone would leave on a fresh install. + * + * 2. Per-page access gating (D14 — replaces the Phase-1/2 protected-prefix + * model). The middleware resolves the published page at the path, reads + * its `access` level, and gates accordingly: + * - no published page → pass through (downstream 404) + * - public page → pass through + * - restricted (level 'groups') + anonymous → login (redirect to a + * published login page when one exists, else the built-in page) + * - restricted + logged-in-but-not-in-an-allowed-group → the built-in + * "no access" page (D17 — they're already signed in, so a redirect + * to login would loop; a 404 would hide the page's existence) + * - restricted + member of an allowed group → pass through + * + * Skip prefixes (`/_instatic/`, `/admin/`, `/health`, `/api/visitor/`, + * `/uploads/`) keep the middleware off infrastructure and asset traffic so it + * never interferes with admin, runtime assets, or the visitor API itself. + */ +import type { DbClient } from '../db/client' +import { getVisitorAuthConfig } from './config' +import { validateVisitorSession } from './sessions' +import { listGroupIdsForVisitor } from './groups' +import { getPublishedPageAccessForPath, publishedPageExistsAtPath } from '../publish/publicRouter' +import { + BUILT_IN_LOGIN_PAGE_HTML, + BUILT_IN_NO_ACCESS_PAGE_HTML, + BUILT_IN_REGISTER_PAGE_HTML, +} from '../publish/visitorAuthRuntime' + +/** Paths the middleware must never gate. */ +const SKIP_PREFIXES = ['/_instatic/', '/admin/', '/health', '/api/visitor/', '/uploads/'] + +/** Built-in register page path (the login page links here). */ +const BUILT_IN_REGISTER_PATH = '/register' + +function htmlResponse(html: string, status = 200): Response { + return new Response(html, { + status, + headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, + }) +} + +/** + * Serve a built-in auth page on a direct GET when no published page exists at + * that path. Returns the `Response` to short-circuit with, or `null` to let + * the request continue. + */ +async function maybeServeBuiltInAuthPage( + db: DbClient, + pathname: string, + loginPath: string, +): Promise { + if (pathname !== loginPath && pathname !== BUILT_IN_REGISTER_PATH) return null + if (await publishedPageExistsAtPath(db, pathname)) return null // a designed page wins + if (pathname === loginPath) return htmlResponse(BUILT_IN_LOGIN_PAGE_HTML) + return htmlResponse(BUILT_IN_REGISTER_PAGE_HTML) +} + +/** + * Build the anonymous-visitor login response for a restricted page. Prefer the + * operator's published login page (302 redirect, so the browser address bar + * shows the real URL and `?redirect=` round-trips the gated path for post-login + * bounce-back); fall back to the built-in login page served inline so a fresh + * install without a designed login page still works end-to-end. + */ +async function anonymousLoginResponse( + db: DbClient, + loginPath: string, + pathname: string, + search: string, +): Promise { + if (await publishedPageExistsAtPath(db, loginPath)) { + const redirect = encodeURIComponent(pathname + search) + return new Response(null, { + status: 302, + headers: { location: `${loginPath}?redirect=${redirect}` }, + }) + } + return htmlResponse(BUILT_IN_LOGIN_PAGE_HTML) +} + +/** + * Returns a `Response` to short-circuit the request with, or `null` to let + * the request continue through the normal router. + */ +export async function visitorAuthMiddleware(req: Request, db: DbClient): Promise { + const cfg = await getVisitorAuthConfig(db) + if (!cfg.enabled) return null + + const url = new URL(req.url) + const { pathname, search } = url + + // Never gate infrastructure / admin / visitor-API / asset traffic. + if (SKIP_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return null + + // Job 1: built-in login/register pages on direct visit (GET only). + if (req.method === 'GET') { + const builtIn = await maybeServeBuiltInAuthPage(db, pathname, cfg.loginPath) + if (builtIn) return builtIn + } + + // Job 2: per-page access gating (D14). No published page → pass through. + const access = await getPublishedPageAccessForPath(db, pathname) + if (!access) return null + if (access.level === 'public') return null + + // Restricted (level 'groups'). Anonymous visitor → login. + const session = await validateVisitorSession(db, req) + if (!session) return anonymousLoginResponse(db, cfg.loginPath, pathname, search) + + // Logged in — check group membership. A member in ANY of the page's allowed + // groups may view it (D14). Everyone else gets the D17 "no access" page; the + // restricted content is never leaked to either branch. + const userGroupIds = await listGroupIdsForVisitor(db, session.userId) + const allowed = access.groups.some((g) => userGroupIds.includes(g)) + if (allowed) return null + return htmlResponse(BUILT_IN_NO_ACCESS_PAGE_HTML) +} diff --git a/server/visitor-auth/rateLimits.ts b/server/visitor-auth/rateLimits.ts new file mode 100644 index 000000000..91252e1f7 --- /dev/null +++ b/server/visitor-auth/rateLimits.ts @@ -0,0 +1,49 @@ +/** + * In-memory rate limiters for the visitor-auth surface. + * + * Mirrors the admin auth surface (`server/auth/rateLimit.ts`): one + * `RateLimiter` singleton per concern, keyed by IP or by `(ip, email)` / + * email. The visitor surface is a separate process-wide set so visitor + * traffic cannot consume the admin buckets and vice-versa. + * + * visitorLoginRateLimit 5 / 15min per (ip, email) + * visitorLoginPerIpRateLimit 30 / 10min per ip + * visitorRegisterPerIpRateLimit 3 / 60min per ip + * visitorForgotPerIpRateLimit 3 / 60min per ip + * visitorForgotPerEmailRateLimit 1 / 15min per email + * + * The limits are intentionally tight on registration / forgot (high abuse + * surface, low legitimate volume) and looser on login (real users mistype + * passwords). See `docs/PRD.md` §4.4. + */ +import { RateLimiter } from '../auth/rateLimit' + +/** Per-(ip, email) tuple — defends a single visitor account across many IPs. */ +export const visitorLoginRateLimit = new RateLimiter({ + limit: 5, + windowMs: 15 * 60 * 1000, +}) + +/** Per-IP — blanket cap so one attacker IP cannot grind many accounts. */ +export const visitorLoginPerIpRateLimit = new RateLimiter({ + limit: 30, + windowMs: 10 * 60 * 1000, +}) + +/** Per-IP registration — throttles throwaway-account farming. */ +export const visitorRegisterPerIpRateLimit = new RateLimiter({ + limit: 3, + windowMs: 60 * 60 * 1000, +}) + +/** Per-IP forgot-password — throttles email enumeration via reset floods. */ +export const visitorForgotPerIpRateLimit = new RateLimiter({ + limit: 3, + windowMs: 60 * 60 * 1000, +}) + +/** Per-email forgot-password — caps reset-mail volume to a single address. */ +export const visitorForgotPerEmailRateLimit = new RateLimiter({ + limit: 1, + windowMs: 15 * 60 * 1000, +}) diff --git a/server/visitor-auth/redirect.ts b/server/visitor-auth/redirect.ts new file mode 100644 index 000000000..0aa35f0f5 --- /dev/null +++ b/server/visitor-auth/redirect.ts @@ -0,0 +1,48 @@ +/** + * Post-login landing-path resolution (Phase 3 — D15). + * + * Extracted from the login handler so the resolution rule lives in exactly one + * testable place and the handler stays under the module-size ceiling. The + * browser runtime stays dumb: it just honours the resolved `redirect` the + * handler returns in the login response body. + * + * Resolution priority (per D15): + * 1. An explicit `?redirect=` query param (highest — covers the "tried to + * hit a gated page" bounce-back-after-auth case). + * 2. The visitor's primary group's `landingPath`, when it is set and not `/`. + * 3. The configured `defaultLandingPath` (defaulting to `/`). + * + * The primary-group landing + group row are loaded lazily (only when no + * explicit redirect) so the common redirect-after-gated-hit path does no DB + * work beyond the cookie mint that already happened. + */ +import type { DbClient } from '../db/client' +import { findVisitorGroupById, getVisitorPrimaryGroupId } from './groups' + +/** + * Resolve a visitor's post-login landing path. + * + * @param db The DB client (used only when no explicit redirect). + * @param req The login request (read for its `?redirect=` query param). + * @param userId The freshly-authenticated visitor's id. + * @param defaultLandingPath The configured default landing (D15); `/` when blank. + */ +export async function resolveLoginRedirect( + db: DbClient, + req: Request, + userId: string, + defaultLandingPath: string, +): Promise { + const explicit = new URL(req.url).searchParams.get('redirect')?.trim() ?? '' + if (explicit) return explicit + + const primaryGroupId = await getVisitorPrimaryGroupId(db, userId) + if (primaryGroupId) { + const primaryGroup = await findVisitorGroupById(db, primaryGroupId) + if (primaryGroup && primaryGroup.landingPath && primaryGroup.landingPath !== '/') { + return primaryGroup.landingPath + } + } + + return defaultLandingPath || '/' +} diff --git a/server/visitor-auth/repositories.ts b/server/visitor-auth/repositories.ts new file mode 100644 index 000000000..2357af487 --- /dev/null +++ b/server/visitor-auth/repositories.ts @@ -0,0 +1,581 @@ +/** + * Visitor-auth DB CRUD — users, sessions, login attempts, password-reset + * tokens. + * + * Visitor *roles* used to live here but were extracted into `./roles.ts` + * (mirroring the admin system's `server/repositories/roles.ts` split) so + * this file stays under the module-size ceiling. Import role symbols from + * `./roles`. + * + * Mirrors the admin repositories (`server/repositories/{roles,users}.ts`) + * but against the isolated `visitor_*` tables. All SQL is ANSI-standard + * (no Postgres-isms: no `now()` in DML, no `::cast`, no `any($N::...)`, + * no `distinct on`) so the same statements run unchanged against both the + * Postgres and SQLite adapters. `current_timestamp` is the portable + * "now" expression; absolute ISO strings are computed in JS for the + * expiry-window comparisons (the `now` there is caller-supplied, not a + * column default). + * + * Rows are mapped to camelCase domain interfaces through `rowTo*` helpers — + * the only place that knows the snake_case column names is this module. + * Column lists are inlined into each tagged template (matching + * `server/repositories/roles.ts`) because a `db\`select ${COLS}\`` would + * interpolate the constant as a bind parameter, not as SQL text. + */ +import { nanoid } from 'nanoid' +import type { DbClient } from '../db/client' +import { isoDateOrNull } from '@core/utils/isoDate' +import { createSessionToken, hashSessionToken } from '../auth/tokens' +import { + VISITOR_PASSWORD_RESET_TTL_MS, + type VisitorLoginAttemptResult, + type VisitorPasswordResetToken, + type VisitorPasswordResetTokenRow, + type VisitorSession, + type VisitorSessionRow, + type VisitorUser, + type VisitorUserRow, + type VisitorUserStatus, +} from './types' + +function rowToUser(row: VisitorUserRow): VisitorUser { + return { + id: row.id, + email: row.email, + emailNormalized: row.email_normalized, + passwordHash: row.password_hash, + displayName: row.display_name, + roleId: row.role_id, + primaryGroupId: row.primary_group_id ?? null, + status: row.status as VisitorUserStatus, + failedLoginCount: Number(row.failed_login_count ?? 0), + lockedUntil: isoDateOrNull(row.locked_until), + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + profileFields: normalizeProfileFields(row.profile_fields_json), + } +} + +/** + * Coerce a raw profile_fields_json cell (string | object | undefined) into a + * plain object. The _json-suffix adapter auto-parses the column on read, but + * this is defensive: raw migration SQL and the PG jsonb path may surface a + * string, and a NULL-safe default of {} keeps callers branch-free. + */ +function normalizeProfileFields(raw: VisitorUserRow['profile_fields_json']): Record { + if (!raw) return {} + if (typeof raw === 'string') { + try { const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : {} } catch { return {} } + } + return raw as Record +} + +function rowToSession(row: VisitorSessionRow): VisitorSession { + return { + idHash: row.id_hash, + userId: row.user_id, + createdAt: new Date(row.created_at).toISOString(), + lastSeenAt: new Date(row.last_seen_at).toISOString(), + expiresAt: new Date(row.expires_at).toISOString(), + revokedAt: isoDateOrNull(row.revoked_at), + ipAddress: row.ip_address, + userAgent: row.user_agent, + deviceLabel: row.device_label, + } +} + +function rowToPasswordResetToken(row: VisitorPasswordResetTokenRow): VisitorPasswordResetToken { + return { + id: row.id, + userId: row.user_id, + tokenHash: row.token_hash, + expiresAt: new Date(row.expires_at).toISOString(), + usedAt: isoDateOrNull(row.used_at), + createdAt: new Date(row.created_at).toISOString(), + } +} + +// --------------------------------------------------------------------------- +// Users +// --------------------------------------------------------------------------- + +/** + * Lookup by normalized email, active (non-soft-deleted) only. The partial + * unique index `visitor_users_email_active_idx` enforces uniqueness among + * active rows, so at most one row matches. + */ +export async function findVisitorUserByEmailNormalized( + db: DbClient, + emailNormalized: string, +): Promise { + const { rows } = await db` + select id, email, email_normalized, password_hash, display_name, role_id, primary_group_id, status, failed_login_count, locked_until, created_at, updated_at, deleted_at, profile_fields_json + from visitor_users + where email_normalized = ${emailNormalized} + and deleted_at is null + limit 1 + ` + return rows[0] ? rowToUser(rows[0]) : null +} + +export async function findVisitorUserById(db: DbClient, id: string): Promise { + const { rows } = await db` + select id, email, email_normalized, password_hash, display_name, role_id, primary_group_id, status, failed_login_count, locked_until, created_at, updated_at, deleted_at, profile_fields_json + from visitor_users + where id = ${id} + and deleted_at is null + limit 1 + ` + return rows[0] ? rowToUser(rows[0]) : null +} + +export async function createVisitorUser( + db: DbClient, + input: { + id: string + email: string + emailNormalized: string + passwordHash: string + displayName: string + roleId: string + }, +): Promise { + await db` + insert into visitor_users (id, email, email_normalized, password_hash, display_name, role_id) + values (${input.id}, ${input.email}, ${input.emailNormalized}, ${input.passwordHash}, ${input.displayName}, ${input.roleId}) + ` + const created = await findVisitorUserById(db, input.id) + if (!created) throw new Error('[visitor-auth] visitor user insert did not return a row') + return created +} + +/** Reset the failed-login counter and clear any active lock on a successful login. */ +export async function markVisitorUserLoggedIn(db: DbClient, id: string): Promise { + await db` + update visitor_users + set failed_login_count = 0, + locked_until = ${null}, + updated_at = current_timestamp + where id = ${id} + ` +} + +/** + * Bump the failed-login counter and (when a lockout was triggered) persist + * the new `locked_until` deadline. Passing `lockedUntil: null` records the + * failure without locking — the lockout decision is the caller's + * (`evaluateFailedAttempt`). + */ +export async function recordVisitorFailedLogin( + db: DbClient, + id: string, + lockedUntil: Date | null, +): Promise { + await db` + update visitor_users + set failed_login_count = failed_login_count + 1, + locked_until = ${lockedUntil}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` +} + +export async function setVisitorUserStatus( + db: DbClient, + id: string, + status: VisitorUserStatus, +): Promise { + await db` + update visitor_users + set status = ${status}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` +} + +/** Update only `display_name` — the single self-service field in Phase 1 (`PATCH /me`). */ +export async function updateVisitorUserDisplayName( + db: DbClient, + id: string, + displayName: string, +): Promise { + await db` + update visitor_users + set display_name = ${displayName}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + return findVisitorUserById(db, id) +} + +/** + * Update a visitor's custom profile field VALUES (migration 025). Stores the + * whole map — callers should merge against the current values first. Empty + * object is valid (clears all profile fields). + */ +export async function updateVisitorUserProfileFields( + db: DbClient, + id: string, + profileFields: Record, +): Promise { + await db` + update visitor_users + set profile_fields_json = ${JSON.stringify(profileFields)}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + return findVisitorUserById(db, id) +} + +/** + * Set a visitor's password hash. `visitor_users` has no `password_updated_at` + * column (unlike the admin `users` table), so only `password_hash` + + * `updated_at` are touched. Callers MUST `revokeAllVisitorSessionsForUser` + * afterwards so the password change forces a fresh login. + */ +export async function updateVisitorUserPassword( + db: DbClient, + id: string, + newPasswordHash: string, +): Promise { + await db` + update visitor_users + set password_hash = ${newPasswordHash}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` +} + +// ─── Admin-facing read/management (used by /admin/api/cms/visitor-auth/*) ─── + +/** + * Paginated list of active (non-soft-deleted) visitors, newest first. + * Optional `search` does a case-insensitive LIKE on email or display name. + * Mirrors the admin `listUsers` shape so the admin UI's user-management + * table can reuse the same row contract. + */ +export async function listVisitorUsers( + db: DbClient, + options: { limit?: number; offset?: number; search?: string } = {}, +): Promise { + const limit = Math.min(Math.max(options.limit ?? 50, 1), 200) + const offset = Math.max(options.offset ?? 0, 0) + const search = options.search?.trim().toLowerCase() ?? '' + const pattern = search ? `%${search}%` : '' + const { rows } = pattern + ? await db` + select id, email, email_normalized, password_hash, display_name, role_id, primary_group_id, status, failed_login_count, locked_until, created_at, updated_at, deleted_at, profile_fields_json + from visitor_users + where deleted_at is null + and (lower(email) like ${pattern} or lower(display_name) like ${pattern}) + order by created_at desc + limit ${limit} offset ${offset} + ` + : await db` + select id, email, email_normalized, password_hash, display_name, role_id, primary_group_id, status, failed_login_count, locked_until, created_at, updated_at, deleted_at, profile_fields_json + from visitor_users + where deleted_at is null + order by created_at desc + limit ${limit} offset ${offset} + ` + return rows.map(rowToUser) +} + +/** Total count of active visitors, optionally narrowed by the same search filter used by `listVisitorUsers` so the pagination total matches the filtered list. */ +export async function countVisitorUsers( + db: DbClient, + options: { search?: string } = {}, +): Promise { + const search = options.search?.trim().toLowerCase() ?? '' + const pattern = search ? `%${search}%` : '' + const { rows } = pattern + ? await db<{ count: number }>` + select count(*) as count + from visitor_users + where deleted_at is null + and (lower(email) like ${pattern} or lower(display_name) like ${pattern}) + ` + : await db<{ count: number }>` + select count(*) as count + from visitor_users + where deleted_at is null + ` + return Number(rows[0]?.count ?? 0) +} + +/** Reassign a visitor to a different visitor role. Validates the role exists. */ +export async function updateVisitorUserRole( + db: DbClient, + id: string, + roleId: string, +): Promise { + await db` + update visitor_users + set role_id = ${roleId}, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + return findVisitorUserById(db, id) +} + +/** + * Soft-delete a visitor (set `deleted_at`). The partial unique email index + * only spans non-deleted rows, so the email can re-register afterwards. + * Caller is responsible for revoking active sessions (see + * `revokeAllVisitorSessionsForUser`). + */ +export async function softDeleteVisitorUser(db: DbClient, id: string): Promise { + const result = await db` + update visitor_users + set deleted_at = current_timestamp, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + return result.rowCount > 0 +} + +/** + * GDPR self-service account deletion (V8). Wipes every PII column on the + * visitor row, then soft-deletes it: + * - email / email_normalized → `'deleted+' || id` (stable, non-PII + * placeholder; the partial unique email index no longer covers it, so the + * original address can re-register) + * - display_name → '' + * - password_hash → '' (never matches any input — the row is + * dead anyway, but this guarantees no credential reuse) + * - status → 'suspended' + * - deleted_at / updated_at → now + * + * The row is KEPT (soft-delete) so FK integrity (visitor_sessions, + * visitor_login_attempts, visitor_password_reset_tokens) and any audit + * history stay intact. Also anonymizes the user's `visitor_login_attempts` + * rows (email_normalized / ip_address / user_agent → null). Returns true only + * when the visitor row was previously active (matched the `deleted_at is + * null` guard) — a double-delete is a no-op returning false. + */ +export async function hardDeleteVisitorUser(db: DbClient, id: string): Promise { + const placeholder = `deleted+${id}` + const result = await db` + update visitor_users + set email = ${placeholder}, + email_normalized = ${placeholder}, + display_name = '', + password_hash = '', + status = 'suspended', + deleted_at = current_timestamp, + updated_at = current_timestamp + where id = ${id} + and deleted_at is null + ` + // Anonymize the user's login-attempt audit rows (PII columns) regardless + // of whether the user row matched — idempotent and defense-in-depth. + await db` + update visitor_login_attempts + set email_normalized = null, + ip_address = null, + user_agent = null + where user_id = ${id} + ` + return result.rowCount > 0 +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +interface CreateVisitorSessionInput { + idHash: string + userId: string + expiresAt: Date + ipAddress: string | null + userAgent: string | null + deviceLabel?: string +} + +export async function createVisitorSession( + db: DbClient, + input: CreateVisitorSessionInput, +): Promise { + const deviceLabel = input.deviceLabel ?? '' + await db` + insert into visitor_sessions (id_hash, user_id, expires_at, ip_address, user_agent, device_label) + values (${input.idHash}, ${input.userId}, ${input.expiresAt}, ${input.ipAddress}, ${input.userAgent}, ${deviceLabel}) + ` + const created = await findActiveVisitorSessionByHash(db, input.idHash) + if (!created) throw new Error('[visitor-auth] visitor session insert did not return a row') + return created +} + +/** + * Active session lookup: hash match, not revoked, and not past `expires_at`. + * `nowIso` is supplied by the caller so every comparison uses one clock read. + */ +export async function findActiveVisitorSessionByHash( + db: DbClient, + idHash: string, + nowIso: string = new Date().toISOString(), +): Promise { + const { rows } = await db` + select id_hash, user_id, created_at, last_seen_at, expires_at, revoked_at, ip_address, user_agent, device_label + from visitor_sessions + where id_hash = ${idHash} + and revoked_at is null + and expires_at > ${nowIso} + limit 1 + ` + return rows[0] ? rowToSession(rows[0]) : null +} + +/** Refresh `last_seen_at` — fire-and-forget from the session cache (debounced by the caller). */ +export async function touchVisitorSession( + db: DbClient, + idHash: string, + nowIso: string = new Date().toISOString(), +): Promise { + await db` + update visitor_sessions + set last_seen_at = ${nowIso} + where id_hash = ${idHash} + and revoked_at is null + ` +} + +export async function revokeVisitorSessionByHash(db: DbClient, idHash: string): Promise { + await db` + update visitor_sessions + set revoked_at = current_timestamp + where id_hash = ${idHash} + and revoked_at is null + ` +} + +export async function revokeAllVisitorSessionsForUser(db: DbClient, userId: string): Promise { + await db` + update visitor_sessions + set revoked_at = current_timestamp + where user_id = ${userId} + and revoked_at is null + ` +} + +// --------------------------------------------------------------------------- +// Login attempts (audit trail — no auth decision hinges on this table in Phase 1) +// --------------------------------------------------------------------------- + +interface RecordVisitorLoginAttemptInput { + emailNormalized?: string | null + ipAddress?: string | null + userAgent?: string | null + userId?: string | null + result: VisitorLoginAttemptResult +} + +export async function recordVisitorLoginAttempt( + db: DbClient, + input: RecordVisitorLoginAttemptInput, +): Promise { + await db` + insert into visitor_login_attempts (id, attempted_at, email_normalized, ip_address, user_agent, user_id, result) + values ( + ${nanoid()}, + current_timestamp, + ${input.emailNormalized ?? null}, + ${input.ipAddress ?? null}, + ${input.userAgent ?? null}, + ${input.userId ?? null}, + ${input.result} + ) + ` +} + +/** + * Delete login-attempt rows older than `olderThanDays`. Call site is optional + * in Phase 1 (the table is append-only until a cleanup job is wired); kept + * here so a future cron has one canonical purge query. + */ +export async function purgeOldVisitorLoginAttempts( + db: DbClient, + olderThanDays = 90, +): Promise { + const cutoffIso = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000).toISOString() + await db` + delete from visitor_login_attempts + where attempted_at < ${cutoffIso} + ` +} + +// --------------------------------------------------------------------------- +// Password reset tokens (Phase 2 — V7) +// --------------------------------------------------------------------------- + +/** + * Mint a one-shot password-reset token for `userId`. Returns the RAW token + * (base64url, 32 random bytes) so the caller can hand it to the email + * transport — the DB persists only its SHA-256 hash (`hashSessionToken`), + * mirroring the session-token storage pattern. A token is invalid once + * consumed (`used_at`) or past `expires_at` (1h TTL, see + * `VISITOR_PASSWORD_RESET_TTL_MS`). + */ +export async function createPasswordResetToken( + db: DbClient, + userId: string, +): Promise { + const rawToken = createSessionToken() // randomBytes(32) base64url + const tokenHash = await hashSessionToken(rawToken) // SHA-256 hex + const expiresAt = new Date(Date.now() + VISITOR_PASSWORD_RESET_TTL_MS).toISOString() + await db` + insert into visitor_password_reset_tokens (id, user_id, token_hash, expires_at) + values (${nanoid()}, ${userId}, ${tokenHash}, ${expiresAt}) + ` + return rawToken +} + +/** + * Look up a token by its hash. Only matches rows that are unused (`used_at + * is null`) and not yet expired (`expires_at > now`). `nowIso` is supplied by + * the caller so the freshness check uses one clock read. Returns `null` for + * unknown / consumed / expired tokens — callers surface a single + * `invalid_or_expired_token` error so the failure mode reveals nothing. + */ +export async function findValidPasswordResetToken( + db: DbClient, + tokenHash: string, + nowIso: string = new Date().toISOString(), +): Promise { + const { rows } = await db` + select id, user_id, token_hash, expires_at, used_at, created_at + from visitor_password_reset_tokens + where token_hash = ${tokenHash} + and used_at is null + and expires_at > ${nowIso} + limit 1 + ` + return rows[0] ? rowToPasswordResetToken(rows[0]) : null +} + +/** + * Atomically consume a token (set `used_at`). Idempotent: the `where used_at + * is null` guard means only the first caller wins. Returns true when this + * call performed the consume (`rowCount > 0`); false when the token was + * already consumed (concurrent reset race) or doesn't exist. The reset + * handler treats a `false` result as `invalid_or_expired_token`. + */ +export async function consumePasswordResetToken( + db: DbClient, + tokenHash: string, +): Promise { + const result = await db` + update visitor_password_reset_tokens + set used_at = current_timestamp + where token_hash = ${tokenHash} + and used_at is null + ` + return result.rowCount > 0 +} diff --git a/server/visitor-auth/roles.ts b/server/visitor-auth/roles.ts new file mode 100644 index 000000000..b73de78c6 --- /dev/null +++ b/server/visitor-auth/roles.ts @@ -0,0 +1,194 @@ +/** + * Visitor-role DB CRUD. + * + * Extracted from `repositories.ts` (which mixed roles / users / sessions / + * login attempts / password-reset tokens) so each concern owns its own + * module — mirroring the admin system, where `server/repositories/roles.ts` + * is its own file separate from `users.ts`. Everything here is read/write + * against the `visitor_roles` table only. + * + * All SQL is ANSI-standard (no Postgres-isms — see `repositories.ts` header + * for the full dialect rules) so the same statements run unchanged against + * both the Postgres and SQLite adapters. Rows map to the camelCase + * `VisitorRole` domain shape through `rowToRole`; the column list is inlined + * into each tagged template (matching `server/repositories/roles.ts`) because + * a `db\`select ${COLS}\`` would interpolate the constant as a bind + * parameter, not as SQL text. + * + * Policy: system roles (`member` / `admin`) MAY be edited (name + + * capabilities) but NEVER deleted; custom roles may be edited and (when no + * active visitor is assigned to them) deleted. There is no Owner-lock + * concept (unlike the admin role system). + */ +import { nanoid } from 'nanoid' +import type { DbClient } from '../db/client' +import { filterArray, Type } from '@core/utils/typeboxHelpers' +import type { VisitorRole, VisitorRoleRow } from './types' + +// Capabilities for visitor roles are free-form strings (Phase-1 simplification +// — no junction table, no enum). `filterArray` keeps corrupt column data from +// crashing the read while staying inside the whitelisted +// `@core/utils/typeboxHelpers` surface. +const VisitorCapabilitySchema = Type.String() + +function rowToRole(row: VisitorRoleRow): VisitorRole { + return { + id: row.id, + name: row.name, + capabilities: filterArray(VisitorCapabilitySchema, row.capabilities_json), + isSystem: Boolean(row.is_system), + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + } +} + +/** + * Typed mutation error for visitor-role writes. Carries an HTTP `status` + * (default 400) so the shared `mutationErrorResponse` handler can translate + * it into the `{ error }` JSON envelope without the handler hard-coding the + * status. Mirrors `RoleMutationError` in `server/repositories/roles.ts`. + */ +export class VisitorRoleMutationError extends Error { + readonly status: number + + constructor(message: string, status = 400) { + super(message) + this.name = 'VisitorRoleMutationError' + this.status = status + } +} + +export async function findVisitorRoleById(db: DbClient, id: string): Promise { + const { rows } = await db` + select id, name, capabilities_json, is_system, created_at, updated_at + from visitor_roles + where id = ${id} + limit 1 + ` + return rows[0] ? rowToRole(rows[0]) : null +} + +export async function findVisitorRoleByName(db: DbClient, name: string): Promise { + const { rows } = await db` + select id, name, capabilities_json, is_system, created_at, updated_at + from visitor_roles + where name = ${name} + limit 1 + ` + return rows[0] ? rowToRole(rows[0]) : null +} + +export async function listVisitorRoles(db: DbClient): Promise { + const { rows } = await db` + select id, name, capabilities_json, is_system, created_at, updated_at + from visitor_roles + order by is_system desc, name asc + ` + return rows.map(rowToRole) +} + +/** + * Pre-check name uniqueness (excluding the row currently being edited, if + * any) so a duplicate name surfaces a clean 409 instead of a dialect-specific + * DB constraint violation. The `visitor_roles.name` UNIQUE constraint is the + * backstop; this is the friendly, portable front line — same shape as the + * admin `assertRoleSlugAvailable`. + */ +async function assertVisitorRoleNameAvailable( + db: DbClient, + name: string, + currentRoleId?: string, +): Promise { + const existing = await findVisitorRoleByName(db, name) + if (existing && existing.id !== currentRoleId) { + throw new VisitorRoleMutationError('Visitor role name is already in use', 409) + } +} + +/** + * Create a custom visitor role. Mirrors the admin `createCustomRole` shape + * (minus slug/description — visitor roles only carry name + capabilities). + * + * Policy: a duplicate name is rejected up front via + * {@link assertVisitorRoleNameAvailable} so the caller gets a 409, not a raw + * constraint error. Custom roles are always created with `is_system = false`. + */ +export async function createVisitorRole( + db: DbClient, + input: { name: string; capabilities: string[] }, +): Promise { + const name = input.name.trim() + if (!name) throw new VisitorRoleMutationError('Visitor role name is required') + await assertVisitorRoleNameAvailable(db, name) + + const id = nanoid() + const { rows } = await db` + insert into visitor_roles (id, name, capabilities_json, is_system) + values (${id}, ${name}, ${input.capabilities}, ${false}) + returning id, name, capabilities_json, is_system, created_at, updated_at + ` + return rowToRole(rows[0]!) +} + +/** + * Update a visitor role's name and/or capabilities. + * + * Policy (mirror the admin `roles.ts` rules, simplified — visitor roles have + * no Owner-lock concept): system roles (`member` / `admin`) MAY be edited + * (name + capabilities); they are NEVER deletable (see {@link deleteVisitorRole}). + * Custom roles may be edited and (when unassigned) deleted. A rename that + * collides with another role's name is rejected with a 409. + */ +export async function updateVisitorRole( + db: DbClient, + roleId: string, + input: { name?: string; capabilities?: string[] }, +): Promise { + const current = await findVisitorRoleById(db, roleId) + if (!current) return null + + const name = input.name === undefined ? current.name : input.name.trim() + if (!name) throw new VisitorRoleMutationError('Visitor role name is required') + await assertVisitorRoleNameAvailable(db, name, current.id) + const capabilities = input.capabilities ?? current.capabilities + + const { rows } = await db` + update visitor_roles + set name = ${name}, + capabilities_json = ${capabilities}, + updated_at = current_timestamp + where id = ${roleId} + returning id, name, capabilities_json, is_system, created_at, updated_at + ` + return rows[0] ? rowToRole(rows[0]) : null +} + +/** + * Delete a visitor role. + * + * Policy: system roles are never deletable (409); custom roles are deletable + * only when no active visitor is assigned to them (409 otherwise — the + * `visitor_users.role_id` FK is `on delete restrict`, so this guard turns the + * restriction into a friendly message instead of a 500). Returns the deleted + * role on success, or `null` when no row matched `roleId`. + */ +export async function deleteVisitorRole(db: DbClient, roleId: string): Promise { + const current = await findVisitorRoleById(db, roleId) + if (!current) return null + if (current.isSystem) { + throw new VisitorRoleMutationError('System roles cannot be deleted', 409) + } + + const { rows } = await db<{ count: number }>` + select count(*) as count + from visitor_users + where role_id = ${roleId} + and deleted_at is null + ` + if (Number(rows[0]?.count ?? 0) > 0) { + throw new VisitorRoleMutationError('Cannot delete a role assigned to visitors', 409) + } + + const result = await db`delete from visitor_roles where id = ${roleId}` + return result.rowCount > 0 ? current : null +} diff --git a/server/visitor-auth/sessions.ts b/server/visitor-auth/sessions.ts new file mode 100644 index 000000000..bb415e886 --- /dev/null +++ b/server/visitor-auth/sessions.ts @@ -0,0 +1,144 @@ +/** + * Visitor-session cookie helpers + an in-memory session validation cache. + * + * Cookie helpers mirror `server/handlers/cms/session.ts` but scope the + * cookie to `Path=/` (visitor auth gates public-site routes, not the admin + * SPA) and use the visitor cookie name. The `Secure` flag follows the same + * rule: set when the configured public origin is HTTPS OR the request URL + * is https — the former covers TLS-terminating edges that hand the + * container plain HTTP. + * + * The validation cache (D5) fronts `findActiveVisitorSessionByHash` with a + * short-TTL `Map` so a visitor navigating between protected pages does not + * pay a DB round-trip per request. A hit whose cached session is past its + * `expires_at` is treated as a miss; a hit that is still live optionally + * fires a debounced `touchVisitorSession` (no `await`) so `last_seen_at` + * stays fresh without write amplification. + */ +import type { DbClient } from '../db/client' +import { hashSessionToken } from '../auth/tokens' +import { publicOriginIsHttps } from '../auth/security' +import { + VISITOR_SESSION_COOKIE_NAME, + type VisitorSession, +} from './types' +import { findActiveVisitorSessionByHash, touchVisitorSession } from './repositories' + +/** True when the inbound request was made over HTTPS (configured origin wins). */ +function requestIsHttps(req: Request): boolean { + if (publicOriginIsHttps()) return true + return req.url.startsWith('https://') +} + +function visitorCookieAttributes(secure: boolean): string { + // Path=/ — visitor auth gates public-site routes, not just /admin. + // HttpOnly — JS in the browser cannot read the cookie (XSS mitigation). + // SameSite=Lax — cross-origin POST/PUT/DELETE don't carry the cookie (CSRF). + // Secure — browser only sends the cookie over HTTPS (set when applicable). + const base = 'Path=/; HttpOnly; SameSite=Lax' + return secure ? `${base}; Secure` : base +} + +export function visitorSessionCookie(req: Request, token: string, expiresAt: Date): string { + const attrs = visitorCookieAttributes(requestIsHttps(req)) + return `${VISITOR_SESSION_COOKIE_NAME}=${token}; ${attrs}; Expires=${expiresAt.toUTCString()}` +} + +export function clearVisitorSessionCookie(req: Request): string { + const attrs = visitorCookieAttributes(requestIsHttps(req)) + return `${VISITOR_SESSION_COOKIE_NAME}=; ${attrs}; Max-Age=0` +} + +/** Parse a `Cookie` header into a flat map. Returns `{}` when absent. */ +function parseCookies(header: string | null): Record { + if (!header) return {} + const out: Record = {} + for (const part of header.split(';')) { + const eq = part.indexOf('=') + if (eq < 0) continue + const name = part.slice(0, eq).trim() + if (!name) continue + out[name] = decodeURIComponent(part.slice(eq + 1).trim()) + } + return out +} + +/** + * Read the visitor session cookie off the request and return its hash, or + * `null` when no cookie is present. The hash (not the raw token) is the + * `visitor_sessions.id_hash` key, so it is safe to log / use as a cache key. + */ +export async function getVisitorSessionIdHash(req: Request): Promise { + const cookies = parseCookies(req.headers.get('cookie')) + const token = cookies[VISITOR_SESSION_COOKIE_NAME] + if (!token) return null + return hashSessionToken(token) +} + +// --------------------------------------------------------------------------- +// In-memory session cache (D5) +// --------------------------------------------------------------------------- + +const SESSION_CACHE_TTL_MS = 5 * 60 * 1000 +const SESSION_TOUCH_DEBOUNCE_MS = 30 * 1000 + +interface CachedEntry { + session: VisitorSession + /** Wall-clock time the entry was populated — drives the TTL eviction. */ + cachedAt: number +} + +const sessionCache = new Map() + +/** Drop the cached entry for `idHash` — call on logout / revoke. */ +export function invalidateVisitorSessionCache(idHash: string): void { + sessionCache.delete(idHash) +} + +/** + * Resolve the request to a live visitor session, or `null` when there is no + * cookie / the session was revoked / the session has expired. + * + * Cache flow: + * 1. No cookie → `null`. + * 2. Cache hit AND the session is still within its `expires_at` → return + * the cached session. Side-effect: if `lastSeenAt` is older than the + * debounce window, fire-and-forget `touchVisitorSession` (no `await`). + * 3. Cache miss (or stale) → `findActiveVisitorSessionByHash`; `null` → + * return `null`; otherwise cache + return. + * + * The cache is keyed by the token hash and bounded by a 5-minute TTL — a + * revoked session therefore lingers for at most 5 minutes after logout on a + * given worker unless `invalidateVisitorSessionCache` is called. The logout + * handler calls it, so the common path clears immediately. + */ +export async function validateVisitorSession( + db: DbClient, + req: Request, +): Promise { + const idHash = await getVisitorSessionIdHash(req) + if (!idHash) return null + + const now = Date.now() + const cached = sessionCache.get(idHash) + if (cached) { + const expiredByTtl = now - cached.cachedAt > SESSION_CACHE_TTL_MS + const expiredByExpiry = Date.parse(cached.session.expiresAt) <= now + if (!expiredByTtl && !expiredByExpiry) { + // Debounced last-seen touch — never awaited, never blocks the response. + if (now - Date.parse(cached.session.lastSeenAt) > SESSION_TOUCH_DEBOUNCE_MS) { + void touchVisitorSession(db, idHash, new Date(now).toISOString()) + // Optimistically advance the cached lastSeenAt so we don't fire a + // touch on every subsequent in-window request. + cached.session = { ...cached.session, lastSeenAt: new Date(now).toISOString() } + } + return cached.session + } + sessionCache.delete(idHash) + } + + const session = await findActiveVisitorSessionByHash(db, idHash) + if (!session) return null + sessionCache.set(idHash, { session, cachedAt: now }) + return session +} diff --git a/server/visitor-auth/types.ts b/server/visitor-auth/types.ts new file mode 100644 index 000000000..a8ed7520f --- /dev/null +++ b/server/visitor-auth/types.ts @@ -0,0 +1,279 @@ +/** + * Visitor-auth domain types, constants, and DB row shapes. + * + * The visitor system is intentionally isolated from the admin auth system: + * different tables (`visitor_users` / `visitor_sessions` / …), a different + * cookie name, a different code path. The only thing shared is a small + * set of stateless crypto / rate-limit / lockout utilities from + * `server/auth/` (see the import whitelist in `SPEC-3` / `docs/PRD.md` D3). + * + * The `*Row` interfaces match the `002_visitor_auth` migration columns + * exactly (snake_case). The public `Visitor*` interfaces are the + * camelCase domain shape the handlers and clients speak. Row → domain + * conversion lives in `repositories.ts`. + */ + +/** Cookie name — deliberately distinct from the admin session cookie. */ +export const VISITOR_SESSION_COOKIE_NAME = 'instatic_visitor_session' + +/** Absolute session lifetime (90 days). */ +export const VISITOR_SESSION_ABSOLUTE_MS = 1000 * 60 * 60 * 24 * 90 + +/** Idle timeout (30 days, advisory — the absolute timeout is the hard cap). */ +export const VISITOR_SESSION_IDLE_MS = 1000 * 60 * 60 * 24 * 30 + +/** Minimum visitor password length. */ +export const VISITOR_PASSWORD_MIN = 8 + +/** Password-reset token lifetime (1 hour). Raw tokens are never persisted; only their SHA-256 hash is. */ +export const VISITOR_PASSWORD_RESET_TTL_MS = 1000 * 60 * 60 + +/** The single row id used for the `visitor_auth_config` table. */ +export const VISITOR_AUTH_CONFIG_ROW_ID = 'default' + +/** + * The fixed group id used by the Phase-3 `024_page_access` migration backfill. + * When an upgraded install still carried Phase-1/2 `protected_prefixes_json`, + * every prefix-matched page is converted to per-page access against a single + * synthesized `members` group carrying this id. Kept as a named constant so + * the migration + any diagnostic tooling reference one id. + */ +export const VISITOR_BACKFILL_MEMBERS_GROUP_ID = 'vis_group_members_backfill' + +export interface VisitorRole { + id: string + name: string + capabilities: string[] + isSystem: boolean + createdAt: string + updatedAt: string +} + +export type VisitorUserStatus = 'active' | 'suspended' + +export interface VisitorUser { + id: string + email: string + emailNormalized: string + passwordHash: string + displayName: string + roleId: string + /** D15: the visitor's designated primary group (drives login redirect). Nullable. */ + primaryGroupId: string | null + status: VisitorUserStatus + failedLoginCount: number + lockedUntil: string | null + createdAt: string + updatedAt: string + /** Custom profile field VALUES (object keyed by field id). Empty {} when no fields configured/set. */ + profileFields: Record +} + +export interface VisitorSession { + idHash: string + userId: string + createdAt: string + lastSeenAt: string + expiresAt: string + revokedAt: string | null + ipAddress: string | null + userAgent: string | null + deviceLabel: string +} + +export interface VisitorAuthConfig { + enabled: boolean + /** D15: where a visitor with no primary-group landing path lands after login. */ + defaultLandingPath: string + loginPath: string + registrationOpen: boolean + defaultRole: string + /** + * Site-builder-defined custom profile field DEFINITIONS (DataField[]). + * Default [] = no custom profile fields (pre-framework behaviour). + * Values live per-visitor in visitor_users.profile_fields_json. + */ + profileFields: VisitorProfileField[] +} + +/** + * A site-builder-defined visitor profile field. A minimal projection of the + * core DataField shape (id/label/type/required) — kept narrow intentionally + * so the visitor surface doesn't drag in the full field-type union until a + * field type beyond text/longText/select/boolean is actually needed. + */ +export type VisitorProfileFieldType = 'text' | 'longText' | 'select' | 'boolean' + +export interface VisitorProfileField { + id: string + label: string + type: VisitorProfileFieldType + required?: boolean + options?: { value: string; label: string }[] +} + +export const DEFAULT_VISITOR_AUTH_CONFIG: VisitorAuthConfig = { + enabled: false, + defaultLandingPath: '/', + loginPath: '/login', + registrationOpen: true, + defaultRole: 'member', + profileFields: [], +} + +/** Outcome of a login attempt — persisted to `visitor_login_attempts`. */ +export type VisitorLoginAttemptResult = + | 'success' + | 'bad_password' + | 'no_user' + | 'locked' + | 'rate_limited' + | 'account_disabled' + +// --------------------------------------------------------------------------- +// DB row shapes (snake_case — match the `002_visitor_auth` migration exactly) +// --------------------------------------------------------------------------- + +export interface VisitorRoleRow { + id: string + name: string + capabilities_json: unknown + is_system: boolean | number + created_at: Date | string + updated_at: Date | string +} + +export interface VisitorUserRow { + id: string + email: string + email_normalized: string + password_hash: string + display_name: string + role_id: string + /** D15 primary group — nullable, references visitor_groups(id). */ + primary_group_id: string | null + status: VisitorUserStatus | string + failed_login_count: number + locked_until: Date | string | null + created_at: Date | string + updated_at: Date | string + deleted_at: Date | string | null + /** Custom profile field VALUES (object keyed by field id). Auto-parsed by the _json-suffix adapter. */ + profile_fields_json?: Record | string +} + +export interface VisitorSessionRow { + id_hash: string + user_id: string + created_at: Date | string + last_seen_at: Date | string + expires_at: Date | string + revoked_at: Date | string | null + ip_address: string | null + user_agent: string | null + device_label: string +} + +export interface VisitorLoginAttemptRow { + id: string + attempted_at: Date | string + email_normalized: string | null + ip_address: string | null + user_agent: string | null + user_id: string | null + result: VisitorLoginAttemptResult | string +} + +export interface VisitorAuthConfigRow { + id: string + enabled: boolean | number + /** D15 default landing path (replaces the retired `protected_prefixes_json`). */ + default_landing_path: string + login_path: string + registration_open: boolean | number + default_role: string + updated_at: Date | string + /** Site-builder-configured profile field DEFINITIONS (DataField[]). */ + profile_fields_json?: unknown +} + +/** + * A valid (unconsumed, unexpired) password-reset token lookup result. + * `token_hash` is the SHA-256 hex of the raw token; the raw token is never + * stored and is therefore never part of this shape. + */ +export interface VisitorPasswordResetToken { + id: string + userId: string + tokenHash: string + expiresAt: string + usedAt: string | null + createdAt: string +} + +/** DB row shape for `visitor_password_reset_tokens` (snake_case — matches the `022_visitor_password_reset` migration). */ +export interface VisitorPasswordResetTokenRow { + id: string + user_id: string + token_hash: string + expires_at: Date | string + used_at: Date | string | null + created_at: Date | string +} + +// --------------------------------------------------------------------------- +// Member groups (Phase 3 — D13/D14/D15) +// --------------------------------------------------------------------------- + +/** + * A member group — a content-segmentation segment used for page-level access + * (D14) and login-redirect resolution (D15). Orthogonal to {@link VisitorRole} + * (D13): a role answers "what can a member DO"; a group answers "what can a + * member SEE / where do they land". A visitor belongs to 0..N groups via the + * {@link VisitorUserGroup} junction, with one designated primary group + * (`visitor_users.primary_group_id`). + */ +export interface VisitorGroup { + id: string + name: string + /** Lowercase kebab, derived from the name. NOT unique (unlike `name`). */ + slug: string + /** D15: where primary members of this group land after login. */ + landingPath: string + description: string + isSystem: boolean + createdAt: string + updatedAt: string +} + +/** DB row shape for `visitor_groups` (snake_case — matches the `023_member_groups` migration). */ +export interface VisitorGroupRow { + id: string + name: string + slug: string + landing_path: string + description: string + is_system: boolean | number + created_at: Date | string + updated_at: Date | string +} + +/** + * A membership row in the `visitor_user_groups` junction (D13). A visitor is + * in a group at most once (UNIQUE(user_id, group_id)). Junction CASCADEs on + * both sides, so deleting a group or a user removes its memberships. + */ +export interface VisitorUserGroup { + id: string + userId: string + groupId: string + createdAt: string +} + +/** DB row shape for `visitor_user_groups` (snake_case — matches the `023_member_groups` migration). */ +export interface VisitorUserGroupRow { + id: string + user_id: string + group_id: string + created_at: Date | string +} diff --git a/server/visitor-auth/visitorData.ts b/server/visitor-auth/visitorData.ts new file mode 100644 index 000000000..49afdce21 --- /dev/null +++ b/server/visitor-auth/visitorData.ts @@ -0,0 +1,84 @@ +/** + * Per-visitor data resolver — the IDOR-safe identity source for the + * `visitor.current` and `visitor.owned-rows` loop sources. + * + * The load-bearing security rule: **the visitor identity is derived solely + * from the validated session cookie**, never from any value supplied by the + * loop's filters, query, or path. Every call into this module constructs a + * minimal synthetic Request carrying only the visitor session cookie and + * hands it to the existing cached `validateVisitorSession` — so the + * in-memory session cache, expiry checks, and touch-debounce are all reused + * rather than re-implemented. No visitor id from request input is ever + * trusted on a read path (see the architecture gate + * `visitor-data-isolation.test.ts`). + * + * Loop sources receive the parsed cookie map (`ctx.request.cookies`) rather + * than a `Request`; this module is the bridge between that map and the + * Request-shaped session validator. + */ +import type { DbClient } from '../db/client' +import { VISITOR_SESSION_COOKIE_NAME } from './types' +import { validateVisitorSession } from './sessions' +import { findVisitorUserById } from './repositories' +import { findVisitorRoleById } from './roles' + +/** + * Build a minimal Request that carries a single visitor session cookie, so + * the cached `validateVisitorSession` can be reused unchanged. Only the + * `cookie` header is read by the validator; the URL/method are arbitrary. + */ +function requestFromCookies(cookies: Record): Request { + const token = cookies?.[VISITOR_SESSION_COOKIE_NAME] + const req = new Request('http://instatic.local/visitor-resolve') + // Set the header imperatively rather than via the Request init: some + // environments (e.g. the test harness's happy-dom Request) strip a `cookie` + // header supplied through the constructor's `headers` init, mirroring the + // browser fetch spec's forbidden-header rule. The real Bun server's Request + // accepts either form, but imperative `.set()` works in BOTH, so the + // resolver behaves identically in production and tests. + if (token) req.headers.set('cookie', `${VISITOR_SESSION_COOKIE_NAME}=${token}`) + return req +} + +/** + * The resolved visitor for loop sources: the VisitorUser plus its role name + * (resolved via a second lookup so the source consumer doesn't have to). + * IDOR-safe — derived solely from the validated session cookie. + */ +export interface ResolvedVisitor { + id: string + displayName: string + email: string + roleId: string + roleName: string | null + profileFields: Record +} + +/** + * Resolve the logged-in visitor from a cookie map. Returns the visitor with + * role name + profile fields, or `null` when there is no cookie, no valid + * session, or the visitor record no longer exists. + * + * This is the single entry point both loop sources use to obtain visitor + * identity. It accepts ONLY a cookie map — never a visitor/user/owner id — + * which is what makes it IDOR-safe by construction. + */ +export async function resolveVisitorFromCookie( + db: DbClient, + cookies: Record | undefined, +): Promise { + if (!cookies) return null + const session = await validateVisitorSession(db, requestFromCookies(cookies)) + if (!session) return null + const user = await findVisitorUserById(db, session.userId) + if (!user) return null + const role = user.roleId ? await findVisitorRoleById(db, user.roleId) : null + return { + id: user.id, + displayName: user.displayName, + email: user.email, + roleId: user.roleId, + roleName: role?.name ?? null, + profileFields: user.profileFields, + } +} diff --git a/server/handlers/cms/pageDiff.ts b/server/writePolicy/pageDiff.ts similarity index 86% rename from server/handlers/cms/pageDiff.ts rename to server/writePolicy/pageDiff.ts index d0063089e..f6877b01b 100644 --- a/server/handlers/cms/pageDiff.ts +++ b/server/writePolicy/pageDiff.ts @@ -1,5 +1,8 @@ /** - * Page write diff validator for PUT /admin/api/cms/pages. + * Page write diff validator — the per-category capability POLICY for page + * changes, shared by BOTH write transports: the transactional HTTP save + * (server/handlers/cms/siteDocument.ts) and the collab relay's update guard + * (server/collab/updateGuard.ts). * * The pages endpoint owns both dangerous roster reconciliation and ordinary * node edits. A coarse `site.structure.edit` gate protects deletion, but it @@ -12,7 +15,8 @@ * - content: props whose module schema marks them content-editable. * - style: class assignments, inline styles, breakpoint overrides. */ -import type { CoreCapability } from '../../auth/capabilities' +import type { CoreCapability } from '../auth/capabilities' +import { deepEqual } from '@core/utils/deepEqual' import { ForbiddenSiteChangeError } from './siteDiff' import { registry, resolvePropertyControlCategory } from '@core/module-engine' import type { Page, PageNode } from '@core/page-tree' @@ -185,29 +189,3 @@ function propChangeKind(moduleId: string, propKey: string): PageChangeKind { return resolvePropertyControlCategory(control) === 'content' ? 'content' : 'structure' } -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true - if (a === null || b === null) return a === b - if (typeof a !== typeof b) return false - if (typeof a !== 'object') return false - if (Array.isArray(a)) { - if (!Array.isArray(b)) return false - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (!deepEqual(a[i], b[i])) return false - } - return true - } - if (Array.isArray(b)) return false - - const aKeys = Object.keys(a as Record) - const bKeys = Object.keys(b as Record) - if (aKeys.length !== bKeys.length) return false - for (const key of aKeys) { - if (!Object.prototype.hasOwnProperty.call(b, key)) return false - if (!deepEqual((a as Record)[key], (b as Record)[key])) { - return false - } - } - return true -} diff --git a/server/handlers/cms/siteDiff.ts b/server/writePolicy/siteDiff.ts similarity index 87% rename from server/handlers/cms/siteDiff.ts rename to server/writePolicy/siteDiff.ts index c8671f032..0f2c6fc63 100644 --- a/server/handlers/cms/siteDiff.ts +++ b/server/writePolicy/siteDiff.ts @@ -1,5 +1,8 @@ /** - * Site-shell write diff validator — enforces granular capabilities on PUT /admin/api/cms/site. + * Site-shell write diff validator — the per-category capability POLICY for + * shell changes, shared by BOTH write transports: the transactional HTTP + * save (server/handlers/cms/siteDocument.ts) and the collab relay's update + * guard (server/collab/updateGuard.ts). * * The save endpoint accepts the site shell and replaces the draft. * To support a "Client" role with `site.content.edit` only, we walk the diff @@ -28,11 +31,12 @@ * the incoming document is treated as a structural change in its entirety — * a content-only caller cannot bootstrap a site from nothing. */ -import type { CoreCapability } from '../../auth/capabilities' +import type { CoreCapability } from '../auth/capabilities' import type { StyleRule, SiteShell, } from '@core/page-tree' +import { deepEqual } from '@core/utils/deepEqual' type SiteChangeKind = 'structure' | 'content' | 'style' @@ -237,33 +241,4 @@ function diffFiles( } } -// --------------------------------------------------------------------------- -// Small deep-equal helpers -// --------------------------------------------------------------------------- -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true - if (a === null || b === null) return a === b - if (typeof a !== typeof b) return false - if (typeof a !== 'object') return false - if (Array.isArray(a)) { - if (!Array.isArray(b)) return false - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (!deepEqual(a[i], b[i])) return false - } - return true - } - if (Array.isArray(b)) return false - const aKeys = Object.keys(a as Record) - const bKeys = Object.keys(b as Record) - if (aKeys.length !== bKeys.length) return false - for (const k of aKeys) { - if (!Object.prototype.hasOwnProperty.call(b, k)) return false - if (!deepEqual( - (a as Record)[k], - (b as Record)[k], - )) return false - } - return true -} diff --git a/src/__tests__/admin/data/dataCanvasRowCreation.test.tsx b/src/__tests__/admin/data/dataCanvasRowCreation.test.tsx index f78362e15..5e7d69b9b 100644 --- a/src/__tests__/admin/data/dataCanvasRowCreation.test.tsx +++ b/src/__tests__/admin/data/dataCanvasRowCreation.test.tsx @@ -82,8 +82,8 @@ function renderCanvas(table: DataTable, rows: DataRow[], overrides: Partial { - it('hides "Add row" and "Duplicate row" when every field on the table is a locked built-in', () => { +describe('DataCanvas — row creation', () => { + it('hides "Add row" and "Duplicate row" for a system table', () => { renderCanvas(makePagesTable(), [makeRow()]) expect(screen.queryByRole('button', { name: /add row/i })).toBeNull() @@ -92,7 +92,7 @@ describe('DataCanvas — row creation on locked system tables', () => { expect(screen.queryByRole('menuitem', { name: /duplicate row/i })).toBeNull() }) - it('shows "Add row" and "Duplicate row" once the table has a custom (non-built-in) field', () => { + it('still hides both actions when a system table has a custom editable field', () => { const table = makePagesTable({ fields: [ { type: 'text', id: 'title', label: 'Title', required: true, builtIn: true }, @@ -102,9 +102,29 @@ describe('DataCanvas — row creation on locked system tables', () => { }) renderCanvas(table, [makeRow()]) - expect(screen.getByRole('button', { name: /add row/i })).toBeDefined() + expect(screen.queryByRole('button', { name: /add row/i })).toBeNull() fireEvent.contextMenu(screen.getByText('Home').closest('[role="row"]')!, { clientX: 100, clientY: 100 }) + expect(screen.queryByRole('menuitem', { name: /duplicate row/i })).toBeNull() + }) + + it('shows "Add row" and "Duplicate row" for a custom table with editable fields', () => { + const table = makePagesTable({ + id: 'table-projects', + name: 'projects', + slug: 'projects', + kind: 'data', + singularLabel: 'Project', + pluralLabel: 'Projects', + system: false, + fields: [{ type: 'text', id: 'name', label: 'Name', required: true }], + primaryFieldId: 'name', + }) + renderCanvas(table, [makeRow({ tableId: table.id, cells: { name: 'Campaign' } })]) + + expect(screen.getByRole('button', { name: /add row/i })).toBeDefined() + + fireEvent.contextMenu(screen.getByText('Campaign').closest('[role="row"]')!, { clientX: 100, clientY: 100 }) expect(screen.getByRole('menuitem', { name: /duplicate row/i })).toBeDefined() }) diff --git a/src/__tests__/admin/data/dataContextMenus.test.tsx b/src/__tests__/admin/data/dataContextMenus.test.tsx index f7ab544c8..c5e9a54e8 100644 --- a/src/__tests__/admin/data/dataContextMenus.test.tsx +++ b/src/__tests__/admin/data/dataContextMenus.test.tsx @@ -257,6 +257,8 @@ describe('Data table context menu', () => { />, ) + expect(screen.getByText('data').closest('[data-accent]')?.getAttribute('data-accent')).toBe('d') + fireEvent.contextMenu(screen.getByRole('option', { name: /products/i }), { clientX: 60, clientY: 140, @@ -273,6 +275,38 @@ describe('Data table context menu', () => { expect(onDeleteTable).toHaveBeenCalledWith(customTable) }) + it('assigns a distinct stable accent to every table kind', () => { + render( + {}} + onCreateTable={() => {}} + onOpenExport={() => {}} + onOpenImport={() => {}} + onOpenTableSettings={() => {}} + onDeleteTable={() => {}} + canCreateTable + canManage + canExport + canImport + />, + ) + + const badgeAccents = ['page', 'post-type', 'component', 'data'].map( + (label) => screen.getByText(label).closest('[data-accent]')?.getAttribute('data-accent'), + ) + expect(badgeAccents).toEqual(['a', 'b', 'c', 'd']) + expect(new Set(badgeAccents).size).toBe(badgeAccents.length) + }) + it('disables deleting protected system tables', () => { render( = new Map([ // exports. No request handlers live here. ['shared.ts', 'Shared request helpers; no handlers.'], ['session.ts', 'Session lookup helper; called from auth.ts which gates.'], - ['siteDiff.ts', 'Diff validator called from site.ts after that file gates.'], - ['pageDiff.ts', 'Diff validator called from pages.ts after that file gates.'], // Media upload helpers — `acceptUploadedMedia`, `readUploadedFile`, // file-magic sniffing. Always called by an already-gated parent // handler (`/me/avatar`, `/media`). @@ -74,6 +72,10 @@ const ALLOWLIST: ReadonlyMap = new Map([ // pages, not a CMS admin route. Reached only via the public router. ['loop.ts', 'Published-page runtime endpoint; not a /admin/api/cms/ route.'], ['hole.ts', 'Published-page runtime endpoint; not a /admin/api/cms/ route.'], + // Auth-gate fragment endpoint — `/_instatic/gate/` renders a gated + // container subtree for authorised visitors (same trust model as hole/loop: + // reached only via the public router, authorisation lives in gateHelpers). + ['gate.ts', 'Published-page runtime endpoint; not a /admin/api/cms/ route.'], // Module-JS assets — `/_instatic/module-js/.js` serves published // module runtimes to anonymous visitors, same trust model as hole/loop. ['moduleJs.ts', 'Published-page runtime endpoint; not a /admin/api/cms/ route.'], @@ -110,6 +112,7 @@ const ALLOWLIST: ReadonlyMap = new Map([ ['dashboard/posts.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], ['dashboard/media.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], ['dashboard/plugins.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], + ['dashboard/members.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher (visitor count + registration histogram).'], ['dashboard/publishLineup.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], ['dashboard/activity.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], ['dashboard/storage.ts', 'Widget data reader called by gated dashboard/index.ts dispatcher.'], diff --git a/src/__tests__/architecture/framework-typography-spacing.test.ts b/src/__tests__/architecture/framework-typography-spacing.test.ts index ccc47d75e..d52f3be95 100644 --- a/src/__tests__/architecture/framework-typography-spacing.test.ts +++ b/src/__tests__/architecture/framework-typography-spacing.test.ts @@ -135,6 +135,6 @@ describe('architecture — left sidebar', () => { const layoutSource = readSource('admin/pages/site/sidebars/LeftSidebar/LeftSidebar.tsx') it('mounts the consolidated FrameworkPanel', () => { - expect(layoutSource).toContain('') + expect(layoutSource).toMatch(/([ */ const GRANDFATHERED: Record = { 'src/admin/pages/site/store/slices/visualComponentsSlice.ts': 715, + // server/router.ts: the routing table is a flat first-match-wins array; the + // visitor-auth Phase-1 addition of tryServeVisitorRoutes pushed it past the + // 700-line ceiling. Splitting a route table by concern adds indirection + // without clarifying dispatch order, so it's frozen here. See + // docs/ARCHITECTURE.md §4.1 (route positions are documented invariants). + 'server/router.ts': 704, // server/repositories/media.ts graduated: the row ↔ asset mapping unit was // extracted into server/repositories/mediaAssetMapping.ts, dropping media.ts // to 583 lines — under CEILING, so it's now held by the normal ceiling rule. diff --git a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts index 6ee37600c..8c97aefda 100644 --- a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts +++ b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts @@ -36,6 +36,7 @@ const BARRELLED_MODULES = [ 'framework', 'framework-schema', 'fonts', + 'collab', ] // Scan production + test sources in both the app and the server. diff --git a/src/__tests__/architecture/visitor-data-isolation.test.ts b/src/__tests__/architecture/visitor-data-isolation.test.ts new file mode 100644 index 000000000..545c58e9d --- /dev/null +++ b/src/__tests__/architecture/visitor-data-isolation.test.ts @@ -0,0 +1,126 @@ +/** + * Architecture gate: visitor-data framework IDOR isolation. + * + * The load-bearing security rule for the per-visitor-data framework: visitor + * identity is derived SOLELY from the validated session cookie — never from + * loop filters, query params, path segments, or form bodies. If any of these + * tests fail, a visitor could be shown (or stamped as the owner of) another + * visitor's data. + * + * These are static source-analysis checks (read file contents, assert + * invariants). They are intentionally strict: the cookie is the one and only + * identity source, enforced at the resolver boundary and the loop-source + * boundary. Defence-in-depth note: visitor ids are nanoid (21 chars, ~126 + * bits, unguessable) — but unguessability is secondary; cookie-derived + * identity is the primary IDOR guard. + * + * See `docs/PER-VISITOR-DATA-SPEC.md` § "Security invariants". + */ + +import { describe, expect, it } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join, dirname } from 'node:path' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') + +async function read(relative: string): Promise { + return await readFile(join(ROOT, relative), 'utf-8') +} + +/** + * Strip comments + string literals so docstrings/strings never satisfy or + * fail a structural scan. Mirrors the helper in + * `plugin-sandbox-invariants.test.ts` (not a full parser, but sufficient for + * grep-style structural checks over source). + */ +function stripCommentsAndStrings(source: string): string { + let s = source.replace(/\/\*[\s\S]*?\*\//g, ' ') // block comments + s = s.replace(/\/\/[^\n]*/g, ' ') // line comments + s = s.replace(/'(?:\\.|[^'\\])*'/g, "''") // single-quoted strings + s = s.replace(/"(?:\\.|[^"\\])*"/g, '""') // double-quoted strings + return s +} + +describe('visitor-data framework IDOR isolation', () => { + // ───────────────────────────────────────────────────────────────────── + // 1. The resolver accepts no identity id parameter — cookie map only. + // ───────────────────────────────────────────────────────────────────── + it('resolveVisitorFromCookie derives identity only from the cookie map, not an id param', async () => { + const source = stripCommentsAndStrings(await read('server/visitor-auth/visitorData.ts')) + + // The public resolver exists and takes the cookie map as its identity input. + expect(source).toContain('export async function resolveVisitorFromCookie') + expect(source).toContain('cookies: Record') + + // It must reach identity through the cached session validator (cookie path), + // not by accepting/resolving a visitor/user/owner id. + expect(source).toContain('validateVisitorSession') + + // No identity-id parameter may influence resolution. The resolver signature + // has (db, cookies) — ban an explicit visitor/user/owner id param name. + expect(source).not.toMatch(/resolveVisitorFromCookie\s*\([^)]*\bvisitorId\b/) + expect(source).not.toMatch(/resolveVisitorFromCookie\s*\([^)]*\buserId\b/) + expect(source).not.toMatch(/resolveVisitorFromCookie\s*\([^)]*\bownerId\b/) + }) + + // ───────────────────────────────────────────────────────────────────── + // 2. Visitor loop sources read identity from ctx.visitor, never from + // request filters/query/path (which a caller could forge). + // ───────────────────────────────────────────────────────────────────── + for (const rel of ['src/core/loops/sources/visitorCurrent.ts', 'src/core/loops/sources/visitorOwnedRows.ts']) { + it(`identity in ${rel} comes from ctx.visitor, not request input`, async () => { + const source = stripCommentsAndStrings(await read(rel)) + + // Identity is the cookie-resolved ctx.visitor. + expect(source).toContain('ctx.visitor') + + // The owner filter binds ctx.visitor.id (ownedRows). Confirm ctx.visitor + // is referenced — then forbid deriving a visitor from request input. + // ctx.filters.tableId is allowed (it selects a TABLE, not an identity). + expect(source).not.toMatch(/ctx\.request\.query\s*\[?\s*['"]?\w*([Ii]d|[Uu]serId|[Oo]wnerId)/) + expect(source).not.toMatch(/ctx\.request\.path\b/) + expect(source).not.toMatch(/ctx\.filters\s*\.\s*(visitor|user|owner)/i) + // No visitor/user/owner id read off the slug (request identity). + expect(source).not.toMatch(/ctx\.request\.(slug|query)\b[^=]*=\s*ctx\.visitor/) + }) + } + + it('visitor.owned-rows filters owned rows by ctx.visitor.id as a bound parameter', async () => { + const source = stripCommentsAndStrings(await read('src/core/loops/sources/visitorOwnedRows.ts')) + // Identity is captured from ctx.visitor (cookie-derived) … + expect(source).toMatch(/const\s+visitor\s*=\s*ctx\.visitor/) + // … used as the visitor_user_id bind in BOTH the count and the page query … + expect(source).toMatch(/visitor_user_id\s*=\s*\$\{visitor\.id\}/) + // … and the anonymous guard returns empty before any query runs. + expect(source).toMatch(/if\s*\(\s*!visitor\s*\)\s*return\s*\{\s*items:\s*\[\]\s*,\s*totalItems:\s*0\s*\}/) + }) + + // ───────────────────────────────────────────────────────────────────── + // 3. The form handler stamps the visitor only from the validated session, + // never from the submitted body or validated cells. + // ───────────────────────────────────────────────────────────────────── + it('form handler stamps visitor_user_id from the session, never the body', async () => { + const source = stripCommentsAndStrings(await read('server/forms/handler.ts')) + + // Cookie/session path is the only source of identity here. + expect(source).toContain('validateVisitorSession') + expect(source).toContain('capturesVisitorOwner') + + // visitorUserId must NOT be assigned from request body or the validated cells. + expect(source).not.toMatch(/visitorUserId\s*=\s*body\b/) + expect(source).not.toMatch(/visitorUserId\s*=\s*(?:validation\.)?cells\b/) + expect(source).not.toMatch(/visitor_user_id\s*=\s*\$\{body/) + }) + + // ───────────────────────────────────────────────────────────────────── + // 4. Both visitor sources are registered so the framework is wired up. + // ───────────────────────────────────────────────────────────────────── + it('both visitor loop sources are registered in the sources index', async () => { + const source = await read('src/core/loops/sources/index.ts') + expect(source).toContain('VisitorCurrentSource') + expect(source).toContain('VisitorOwnedRowsSource') + expect(source).toMatch(/registerOrReplace\(VisitorCurrentSource\)/) + expect(source).toMatch(/registerOrReplace\(VisitorOwnedRowsSource\)/) + }) +}) diff --git a/src/__tests__/base-modules.test.ts b/src/__tests__/base-modules.test.ts index 0533c6f74..5732886d5 100644 --- a/src/__tests__/base-modules.test.ts +++ b/src/__tests__/base-modules.test.ts @@ -520,7 +520,8 @@ describe('base.container — render() specifics', () => { }) it('exposes HTML tag selection (built-in tag + custom override)', () => { - expect(Object.keys(ContainerModule.schema).sort()).toEqual(['customTag', 'htmlAttributes', 'tag']) + // visitor-auth Phase 3 adds `authGate` (member-group gating) to containers. + expect(Object.keys(ContainerModule.schema).sort()).toEqual(['authGate', 'customTag', 'htmlAttributes', 'tag']) }) it('renders children HTML inside the container', () => { diff --git a/src/__tests__/canvas/classStyleInjector.test.ts b/src/__tests__/canvas/classStyleInjector.test.ts index 275ef917e..405f34a3a 100644 --- a/src/__tests__/canvas/classStyleInjector.test.ts +++ b/src/__tests__/canvas/classStyleInjector.test.ts @@ -276,6 +276,45 @@ describe('generateAmbientPlaceholderSuppressionCSS', () => { expect(css).toContain(':is(.dots i)') }) + + // Same resilience contract the shared serializer already honours + // (`bagToDeclarations` in @core/publisher/classCss): a corrupt or legacy + // rule can carry a non-object `styles`/`contextStyles` bag, and + // `Object.keys(null)` throws. This runs BEFORE that serializer, so an + // unguarded read here blanks the whole canvas on one bad rule. + it('treats a malformed style bag as unauthored instead of throwing', () => { + const nullStyles = makeAmbient('nullStyles', '.a', null as never) + const nullContexts = makeAmbient('nullContexts', '.b', {}, null as never) + const nullContextBag = makeAmbient('nullContextBag', '.c', {}, { + mobile: null as never, + }) + + expect(generateAmbientPlaceholderSuppressionCSS({ nullStyles })).toBe('') + expect(generateAmbientPlaceholderSuppressionCSS({ nullContexts })).toBe('') + expect(generateAmbientPlaceholderSuppressionCSS({ nullContextBag })).toBe('') + }) + + it('one malformed ambient rule does not stop the rest suppressing', () => { + // A packageJson-shaped object wrongly stored under a style-rule key. + const corrupt = { + id: 'corrupt', + name: 'corrupt', + kind: 'ambient', + selector: '.corrupt', + order: 0, + dependencies: {}, + createdAt: 0, + updatedAt: 0, + } as unknown as StyleRule + + const css = generateAmbientPlaceholderSuppressionCSS({ + corrupt, + dots: makeAmbient('dots', '.dots i', { width: '12px' }), + }) + + expect(css).toContain(':is(.dots i)') + expect(css).not.toContain('.corrupt') + }) }) describe('createCanvasClassCssMemo', () => { diff --git a/src/__tests__/canvas/inlineTextEditingWiring.test.ts b/src/__tests__/canvas/inlineTextEditingWiring.test.ts index 63a3087fb..38df4fcbf 100644 --- a/src/__tests__/canvas/inlineTextEditingWiring.test.ts +++ b/src/__tests__/canvas/inlineTextEditingWiring.test.ts @@ -59,6 +59,15 @@ describe('inline text editing wiring (in-place contentEditable)', () => { expect(src).toContain('el.focus()') }) + it('NodeRenderer keeps the session surface live under co-editing (remote Y.Text merge)', () => { + // Without this attach, a peer's characters never reach the frozen + // contentEditable and the next local keystroke's whole-string snapshot + // commit DELETES them from the CRDT. Behavior gated end-to-end by + // src/__tests__/collab/inlineEditRemoteMerge.test.tsx. + const src = readFileSync(NODE_RENDERER, 'utf-8') + expect(src).toContain('attachInlineEditRemoteMerge') + }) + it('the canvas keyboard handler bails while an inline edit is active', () => { const src = readFileSync(KEYBOARD_SHORTCUTS, 'utf-8') expect(src).toContain('if (useEditorStore.getState().activeInlineEdit) return') diff --git a/src/__tests__/canvas/selectionToolbar.test.tsx b/src/__tests__/canvas/selectionToolbar.test.tsx index 9ac429cbe..9d2dd6a6a 100644 --- a/src/__tests__/canvas/selectionToolbar.test.tsx +++ b/src/__tests__/canvas/selectionToolbar.test.tsx @@ -272,7 +272,10 @@ describe('canvas selection toolbar', () => { act(() => { // Iframe content is mounted on the iframe's `load` event (microtask // in happy-dom). One RAF tick may run before the iframe portal has - // populated, so flush a few in case the first observation is stale. + // populated, and sibling overlays (peer presence) enqueue their own + // portal-capture frames — flush a few so the positioning tick runs. + raf.flushOne() + raf.flushOne() raf.flushOne() raf.flushOne() raf.flushOne() diff --git a/src/__tests__/collab/applyPatches.test.ts b/src/__tests__/collab/applyPatches.test.ts new file mode 100644 index 000000000..29cc682b4 --- /dev/null +++ b/src/__tests__/collab/applyPatches.test.ts @@ -0,0 +1,232 @@ +/** + * Patch→Y translation — the collaborative write path. + * + * The editor keeps producing Mutative patches (same recipes, same mutation + * API); `applySitePatchesToDocs` translates them into Y operations on the + * right documents. The invariant under test: after translating a mutation's + * patches, PROJECTING the docs reproduces the post-mutation site — while + * inline-text edits go through minimal Y.Text splices (so concurrent remote + * edits survive) and roster ops land in the site doc. + * + * Tests drive REAL patches: `create(site, recipe, { enablePatches })` with + * actual @core/page-tree mutations, exactly like the store's mutation engine. + */ +import { describe, expect, it } from 'bun:test' +import { create, type Patches } from 'mutative' +import * as Y from 'yjs' +import '@modules/base' +import type { SiteDocument } from '@core/page-tree' +import { addPage, deletePage, moveNode, renamePage, updateNodeProps } from '@core/page-tree' +import { + applySitePatchesToDocs, + createCollabDocSet, + LOCAL_ORIGIN, + projectPageDoc, + projectSiteDoc, + seedPageDoc, + seedSiteDoc, + treeMap, + type CollabDocSet, +} from '@core/collab' +import { makeNode, makePage, makeSite } from '../fixtures' + +function fixtureSite(): SiteDocument { + return makeSite({ + pages: [ + makePage({ + id: 'p1', + slug: 'index', + title: 'Home', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['t1', 'c1'] }), + t1: makeNode({ id: 't1', moduleId: 'base.text', props: { text: 'hello world', tag: 'p' } }), + c1: makeNode({ id: 'c1', moduleId: 'base.container', children: [] }), + }, + }), + makePage({ id: 'p2', slug: 'about', title: 'About' }), + ], + styleRules: { + r1: { + id: 'r1', name: 'hero', kind: 'class', selector: '.hero', order: 0, + styles: { color: 'var(--text)' }, contextStyles: {}, createdAt: 1, updatedAt: 1, + }, + }, + }) +} + +/** Seed a doc set exactly like the server would, then hand it to the translator. */ +function seededDocSet(site: SiteDocument): CollabDocSet { + const docs = createCollabDocSet() + seedSiteDoc(docs.ensure('site:default'), site) + for (const page of site.pages) seedPageDoc(docs.ensure(`page:${page.id}`), page) + return docs +} + +function yTextOf(doc: Y.Doc, nodeId: string): Y.Text { + const nodes = treeMap(doc).get('nodes') as Y.Map + const node = nodes.get(nodeId) as Y.Map + const props = node.get('props') as Y.Map + return props.get('text') as Y.Text +} + +function mutate( + site: SiteDocument, + recipe: (draft: SiteDocument) => void, +): { next: SiteDocument; patches: Patches } { + const [next, patches] = create(site, recipe, { enablePatches: true }) + return { next, patches } +} + +function translate(site: SiteDocument, docs: CollabDocSet, recipe: (d: SiteDocument) => void): SiteDocument { + const { next, patches } = mutate(site, recipe) + applySitePatchesToDocs(patches, site, next, docs, LOCAL_ORIGIN) + return next +} + +describe('applySitePatchesToDocs — page tree edits', () => { + it('prop set projects back identical to the post-mutation page', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + const next = translate(site, docs, (d) => { + updateNodeProps(d.pages[0], 't1', { tag: 'h2' }) + }) + const projected = projectPageDoc(docs.ensure('page:p1'), 'p1') + expect(projected.nodes.t1.props.tag).toBe('h2') + expect(projected.nodes.t1.props.text).toBe('hello world') + expect(projected.nodes.root.children).toEqual(next.pages[0].nodes.root.children) + }) + + it('inline-text edit splices Y.Text so a concurrent remote insertion survives', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + const local = docs.ensure('page:p1') + // Remote peer shares history and types at the end concurrently. + const remote = new Y.Doc() + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local)) + const remoteText = ( + (treeMap(remote).get('nodes') as Y.Map).get('t1') as Y.Map + ).get('props') as Y.Map + ;(remoteText.get('text') as Y.Text).insert(11, ' [remote]') + + translate(site, docs, (d) => { + updateNodeProps(d.pages[0], 't1', { text: 'hello brave world' }) + }) + + // Exchange updates both ways. + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local, Y.encodeStateVector(remote))) + Y.applyUpdate(local, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(local))) + + const merged = projectPageDoc(local, 'p1').nodes.t1.props.text as string + expect(merged).toContain('brave') + expect(merged).toContain('[remote]') + expect(projectPageDoc(remote, 'p1').nodes.t1.props.text).toBe(merged) + }) + + it('falls back safely when the projected pre-value drifted from the live Y.Text', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + const local = docs.ensure('page:p1') + // Simulate a caller holding a stale JSON snapshot after a remote update + // already landed in the authoritative doc. The stale splice indexes used + // to target the wrong characters or throw when the live text was shorter. + yTextOf(local, 't1').delete(0, 11) + yTextOf(local, 't1').insert(0, 'x') + + expect(() => { + translate(site, docs, (d) => { + updateNodeProps(d.pages[0], 't1', { text: 'hello brave world' }) + }) + }).not.toThrow() + expect(projectPageDoc(local, 'p1').nodes.t1.props.text).toBe('hello brave world') + }) + + it('node move and delete project back identical children', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + const next = translate(site, docs, (d) => { + moveNode(d.pages[0], 'c1', 'root', 0) // move c1 before t1 + }) + expect(projectPageDoc(docs.ensure('page:p1'), 'p1').nodes.root.children) + .toEqual(next.pages[0].nodes.root.children) + + const next2 = translate(next, docs, (d) => { + // deleteNode is tree-level; page IS a NodeTree + d.pages[0].nodes.root.children = d.pages[0].nodes.root.children.filter((c) => c !== 'c1') + delete d.pages[0].nodes.c1 + }) + const projected = projectPageDoc(docs.ensure('page:p1'), 'p1') + expect(projected.nodes.c1).toBeUndefined() + expect(projected.nodes.root.children).toEqual(next2.pages[0].nodes.root.children) + }) + + it('page rename lands in the page meta', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + renamePage(d, 'p1', 'Homepage', 'index') + }) + expect(projectPageDoc(docs.ensure('page:p1'), 'p1').title).toBe('Homepage') + }) +}) + +describe('applySitePatchesToDocs — rosters', () => { + it('addPage creates the roster entry AND populates a fresh page doc', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + let newId = '' + translate(site, docs, (d) => { + newId = addPage(d, 'Fresh', 'fresh').id + }) + const projectedSite = projectSiteDoc(docs.ensure('site:default')) + expect(projectedSite.rosters.pages).toContain(newId) + const projectedPage = projectPageDoc(docs.ensure(`page:${newId}`), newId) + expect(projectedPage.title).toBe('Fresh') + expect(Object.keys(projectedPage.nodes).length).toBeGreaterThan(0) + }) + + it('deletePage removes the roster entry (the doc is left for relay GC)', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + deletePage(d, 'p2') + }) + const projected = projectSiteDoc(docs.ensure('site:default')) + expect(projected.rosters.pages).not.toContain('p2') + expect(projected.rosters.pages).toContain('p1') + }) + + it('page reorder rewrites pageOrder', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + const [a, b] = d.pages + d.pages[0] = b + d.pages[1] = a + }) + expect(projectSiteDoc(docs.ensure('site:default')).rosters.pages).toEqual(['p2', 'p1']) + }) +}) + +describe('applySitePatchesToDocs — shell', () => { + it('style rule edits land per-rule; settings edits per top-level key', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + d.styleRules.r1.styles.color = 'var(--text-muted)' + d.settings.metaTitle = 'Acme rules' + }) + const projected = projectSiteDoc(docs.ensure('site:default')) + const rule = projected.shell.styleRules as Record }> + expect(rule.r1.styles.color).toBe('var(--text-muted)') + expect((projected.shell.settings as Record).metaTitle).toBe('Acme rules') + }) + + it('site rename is a plain shell field write', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + d.name = 'Renamed' + }) + expect(projectSiteDoc(docs.ensure('site:default')).shell.name).toBe('Renamed') + }) +}) diff --git a/src/__tests__/collab/awareness.test.tsx b/src/__tests__/collab/awareness.test.tsx new file mode 100644 index 000000000..d18b7d76d --- /dev/null +++ b/src/__tests__/collab/awareness.test.tsx @@ -0,0 +1,217 @@ +/** + * Peer presence — deterministic identity colors, local-state publishing, + * docId-scoped peer filtering (with TypeBox validation of wire states), and + * a smoke render of the canvas presence overlay against fake awareness + * states. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { render, screen, cleanup, act } from '@testing-library/react' +import * as Y from 'yjs' +import * as awarenessProtocol from 'y-protocols/awareness' +import { useEditorStore } from '@site/store/store' +import { + activeEditorDocId, + peerColor, + usePeerPresences, +} from '@site/collab/awarenessState' +import { + connectCollabProvider, + disconnectCollabProvider, +} from '@site/store/slices/site/collabBinding' +import type { + BoundCollabDoc, + CollabProvider, + CollabResetListener, +} from '@site/collab/collabProvider' +import type { ResetReason } from '@core/collab' +import { PeerPresenceOverlay } from '@admin/pages/site/canvas/PeerPresenceOverlay' +import '@modules/base/index' + +/** Minimal provider stub: real Awareness, synced-on-bind docs, no transport. */ +function fakeProvider(): CollabProvider & { + awareness: awarenessProtocol.Awareness + triggerReset: (docId: string, reason?: ResetReason) => void +} { + const presenceDoc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(presenceDoc) + const bound = new Map() + const resetListeners = new Set() + return { + bind: (docId) => { + let entry = bound.get(docId) + if (!entry) { + entry = { doc: new Y.Doc(), synced: true, whenSynced: Promise.resolve() } + bound.set(docId, entry) + } + return entry + }, + unbind: (docId) => { + bound.get(docId)?.doc.destroy() + bound.delete(docId) + }, + awareness, + status: () => 'connected', + canSend: () => true, + reconnectNow: () => {}, + onStatus: () => () => {}, + onReset: (listener) => { + resetListeners.add(listener) + return () => resetListeners.delete(listener) + }, + triggerReset: (docId, reason = 'rewritten') => { + for (const listener of resetListeners) listener(docId, reason) + }, + destroy: () => { + awareness.destroy() + presenceDoc.destroy() + }, + } +} + +/** Inject a remote peer's presence into `target` the way the wire would. */ +function injectPeerState(target: awarenessProtocol.Awareness, state: unknown): number { + const peerDoc = new Y.Doc() + const peer = new awarenessProtocol.Awareness(peerDoc) + peer.setLocalState(state) + const update = awarenessProtocol.encodeAwarenessUpdate(peer, [peer.clientID]) + awarenessProtocol.applyAwarenessUpdate(target, update, 'remote') + return peer.clientID +} + +function PeerList({ docId }: { docId: string | null }) { + const peers = usePeerPresences(docId) + return ( +
    + {peers.map((peer) => ( +
  • {peer.user.name}
  • + ))} +
+ ) +} + +afterEach(() => { + cleanup() + disconnectCollabProvider() + useEditorStore.getState().clearSite() +}) + +describe('peerColor', () => { + it('is deterministic and HSL-formatted', () => { + expect(peerColor('user-a')).toBe(peerColor('user-a')) + expect(peerColor('user-a')).toMatch(/^hsl\(\d+ 70% 45%\)$/) + expect(peerColor('user-a')).not.toBe(peerColor('user-b')) + }) +}) + +describe('activeEditorDocId', () => { + it('routes VC mode to the component doc and page mode to the page doc', () => { + expect( + activeEditorDocId({ activeDocument: { kind: 'visualComponent', vcId: 'vc-1' }, activePageId: 'p1' }), + ).toBe('component:vc-1') + expect(activeEditorDocId({ activeDocument: null, activePageId: 'p1' })).toBe('page:p1') + expect(activeEditorDocId({ activeDocument: null, activePageId: null })).toBeNull() + }) +}) + +describe('collab reset during inline editing', () => { + it('ends only the inline session owned by the reset document', () => { + const store = useEditorStore.getState() + store.createSite('Reset Site') + const pageId = useEditorStore.getState().activePageId! + const page = useEditorStore.getState().site!.pages[0] + const nodeId = useEditorStore + .getState() + .insertNode('base.text', { text: 'hello' }, page.rootNodeId) + useEditorStore.getState().startInlineEdit(nodeId, 'desktop') + expect(useEditorStore.getState().activeInlineEdit).not.toBeNull() + + const provider = fakeProvider() + connectCollabProvider(provider) + provider.triggerReset('page:another-page') + expect(useEditorStore.getState().activeInlineEdit).not.toBeNull() + + provider.triggerReset(`page:${pageId}`) + expect(useEditorStore.getState().activeInlineEdit).toBeNull() + }) +}) + +describe('usePeerPresences', () => { + it('returns peers on the same doc, drops other docs and malformed states', async () => { + const provider = fakeProvider() + connectCollabProvider(provider) + + render() + + await act(async () => { + injectPeerState(provider.awareness, { + user: { id: 'u2', name: 'Ada', color: peerColor('u2'), avatarUrl: null, gravatarHash: null }, + docId: 'page:p1', + selectedNodeIds: ['n1'], + editingNodeId: null, + pointer: null, + textCaret: null, + }) + injectPeerState(provider.awareness, { + user: { id: 'u3', name: 'Grace', color: peerColor('u3'), avatarUrl: null, gravatarHash: null }, + docId: 'page:OTHER', + selectedNodeIds: [], + editingNodeId: null, + pointer: null, + textCaret: null, + }) + // Malformed wire state — must be dropped by validation, not crash. + injectPeerState(provider.awareness, { user: { id: 42 }, docId: 'page:p1' }) + }) + + expect(screen.getByText('Ada')).toBeTruthy() + expect(screen.queryByText('Grace')).toBeNull() + }) +}) + +describe('PeerPresenceOverlay', () => { + it('module CSS never hides presence chrome by default (regression: invisible cursors)', () => { + // The overlay positioning helpers hide with INLINE `display: none` and + // show by CLEARING the inline value — a stylesheet `display: none` + // default would make every cleared element fall back to hidden forever + // (the bug that shipped rings/tags/cursors invisible). Presence chrome + // must start visible and let the first RAF tick place or hide it. + const { readFileSync } = require('fs') as typeof import('fs') + const css = readFileSync( + new URL('../../admin/pages/site/canvas/PeerPresenceOverlay.module.css', import.meta.url), + 'utf-8', + ) + // Scan RULES only — comments may (and do) mention the forbidden pattern. + const rulesOnly = css.replace(/\/\*[\s\S]*?\*\//g, '') + expect(rulesOnly).not.toContain('display: none') + }) + + + it('renders a name tag for a peer selection on the active page doc', async () => { + const provider = fakeProvider() + connectCollabProvider(provider) + + // The overlay derives its docId from the store's active page. + const site = useEditorStore.getState().createSite('Presence Site') + const pageId = site.pages[0].id + useEditorStore.setState({ activePageId: pageId }) + + render() + + await act(async () => { + injectPeerState(provider.awareness, { + user: { id: 'u9', name: 'Marge', color: peerColor('u9'), avatarUrl: null, gravatarHash: null }, + docId: `page:${pageId}`, + selectedNodeIds: [site.pages[0].rootNodeId], + editingNodeId: site.pages[0].rootNodeId, + pointer: { x: 10, y: 20, breakpointId: 'bp-desktop' }, + textCaret: null, + }) + }) + + const tag = document.querySelector('[data-peer-name-tag]') + expect(tag?.textContent).toContain('Marge') + expect(tag?.getAttribute('data-editing')).toBe('true') + expect(document.querySelector('[data-peer-selection-ring]')).toBeTruthy() + expect(document.querySelector('[data-peer-pointer]')).toBeTruthy() + }) +}) diff --git a/src/__tests__/collab/caretPositions.test.ts b/src/__tests__/collab/caretPositions.test.ts new file mode 100644 index 000000000..5f68deaa8 --- /dev/null +++ b/src/__tests__/collab/caretPositions.test.ts @@ -0,0 +1,51 @@ +/** + * Caret codec — base64 relative positions round-trip through a Y.Text and + * stay CORRECT under concurrent edits (the reason plain indices don't work). + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import { treeMap } from '@core/collab' +import { + encodeCaretRange, + resolveCaretRange, +} from '@site/collab/caretPositions' + +function docWithText(initial: string): { doc: Y.Doc; text: Y.Text } { + const doc = new Y.Doc() + const text = new Y.Text(initial) + doc.transact(() => { + const nodes = new Y.Map() + const node = new Y.Map() + const props = new Y.Map() + props.set('text', text) + node.set('props', props) + nodes.set('n1', node) + treeMap(doc).set('nodes', nodes) + }) + return { doc, text } +} + +describe('caretPositions', () => { + it('round-trips offsets through encode → resolve', () => { + const { doc } = docWithText('Hello world') + const caret = encodeCaretRange(doc, 'n1', 'text', 6, 11) + expect(caret).not.toBeNull() + expect(resolveCaretRange(doc, caret!)).toEqual({ anchor: 6, head: 11 }) + }) + + it('positions survive a concurrent insert BEFORE the caret', () => { + const { doc, text } = docWithText('Hello world') + const caret = encodeCaretRange(doc, 'n1', 'text', 6, 6) // before "world" + text.insert(0, '>>> ') // concurrent edit shifts everything right + expect(resolveCaretRange(doc, caret!)).toEqual({ anchor: 10, head: 10 }) + }) + + it('returns null for a non-Y.Text prop and for malformed wire data', () => { + const doc = new Y.Doc() + expect(encodeCaretRange(doc, 'missing', 'text', 0, 0)).toBeNull() + const { doc: ok } = docWithText('abc') + expect( + resolveCaretRange(ok, { nodeId: 'n1', prop: 'text', anchor: '!!notbase64', head: '!!' }), + ).toBeNull() + }) +}) diff --git a/src/__tests__/collab/collabNotices.test.ts b/src/__tests__/collab/collabNotices.test.ts new file mode 100644 index 000000000..155428bd1 --- /dev/null +++ b/src/__tests__/collab/collabNotices.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { + clearCollabBlockNotice, + collabBlockToast, + collabResetToast, + resetTargetsActiveDocument, +} from '@site/store/slices/site/collabNotices' +import { + __resetToastBusForTests, + subscribeToasts, + type Toast, +} from '@ui/components/Toast/toastBus' + +afterEach(() => { + clearCollabBlockNotice() + __resetToastBusForTests() +}) + +describe('collab notices', () => { + it('shows one blocked-edit toast per outage and re-arms after recovery', () => { + let toasts: ReadonlyArray = [] + const unsubscribe = subscribeToasts((next) => { + toasts = next + }) + + expect(collabBlockToast('offline')).toBe(true) + expect(collabBlockToast('offline')).toBe(false) + expect(collabBlockToast('connecting')).toBe(false) + expect(toasts).toHaveLength(1) + expect(toasts[0]).toMatchObject({ + kind: 'error', + title: 'Change not applied', + }) + + clearCollabBlockNotice() + expect(collabBlockToast('syncing')).toBe(true) + expect(toasts).toHaveLength(2) + unsubscribe() + }) + + it('keeps routine reseeds silent but reports discarded local work', () => { + let toasts: ReadonlyArray = [] + const unsubscribe = subscribeToasts((next) => { + toasts = next + }) + + collabResetToast('rewritten') + expect(toasts).toHaveLength(0) + collabResetToast('stale') + collabResetToast('refused') + collabResetToast('oversize') + + expect(toasts.map((toast) => toast.title)).toEqual([ + 'A change was reverted', + 'A change was reverted', + 'A change was reverted', + ]) + unsubscribe() + }) + + it('matches resets only to the active page or visual component', () => { + expect( + resetTargetsActiveDocument('page:page-1', { kind: 'page', pageId: 'page-1' }, null), + ).toBe(true) + expect( + resetTargetsActiveDocument('page:page-1', { kind: 'visualComponent', vcId: 'vc-1' }, 'page-1'), + ).toBe(true) + expect( + resetTargetsActiveDocument( + 'component:vc-1', + { kind: 'visualComponent', vcId: 'vc-1' }, + 'page-1', + ), + ).toBe(true) + expect( + resetTargetsActiveDocument('component:vc-2', { kind: 'page', pageId: 'page-1' }, 'page-1'), + ).toBe(false) + }) +}) diff --git a/src/__tests__/collab/inlineEditRemoteMerge.test.tsx b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx new file mode 100644 index 000000000..5c2ae07fd --- /dev/null +++ b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx @@ -0,0 +1,327 @@ +/** + * Co-typing ONE text node — the headline collab promise ("two admins can + * co-type one text node simultaneously"). + * + * The inline-edit surface is a contentEditable that React does not own: it is + * seeded once at session start and every keystroke commits the element's + * WHOLE string through `applyInlineEditValue` → `applyTextDiff`. If a remote + * peer's characters land in the doc mid-session but never reach the frozen + * surface, the next local keystroke's snapshot diff DELETES them from the + * CRDT — both replicas converge, to text missing the peer's edit. + * + * These tests mount the real canvas (NodeRenderer wiring, iframe portal) and + * play the remote peer at the Y level, proving the surface merges remote + * edits mid-session and the next keystroke preserves both intents. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import React from 'react' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import * as Y from 'yjs' +import { CanvasTransformLayer } from '@site/canvas/CanvasTransformLayer' +import { useEditorStore } from '@site/store/store' +import { encodeCollabDocId, LOCAL_ORIGIN, seedPageDoc, treeMap } from '@core/collab' +import { collabDocFor } from '@site/store/slices/site/collabBinding' +import { + attachInlineEditRemoteMerge, + transformIndexThroughTextDelta, +} from '@site/collab/inlineEditRemoteMerge' +import { seedInlineEditableContent } from '@modules/base/shared/inlineText' +import { waitForCanvasNodeInFrame } from '../canvas/iframeCanvasQuery' +import { makeNode, makePage } from '../fixtures' +import '@modules/base' + +const originalFetch = globalThis.fetch + +function yTextOf(doc: Y.Doc, nodeId: string): Y.Text { + const nodes = treeMap(doc).get('nodes') as Y.Map + const node = nodes.get(nodeId) as Y.Map + const props = node.get('props') as Y.Map + return props.get('text') as Y.Text +} + +function storeText(nodeId: string): unknown { + const site = useEditorStore.getState().site + if (!site) return undefined + for (const page of site.pages) { + if (page.nodes[nodeId]) return page.nodes[nodeId].props.text + } + return undefined +} + +beforeEach(() => { + cleanup() + document.body.replaceChildren() + globalThis.fetch = (async () => new Response('', { status: 404 })) as typeof fetch + useEditorStore.getState().clearSite() +}) + +afterEach(() => { + cleanup() + useEditorStore.getState().clearSite() + document.body.replaceChildren() + globalThis.fetch = originalFetch +}) + +/** Create a real site via store actions (keeps the detached collab docs + * aligned), insert a text node, and mount the canvas on the desktop frame. */ +async function setupEditingSession(initialText: string): Promise<{ + nodeId: string + pageId: string + editable: HTMLElement + local: Y.Doc +}> { + const store = useEditorStore.getState() + store.createSite('Co-typing') + const pageId = useEditorStore.getState().activePageId! + const page = useEditorStore.getState().site!.pages.find((p) => p.id === pageId)! + const nodeId = useEditorStore + .getState() + .insertNode('base.text', { text: initialText }, page.rootNodeId) + + render( + p.id === pageId)!} + breakpoints={useEditorStore.getState().site!.breakpoints} + activeBreakpointId="desktop" + onBreakpointActivate={() => {}} + />, + ) + await waitForCanvasNodeInFrame('desktop', nodeId) + + await act(async () => { + useEditorStore.getState().startInlineEdit(nodeId, 'desktop') + }) + const editable = await waitForCanvasNodeInFrame('desktop', nodeId) + await waitFor(() => expect(editable.getAttribute('contenteditable')).toBeTruthy()) + expect(editable.textContent).toBe(initialText) + + const local = collabDocFor(encodeCollabDocId({ kind: 'page', rowId: pageId }))! + expect(local).toBeTruthy() + return { nodeId, pageId, editable, local } +} + +/** Clone the local doc into a peer replica, mutate its Y.Text, sync back. */ +async function remoteTextEdit( + local: Y.Doc, + nodeId: string, + edit: (text: Y.Text) => void, +): Promise { + const remote = new Y.Doc() + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local)) + edit(yTextOf(remote, nodeId)) + await act(async () => { + Y.applyUpdate(local, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(local))) + // Flush the projection microtask so the store catches up too. + await Promise.resolve() + }) + return remote +} + +describe('co-typing one text node (remote merge into the inline-edit surface)', () => { + it('a remote peer edit lands in the editing surface and survives the next local keystroke', async () => { + const { nodeId, editable, local } = await setupEditingSession('hello') + + // Remote peer appends " world" while the local session is open. + const remote = await remoteTextEdit(local, nodeId, (t) => t.insert(5, ' world')) + + // The store projected the remote edit (this always worked)... + expect(storeText(nodeId)).toBe('hello world') + // ...and the SESSION SURFACE shows it too. A frozen surface here means the + // next keystroke's whole-string snapshot diff deletes " world" from the CRDT. + expect(editable.textContent).toBe('hello world') + + // Local user keeps typing: append "!". + editable.textContent = `${editable.textContent}!` + fireEvent.input(editable) + + // Intent preservation: BOTH edits survive, on every replica. + expect(yTextOf(local, nodeId).toString()).toBe('hello world!') + expect(storeText(nodeId)).toBe('hello world!') + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local, Y.encodeStateVector(remote))) + expect(yTextOf(remote, nodeId).toString()).toBe('hello world!') + }) + + it('interleaved co-typing keeps every character from both peers', async () => { + const { nodeId, editable, local } = await setupEditingSession('ab') + + // Peer prepends "X" — local surface must become "Xab". + const remote = await remoteTextEdit(local, nodeId, (t) => t.insert(0, 'X')) + expect(editable.textContent).toBe('Xab') + + // Local types "!" at the end. + editable.textContent = `${editable.textContent}!` + fireEvent.input(editable) + expect(yTextOf(local, nodeId).toString()).toBe('Xab!') + + // Peer deletes its own "X" concurrently with another local keystroke. + await remoteTextEdit(local, nodeId, (t) => t.delete(0, 1)) + expect(editable.textContent).toBe('ab!') + editable.textContent = `${editable.textContent}?` + fireEvent.input(editable) + expect(yTextOf(local, nodeId).toString()).toBe('ab!?') + expect(storeText(nodeId)).toBe('ab!?') + // The first remote replica converges to the same text. + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local, Y.encodeStateVector(remote))) + expect(yTextOf(remote, nodeId).toString()).toBe('ab!?') + }) +}) + +describe('transformIndexThroughTextDelta', () => { + it('shifts the caret right past inserts at or before it', () => { + // "hello" → "Xhello": caret 3 → 4. + expect(transformIndexThroughTextDelta([{ insert: 'X' }], 3)).toBe(4) + // Insert exactly AT the caret pushes it right (Y relative-position rule). + expect(transformIndexThroughTextDelta([{ retain: 3 }, { insert: 'ab' }], 3)).toBe(5) + // Insert after the caret leaves it alone. + expect(transformIndexThroughTextDelta([{ retain: 4 }, { insert: 'z' }], 3)).toBe(3) + }) + + it('shifts the caret left past deletes before it and clamps inside a spanning delete', () => { + // Delete [0,2) with caret at 3 → 1. + expect(transformIndexThroughTextDelta([{ delete: 2 }], 3)).toBe(1) + // Delete [1,4) spans the caret at 2 → collapses to the delete start (1). + expect(transformIndexThroughTextDelta([{ retain: 1 }, { delete: 3 }], 2)).toBe(1) + // Delete at/after the caret leaves it alone. + expect(transformIndexThroughTextDelta([{ retain: 3 }, { delete: 2 }], 3)).toBe(3) + }) + + it('composes mixed op sequences', () => { + // "hello world" → "hi world!": caret after "hello" (5). + const delta = [{ retain: 1 }, { delete: 4 }, { insert: 'i' }, { retain: 6 }, { insert: '!' }] + expect(transformIndexThroughTextDelta(delta, 5)).toBe(2) + }) +}) + +describe('attachInlineEditRemoteMerge (surface-level)', () => { + function seededSurface(text: string, nodeId = 't1') { + const doc = new Y.Doc() + seedPageDoc( + doc, + makePage({ + id: 'p1', + rootNodeId: 'root', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: [nodeId] }), + [nodeId]: makeNode({ id: nodeId, moduleId: 'base.text', props: { text, tag: 'p' } }), + }, + }), + ) + const el = document.createElement('p') + document.body.appendChild(el) + seedInlineEditableContent(el, text) + const detach = attachInlineEditRemoteMerge({ el, doc, nodeId, prop: 'text' }) + return { doc, el, nodeId, detach } + } + + function remoteInsert(doc: Y.Doc, nodeId: string, index: number, value: string): void { + const remote = new Y.Doc() + Y.applyUpdate(remote, Y.encodeStateAsUpdate(doc)) + yTextOf(remote, nodeId).insert(index, value) + Y.applyUpdate(doc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(doc))) + } + + it('preserves the local caret across a remote insert before it — with
line breaks', () => { + const { doc, el, nodeId, detach } = seededSurface('ab\ncd') + // Children: "ab",
, "cd" — caret between "c" and "d" (absolute offset 4). + const cdNode = el.childNodes[2] + const range = document.createRange() + range.setStart(cdNode, 1) + range.collapse(true) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + + remoteInsert(doc, nodeId, 0, 'Z') + + expect(el.innerHTML).toBe('Zab
cd') + // Caret followed its character: still between "c" and "d". + expect(selection.anchorNode?.textContent).toBe('cd') + expect(selection.anchorOffset).toBe(1) + detach() + }) + + it('ignores LOCAL_ORIGIN transactions (the DOM is where those came from)', () => { + const { doc, el, nodeId, detach } = seededSurface('hello') + // A local applyTextDiff-style write: the surface already shows it, so the + // merge must not touch the DOM (a rewrite would collapse the caret). + el.appendChild(el.ownerDocument.createTextNode('!')) + const marker = el.firstChild + doc.transact(() => { + yTextOf(doc, nodeId).insert(5, '!') + }, LOCAL_ORIGIN) + expect(el.firstChild).toBe(marker) // untouched — no rewrite happened + expect(el.textContent).toBe('hello!') + detach() + }) + + it('defers the merge during IME composition and applies it on compositionend', () => { + const { doc, el, nodeId, detach } = seededSurface('hello') + el.dispatchEvent(new Event('compositionstart')) + remoteInsert(doc, nodeId, 5, ' world') + expect(el.textContent).toBe('hello') // frozen mid-composition + el.dispatchEvent(new Event('compositionend')) + expect(el.textContent).toBe('hello world') + detach() + }) + + it('keeps merging after a remote whole-node write replaces the Y.Text instance', () => { + const { doc, el, nodeId, detach } = seededSurface('hello') + const nodes = treeMap(doc).get('nodes') as Y.Map + const replacement = new Y.Map() + const props = new Y.Map() + props.set('text', new Y.Text('replaced')) + props.set('tag', 'p') + replacement.set('id', nodeId) + replacement.set('moduleId', 'base.text') + replacement.set('props', props) + replacement.set('breakpointOverrides', new Y.Map()) + replacement.set('children', new Y.Array()) + + doc.transact(() => { + nodes.set(nodeId, replacement) + }, 'remote-whole-node') + expect(el.textContent).toBe('replaced') + + yTextOf(doc, nodeId).insert(8, ' again') + expect(el.textContent).toBe('replaced again') + detach() + }) + + it('invalidates the edit session when a remote write removes its Y.Text', () => { + let invalidations = 0 + const doc = new Y.Doc() + seedPageDoc( + doc, + makePage({ + id: 'p1', + rootNodeId: 'root', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['t1'] }), + t1: makeNode({ id: 't1', moduleId: 'base.text', props: { text: 'hello', tag: 'p' } }), + }, + }), + ) + const el = document.createElement('p') + seedInlineEditableContent(el, 'hello') + const detach = attachInlineEditRemoteMerge({ + el, + doc, + nodeId: 't1', + prop: 'text', + onInvalidated: () => { invalidations++ }, + }) + + doc.transact(() => { + ;(treeMap(doc).get('nodes') as Y.Map).delete('t1') + }, 'remote-delete') + expect(invalidations).toBe(1) + detach() + }) + + it('stops merging after detach', () => { + const { doc, el, nodeId, detach } = seededSurface('hello') + detach() + remoteInsert(doc, nodeId, 0, 'X') + expect(el.textContent).toBe('hello') + }) +}) diff --git a/src/__tests__/collab/merge.test.ts b/src/__tests__/collab/merge.test.ts new file mode 100644 index 000000000..d6bb29ecc --- /dev/null +++ b/src/__tests__/collab/merge.test.ts @@ -0,0 +1,188 @@ +/** + * CRDT merge scenarios — two docs exchanging updates must converge with the + * GRANULARITY the product promises: different nodes never collide, different + * props of one node never collide, and the inline-text prop merges + * character-level. `applyTextDiff` is the minimal-splice bridge between + * contentEditable string snapshots and Y.Text operations. + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import '@modules/base' +import { applyTextDiff, projectPageDoc, seedPageDoc, treeMap } from '@core/collab' +import { makeNode, makePage } from '../fixtures' + +function fixturePage() { + return makePage({ + id: 'p1', + slug: 'index', + title: 'Home', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['t1', 'c1'] }), + t1: makeNode({ id: 't1', moduleId: 'base.text', props: { text: 'hello world', tag: 'p' } }), + c1: makeNode({ id: 'c1', moduleId: 'base.container', children: [] }), + }, + }) +} + +/** Seed once, clone into a second doc — the shared-history starting point. */ +function seededPair(): [Y.Doc, Y.Doc] { + const a = new Y.Doc() + seedPageDoc(a, fixturePage()) + const b = new Y.Doc() + Y.applyUpdate(b, Y.encodeStateAsUpdate(a)) + return [a, b] +} + +function syncDocs(a: Y.Doc, b: Y.Doc): void { + Y.applyUpdate(b, Y.encodeStateAsUpdate(a, Y.encodeStateVector(b))) + Y.applyUpdate(a, Y.encodeStateAsUpdate(b, Y.encodeStateVector(a))) +} + +function nodeMap(doc: Y.Doc, id: string): Y.Map { + return (treeMap(doc).get('nodes') as Y.Map).get(id) as Y.Map +} + +describe('granular merge', () => { + it('concurrent edits to different props of the same node both survive', () => { + const [a, b] = seededPair() + ;(nodeMap(a, 't1').get('props') as Y.Map).set('tag', 'h1') + ;(nodeMap(b, 't1').get('props') as Y.Map).set('align', 'center') + syncDocs(a, b) + for (const doc of [a, b]) { + const projected = projectPageDoc(doc, 'p1') + expect(projected.nodes.t1.props.tag).toBe('h1') + expect(projected.nodes.t1.props.align).toBe('center') + } + }) + + it('concurrent Y.Text typing merges character-level and identically on both peers', () => { + const [a, b] = seededPair() + const textA = (nodeMap(a, 't1').get('props') as Y.Map).get('text') as Y.Text + const textB = (nodeMap(b, 't1').get('props') as Y.Map).get('text') as Y.Text + textA.insert(0, 'A: ') // prepend on peer A + textB.insert(textB.length, ' :B') // append on peer B + syncDocs(a, b) + const merged = textA.toString() + expect(merged).toContain('A: ') + expect(merged).toContain(' :B') + expect(merged).toContain('hello world') + expect(textB.toString()).toBe(merged) + }) + + it('concurrent child insert + child delete converge and reconcile identically', () => { + const [a, b] = seededPair() + a.transact(() => { + const nodes = treeMap(a).get('nodes') as Y.Map + const fresh = new Y.Map() + fresh.set('id', 'n2') + fresh.set('moduleId', 'base.container') + fresh.set('props', new Y.Map()) + fresh.set('breakpointOverrides', new Y.Map()) + fresh.set('children', new Y.Array()) + nodes.set('n2', fresh) + const rootChildren = (nodes.get('root') as Y.Map).get('children') as Y.Array + rootChildren.insert(rootChildren.length, ['n2']) + }) + b.transact(() => { + const nodes = treeMap(b).get('nodes') as Y.Map + nodes.delete('c1') + const rootChildren = (nodes.get('root') as Y.Map).get('children') as Y.Array + rootChildren.delete(1, 1) // remove 'c1' + }) + syncDocs(a, b) + const pa = projectPageDoc(a, 'p1') + const pb = projectPageDoc(b, 'p1') + expect(pa.nodes.root.children).toEqual(pb.nodes.root.children) + expect(pa.nodes.root.children).toContain('n2') + expect(pa.nodes.root.children).not.toContain('c1') + }) +}) + +describe('applyTextDiff', () => { + it('applies a minimal middle splice', () => { + const t = new Y.Doc().getText('t') + t.insert(0, 'hello world') + applyTextDiff(t, 'hello world', 'hello brave world') + expect(t.toString()).toBe('hello brave world') + }) + + it('handles pure insertion, pure deletion, and full replacement', () => { + const t = new Y.Doc().getText('t') + t.insert(0, 'abc') + applyTextDiff(t, 'abc', 'abXc') + expect(t.toString()).toBe('abXc') + applyTextDiff(t, 'abXc', 'ac') + expect(t.toString()).toBe('ac') + applyTextDiff(t, 'ac', 'zz') + expect(t.toString()).toBe('zz') + }) + + it('no-ops on identical strings (no spurious Y operations)', () => { + const doc = new Y.Doc() + const t = doc.getText('t') + t.insert(0, 'same') + const before = Y.encodeStateVector(doc) + applyTextDiff(t, 'same', 'same') + expect(Y.encodeStateVector(doc)).toEqual(before) + }) + + it('keeps a concurrent remote insertion when splicing (the granularity proof)', () => { + const a = new Y.Doc() + a.getText('t').insert(0, 'hello world') + const b = new Y.Doc() + Y.applyUpdate(b, Y.encodeStateAsUpdate(a)) + // A splices via diff (panel edit), B types at the end concurrently. + applyTextDiff(a.getText('t'), 'hello world', 'hello brave world') + b.getText('t').insert(11, '!!') + syncDocs(a, b) + const merged = a.getText('t').toString() + expect(merged).toContain('brave') + expect(merged).toContain('!!') + expect(b.getText('t').toString()).toBe(merged) + }) +}) + +describe('wire frame codec', () => { + it('round-trips docId + frameType + payload', async () => { + const { encodeCollabFrame, decodeCollabFrame, FRAME_SYNC } = await import('@core/collab') + const frame = encodeCollabFrame('page:p1', 'gen-1', FRAME_SYNC, new Uint8Array([1, 2, 3])) + const decoded = decodeCollabFrame(frame) + expect([decoded.docId, decoded.frameType, [...decoded.payload]]).toEqual([ + 'page:p1', + FRAME_SYNC, + [1, 2, 3], + ]) + }) + + it('round-trips an empty payload (reset frames)', async () => { + const { encodeCollabFrame, decodeCollabFrame, FRAME_RESET } = await import('@core/collab') + const decoded = decodeCollabFrame(encodeCollabFrame('site:default', 'gen-1', FRAME_RESET, new Uint8Array())) + expect(decoded.docId).toBe('site:default') + expect(decoded.frameType).toBe(FRAME_RESET) + expect(decoded.payload.length).toBe(0) + }) + it('round-trips the generation and refuses to lose it', async () => { + const { encodeCollabFrame, decodeCollabFrame } = await import('@core/collab') + const frame = decodeCollabFrame(encodeCollabFrame('page:p1', 'gen-abc', 0, new Uint8Array([7]))) + expect(frame.docId).toBe('page:p1') + expect(frame.generation).toBe('gen-abc') + expect([...frame.payload]).toEqual([7]) + }) + + it("treats '' as a generation, not a missing field", async () => { + const { encodeCollabFrame, decodeCollabFrame } = await import('@core/collab') + const frame = decodeCollabFrame(encodeCollabFrame('presence', '', 1, new Uint8Array())) + expect(frame.generation).toBe('') + expect(frame.frameType).toBe(1) + }) + + it('round-trips a reset reason and degrades an unknown one to the routine reseed', async () => { + const { encodeResetPayload, decodeResetReason } = await import('@core/collab') + for (const reason of ['rewritten', 'stale', 'refused', 'oversize'] as const) { + expect(decodeResetReason(encodeResetPayload(reason))).toBe(reason) + } + expect(decodeResetReason(new Uint8Array())).toBe('rewritten') + expect(decodeResetReason(new Uint8Array([9, 9, 9]))).toBe('rewritten') + }) + +}) diff --git a/src/__tests__/collab/projectionOwnership.test.ts b/src/__tests__/collab/projectionOwnership.test.ts new file mode 100644 index 000000000..36adf50e3 --- /dev/null +++ b/src/__tests__/collab/projectionOwnership.test.ts @@ -0,0 +1,125 @@ +/** + * The projection must return OWNED data, not live references into the Y doc. + * + * `projectSiteDoc` reads plain values straight out of the shell Y.Maps. If it + * hands those references out, the editor store and the CRDT end up sharing the + * same mutable objects, and two things go wrong: + * + * 1. `validateSite` normalizes in place (`normalizeFrameworkColors` assigns + * `colors.tokens`), so validating a projection silently REWRITES state + * held inside the Y doc, outside any transaction. + * 2. The editor store is created with Mutative `enableAutoFreeze: true`, so + * the moment a projected shell lands in the store, everything reachable + * from it is deep-frozen — including the objects the Y doc still holds. + * The NEXT projection then throws + * `TypeError: Cannot assign to read only property 'tokens'`, the + * projection is skipped, and the editor never renders. + * + * Both are reproduced below with the real seed → project → validate → freeze + * cycle the editor performs on every remote update. + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import '@modules/base' +import { projectSiteDoc, seedSiteDoc, shellMap } from '@core/collab' +import { validateSite } from '@core/persistence/validate' +import { makeSite } from '../fixtures' + +/** + * What Mutative's `enableAutoFreeze` does to state that lands in the store. + * + * Applied ONLY to the fixture's own `framework` object below — never to a whole + * validated shell. `parseSiteSettings`/`parseSiteDocument` hand back shared + * module singletons (`DEFAULT_SITE_SETTINGS`, `DEFAULT_PACKAGE_JSON`) by + * reference on their fallback paths, and `bun test` runs every file in ONE + * process, so freezing a validated shell wholesale would freeze those + * singletons for the entire run and break unrelated suites downstream. + */ +function deepFreeze(value: T): T { + if (value === null || typeof value !== 'object') return value + Object.freeze(value) + for (const inner of Object.values(value as Record)) deepFreeze(inner) + return value +} + +function siteWithFrameworkColors() { + const site = makeSite({}) + return { + ...site, + settings: { + ...site.settings, + framework: { + colors: { + tokens: [ + { + id: 'tok1', + category: '', + slug: 'Brand Primary', + lightValue: '#ffffff', + darkValue: '', + darkModeEnabled: false, + generateUtilities: { text: true, background: true, border: true, fill: false }, + generateTransparent: true, + generateShades: { enabled: false, count: 4 }, + generateTints: { enabled: false, count: 4 }, + order: 0, + createdAt: 0, + updatedAt: 0, + }, + ], + }, + }, + }, + } as typeof site +} + +function projectShell(doc: Y.Doc) { + const projected = projectSiteDoc(doc) + return validateSite({ ...projected.shell, id: 'default', updatedAt: 0 }) +} + +describe('site projection ownership', () => { + it('survives a second projection after the first landed in a frozen store', () => { + const doc = new Y.Doc() + seedSiteDoc(doc, siteWithFrameworkColors()) + + // First projection → validated → store. Freezing what the projection + // handed out is what the store's auto-freeze does; if that reaches the + // doc's own objects, the doc is poisoned. + const first = projectSiteDoc(doc) + validateSite({ ...first.shell, id: 'default', updatedAt: 0 }) + deepFreeze((first.shell.settings as Record).framework) + + // Second projection — a peer edit, an undo, any remote update at all. + expect(() => projectShell(doc)).not.toThrow() + }) + + it('does not let validation write normalized values back into the Y doc', () => { + const doc = new Y.Doc() + seedSiteDoc(doc, siteWithFrameworkColors()) + + const validated = projectShell(doc) + // Validation normalizes the slug and fills the dark value... + expect(validated.settings.framework?.colors?.tokens[0]?.slug).toBe('brand-primary') + expect(validated.settings.framework?.colors?.tokens[0]?.darkValue).not.toBe('') + + // ...but the doc must still hold exactly what was seeded. + const settings = shellMap(doc).get('settings') as Y.Map + const framework = settings.get('framework') as { + colors: { tokens: Array<{ slug: string; darkValue: string }> } + } + expect(framework.colors.tokens[0].slug).toBe('Brand Primary') + expect(framework.colors.tokens[0].darkValue).toBe('') + }) + + it('hands out a fresh object graph on every projection', () => { + const doc = new Y.Doc() + seedSiteDoc(doc, siteWithFrameworkColors()) + + const settings = shellMap(doc).get('settings') as Y.Map + const stored = settings.get('framework') + + expect(projectSiteDoc(doc).shell.settings).not.toBe(settings) + expect((projectSiteDoc(doc).shell.settings as Record).framework).not.toBe(stored) + }) +}) diff --git a/src/__tests__/collab/propertiesPanelTextarea.test.tsx b/src/__tests__/collab/propertiesPanelTextarea.test.tsx new file mode 100644 index 000000000..dd3a05c62 --- /dev/null +++ b/src/__tests__/collab/propertiesPanelTextarea.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { TextareaControl } from '@site/property-controls/TextareaControl' + +afterEach(cleanup) + +describe('properties-panel textarea under co-editing', () => { + it('adopts a remote controlled value before the next local input event', () => { + const commits: string[] = [] + const onChange = (_key: string, value: string) => { + commits.push(value) + } + const view = render( + , + ) + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement + + // A native edit has changed the DOM but its input event has not run yet. + // Remote projection then re-renders the controlled property value. + textarea.value = 'hello stale local' + view.rerender( + , + ) + expect(textarea.value).toBe('hello remote') + + // The next local event starts from the projected remote value rather than + // committing the stale DOM snapshot over it. + fireEvent.change(textarea, { target: { value: 'hello remote!' } }) + expect(commits).toEqual(['hello remote!']) + }) +}) diff --git a/src/__tests__/collab/provider.test.ts b/src/__tests__/collab/provider.test.ts new file mode 100644 index 000000000..4a660424e --- /dev/null +++ b/src/__tests__/collab/provider.test.ts @@ -0,0 +1,227 @@ +/** + * CollabProvider transport behavior against an in-memory fake socket: + * step1 on bind, immediate update frames on local transactions, remote + * update application under REMOTE_ORIGIN, synced gating on step2, and the + * reset flow. The real wire runs in collabRelayIntegration.test.ts. + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' +import * as syncProtocol from 'y-protocols/sync' +import { + decodeCollabFrame, + encodeCollabFrame, + FRAME_PING, + FRAME_RESET, + FRAME_SYNC, + LOCAL_ORIGIN, +} from '@core/collab' +import { + createCollabProvider, + type CollabSocketLike, +} from '@site/collab/collabProvider' + +class FakeSocket implements CollabSocketLike { + binaryType = 'arraybuffer' + readyState = 1 + bufferedAmount = 0 + sent: Uint8Array[] = [] + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + send(data: Uint8Array): void { + this.sent.push(data) + } + close(): void { + this.readyState = 3 + } + open(): void { + this.onopen?.() + } + emit(frame: Uint8Array): void { + this.onmessage?.({ data: frame.buffer.slice(frame.byteOffset, frame.byteOffset + frame.byteLength) }) + } +} + +function lastSyncMessageType(socket: FakeSocket, docId: string): number | null { + for (let i = socket.sent.length - 1; i >= 0; i--) { + const frame = decodeCollabFrame(socket.sent[i]) + if (frame.docId === docId && frame.frameType === FRAME_SYNC) { + return decoding.readVarUint(decoding.createDecoder(frame.payload)) + } + } + return null +} + +describe('collab provider', () => { + it('sends syncStep1 for a bound doc on connect and marks it synced on step2', async () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + const binding = provider.bind('page:p1') + expect(lastSyncMessageType(socket, 'page:p1')).toBe(0) // step1 + expect(binding.synced).toBe(false) + + // Server replies with step2 (its full state against our vector). + const serverDoc = new Y.Doc() + serverDoc.getMap('tree').set('rootNodeId', 'root') + const encoder = encoding.createEncoder() + syncProtocol.writeSyncStep2(encoder, serverDoc, Y.encodeStateVector(new Y.Doc())) + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_SYNC, encoding.toUint8Array(encoder))) + + await binding.whenSynced + expect(binding.synced).toBe(true) + expect(binding.doc.getMap('tree').get('rootNodeId')).toBe('root') + provider.destroy() + }) + + it('sends an update frame immediately on a local transaction; remote-applied updates do not echo', () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + const { doc } = provider.bind('page:p1') + const sentBefore = socket.sent.length + + doc.transact(() => { + doc.getMap('tree').set('rootNodeId', 'root') + }, LOCAL_ORIGIN) + expect(socket.sent.length).toBe(sentBefore + 1) + expect(lastSyncMessageType(socket, 'page:p1')).toBe(2) // update + + // A remote update applied by the provider must NOT be re-sent. + const remote = new Y.Doc() + remote.getMap('meta').set('title', 'From remote') + const encoder = encoding.createEncoder() + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(remote)) + const countBeforeRemote = socket.sent.length + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_SYNC, encoding.toUint8Array(encoder))) + expect(doc.getMap('meta').get('title')).toBe('From remote') + expect(socket.sent.length).toBe(countBeforeRemote) + provider.destroy() + }) + + it('a reset frame unbinds the doc and notifies listeners', () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + provider.bind('page:p1') + const resets: string[] = [] + provider.onReset((docId) => resets.push(docId)) + + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_RESET, new Uint8Array())) + expect(resets).toEqual(['page:p1']) + // A rebind gets a FRESH doc (the old one was destroyed). + const rebound = provider.bind('page:p1') + expect(rebound.synced).toBe(false) + provider.destroy() + }) + + it('settles whenSynced when a never-synced doc is unbound (no hanging awaiters)', async () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + const binding = provider.bind('page:p1') // bound but never sent step2 + + let settled = false + void binding.whenSynced.then(() => { + settled = true + }) + // Reset (or any unbind) before the first sync must resolve the promise, + // not leave every chained continuation pending forever. + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_RESET, new Uint8Array())) + await binding.whenSynced // resolves instead of hanging the test + await Promise.resolve() + expect(settled).toBe(true) + provider.destroy() + }) + + // ── Liveness ────────────────────────────────────────────────────────────── + // `readyState` cannot distinguish a live socket from a black-holed one, and + // the write gate refuses edits it cannot deliver — so "connected" has to + // mean "answered a ping recently", not "the browser has not noticed yet". + + it('pings on an interval and goes offline when the socket stops answering', async () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket, pingIntervalMs: 20 }) + const seen: string[] = [] + provider.onStatus((status) => seen.push(status)) + socket.open() + expect(provider.canSend()).toBe(true) + + // A ping goes out on the interval... + await Bun.sleep(35) + const pings = socket.sent.filter((f) => decodeCollabFrame(f).frameType === FRAME_PING) + expect(pings.length).toBeGreaterThan(0) + + // ...and with no inbound traffic at all, the provider stops claiming to be + // connected rather than waiting for a close that may never come. + await Bun.sleep(80) + expect(provider.status()).toBe('offline') + expect(provider.canSend()).toBe(false) + expect(seen).toContain('offline') + provider.destroy() + }) + + it('refuses to send while the socket backlog is not draining', () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket, pingIntervalMs: 60_000 }) + socket.open() + expect(provider.canSend()).toBe(true) + + // Open, but the bytes are not moving — an edit now would only join a + // buffer the browser discards on unload. + socket.bufferedAmount = 1024 * 1024 + expect(provider.canSend()).toBe(false) + provider.destroy() + }) + + it('reconnectNow escapes the backoff and is a no-op while connected', () => { + let created = 0 + const socket = new FakeSocket() + const provider = createCollabProvider({ + createSocket: () => { + created += 1 + return socket + }, + }) + socket.open() + provider.reconnectNow() + expect(created).toBe(1) // already connected — nothing to do + + socket.readyState = 3 + socket.onclose?.() + provider.reconnectNow() + expect(created).toBe(2) + provider.destroy() + }) + + it('detaches a late-opening socket and every document listener on destroy', async () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ + createSocket: () => socket, + pingIntervalMs: 10, + }) + const binding = provider.bind('page:p1') + const statuses: string[] = [] + const resets: string[] = [] + provider.onStatus((status) => statuses.push(status)) + provider.onReset((docId) => resets.push(docId)) + const sentBeforeDestroy = socket.sent.length + + provider.destroy() + binding.doc.getMap('meta').set('title', 'After destroy') + socket.open() + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_RESET, new Uint8Array())) + await Bun.sleep(30) + + expect(socket.onopen).toBeNull() + expect(socket.onmessage).toBeNull() + expect(socket.onclose).toBeNull() + expect(socket.onerror).toBeNull() + expect(socket.sent).toHaveLength(sentBeforeDestroy) + expect(statuses).toEqual([]) + expect(resets).toEqual([]) + }) +}) diff --git a/src/__tests__/collab/seedProject.test.ts b/src/__tests__/collab/seedProject.test.ts new file mode 100644 index 000000000..e93fb0f1a --- /dev/null +++ b/src/__tests__/collab/seedProject.test.ts @@ -0,0 +1,270 @@ +/** + * Collab core — seed → project round-trips (Yjs docs ⇄ domain JSON). + * + * The projection is the ONLY reader of Y state and must reproduce the domain + * shape exactly (minus `parentId`, which is derived and re-indexed), heal + * tree-integrity damage deterministically (duplicate child ids, orphan + * nodes — possible under rare concurrent-merge interleavings), and reconcile + * the shell's roster order against roster membership. + * + * `import '@modules/base'` populates the module registry so `base.text`'s + * inline-text prop (`text`) seeds as Y.Text — the character-merge substrate. + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import '@modules/base' +import { + projectComponentDoc, + projectLayoutDoc, + projectPageDoc, + projectSiteDoc, + reconcileTreeIntegrity, + seedComponentDoc, + seedLayoutDoc, + seedPageDoc, + seedSiteDoc, + shellMap, + treeMap, +} from '@core/collab' +import type { BaseNode } from '@core/page-tree' +import { validateSite } from '@core/persistence/validate' +import { makeNode, makePage, makeSite, makeVC } from '../fixtures' + +function fixturePage() { + return makePage({ + id: 'p1', + slug: 'index', + title: 'Home', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['t1', 'c1'] }), + t1: makeNode({ id: 't1', moduleId: 'base.text', props: { text: 'Hello', tag: 'p' } }), + c1: makeNode({ id: 'c1', moduleId: 'base.container', children: [] }), + }, + }) +} + +describe('page doc seed → project round-trip', () => { + it('projects back exactly the seeded page (parentId re-derived)', () => { + const page = fixturePage() + const doc = new Y.Doc() + seedPageDoc(doc, page) + const projected = projectPageDoc(doc, 'p1') + + expect(projected.id).toBe('p1') + expect(projected.title).toBe('Home') + expect(projected.slug).toBe('index') + expect(projected.rootNodeId).toBe(page.rootNodeId) + expect(projected.nodes.t1.props.text).toBe('Hello') + expect(projected.nodes.t1.props.tag).toBe('p') + expect(projected.nodes.root.children).toEqual(['t1', 'c1']) + expect(projected.nodes.t1.parentId).toBe('root') // reindexed, not stored + }) + + it('stores the inline-text prop as Y.Text (character-merge substrate)', () => { + const doc = new Y.Doc() + seedPageDoc(doc, fixturePage()) + const nodes = treeMap(doc).get('nodes') as Y.Map + const t1 = nodes.get('t1') as Y.Map + const props = t1.get('props') as Y.Map + expect(props.get('text')).toBeInstanceOf(Y.Text) + expect((props.get('text') as Y.Text).toString()).toBe('Hello') + expect(props.get('tag')).toBe('p') // non-inline props stay plain + }) + + it('round-trips breakpoint overrides and scalar node fields', () => { + const page = makePage({ + id: 'p2', + slug: 'about', + title: 'About', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['b1'] }), + b1: makeNode({ + id: 'b1', + moduleId: 'base.container', + label: 'Hero', + locked: true, + classIds: ['cls-1'], + breakpointOverrides: { mobile: { gap: '8px' } }, + }), + }, + }) + const doc = new Y.Doc() + seedPageDoc(doc, page) + const projected = projectPageDoc(doc, 'p2') + expect(projected.nodes.b1.label).toBe('Hero') + expect(projected.nodes.b1.locked).toBe(true) + expect(projected.nodes.b1.classIds).toEqual(['cls-1']) + expect(projected.nodes.b1.breakpointOverrides).toEqual({ mobile: { gap: '8px' } }) + }) +}) + +describe('component + layout doc round-trips', () => { + it('projects a VC back with tree, params and meta intact', () => { + const vc = makeVC({ id: 'vc1', name: 'Card' }) + const doc = new Y.Doc() + seedComponentDoc(doc, vc) + const projected = projectComponentDoc(doc, 'vc1') + expect(projected.id).toBe('vc1') + expect(projected.name).toBe('Card') + expect(projected.tree.rootNodeId).toBe(vc.tree.rootNodeId) + expect(Object.keys(projected.tree.nodes)).toEqual(Object.keys(vc.tree.nodes)) + expect(projected.params).toEqual(vc.params) + }) + + it('projects a layout back from its whole-snapshot storage', () => { + const layout = { + id: 'l1', + name: 'Hero section', + rootNodeId: 'root', + nodes: { root: makeNode({ id: 'root', moduleId: 'base.container' }) }, + classes: {}, + createdAt: 1_700_000_000_000, + } + const doc = new Y.Doc() + seedLayoutDoc(doc, layout) + const projected = projectLayoutDoc(doc, 'l1') + expect(projected.name).toBe('Hero section') + expect(projected.rootNodeId).toBe('root') + expect(Object.keys(projected.nodes)).toEqual(['root']) + }) +}) + +describe('site doc (shell + rosters)', () => { + it('projects shell fields, per-entry maps, and rosters back', () => { + const site = makeSite({ + name: 'Acme', + pages: [makePage({ id: 'a', slug: 'index' }), makePage({ id: 'b', slug: 'b' })], + visualComponents: [makeVC({ id: 'vc1', name: 'Card' })], + styleRules: { + r1: { + id: 'r1', name: 'hero', kind: 'class', selector: '.hero', order: 0, + styles: { color: 'var(--text)' }, contextStyles: {}, createdAt: 1, updatedAt: 1, + }, + }, + }) + const doc = new Y.Doc() + seedSiteDoc(doc, site) + const projected = projectSiteDoc(doc) + expect(projected.shell.name).toBe('Acme') + expect(projected.shell.styleRules.r1.selector).toBe('.hero') + expect(projected.shell.settings).toEqual(site.settings) + expect(projected.rosters.pages).toEqual(['a', 'b']) + expect(projected.rosters.components).toEqual(['vc1']) + }) + + it('validating the projected shell drops a malformed style rule instead of crashing', () => { + // A whole-shell-field value (packageJson / runtime) can end up wrongly + // stored under a style-rule key. The client adopts the projection through + // validateSite (same as the HTTP load path), which must drop the bad entry + // rather than reject the whole shell — otherwise a downstream panel that + // reads `rule.name`/`rule.styles` crashes the editor. + const site = makeSite({ + styleRules: { + good: { + id: 'good', name: 'hero', kind: 'class', selector: '.hero', order: 0, + styles: { color: 'red' }, contextStyles: {}, createdAt: 1, updatedAt: 1, + }, + }, + }) + const doc = new Y.Doc() + seedSiteDoc(doc, site) + // Inject a packageJson-shaped object under a nanoid style-rule key. + doc.transact(() => { + const styleRules = shellMap(doc).get('styleRules') as Y.Map + styleRules.set('bad', { dependencies: { '@x/y': '1.0.0' }, devDependencies: {} }) + }) + const projected = projectSiteDoc(doc) + expect('bad' in projected.shell.styleRules).toBe(true) // projection is faithful + + const shell = validateSite({ ...projected.shell, id: 'default', updatedAt: 0 }) + expect(shell.styleRules.good.selector).toBe('.hero') // good rule survives + expect('bad' in shell.styleRules).toBe(false) // malformed rule dropped + }) + + it('reconciles roster order against membership (dedupe, append missing, drop deleted)', () => { + const site = makeSite({ + pages: [makePage({ id: 'a', slug: 'index' }), makePage({ id: 'b', slug: 'b' })], + }) + const doc = new Y.Doc() + seedSiteDoc(doc, site) + doc.transact(() => { + const rosters = doc.getMap('rosters') + const order = rosters.get('pageOrder') as Y.Array + order.push(['a']) // duplicate + order.push(['ghost']) // not a member + const pages = rosters.get('pages') as Y.Map + pages.set('c', true) // member missing from order + }) + const projected = projectSiteDoc(doc) + expect(projected.rosters.pages).toEqual(['a', 'b', 'c']) + }) +}) + +describe('tree-integrity reconcile', () => { + it('heals duplicate child ids and re-attaches orphan nodes deterministically', () => { + const doc = new Y.Doc() + seedPageDoc(doc, fixturePage()) + doc.transact(() => { + const nodes = treeMap(doc).get('nodes') as Y.Map + const root = nodes.get('root') as Y.Map + ;(root.get('children') as Y.Array).push(['t1']) // duplicate child + // Orphan node referenced by nothing: + const ghost = new Y.Map() + ghost.set('id', 'ghost') + ghost.set('moduleId', 'base.text') + ghost.set('props', new Y.Map()) + ghost.set('breakpointOverrides', new Y.Map()) + ghost.set('children', new Y.Array()) + nodes.set('ghost', ghost) + // Dangling child id with no node entry: + ;(root.get('children') as Y.Array).push(['missing-node']) + }) + const projected = projectPageDoc(doc, 'p1') + expect(projected.nodes.root.children.filter((c) => c === 't1')).toHaveLength(1) + expect(projected.nodes.root.children).not.toContain('missing-node') + expect(projected.nodes.root.children).toContain('ghost') + expect(projected.nodes.ghost.parentId).toBe('root') + }) + + it('re-attaches only orphan SUBTREE ROOTS, keeping the subtree single-parented', () => { + // Regression: a move-vs-delete race can orphan a MULTI-node subtree (O + // containing C). Appending every unclaimed node flat under root left C + // referenced by both root.children AND O.children — a node with two + // parents that renders twice. Only O (the subtree root) may re-attach. + const nodes: Record = { + root: { id: 'root', moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: [], parentId: null }, + orphanParent: { + id: 'orphanParent', moduleId: 'base.container', props: {}, breakpointOverrides: {}, + children: ['orphanChild'], parentId: null, + }, + orphanChild: { + id: 'orphanChild', moduleId: 'base.text', props: {}, breakpointOverrides: {}, + children: [], parentId: null, + }, + } + reconcileTreeIntegrity(nodes, 'root') + + // The subtree root is under root exactly once; the child is NOT. + expect(nodes.root.children).toEqual(['orphanParent']) + // The child stays under its orphan parent — single-parent preserved. + expect(nodes.orphanParent.children).toEqual(['orphanChild']) + // No node appears in two children arrays. + const allChildRefs = Object.values(nodes).flatMap((n) => n.children) + expect(new Set(allChildRefs).size).toBe(allChildRefs.length) + }) + + it('breaks orphan-only cycles deterministically instead of hanging', () => { + // A and B reference each other but neither is reachable from root. + const nodes: Record = { + root: { id: 'root', moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: [], parentId: null }, + a: { id: 'a', moduleId: 'base.text', props: {}, breakpointOverrides: {}, children: ['b'], parentId: null }, + b: { id: 'b', moduleId: 'base.text', props: {}, breakpointOverrides: {}, children: ['a'], parentId: null }, + } + reconcileTreeIntegrity(nodes, 'root') + // Lowest id ('a') breaks the cycle and re-attaches under root; 'b' stays + // its child; every node is claimed exactly once. + expect(nodes.root.children).toEqual(['a']) + expect(nodes.a.children).toEqual(['b']) + expect(nodes.b.children).toEqual([]) // the back-edge to 'a' is dropped + }) +}) diff --git a/src/__tests__/collab/socketUrl.test.ts b/src/__tests__/collab/socketUrl.test.ts new file mode 100644 index 000000000..bfcd0463e --- /dev/null +++ b/src/__tests__/collab/socketUrl.test.ts @@ -0,0 +1,78 @@ +/** + * Where the collab WebSocket dials. + * + * The dev branch exists because Vite (running inside Bun) cannot forward + * WebSocket upgrades; the socket skips the proxy and dials the CMS port. + * The invariant that matters most is that only the PORT is swapped — the + * session cookie is `SameSite=Lax`, so rewriting `127.0.0.1` to `localhost` + * (or the reverse) would make the handshake cross-site, drop the cookie, and + * 401 into an endless reconnect. + */ +import { describe, expect, it } from 'bun:test' +import { collabSocketUrl } from '@site/collab/socketUrl' + +const PATH = '/admin/api/cms/site-socket' + +describe('collabSocketUrl', () => { + describe('production (same origin)', () => { + it('uses the page host verbatim', () => { + const url = collabSocketUrl( + { protocol: 'https:', host: 'cms.example.com', hostname: 'cms.example.com' }, + null, + ) + expect(url).toBe(`wss://cms.example.com${PATH}`) + }) + + it('keeps a non-default port that is part of the origin', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: 'box.internal:8080', hostname: 'box.internal' }, + null, + ) + expect(url).toBe(`ws://box.internal:8080${PATH}`) + }) + + it('downgrades to ws on a plain-http origin', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: 'cms.example.com', hostname: 'cms.example.com' }, + null, + ) + expect(url).toBe(`ws://cms.example.com${PATH}`) + }) + }) + + describe('vite dev (CMS port dialled directly)', () => { + it('swaps only the port, preserving a localhost page', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: 'localhost:5173', hostname: 'localhost' }, + '3001', + ) + expect(url).toBe(`ws://localhost:3001${PATH}`) + }) + + // `bun run dev` serves Vite on 127.0.0.1; rewriting to a `localhost` + // literal here would make the handshake cross-site and drop the cookie. + it('swaps only the port, preserving a 127.0.0.1 page', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: '127.0.0.1:5173', hostname: '127.0.0.1' }, + '3001', + ) + expect(url).toBe(`ws://127.0.0.1:3001${PATH}`) + }) + + it('honours a non-default CMS port (e2e harness)', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: '127.0.0.1:5174', hostname: '127.0.0.1' }, + '3002', + ) + expect(url).toBe(`ws://127.0.0.1:3002${PATH}`) + }) + + it('never carries the Vite port through', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: 'localhost:5173', hostname: 'localhost' }, + '3001', + ) + expect(url).not.toContain('5173') + }) + }) +}) diff --git a/src/__tests__/data/buildDataMeta.test.ts b/src/__tests__/data/buildDataMeta.test.ts index 0e9d59166..7a729fd22 100644 --- a/src/__tests__/data/buildDataMeta.test.ts +++ b/src/__tests__/data/buildDataMeta.test.ts @@ -189,6 +189,73 @@ describe('buildDataMeta', () => { expect(fields.find((f) => f.id === 'missing')).toBeUndefined() }) + it('projects a repeater item schema with media and resolved relations', () => { + const authorTable = makeDataTable({ + id: 'table-authors', + slug: 'authors', + name: 'Authors', + }) + const projectTable = makePostTypeTable({ + fields: [{ + type: 'repeater', + id: 'gallery', + label: 'Project gallery', + itemLabelFieldId: 'caption', + fields: [ + { type: 'text', id: 'caption', label: 'Caption' }, + { + type: 'media', + id: 'images', + label: 'Images', + mediaKind: 'image', + allowMultiple: true, + }, + { + type: 'relation', + id: 'photographer', + label: 'Photographer', + targetTableId: 'table-authors', + }, + { + type: 'relation', + id: 'missing', + label: 'Missing', + targetTableId: 'table-missing', + }, + ], + }], + }) + + const meta = buildDataMeta([authorTable, projectTable]) + const gallery = meta.tables + .find((table) => table.id === projectTable.id) + ?.fields.find((field) => field.id === 'gallery') + + expect(gallery).toEqual({ + id: 'gallery', + label: 'Project gallery', + type: 'repeater', + itemLabelFieldId: 'caption', + fields: [ + { id: 'caption', label: 'Caption', type: 'text' }, + { + id: 'images', + label: 'Images', + type: 'media', + mediaKind: 'image', + allowMultiple: true, + }, + { + id: 'photographer', + label: 'Photographer', + type: 'relation', + targetTableSlug: 'authors', + }, + ], + }) + expect(Value.Check(DataMetaSchema, meta)).toBe(true) + }) + it('output validates against DataMetaSchema', () => { const authorTable = makeDataTable({ id: 'table-authors', diff --git a/src/__tests__/data/contentAdmin.test.tsx b/src/__tests__/data/contentAdmin.test.tsx index 73516e019..dac0d1ea8 100644 --- a/src/__tests__/data/contentAdmin.test.tsx +++ b/src/__tests__/data/contentAdmin.test.tsx @@ -326,6 +326,27 @@ beforeEach(() => { const calls: FetchCall[] = [] ;(globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls = calls + + // The posts-rows endpoint is STATEFUL, like the real server: a row created by + // POST is visible to every later GET, and a deleted row is gone from it. + // + // A stateless `rows: []` made this mock lie about the one ordering the app is + // allowed to produce. The workspace treats a list response as authoritative + // unless the selected row changed *during* that request (see + // useContentWorkspace.loadEntries) — correct, because a real GET issued after + // a POST returns the new row. But the mock returned an empty list forever, so + // whenever the initial list GET happened to be issued after the create, its + // (bogus) empty response wiped the new row and the test failed. Which request + // won that race was pure scheduling luck, making every create-then-assert test + // in this file order-dependent. + let postsRows: ReturnType[] = [] + const putRow = (row: ReturnType) => { + const i = postsRows.findIndex((r) => r.id === row.id) + if (i === -1) postsRows.push(row) + else postsRows[i] = row + return row + } + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }) const url = String(input) @@ -337,7 +358,7 @@ beforeEach(() => { } if (url === '/admin/api/cms/data/tables/posts/rows' && init?.method === 'GET') { - return json({ rows: [] }) + return json({ rows: postsRows }) } if (url === '/admin/api/cms/data/authors' && init?.method === 'GET') { @@ -346,24 +367,25 @@ beforeEach(() => { if (url === '/admin/api/cms/data/tables/posts/rows' && init?.method === 'POST') { return json({ - row: makeRow('entry_1', 'posts', { title: 'Untitled', slug: 'untitled' }, { + row: putRow(makeRow('entry_1', 'posts', { title: 'Untitled', slug: 'untitled' }, { authorUserId: ownerAuthor.id, author: ownerAuthor, - }), + })), }, 201) } if (url === '/admin/api/cms/data/rows/entry_1' && init?.method === 'PATCH') { const draft = JSON.parse(String(init.body)) return json({ - row: { + row: putRow({ ...makeRow('entry_1', 'posts', draft.cells ?? {}), updatedAt: '2026-05-01T10:01:00.000Z', - }, + }), }) } if (url === '/admin/api/cms/data/rows/entry_1' && init?.method === 'DELETE') { + postsRows = postsRows.filter((r) => r.id !== 'entry_1') return json({ row: makeRow('entry_1', 'posts', { title: 'Untitled', slug: 'untitled' }, { authorUserId: ownerAuthor.id, @@ -375,23 +397,23 @@ beforeEach(() => { if (url === '/admin/api/cms/data/rows/entry_1/publish' && init?.method === 'POST') { return json({ - row: { + row: putRow({ ...makeRow('entry_1', 'posts', { title: 'My first post', slug: 'untitled', body: '## Intro', featuredMedia: null, seoTitle: '', seoDescription: '' }), status: 'published', updatedAt: '2026-05-01T10:02:00.000Z', publishedAt: '2026-05-01T10:02:00.000Z', - }, + }), }) } if (url === '/admin/api/cms/data/rows/entry_1/status' && init?.method === 'PATCH') { const body = JSON.parse(String(init.body)) return json({ - row: { + row: putRow({ ...makeRow('entry_1', 'posts', { title: 'My first post', slug: 'updated-slug', body: '', featuredMedia: imageAsset.id, seoTitle: '', seoDescription: '' }), status: body.status, updatedAt: '2026-05-01T10:03:00.000Z', - }, + }), }) } diff --git a/src/__tests__/data/dataGridToolbar.test.tsx b/src/__tests__/data/dataGridToolbar.test.tsx new file mode 100644 index 000000000..5e9aaaef0 --- /dev/null +++ b/src/__tests__/data/dataGridToolbar.test.tsx @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, render, screen } from '@testing-library/react' +import { DataGridToolbar } from '@admin/pages/data/components/DataGrid/DataGridToolbar' +import type { DataTable } from '@core/data/schemas' + +afterEach(cleanup) + +const table: DataTable = { + id: 'portfolio-projects', + name: 'Portfolio projects', + slug: 'portfolio-projects', + kind: 'data', + singularLabel: 'Portfolio project', + pluralLabel: 'Portfolio projects', + routeBase: '', + primaryFieldId: 'name', + system: false, + createdByUserId: null, + updatedByUserId: null, + createdAt: '2026-07-28T00:00:00.000Z', + updatedAt: '2026-07-28T00:00:00.000Z', + fields: [{ type: 'text', id: 'name', label: 'Name' }], +} + +const statusCounts = { + all: 0, + pages: 0, + templates: 0, + published: 0, + scheduled: 0, + draft: 0, + unpublished: 0, +} + +function renderToolbar(totalCount: number) { + return render( + undefined} + onAddRow={() => undefined} + hasPublishWorkflow={false} + statusViewOrder={[]} + statusFilter="all" + onStatusFilterChange={() => undefined} + statusCounts={statusCounts} + sort={null} + sortLabel={null} + onClearSort={() => undefined} + />, + ) +} + +describe('DataGridToolbar', () => { + it('uses the table name once and keeps supporting copy generic', () => { + const { rerender } = renderToolbar(1) + + expect(screen.getAllByText('Portfolio projects')).toHaveLength(1) + expect(screen.getByText('1 row')).toBeTruthy() + expect(screen.getByLabelText('Search').getAttribute('placeholder')).toBe('Search…') + + rerender( + undefined} + onAddRow={() => undefined} + hasPublishWorkflow={false} + statusViewOrder={[]} + statusFilter="all" + onStatusFilterChange={() => undefined} + statusCounts={statusCounts} + sort={null} + sortLabel={null} + onClearSort={() => undefined} + />, + ) + + expect(screen.getByText('2 rows')).toBeTruthy() + }) +}) diff --git a/src/__tests__/data/newTableSchemaComposer.test.tsx b/src/__tests__/data/newTableSchemaComposer.test.tsx new file mode 100644 index 000000000..ef717443c --- /dev/null +++ b/src/__tests__/data/newTableSchemaComposer.test.tsx @@ -0,0 +1,317 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { cleanup, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NewTableDialog } from '@admin/pages/data/components/NewTableDialog/NewTableDialog' +import { NewFieldDialog } from '@admin/pages/data/components/NewFieldDialog/NewFieldDialog' +import { FieldSchemaComposer } from '@admin/pages/data/components/FieldSchemaComposer' +import { TableSettings } from '@admin/pages/data/components/DataInspector/TableSettings' +import type { + CreateDataTableInput, + DataField, + DataTable, +} from '@core/data/schemas' + +afterEach(cleanup) + +describe('NewTableDialog schema composer', () => { + it('only exposes the editable collection slug for post types', async () => { + const user = userEvent.setup() + + render( + undefined} + onCreate={async () => undefined} + tables={[]} + />, + ) + + await user.type(screen.getByLabelText('Name'), 'Case Studies') + expect((screen.getByLabelText('Singular label') as HTMLInputElement).value).toBe('Case Study') + expect(screen.queryByLabelText('Slug')).toBeNull() + expect(screen.queryByText('Identity')).toBeNull() + expect(screen.queryByText('Behavior')).toBeNull() + expect(screen.queryByText('Kind')).toBeNull() + + const kindControl = screen.getByRole('group', { name: 'Table kind' }) + const nameInput = screen.getByLabelText('Name') + expect( + kindControl.compareDocumentPosition(nameInput) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + + await user.click(screen.getByRole('button', { name: 'Post type' })) + const slugInput = screen.getByLabelText('Slug') as HTMLInputElement + const routePreview = screen.getByText('/case-studies/entry-slug') + expect(slugInput.value).toBe('case-studies') + expect( + slugInput.compareDocumentPosition(routePreview) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + + await user.clear(screen.getByLabelText('Slug')) + await user.type(screen.getByLabelText('Slug'), 'work') + expect(screen.getByText('/work/entry-slug')).toBeTruthy() + + await user.click(screen.getByRole('button', { name: 'Data table' })) + expect(screen.queryByLabelText('Slug')).toBeNull() + }) + + it('uses the slug placeholder in the empty route preview', async () => { + const user = userEvent.setup() + + render( + undefined} + onCreate={async () => undefined} + tables={[]} + />, + ) + + await user.click(screen.getByRole('button', { name: 'Post type' })) + expect((screen.getByLabelText('Slug') as HTMLInputElement).placeholder).toBe('projects') + expect(screen.getByText('/projects/entry-slug')).toBeTruthy() + }) + + it('assigns the primary field directly from eligible Schema rows', async () => { + const user = userEvent.setup() + const table: DataTable = { + id: 'projects', + name: 'Projects', + slug: 'projects', + kind: 'data', + singularLabel: 'Project', + pluralLabel: 'Projects', + routeBase: '', + primaryFieldId: 'name', + system: false, + createdByUserId: null, + updatedByUserId: null, + createdAt: '2026-07-28T00:00:00.000Z', + updatedAt: '2026-07-28T00:00:00.000Z', + fields: [ + { type: 'text', id: 'name', label: 'Name' }, + { type: 'text', id: 'client', label: 'Client' }, + ], + } + const onUpdateTable = mock(async (input: Partial) => ({ + ...table, + ...input, + })) + + render( + undefined} + canEdit + canDelete={false} + />, + ) + + expect(screen.queryByRole('button', { name: 'Display' })).toBeNull() + expect(screen.getByRole('button', { name: 'Schema · 2 fields' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Add field' })).toBeTruthy() + expect(screen.queryByText('Record fields')).toBeNull() + expect(screen.queryByLabelText('Primary field')).toBeNull() + expect(screen.queryByLabelText('Slug')).toBeNull() + expect(screen.queryByRole('button', { name: 'Routing' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Kind' })).toBeNull() + expect( + screen.getByRole('button', { name: 'Name is the primary field' }).getAttribute('aria-pressed'), + ).toBe('true') + + await user.click(screen.getByRole('button', { name: 'Set Client as primary field' })) + expect(onUpdateTable).toHaveBeenCalledWith({ primaryFieldId: 'client' }) + }) + + it('uses the shared first-letter accent pills for field types', () => { + render( + undefined} + />, + ) + + expect(screen.getByText('Media').getAttribute('data-accent')).toBe('m') + expect(screen.getByText('Repeater').getAttribute('data-accent')).toBe('r') + }) + + it('derives the field ID from the label until the ID is changed manually', async () => { + const user = userEvent.setup() + const created: DataField[] = [] + + render( + undefined} + existingFieldIds={[]} + tables={[]} + onCreate={async (field) => { + created.push(field) + }} + />, + ) + + const labelInput = screen.getByLabelText(/^Label/) as HTMLInputElement + const idInput = screen.getByLabelText(/^ID/) as HTMLInputElement + const idLabel = screen.getByText('ID', { selector: 'label' }) + const typeLabel = screen.getByText('Type', { selector: 'label' }) + + expect( + labelInput.compareDocumentPosition(idInput) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect(idLabel.parentElement?.parentElement).toBe(typeLabel.parentElement?.parentElement) + + await user.type(labelInput, '42 Hero Image') + expect(idInput.value).toBe('field_42_hero_image') + + await user.clear(idInput) + await user.type(idInput, 'cover') + await user.type(labelInput, ' Updated') + expect(idInput.value).toBe('cover') + + await user.click(screen.getByRole('button', { name: 'Create field' })) + expect(created[0]).toMatchObject({ + type: 'text', + id: 'cover', + label: '42 Hero Image Updated', + }) + }) + + it('creates a table and its repeater schema in one atomic input', async () => { + const user = userEvent.setup() + const created: CreateDataTableInput[] = [] + const onCreate = mock(async (input: CreateDataTableInput) => { + created.push(input) + }) + + render( + undefined} + onCreate={onCreate} + tables={[]} + />, + ) + + await user.type(screen.getByLabelText('Name'), 'Projects') + expect((screen.getByLabelText('Singular label') as HTMLInputElement).value).toBe('Project') + expect((screen.getByLabelText('Plural label') as HTMLInputElement).value).toBe('Projects') + await user.click(screen.getByRole('button', { name: 'Add field' })) + + const fieldDialog = screen.getAllByRole('dialog').at(-1)! + await user.click(within(fieldDialog).getByRole('combobox')) + await user.click(screen.getByRole('option', { name: 'Repeater' })) + await user.type(within(fieldDialog).getByLabelText(/^Label/), 'Gallery') + expect((within(fieldDialog).getByLabelText(/^ID/) as HTMLInputElement).value).toBe('gallery') + await user.click(within(fieldDialog).getByRole('button', { name: 'Add item field' })) + + const nestedDialog = screen.getAllByRole('dialog').at(-1)! + await user.type(within(nestedDialog).getByLabelText(/^Label/), 'Caption') + expect((within(nestedDialog).getByLabelText(/^ID/) as HTMLInputElement).value).toBe('caption') + await user.click(within(nestedDialog).getByRole('button', { name: 'Create field' })) + + await waitFor(() => expect(screen.getAllByRole('dialog')).toHaveLength(2)) + const repeaterDialog = screen.getAllByRole('dialog').at(-1)! + await user.click(within(repeaterDialog).getByRole('button', { name: 'Create field' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Edit Gallery' })).toBeTruthy()) + const nameInput = screen.getByLabelText('Name') as HTMLInputElement + if (!nameInput.value) await user.type(nameInput, 'Projects') + await user.click(screen.getByRole('button', { name: 'Create' })) + + expect(onCreate).toHaveBeenCalledTimes(1) + expect(created[0]).toMatchObject({ + name: 'Projects', + slug: 'projects', + kind: 'data', + primaryFieldId: 'name', + }) + expect(created[0]?.fields).toContainEqual({ + type: 'repeater', + id: 'gallery', + label: 'Gallery', + fields: [{ type: 'text', id: 'caption', label: 'Caption' }], + }) + }) + + it('keeps the table draft open when Escape closes its field dialog', async () => { + const user = userEvent.setup() + + render( + undefined} + onCreate={async () => undefined} + tables={[]} + />, + ) + + await user.type(screen.getByLabelText('Name'), 'Projects') + await user.click(screen.getByRole('button', { name: 'Add field' })) + expect(screen.getAllByRole('dialog')).toHaveLength(2) + + await user.keyboard('{Escape}') + + await waitFor(() => expect(screen.getAllByRole('dialog')).toHaveLength(1)) + expect(screen.getByRole('dialog', { name: 'New table' })).toBeTruthy() + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('Projects') + }) + + it('offers accessible move controls for top-level and repeater item fields', async () => { + const user = userEvent.setup() + let schemaFields: DataField[] = [ + { type: 'text', id: 'name', label: 'Name' }, + { type: 'text', id: 'caption', label: 'Caption' }, + ] + const onSchemaChange = mock(async (fields: DataField[]) => { + schemaFields = fields + }) + + const { rerender } = render( + , + ) + await user.click(screen.getByRole('button', { name: 'Move Caption up' })) + expect(schemaFields.map((field) => field.id)).toEqual(['caption', 'name']) + + const savedFields: DataField[] = [] + rerender( + undefined} + existingFieldIds={[]} + tables={[]} + initialField={{ + type: 'repeater', + id: 'gallery', + label: 'Gallery', + fields: [ + { type: 'media', id: 'image', label: 'Image', mediaKind: 'image' }, + { type: 'text', id: 'caption', label: 'Caption' }, + ], + }} + onCreate={async (field) => { + savedFields.push(field) + }} + />, + ) + + await user.click(screen.getByRole('button', { name: 'Move Caption up' })) + const summarySelect = screen.getAllByRole('combobox').at(-1)! + await user.click(summarySelect) + expect(screen.getByRole('option', { name: 'Caption' })).toBeTruthy() + expect(screen.queryByRole('option', { name: 'Image' })).toBeNull() + await user.click(screen.getByRole('option', { name: 'Caption' })) + await user.click(screen.getByRole('button', { name: 'Save field' })) + + expect(savedFields[0]?.type).toBe('repeater') + if (savedFields[0]?.type === 'repeater') { + expect(savedFields[0].fields.map((field) => field.id)).toEqual(['caption', 'image']) + } + }) +}) diff --git a/src/__tests__/data/repeaterCell.test.tsx b/src/__tests__/data/repeaterCell.test.tsx new file mode 100644 index 000000000..bc75a0a72 --- /dev/null +++ b/src/__tests__/data/repeaterCell.test.tsx @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { RepeaterCell } from '@admin/pages/data/components/DataGrid/cells/RepeaterCell' +import type { DataField, RepeaterValue } from '@core/data/schemas' + +afterEach(cleanup) + +const field: Extract = { + type: 'repeater', + id: 'credits', + label: 'Credits', + itemLabelFieldId: 'name', + fields: [ + { type: 'text', id: 'name', label: 'Name', required: true }, + { type: 'text', id: 'role', label: 'Role' }, + ], +} + +function Harness() { + const [value, setValue] = useState([]) + return ( + <> + setValue(next as RepeaterValue)} + context="detail" + /> + {JSON.stringify(value)} + + ) +} + +describe('RepeaterCell', () => { + it('keeps the field label and muted item count in one heading row', () => { + render() + + const label = screen.getByText('Credits') + const count = screen.getByText('0 items') + + expect(label.parentElement).toBe(count.parentElement) + }) + + it('uses the Media grid and list views for media-only repeaters', async () => { + const user = userEvent.setup() + const galleryField: Extract = { + type: 'repeater', + id: 'gallery', + label: 'Gallery', + fields: [{ type: 'media', id: 'image', label: 'Image', mediaKind: 'image' }], + } + + render( + undefined} + context="detail" + />, + ) + + expect(screen.getByRole('group', { name: 'Gallery view' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Grid view' }).getAttribute('aria-pressed')).toBe('true') + expect(screen.queryByRole('button', { name: 'Collapse Item 1' })).toBeNull() + + await user.click(screen.getByRole('button', { name: 'List view' })) + + expect(screen.getByRole('button', { name: 'List view' }).getAttribute('aria-pressed')).toBe('true') + }) + + it('adds, labels, duplicates, reorders, and removes structured items', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Add item' })) + const inputs = screen.getAllByRole('textbox') + await user.type(inputs[0]!, 'Annie') + await user.type(inputs[1]!, 'Photographer') + + await user.click(screen.getByRole('button', { name: 'Duplicate item 1' })) + expect(screen.getAllByRole('button', { name: 'Collapse Annie' })).toHaveLength(2) + + await user.click(screen.getByRole('button', { name: 'Move item 2 up' })) + await user.click(screen.getByRole('button', { name: 'Delete item 2' })) + + const value = JSON.parse(screen.getByTestId('value').textContent ?? '[]') as RepeaterValue + expect(value).toHaveLength(1) + expect(value[0]?.cells).toEqual({ name: 'Annie', role: 'Photographer' }) + expect(typeof value[0]?.id).toBe('string') + }) + + it('does not expose opaque media ids when a configured summary is empty', () => { + const galleryField: Extract = { + type: 'repeater', + id: 'gallery', + label: 'Gallery', + itemLabelFieldId: 'caption', + fields: [ + { type: 'media', id: 'image', label: 'Image', mediaKind: 'image' }, + { type: 'text', id: 'caption', label: 'Caption' }, + ], + } + + render( + undefined} + context="detail" + />, + ) + + expect(screen.getByRole('button', { name: 'Collapse Item 1' })).toBeTruthy() + expect(screen.queryByRole('button', { name: /opaque-media-id/ })).toBeNull() + }) + + it('uses author-facing select labels in collapsed item summaries', () => { + const selectField: Extract = { + type: 'repeater', + id: 'links', + label: 'Links', + itemLabelFieldId: 'placement', + fields: [{ + type: 'select', + id: 'placement', + label: 'Placement', + options: [ + { id: 'hero-primary', value: 'hero_primary', label: 'Hero primary' }, + { id: 'footer-secondary', value: 'footer_secondary', label: 'Footer secondary' }, + ], + }], + } + + render( + undefined} + context="detail" + />, + ) + + expect(screen.getByRole('button', { name: 'Collapse Hero primary' })).toBeTruthy() + expect(screen.queryByRole('button', { name: /hero-primary/ })).toBeNull() + }) + + it('uses author-facing multi-select labels in collapsed item summaries', () => { + const multiSelectField: Extract = { + type: 'repeater', + id: 'tags', + label: 'Tags', + itemLabelFieldId: 'audience', + fields: [{ + type: 'multiSelect', + id: 'audience', + label: 'Audience', + options: [ + { id: 'architects', value: 'architects', label: 'Architects' }, + { id: 'photographers', value: 'photographers', label: 'Photographers' }, + ], + }], + } + + render( + undefined} + context="detail" + />, + ) + + expect(screen.getByRole('button', { + name: 'Collapse Architects, Photographers', + })).toBeTruthy() + }) +}) diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index d054a1969..bfd44dc00 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -91,6 +91,23 @@ describe('development workflow', () => { expect(viteConfig).toContain('changeOrigin: true') }) + it('Vite never forwards WebSocket upgrades, and the collab socket gets the CMS port instead', () => { + const viteConfig = readSiteFile('vite.config.ts') + const devScript = readSiteFile('scripts/dev.ts') + + // Vite runs inside Bun (scripts/vite.ts), and Bun's node:http client never + // emits 'upgrade'. Enabling `ws` forwarding makes the browser socket hang + // and then kills the dev process via `socket.destroySoon()`. The collab + // socket dials the CMS port directly instead — never re-enable this. + expect(viteConfig).not.toMatch(/^\s*ws:\s*true/m) + + // The dialled port must come from the same source as the proxy target so + // they cannot drift, and dev.ts must actually hand PORT to the Vite child + // (it previously relied on both defaults happening to be 3001). + expect(viteConfig).toContain("'import.meta.env.VITE_CMS_DEV_PORT': JSON.stringify(process.env.PORT ?? '3001')") + expect(devScript).toContain('env: { PORT: String(CMS_PORT) }') + }) + it('Vite forwards public page routes to the CMS server instead of the admin SPA', () => { const viteConfig = readSiteFile('vite.config.ts') diff --git a/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx b/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx index 5a56ca7b2..637bb5cd6 100644 --- a/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx +++ b/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx @@ -9,6 +9,10 @@ import type { SiteDocument } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { IPersistenceAdapter } from '@core/persistence/types' import { buildCoreFrameworkSettings } from '@core/framework' +import { + subscribeToasts, + type Toast, +} from '@ui/components/Toast/toastBus' afterEach(cleanup) @@ -63,9 +67,11 @@ function makeAdapter(site: SiteDocument): IPersistenceAdapter & { loadCount: () return { async loadSite() { loads += 1 - return site + return { site, rowSeqs: {}, shellSeq: 0 } + }, + async saveSite() { + return { seq: 1 } }, - async saveSite() {}, loadCount: () => loads, } } @@ -79,9 +85,11 @@ function makeControlledAdapter( async loadSite() { loads += 1 await new Promise((resolve) => resolvers.push(resolve)) - return site + return { site, rowSeqs: {}, shellSeq: 0 } + }, + async saveSite() { + return { seq: 1 } }, - async saveSite() {}, loadCount: () => loads, resolveNextLoad: () => { resolvers.shift()?.() @@ -194,3 +202,63 @@ describe('Site editor Data workspace deep links', () => { }) }) }) + +describe('Site editor persistence failures', () => { + it('reports a failed site load through the global toast bus', async () => { + const toasts: Toast[] = [] + const unsubscribe = subscribeToasts((snapshot) => { + toasts.splice(0, toasts.length, ...snapshot) + }) + const adapter: IPersistenceAdapter = { + async loadSite() { + throw new Error('Database unavailable') + }, + async saveSite() { + return { seq: 1 } + }, + } + + const hook = renderHook(() => usePersistence('default', adapter, { enabled: true })) + + await waitFor(() => { + expect(hook.result.current.saveStatus).toEqual({ + state: 'error', + message: 'Database unavailable', + }) + expect(toasts).toContainEqual(expect.objectContaining({ + kind: 'error', + title: 'Site load failed', + body: 'Database unavailable', + location: 'site-editor:persistence', + })) + }) + unsubscribe() + }) + + it('reports a failed initial draft creation through the global toast bus', async () => { + const toasts: Toast[] = [] + const unsubscribe = subscribeToasts((snapshot) => { + toasts.splice(0, toasts.length, ...snapshot) + }) + const adapter: IPersistenceAdapter = { + async loadSite() { + return null + }, + async saveSite() { + throw new Error('Storage is read-only') + }, + } + + renderHook(() => usePersistence('default', adapter, { enabled: true })) + + await waitFor(() => { + expect(toasts).toContainEqual(expect.objectContaining({ + kind: 'error', + title: 'Draft creation failed', + body: 'Storage is read-only', + location: 'site-editor:persistence', + })) + }) + unsubscribe() + }) +}) diff --git a/src/__tests__/editor-store/deleteNodesDepthOrder.test.ts b/src/__tests__/editor-store/deleteNodesDepthOrder.test.ts index 3e925d352..9ec213c98 100644 --- a/src/__tests__/editor-store/deleteNodesDepthOrder.test.ts +++ b/src/__tests__/editor-store/deleteNodesDepthOrder.test.ts @@ -74,7 +74,6 @@ describe('deleteNodes — batch ordering and routing', () => { const childB = useEditorStore.getState().insertNode('base.text', { text: 'b' }, container) const sibling = useEditorStore.getState().insertNode('base.text', { text: 's' }, rootId) const baseline = Object.keys(useEditorStore.getState().site!.pages[0].nodes).length - const depthBefore = useEditorStore.getState()._historyPast.length // Parent first — depth-DESC ordering must delete the leaves first so the // already-deleted descendants hit the "node not found" guard cleanly. @@ -86,9 +85,8 @@ describe('deleteNodes — batch ordering and routing', () => { expect(nodes[childB]).toBeUndefined() expect(nodes[sibling]).toBeUndefined() expect(Object.keys(nodes).length).toBe(baseline - 4) - expect(useEditorStore.getState()._historyPast.length).toBe(depthBefore + 1) - // One undo restores the whole batch. + // One undo restores the whole batch — the deletion was a single step. useEditorStore.getState().undo() const restored = useEditorStore.getState().site!.pages[0].nodes expect(Object.keys(restored).length).toBe(baseline) diff --git a/src/__tests__/editor-store/dirtyTracking.test.ts b/src/__tests__/editor-store/dirtyTracking.test.ts deleted file mode 100644 index 0cd43b174..000000000 --- a/src/__tests__/editor-store/dirtyTracking.test.ts +++ /dev/null @@ -1,471 +0,0 @@ -/** - * Patch-derived save-dirty tracking (slices/site/dirtyTracking.ts). - * - * Unit half: collectDirtyFromSitePatches resolves site-relative Mutative - * patch paths to page / Visual Component ids against the POST-mutation site, - * derives DELETED row ids from the pre/post membership diff (robust to every - * recipe style — splice, filter-reassign, wholesale replace), and escalates - * anything unattributable to `all` (over-marking is safe, under-marking - * loses edits). - * - * Store half: the real editor store wires the collector into - * runHistoricMutation, undo/redo, the lifecycle resets, and the - * `_dirtySave` snapshot/restore actions consumed by autosave — including - * the snapshot-time netting rule (a row that exists again is never shipped - * as deleted; a write-mark for a gone row is dropped). - */ -import { describe, it, expect, beforeEach } from 'bun:test' -import type { Patches } from 'mutative' -import { useEditorStore } from '@site/store/store' -import { - collectDirtyFromSitePatches, - emptyDirtyMarks, - mergeDirtyMarks, - type DirtyMarks, -} from '@site/store/slices/site/dirtyTracking' -import { makeNode, makePage, makeSite, makeVC } from '../fixtures' - -// --------------------------------------------------------------------------- -// Unit: collectDirtyFromSitePatches -// --------------------------------------------------------------------------- - -function twoPageTwoVcSite() { - return makeSite({ - pages: [ - makePage({ id: 'page-a', slug: 'index', title: 'Home' }), - makePage({ id: 'page-b', slug: 'about', title: 'About' }), - ], - visualComponents: [ - makeVC({ id: 'vc-one', name: 'One' }), - makeVC({ id: 'vc-two', name: 'Two' }), - ], - }) -} - -describe('collectDirtyFromSitePatches', () => { - it('attributes a nested page edit to that page id', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [ - { op: 'replace', path: ['pages', 1, 'nodes', 'root', 'props', 'text'], value: 'x' }, - ] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-b']) - expect(marks.componentIds.size).toBe(0) - }) - - it('attributes a VC tree edit to that component id', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [ - { op: 'replace', path: ['visualComponents', 0, 'tree', 'nodes', 'vc-root', 'props', 'x'], value: 1 }, - ] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(false) - expect(marks.pageIds.size).toBe(0) - expect([...marks.componentIds]).toEqual(['vc-one']) - }) - - it('attributes a remove at exactly [pages, i] to the deleted page id (membership diff)', () => { - const pre = twoPageTwoVcSite() - const post = makeSite({ - pages: [makePage({ id: 'page-a', slug: 'index', title: 'Home' })], - visualComponents: pre.visualComponents, - }) - const patches: Patches = [{ op: 'remove', path: ['pages', 1] }] - const marks = collectDirtyFromSitePatches(patches, pre, post) - expect(marks.all).toBe(false) - expect(marks.pageIds.size).toBe(0) - expect([...marks.deletedPageIds]).toEqual(['page-b']) - }) - - it('attributes a remove at exactly [visualComponents, i] to the deleted component id', () => { - const pre = twoPageTwoVcSite() - const post = makeSite({ - pages: pre.pages, - visualComponents: [makeVC({ id: 'vc-one', name: 'One' })], - }) - const patches: Patches = [{ op: 'remove', path: ['visualComponents', 1] }] - const marks = collectDirtyFromSitePatches(patches, pre, post) - expect(marks.all).toBe(false) - expect(marks.componentIds.size).toBe(0) - expect([...marks.deletedComponentIds]).toEqual(['vc-two']) - }) - - it('escalates a wholesale [pages] replacement to all AND still attributes deletions', () => { - const pre = twoPageTwoVcSite() - const post = makeSite({ - pages: [makePage({ id: 'page-a', slug: 'index', title: 'Home' })], - visualComponents: pre.visualComponents, - }) - const patches: Patches = [{ op: 'replace', path: ['pages'], value: post.pages }] - const marks = collectDirtyFromSitePatches(patches, pre, post) - expect(marks.all).toBe(true) - expect([...marks.deletedPageIds]).toEqual(['page-b']) - }) - - it('marks nothing for [pages, length] bookkeeping when membership is unchanged', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [{ op: 'replace', path: ['pages', 'length'], value: 1 }] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks).toEqual(emptyDirtyMarks()) - }) - - it('escalates an index that does not resolve in the post-state to all', () => { - const site = twoPageTwoVcSite() // 2 pages — index 5 resolves to nothing - const patches: Patches = [{ op: 'replace', path: ['pages', 5, 'title'], value: 'ghost' }] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(true) - }) - - it('escalates a non-numeric, non-length second segment to all', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [{ op: 'replace', path: ['pages', 'not-an-index'], value: 1 }] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(true) - }) - - it('marks nothing for shell-field paths — the shell is always saved', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [ - { op: 'replace', path: ['styleRules', 'x'], value: {} }, - { op: 'replace', path: ['name'], value: 'Renamed' }, - { op: 'replace', path: ['updatedAt'], value: 123 }, - ] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks).toEqual(emptyDirtyMarks()) - }) - - it('attributes an element add at [pages, i] to the added page', () => { - const post = twoPageTwoVcSite() - const pre = makeSite({ - pages: [makePage({ id: 'page-a', slug: 'index', title: 'Home' })], - visualComponents: post.visualComponents, - }) - const patches: Patches = [{ op: 'add', path: ['pages', 1], value: post.pages[1] }] - const marks = collectDirtyFromSitePatches(patches, pre, post) - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-b']) - expect(marks.deletedPageIds.size).toBe(0) - }) - - it('accumulates marks across a mixed patch set', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [ - { op: 'replace', path: ['pages', 0, 'title'], value: 'New Home' }, - { op: 'replace', path: ['visualComponents', 1, 'name'], value: 'Two v2' }, - { op: 'replace', path: ['styleRules', 'r1'], value: {} }, - ] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-a']) - expect([...marks.componentIds]).toEqual(['vc-two']) - }) -}) - -describe('mergeDirtyMarks', () => { - it('unions write and deleted ids and propagates the all flag', () => { - const target: DirtyMarks = { - ...emptyDirtyMarks(), - pageIds: new Set(['page-a']), - componentIds: new Set(['vc-one']), - deletedPageIds: new Set(['page-gone']), - } - mergeDirtyMarks(target, { - ...emptyDirtyMarks(), - pageIds: new Set(['page-b']), - deletedLayoutIds: new Set(['layout-gone']), - }) - expect(target.all).toBe(false) - expect([...target.pageIds].sort()).toEqual(['page-a', 'page-b']) - expect([...target.componentIds]).toEqual(['vc-one']) - expect([...target.deletedPageIds]).toEqual(['page-gone']) - expect([...target.deletedLayoutIds]).toEqual(['layout-gone']) - - mergeDirtyMarks(target, { ...emptyDirtyMarks(), all: true }) - expect(target.all).toBe(true) - // Existing ids survive an all-merge — `all` is a flag, not a reset. - expect([...target.pageIds].sort()).toEqual(['page-a', 'page-b']) - }) - - it('never clears all once set, even when incoming is partial', () => { - const target: DirtyMarks = { ...emptyDirtyMarks(), all: true } - mergeDirtyMarks(target, { ...emptyDirtyMarks(), pageIds: new Set(['page-a']) }) - expect(target.all).toBe(true) - }) -}) - -// --------------------------------------------------------------------------- -// Store integration: the real editor store -// --------------------------------------------------------------------------- - -function freshStore() { - useEditorStore.setState({ - site: null, - activePageId: null, - activeDocument: null, - selectedNodeId: null, - selectedNodeIds: [], - hoveredNodeId: null, - _historyPast: [], - _historyFuture: [], - _historyCoalesceKey: null, - canUndo: false, - canRedo: false, - hasUnsavedChanges: false, - _dirtySave: emptyDirtyMarks(), - } as Parameters[0]) -} - -function dirty(): DirtyMarks { - return useEditorStore.getState()._dirtySave -} - -function loadTwoPageSite() { - useEditorStore.getState().loadSite( - makeSite({ - pages: [ - makePage({ id: 'page-a', slug: 'index', title: 'Home' }), - makePage({ id: 'page-b', slug: 'about', title: 'About' }), - ], - visualComponents: [makeVC({ id: 'vc-card', name: 'Card' })], - }), - ) -} - -describe('editor store dirty-save tracking', () => { - beforeEach(freshStore) - - it('loadSite starts with empty marks', () => { - loadTwoPageSite() - expect(dirty()).toEqual(emptyDirtyMarks()) - }) - - it('updateNodeProps on the active page marks exactly that page', () => { - loadTwoPageSite() - // loadSite activates the home page (slug `index`) — page-a. - expect(useEditorStore.getState().activePageId).toBe('page-a') - - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - - const marks = dirty() - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-a']) - expect(marks.componentIds.size).toBe(0) - }) - - it('editing a VC tree in VC canvas mode marks exactly that component', () => { - loadTwoPageSite() - useEditorStore.getState().setActiveDocument({ kind: 'visualComponent', vcId: 'vc-card' }) - - useEditorStore.getState().updateNodeProps('vc-root', { padding: '8px' }) - - const marks = dirty() - expect(marks.all).toBe(false) - expect(marks.pageIds.size).toBe(0) - expect([...marks.componentIds]).toEqual(['vc-card']) - }) - - it('componentizing a page node marks both the edited page and the new component', () => { - const sourceNode = makeNode({ id: 'source-text', moduleId: 'base.text' }) - useEditorStore.getState().loadSite( - makeSite({ - pages: [ - makePage({ - id: 'page-a', - slug: 'index', - title: 'Home', - nodes: { - root: makeNode({ id: 'root', moduleId: 'base.body', children: ['source-text'] }), - 'source-text': sourceNode, - }, - }), - ], - visualComponents: [], - }), - ) - - useEditorStore.getState().convertNodeToComponent('source-text', 'Saved Card') - - const state = useEditorStore.getState() - const createdComponent = state.site!.visualComponents.find((vc) => vc.name === 'Saved Card') - expect(createdComponent).toBeDefined() - - const marks = dirty() - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-a']) - expect([...marks.componentIds]).toEqual([createdComponent!.id]) - }) - - it('takeDirtySaveSnapshot returns the accumulated marks AND resets the accumulator', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - - const snapshot = useEditorStore.getState().takeDirtySaveSnapshot() - - expect([...snapshot.pageIds]).toEqual(['page-a']) - expect(snapshot.all).toBe(false) - expect(dirty()).toEqual(emptyDirtyMarks()) - - // The snapshot is an independent copy — mutating it cannot poison the store. - snapshot.pageIds.add('page-injected') - expect(dirty().pageIds.size).toBe(0) - }) - - it('undo after a snapshot re-marks the restored page', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - useEditorStore.getState().takeDirtySaveSnapshot() // simulate a successful save - expect(dirty()).toEqual(emptyDirtyMarks()) - - useEditorStore.getState().undo() - - // The undone page differs from what storage now holds — it must re-save. - expect([...dirty().pageIds]).toEqual(['page-a']) - expect(dirty().all).toBe(false) - }) - - it('redo after a snapshot re-marks the replayed page', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - useEditorStore.getState().undo() - useEditorStore.getState().takeDirtySaveSnapshot() - - useEditorStore.getState().redo() - - expect([...dirty().pageIds]).toEqual(['page-a']) - }) - - it('restoreDirtySaveSnapshot merges a failed save back into fresh marks', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - const snapshot = useEditorStore.getState().takeDirtySaveSnapshot() - - // While the (failed) save was in flight, the user edited page-b. - useEditorStore.setState({ activePageId: 'page-b' }) - useEditorStore.getState().updateNodeProps('root', { text: 'other page' }) - expect([...dirty().pageIds]).toEqual(['page-b']) - - useEditorStore.getState().restoreDirtySaveSnapshot(snapshot) - - expect([...dirty().pageIds].sort()).toEqual(['page-a', 'page-b']) - expect(dirty().all).toBe(false) - }) - - it('markAllDirtyForSave sets the conservative full-save flag', () => { - loadTwoPageSite() - useEditorStore.getState().markAllDirtyForSave() - expect(dirty().all).toBe(true) - }) - - it('loadSite resets accumulated marks', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - expect(dirty().pageIds.size).toBe(1) - - loadTwoPageSite() - expect(dirty()).toEqual(emptyDirtyMarks()) - }) - - it('createSite resets marks to all=true — a brand-new site needs a full first save', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - - useEditorStore.getState().createSite('Fresh Site') - - const marks = dirty() - expect(marks.all).toBe(true) - expect(marks.pageIds.size).toBe(0) - expect(marks.componentIds.size).toBe(0) - }) - - it('clearSite resets marks to empty', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - - useEditorStore.getState().clearSite() - - expect(dirty()).toEqual(emptyDirtyMarks()) - }) - - it('addPage marks the created page (and only it)', () => { - loadTwoPageSite() - const newPage = useEditorStore.getState().addPage('Pricing', 'pricing') - - const marks = dirty() - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual([newPage.id]) - expect(marks.componentIds.size).toBe(0) - }) - - it('deletePage records the deleted id — never as a changed page, never as all', () => { - loadTwoPageSite() - // Delete the LAST page so no surviving element is displaced to a new index. - useEditorStore.getState().deletePage('page-b') - - const state = useEditorStore.getState() - expect(state.site!.pages.map((p) => p.id)).toEqual(['page-a']) - // The deleted page must NOT appear as a changed page — its removal ships - // as an explicit deleted-id. - expect(dirty().pageIds.has('page-b')).toBe(false) - expect([...dirty().deletedPageIds]).toEqual(['page-b']) - expect(dirty().all).toBe(false) - }) - - it('a VC delete records the deleted component id without escalating to all', () => { - useEditorStore.getState().loadSite( - makeSite({ - pages: [makePage({ id: 'page-a', slug: 'index' })], - visualComponents: [ - makeVC({ id: 'vc-one', name: 'One' }), - makeVC({ id: 'vc-two', name: 'Two' }), - ], - }), - ) - - // Delete the LAST component — no displaced survivors. - useEditorStore.getState().deleteVisualComponent('vc-two') - - const state = useEditorStore.getState() - expect(state.site!.visualComponents.map((vc) => vc.id)).toEqual(['vc-one']) - expect(dirty().all).toBe(false) - expect(dirty().componentIds.has('vc-two')).toBe(false) - expect([...dirty().deletedComponentIds]).toEqual(['vc-two']) - }) - - it('snapshot netting: delete + undo within one save window ships a write, not a delete', () => { - loadTwoPageSite() - useEditorStore.getState().deletePage('page-b') - expect([...dirty().deletedPageIds]).toEqual(['page-b']) - - useEditorStore.getState().undo() // page-b is back in the store - - const snapshot = useEditorStore.getState().takeDirtySaveSnapshot() - // The row exists again — it must NOT ship as deleted… - expect(snapshot.deletedPageIds.size).toBe(0) - // …and the restored content ships as a plain write. - expect(snapshot.pageIds.has('page-b')).toBe(true) - }) - - it('snapshot netting: a write-mark for a row deleted afterwards is dropped', () => { - loadTwoPageSite() - // Edit page-b, THEN delete it — shipping it as changed would resurrect it. - useEditorStore.setState({ activePageId: 'page-b' }) - useEditorStore.getState().updateNodeProps('root', { text: 'edited' }) - expect(dirty().pageIds.has('page-b')).toBe(true) - - useEditorStore.getState().deletePage('page-b') - - const snapshot = useEditorStore.getState().takeDirtySaveSnapshot() - expect(snapshot.pageIds.has('page-b')).toBe(false) - expect([...snapshot.deletedPageIds]).toEqual(['page-b']) - }) - - it('shell-only mutations (site rename) accumulate no marks', () => { - loadTwoPageSite() - useEditorStore.getState().updateSiteName('Renamed Site') - - expect(useEditorStore.getState().site!.name).toBe('Renamed Site') - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) - expect(dirty()).toEqual(emptyDirtyMarks()) - }) -}) diff --git a/src/__tests__/editor-store/historyCoalescingFold.test.ts b/src/__tests__/editor-store/historyCoalescingFold.test.ts deleted file mode 100644 index 135fa03d7..000000000 --- a/src/__tests__/editor-store/historyCoalescingFold.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * History coalescing fold — per-path patch dedup inside a typing burst. - * - * `commitHistory` folds consecutive same-`coalesceKey` entries into the top - * history entry. The fold must keep AT MOST one inverse and one forward patch - * per touched path (oldest inverse wins, newest forward value wins) instead of - * accumulating 2K patch pairs over a K-keystroke burst — while keeping - * undo/redo results bit-identical: - * - * 1. Burst on an existing prop: undo → original value, redo → final value. - * 2. Burst that CREATES a prop (add-op first keystroke): undo → prop absent, - * redo (from the prop-absent state) → final value. - * 3. Burst touching two paths (the prop + the `site.updatedAt` stamp that - * `runHistoricMutation` appends): both paths revert and replay correctly. - * 4. Size bound: a 100-keystroke burst leaves at most one patch per touched - * path per direction in the top entry. - * 5. Pin the Mutative `apply()` behavior the fold relies on: 'add' and - * 'replace' are interchangeable for plain-object keys. - */ -import { describe, it, expect, beforeEach } from 'bun:test' -import { apply } from 'mutative' -import type { Patches } from 'mutative' -import { useEditorStore } from '@site/store/store' -import '@modules/base/index' - -function freshStore(): void { - useEditorStore.setState({ - site: null, - activePageId: null, - activeDocument: null, - selectedNodeId: null, - selectedNodeIds: [], - hoveredNodeId: null, - _historyPast: [], - _historyFuture: [], - _historyCoalesceKey: null, - canUndo: false, - canRedo: false, - hasUnsavedChanges: false, - } as Parameters[0]) -} - -beforeEach(freshStore) - -/** Create a site with one text node and return its id. */ -function setupTextNode(initialProps: Record = { text: '' }): string { - const site = useEditorStore.getState().createSite('Fold Test') - const rootId = site.pages[0].rootNodeId - return useEditorStore.getState().insertNode('base.text', initialProps, rootId) -} - -function pathKey(path: Patches[number]['path']): string { - return JSON.stringify(path) -} - -describe('history coalescing — fold correctness', () => { - it('burst on an existing prop: undo → original value, redo → final value', () => { - const nodeId = setupTextNode({ text: 'original' }) - - for (const text of ['a', 'ab', 'abc', 'abcd']) { - useEditorStore.getState().updateNodeProps(nodeId, { text }) - } - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('abcd') - - useEditorStore.getState().undo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('original') - - useEditorStore.getState().redo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('abcd') - }) - - it('burst that CREATES a prop: undo → prop absent, redo → final value', () => { - const nodeId = setupTextNode({ text: 'hello' }) - - // `title` does not exist on the node yet — the first keystroke's forward - // patch is 'add'-shaped, and the folded forward patch must stay applicable - // from the post-undo state where the prop is absent. - for (const title of ['t', 'ti', 'tit', 'title']) { - useEditorStore.getState().updateNodeProps(nodeId, { title }) - } - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.title).toBe('title') - - useEditorStore.getState().undo() - const propsAfterUndo = useEditorStore.getState().site!.pages[0].nodes[nodeId].props - expect('title' in propsAfterUndo).toBe(false) - expect(propsAfterUndo.text).toBe('hello') - - useEditorStore.getState().redo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.title).toBe('title') - }) - - it("the folded forward patch for a burst-created prop keeps the oldest 'add' op", () => { - const nodeId = setupTextNode({ text: 'hello' }) - for (const title of ['t', 'ti', 'tit']) { - useEditorStore.getState().updateNodeProps(nodeId, { title }) - } - - const top = useEditorStore.getState()._historyPast.at(-1)! - const titlePatch = top.forward.find((p) => Array.isArray(p.path) && p.path.at(-1) === 'title') - expect(titlePatch).toBeDefined() - expect(titlePatch!.op).toBe('add') - expect(titlePatch!.value).toBe('tit') - }) - - it('burst touching two paths: prop AND site.updatedAt both revert and replay', () => { - const nodeId = setupTextNode({ text: 'start' }) - - // Force a sentinel timestamp so the pre-burst value is distinguishable - // from anything Date.now() can produce during the burst. - const site = useEditorStore.getState().site! - useEditorStore.setState({ site: { ...site, updatedAt: 1000 } }) - - for (const text of ['x', 'xy', 'xyz']) { - useEditorStore.getState().updateNodeProps(nodeId, { text }) - } - const updatedAtAfterBurst = useEditorStore.getState().site!.updatedAt - expect(updatedAtAfterBurst).not.toBe(1000) - - useEditorStore.getState().undo() - expect(useEditorStore.getState().site!.updatedAt).toBe(1000) - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('start') - - useEditorStore.getState().redo() - expect(useEditorStore.getState().site!.updatedAt).toBe(updatedAtAfterBurst) - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('xyz') - }) - - it('undo + redo round-trips a burst bit-identically', () => { - const nodeId = setupTextNode({ text: 'seed' }) - for (let i = 1; i <= 10; i++) { - useEditorStore.getState().updateNodeProps(nodeId, { text: 'seed'.repeat(i) }) - } - - const afterBurst = JSON.stringify(useEditorStore.getState().site) - useEditorStore.getState().undo() - useEditorStore.getState().redo() - expect(JSON.stringify(useEditorStore.getState().site)).toBe(afterBurst) - }) -}) - -describe('history coalescing — fold size bound', () => { - it('a 100-keystroke burst keeps at most one patch per path per direction', () => { - const nodeId = setupTextNode({ text: '' }) - const depthBefore = useEditorStore.getState()._historyPast.length - - for (let i = 1; i <= 100; i++) { - useEditorStore.getState().updateNodeProps(nodeId, { text: 'x'.repeat(i) }) - } - - const past = useEditorStore.getState()._historyPast - // Still exactly one coalesced entry for the whole burst. - expect(past.length).toBe(depthBefore + 1) - - const top = past.at(-1)! - for (const direction of [top.inverse, top.forward]) { - const paths = direction.map((p) => pathKey(p.path)) - // No duplicate paths — the burst touched 2 paths (the prop + updatedAt), - // so each direction holds at most 2 patches, not ~200. - expect(new Set(paths).size).toBe(paths.length) - expect(direction.length).toBeLessThanOrEqual(2) - } - - // The fold must not have broken undo/redo. - useEditorStore.getState().undo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('') - useEditorStore.getState().redo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('x'.repeat(100)) - }) -}) - -describe('mutative apply() op semantics the fold relies on', () => { - it("treats 'add' and 'replace' identically for plain-object keys", () => { - // Verified against mutative@1.3.0 src/apply.ts: for plain objects both ops - // run `base[key] = value`. (They differ ONLY for array indices, where - // 'add' splices — coalescing recipes never patch array tails.) This pin - // fails if a mutative upgrade changes that contract. - const base = { props: { existing: 1 } as Record } - const viaAdd = apply(base, [{ op: 'add', path: ['props', 'k'], value: 'v' }] as Patches) - const viaReplace = apply(base, [{ op: 'replace', path: ['props', 'k'], value: 'v' }] as Patches) - expect(viaAdd).toEqual(viaReplace) - - // 'remove' deletes the key, and deleting an absent key is a no-op — the - // fold may therefore collapse any op sequence involving 'remove' to the - // newest patch wholesale. - const removed = apply(base, [{ op: 'remove', path: ['props', 'absent'] }] as Patches) - expect(removed).toEqual(base) - }) -}) diff --git a/src/__tests__/editor-store/inlineEditSlice.test.ts b/src/__tests__/editor-store/inlineEditSlice.test.ts index 02517eadb..3b5de3109 100644 --- a/src/__tests__/editor-store/inlineEditSlice.test.ts +++ b/src/__tests__/editor-store/inlineEditSlice.test.ts @@ -30,20 +30,15 @@ function nodeText(nodeId: string): unknown { } beforeEach(() => { + // clearSite also resets the collab binding's docs + undo history. + useEditorStore.getState().clearSite() useEditorStore.setState({ - site: null, activePageId: null, activeDocument: null, activeInlineEdit: null, - _historyPast: [], - _historyFuture: [], - _historyCoalesceKey: null, - canUndo: false, - canRedo: false, selectedNodeId: null, selectedNodeIds: [], hoveredNodeId: null, - hasUnsavedChanges: false, }) }) @@ -120,17 +115,17 @@ describe('startInlineEdit', () => { }) describe('applyInlineEditValue — live commit, one undo entry per burst', () => { - it('commits every keystroke live and coalesces the burst into ONE history entry', () => { + it('commits every keystroke live and coalesces the burst into ONE undo step', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().applyInlineEditValue('HelloW') useEditorStore.getState().applyInlineEditValue('HelloWo') useEditorStore.getState().applyInlineEditValue('HelloWorld') - const state = useEditorStore.getState() expect(nodeText(nodeId)).toBe('HelloWorld') - expect(state._historyPast.length).toBe(entriesBefore + 1) - expect(state.activeInlineEdit?.committed).toBe(true) + expect(useEditorStore.getState().activeInlineEdit?.committed).toBe(true) + // ONE undo reverts the whole burst — not one keystroke. + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBe('Hello') }) it('a single undo() reverts the whole burst to the initial value', () => { @@ -145,21 +140,21 @@ describe('applyInlineEditValue — live commit, one undo entry per burst', () => it('does not flip committed when the applied value equals the stored value', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().applyInlineEditValue('Hello') const state = useEditorStore.getState() expect(state.activeInlineEdit?.committed).toBe(false) - expect(state._historyPast.length).toBe(entriesBefore) + // No history entry was pushed: undo reverts the node INSERT, not a + // (nonexistent) equal-value edit. + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBeUndefined() }) it('isolates the session burst from a prior Properties-panel burst on the same prop', () => { const { nodeId } = setupSiteWithTextNode() // Simulate panel typing: same coalesce key the inline session will use. useEditorStore.getState().updateNodeProps(nodeId, { text: 'PanelTyped' }) - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().startInlineEdit(nodeId, 'bp') useEditorStore.getState().applyInlineEditValue('PanelTypedX') - expect(useEditorStore.getState()._historyPast.length).toBe(entriesBefore + 1) // Escape reverts ONLY the inline burst, not the panel typing. useEditorStore.getState().cancelInlineEdit() expect(nodeText(nodeId)).toBe('PanelTyped') @@ -177,13 +172,14 @@ describe('endInlineEdit', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') useEditorStore.getState().applyInlineEditValue('HelloA') - const entriesAfterBurst = useEditorStore.getState()._historyPast.length useEditorStore.getState().endInlineEdit() expect(useEditorStore.getState().activeInlineEdit).toBeNull() expect(nodeText(nodeId)).toBe('HelloA') - // A later edit of the SAME prop starts a fresh undo entry. + // A later edit of the SAME prop starts a fresh undo entry: the first + // undo reverts only it, back to the session's committed value. useEditorStore.getState().updateNodeProps(nodeId, { text: 'HelloB' }) - expect(useEditorStore.getState()._historyPast.length).toBe(entriesAfterBurst + 1) + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBe('HelloA') }) }) @@ -192,23 +188,26 @@ describe('cancelInlineEdit', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') useEditorStore.getState().applyInlineEditValue('Mangled') - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().cancelInlineEdit() const state = useEditorStore.getState() expect(state.activeInlineEdit).toBeNull() expect(nodeText(nodeId)).toBe('Hello') - expect(state._historyPast.length).toBe(entriesBefore - 1) + // The cancel consumed the burst's undo step: the next undo reverts the + // node insert itself. + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBeUndefined() }) it('does NOT undo for an uncommitted session', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().cancelInlineEdit() const state = useEditorStore.getState() expect(state.activeInlineEdit).toBeNull() - expect(state._historyPast.length).toBe(entriesBefore) expect(nodeText(nodeId)).toBe('Hello') + // History untouched: the next undo reverts the node insert. + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBeUndefined() }) }) diff --git a/src/__tests__/editor-store/layoutsSlice.test.ts b/src/__tests__/editor-store/layoutsSlice.test.ts index ea9478827..1c135c416 100644 --- a/src/__tests__/editor-store/layoutsSlice.test.ts +++ b/src/__tests__/editor-store/layoutsSlice.test.ts @@ -3,7 +3,7 @@ * * Covers: * 1. saveNodeAsLayout: captures the subtree + referenced classes onto - * site.layouts (and marks the layout dirty for save). + * site.layouts. * 2. Guards: page root refused; duplicate names throw SavedLayoutNameError. * 3. insertLayout: exact structure restoration with fresh node ids * (paste semantics — props, classIds, child order survive). @@ -66,8 +66,6 @@ describe('layoutsSlice.saveNodeAsLayout', () => { expect(layout.nodes[textId].props.text).toBe('Saved copy') expect(layout.classes[classId]?.name).toBe('layout-style') - // Dirty tracking: the new layout ships on the next incremental save. - expect([...useEditorStore.getState()._dirtySave.layoutIds]).toContain(layoutId!) }) it('refuses to capture the page root', () => { diff --git a/src/__tests__/editor-store/mutateAllPagesAndSite.test.ts b/src/__tests__/editor-store/mutateAllPagesAndSite.test.ts index ae1a47e46..18f0647a8 100644 --- a/src/__tests__/editor-store/mutateAllPagesAndSite.test.ts +++ b/src/__tests__/editor-store/mutateAllPagesAndSite.test.ts @@ -199,8 +199,6 @@ describe('mutateAllPagesAndSite — atomicity', () => { return true }) - const historyBefore = useEditorStore.getState()._historyPast.length - // Now run the four-helper recipe. useEditorStore.getState().mutateAllPagesAndSite((_site, helpers) => { helpers.addPage({ title: 'Added', slug: 'added', nodeFragment: makeFragment() }) @@ -210,8 +208,13 @@ describe('mutateAllPagesAndSite — atomicity', () => { return true }) - const historyAfter = useEditorStore.getState()._historyPast.length - expect(historyAfter - historyBefore).toBe(1) + // Exactly ONE history snapshot: a single undo reverts all four helpers. + useEditorStore.getState().undo() + const site = useEditorStore.getState().site! + expect(site.pages.some((p) => p.slug === 'added')).toBe(false) + expect(Object.values(site.styleRules).some((r) => r.name === 'new-rule')).toBe(false) + expect(site.pages.find((p) => p.id === existingPageId)?.title).toBe('Old') + expect(site.styleRules[existingRuleId]?.styles.color).toBeUndefined() }) it('undo after the four-helper recipe reverts ALL four mutations in one press', () => { diff --git a/src/__tests__/editor-store/nodeInlineStyles.test.ts b/src/__tests__/editor-store/nodeInlineStyles.test.ts index 24d7a6423..f97ea7d37 100644 --- a/src/__tests__/editor-store/nodeInlineStyles.test.ts +++ b/src/__tests__/editor-store/nodeInlineStyles.test.ts @@ -9,8 +9,8 @@ import { useEditorStore } from '@site/store/store' import '@modules/base/index' function freshStore() { + useEditorStore.getState().clearSite() useEditorStore.setState({ - site: null, activePageId: null, selectedNodeId: null, selectedNodeIds: [], @@ -20,11 +20,6 @@ function freshStore() { activeClassId: null, previewClassAssignment: null, propertiesPanel: { collapsed: false, x: 0, y: 0, width: 280 }, - _historyPast: [], - _historyFuture: [], - canUndo: false, - canRedo: false, - hasUnsavedChanges: false, } as Parameters[0]) } @@ -72,25 +67,28 @@ describe('setNodeInlineStyles', () => { it('clearNodeInlineStyles removes the whole inlineStyles field in one step', () => { const id = setup() useEditorStore.getState().setNodeInlineStyles(id, { color: 'red', display: 'flex' }) - const historyBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().clearNodeInlineStyles(id) expect(nodeInline(id)).toBeUndefined() - expect(useEditorStore.getState()._historyPast.length).toBe(historyBefore + 1) + // ONE undo restores the full style bag — the clear was a single step. + useEditorStore.getState().undo() + expect(nodeInline(id)).toEqual({ color: 'red', display: 'flex' }) }) it('clearNodeInlineStyles is a no-op (no history) when there are no inline styles', () => { const id = setup() - const historyBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().clearNodeInlineStyles(id) - expect(useEditorStore.getState()._historyPast.length).toBe(historyBefore) + // No entry was pushed: the next undo reverts the node INSERT itself. + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes[id]).toBeUndefined() }) it('a no-op patch (removing an absent key) records no change', () => { const id = setup() - const historyBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().removeNodeInlineStyleProperty(id, 'color') - expect(useEditorStore.getState()._historyPast.length).toBe(historyBefore) + // No entry was pushed: the next undo reverts the node INSERT itself. + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes[id]).toBeUndefined() }) it('setInlineStyleEditing(true) clears the active class (mutually exclusive)', () => { @@ -129,7 +127,6 @@ describe('setNodeInlineStyles', () => { const id = setup() // Simulate "display: flex" then setting a flex sub-property. useEditorStore.getState().setNodeInlineStyles(id, { display: 'flex', alignItems: 'center' }) - const historyBefore = useEditorStore.getState()._historyPast.length // Clearing display must prune the now-orphaned flex property too — one step. useEditorStore.getState().setNodeInlineStyles(id, { @@ -140,6 +137,8 @@ describe('setNodeInlineStyles', () => { }) expect(nodeInline(id)).toBeUndefined() - expect(useEditorStore.getState()._historyPast.length).toBe(historyBefore + 1) + // ONE undo restores both pruned properties — the clear was a single step. + useEditorStore.getState().undo() + expect(nodeInline(id)).toEqual({ display: 'flex', alignItems: 'center' }) }) }) diff --git a/src/__tests__/editor-store/siteRuntimeSlice.test.ts b/src/__tests__/editor-store/siteRuntimeSlice.test.ts index 09f428acf..cc8bd882c 100644 --- a/src/__tests__/editor-store/siteRuntimeSlice.test.ts +++ b/src/__tests__/editor-store/siteRuntimeSlice.test.ts @@ -13,7 +13,6 @@ function resetStore() { selectedNodeId: null, selectedNodeIds: [], hoveredNodeId: null, - hasUnsavedChanges: false, }) } @@ -51,7 +50,7 @@ describe('site runtime store actions', () => { expect(useEditorStore.getState().site?.runtime).toEqual(useEditorStore.getState().siteRuntime) }) - it('patches script runtime settings, marks the project dirty, and participates in undo', () => { + it('patches script runtime settings and participates in undo', () => { const store = useEditorStore.getState() store.createSite('Runtime Site') const fileId = useEditorStore.getState().createFile('src/scripts/confetti.ts', 'script') @@ -60,7 +59,6 @@ describe('site runtime store actions', () => { _historyFuture: [], canUndo: false, canRedo: false, - hasUnsavedChanges: false, }) useEditorStore.getState().patchScriptRuntimeConfig(fileId, { @@ -70,7 +68,6 @@ describe('site runtime store actions', () => { }) const afterPatch = useEditorStore.getState() - expect(afterPatch.hasUnsavedChanges).toBe(true) expect(afterPatch.canUndo).toBe(true) expect(afterPatch.siteRuntime.scripts[fileId]).toEqual({ ...DEFAULT_SCRIPT_RUNTIME_CONFIG, @@ -107,19 +104,17 @@ describe('site runtime store actions', () => { expect(useEditorStore.getState().site?.runtime?.scripts[fileId]).toBeUndefined() }) - it('marks dependency manifest edits dirty and makes them undoable', () => { + it('makes dependency manifest edits undoable', () => { useEditorStore.getState().createSite('Runtime Site') useEditorStore.setState({ _historyPast: [], _historyFuture: [], canUndo: false, canRedo: false, - hasUnsavedChanges: false, }) useEditorStore.getState().setDependency('canvas-confetti', '^1.9.3') - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) expect(useEditorStore.getState().canUndo).toBe(true) expect(useEditorStore.getState().packageJson.dependencies['canvas-confetti']).toBe('^1.9.3') expect(useEditorStore.getState().site?.packageJson?.dependencies['canvas-confetti']).toBe('^1.9.3') diff --git a/src/__tests__/editor-store/styleRuleSlice.test.ts b/src/__tests__/editor-store/styleRuleSlice.test.ts index c18d877f6..f39a56acc 100644 --- a/src/__tests__/editor-store/styleRuleSlice.test.ts +++ b/src/__tests__/editor-store/styleRuleSlice.test.ts @@ -157,17 +157,20 @@ describe('styleRuleSlice.clearClassStyleProperties', () => { getStore().updateClassStyles(cls.id, { display: 'flex', alignItems: 'center', color: 'red' }) getStore().setClassContextStyles(cls.id, 'mobile', { gap: '8px' }) - const historyBefore = historyLength() getStore().clearClassStyleProperties(cls.id, ['display', 'alignItems', 'gap']) - const rule = useEditorStore.getState().site!.styleRules[cls.id] + let rule = useEditorStore.getState().site!.styleRules[cls.id] // Pruned everywhere; the unrelated `color` survives. expect('display' in rule.styles).toBe(false) expect('alignItems' in rule.styles).toBe(false) expect(rule.styles.color).toBe('red') expect('gap' in (rule.contextStyles.mobile ?? {})).toBe(false) - // Single undo step. - expect(historyLength()).toBe(historyBefore + 1) + // Single undo step: ONE undo restores base + context properties together. + getStore().undo() + rule = useEditorStore.getState().site!.styleRules[cls.id] + expect(rule.styles.display).toBe('flex') + expect(rule.styles.alignItems).toBe('center') + expect(rule.contextStyles.mobile?.gap).toBe('8px') }) it('is a no-op (no history) when none of the properties are set', () => { diff --git a/src/__tests__/editor-store/undo-redo.test.ts b/src/__tests__/editor-store/undo-redo.test.ts index 36a3b387c..a9007b58d 100644 --- a/src/__tests__/editor-store/undo-redo.test.ts +++ b/src/__tests__/editor-store/undo-redo.test.ts @@ -1,12 +1,16 @@ /** - * Undo/Redo store tests — verifies J4 requirements: - * - undo/redo operates only on site state - * - canUndo / canRedo flags stay accurate - * - history is capped at MAX_HISTORY (50) - * - undo then modify creates a new branch (future is cleared) + * Undo/Redo store tests. + * + * History lives in per-doc Y.UndoManagers inside the collab binding + * (slices/site/collabBinding.ts) — these tests pin down the OBSERVABLE + * contract through the store: undo/redo operates only on site state, + * canUndo/canRedo stay accurate, coalescing folds a typing burst into one + * step, and undo-then-modify clears the redo branch. */ import { describe, it, expect, beforeEach } from 'bun:test' import { useEditorStore } from '@site/store/store' +// `startInlineEdit` resolves its `inlineTextEdit` spec from the module registry. +import '@modules/base/text' // Helper: get fresh store state (Zustand is module-singleton — reset between tests) function getStore() { @@ -14,17 +18,13 @@ function getStore() { } beforeEach(() => { - // Reset store to a clean slate before each test + // Reset store to a clean slate before each test. clearSite also resets the + // collab binding's docs + undo managers. + useEditorStore.getState().clearSite() useEditorStore.setState({ - site: null, - _historyPast: [], - _historyFuture: [], - canUndo: false, - canRedo: false, selectedNodeId: null, selectedNodeIds: [], hoveredNodeId: null, - hasUnsavedChanges: false, }) }) @@ -101,6 +101,47 @@ describe('Undo / Redo — basic lifecycle', () => { expect(useEditorStore.getState().site!.pages[0].nodes[nextId]).toBeDefined() }) + it('undo keeps surviving selections and only drops the reverted node', () => { + const site = getStore().createSite('Test SiteDocument') + const rootId = site.pages[0].rootNodeId + const survivorId = useEditorStore.getState().insertNode('base.text', {}, rootId) + const revertedId = useEditorStore.getState().insertNode('base.text', {}, rootId) + + useEditorStore.getState().selectNode(survivorId) + useEditorStore.getState().addToSelection(revertedId) + expect(useEditorStore.getState().selectedNodeIds).toEqual([survivorId, revertedId]) + + // Reverts only the second insertion. + useEditorStore.getState().undo() + + const afterUndo = useEditorStore.getState() + expect(afterUndo.site!.pages[0].nodes[revertedId]).toBeUndefined() + expect(afterUndo.site!.pages[0].nodes[survivorId]).toBeDefined() + // The survivor keeps its selection; the anchor re-syncs to it rather than + // the whole multi-selection being cleared. + expect(afterUndo.selectedNodeIds).toEqual([survivorId]) + expect(afterUndo.selectedNodeId).toBe(survivorId) + }) + + it('undo closes an inline-edit session on the node it reverts', () => { + const site = getStore().createSite('Test SiteDocument') + const rootId = site.pages[0].rootNodeId + const breakpointId = site.breakpoints[0]!.id + const insertedId = useEditorStore.getState().insertNode('base.text', {}, rootId) + + useEditorStore.getState().startInlineEdit(insertedId, breakpointId) + expect(useEditorStore.getState().activeInlineEdit?.nodeId).toBe(insertedId) + + useEditorStore.getState().undo() + + const afterUndo = useEditorStore.getState() + expect(afterUndo.site!.pages[0].nodes[insertedId]).toBeUndefined() + // A session pointing at a node that no longer exists must not survive — + // it is not necessarily part of the selection, so it is pruned by + // tree-membership. + expect(afterUndo.activeInlineEdit).toBeNull() + }) + it('redo prunes selection when replaying a deletion', () => { const site = getStore().createSite('Test SiteDocument') const rootId = site.pages[0].rootNodeId @@ -148,7 +189,6 @@ describe('Undo / Redo — basic lifecycle', () => { useEditorStore.getState().insertNode('base.text', {}, rootId) expect(useEditorStore.getState().canRedo).toBe(false) - expect(useEditorStore.getState()._historyFuture).toHaveLength(0) }) it('multiple mutations are each individually undoable', () => { @@ -193,8 +233,6 @@ describe('Undo / Redo — basic lifecycle', () => { useEditorStore.getState().createSite('New SiteDocument') expect(useEditorStore.getState().canUndo).toBe(false) expect(useEditorStore.getState().canRedo).toBe(false) - expect(useEditorStore.getState()._historyPast).toHaveLength(0) - expect(useEditorStore.getState()._historyFuture).toHaveLength(0) }) it('canvas/UI state (zoom, panX) is not affected by undo', () => { @@ -223,31 +261,39 @@ describe('Undo / Redo — input coalescing', () => { it('coalesces consecutive same-prop edits into one undo entry', () => { const { nodeId } = setupTextNode() - const depthAfterInsert = useEditorStore.getState()._historyPast.length // Simulate per-keystroke typing on a single prop. for (const text of ['H', 'He', 'Hel', 'Hell', 'Hello']) { useEditorStore.getState().updateNodeProps(nodeId, { text }) } - - // The whole typing burst added exactly ONE history entry, not five. - expect(useEditorStore.getState()._historyPast.length).toBe(depthAfterInsert + 1) expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('Hello') - // A single undo reverts the entire burst back to the pre-typing value. + // A single undo reverts the entire burst back to the pre-typing value — + // NOT one keystroke — and the node insert stays applied. useEditorStore.getState().undo() expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('') + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId]).toBeDefined() }) it('does not coalesce edits to different props', () => { const { nodeId } = setupTextNode() - const depthAfterInsert = useEditorStore.getState()._historyPast.length + // Capture the module-default tag value BEFORE the edit — different test + // files may register base.text with different default props. + const tagBefore = useEditorStore.getState().site!.pages[0].nodes[nodeId].props.tag useEditorStore.getState().updateNodeProps(nodeId, { text: 'hi' }) useEditorStore.getState().updateNodeProps(nodeId, { tag: 'h1' }) - // Different prop keys → two distinct undo entries. - expect(useEditorStore.getState()._historyPast.length).toBe(depthAfterInsert + 2) + // Different prop keys → two distinct undo entries: the first undo + // reverts ONLY the tag edit, the text edit survives it. + useEditorStore.getState().undo() + let node = useEditorStore.getState().site!.pages[0].nodes[nodeId] + expect(node.props.tag).toBe(tagBefore) + expect(node.props.text).toBe('hi') + + useEditorStore.getState().undo() + node = useEditorStore.getState().site!.pages[0].nodes[nodeId] + expect(node.props.text).toBe('') }) it('breaks the burst after undo so the next edit is a fresh entry', () => { @@ -256,11 +302,16 @@ describe('Undo / Redo — input coalescing', () => { useEditorStore.getState().updateNodeProps(nodeId, { text: 'a' }) useEditorStore.getState().updateNodeProps(nodeId, { text: 'ab' }) useEditorStore.getState().undo() // back to '' - const depthAfterUndo = useEditorStore.getState()._historyPast.length + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('') - // Typing again must NOT fold into the undone burst. + // Typing again must NOT fold into the undone burst: it forms a fresh + // entry whose undo returns to '' (the post-undo value), and the node + // insert stays applied. useEditorStore.getState().updateNodeProps(nodeId, { text: 'x' }) - expect(useEditorStore.getState()._historyPast.length).toBe(depthAfterUndo + 1) + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('x') + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('') + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId]).toBeDefined() }) it('a non-coalescing mutation ends the burst', () => { @@ -271,10 +322,12 @@ describe('Undo / Redo — input coalescing', () => { useEditorStore.getState().updateNodeProps(nodeId, { text: 'a' }) // Structural mutation in between resets the coalescing key. useEditorStore.getState().insertNode('base.text', { text: '' }, rootId) - const depth = useEditorStore.getState()._historyPast.length - useEditorStore.getState().updateNodeProps(nodeId, { text: 'ab' }) - expect(useEditorStore.getState()._historyPast.length).toBe(depth + 1) + + // 'ab' formed its OWN entry (no folding across the structural break): + // the first undo reverts only it, back to 'a' — not to ''. + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('a') }) it('redo replays a coalesced burst back to its final value', () => { @@ -323,10 +376,11 @@ describe('Undo / Redo — patch correctness', () => { const b = useEditorStore.getState().insertNode('base.container', {}, rootId) useEditorStore.getState().moveNode(a, b, 0) - const afterMove = JSON.stringify(useEditorStore.getState().site!.pages[0].nodes) + const afterMove = structuredClone(useEditorStore.getState().site!.pages[0].nodes) useEditorStore.getState().undo() useEditorStore.getState().redo() - const afterRoundTrip = JSON.stringify(useEditorStore.getState().site!.pages[0].nodes) - expect(afterRoundTrip).toBe(afterMove) + // Deep equality, not string equality — the projection rebuilds node + // objects from the Y maps, so key ORDER may differ; content must not. + expect(useEditorStore.getState().site!.pages[0].nodes).toEqual(afterMove) }) }) diff --git a/src/__tests__/editor-store/visualComponentsMutationContract.test.ts b/src/__tests__/editor-store/visualComponentsMutationContract.test.ts index 15603a116..f32924d7f 100644 --- a/src/__tests__/editor-store/visualComponentsMutationContract.test.ts +++ b/src/__tests__/editor-store/visualComponentsMutationContract.test.ts @@ -3,27 +3,23 @@ * * Visual Components live inside the assembled SiteDocument, so every action * that mutates them must use the same document mutation contract as page/tree - * actions: snapshot undo history, mark the document dirty, and allow undo to - * restore the previous SiteDocument. + * actions: capture an undo step (via the collab binding's per-doc + * Y.UndoManagers) and allow undo to restore the previous SiteDocument. */ import { beforeEach, describe, expect, it } from 'bun:test' import { useEditorStore } from '@site/store/store' +import { collabClearHistory } from '@site/store/slices/site/collabBinding' import { makeNode, makePage, makeSite, makeVC, makeVCNode } from '../fixtures' function freshStore() { + useEditorStore.getState().clearSite() useEditorStore.setState({ - site: null, activePageId: null, activeDocument: null, selectedNodeId: null, selectedNodeIds: [], hoveredNodeId: null, - _historyPast: [], - _historyFuture: [], - canUndo: false, - canRedo: false, - hasUnsavedChanges: false, - } as Parameters[0]) + }) } function loadSiteWithCardVc() { @@ -45,18 +41,15 @@ function loadSiteWithCardVc() { } function expectMutationContract(action: () => void, assertChanged: () => void): void { - expect(useEditorStore.getState().hasUnsavedChanges).toBe(false) expect(useEditorStore.getState().canUndo).toBe(false) action() assertChanged() - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) expect(useEditorStore.getState().canUndo).toBe(true) useEditorStore.getState().undo() - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) expect(useEditorStore.getState().canRedo).toBe(true) } @@ -145,13 +138,7 @@ describe('Visual Component actions use the SiteDocument mutation contract', () = expect(useEditorStore.getState().site!.visualComponents[0].tree.nodes['vc-root'].propBindings).toBeUndefined() useEditorStore.getState().setNodePropBinding('vc-root', 'text', 'param-title') - useEditorStore.setState({ - hasUnsavedChanges: false, - _historyPast: [], - _historyFuture: [], - canUndo: false, - canRedo: false, - } as Parameters[0]) + collabClearHistory() expectMutationContract( () => { diff --git a/src/__tests__/layout/editorLayoutPersistence.test.tsx b/src/__tests__/layout/editorLayoutPersistence.test.tsx index 1201d3ff1..b8bf260b6 100644 --- a/src/__tests__/layout/editorLayoutPersistence.test.tsx +++ b/src/__tests__/layout/editorLayoutPersistence.test.tsx @@ -102,6 +102,7 @@ function resetStore() { propertiesPanel: { collapsed: false, x: 0, y: 0, width: 360 }, propertiesPanelMode: 'docked', leftSidebarWidth: 320, + leftSidebarMode: 'docked', focusedPanel: 'canvas', explorerPanelOpen: true, explorerPanelTab: 'layers', @@ -338,7 +339,9 @@ describe('AdminCanvasLayout — persisted panel layout', () => { rightWidth: 390, rightOpen: true, propertiesPanelMode: 'floating', + leftSidebarMode: 'floating', activeLeftPanel: 'explorer', + agentPanelOpen: true, explorerPanelTab: 'code', codeEditorPanelOpen: true, activeEditorFileId: 'file-1', @@ -356,6 +359,7 @@ describe('AdminCanvasLayout — persisted panel layout', () => { expect(state.explorerPanelTab).toBe('code') expect(state.propertiesPanel.collapsed).toBe(false) expect(state.propertiesPanelMode).toBe('floating') + expect(state.leftSidebarMode).toBe('floating') expect(state.propertiesPanel.width).toBe(390) expect(state.leftSidebarWidth).toBe(410) expect(state.codeEditorPanelOpen).toBe(true) @@ -363,7 +367,7 @@ describe('AdminCanvasLayout — persisted panel layout', () => { expect(state.selectorsPanelOpen).toBe(false) expect(state.frameworkPanelOpen).toBe(false) expect(state.dependenciesPanelOpen).toBe(false) - expect(state.isAgentOpen).toBe(false) + expect(state.isAgentOpen).toBe(true) }, { timeout: 150 }) }) }) @@ -531,11 +535,33 @@ describe('AdminCanvasLayout — permanent panel rail', () => { fireEvent.click(within(rail).getByRole('button', { name: /open ai assistant panel/i })) expect(sidebar.getAttribute('data-expanded')).toBe('true') - expect(sidebar.getAttribute('data-active-panel')).toBe('agent') + expect(sidebar.getAttribute('data-active-panel')).toBe('dependencies') expect(sidebar.getAttribute('style')).toContain('--left-sidebar-panel-width: 320px') expect(useEditorStore.getState().isAgentOpen).toBe(true) - expect(useEditorStore.getState().dependenciesPanelOpen).toBe(false) + expect(useEditorStore.getState().dependenciesPanelOpen).toBe(true) expect(useEditorStore.getState().explorerPanelOpen).toBe(false) + const agentPanel = within(sidebar).getByTestId('agent-panel').closest('[data-panel]') + expect(agentPanel).not.toBeNull() + + const agentResizeHandle = within(agentPanel as HTMLElement).getByRole('separator', { + name: /resize ai assistant panel/i, + }) + fireEvent.keyDown(agentResizeHandle, { key: 'ArrowRight' }) + fireEvent.keyDown(agentResizeHandle, { key: 'ArrowDown' }) + + await waitFor(() => { + expect((agentPanel as HTMLElement).style.getPropertyValue('--panel-w')).toBe('330px') + expect((agentPanel as HTMLElement).style.getPropertyValue('--panel-h')).toBe('490px') + const stored = JSON.parse(localStorage.getItem(LAYOUT_STORAGE_KEY) ?? '{}') + expect(stored.panelSizes?.agent).toEqual({ width: 330, height: 490 }) + }, { timeout: 150 }) + + fireEvent.click(within(rail).getByRole('button', { name: /open explorer panel/i })) + + expect(sidebar.getAttribute('data-active-panel')).toBe('explorer') + expect(useEditorStore.getState().explorerPanelOpen).toBe(true) + expect(useEditorStore.getState().isAgentOpen).toBe(true) + expect(within(sidebar).getByTestId('explorer-panel')).toBeDefined() expect(within(sidebar).getByTestId('agent-panel')).toBeDefined() }) @@ -564,6 +590,18 @@ describe('AdminCanvasLayout — permanent panel rail', () => { const floatingPanel = screen.getByTestId('properties-panel') expect(floatingPanel.getAttribute('data-variant')).toBe('floating') + const propertiesResizeHandle = within(floatingPanel).getByRole('separator', { + name: /resize properties panel/i, + }) + fireEvent.keyDown(propertiesResizeHandle, { key: 'ArrowRight' }) + fireEvent.keyDown(propertiesResizeHandle, { key: 'ArrowDown', shiftKey: true }) + + await waitFor(() => { + expect(floatingPanel.style.getPropertyValue('--panel-w')).toBe('370px') + expect(floatingPanel.style.getPropertyValue('--panel-h')).toBe('720px') + const stored = JSON.parse(localStorage.getItem(LAYOUT_STORAGE_KEY) ?? '{}') + expect(stored.panelSizes?.properties).toEqual({ width: 370, height: 720 }) + }, { timeout: 150 }) fireEvent.click(within(floatingPanel).getByRole('button', { name: /dock properties panel/i })) @@ -574,6 +612,56 @@ describe('AdminCanvasLayout — permanent panel rail', () => { }, { timeout: 150 }) }) + it('can unpin hosted left-rail panels and keeps switching in the floating host', async () => { + renderEditorLayout() + + const sidebar = await screen.findByTestId('left-sidebar') + const rail = within(sidebar).getByRole('navigation', { name: /panel dock/i }) + const panelSlot = within(sidebar).getByTestId('left-sidebar-panel-slot') + + fireEvent.click(within(sidebar).getByRole('button', { name: /unpin explorer panel/i })) + + await waitFor(() => { + expect(useEditorStore.getState().leftSidebarMode).toBe('floating') + expect(sidebar.getAttribute('data-expanded')).toBe('false') + expect(panelSlot.getAttribute('data-mode')).toBe('floating') + }, { timeout: 150 }) + + const explorerResizeHandle = within(panelSlot).getByRole('separator', { + name: /resize explorer panel/i, + }) + fireEvent.keyDown(explorerResizeHandle, { key: 'ArrowRight' }) + fireEvent.keyDown(explorerResizeHandle, { key: 'ArrowUp' }) + + await waitFor(() => { + expect(panelSlot.style.getPropertyValue('--panel-w')).toBe('330px') + expect(panelSlot.style.getPropertyValue('--panel-h')).toBe('510px') + const stored = JSON.parse(localStorage.getItem(LAYOUT_STORAGE_KEY) ?? '{}') + expect(stored.panelSizes?.site).toEqual({ width: 330, height: 510 }) + }, { timeout: 150 }) + + const railTargets = [ + ['selectors', 'selectors-panel'], + ['framework', 'framework-panel'], + ['dependencies', 'dependencies-panel'], + ] as const + + for (const [label, testId] of railTargets) { + fireEvent.click(within(rail).getByRole('button', { name: new RegExp(`open ${label} panel`, 'i') })) + expect(within(panelSlot).getByTestId(testId)).toBeDefined() + expect(within(panelSlot).getByRole('button', { name: new RegExp(`dock ${label} panel`, 'i') })).toBeDefined() + expect(sidebar.getAttribute('data-expanded')).toBe('false') + } + + fireEvent.click(within(panelSlot).getByRole('button', { name: /dock dependencies panel/i })) + + await waitFor(() => { + expect(useEditorStore.getState().leftSidebarMode).toBe('docked') + expect(sidebar.getAttribute('data-expanded')).toBe('true') + expect(panelSlot.getAttribute('data-mode')).toBe('docked') + }, { timeout: 150 }) + }) + it('marks the canvas stage while the right sidebar is open', async () => { loadSiteWithSelectedHeading() renderEditorLayout() diff --git a/src/__tests__/layout/floatingWindowPosition.test.ts b/src/__tests__/layout/floatingWindowPosition.test.ts index 5018315b5..c4df3520c 100644 --- a/src/__tests__/layout/floatingWindowPosition.test.ts +++ b/src/__tests__/layout/floatingWindowPosition.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test' -import { clampFloatingPanelPosition } from '@admin/shared/FloatingWindow' +import { + clampFloatingPanelPosition, + clampFloatingPanelSize, +} from '@admin/shared/FloatingWindow' describe('floating window viewport clamp', () => { it('keeps a reachable header strip after a far-left drag', () => { @@ -16,3 +19,33 @@ describe('floating window viewport clamp', () => { )).toEqual({ x: -470, y: 450 }) }) }) + +describe('floating window size clamp', () => { + it('respects minimum usable dimensions', () => { + expect(clampFloatingPanelSize( + { width: 80, height: 40 }, + { + viewportWidth: 1_440, + viewportHeight: 900, + panelLeft: 400, + panelTop: 160, + minWidth: 280, + minHeight: 240, + }, + )).toEqual({ width: 280, height: 240 }) + }) + + it('keeps a resized panel inside the available bottom-right viewport', () => { + expect(clampFloatingPanelSize( + { width: 2_000, height: 1_200 }, + { + viewportWidth: 1_440, + viewportHeight: 900, + panelLeft: 400, + panelTop: 160, + minWidth: 280, + minHeight: 240, + }, + )).toEqual({ width: 1_024, height: 724 }) + }) +}) diff --git a/src/__tests__/layout/workspaceLayoutStorage.test.ts b/src/__tests__/layout/workspaceLayoutStorage.test.ts index 682cdce97..f85ea72ad 100644 --- a/src/__tests__/layout/workspaceLayoutStorage.test.ts +++ b/src/__tests__/layout/workspaceLayoutStorage.test.ts @@ -3,9 +3,11 @@ import { EDITOR_LAYOUT_STORAGE_KEY, readEditorLayout, readStoredPanelPosition, + readStoredPanelSize, readWorkspaceLayout, workspaceFromPathname, writeStoredPanelPosition, + writeStoredPanelSize, writeWorkspaceLayout, } from '@admin/state/workspaceLayoutStorage' @@ -37,6 +39,28 @@ describe('workspaceLayoutStorage — floating panel positions', () => { }) }) +describe('workspaceLayoutStorage — floating panel sizes', () => { + it('stores floating panel dimensions without clobbering its position', () => { + writeStoredPanelPosition('site', { x: 420, y: 140 }) + writeStoredPanelSize('site', { width: 540, height: 460 }) + + expect(readStoredPanelPosition('site')).toEqual({ x: 420, y: 140 }) + expect(readStoredPanelSize('site')).toEqual({ width: 540, height: 460 }) + expect(readEditorLayout()?.panelSizes?.site).toEqual({ width: 540, height: 460 }) + }) + + it('preserves workspace layout state when updating floating dimensions', () => { + writeWorkspaceLayout('site', { leftWidth: 380, leftSidebarMode: 'floating' }) + writeStoredPanelSize('properties', { width: 480, height: 620 }) + + expect(readWorkspaceLayout('site')).toMatchObject({ + leftWidth: 380, + leftSidebarMode: 'floating', + }) + expect(readStoredPanelSize('properties')).toEqual({ width: 480, height: 620 }) + }) +}) + describe('workspaceLayoutStorage — per-workspace layouts', () => { it('namespaces sidebar state by workspace', () => { writeWorkspaceLayout('site', { leftWidth: 400, rightOpen: true, rightWidth: 380 }) diff --git a/src/__tests__/loops/entryField.test.ts b/src/__tests__/loops/entryField.test.ts new file mode 100644 index 000000000..eac9ce622 --- /dev/null +++ b/src/__tests__/loops/entryField.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test' +import { resolveEntryFieldItems } from '@core/loops' + +describe('current-entry-field loop items', () => { + it('keeps primitive array values in authored order', () => { + const result = resolveEntryFieldItems(['first', 'second'], { + direction: 'asc', + limit: 10, + }) + + expect(result.totalItems).toBe(2) + expect(result.items.map((item) => item.fields.value)).toEqual(['first', 'second']) + expect(result.items.map((item) => item.fields.index)).toEqual([0, 1]) + }) + + it('projects object members while preserving the original value', () => { + const object = { id: 'shot-1', caption: 'Evening light' } + const result = resolveEntryFieldItems([object]) + + expect(result.items[0]?.id).toBe('shot-1') + expect(result.items[0]?.fields.caption).toBe('Evening light') + expect(result.items[0]?.fields.value).toEqual(object) + }) + + it('enriches media ids with renderable media fields', () => { + const result = resolveEntryFieldItems(['media-1'], { + mediaByReference: new Map([ + ['media-1', { + publicPath: '/uploads/portfolio-1.webp', + mimeType: 'image/webp', + width: 1600, + height: 1067, + altText: 'Portrait in a studio', + }], + ]), + }) + + expect(result.items[0]?.fields).toMatchObject({ + id: 'media-1', + value: '/uploads/portfolio-1.webp', + src: '/uploads/portfolio-1.webp', + url: '/uploads/portfolio-1.webp', + path: '/uploads/portfolio-1.webp', + altText: 'Portrait in a studio', + mimeType: 'image/webp', + width: 1600, + height: 1067, + }) + }) + + it('returns an empty result for non-array fields', () => { + expect(resolveEntryFieldItems('not-an-array')).toEqual({ + items: [], + totalItems: 0, + }) + }) +}) diff --git a/src/__tests__/loops/visitorCurrentSource.test.ts b/src/__tests__/loops/visitorCurrentSource.test.ts new file mode 100644 index 000000000..f6b654532 --- /dev/null +++ b/src/__tests__/loops/visitorCurrentSource.test.ts @@ -0,0 +1,113 @@ +/** + * Unit tests for the `visitor.current` loop source — the per-visitor + * personalisation source (Use-case A of the visitor-data framework). + * + * Mirrors the static-shape + fetch style of `dataRowsSource.test.ts`, but + * also exercises `fetch()` since `VisitorCurrentSource.fetch()` is pure: it + * never touches `ctx.db`, so no `createTestDb()` is required. The whole + * behaviour is a pure projection of `ctx.visitor`. + * + * The load-bearing contract pinned here: + * - the source is `perVisitor: true` (cookie-derived, never baked), + * - anonymous requests (no `ctx.visitor`) render nothing, + * - a logged-in visitor resolves to exactly one item carrying their + * identity + every custom profile field (spread dynamically), + * - identity is derived SOLELY from `ctx.visitor` — junk in `ctx.request` + * / `ctx.filters` is ignored entirely (IDOR-safe by construction). + */ +import { describe, expect, it } from 'bun:test' +import { VisitorCurrentSource } from '@core/loops/sources/visitorCurrent' + +/** + * Build a minimal `SourceFetchContext`-shaped object. `fetch()` reads only + * `ctx.visitor`, so the other keys are inert — cast `as any` to satisfy the + * type without importing internal helpers, matching how the loop test suite + * constructs ctx literals. + */ +function makeCtx(visitor: unknown | undefined): any { + return { + db: undefined, + site: {}, + filters: {}, + orderBy: 'createdAt', + direction: 'desc', + limit: 50, + offset: 0, + visitor, + } +} + +describe('visitor.current loop source', () => { + it('declares its id and per-visitor classification', () => { + expect(VisitorCurrentSource.id).toBe('visitor.current') + expect(VisitorCurrentSource.perVisitor).toBe(true) + }) + + it('declares the core identity fields', () => { + const fieldIds = VisitorCurrentSource.fields.map((field) => field.id) + expect(fieldIds).toContain('id') + expect(fieldIds).toContain('displayName') + expect(fieldIds).toContain('email') + expect(fieldIds).toContain('roleName') + }) + + it('renders nothing for an anonymous request (no ctx.visitor)', async () => { + const result = await VisitorCurrentSource.fetch(makeCtx(undefined)) + expect(result).toEqual({ items: [], totalItems: 0 }) + }) + + it('emits exactly one item projecting the visitor identity + custom profile fields', async () => { + const visitor = { + id: 'v_123', + displayName: 'Ada Visitor', + email: 'ada@example.com', + roleName: 'member', + profileFields: { schoolName: 'Oasis', grade: 'Y2' }, + } + const result = await VisitorCurrentSource.fetch(makeCtx(visitor)) + + expect(result.totalItems).toBe(1) + expect(result.items).toHaveLength(1) + + const item = result.items[0]! + // Identity + expect(item.id).toBe('v_123') + expect(item.fields['id']).toBe('v_123') + expect(item.fields['displayName']).toBe('Ada Visitor') + expect(item.fields['email']).toBe('ada@example.com') + expect(item.fields['roleName']).toBe('member') + // Custom profile fields spread dynamically (schoolName binding resolves). + expect(item.fields['schoolName']).toBe('Oasis') + expect(item.fields['grade']).toBe('Y2') + }) + + it('ignores ctx.request and ctx.filters entirely (identity is cookie-derived)', async () => { + const visitor = { + id: 'v_solo', + displayName: 'Solo', + email: 'solo@example.com', + roleName: null, + profileFields: {}, + } + // Junk request + filters that, if trusted, would change the output. + const ctx = makeCtx(visitor) + ctx.request = { + query: { id: 'attacker', displayName: 'Attacker' }, + path: '/impersonate', + slug: 'forged', + cookies: { visitor_session: 'forged-token' }, + } + ctx.filters = { id: 'forged-id', schoolName: 'Tampered' } + + const result = await VisitorCurrentSource.fetch(ctx) + + expect(result.totalItems).toBe(1) + const item = result.items[0]! + // Output reflects ONLY ctx.visitor — request/filters never leak in. + expect(item.id).toBe('v_solo') + expect(item.fields['id']).toBe('v_solo') + expect(item.fields['displayName']).toBe('Solo') + expect(item.fields['email']).toBe('solo@example.com') + expect(item.fields['schoolName']).toBeUndefined() + }) +}) diff --git a/src/__tests__/loops/visitorOwnedRowsSource.test.ts b/src/__tests__/loops/visitorOwnedRowsSource.test.ts new file mode 100644 index 000000000..5113bf06a --- /dev/null +++ b/src/__tests__/loops/visitorOwnedRowsSource.test.ts @@ -0,0 +1,265 @@ +/** + * Integration tests for the `visitor.owned-rows` loop source — the per-visitor + * owned-data iteration (Use-case B of the visitor-data framework). + * + * Mirrors the `dataRowsFetch.test.ts` harness (`createTestDb()` with every + * migration applied, including migration `026` which adds + * `data_rows.visitor_user_id`). The load-bearing contract pinned here is the + * IDOR isolation rule: the source reads the visitor id ONLY from + * `ctx.visitor.id` (cookie-derived) and filters `data_rows.visitor_user_id` + * against it, so a visitor can never read another visitor's rows — even when + * both visitors own rows in the same table. + * + * Seeding mirrors `seedDataRow` from the data-rows harness but adds the + * `visitor_user_id` column; the rows belong to real `visitor_users` rows + * (FK with ON DELETE SET NULL), so two visitor accounts are created up front. + */ +import { describe, expect, it, beforeAll, afterAll } from 'bun:test' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { VisitorOwnedRowsSource } from '@core/loops/sources/visitorOwnedRows' + +type Db = TestDb['db'] + +// --------------------------------------------------------------------------- +// Seeding helpers +// --------------------------------------------------------------------------- + +/** + * Insert a `visitor_users` row directly. Mirrors the migration `021` columns + * (`id, email, email_normalized, password_hash, display_name, role_id, + * status`). The system `member` role is seeded by migration `021`, so we + * reuse it as the role_id. + */ +async function seedVisitorUser( + db: Db, + input: { + id: string + email: string + displayName: string + }, +): Promise { + await db` + insert into visitor_users (id, email, email_normalized, password_hash, display_name, role_id, status) + values (${input.id}, ${input.email}, ${input.email}, 'h', ${input.displayName}, 'member', 'active') + ` +} + +interface OwnedRowSeed { + rowId: string + tableId: string + slug: string + cells: Record + visitorUserId: string + createdAt: string + updatedAt: string +} + +/** + * Insert a data-kind row owned by a visitor. Mirrors `seedDataRow` from the + * data-rows harness but adds the `visitor_user_id` column (migration `026`). + */ +async function seedOwnedRow(db: Db, seed: OwnedRowSeed): Promise { + await db` + insert into data_rows + (id, table_id, cells_json, slug, status, created_at, updated_at, visitor_user_id) + values + (${seed.rowId}, ${seed.tableId}, ${JSON.stringify(seed.cells)}, ${seed.slug}, + 'draft', ${seed.createdAt}, ${seed.updatedAt}, ${seed.visitorUserId}) + ` +} + +/** + * Build a `SourceFetchContext`-shaped object for the owned-rows source. The + * test DB satisfies `LoopSourceDb` (it exposes `unsafe` + `dialect`), and + * `ctx.visitor.id` is the IDOR load-bearing input — cast `as any` to satisfy + * the type without importing internal helpers. + */ +function makeCtx(db: Db, overrides: Record = {}): any { + return { + db, + site: {}, + filters: { tableId: 'things' }, + orderBy: 'createdAt', + direction: 'asc', + limit: 50, + offset: 0, + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Shared fixture +// --------------------------------------------------------------------------- + +let testDb: TestDb +let db: Db + +const VISITOR_A = 'visitor-a' +const VISITOR_B = 'visitor-b' + +beforeAll(async () => { + testDb = await createTestDb() + db = testDb.db + + await seedVisitorUser(db, { id: VISITOR_A, email: 'a@example.com', displayName: 'Visitor A' }) + await seedVisitorUser(db, { id: VISITOR_B, email: 'b@example.com', displayName: 'Visitor B' }) + + // Primary data-kind table — 3 rows: 2 owned by visitorA, 1 by visitorB + // (the IDOR double-visitor seed). + await db` + insert into data_tables (id, name, slug, kind, route_base, singular_label, plural_label) + values ('things', 'Things', 'things', 'data', '/things', 'Thing', 'Things') + ` + await seedOwnedRow(db, { + rowId: 'thing-a1', + tableId: 'things', + slug: 'a1', + cells: { title: 'A1', owner: 'A' }, + visitorUserId: VISITOR_A, + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-02T00:00:00.000Z', + }) + await seedOwnedRow(db, { + rowId: 'thing-a2', + tableId: 'things', + slug: 'a2', + cells: { title: 'A2', owner: 'A' }, + visitorUserId: VISITOR_A, + createdAt: '2026-03-02T00:00:00.000Z', + updatedAt: '2026-03-03T00:00:00.000Z', + }) + // The row visitorA must NOT see — owned by visitorB in the same table. + await seedOwnedRow(db, { + rowId: 'thing-b1', + tableId: 'things', + slug: 'b1', + cells: { title: 'B1', owner: 'B' }, + visitorUserId: VISITOR_B, + createdAt: '2026-03-03T00:00:00.000Z', + updatedAt: '2026-03-04T00:00:00.000Z', + }) + + // Second data-kind table with a visitorA-owned row — proves ctx.filters.tableId + // scoping excludes rows from other tables. + await db` + insert into data_tables (id, name, slug, kind, route_base, singular_label, plural_label) + values ('other', 'Other', 'other', 'data', '/other', 'Other', 'Others') + ` + await seedOwnedRow(db, { + rowId: 'other-a1', + tableId: 'other', + slug: 'oa1', + cells: { title: 'OA1' }, + visitorUserId: VISITOR_A, + createdAt: '2026-04-01T00:00:00.000Z', + updatedAt: '2026-04-02T00:00:00.000Z', + }) +}) + +afterAll(async () => { + await testDb.cleanup() +}) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('visitor.owned-rows loop source', () => { + it('declares its id, per-visitor classification, tableId filter, and order options', () => { + expect(VisitorOwnedRowsSource.id).toBe('visitor.owned-rows') + expect(VisitorOwnedRowsSource.perVisitor).toBe(true) + + // tableId filter for scoping to a specific data table. + expect(VisitorOwnedRowsSource.filterSchema).toHaveProperty('tableId') + + // Order options include createdAt / updatedAt / slug. + const orderIds = VisitorOwnedRowsSource.orderByOptions.map((o) => o.id) + expect(orderIds).toContain('createdAt') + expect(orderIds).toContain('updatedAt') + expect(orderIds).toContain('slug') + }) + + it('returns EXACTLY visitorA\'s rows and NEVER visitorB\'s (IDOR isolation)', async () => { + const result = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { + visitor: { + id: VISITOR_A, + displayName: 'Visitor A', + email: 'a@example.com', + roleName: 'member', + profileFields: {}, + }, + }), + ) + + expect(result.totalItems).toBe(2) + const ids = result.items.map((i) => i.id) + expect(ids).toContain('thing-a1') + expect(ids).toContain('thing-a2') + // visitorB's row must never leak through, even though it lives in the + // same table — the visitor_user_id filter is bound from ctx.visitor.id. + expect(ids).not.toContain('thing-b1') + }) + + it('renders nothing for an anonymous request (no ctx.visitor)', async () => { + const result = await VisitorOwnedRowsSource.fetch(makeCtx(db, { visitor: undefined })) + expect(result).toEqual({ items: [], totalItems: 0 }) + }) + + it('honours ctx.filters.tableId — rows from a different table are excluded', async () => { + // Fetch the OTHER table → only that table's visitorA row, never the + // 'things' rows. + const otherResult = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { + filters: { tableId: 'other' }, + visitor: { + id: VISITOR_A, + displayName: 'Visitor A', + email: 'a@example.com', + roleName: 'member', + profileFields: {}, + }, + }), + ) + expect(otherResult.totalItems).toBe(1) + expect(otherResult.items.map((i) => i.id)).toEqual(['other-a1']) + + // And the default 'things' table fetch never returns the 'other' row. + const thingsResult = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { + visitor: { + id: VISITOR_A, + displayName: 'Visitor A', + email: 'a@example.com', + roleName: 'member', + profileFields: {}, + }, + }), + ) + expect(thingsResult.items.map((i) => i.id)).not.toContain('other-a1') + }) + + it('honours limit/offset while totalItems stays the full owned count', async () => { + const visitorA = { + id: VISITOR_A, + displayName: 'Visitor A', + email: 'a@example.com', + roleName: 'member', + profileFields: {}, + } + + const first = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { visitor: visitorA, limit: 1, offset: 0, orderBy: 'createdAt', direction: 'asc' }), + ) + expect(first.items).toHaveLength(1) + expect(first.items[0]!.id).toBe('thing-a1') // earliest createdAt first + expect(first.totalItems).toBe(2) + + const rest = await VisitorOwnedRowsSource.fetch( + makeCtx(db, { visitor: visitorA, limit: 1, offset: 1, orderBy: 'createdAt', direction: 'asc' }), + ) + expect(rest.items).toHaveLength(1) + expect(rest.items[0]!.id).toBe('thing-a2') + expect(rest.totalItems).toBe(2) // stable total across pages + }) +}) diff --git a/src/__tests__/media/mediaWorkspaceFolders.test.tsx b/src/__tests__/media/mediaWorkspaceFolders.test.tsx index 15da699ee..03e8054cf 100644 --- a/src/__tests__/media/mediaWorkspaceFolders.test.tsx +++ b/src/__tests__/media/mediaWorkspaceFolders.test.tsx @@ -374,4 +374,35 @@ describe('Media workspace folder grid', () => { expect(result.current.folderSelection).toBe(FOLDER_ALL) expect(result.current.visibleAssets.map((entry) => entry.id)).toEqual(['foldered_image']) }) + + it('returns multi-selected assets in selection order, not library order', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/media/folders')) { + return new Response(JSON.stringify({ folders: [] }), { status: 200 }) + } + if (url.endsWith('/media')) { + return new Response(JSON.stringify({ + assets: [ + asset({ id: 'library-first', filename: 'first.png' }), + asset({ id: 'library-second', filename: 'second.png' }), + asset({ id: 'library-third', filename: 'third.png' }), + ], + }), { status: 200 }) + } + return new Response(JSON.stringify({ error: 'Unexpected URL' }), { status: 404 }) + }) as typeof fetch + + const { result } = renderHook(() => useMediaWorkspace()) + await waitFor(() => expect(result.current.loading).toBe(false)) + + act(() => { + result.current.addToSelection(['library-third', 'library-first']) + }) + + expect(result.current.selectedAssets.map((entry) => entry.id)).toEqual([ + 'library-third', + 'library-first', + ]) + }) }) diff --git a/src/__tests__/media/mediaWorkspaceMutations.test.tsx b/src/__tests__/media/mediaWorkspaceMutations.test.tsx index bddfa756e..b131f1bcf 100644 --- a/src/__tests__/media/mediaWorkspaceMutations.test.tsx +++ b/src/__tests__/media/mediaWorkspaceMutations.test.tsx @@ -75,6 +75,40 @@ async function mountWorkspace(initialAssets: Record[]) { } describe('useMediaWorkspace mutation envelope (assetMut)', () => { + it('can reselect an asset after moving it out of an ordered selection', async () => { + routeFetch([ + assetRow({ id: 'asset_1', folderIds: [] }), + assetRow({ id: 'asset_2', filename: 'two.png', folderIds: [] }), + ], (_url, init) => { + const body = JSON.parse(String(init?.body)) as { add?: string[] } + return jsonResponse({ + asset: assetRow({ + id: body.add?.[0] === 'folder_2' ? 'asset_2' : 'asset_1', + folderIds: body.add ?? [], + }), + }) + }) + const view = await mountWorkspace([ + assetRow({ id: 'asset_1', folderIds: [] }), + assetRow({ id: 'asset_2', filename: 'two.png', folderIds: [] }), + ]) + + act(() => { + view.result.current.addToSelection(['asset_1', 'asset_2']) + }) + await act(async () => { + await view.result.current.moveAssetsToFolder(['asset_1'], 'folder_1') + }) + act(() => { + view.result.current.toggleAssetInSelection('asset_1') + }) + + expect(view.result.current.selectedAssets.map((asset) => asset.id)).toEqual([ + 'asset_2', + 'asset_1', + ]) + }) + it('a thrown op sets the error and returns null without throwing', async () => { routeFetch([assetRow()], () => jsonResponse({ error: 'Filename already taken' }, 409), diff --git a/src/__tests__/persistence/cmsAdapter.test.ts b/src/__tests__/persistence/cmsAdapter.test.ts index 098c1b8eb..a5450e452 100644 --- a/src/__tests__/persistence/cmsAdapter.test.ts +++ b/src/__tests__/persistence/cmsAdapter.test.ts @@ -3,6 +3,7 @@ import type { Page, SiteDocument } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { SavedLayout } from '@core/layouts' import { CmsAdapter } from '@core/persistence/cms' +import { SaveConflictError } from '@core/persistence/saveConflict' function makePage(id: string, slug: string): Page { return { @@ -96,7 +97,7 @@ describe('CmsAdapter', () => { const loaded = await adapter.loadSite('ignored-in-single-site-mode') - expect(loaded?.id).toBe('project_1') + expect(loaded?.site.id).toBe('project_1') expect(calls[0]).toMatchObject({ input: '/admin/api/cms/site', init: { method: 'GET', credentials: 'include' }, @@ -297,3 +298,89 @@ describe('CmsAdapter save wire shapes', () => { expect(body.deletedPageIds).toEqual([]) }) }) + +// --------------------------------------------------------------------------- +// Conflict-detection wire shapes — baseSeqs / shellBaseSeq + the 409 path +// --------------------------------------------------------------------------- + +describe('CmsAdapter conflict protocol', () => { + function emptyDirty() { + return { + all: false, + pageIds: new Set(), + componentIds: new Set(), + layoutIds: new Set(), + deletedPageIds: new Set(), + deletedComponentIds: new Set(), + deletedLayoutIds: new Set(), + } + } + + it('ships the base-seq subset covering exactly the changed + deleted rows, plus shellBaseSeq', async () => { + const calls: Array<{ init?: RequestInit }> = [] + const adapter = new CmsAdapter(async (_input, init) => { + calls.push({ init }) + return new Response(JSON.stringify({ ok: true, seq: 9 }), { status: 200 }) + }) + + const doc = site() + const result = await adapter.saveSite(doc, { + dirty: { + ...emptyDirty(), + pageIds: new Set(['page_home']), + deletedComponentIds: new Set(['vc-gone']), + }, + baseSeqs: { + page_home: 4, + 'vc-gone': 5, + 'unrelated-row': 99, // not shipped — must not leak onto the wire + }, + shellBaseSeq: 7, + }) + + expect(result.seq).toBe(9) + const body = JSON.parse(String(calls[0].init?.body)) as Record + expect(body.baseSeqs).toEqual({ page_home: 4, 'vc-gone': 5 }) + expect(body.shellBaseSeq).toBe(7) + }) + + it('throws SaveConflictError with the parsed conflicts on a 409', async () => { + const conflicts = [{ table: 'pages', rowId: 'page_home', seq: 12 }] + const adapter = new CmsAdapter(async () => + new Response(JSON.stringify({ error: 'save conflict', conflicts }), { status: 409 })) + + const err = await adapter + .saveSite(site(), { dirty: emptyDirty(), baseSeqs: {}, shellBaseSeq: 0 }) + .then(() => null, (e: unknown) => e) + expect(err).toBeInstanceOf(SaveConflictError) + expect((err as SaveConflictError).conflicts).toEqual([ + { table: 'pages', rowId: 'page_home', seq: 12 }, + ]) + }) + + it('loadSite returns per-row seqs and the shell seq alongside the document', async () => { + const adapter = new CmsAdapter(async (input) => { + const url = String(input) + if (url.endsWith('/site')) { + return new Response(JSON.stringify({ site: site(), seq: 3 }), { status: 200 }) + } + if (url.endsWith('/pages')) { + return new Response(JSON.stringify({ + rows: [{ + id: 'page_home', tableId: 'pages', slug: 'index', status: 'draft', seq: 2, + cells: { title: 'index', slug: 'index', body: { rootNodeId: 'root', nodes: { root: { id: 'root', moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: [] } } } }, + authorUserId: null, createdByUserId: null, updatedByUserId: null, publishedByUserId: null, + author: null, createdBy: null, updatedBy: null, publishedBy: null, + createdAt: '2026-01-01', updatedAt: '2026-01-01', publishedAt: null, + scheduledPublishAt: null, deletedAt: null, + }], + }), { status: 200 }) + } + return new Response(JSON.stringify({ rows: [] }), { status: 200 }) + }) + + const loaded = await adapter.loadSite('default') + expect(loaded?.shellSeq).toBe(3) + expect(loaded?.rowSeqs).toEqual({ page_home: 2 }) + }) +}) diff --git a/src/__tests__/persistence/savePersistenceQueue.test.tsx b/src/__tests__/persistence/savePersistenceQueue.test.tsx deleted file mode 100644 index 4bc282d88..000000000 --- a/src/__tests__/persistence/savePersistenceQueue.test.tsx +++ /dev/null @@ -1,208 +0,0 @@ -/** - * usePersistence single-flight save queue. - * - * Every save trigger (autosave, Cmd+S, save-request events, the MCP bridge, - * unmount flush) funnels through `saveCurrentSite`, which allows at most ONE - * save on the wire and ONE queued follow-up: - * - * - N triggers during an in-flight save coalesce into a single follow-up - * that reads the LATEST store state when it runs, - * - the follow-up is skipped when the in-flight save already shipped - * everything (no unsaved changes remain), - * - a FAILED in-flight save does not cancel the queued retry — its dirty - * marks were restored, so the retry ships them again. - * - * Two saves can therefore never interleave on the wire — the failure mode - * the retired four-request protocol had. - */ -import { afterEach, describe, expect, it } from 'bun:test' -import React, { useEffect } from 'react' -import { cleanup, render, waitFor } from '@testing-library/react' -import { usePersistence } from '@site/hooks/usePersistence' -import type { IPersistenceAdapter, SaveSiteOptions } from '@core/persistence/types' -import type { SiteDocument } from '@core/page-tree' -import { useEditorStore } from '@site/store/store' -import { emptyDirtyMarks } from '@site/store/slices/site/dirtyTracking' -import { makePage, makeSite } from '../fixtures' - -interface Deferred { - promise: Promise - resolve: () => void - reject: (err: unknown) => void -} - -function deferred(): Deferred { - let resolve!: () => void - let reject!: (err: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -interface RecordedSave { - dirty: SaveSiteOptions['dirty'] - gate: Deferred -} - -/** Adapter whose saveSite parks on a caller-controlled gate per invocation. */ -function makeGatedAdapter(): { adapter: IPersistenceAdapter; saves: RecordedSave[] } { - const saves: RecordedSave[] = [] - const adapter: IPersistenceAdapter = { - loadSite: async () => undefined, - saveSite: (_site: SiteDocument, opts: SaveSiteOptions = {}) => { - const gate = deferred() - saves.push({ dirty: opts.dirty, gate }) - return gate.promise - }, - } - return { adapter, saves } -} - -/** Mounts usePersistence and hands the save callback out to the test. */ -function HookHost({ - adapter, - onSave, -}: { - adapter: IPersistenceAdapter - onSave: (save: () => Promise) => void -}) { - const { saveSite } = usePersistence('default', adapter, { enabled: true }) - useEffect(() => { - onSave(saveSite) - }, [onSave, saveSite]) - return null -} - -function seedStore(): void { - useEditorStore.setState({ - _historyPast: [], - _historyFuture: [], - _historyCoalesceKey: null, - hasUnsavedChanges: false, - _dirtySave: emptyDirtyMarks(), - } as Parameters[0]) - useEditorStore.getState().loadSite( - makeSite({ - pages: [ - makePage({ id: 'page-a', slug: 'index', title: 'Home' }), - makePage({ id: 'page-b', slug: 'about', title: 'About' }), - ], - }), - ) -} - -async function mountHook(adapter: IPersistenceAdapter): Promise<() => Promise> { - let save: (() => Promise) | null = null - render( { save = s }} />) - await waitFor(() => expect(save).not.toBeNull()) - return save! -} - -function editPage(pageId: string): void { - const store = useEditorStore.getState() - useEditorStore.setState({ activePageId: pageId }) - store.updateNodeProps('root', { text: `edit-${pageId}-${Math.random()}` }) -} - -afterEach(cleanup) - -describe('usePersistence single-flight save queue', () => { - it('coalesces N mid-flight triggers into ONE follow-up that ships the latest state', async () => { - seedStore() - const { adapter, saves } = makeGatedAdapter() - const save = await mountHook(adapter) - - editPage('page-a') - const first = save() - await waitFor(() => expect(saves).toHaveLength(1)) - expect([...saves[0].dirty!.pageIds]).toEqual(['page-a']) - - // Three triggers while save #1 is on the wire — plus a NEW edit. - editPage('page-b') - const q1 = save() - const q2 = save() - const q3 = save() - // All coalesce into one queued promise; nothing new on the wire yet. - expect(saves).toHaveLength(1) - - saves[0].gate.resolve() - await first - - await waitFor(() => expect(saves).toHaveLength(2)) - // The follow-up shipped the LATEST marks (page-b's edit). - expect(saves[1].dirty!.pageIds.has('page-b')).toBe(true) - - saves[1].gate.resolve() - await Promise.all([q1, q2, q3]) - // No third save — the queue drained. - expect(saves).toHaveLength(2) - }) - - it('skips the queued follow-up when the in-flight save already shipped everything', async () => { - seedStore() - const { adapter, saves } = makeGatedAdapter() - const save = await mountHook(adapter) - - editPage('page-a') - const first = save() - await waitFor(() => expect(saves).toHaveLength(1)) - - // Trigger spam with NO new edits while save #1 is in flight. - const q = save() - saves[0].gate.resolve() - await first - await q - - // hasUnsavedChanges went false when save #1 landed — the follow-up is - // pointless and must not fire. - expect(saves).toHaveLength(1) - expect(useEditorStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('a failed in-flight save does not cancel the queued retry, and the retry re-ships the restored marks', async () => { - seedStore() - const { adapter, saves } = makeGatedAdapter() - const save = await mountHook(adapter) - - editPage('page-a') - const first = save() - await waitFor(() => expect(saves).toHaveLength(1)) - - editPage('page-b') - const q = save() - - saves[0].gate.reject(new Error('network down')) - await expect(first).rejects.toThrow('network down') - - // The retry fires and carries BOTH page-a (restored from the failed - // snapshot) and page-b (the new edit). - await waitFor(() => expect(saves).toHaveLength(2)) - expect(saves[1].dirty!.pageIds.has('page-a')).toBe(true) - expect(saves[1].dirty!.pageIds.has('page-b')).toBe(true) - - saves[1].gate.resolve() - await q - expect(useEditorStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('a new trigger after the queue drained starts a fresh save', async () => { - seedStore() - const { adapter, saves } = makeGatedAdapter() - const save = await mountHook(adapter) - - editPage('page-a') - const first = save() - await waitFor(() => expect(saves).toHaveLength(1)) - saves[0].gate.resolve() - await first - - editPage('page-b') - const second = save() - await waitFor(() => expect(saves).toHaveLength(2)) - expect(saves[1].dirty!.pageIds.has('page-b')).toBe(true) - saves[1].gate.resolve() - await second - }) -}) diff --git a/src/__tests__/publisher/classStyleInjector.test.ts b/src/__tests__/publisher/classStyleInjector.test.ts index a33efc0af..7758418d7 100644 --- a/src/__tests__/publisher/classStyleInjector.test.ts +++ b/src/__tests__/publisher/classStyleInjector.test.ts @@ -84,6 +84,24 @@ describe('bagToCSS', () => { expect(css).not.toContain('display: block !important') }) + it('emits nothing for a non-object bag instead of throwing (corrupt rule resilience)', () => { + // A malformed persisted rule can carry a non-object `styles` (bad import, + // plugin write, older data). `Object.entries(null)` would throw and blank + // the whole canvas — one bad rule must degrade to empty, not crash. + expect(bagToCSS(undefined as never)).toBe('') + expect(bagToCSS(null as never)).toBe('') + expect(bagToCSS(42 as never)).toBe('') + }) + + it('one malformed rule does not stop the rest of the stylesheet emitting', () => { + const good = makeClass('good', { color: 'red' }) + // A packageJson-shaped object wrongly stored as a style rule: no `styles`. + const corrupt = { dependencies: {}, devDependencies: {} } as unknown as StyleRule + const css = generateClassCSS({ corrupt, good }, [], []) + expect(css).toContain('color: red') + expect(css).not.toContain('dependencies') + }) + it('converts camelCase to kebab-case', () => { const css = bagToCSS({ backgroundColor: '#fff', borderTopLeftRadius: '4px' }) expect(css).toContain('background-color: #fff;') diff --git a/src/__tests__/publisher/loopRender.test.ts b/src/__tests__/publisher/loopRender.test.ts index 9937864a1..ce2d1ae42 100644 --- a/src/__tests__/publisher/loopRender.test.ts +++ b/src/__tests__/publisher/loopRender.test.ts @@ -308,6 +308,44 @@ describe('publisher loop renderer', () => { const footerIdx = html.lastIndexOf('

OUTER

') expect(footerIdx).toBeGreaterThan(html.indexOf('

Beta

')) }) + + it('resolves an entry-field loop separately for every outer item', () => { + const page = makePage({ + root: { moduleId: 'base.body', children: ['outer'] }, + outer: { moduleId: 'base.loop', children: ['inner'], props: { sourceId: 'outer' } }, + inner: { + moduleId: 'base.loop', + children: ['card'], + props: { + sourceId: 'entry.field', + filters: { fieldId: 'gallery' }, + direction: 'asc', + limit: 10, + }, + }, + card: { + moduleId: 'test.pair', + props: { parent: '', current: '' }, + dynamicBindings: { + parent: { source: 'parentEntry', field: 'title' }, + current: { source: 'currentEntry', field: 'value' }, + }, + }, + }) + const outerItems: LoopItem[] = [ + { id: 'p1', fields: { title: 'Project 1', gallery: ['p1-a', 'p1-b'] } }, + { id: 'p2', fields: { title: 'Project 2', gallery: ['p2-a'] } }, + ] + const html = publishPage(page, makeSite(), baseRegistry, { + loopData: new Map([['outer', loopData(outerItems)]]), + }).html + + expect(html).toContain('

Project 1/p1-a

') + expect(html).toContain('

Project 1/p1-b

') + expect(html).toContain('

Project 2/p2-a

') + expect(html).not.toContain('Project 1/p2-a') + expect(html).not.toContain('Project 2/p1-a') + }) }) it('attaches data attrs and registers infinite mode', () => { diff --git a/src/__tests__/server/capabilityRouteMatrix.test.ts b/src/__tests__/server/capabilityRouteMatrix.test.ts index c713a87d0..e0acb66d3 100644 --- a/src/__tests__/server/capabilityRouteMatrix.test.ts +++ b/src/__tests__/server/capabilityRouteMatrix.test.ts @@ -21,6 +21,28 @@ async function loadSiteShell( return body.site } +/** The shell's live sync seq — the conflict-detection base a fresh client would hold. */ +async function loadShellSeq( + harness: Awaited>, + cookie: string, +): Promise { + const res = await harness.cms('/admin/api/cms/site', { method: 'GET', cookie }) + expect(res.status).toBe(200) + const body = await readJson<{ seq?: number }>(res) + return body.seq ?? 0 +} + +/** Live per-row sync seqs for the pages collection — a fresh client's base map. */ +async function pagesBaseSeqs( + harness: Awaited>, + cookie: string, +): Promise> { + const res = await harness.cms('/admin/api/cms/pages', { method: 'GET', cookie }) + expect(res.status).toBe(200) + const body = await readJson<{ rows: DataRow[] }>(res) + return Object.fromEntries((body.rows ?? []).map((row) => [row.id, row.seq ?? 0])) +} + async function loadPages( harness: Awaited>, cookie: string, @@ -43,6 +65,8 @@ function siteDocBody(overrides: { deletedComponentIds?: string[] changedLayouts?: unknown[] deletedLayoutIds?: string[] + baseSeqs?: Record + shellBaseSeq?: number }): Record { return { mode: 'incremental', @@ -52,6 +76,11 @@ function siteDocBody(overrides: { deletedComponentIds: [], changedLayouts: [], deletedLayoutIds: [], + // Conflict-detection bases — scenarios that expect a save to LAND pass + // live values; scenarios rejected by the capability diff gate (403, + // phase 1) never reach the conflict check, so the defaults suffice. + baseSeqs: {}, + shellBaseSeq: 0, ...overrides, } } @@ -134,7 +163,10 @@ describe('capability route matrix', () => { const styleAllowed = await harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: styleUser.cookie, - json: siteDocBody({ site: styleEdit }), + json: siteDocBody({ + site: styleEdit, + shellBaseSeq: await loadShellSeq(harness, styleUser.cookie), + }), }) expect(styleAllowed.status).toBe(200) @@ -155,7 +187,10 @@ describe('capability route matrix', () => { const structureAllowed = await harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: structureUser.cookie, - json: siteDocBody({ site: { ...afterStyle, name: 'Capability Matrix Renamed' } }), + json: siteDocBody({ + site: { ...afterStyle, name: 'Capability Matrix Renamed' }, + shellBaseSeq: await loadShellSeq(harness, structureUser.cookie), + }), }) expect(structureAllowed.status).toBe(200) @@ -287,7 +322,11 @@ describe('capability route matrix', () => { const seedRes = await harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: ownerCookie, - json: siteDocBody({ site: ownerShell, changedPages: [seededPage] }), + json: siteDocBody({ + site: ownerShell, + changedPages: [seededPage], + baseSeqs: await pagesBaseSeqs(harness, ownerCookie), + }), }) expect(seedRes.status).toBe(200) @@ -299,7 +338,11 @@ describe('capability route matrix', () => { const editRes = await harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: contentEditor.cookie, - json: siteDocBody({ site: editorShell, changedPages: [editedPage] }), + json: siteDocBody({ + site: editorShell, + changedPages: [editedPage], + baseSeqs: await pagesBaseSeqs(harness, contentEditor.cookie), + }), }) expect(editRes.status).toBe(200) diff --git a/src/__tests__/server/cmsDataAuthorization.test.ts b/src/__tests__/server/cmsDataAuthorization.test.ts index d80f16696..997e84b9d 100644 --- a/src/__tests__/server/cmsDataAuthorization.test.ts +++ b/src/__tests__/server/cmsDataAuthorization.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from 'bun:test' import { handleCmsRequest } from '../../../server/handlers/cms' import type { DbClient } from '../../../server/db' import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { registerPublishFlush } from '../../../server/publish/publishFlush' +import { upsertDataRowDraft } from '../../../server/repositories/data' const ownedPassword = 'long-enough-password' @@ -203,6 +205,39 @@ describe('CMS data ownership authorization', () => { return testDb } + it('allows editing built-in fields on an existing system-table row', async () => { + const { db } = await makeDb() + const ownerCookie = await setupOwner(db) + + const list = await request(db, '/admin/api/cms/data/tables/pages/rows', { + method: 'GET', + cookie: ownerCookie, + }) + expect(list.status).toBe(200) + const rows = (await body(list)).rows as Array<{ + id: string + cells: Record + }> + const home = rows[0] + expect(home).toBeDefined() + + const update = await request(db, `/admin/api/cms/data/rows/${home.id}`, { + method: 'PATCH', + cookie: ownerCookie, + body: JSON.stringify({ + cells: { + ...home.cells, + seoTitle: 'Updated from Data', + seoDescription: 'Bulk-editable metadata', + }, + }), + }) + expect(update.status).toBe(200) + const updated = (await body(update)).row as { cells: Record } + expect(updated.cells.seoTitle).toBe('Updated from Data') + expect(updated.cells.seoDescription).toBe('Bulk-editable metadata') + }) + it('filters own-edit users to their rows and lets any-edit users see all rows', async () => { const { db } = await makeDb() const ownerCookie = await setupOwner(db) @@ -327,6 +362,48 @@ describe('CMS data ownership authorization', () => { expect(await body(reassign)).toMatchObject({ row: { authorUserId: managerId } }) }) + it('flushes the collab relay before reading the row to schedule', async () => { + // A page created in the visual editor lives only in the relay's in-memory + // doc until its persist debounce elapses. Scheduling one right after + // creating it used to 404 with "Data row not found" because this handler + // read the DB directly — publish flushes, so "publish later" must too. + const { db } = await makeDb() + const ownerCookie = await setupOwner(db) + + const relayResidentId = 'relay-resident-row' + let flushed = false + // Stands in for the relay persisting a doc that has no DB row yet. The + // handler's own flush is the ONLY thing that can make this row exist. + const detach = registerPublishFlush(async () => { + flushed = true + await upsertDataRowDraft( + db, + { + id: relayResidentId, + tableId: 'posts', + cells: { title: 'Relay-resident page' }, + slug: 'relay-resident-page', + }, + null, + { collabInternal: true }, + ) + }) + cleanupFns.push(async () => detach()) + + const scheduledAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + const schedule = await request(db, `/admin/api/cms/data/rows/${relayResidentId}/schedule`, { + method: 'POST', + cookie: ownerCookie, + body: JSON.stringify({ at: scheduledAt }), + }) + + expect(flushed).toBe(true) + expect(schedule.status).toBe(200) + expect(await body(schedule)).toMatchObject({ + row: { id: relayResidentId, status: 'scheduled' }, + }) + }) + it('schedules and cancels row publication only through the schedule endpoint', async () => { const { db } = await makeDb() const ownerCookie = await setupOwner(db) diff --git a/src/__tests__/server/cmsRouteAuthorization.test.ts b/src/__tests__/server/cmsRouteAuthorization.test.ts index 77742422c..1741029cf 100644 --- a/src/__tests__/server/cmsRouteAuthorization.test.ts +++ b/src/__tests__/server/cmsRouteAuthorization.test.ts @@ -109,7 +109,12 @@ async function currentSiteDocument(db: DbClient, cookie: string): Promise { deletedComponentIds: [], changedLayouts: [], deletedLayoutIds: [], + baseSeqs: {}, + shellBaseSeq: 0, }), headers: { 'content-type': 'application/json', diff --git a/src/__tests__/server/collabDocuments.test.ts b/src/__tests__/server/collabDocuments.test.ts new file mode 100644 index 000000000..b48e9d85f --- /dev/null +++ b/src/__tests__/server/collabDocuments.test.ts @@ -0,0 +1,48 @@ +/** + * collab_documents repository — CRDT state blob round-trips through both the + * upsert path and the delete path on a real migrated database. + */ +import { describe, expect, it } from 'bun:test' +import { + deleteCollabDocuments, + getCollabDocumentState, + putCollabDocumentState, +} from '../../../server/repositories/collabDocuments' +import { createTestDb } from '../helpers/createTestDb' + +describe('collab_documents repository', () => { + it('put → get round-trips bytes and increments seq on upsert', async () => { + const { db, cleanup } = await createTestDb() + try { + expect(await getCollabDocumentState(db, 'page:x')).toBeNull() + + const first = new Uint8Array([1, 2, 3, 250]) + await putCollabDocumentState(db, 'page:x', first, 'gen-1') + expect([...(await getCollabDocumentState(db, 'page:x'))!.state]).toEqual([1, 2, 3, 250]) + + const second = new Uint8Array([9, 9]) + await putCollabDocumentState(db, 'page:x', second, 'gen-1') + expect([...(await getCollabDocumentState(db, 'page:x'))!.state]).toEqual([9, 9]) + + const { rows } = await db<{ seq: number }>` + select seq from collab_documents where doc_id = ${'page:x'} + ` + expect(Number(rows[0].seq)).toBe(2) + } finally { + await cleanup() + } + }) + + it('deleteCollabDocuments removes exactly the named docs', async () => { + const { db, cleanup } = await createTestDb() + try { + await putCollabDocumentState(db, 'page:a', new Uint8Array([1]), 'gen-1') + await putCollabDocumentState(db, 'page:b', new Uint8Array([2]), 'gen-1') + await deleteCollabDocuments(db, ['page:a', 'page:missing']) + expect(await getCollabDocumentState(db, 'page:a')).toBeNull() + expect(await getCollabDocumentState(db, 'page:b')).not.toBeNull() + } finally { + await cleanup() + } + }) +}) diff --git a/src/__tests__/server/collabRelay.test.ts b/src/__tests__/server/collabRelay.test.ts new file mode 100644 index 000000000..6b9f7d08e --- /dev/null +++ b/src/__tests__/server/collabRelay.test.ts @@ -0,0 +1,358 @@ +/** + * Collab relay — doc lifecycle, deterministic seeding, persistence (blob + + * derived JSON), roster-driven deletion, and out-of-relay reset wiring. + * Runs on a real migrated database via the capability harness (setup seeds + * the home page row exactly like a live install). + */ +import { afterEach, describe, expect, it, spyOn } from 'bun:test' +import * as Y from 'yjs' +import { + LOCAL_ORIGIN, + projectPageDoc, + rostersMap, + SITE_DOC_ID, + treeMap, +} from '@core/collab' +import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' +import type { DbClient } from '../../../server/db' +import { getCollabDocumentState } from '../../../server/repositories/collabDocuments' +import { saveDataRowDraft } from '../../../server/repositories/data' +import { + createCapabilityTestHarness, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +let cleanups: Array<() => Promise> = [] + +afterEach(async () => { + for (const fn of cleanups.reverse()) await fn() + cleanups = [] +}) + +async function setup(): Promise<{ harness: CapabilityTestHarness; relay: CollabRelay; homeId: string }> { + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + await harness.setupOwner() + const relay = createCollabRelay(harness.db, { persistDebounceMs: 10 }) + cleanups.push(() => relay.destroy()) + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + return { harness, relay, homeId: rows[0].id } +} + +/** + * Wrap a DbClient so the first collab blob write AFTER `arm()` blocks until + * released. Arming is explicit because `openDoc` persists the freshly minted + * generation immediately — the write we want to hold is the later debounced + * one, not that mint. + */ +function gateCollabBlobWrites(db: DbClient): { + db: DbClient + arm: () => void + blocked: Promise + release: () => void +} { + let announceBlocked!: () => void + const blocked = new Promise((resolve) => { + announceBlocked = resolve + }) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let armed = false + let gated = false + + const wrapped = (async (strings: TemplateStringsArray, ...values: unknown[]) => { + if (armed && !gated && strings.join('?').includes('insert into collab_documents')) { + gated = true + announceBlocked() + await gate + } + return db(strings, ...values) + }) as DbClient + Object.defineProperty(wrapped, 'dialect', { get: () => db.dialect }) + wrapped.unsafe = ((sql: string, params?: unknown[]) => db.unsafe(sql, params)) as DbClient['unsafe'] + wrapped.transaction = ((fn: Parameters[0]) => + db.transaction(fn)) as DbClient['transaction'] + return { db: wrapped, arm: () => { armed = true }, blocked, release } +} + +function failNextCollabBlobWrite(db: DbClient): { + db: DbClient + arm: () => void + failed: Promise +} { + let announceFailed!: () => void + const failed = new Promise((resolve) => { + announceFailed = resolve + }) + let armed = false + let hasFailed = false + + const wrapped = (async (strings: TemplateStringsArray, ...values: unknown[]) => { + if ( + armed && + !hasFailed && + strings.join('?').includes('insert into collab_documents') + ) { + hasFailed = true + announceFailed() + throw new Error('simulated transient collab persistence failure') + } + return db(strings, ...values) + }) as DbClient + Object.defineProperty(wrapped, 'dialect', { get: () => db.dialect }) + wrapped.unsafe = ((sql: string, params?: unknown[]) => db.unsafe(sql, params)) as DbClient['unsafe'] + wrapped.transaction = ((fn: Parameters[0]) => + db.transaction(fn)) as DbClient['transaction'] + return { db: wrapped, arm: () => { armed = true }, failed } +} + +function editTitleUpdate(doc: Y.Doc, nodeText: string): void { + doc.transact(() => { + const nodes = treeMap(doc).get('nodes') as Y.Map + const rootId = treeMap(doc).get('rootNodeId') as string + const root = nodes.get(rootId) as Y.Map + root.set('label', nodeText) + }, LOCAL_ORIGIN) +} + +describe('collab relay', () => { + it('seeds a page doc deterministically from the stored row (identical state on repeat)', async () => { + const { harness, relay, homeId } = await setup() + const { doc: doc } = await relay.openDoc(`page:${homeId}`) + const projected = projectPageDoc(doc, homeId) + expect(projected.slug).toBe('index') + expect(projected.rootNodeId).not.toBe('') + + // A second relay (fresh registry, no blob persisted yet? force reset) — + // deterministic seeding must produce an identical state vector. + const relay2 = createCollabRelay(harness.db, { persistDebounceMs: 10 }) + cleanups.push(() => relay2.destroy()) + const { doc: doc2 } = await relay2.openDoc(`page:${homeId}`) + expect(Y.encodeStateVector(doc2)).toEqual(Y.encodeStateVector(doc)) + }) + + it('persists the blob AND the derived JSON after an update', async () => { + const { harness, relay, homeId } = await setup() + const docId = `page:${homeId}` + const { doc: doc } = await relay.openDoc(docId) + editTitleUpdate(doc, 'Hero section') + + await new Promise((resolve) => setTimeout(resolve, 30)) + await relay.flushAll() + + expect((await getCollabDocumentState(harness.db, docId))?.state).toBeDefined() + const { rows } = await harness.db<{ cells_json: Record }>` + select cells_json from data_rows where id = ${homeId} + ` + const body = rows[0].cells_json.body as { nodes: Record; rootNodeId: string } + expect(body.nodes[body.rootNodeId].label).toBe('Hero section') + }) + + it('keeps a dirty doc resident and retries when the final persist fails', async () => { + const errorLog = spyOn(console, 'error').mockImplementation(() => {}) + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + await harness.setupOwner() + const failing = failNextCollabBlobWrite(harness.db) + const relay = createCollabRelay(failing.db, { persistDebounceMs: 5 }) + cleanups.push(() => relay.destroy()) + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + const homeId = rows[0].id + const docId = `page:${homeId}` + const { doc } = await relay.retain(docId) + + failing.arm() + editTitleUpdate(doc, 'Survives a transient failure') + relay.release(docId) + await failing.failed + + await new Promise((resolve) => setTimeout(resolve, 30)) + const persisted = await getCollabDocumentState(harness.db, docId) + expect(persisted?.state).toBeDefined() + const { rows: pageRows } = await harness.db<{ cells_json: Record }>` + select cells_json from data_rows where id = ${homeId} + ` + const body = pageRows[0].cells_json.body as { + nodes: Record + rootNodeId: string + } + expect(body.nodes[body.rootNodeId].label).toBe('Survives a transient failure') + expect(errorLog).toHaveBeenCalled() + errorLog.mockRestore() + }) + + it('makes an explicit flush fail instead of publishing stale derived JSON', async () => { + const errorLog = spyOn(console, 'error').mockImplementation(() => {}) + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + await harness.setupOwner() + const failing = failNextCollabBlobWrite(harness.db) + const relay = createCollabRelay(failing.db, { persistDebounceMs: 1_000 }) + cleanups.push(() => relay.destroy()) + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + const docId = `page:${rows[0].id}` + const { doc } = await relay.openDoc(docId) + + failing.arm() + editTitleUpdate(doc, 'Must not publish yet') + await expect(relay.flushAll()).rejects.toThrow('collaborative state persistence failed') + await failing.failed + + // The failed flush schedules a retry; a later explicit flush can safely + // wait for it and succeeds once the transient database error is gone. + await relay.flushAll() + expect(errorLog).toHaveBeenCalled() + errorLog.mockRestore() + }) + + it('roster removal soft-deletes the row on site-doc persist', async () => { + const { harness, relay, homeId } = await setup() + const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) + siteDoc.transact(() => { + const rosters = rostersMap(siteDoc) + ;(rosters.get('pages') as Y.Map).delete(homeId) + }, LOCAL_ORIGIN) + + await new Promise((resolve) => setTimeout(resolve, 30)) + await relay.flushAll() + + const { rows } = await harness.db<{ deleted_at: string | null }>` + select deleted_at from data_rows where id = ${homeId} + ` + expect(rows[0].deleted_at).not.toBeNull() + }) + + it('an out-of-relay row write resets the doc and notifies listeners', async () => { + const { harness, relay, homeId } = await setup() + const docId = `page:${homeId}` + await relay.openDoc(docId) + await relay.flushAll() + expect((await getCollabDocumentState(harness.db, docId))?.state).toBeDefined() + + const resets: string[] = [] + relay.onReset((id) => resets.push(id)) + + // Simulate a pack install / data-workspace edit. + const { rows } = await harness.db<{ cells_json: Record; slug: string }>` + select cells_json, slug from data_rows where id = ${homeId} + ` + await saveDataRowDraft(harness.db, homeId, { cells: rows[0].cells_json, slug: rows[0].slug }) + + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(resets).toContain(docId) + expect(await getCollabDocumentState(harness.db, docId)).toBeNull() + }) + + it('a doc with neither blob nor row starts empty (client-created-row flow) and persists a new row', async () => { + const { harness, relay } = await setup() + const docId = 'page:fresh-row-id' + const { doc: doc } = await relay.openDoc(docId) + expect(treeMap(doc).get('rootNodeId')).toBeUndefined() + + // Client update arrives with full content (translator-populated shape). + doc.transact(() => { + const tree = treeMap(doc) + tree.set('rootNodeId', 'root') + const nodes = new Y.Map() + tree.set('nodes', nodes) + const root = new Y.Map() + root.set('id', 'root') + root.set('moduleId', 'base.body') + root.set('props', new Y.Map()) + root.set('breakpointOverrides', new Y.Map()) + root.set('children', new Y.Array()) + nodes.set('root', root) + const meta = doc.getMap('meta') + meta.set('title', 'Fresh') + meta.set('slug', 'fresh') + }, LOCAL_ORIGIN) + + await new Promise((resolve) => setTimeout(resolve, 30)) + await relay.flushAll() + + const { rows } = await harness.db<{ id: string; slug: string }>` + select id, slug from data_rows where id = ${'fresh-row-id'} + ` + expect(rows[0]?.slug).toBe('fresh') + }) + + // ── Lifecycle races ─────────────────────────────────────────────────────── + // The reset seam is how every out-of-relay write (Settings save, Super + // Import, plugin install, data-workspace edit) reaches connected editors. + // Both cases below FAIL without the eviction/flush ordering in relay.ts. + + it('a reset is not undone by a persist that was already in flight', async () => { + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + await harness.setupOwner() + const gated = gateCollabBlobWrites(harness.db) + const relay = createCollabRelay(gated.db, { persistDebounceMs: 5 }) + cleanups.push(() => relay.destroy()) + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + const docId = `page:${rows[0].id}` + + const { doc } = await relay.openDoc(docId) + // The generation-mint write has landed; arm the gate so the DEBOUNCED + // persist is the one held mid-flight. + gated.arm() + editTitleUpdate(doc, 'About to be reset') + + // Block the blob write mid-flight, then reset underneath it. + await gated.blocked + const reset = relay.resetDocs([docId]) + gated.release() + await reset + + // Without the await in `evict`, the released insert lands AFTER + // deleteCollabDocuments and resurrects the dead generation. + expect(await getCollabDocumentState(harness.db, docId)).toBeNull() + }) + + it('a relay-only page survives a site-doc reset instead of vanishing from the roster', async () => { + const { harness, relay } = await setup() + const rowId = 'relay-only-page' + const { doc: pageDoc } = await relay.openDoc(`page:${rowId}`) + await relay.openDoc(SITE_DOC_ID) + + pageDoc.transact(() => { + const tree = treeMap(pageDoc) + tree.set('rootNodeId', 'root') + const nodes = new Y.Map() + const root = new Y.Map() + root.set('id', 'root') + root.set('moduleId', 'base.body') + root.set('props', new Y.Map()) + root.set('breakpointOverrides', new Y.Map()) + root.set('children', new Y.Array()) + nodes.set('root', root) + tree.set('nodes', nodes) + const meta = pageDoc.getMap('meta') + meta.set('title', 'Relay only') + meta.set('slug', 'relay-only') + }, LOCAL_ORIGIN) + + // Reset the SITE doc while the new page exists ONLY in the relay. Its + // derived JSON must be flushed first, or the reseed — which builds the + // roster from listDataRowIdSlugs — cannot see the row at all. + await relay.resetDocs([SITE_DOC_ID]) + + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where id = ${rowId} and deleted_at is null + ` + expect(rows).toHaveLength(1) + + const { doc: reseeded } = await relay.openDoc(SITE_DOC_ID) + const pages = rostersMap(reseeded).get('pages') as Y.Map + expect([...pages.keys()]).toContain(rowId) + }) +}) diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts new file mode 100644 index 000000000..b01e750e0 --- /dev/null +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -0,0 +1,656 @@ +/** + * Collab end-to-end — a real `Bun.serve` running the relay + socket layer, + * with real client providers (the same transport the editor uses) over real + * WebSockets: + * + * - two clients edit different nodes concurrently and CONVERGE, + * - the relay persists the blob and the derived row JSON after its + * debounce (the publisher path reads exactly what admins see), + * - a read-only connection's update frames are ignored, + * - an out-of-relay row write triggers the reset protocol, + * - a client that missed edits while disconnected catches up on + * reconnect via Yjs state vectors, + * - one peer cannot erase another peer's presence for everyone, + * - `runPublishFlush` drains the persist debounce, so publish bakes the + * edit an admin made seconds earlier instead of losing it. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as encoding from 'lib0/encoding' +import * as syncProtocol from 'y-protocols/sync' +import { + decodeCollabFrame, + encodeCollabFrame, + FRAME_PING, + FRAME_PONG, + FRAME_SYNC, + PRESENCE_DOC_ID, + LOCAL_ORIGIN, + projectPageDoc, + SITE_SOCKET_PATH, + treeMap, +} from '@core/collab' +import { pageFromRow } from '@core/data/pageFromRow' +import { + createCollabProvider, + type CollabProvider, + type CollabSocketLike, +} from '@site/collab/collabProvider' +import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' +import { + createCollabSocketLayer, + handleCollabSocketUpgrade, +} from '../../../server/collab/socket' +import { getCollabDocumentState } from '../../../server/repositories/collabDocuments' +import { runPublishFlush } from '../../../server/publish/publishFlush' +import { getDataRow, saveDataRowDraft } from '../../../server/repositories/data' +import { findUserByEmail } from '../../../server/repositories/users' +import { peerColor } from '@site/collab/awarenessState' +import { + createCapabilityTestHarness, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' +// The update guard classifies prop changes via the module registry. +import '@modules/base/index' + +const PERSIST_DEBOUNCE_MS = 25 + +let cleanups: Array<() => Promise | void> = [] + +afterEach(async () => { + for (const fn of cleanups.reverse()) await fn() + cleanups = [] +}) + +async function waitFor( + predicate: () => boolean | Promise, + timeoutMs = 4_000, +): Promise { + const start = Date.now() + while (!(await predicate())) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +interface Stack { + harness: CapabilityTestHarness + relay: CollabRelay + url: string + cookie: string + homeId: string +} + +async function startStack(): Promise { + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + const cookie = await harness.setupOwner() + const relay = createCollabRelay(harness.db, { persistDebounceMs: PERSIST_DEBOUNCE_MS }) + cleanups.push(() => relay.destroy()) + const socketLayer = createCollabSocketLayer(relay) + + const server = Bun.serve({ + port: 0, + fetch: async (req, srv) => { + if (new URL(req.url).pathname === SITE_SOCKET_PATH) { + const rejection = await handleCollabSocketUpgrade(req, harness.db, srv) + if (rejection === null) return undefined + return rejection + } + return new Response('not found', { status: 404 }) + }, + websocket: socketLayer.handlers, + }) + socketLayer.setPublisher(server) + cleanups.push(() => server.stop(true)) + + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + return { + harness, + relay, + url: `ws://localhost:${server.port}${SITE_SOCKET_PATH}`, + cookie, + homeId: rows[0].id, + } +} + +/** Real editor transport over a real WebSocket, authenticated by cookie. */ +function connectClient( + stack: Stack, + cookie = stack.cookie, +): CollabProvider & { lastSocket: () => WebSocket | null } { + let lastSocket: WebSocket | null = null + const provider = createCollabProvider({ + createSocket: () => { + // Bun's WebSocket client supports custom handshake headers. + lastSocket = new WebSocket(stack.url, { headers: { cookie } }) + return lastSocket as unknown as CollabSocketLike + }, + }) + cleanups.push(() => provider.destroy()) + return Object.assign(provider, { lastSocket: () => lastSocket }) +} + +function setNodeLabel(doc: Y.Doc, nodeId: string, label: string): void { + doc.transact(() => { + const nodes = treeMap(doc).get('nodes') as Y.Map + const node = nodes.get(nodeId) as Y.Map + node.set('label', label) + }, LOCAL_ORIGIN) +} + +function nodeLabel(doc: Y.Doc, nodeId: string): unknown { + const nodes = treeMap(doc).get('nodes') as Y.Map | undefined + const node = nodes?.get(nodeId) as Y.Map | undefined + return node?.get('label') +} + +function insertChildNode(doc: Y.Doc, nodeId: string, moduleId: string): void { + doc.transact(() => { + const tree = treeMap(doc) + const nodes = tree.get('nodes') as Y.Map + const rootId = tree.get('rootNodeId') as string + const node = new Y.Map() + node.set('moduleId', moduleId) + node.set('props', new Y.Map()) + node.set('breakpointOverrides', new Y.Map()) + node.set('children', new Y.Array()) + node.set('classIds', []) + nodes.set(nodeId, node) + const rootChildren = (nodes.get(rootId) as Y.Map).get('children') as Y.Array + rootChildren.push([nodeId]) + }, LOCAL_ORIGIN) +} + +describe('collab relay integration (real server, real sockets)', () => { + it('two clients edit concurrently, converge, and the relay persists blob + derived JSON', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const clientA = connectClient(stack) + const clientB = connectClient(stack) + const boundA = clientA.bind(docId) + const boundB = clientB.bind(docId) + await boundA.whenSynced + await boundB.whenSynced + + const rootId = treeMap(boundA.doc).get('rootNodeId') as string + expect(treeMap(boundB.doc).get('rootNodeId')).toBe(rootId) + + // Concurrent edits on DIFFERENT nodes: A relabels the root, B inserts a + // sibling — merged, not last-writer-wins. + setNodeLabel(boundA.doc, rootId, 'Renamed by A') + insertChildNode(boundB.doc, 'node-from-b', 'base.text') + + await waitFor( + () => + nodeLabel(boundB.doc, rootId) === 'Renamed by A' && + (treeMap(boundA.doc).get('nodes') as Y.Map).has('node-from-b'), + ) + + // The relay persists the blob AND the derived row JSON after its debounce. + await waitFor(async () => { + const stored = await getCollabDocumentState(stack.harness.db, docId) + if (!stored) return false + const restored = new Y.Doc() + Y.applyUpdate(restored, stored.state) + return nodeLabel(restored, rootId) === 'Renamed by A' + }) + await waitFor(async () => { + const row = await getDataRow(stack.harness.db, stack.homeId) + if (!row) return false + const page = pageFromRow(row) + return page.nodes[rootId]?.label === 'Renamed by A' && page.nodes['node-from-b'] !== undefined + }) + }) + + it('refuses a read-only edit AND resets the viewer so its own screen reverts', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const viewer = await stack.harness.createRoleUser({ + name: 'Read Only', + slug: 'collab-viewer', + capabilities: ['site.read'], + }) + const writer = connectClient(stack) + const readOnly = connectClient(stack, viewer.cookie) + const resets: string[] = [] + readOnly.onReset((id) => resets.push(id)) + const boundWriter = writer.bind(docId) + const boundReadOnly = readOnly.bind(docId) + await boundWriter.whenSynced + await boundReadOnly.whenSynced + + const rootId = treeMap(boundReadOnly.doc).get('rootNodeId') as string + // The read-only client mutates its local doc (a not-yet-disabled UI + // affordance, a plugin, the console). The server refuses the update — no + // other peer ever sees it. + setNodeLabel(boundReadOnly.doc, rootId, 'Sneaky viewer edit') + + // Happened-after marker on a DIFFERENT key — writing the same key would + // make the assertion depend on Yjs' concurrent-set clientID tiebreak + // (random per run), not on the server's refusal. Once the marker lands on + // the viewer, the server has processed both frames. + boundWriter.doc.transact(() => { + const nodes = treeMap(boundWriter.doc).get('nodes') as Y.Map + ;(nodes.get(rootId) as Y.Map).set('marker', 'writer-was-here') + }, LOCAL_ORIGIN) + await waitFor(() => { + const nodes = treeMap(boundReadOnly.doc).get('nodes') as Y.Map + return (nodes.get(rootId) as Y.Map).get('marker') === 'writer-was-here' + }) + + // The sneaky edit never reached the WRITER — the guard refused it. + expect(nodeLabel(boundWriter.doc, rootId)).not.toBe('Sneaky viewer edit') + + // …and the refusal RESETS the viewer, so its own optimistic edit reverts + // instead of stranding it in a divergent doc that still reports "synced". + // A read-only connection used to be dropped silently at a `canWrite` gate + // with no reset, leaving exactly that permanent local divergence. + await waitFor(() => resets.includes(docId)) + const rebound = readOnly.bind(docId) + await rebound.whenSynced + expect(nodeLabel(rebound.doc, rootId)).not.toBe('Sneaky viewer edit') + }) + + it('enforces per-category capabilities on partial writers and relays read-only presence', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const contentUser = await stack.harness.createRoleUser({ + name: 'Copy Editor', + slug: 'collab-content-only', + capabilities: ['site.read', 'site.content.edit'], + }) + const viewer = await stack.harness.createRoleUser({ + name: 'Viewer', + slug: 'collab-presence-viewer', + capabilities: ['site.read'], + }) + + const owner = connectClient(stack) + const contentClient = connectClient(stack, contentUser.cookie) + const viewerClient = connectClient(stack, viewer.cookie) + const boundOwner = owner.bind(docId) + const boundContent = contentClient.bind(docId) + viewerClient.bind(docId) + await boundOwner.whenSynced + await boundContent.whenSynced + + // Owner (full writer) adds a text node the content editor may write to. + insertChildNode(boundOwner.doc, 'text-node', 'base.text') + await waitFor(() => + (treeMap(boundContent.doc).get('nodes') as Y.Map).has('text-node'), + ) + + // ALLOWED: a content-category prop change from the content-only editor. + boundContent.doc.transact(() => { + const nodes = treeMap(boundContent.doc).get('nodes') as Y.Map + const props = (nodes.get('text-node') as Y.Map).get('props') as Y.Map + props.set('text', 'copy edited') + }, LOCAL_ORIGIN) + await waitFor(() => { + const nodes = treeMap(boundOwner.doc).get('nodes') as Y.Map + const props = (nodes.get('text-node') as Y.Map).get('props') as Y.Map + return props.get('text') === 'copy edited' + }) + + // REJECTED: a structural change (node insertion) from the same editor — + // the server refuses the update and resets the sender's doc. + const resets: string[] = [] + contentClient.onReset((id) => resets.push(id)) + insertChildNode(boundContent.doc, 'forbidden-node', 'base.container') + await waitFor(() => resets.includes(docId)) + // Reset was sent INSTEAD of applying — the authoritative doc never saw it. + expect((treeMap(boundOwner.doc).get('nodes') as Y.Map).has('forbidden-node')).toBe(false) + + // Read-only presence: a viewer's awareness state reaches other peers + // (presence is not a doc write). Identity is server-verified — the state + // must claim the SESSION's FULL identity (id + name + avatar + gravatar) + // or the frame is dropped, so a peer can't paint another admin's name. + const viewerUser = await findUserByEmail(stack.harness.db, viewer.email) + const viewerUserId = viewerUser!.id + const realIdentity = { + id: viewerUserId, + name: viewerUser!.displayName, + color: peerColor(viewerUserId), + avatarUrl: viewerUser!.avatarUrl, + gravatarHash: viewerUser!.gravatarHash, + } + // A frame keeping the real id but faking the name is still a spoof. + viewerClient.awareness.setLocalState({ user: { ...realIdentity, name: 'Owner Impersonator' } }) + viewerClient.awareness.setLocalState({ user: { id: 'someone-else', name: 'Spoof' } }) + viewerClient.awareness.setLocalState({ user: realIdentity }) + await waitFor(() => { + for (const [, state] of owner.awareness.getStates()) { + const s = state as { user?: { id?: string; name?: string } } + if (s.user?.id === viewerUserId) return true + } + return false + }) + // Neither spoofed frame relayed: no foreign id, no impersonated name. + for (const [, state] of owner.awareness.getStates()) { + const s = state as { user?: { id?: string; name?: string } } + expect(s.user?.id).not.toBe('someone-else') + if (s.user?.id === viewerUserId) expect(s.user?.name).toBe(viewerUser!.displayName) + } + }) + + it('resets a doc when the row is written outside the relay', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const client = connectClient(stack) + const bound = client.bind(docId) + await bound.whenSynced + + const resets: string[] = [] + client.onReset((id) => resets.push(id)) + + // Out-of-relay write (imports, plugins, HTTP save): mutate the stored + // JSON directly — the repository notifies, the relay drops the doc and + // broadcasts FRAME_RESET. + const row = await getDataRow(stack.harness.db, stack.homeId) + await saveDataRowDraft(stack.harness.db, stack.homeId, { + cells: { ...row!.cells, title: 'Rewritten outside the relay' }, + slug: row!.slug, + }) + + await waitFor(() => resets.includes(docId)) + + // Rebinding gets a FRESH server seed carrying the out-of-relay write. + const rebound = client.bind(docId) + await rebound.whenSynced + const projected = projectPageDoc(rebound.doc, stack.homeId) + expect(projected.title).toBe('Rewritten outside the relay') + }) + + it('a reconnecting client catches up on edits it missed while offline', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const clientA = connectClient(stack) + const clientB = connectClient(stack) + const boundA = clientA.bind(docId) + const boundB = clientB.bind(docId) + await boundA.whenSynced + await boundB.whenSynced + const rootId = treeMap(boundA.doc).get('rootNodeId') as string + + // Drop A's socket; B keeps editing while A is offline. + clientA.lastSocket()?.close() + setNodeLabel(boundB.doc, rootId, 'Edited while A was away') + + // A's provider reconnects on its own (1s backoff) and step1's state + // vector pulls exactly the missed delta into the SAME doc. + await waitFor(() => nodeLabel(boundA.doc, rootId) === 'Edited while A was away', 8_000) + }, 12_000) + + it('a peer cannot erase another peer\'s presence for everyone', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const identityOf = async (email: string) => { + const user = (await findUserByEmail(stack.harness.db, email))! + return { + id: user.id, + name: user.displayName, + color: peerColor(user.id), + avatarUrl: user.avatarUrl, + gravatarHash: user.gravatarHash, + } + } + + const evictorUser = await stack.harness.createRoleUser({ + name: 'Evictor', + slug: 'collab-presence-evictor', + capabilities: ['site.read'], + }) + const victimUser = await stack.harness.createRoleUser({ + name: 'Victim', + slug: 'collab-presence-victim', + capabilities: ['site.read'], + }) + + // A third peer is the judge: presence is a broadcast, so what matters is + // what OTHER admins still see — not what the evictor sees in its own tab. + const watcher = connectClient(stack) + const evictor = connectClient(stack, evictorUser.cookie) + const victim = connectClient(stack, victimUser.cookie) + watcher.bind(docId) + evictor.bind(docId) + victim.bind(docId) + + const evictorIdentity = await identityOf(evictorUser.email) + const victimIdentity = await identityOf(victimUser.email) + + victim.awareness.setLocalState({ user: victimIdentity }) + + const watcherSees = (userId: string): boolean => { + for (const [, state] of watcher.awareness.getStates()) { + const s = state as { user?: { id?: string } } + if (s.user?.id === userId) return true + } + return false + } + await waitFor(() => watcherSees(victimIdentity.id)) + + // The evictor broadcasts a removal for a clientID it does NOT own. This is + // exactly what y-protocols emits on its own when it believes a peer timed + // out (`checkOutdatedAwarenessStates`) — routine chatter, not an attack — + // but honouring it would let any peer evict any other peer for EVERYONE. + // The relay refuses: presence is cleared only by its owner, or by that + // owner's disconnect. + awarenessProtocol.removeAwarenessStates( + evictor.awareness, + [victim.awareness.clientID], + 'evict', + ) + + // Order the wire: this legit frame is sent AFTER the removal, so once the + // watcher sees the evictor, the relay has already decided the removal's + // fate. Without this the assertion below could pass simply by racing ahead + // of a clear that was about to land. + evictor.awareness.setLocalState({ user: evictorIdentity }) + await waitFor(() => watcherSees(evictorIdentity.id)) + + // The victim never re-announced — if the relay had honoured the foreign + // clear, they would be gone from the watcher's roster by now. + expect(watcherSees(victimIdentity.id)).toBe(true) + }) + + it('runPublishFlush persists edits still inside the relay debounce window', async () => { + // The publisher reads ROWS, not Y docs. Every publish path awaits + // `runPublishFlush()` first (see publishFlush.ts) so an edit made seconds + // before the click is baked instead of lost to the debounce. Without this + // seam the published HTML silently trails the editor. + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + const cookie = await harness.setupOwner() + // A debounce long enough that nothing persists on its own during the test: + // any row change we observe can ONLY have come from the explicit flush. + const relay = createCollabRelay(harness.db, { persistDebounceMs: 60_000 }) + cleanups.push(() => relay.destroy()) + const socketLayer = createCollabSocketLayer(relay) + + const server = Bun.serve({ + port: 0, + fetch: async (req, srv) => { + if (new URL(req.url).pathname === SITE_SOCKET_PATH) { + const rejection = await handleCollabSocketUpgrade(req, harness.db, srv) + if (rejection === null) return undefined + return rejection + } + return new Response('not found', { status: 404 }) + }, + websocket: socketLayer.handlers, + }) + socketLayer.setPublisher(server) + cleanups.push(() => server.stop(true)) + + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + const homeId = rows[0].id + const stack: Stack = { + harness, + url: `ws://localhost:${server.port}${SITE_SOCKET_PATH}`, + cookie, + homeId, + } + + const client = connectClient(stack) + const bound = client.bind(`page:${homeId}`) + await bound.whenSynced + const rootId = treeMap(bound.doc).get('rootNodeId') as string + + // A second client is the honest way to observe the SERVER's doc: once the + // edit reaches this peer, the relay has definitely applied it. Asserting on + // the editing client's own doc would pass before the frame ever left it. + const observer = connectClient(stack) + const boundObserver = observer.bind(`page:${homeId}`) + await boundObserver.whenSynced + + setNodeLabel(bound.doc, rootId, 'Edited seconds before publish') + await waitFor(() => nodeLabel(boundObserver.doc, rootId) === 'Edited seconds before publish') + + const labelInRow = async (): Promise => { + const row = await getDataRow(harness.db, homeId) + return pageFromRow(row!).nodes[rootId]?.label + } + + // The relay holds the edit in memory, but the ROW the publisher reads does + // not — the debounce has not elapsed. + expect(await labelInRow()).not.toBe('Edited seconds before publish') + + // This is what every publish path does before it reads rows. + await runPublishFlush() + + expect(await labelInRow()).toBe('Edited seconds before publish') + }) + + // ── CRDT lineage ────────────────────────────────────────────────────────── + + it('refuses a stale lineage instead of merging a dead generation', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const client = connectClient(stack) + const bound = client.bind(docId) + await bound.whenSynced + const rootId = treeMap(bound.doc).get('rootNodeId') as string + setNodeLabel(bound.doc, rootId, 'Before the reset') + await waitFor(async () => { + const row = await getDataRow(stack.harness.db, stack.homeId) + return Boolean(row && pageFromRow(row).nodes[rootId]?.label === 'Before the reset') + }) + + const resets: string[] = [] + client.onReset((id, reason) => resets.push(`${id}:${reason}`)) + + // Reset the doc the way an out-of-relay write does. The client is still + // bound and still holds generation N, whose structs sit at the very + // coordinates the reseeded generation N+1 now occupies. + await stack.relay.resetDocs([docId]) + + // The client's own frames must not be merged into the new lineage. + await waitFor(() => resets.length > 0) + expect(resets[0]).toBe(`${docId}:rewritten`) + + // And the authoritative doc must project the reseeded content — no ghost + // nodes carried over from the dead lineage. + const { doc: authoritative } = await stack.relay.openDoc(docId) + const nodes = treeMap(authoritative).get('nodes') as Y.Map + expect(nodes.has(rootId)).toBe(true) + }) + + it('a frame stamped with a dead generation is answered with a reset, not applied', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + const client = connectClient(stack) + const bound = client.bind(docId) + await bound.whenSynced + + const { generation: live } = await stack.relay.openDoc(docId) + const rootId = treeMap(bound.doc).get('rootNodeId') as string + const before = nodeLabel(bound.doc, rootId) + + const socket = client.lastSocket()! + const encoder = encoding.createEncoder() + const forged = new Y.Doc() + Y.applyUpdate(forged, Y.encodeStateAsUpdate(bound.doc)) + setNodeLabel(forged, rootId, 'From a dead lineage') + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(forged)) + socket.send( + encodeCollabFrame(docId, `${live}-dead`, FRAME_SYNC, encoding.toUint8Array(encoder)), + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + const { doc: authoritative } = await stack.relay.openDoc(docId) + expect(nodeLabel(authoritative, rootId)).toBe(before) + }) + + it('answers a ping on a read-only connection without touching the relay', async () => { + const stack = await startStack() + const viewer = await stack.harness.createRoleUser({ + name: 'Ping Viewer', + slug: 'collab-ping-viewer', + capabilities: ['site.read'], + }) + const client = connectClient(stack, viewer.cookie) + // Give the socket a moment to open before probing liveness. + await waitFor(() => client.status() === 'connected') + + const socket = client.lastSocket()! + const pongs: number[] = [] + socket.addEventListener('message', (event: MessageEvent) => { + const data = new Uint8Array(event.data as ArrayBuffer) + pongs.push(decodeCollabFrame(data).frameType) + }) + socket.send(encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_PING, new Uint8Array())) + + await waitFor(() => pongs.includes(FRAME_PONG)) + expect(client.status()).toBe('connected') + }) + + // The mirror of "a reconnecting client catches up on edits it missed": that + // test proves server → client. This proves client → server, which is the + // direction that was silently dropping work. + it('recovers edits authored while the socket was down, on reconnect', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const client = connectClient(stack) + const bound = client.bind(docId) + await bound.whenSynced + const rootId = treeMap(bound.doc).get('rootNodeId') as string + + // Kill the transport, then edit. The frame is dropped on the floor — + // `sendFrame` cannot reach a closed socket. + client.lastSocket()!.close() + await waitFor(() => client.status() !== 'connected') + setNodeLabel(bound.doc, rootId, 'Written while offline') + + // The relay has NOT seen it. + const { doc: beforeReconnect } = await stack.relay.openDoc(docId) + expect(nodeLabel(beforeReconnect, rootId)).not.toBe('Written while offline') + + // Reconnect. The server asks what this client holds, and the client's + // existing readSyncMessage answers with exactly the missing delta. + client.reconnectNow() + await waitFor(async () => { + const { doc: authoritative } = await stack.relay.openDoc(docId) + return nodeLabel(authoritative, rootId) === 'Written while offline' + }) + + // And it reaches storage, not just memory. + await waitFor(async () => { + const row = await getDataRow(stack.harness.db, stack.homeId) + return Boolean(row && pageFromRow(row).nodes[rootId]?.label === 'Written while offline') + }) + }) +}) diff --git a/src/__tests__/server/collabSocket.test.ts b/src/__tests__/server/collabSocket.test.ts new file mode 100644 index 000000000..b1e8fad2b --- /dev/null +++ b/src/__tests__/server/collabSocket.test.ts @@ -0,0 +1,121 @@ +/** + * Collab socket — upgrade gating (session + Origin + write capability) for + * the co-editing WebSocket. The full two-client convergence round-trip runs + * in collabRelayIntegration.test.ts against a real Bun.serve. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { SITE_SOCKET_PATH } from '@core/collab' +import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' +import { + handleCollabSocketUpgrade, + type CollabSocketData, +} from '../../../server/collab/socket' +import { + createCapabilityTestHarness, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +let cleanups: Array<() => Promise> = [] + +afterEach(async () => { + for (const fn of cleanups.reverse()) await fn() + cleanups = [] +}) + +async function setup(): Promise<{ harness: CapabilityTestHarness; relay: CollabRelay; cookie: string }> { + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + const cookie = await harness.setupOwner() + const relay = createCollabRelay(harness.db, { persistDebounceMs: 10 }) + cleanups.push(() => relay.destroy()) + return { harness, relay, cookie } +} + +/** `cookie`/`origin` are fetch-forbidden constructor headers — set after construction. */ +function socketRequest(headers: Record): Request { + const req = new Request(`http://localhost${SITE_SOCKET_PATH}`) + for (const [name, value] of Object.entries(headers)) req.headers.set(name, value) + return req +} + +function fakeServer(upgradeResult: boolean): { + upgrade: (req: Request, options: { data: CollabSocketData }) => boolean + data: () => CollabSocketData | null +} { + let captured: CollabSocketData | null = null + return { + upgrade: (_req, options) => { + captured = options.data + return upgradeResult + }, + data: () => captured, + } +} + +describe('collab socket — upgrade gating', () => { + it('rejects an unauthenticated handshake with 401 before upgrading', async () => { + const { harness } = await setup() + const server = fakeServer(true) + const res = await handleCollabSocketUpgrade(socketRequest({}), harness.db, server) + expect(res?.status).toBe(401) + expect(server.data()).toBeNull() + }) + + it('rejects a cross-origin handshake with 403 (CSWSH defense) even with a valid session', async () => { + const { harness, cookie } = await setup() + const server = fakeServer(true) + const res = await handleCollabSocketUpgrade( + socketRequest({ cookie, origin: 'https://evil.example' }), + harness.db, + server, + ) + expect(res?.status).toBe(403) + expect(server.data()).toBeNull() + }) + + it('upgrades an authenticated same-origin handshake with fullSiteWriter resolved from capabilities', async () => { + const { harness, cookie } = await setup() + const server = fakeServer(true) + expect(await handleCollabSocketUpgrade(socketRequest({ cookie }), harness.db, server)).toBeNull() + // The owner holds every site-write cap, so it skips the per-update guard. + expect(server.data()?.fullSiteWriter).toBe(true) + + // A partial writer (one category) is NOT a full writer — its frames run + // through the guard, same path a read-only viewer takes. + const contentOnly = await harness.createRoleUser({ + name: 'Content Only', + slug: 'site-content-only', + capabilities: ['site.read', 'site.content.edit'], + }) + const contentServer = fakeServer(true) + expect( + await handleCollabSocketUpgrade( + socketRequest({ cookie: contentOnly.cookie }), + harness.db, + contentServer, + ), + ).toBeNull() + expect(contentServer.data()?.fullSiteWriter).toBe(false) + + const readOnly = await harness.createRoleUser({ + name: 'Site Viewer', + slug: 'site-viewer-only', + capabilities: ['site.read'], + }) + const readOnlyServer = fakeServer(true) + expect( + await handleCollabSocketUpgrade( + socketRequest({ cookie: readOnly.cookie }), + harness.db, + readOnlyServer, + ), + ).toBeNull() + expect(readOnlyServer.data()?.fullSiteWriter).toBe(false) + }) + + it('returns 426 when the request is not an upgradable WebSocket handshake', async () => { + const { harness, cookie } = await setup() + const res = await handleCollabSocketUpgrade(socketRequest({ cookie }), harness.db, fakeServer(false)) + expect(res?.status).toBe(426) + }) +}) diff --git a/src/__tests__/server/collabUpdateGuard.test.ts b/src/__tests__/server/collabUpdateGuard.test.ts new file mode 100644 index 000000000..8c35ae885 --- /dev/null +++ b/src/__tests__/server/collabUpdateGuard.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import { + dataMap, + metaMap, + rostersMap, + seedComponentDoc, + seedLayoutDoc, + seedPageDoc, + seedSiteDoc, + shellMap, + SITE_DOC_ID, + treeMap, +} from '@core/collab' +import type { CoreCapability } from '@core/capabilities' +import type { SavedLayout } from '@core/layouts' +import { validateGuardedUpdate } from '../../../server/collab/updateGuard' +import { makeNode, makePage, makeSite, makeVC } from '../fixtures' + +const CONTENT: CoreCapability[] = ['site.content.edit'] +const STYLE: CoreCapability[] = ['site.style.edit'] +const STRUCTURE: CoreCapability[] = ['site.structure.edit'] + +function updateFrom(doc: Y.Doc, mutate: (fork: Y.Doc) => void): Uint8Array { + const fork = new Y.Doc() + Y.applyUpdate(fork, Y.encodeStateAsUpdate(doc)) + const stateVector = Y.encodeStateVector(doc) + fork.transact(() => mutate(fork)) + return Y.encodeStateAsUpdate(fork, stateVector) +} + +function nodeMap(doc: Y.Doc, nodeId: string): Y.Map { + const nodes = treeMap(doc).get('nodes') as Y.Map + return nodes.get(nodeId) as Y.Map +} + +function expectAllowed( + docId: string, + doc: Y.Doc, + update: Uint8Array, + capabilities: CoreCapability[], +): void { + expect(validateGuardedUpdate(docId, doc, update, capabilities)).toEqual({ ok: true }) +} + +function expectForbidden( + docId: string, + doc: Y.Doc, + update: Uint8Array, + capabilities: CoreCapability[], + reason: string, +): void { + expect(validateGuardedUpdate(docId, doc, update, capabilities)).toMatchObject({ + ok: false, + reason: expect.stringContaining(reason), + }) +} + +describe('collab update capability guard', () => { + it('separates page content, style, and structure updates', () => { + const page = makePage({ + id: 'page-1', + nodes: { + root: makeNode({ + id: 'root', + moduleId: 'base.body', + children: ['copy'], + }), + copy: makeNode({ + id: 'copy', + moduleId: 'base.text', + props: { text: 'Original' }, + }), + }, + }) + const doc = new Y.Doc() + seedPageDoc(doc, page) + const docId = 'page:page-1' + + const contentUpdate = updateFrom(doc, (fork) => { + const props = nodeMap(fork, 'copy').get('props') as Y.Map + const text = props.get('text') as Y.Text + text.insert(text.length, ' copy') + }) + expectAllowed(docId, doc, contentUpdate, CONTENT) + expectForbidden(docId, doc, contentUpdate, STYLE, 'forbidden content change') + + const styleUpdate = updateFrom(doc, (fork) => { + nodeMap(fork, 'copy').set('classIds', ['hero-copy']) + }) + expectAllowed(docId, doc, styleUpdate, STYLE) + expectForbidden(docId, doc, styleUpdate, CONTENT, 'forbidden style change') + + const structureUpdate = updateFrom(doc, (fork) => { + nodeMap(fork, 'copy').set('label', 'Hero copy') + }) + expectAllowed(docId, doc, structureUpdate, STRUCTURE) + expectForbidden(docId, doc, structureUpdate, CONTENT, 'forbidden structure change') + }) + + it('separates site-shell content, style, structure, and roster updates', () => { + const site = makeSite() + const doc = new Y.Doc() + seedSiteDoc(doc, site) + + const contentUpdate = updateFrom(doc, (fork) => { + const settings = shellMap(fork).get('settings') as Y.Map + settings.set('metaTitle', 'Collaborative title') + }) + expectAllowed(SITE_DOC_ID, doc, contentUpdate, CONTENT) + expectForbidden(SITE_DOC_ID, doc, contentUpdate, STYLE, 'forbidden content change') + + const styleUpdate = updateFrom(doc, (fork) => { + const styleRules = shellMap(fork).get('styleRules') as Y.Map + styleRules.set('hero-copy', { + id: 'hero-copy', + name: 'Hero copy', + styles: { color: 'var(--foreground)' }, + }) + }) + expectAllowed(SITE_DOC_ID, doc, styleUpdate, STYLE) + expectForbidden(SITE_DOC_ID, doc, styleUpdate, CONTENT, 'forbidden style change') + + const structureUpdate = updateFrom(doc, (fork) => { + shellMap(fork).set('name', 'Renamed site') + }) + expectAllowed(SITE_DOC_ID, doc, structureUpdate, STRUCTURE) + expectForbidden(SITE_DOC_ID, doc, structureUpdate, CONTENT, 'forbidden structure change') + + const rosterUpdate = updateFrom(doc, (fork) => { + const pages = rostersMap(fork).get('pages') as Y.Map + pages.set('page-2', true) + }) + expectAllowed(SITE_DOC_ID, doc, rosterUpdate, STRUCTURE) + expectForbidden(SITE_DOC_ID, doc, rosterUpdate, CONTENT, 'roster changes') + }) + + it('requires structure capability for component and layout documents', () => { + const componentDoc = new Y.Doc() + seedComponentDoc(componentDoc, makeVC({ id: 'component-1', name: 'Hero' })) + const componentUpdate = updateFrom(componentDoc, (fork) => { + metaMap(fork).set('name', 'Renamed hero') + }) + expectAllowed('component:component-1', componentDoc, componentUpdate, STRUCTURE) + expectForbidden( + 'component:component-1', + componentDoc, + componentUpdate, + CONTENT, + 'component changes require', + ) + + const layout: SavedLayout = { + id: 'layout-1', + name: 'Hero layout', + rootNodeId: 'root', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body' }), + }, + classes: {}, + createdAt: 1_700_000_000_000, + } + const layoutDoc = new Y.Doc() + seedLayoutDoc(layoutDoc, layout) + const layoutUpdate = updateFrom(layoutDoc, (fork) => { + dataMap(fork).set('snapshot', { + ...dataMap(fork).get('snapshot') as Record, + classes: { 'hero-layout': { id: 'hero-layout', name: 'Hero layout', styles: {} } }, + }) + }) + expectAllowed('layout:layout-1', layoutDoc, layoutUpdate, STRUCTURE) + expectForbidden( + 'layout:layout-1', + layoutDoc, + layoutUpdate, + STYLE, + 'layout changes require', + ) + }) + + it('rejects updates for unknown document ids', () => { + const doc = new Y.Doc() + const update = updateFrom(doc, (fork) => { + fork.getMap('unknown').set('value', true) + }) + expect(validateGuardedUpdate('unknown:doc', doc, update, STRUCTURE)).toEqual({ + ok: false, + reason: 'unknown doc id unknown:doc', + }) + }) +}) diff --git a/src/__tests__/server/dataCms.test.ts b/src/__tests__/server/dataCms.test.ts index 34b89de88..08a8a61a5 100644 --- a/src/__tests__/server/dataCms.test.ts +++ b/src/__tests__/server/dataCms.test.ts @@ -92,6 +92,7 @@ describe('data CMS repository', () => { primaryFieldId: 'title', fields: defaultFields, system: false, + capturesVisitorOwner: false, createdByUserId: null, updatedByUserId: null, createdAt: '2026-05-01T10:00:00.000Z', diff --git a/src/__tests__/server/holeComposePrefixLookup.test.ts b/src/__tests__/server/holeComposePrefixLookup.test.ts new file mode 100644 index 000000000..0e0e9e29d --- /dev/null +++ b/src/__tests__/server/holeComposePrefixLookup.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test' +// findPageForNodeId is the compose-aware lookup; verify it strips the c0_/t_ +// prefix that templateCompose splices onto wrapped-page node ids. +// Static unit test — no DB. Mirrors the architecture-test style. +import { findPageForNodeId, getPublishedNodeIndexForVersion } from '../../../server/publish/publishedSnapshotCache' + +// Build a minimal PublishedNodeIndex shape by hand (the type isn't exported, +// but findPageForNodeId accepts it structurally). +function makeIndex(nodeIds: string[]): Parameters[0] { + const nodeIndex = new Map() + for (const id of nodeIds) nodeIndex.set(id, { id: 'page-x' } as never) + return { site: {} as never, nodeIndex } as never +} + +describe('findPageForNodeId — compose-prefix awareness', () => { + it('finds a node by its original (non-composed) id', () => { + const idx = makeIndex(['abc123']) + expect(findPageForNodeId(idx, 'abc123')?.effectiveNodeId).toBe('abc123') + }) + + it('finds a node by its c0_-prefixed composed id (everywhere template terminal page)', () => { + const idx = makeIndex(['abc123']) + const found = findPageForNodeId(idx, 'c0_abc123') + expect(found).toBeDefined() + expect(found!.effectiveNodeId).toBe('abc123') + }) + + it('finds a node by its t_ prefixed composed id (outer template wrap)', () => { + const idx = makeIndex(['abc123']) + expect(findPageForNodeId(idx, 't0_abc123')?.effectiveNodeId).toBe('abc123') + expect(findPageForNodeId(idx, 't12_abc123')?.effectiveNodeId).toBe('abc123') + }) + + it('returns undefined for an id with no stripped match', () => { + const idx = makeIndex(['abc123']) + expect(findPageForNodeId(idx, 'c0_totally_missing')).toBeUndefined() + expect(findPageForNodeId(idx, 'no_such_node')).toBeUndefined() + }) + + it('does NOT strip a prefix that is part of the original id', () => { + // An authored id that genuinely starts with c0_ must still match itself. + const idx = makeIndex(['c0_realid']) + expect(findPageForNodeId(idx, 'c0_realid')?.effectiveNodeId).toBe('c0_realid') + }) + + it('getPublishedNodeIndexForVersion remains exported (handler import contract)', () => { + expect(typeof getPublishedNodeIndexForVersion).toBe('function') + }) +}) diff --git a/src/__tests__/server/mediaBatchResolution.test.ts b/src/__tests__/server/mediaBatchResolution.test.ts index 2713eff8e..b213cbf02 100644 --- a/src/__tests__/server/mediaBatchResolution.test.ts +++ b/src/__tests__/server/mediaBatchResolution.test.ts @@ -270,4 +270,33 @@ describe('prefetchMediaAssets (Finding 2)', () => { await cleanup() } }) + + it('resolves media ids stored in entry array fields and keys them by id and path', async () => { + const { db, cleanup } = await createTestDb() + try { + await insertMediaAsset(db, 'gallery-1', '/uploads/gallery-1.png') + const page = makePageWithImageProp('n1', 'content', 'no static media path') + const registry = { get: () => ({ id: 'base.text', schema: {} }) } as unknown as IModuleRegistry + + const map = await prefetchMediaAssets( + page as never, + { visualComponents: [] } as never, + registry, + db, + { + templateContext: { + entryStack: [{ + id: 'project-1', + fields: { gallery: ['gallery-1'] }, + }], + }, + }, + ) + + expect(map.get('gallery-1')?.publicPath).toBe('/uploads/gallery-1.png') + expect(map.get('/uploads/gallery-1.png')?.id).toBe('gallery-1') + } finally { + await cleanup() + } + }) }) diff --git a/src/__tests__/server/pluginContentFieldMapping.test.ts b/src/__tests__/server/pluginContentFieldMapping.test.ts index 5649a6575..922abcdd3 100644 --- a/src/__tests__/server/pluginContentFieldMapping.test.ts +++ b/src/__tests__/server/pluginContentFieldMapping.test.ts @@ -51,4 +51,53 @@ describe('pluginContentFieldsToDataFields', () => { ], new Map()), ).toThrow(/unknown table "authors"/) }) + + it('maps a one-level repeater schema and resolves its relation slugs', () => { + const [field] = pluginContentFieldsToDataFields([{ + type: 'repeater', + id: 'gallery', + label: 'Gallery', + itemLabelFieldId: 'caption', + fields: [ + { type: 'text', id: 'caption', label: 'Caption', required: true }, + { + type: 'media', + id: 'images', + label: 'Images', + mediaKind: 'image', + allowMultiple: true, + }, + { + type: 'relation', + id: 'credit', + label: 'Credit', + targetTableSlug: 'people', + }, + ], + }], new Map([['people', 'table-people']])) + + expect(field).toEqual({ + type: 'repeater', + id: 'gallery', + label: 'Gallery', + itemLabelFieldId: 'caption', + fields: [ + { type: 'text', id: 'caption', label: 'Caption', required: true }, + { + type: 'media', + id: 'images', + label: 'Images', + mediaKind: 'image', + allowMultiple: true, + }, + { + type: 'relation', + id: 'credit', + label: 'Credit', + targetTableId: 'table-people', + }, + ], + }) + expect(Value.Check(DataFieldSchema, field)).toBe(true) + }) }) diff --git a/src/__tests__/server/siteDocumentSave.test.ts b/src/__tests__/server/siteDocumentSave.test.ts index 78feb5d84..75cc27624 100644 --- a/src/__tests__/server/siteDocumentSave.test.ts +++ b/src/__tests__/server/siteDocumentSave.test.ts @@ -21,7 +21,12 @@ * - replace mode (imports): server-derived deletions, deleted*Ids must be * empty, * - the site-global sync seq: response seq strictly increases and is - * stamped on written AND deleted rows. + * stamped on written AND deleted rows, + * - conflict detection: incremental saves carry base seqs; a shipped row + * stored NEWER than its base (or with no base entry at all) 409s the + * whole save with a `conflicts` payload and writes nothing. The shell + * participates only when its content actually changed (row-only saves + * don't stamp the shell seq). Replace mode skips the check. * * Runs against a real isolated SQLite DB through the established capability * harness (`createCapabilityTestHarness` → `createTestDb`): migrations @@ -180,23 +185,73 @@ interface DocOverrides { deletedComponentIds?: string[] changedLayouts?: unknown[] deletedLayoutIds?: string[] + baseSeqs?: Record + shellBaseSeq?: number } -function putDoc(ctx: Ctx, overrides: DocOverrides = {}): Promise { +/** Every row id an incremental body touches — changed + deleted, all collections. */ +function touchedIds(body: { + changedPages: unknown[] + deletedPageIds: string[] + changedComponents: unknown[] + deletedComponentIds: string[] + changedLayouts: unknown[] + deletedLayoutIds: string[] +}): string[] { + const changed = [...body.changedPages, ...body.changedComponents, ...body.changedLayouts] + .map((row) => (row && typeof row === 'object' ? (row as { id?: unknown }).id : undefined)) + .filter((id): id is string => typeof id === 'string') + return [...changed, ...body.deletedPageIds, ...body.deletedComponentIds, ...body.deletedLayoutIds] +} + +/** Stored seqs for `ids` (soft-deleted rows included) — the up-to-date client's base map. */ +async function liveBaseSeqs(harness: CapabilityTestHarness, ids: string[]): Promise> { + if (ids.length === 0) return {} + const { rows } = await harness.db<{ id: string; seq: number }>` + select id, seq from data_rows + ` + const wanted = new Set(ids) + const bases: Record = {} + for (const row of rows) { + if (wanted.has(row.id)) bases[row.id] = Number(row.seq) + } + return bases +} + +async function liveShellSeq(harness: CapabilityTestHarness): Promise { + const { rows } = await harness.db<{ seq: number }>` + select seq from site where id = 'default' + ` + return rows[0] ? Number(rows[0].seq) : 0 +} + +/** + * PUT the document. Unless the test overrides them, `baseSeqs` and + * `shellBaseSeq` are derived from live storage — modeling a fully + * synchronized client, so pre-conflict scenarios stay focused on their own + * concern. Conflict tests pass stale values explicitly. + */ +async function putDoc(ctx: Ctx, overrides: DocOverrides = {}): Promise { + const json = { + mode: 'incremental' as const, + site: ctx.shell, + changedPages: [] as unknown[], + deletedPageIds: [] as string[], + changedComponents: [] as unknown[], + deletedComponentIds: [] as string[], + changedLayouts: [] as unknown[], + deletedLayoutIds: [] as string[], + ...overrides, + } + const body = { + ...json, + baseSeqs: json.baseSeqs ?? (await liveBaseSeqs(ctx.harness, touchedIds(json))), + shellBaseSeq: json.shellBaseSeq ?? (await liveShellSeq(ctx.harness)), + } return ctx.harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: ctx.cookie, - json: { - mode: 'incremental', - site: ctx.shell, - changedPages: [], - deletedPageIds: [], - changedComponents: [], - deletedComponentIds: [], - changedLayouts: [], - deletedLayoutIds: [], - ...overrides, - }, + json: body, }) } @@ -748,3 +803,217 @@ describe('site-document save — sync seq', () => { } }) }) + +// --------------------------------------------------------------------------- +// Conflict detection — baseSeqs / shellBaseSeq (multi-admin level A) +// --------------------------------------------------------------------------- + +describe('site-document save — conflict detection', () => { + it('409s a save whose shipped row is stored NEWER than its base seq; nothing is written', async () => { + const ctx = await setupHarness() + try { + const first = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + // "Admin A" edits the row again — storage moves past `first`. + const second = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin A v2')], + })) + + // "Admin B" saves from the stale base — rejected, atomically. + const res = await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin B stale')], + baseSeqs: { 'page-a': first }, + }) + expect(res.status).toBe(409) + const body = await readJson<{ error: string; conflicts: unknown[] }>(res) + expect(body.conflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq: second }]) + + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.cells_json.title).toBe('Admin A v2') + expect(rows.get('page-a')!.seq).toBe(second) + } finally { + await ctx.harness.cleanup() + } + }) + + it('Keep-mine: the identical save succeeds once the base seq is bumped to the conflict seq', async () => { + const ctx = await setupHarness() + try { + const first = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + const second = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin A v2')], + })) + + const stale = await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin B wins')], + baseSeqs: { 'page-a': first }, + }) + expect(stale.status).toBe(409) + + // Keep-mine bumps the base to the remote seq — the stated overwrite lands. + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin B wins')], + baseSeqs: { 'page-a': second }, + })) + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.cells_json.title).toBe('Admin B wins') + } finally { + await ctx.harness.cleanup() + } + }) + + it('client-created rows (no stored counterpart) pass with no base entry', async () => { + const ctx = await setupHarness() + try { + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-new', 'fresh')], + baseSeqs: {}, + })) + } finally { + await ctx.harness.cleanup() + } + }) + + it('a stored row shipped with NO base entry conflicts — a blind overwrite is never silent', async () => { + const ctx = await setupHarness() + try { + const seq = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about')], + })) + const res = await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'blind write')], + baseSeqs: {}, + }) + expect(res.status).toBe(409) + const body = await readJson<{ conflicts: unknown[] }>(res) + expect(body.conflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq }]) + } finally { + await ctx.harness.cleanup() + } + }) + + it('deleting a remotely-newer row conflicts; the row survives', async () => { + const ctx = await setupHarness() + try { + const first = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin A v2')], + })) + + const res = await putDoc(ctx, { + deletedPageIds: ['page-a'], + baseSeqs: { 'page-a': first }, + }) + expect(res.status).toBe(409) + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.deleted_at).toBeNull() + } finally { + await ctx.harness.cleanup() + } + }) + + it('editing a remotely-DELETED row conflicts — soft-deleted rows are visible to the check', async () => { + const ctx = await setupHarness() + try { + const first = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + // "Admin A" deletes the row — the deletion stamps a newer seq. + const second = await expectOk(await putDoc(ctx, { deletedPageIds: ['page-a'] })) + + const res = await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'stale edit')], + baseSeqs: { 'page-a': first }, + }) + expect(res.status).toBe(409) + const body = await readJson<{ conflicts: unknown[] }>(res) + expect(body.conflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq: second }]) + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.deleted_at).not.toBeNull() + } finally { + await ctx.harness.cleanup() + } + }) + + it('row-only saves do NOT stamp the shell seq (the shell write is skipped when unchanged)', async () => { + const ctx = await setupHarness() + try { + const before = await liveShellSeq(ctx.harness) + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about')], + })) + expect(await liveShellSeq(ctx.harness)).toBe(before) + + // The REAL editor bumps `updatedAt` on every mutation — a shell that + // differs only by that bookkeeping timestamp is still "unchanged". + await expectOk(await putDoc(ctx, { + site: { ...ctx.shell, updatedAt: Date.now() + 60_000 }, + changedPages: [pagePayload('page-a', 'about', 'About v2')], + })) + expect(await liveShellSeq(ctx.harness)).toBe(before) + } finally { + await ctx.harness.cleanup() + } + }) + + it('shell conflicts fire only on REAL shell changes with a stale base', async () => { + const ctx = await setupHarness() + try { + // "Admin A" renames the site — a real shell change stamps the shell seq. + await expectOk(await putDoc(ctx, { site: { ...ctx.shell, name: 'Renamed by A' } })) + const shellSeq = await liveShellSeq(ctx.harness) + expect(shellSeq).toBeGreaterThan(0) + + // "Admin B" ships ANOTHER shell change from a stale base — 409. + const res = await putDoc(ctx, { + site: { ...ctx.shell, name: 'Renamed by B (stale)' }, + shellBaseSeq: shellSeq - 1, + }) + expect(res.status).toBe(409) + const body = await readJson<{ conflicts: unknown[] }>(res) + expect(body.conflicts).toEqual([{ table: 'site', rowId: 'default', seq: shellSeq }]) + + // But a row-only save re-shipping the CURRENT stored shell verbatim + // passes even with a stale shell base — unchanged content is no + // overwrite, so the coarse shell check must not fire. + const shellRes = await ctx.harness.cms('/admin/api/cms/site', { method: 'GET', cookie: ctx.cookie }) + const { site: storedShell } = await readJson<{ site: unknown }>(shellRes) + await expectOk(await putDoc(ctx, { + site: storedShell, + changedPages: [pagePayload('page-b', 'contact')], + shellBaseSeq: 0, + })) + } finally { + await ctx.harness.cleanup() + } + }) + + it('replace mode skips the conflict check entirely (imports replace deliberately)', async () => { + const ctx = await setupHarness() + try { + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin A v2')], + })) + + // Stale bases + replace mode — bulldozes by design. + await expectOk(await putDoc(ctx, { + mode: 'replace', + changedPages: [pagePayload('page-a', 'about', 'Imported')], + baseSeqs: { 'page-a': 0 }, + shellBaseSeq: 0, + })) + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.cells_json.title).toBe('Imported') + } finally { + await ctx.harness.cleanup() + } + }) +}) diff --git a/src/__tests__/server/visitorProfileFields.test.ts b/src/__tests__/server/visitorProfileFields.test.ts new file mode 100644 index 000000000..923823e95 --- /dev/null +++ b/src/__tests__/server/visitorProfileFields.test.ts @@ -0,0 +1,100 @@ +/** + * Integration tests for the visitor profile-field repository layer. + * + * Pins the contract of `updateVisitorUserProfileFields` / `findVisitorUserById` + * against a real migrated SQLite DB (`createTestDb()` applies migration `025`, + * which adds `visitor_users.profile_fields_json`). The load-bearing contract + * documented in the repository: + * + * > Update a visitor's custom profile field VALUES. **Stores the whole map** + * > — callers should merge against the current values first. Empty object + * > is valid (clears all profile fields). + * + * This test pins that LAYERING: the repository writes the WHOLE map (no + * merge), so the merge — when it is wanted — is the handler's job, not the + * repo's. Asserting that a second write with a disjoint key REPLACES (not + * merges) keeps the layering honest and prevents the repo from silently + * growing merge logic that would duplicate the handler's responsibility. + */ +import { describe, expect, it, beforeAll, afterAll } from 'bun:test' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { + updateVisitorUserProfileFields, + findVisitorUserById, +} from '../../../server/visitor-auth/repositories' + +type Db = TestDb['db'] + +let testDb: TestDb +let db: Db + +/** + * Insert a fresh visitor user for a test. Mirrors the migration `021` + * `visitor_users` columns (`id, email, email_normalized, password_hash, + * display_name, role_id, status`) — the system `member` role is seeded by + * migration `021`, so it is reused as the role_id. + */ +async function seedVisitor(db: Db, id: string): Promise { + await db` + insert into visitor_users (id, email, email_normalized, password_hash, display_name, role_id, status) + values (${id}, ${id + '@example.com'}, ${id + '@example.com'}, 'h', ${id}, 'member', 'active') + ` +} + +beforeAll(async () => { + testDb = await createTestDb() + db = testDb.db +}) + +afterAll(async () => { + await testDb.cleanup() +}) + +describe('updateVisitorUserProfileFields / findVisitorUserById', () => { + it('writes the profile-field JSON and reads it back via findVisitorUserById', async () => { + await seedVisitor(db, 'v-profile') + + const updated = await updateVisitorUserProfileFields(db, 'v-profile', { schoolName: 'Oasis' }) + expect(updated).not.toBeNull() + expect(updated!.profileFields.schoolName).toBe('Oasis') + + // A fresh read round-trips through the column adapter / normaliser. + const reread = await findVisitorUserById(db, 'v-profile') + expect(reread).not.toBeNull() + expect(reread!.profileFields.schoolName).toBe('Oasis') + }) + + it('clears all profile fields when given an empty object', async () => { + await seedVisitor(db, 'v-clear') + // Seed a value first, then clear it. + await updateVisitorUserProfileFields(db, 'v-clear', { schoolName: 'Oasis', grade: 'Y2' }) + + const cleared = await updateVisitorUserProfileFields(db, 'v-clear', {}) + expect(cleared).not.toBeNull() + expect(cleared!.profileFields).toEqual({}) + + const reread = await findVisitorUserById(db, 'v-clear') + expect(reread!.profileFields).toEqual({}) + }) + + it('stores the WHOLE map (no merge) — pinning the documented layering', async () => { + await seedVisitor(db, 'v-merge') + + // First write carries key `a`. + await updateVisitorUserProfileFields(db, 'v-merge', { a: '1' }) + + // Second write carries a DISJOINT key `b`. Per the documented contract, + // the repository writes the whole map — it does NOT merge against the + // existing `a`. So `a` is gone and only `b` remains. + const result = await updateVisitorUserProfileFields(db, 'v-merge', { b: '2' }) + expect(result).not.toBeNull() + + // Result is { b: '2' }, NOT { a: '1', b: '2' } — the merge (when wanted) + // is the handler's responsibility, never the repo's. + expect(result!.profileFields).toEqual({ b: '2' }) + expect(result!.profileFields).not.toHaveProperty('a') + + const reread = await findVisitorUserById(db, 'v-merge') + expect(reread!.profileFields).toEqual({ b: '2' }) + }) +}) diff --git a/src/__tests__/server/visitorResolveCookie.test.ts b/src/__tests__/server/visitorResolveCookie.test.ts new file mode 100644 index 000000000..bf0565fb5 --- /dev/null +++ b/src/__tests__/server/visitorResolveCookie.test.ts @@ -0,0 +1,149 @@ +/** + * Integration tests for `resolveVisitorFromCookie` — the IDOR-safe identity + * boundary for the visitor-data framework. + * + * This resolver is the SINGLE place where cookie → visitor identity is + * resolved for the `visitor.current` and `visitor.owned-rows` loop sources. + * Its security contract (identity comes ONLY from the validated session + * cookie, never from request input) is locked statically by the architecture + * gate; these tests pin the RUNTIME behaviour across every branch: + * + * - no cookie map at all → null + * - cookie map without a session cookie → null + * - a session cookie that doesn't exist → null + * - a revoked / expired session → null + * - a valid session whose user is gone → null + * - a valid session → ResolvedVisitor (id/email/role/profile) + * + * Uses `createTestDb()` (real in-memory SQLite, all migrations applied) and + * seeds genuine session rows via the production `createSessionToken` / + * `hashSessionToken` pipeline — no hand-rolled token maths — so the + * integration with the cached `validateVisitorSession` is exercised truthfully. + * + * Each branch gets its OWN visitor + fresh random token so the process-global + * session cache can't alias one test's identity with another's. + */ +import { describe, expect, it, beforeAll, afterAll } from 'bun:test' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { resolveVisitorFromCookie } from '../../../server/visitor-auth/visitorData' +import { VISITOR_SESSION_COOKIE_NAME } from '../../../server/visitor-auth/types' +import { invalidateVisitorSessionCache } from '../../../server/visitor-auth/sessions' +import { createSessionToken, hashSessionToken, sessionExpiry } from '../../../server/auth/tokens' + +type Db = TestDb['db'] + +/** Insert a visitor role + user directly (mirrors migration 021 columns). */ +async function seedVisitor(db: Db, id: string, email: string, displayName?: string): Promise { + // The 'member' system role is seeded by migration 021; reuse it. + await db` + insert into visitor_users (id, email, email_normalized, password_hash, display_name, role_id, status, profile_fields_json) + values (${id}, ${email}, ${email}, 'h', ${displayName ?? email.split('@')[0]}, 'member', 'active', ${JSON.stringify({ schoolName: 'Oasis' })}) + ` +} + +/** + * Seed a session row directly and return the RAW token to put in a cookie. + * Inserts directly (rather than via createVisitorSession) so expired/revoked + * variants can be seeded — createVisitorSession re-reads via + * findActiveVisitorSessionByHash and would reject an already-inactive row. + * Uses the production token pipeline so the resolver's hash + DB lookup path + * is exercised end-to-end. Invalidates the process-global session cache for + * this idHash so a stale entry from another test can't leak in. + */ +async function seedSession( + db: Db, + userId: string, + overrides: { revoked?: boolean; expiresAt?: Date } = {}, +): Promise { + const token = createSessionToken() + const idHash = await hashSessionToken(token) + const expiresAt = overrides.expiresAt ?? sessionExpiry() + await db` + insert into visitor_sessions (id_hash, user_id, expires_at, ip_address, user_agent, device_label) + values (${idHash}, ${userId}, ${expiresAt}, ${null}, ${null}, '') + ` + if (overrides.revoked) { + await db`update visitor_sessions set revoked_at = ${new Date().toISOString()} where id_hash = ${idHash}` + } + invalidateVisitorSessionCache(idHash) + return token +} + +function cookieFromToken(token: string): Record { + return { [VISITOR_SESSION_COOKIE_NAME]: token } +} + +let testDb: TestDb +let db: Db + +beforeAll(async () => { + testDb = await createTestDb() + db = testDb.db +}) + +afterAll(async () => { + await testDb.cleanup() +}) + +describe('resolveVisitorFromCookie (IDOR identity boundary)', () => { + it('returns null when no cookie map is supplied', async () => { + expect(await resolveVisitorFromCookie(db, undefined)).toBeNull() + }) + + it('returns null when the cookie map has no visitor session cookie', async () => { + expect(await resolveVisitorFromCookie(db, { unrelated_cookie: 'x' })).toBeNull() + }) + + it('returns null for a session cookie whose token matches no session', async () => { + expect(await resolveVisitorFromCookie(db, cookieFromToken('not-a-real-token'))).toBeNull() + }) + + it('returns null for a REVOKED session', async () => { + await seedVisitor(db, 'v-revoked', 'revoked@example.com') + const token = await seedSession(db, 'v-revoked', { revoked: true }) + expect(await resolveVisitorFromCookie(db, cookieFromToken(token))).toBeNull() + }) + + it('returns null for an EXPIRED session', async () => { + await seedVisitor(db, 'v-expired', 'expired@example.com') + const token = await seedSession(db, 'v-expired', { expiresAt: new Date(Date.now() - 60_000) }) + expect(await resolveVisitorFromCookie(db, cookieFromToken(token))).toBeNull() + }) + + it('returns null when the session is valid but the visitor record is gone', async () => { + await seedVisitor(db, 'v-gone', 'gone@example.com') + const token = await seedSession(db, 'v-gone') + await db`update visitor_users set deleted_at = ${new Date().toISOString()} where id = ${'v-gone'}` + expect(await resolveVisitorFromCookie(db, cookieFromToken(token))).toBeNull() + }) + + it('resolves the full ResolvedVisitor for a valid session', async () => { + await seedVisitor(db, 'v-resolve', 'resolve@example.com', 'Resolve Me') + const token = await seedSession(db, 'v-resolve') + const resolved = await resolveVisitorFromCookie(db, cookieFromToken(token)) + + expect(resolved).not.toBeNull() + expect(resolved!.id).toBe('v-resolve') + expect(resolved!.email).toBe('resolve@example.com') + expect(resolved!.displayName).toBe('Resolve Me') + expect(resolved!.roleId).toBe('member') + expect(resolved!.roleName).toBe('member') // resolved via findVisitorRoleById + // Custom profile field values are carried through. + expect(resolved!.profileFields).toMatchObject({ schoolName: 'Oasis' }) + }) + + it('never inspects cookie values other than the session cookie', async () => { + await seedVisitor(db, 'v-forged', 'forged@example.com') + const token = await seedSession(db, 'v-forged') + // A cookie map carrying forged visitor/user ids must not influence the + // result — identity comes only from the validated session token. + const resolved = await resolveVisitorFromCookie(db, { + ...cookieFromToken(token), + visitorId: 'attacker', + userId: 'attacker', + id: 'attacker', + }) + expect(resolved).not.toBeNull() + expect(resolved!.id).toBe('v-forged') // not 'attacker' + }) +}) diff --git a/src/__tests__/settings/settingsModal.test.tsx b/src/__tests__/settings/settingsModal.test.tsx index 868442494..b126aabcc 100644 --- a/src/__tests__/settings/settingsModal.test.tsx +++ b/src/__tests__/settings/settingsModal.test.tsx @@ -409,30 +409,32 @@ describe('SettingsModal — PreferencesSection toggles', () => { openModal('preferences') render() const switches = screen.getAllByRole('switch') - expect(switches.length).toBe(12) + expect(switches.length).toBe(11) }) - it('Auto-save toggle has aria-checked="true" by default', () => { + it('Hover-preview toggle has aria-checked="true" by default', () => { openModal('preferences') render() - const autoSaveToggle = screen.getByRole('switch', { name: /auto-save/i }) - expect(autoSaveToggle.getAttribute('aria-checked')).toBe('true') + const previewToggle = screen.getByRole('switch', { name: /preview suggestions on hover/i }) + expect(previewToggle.getAttribute('aria-checked')).toBe('true') }) - it('retired snap-to-grid and reduce-motion preferences are not rendered', () => { + it('retired snap-to-grid, reduce-motion and auto-save preferences are not rendered', () => { openModal('preferences') render() expect(screen.queryByRole('switch', { name: /snap to grid/i })).toBeNull() expect(screen.queryByRole('switch', { name: /reduce motion/i })).toBeNull() + // Auto-save retired with live co-editing — the relay persists continuously. + expect(screen.queryByRole('switch', { name: /auto-save/i })).toBeNull() }) it('clicking a toggle flips its aria-checked state', () => { openModal('preferences') render() - const autoSaveToggle = screen.getByRole('switch', { name: /auto-save/i }) - expect(autoSaveToggle.getAttribute('aria-checked')).toBe('true') - fireEvent.click(autoSaveToggle) - expect(autoSaveToggle.getAttribute('aria-checked')).toBe('false') + const previewToggle = screen.getByRole('switch', { name: /preview suggestions on hover/i }) + expect(previewToggle.getAttribute('aria-checked')).toBe('true') + fireEvent.click(previewToggle) + expect(previewToggle.getAttribute('aria-checked')).toBe('false') }) it('hover preview toggle is enabled by default and can be disabled', () => { @@ -450,11 +452,11 @@ describe('SettingsModal — PreferencesSection toggles', () => { it('toggle labels are linked via htmlFor / id (label accessibility)', () => { openModal('preferences') render() - const autoSaveToggle = document.getElementById('pref-autoSave') - expect(autoSaveToggle).not.toBeNull() - const label = document.querySelector('label[for="pref-autoSave"]') + const previewToggle = document.getElementById('pref-hoverPreview') + expect(previewToggle).not.toBeNull() + const label = document.querySelector('label[for="pref-hoverPreview"]') expect(label).not.toBeNull() - expect(label!.textContent).toContain('Auto-save') + expect(label!.textContent).toContain('Preview suggestions on hover') }) }) diff --git a/src/__tests__/settings/settingsSections.test.tsx b/src/__tests__/settings/settingsSections.test.tsx index 450b9e0db..4e6b027a4 100644 --- a/src/__tests__/settings/settingsSections.test.tsx +++ b/src/__tests__/settings/settingsSections.test.tsx @@ -54,7 +54,7 @@ describe('PreferencesSection — catalog-driven rendering', () => { render() // Boolean preferences currently declared in `admin/pages/site/preferences/catalog.ts`: - // autoSave, hoverPreview, confirmBeforeDelete, + // hoverPreview, confirmBeforeDelete, // layersShowIcon, layersShowTag, layersShowClasses, // layersAutoExpandSelected, layersSmoothScroll, // dimInactiveBreakpoints, propertiesSmoothScroll, @@ -62,8 +62,7 @@ describe('PreferencesSection — catalog-driven rendering', () => { // spotlightTelemetryEnabled ← Phase 6: opt-in command-usage telemetry // Adding/removing a boolean preference is one catalog edit and this // assertion updates with it. - expect(screen.getAllByRole('switch')).toHaveLength(12) - expect(screen.getByRole('switch', { name: /auto-save/i })).toBeDefined() + expect(screen.getAllByRole('switch')).toHaveLength(11) expect(screen.getByRole('switch', { name: /preview suggestions on hover/i })).toBeDefined() expect(screen.getByRole('switch', { name: /confirm before deleting/i })).toBeDefined() expect(screen.getByRole('switch', { name: /show module icon/i })).toBeDefined() @@ -81,10 +80,10 @@ describe('PreferencesSection — catalog-driven rendering', () => { it('auto-renders one combobox per select catalog entry', () => { render() - // Select preferences: autoSaveDelay, theme, density, textScale, defaultBreakpoint + // Select preferences: theme, density, textScale, defaultBreakpoint + // (auto-save delay is gone — the collab relay persists continuously) const selects = screen.getAllByRole('combobox') - expect(selects.length).toBe(5) - expect(screen.getByRole('combobox', { name: /auto-save delay/i })).toBeDefined() + expect(selects.length).toBe(4) expect(screen.getByRole('combobox', { name: /theme/i })).toBeDefined() expect(screen.getByRole('combobox', { name: /ui density/i })).toBeDefined() expect(screen.getByRole('combobox', { name: /ui text size/i })).toBeDefined() diff --git a/src/__tests__/templates/templateModel.test.ts b/src/__tests__/templates/templateModel.test.ts index 743f9a967..eb60f0cf2 100644 --- a/src/__tests__/templates/templateModel.test.ts +++ b/src/__tests__/templates/templateModel.test.ts @@ -15,7 +15,6 @@ function resetStore() { _historyFuture: [], canUndo: false, canRedo: false, - hasUnsavedChanges: false, } as Parameters[0]) } @@ -60,14 +59,12 @@ describe('dynamic template model', () => { site, activePageId: page.id, activeDocument: { kind: 'page', pageId: page.id }, - hasUnsavedChanges: false, }) useEditorStore.getState().convertTemplateToPage(page.id) const nextPage = useEditorStore.getState().site?.pages[0] expect(nextPage?.template).toBeUndefined() expect(nextPage?.nodes[page.rootNodeId].dynamicBindings).toBeUndefined() - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) }) it('sets and removes a node dynamic binding without changing the static prop fallback', () => { @@ -80,7 +77,6 @@ describe('dynamic template model', () => { site, activePageId: page.id, activeDocument: { kind: 'page', pageId: page.id }, - hasUnsavedChanges: false, }) useEditorStore.getState().setNodeDynamicBinding(root.id, 'text', { source: 'currentEntry', diff --git a/src/__tests__/toolbar/toolbar.test.ts b/src/__tests__/toolbar/toolbar.test.ts index d8716c623..ae104b7c8 100644 --- a/src/__tests__/toolbar/toolbar.test.ts +++ b/src/__tests__/toolbar/toolbar.test.ts @@ -317,16 +317,6 @@ describe('PublishButton — publish state machine', () => { expect(state).toBe('error') }) - it('source emits role="alert" for error messages (Guideline #224)', () => { - const { readFileSync } = require('fs') - const src = readFileSync( - new URL('../../admin/pages/site/toolbar/PublishActionGroup.tsx', import.meta.url), - 'utf-8', - ) - // Error must be surfaced via role="alert" — not silently swallowed - expect(src).toContain("role={toast.tone === 'alert' ? 'alert' : 'status'}") - }) - it('source drives aria-busy during publish (via SplitButton busy prop)', () => { const { readFileSync } = require('fs') const src = readFileSync( @@ -347,19 +337,17 @@ describe('PublishButton — publish state machine', () => { expect(src).toContain('primaryTestId="toolbar-publish-btn"') }) - it('saves the current draft before calling the CMS publish endpoint', () => { + it('relies on the server-side relay flush instead of a client save before publish', () => { const { readFileSync } = require('fs') const src = readFileSync( new URL('../../admin/pages/site/toolbar/PublishButton.tsx', import.meta.url), 'utf-8', ) - // The publish call is wrapped in `runStepUp(() => publishCmsDraft())` - // so the StepUpProvider can intercept `step_up_required` and re-auth. - // We assert that save runs before that wrapped call lands. - const savePosition = src.indexOf('await onSave?.()') - const publishPosition = src.indexOf('publishCmsDraft()') - expect(savePosition).toBeGreaterThan(-1) - expect(publishPosition).toBeGreaterThan(savePosition) + // Live co-editing streams every edit to the server as it happens; the + // publish ENDPOINT flushes the relay's debounced persist. The button must + // not carry a client-side save path anymore. + expect(src).toContain('publishCmsDraft()') + expect(src).not.toContain('onSave') }) it('loads persisted publish status when the toolbar mounts', () => { @@ -372,13 +360,16 @@ describe('PublishButton — publish state machine', () => { expect(src).toContain('draftMatchesPublished') }) - it('returns from Published to Publish when the draft has unsaved changes', () => { + it('returns from Published to Publish when the draft moves on', () => { const { readFileSync } = require('fs') const src = readFileSync( new URL('../../admin/pages/site/toolbar/PublishButton.tsx', import.meta.url), 'utf-8', ) - expect(src).toContain('hasUnsavedChanges') + // Every store mutation (local or a remote peer's) produces a new `site` + // reference; the button compares against the reference captured at + // publish time and drops back to idle when they diverge. + expect(src).toContain('publishedSiteRef') expect(src).toContain("setState('idle')") }) @@ -563,33 +554,17 @@ describe('Toolbar — structural requirements', () => { expect(src).toContain('useEffect') }) - it('PublishButton uses one split publishing control with explicit draft actions', () => { + it('PublishButton reflects collaboration sync and has no manual save action', () => { const { readFileSync } = require('fs') const src = readFileSync( new URL('../../admin/pages/site/toolbar/PublishButton.tsx', import.meta.url), 'utf-8', ) - expect(src).toContain('PublishActionGroup') - expect(src).toContain('Draft saved') - expect(src).toContain('Unsaved draft') - expect(src).toContain("state === 'published' ? 'Published'") - expect(src).toContain('state === \'published\' ? CheckIcon') - expect(src).toContain("statusLabel={state === 'published' ? null : status.label}") + expect(src).toContain('Draft synced') + expect(src).toContain('Offline — reconnecting') expect(src).toContain('publishDisabled={disabled || state === \'published\'}') - expect(src).toContain('Save draft') - expect(src).toContain('Preview page') - // "Open live page" used to live in this menu — it's now a dedicated - // icon button (OpenLivePageButton) next to the avatar in the Toolbar - // shell so it's reachable from every admin route, not just the Site - // editor. Asserting it's gone from the menu keeps the two surfaces - // from drifting back into a duplicate action. - expect(src).not.toContain("label: 'Open live page'") - expect(src).not.toContain("'toolbar-open-page-new-tab-action'") - expect(src).toContain("'Retry publish'") - expect(src).not.toContain("label: 'Live'") - expect(src).not.toContain("'Publish failed'") + expect(src).not.toContain('Save draft') }) - it('PublishActionGroup keeps the status pill and delegates its split control to the shared SplitButton', () => { const { readFileSync } = require('fs') const src = readFileSync( diff --git a/src/__tests__/ui/segmentedControlChrome.test.tsx b/src/__tests__/ui/segmentedControlChrome.test.tsx index c4163961d..ec24bc473 100644 --- a/src/__tests__/ui/segmentedControlChrome.test.tsx +++ b/src/__tests__/ui/segmentedControlChrome.test.tsx @@ -28,9 +28,15 @@ describe('SegmentedControl editor chrome variants', () => { it('uses the recessed tab surface and top fade in Explorer Layers chrome', () => { const explorerSource = readFileSync('src/admin/pages/site/panels/ExplorerPanel/ExplorerPanel.tsx', 'utf8') const domPanelCss = readFileSync('src/admin/pages/site/panels/DomPanel/DomPanel.module.css', 'utf8') + const leftSidebarCss = readFileSync( + 'src/admin/pages/site/sidebars/LeftSidebar/LeftSidebar.module.css', + 'utf8', + ) expect(explorerSource).toContain('activeSurface="recessed"') expect(domPanelCss).toContain('.searchRow::after') - expect(domPanelCss).toContain('linear-gradient(180deg, var(--bg-body) 0%, transparent)') + expect(domPanelCss).toContain('linear-gradient(180deg, var(--panel-fade-bg) 0%, transparent)') + expect(leftSidebarCss).toContain('--panel-fade-bg: var(--bg-body)') + expect(leftSidebarCss).toContain('--panel-fade-bg: var(--bg-surface)') }) }) diff --git a/src/admin/AuthenticatedAdmin.tsx b/src/admin/AuthenticatedAdmin.tsx index b47fee4ca..c6f2a54c7 100644 --- a/src/admin/AuthenticatedAdmin.tsx +++ b/src/admin/AuthenticatedAdmin.tsx @@ -91,6 +91,10 @@ const UsersPage = prewarmedLazy( () => import('./pages/users/UsersPage').then((m) => ({ default: m.UsersPage })), { displayName: 'UsersPage' }, ) +const MembersPage = prewarmedLazy( + () => import('./pages/members/MembersPage').then((m) => ({ default: m.MembersPage })), + { displayName: 'MembersPage' }, +) const AiPage = prewarmedLazy( () => import('./pages/ai/AiPage').then((m) => ({ default: m.AiPage })), { displayName: 'AiPage' }, @@ -154,6 +158,7 @@ if (typeof window !== 'undefined') { pathname.startsWith('/admin/plugins/') ? PluginPage : pathname.startsWith('/admin/plugins') ? PluginsPage : pathname.startsWith('/admin/users') ? UsersPage : + pathname.startsWith('/admin/members') ? MembersPage : pathname.startsWith('/admin/ai') ? AiPage : pathname.startsWith('/admin/account') ? AccountPage : DashboardPage @@ -182,6 +187,7 @@ const ALL_WORKSPACE_PAGES = [ MediaPage, PluginsPage, UsersPage, + MembersPage, AiPage, AccountPage, PluginPage, @@ -195,6 +201,7 @@ function pageForSection(section: AdminWorkspace) { section === 'media' ? MediaPage : section === 'plugins' ? PluginsPage : section === 'users' ? UsersPage : + section === 'members' ? MembersPage : section === 'ai' ? AiPage : section === 'pluginPage' ? PluginPage : section === 'account' ? AccountPage : @@ -315,6 +322,7 @@ export default function AuthenticatedAdmin({ section, currentUser }: Authenticat section === 'media' ? : section === 'plugins' ? : section === 'users' ? : + section === 'members' ? : section === 'ai' ? : section === 'pluginPage' ? : section === 'account' ? : diff --git a/src/admin/access.ts b/src/admin/access.ts index 6228521ab..caedf3d70 100644 --- a/src/admin/access.ts +++ b/src/admin/access.ts @@ -287,6 +287,10 @@ export function canAccessWorkspace(user: CmsCurrentUser | null, workspace: Admin return canAccessPluginsWorkspace(user) case 'users': return canAccessUsersWorkspace(user) + case 'members': + // Visitor-auth management reuses the `users.manage` capability (a + // dedicated capability is deferred — see server/handlers/cms/visitorAuth.ts). + return hasCapability(user, 'users.manage') case 'ai': return canAccessAiWorkspace(user) case 'account': @@ -320,6 +324,8 @@ export function workspacePath(workspace: AdminWorkspace): string { return '/admin/plugins' case 'users': return '/admin/users' + case 'members': + return '/admin/members' case 'ai': return '/admin/ai' case 'pluginPage': diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 381f862ac..75dcb0bdd 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx @@ -30,8 +30,8 @@ * - Site explorer panel — site concepts: pages, components, styles, scripts * - CodeEditorPanel (Task #432) — center-stage, code editing * - * J12: usePersistence handles CMS draft load on mount, preference-gated - * 30s auto-save, toolbar Save, and Cmd+S immediate save. + * usePersistence handles the CMS draft load on mount and connects the + * live-collab provider — edits stream continuously; there is no manual save. * * Agent Panel: Phase D AI assistant — self-contained floating panel (Guideline #410). * Authenticates via ambient Claude Code credentials through the local Bun server. @@ -40,6 +40,7 @@ import { Toolbar } from '@admin/pages/site/toolbar/Toolbar' import { ZoomControls } from '@admin/pages/site/toolbar/ZoomControls' import { PublishButton } from '@admin/pages/site/toolbar/PublishButton' +import { PeerAvatarStack } from '@admin/pages/site/toolbar/PeerAvatarStack' import { useEditorAppearancePreferences } from '@admin/pages/site/preferences/editorPreferences' import { usePersistence } from '@admin/pages/site/hooks/usePersistence' import { useSiteEditorUrlSync } from '@admin/pages/site/hooks/useSiteEditorUrlSync' @@ -159,11 +160,9 @@ export function AdminCanvasLayout() { canEditContent: canEditContentFlag, canEditStyle: canEditStyleFlag, } - // J12 — wire persistence: load, auto-save, toolbar Save, Cmd+S. - const persistence = usePersistence('default', cmsAdapter, { - markNewSiteUnsaved: true, - enabled: true, - }) + // Boot the document lifecycle: HTTP load for first paint, then the collab + // provider — every edit streams live and the server relay persists. + const persistence = usePersistence('default', cmsAdapter, { enabled: true }) // Keep the open page in lockstep with the URL: consume `?page=` on // load, and mirror the active page's slug back into the address bar so it's // directly linkable. @@ -193,10 +192,6 @@ export function AdminCanvasLayout() { : null const loadEditorBody = usePostPaintEditorBodyGate() - async function saveBeforeWorkspaceNavigation(): Promise { - if (!useEditorStore.getState().hasUnsavedChanges) return - await persistence.saveSite() - } return ( @@ -217,11 +212,7 @@ export function AdminCanvasLayout() { faviconUrl={faviconUrl} section="site" adminNavigationSlot={( - + )} overlay={previewOpen && ( @@ -230,12 +221,9 @@ export function AdminCanvasLayout() { )} rightSlot={( <> + - + )} /> diff --git a/src/admin/modals/Settings/SettingsModal.tsx b/src/admin/modals/Settings/SettingsModal.tsx index c32b3da53..d1f7620b3 100644 --- a/src/admin/modals/Settings/SettingsModal.tsx +++ b/src/admin/modals/Settings/SettingsModal.tsx @@ -26,10 +26,12 @@ import { SettingsCogSolidIcon } from 'pixel-art-icons/icons/settings-cog-solid' import { CommandIcon } from 'pixel-art-icons/icons/command' import { UploadIcon } from 'pixel-art-icons/icons/upload' import { SlidersHorizontalIcon } from 'pixel-art-icons/icons/sliders-horizontal' +import { UsersSolidIcon } from 'pixel-art-icons/icons/users-solid' import { GeneralSection } from './sections/GeneralSection' import { PublishingSection } from './sections/PublishingSection' import { ShortcutsSection } from './sections/ShortcutsSection' import { PreferencesSection } from './sections/PreferencesSection' +import { MembersSection } from './sections/MembersSection' import s from './SettingsModal.module.css' // ─── Nav items ──────────────────────────────────────────────────────────────── @@ -42,6 +44,7 @@ const NAV_ITEMS = [ { id: 'shortcuts', label: 'Shortcuts', icon: CommandIcon, accent: 'sky' }, { id: 'publishing', label: 'Publishing', icon: UploadIcon, accent: 'mint' }, { id: 'preferences', label: 'Preferences', icon: SlidersHorizontalIcon, accent: 'peach' }, + { id: 'members', label: 'Members', icon: UsersSolidIcon, accent: 'peach' }, ] as const type SectionId = typeof NAV_ITEMS[number]['id'] @@ -212,6 +215,7 @@ export function SettingsModal() { {activeSection === 'shortcuts' && } {activeSection === 'publishing' && } {activeSection === 'preferences' && } + {activeSection === 'members' && } diff --git a/src/admin/modals/Settings/sections/MembersSection.module.css b/src/admin/modals/Settings/sections/MembersSection.module.css new file mode 100644 index 000000000..5c88290b6 --- /dev/null +++ b/src/admin/modals/Settings/sections/MembersSection.module.css @@ -0,0 +1,27 @@ +/* MembersSection — visitor-auth settings. + Shares the structural classes (field rows, toggles, section blocks) with + SettingsModal.module.css; this module only owns the save + status row. + Every colour comes from a token. */ + +.actions { + display: flex; + align-items: center; + gap: var(--space-l); + flex-wrap: wrap; + margin-top: var(--space-l); +} + +.status { + margin: 0; + font-size: var(--text-xs); + font-weight: 700; + line-height: 1.45; +} + +.status[data-kind="ok"] { + color: var(--success); +} + +.status[data-kind="error"] { + color: var(--danger); +} diff --git a/src/admin/modals/Settings/sections/MembersSection.tsx b/src/admin/modals/Settings/sections/MembersSection.tsx new file mode 100644 index 000000000..4fdde5a7e --- /dev/null +++ b/src/admin/modals/Settings/sections/MembersSection.tsx @@ -0,0 +1,425 @@ +/** + * MembersSection — visitor-auth configuration (Settings → Members). + * + * Self-contained settings form that loads the current visitor-auth config + * and the visitor role list on mount, lets an admin toggle auth on/off, + * open/close registration, set the login path, pick the default role, and + * set the default landing path for a logged-in visitor with no primary-group + * landing (D15). Saves via {@link saveVisitorAuthConfig}. + * + * Page-level protection is configured per-page in the editor (D14 — each + * page carries an `access` field; see the page "Access" control), NOT in + * this settings section. The retired Phase-1/2 `protectedPrefixes` model is + * gone. + * + * The whole section is gated by the `users.manage` capability — the same + * capability the server-side `/admin/api/cms/visitor-auth/*` routes require. + * Admins without it see a notice instead of the form. + */ +import { useEffect, useState } from 'react' +import { Button } from '@ui/components/Button' +import { Input } from '@ui/components/Input' +import { Select } from '@ui/components/Select' +import { Switch } from '@ui/components/Switch' +import { SkeletonBlock } from '@ui/components/Skeleton' +import { hasCapability } from '@admin/access' +import { useCurrentAdminUser } from '@admin/sessionContext' +import { + getVisitorAuthConfig, + listVisitorRoles, + saveVisitorAuthConfig, + type VisitorAuthConfig, + type VisitorProfileField, + type VisitorRole, +} from '@core/persistence' +import { getErrorMessage } from '@core/utils/errorMessage' +import s from '../SettingsModal.module.css' +import styles from './MembersSection.module.css' + +export function MembersSection() { + const currentUser = useCurrentAdminUser() + // `currentUser === null` only happens outside AdminSessionProvider (layout + // tests / SSR). Treat it as unrestricted there to keep preview mode + // usable; the real browser session always carries a user. + const canManage = !currentUser || hasCapability(currentUser, 'users.manage') + + if (!canManage) { + return ( +

+ You need the Users manage permission to configure visitor authentication. +

+ ) + } + + return +} + +interface LoadedState { + config: VisitorAuthConfig + roles: VisitorRole[] +} + +function MembersConfigForm() { + const [loaded, setLoaded] = useState(null) + const [loadError, setLoadError] = useState(null) + + useEffect(() => { + let cancelled = false + Promise.all([getVisitorAuthConfig(), listVisitorRoles()]) + .then(([config, roles]) => { + if (cancelled) return + setLoaded({ config, roles }) + }) + .catch((err: unknown) => { + if (cancelled) return + setLoadError(getErrorMessage(err, 'Could not load visitor-auth settings')) + }) + return () => { + cancelled = true + } + }, []) + + if (loadError) { + return

{loadError}

+ } + if (!loaded) { + return + } + + return +} + +interface MembersConfigFieldsProps { + initial: VisitorAuthConfig + roles: VisitorRole[] +} + +function MembersConfigFields({ initial, roles }: MembersConfigFieldsProps) { + const [enabled, setEnabled] = useState(initial.enabled) + const [registrationOpen, setRegistrationOpen] = useState(initial.registrationOpen) + const [loginPath, setLoginPath] = useState(initial.loginPath || '/login') + const [defaultRole, setDefaultRole] = useState(initial.defaultRole || '') + // D15: where a logged-in visitor with no primary-group landing is sent. + // Defaults to `/` when the saved config lacks a value. + const [defaultLandingPath, setDefaultLandingPath] = useState(initial.defaultLandingPath || '/') + // Per-visitor-data framework: site-builder-defined custom profile fields + // (e.g. "School name"). Values are stored per-visitor. + const [profileFields, setProfileFields] = useState( + initial.profileFields ?? [] + ) + + const [saving, setSaving] = useState(false) + const [status, setStatus] = useState<{ kind: 'ok' | 'error'; message: string } | null>(null) + + // Role picker options. The saved defaultRole can be a role id OR a role + // name (the server accepts both on save); make sure the current value is + // always selectable even if it no longer matches a row, so the dropdown + // doesn't silently snap to the first option. + const roleOptions = buildRoleOptions(roles, defaultRole) + + async function handleSave() { + setSaving(true) + setStatus(null) + try { + const config = await saveVisitorAuthConfig({ + enabled, + registrationOpen, + loginPath: loginPath.trim() || '/login', + defaultRole, + defaultLandingPath: defaultLandingPath.trim() || '/', + profileFields, + }) + // Reconcile local state with the server's cleaned response. + setEnabled(config.enabled) + setRegistrationOpen(config.registrationOpen) + setLoginPath(config.loginPath) + setDefaultRole(config.defaultRole) + setDefaultLandingPath(config.defaultLandingPath || '/') + setProfileFields(config.profileFields ?? []) + setStatus({ kind: 'ok', message: 'Visitor-auth settings saved.' }) + } catch (err) { + setStatus({ kind: 'error', message: getErrorMessage(err, 'Could not save visitor-auth settings') }) + } finally { + setSaving(false) + } + } + + const enabledId = 'members-enabled' + const registrationId = 'members-registration-open' + + return ( +
+

+ Control whether visitors can register and sign in, and where a logged-in + visitor lands. Page-level access is set per page in the editor. +

+ +
+

General

+ +
+
+
+ +

+ Enabling visitor auth lets visitors register and log in to protected pages. +

+
+ +
+ +
+
+ +

+ When on, visitors can create their own accounts. Turn off to make + sign-up invite-only (accounts must be created another way). +

+
+ +
+
+ +
+ + setLoginPath(e.target.value)} + disabled={saving} + placeholder="/login" + /> +
+ +
+ + setDefaultLandingPath(e.target.value)} + disabled={saving} + placeholder="/" + /> +
+

+ Control which pages members see via each page’s Access setting in the editor. +

+
+ +
+

Profile fields

+

+ Custom fields each visitor can carry (e.g. a school name) and that you + can display on member pages with the visitor.current loop. + Visitors cannot edit these from the portal — values are set here or + via the Members workspace. +

+ +
+ +
+ + {status && ( +

+ {status.message} +

+ )} +
+
+ ) +} + +/** + * Build the ` update(index, { id: slugifyFieldKey(e.target.value) })} + disabled={disabled} + placeholder="schoolName" + /> + +
+ + update(index, { label: e.target.value })} + disabled={disabled} + placeholder="School name" + /> +
+
+ + onChange('label', e.target.value)} - autoComplete="off" - /> -
- - {/* Required */} -
- Required - onChange('required', v)} - /> -
- - {/* Description */} -
- -