Phase 3 — Page Engine: Task Specifications
Purpose: The page engine: page CRUD + nested tree with fractional positions, lazy tree API, metadata (icon/cover/title/description), trash (soft delete/restore/purge + 30-day job), favorites, page permissions (engine v2 with parent-chain inheritance), sharing scopes with the public read endpoint, version history, the page shell UI, the page context menu, and the multi-user E2E proof. Tasks run strictly in order, TASK-0035 → TASK-0046. Phase exit criteria (verbatim from ROADMAP.md): 100k-block page tree loads in < 2 s (virtualized); trash/restore round-trip E2E-tested; unauthorized access rejected with audit event. Scope note: Phase 3 fixes the lazy tree contract (cursor pagination, ≤ 50/page, < 2 s response for deep trees) and the permission/audit gate; the virtualized 100k-block render is completed in Phase 4 (editor) against this contract. The
permission.deniedaudit event (deferred from Phase 2 with a recorded decision) lands in this phase, satisfying the "audit event" exit criterion.
Task Table
| ID | Title | Est. |
|---|---|---|
| TASK-0035 | Pages CRUD API (+ duplicate, archive) | L |
| TASK-0036 | Page tree engine (positions, lazy endpoint, move + cycles) | L |
| TASK-0037 | Nested pages UI (sidebar tree, expand/collapse, indent) | L |
| TASK-0038 | Page metadata UI (icon, cover, title, description) | M |
| TASK-0039 | Trash (soft delete, restore, purge, 30-day job, UI) | M |
| TASK-0040 | Favorites (API + context menu + sidebar section) | S |
| TASK-0041 | Page permissions (engine v2 inheritance + grants API) | L |
| TASK-0042 | Sharing scopes API + public read endpoint | M |
| TASK-0043 | Page version history (snapshots, list/restore, UI panel) | M |
| TASK-0044 | Page shell UI (route, breadcrumbs, header, states) | M |
| TASK-0045 | Page context menu (12 actions wired to existing APIs) | M |
| TASK-0046 | E2E page flows (tests/e2e/pages.spec.ts) | L |
Phase Completion Checklist
- [ ] Every task above meets its Definition of Done
- [ ] Exit: deep tree endpoint < 2 s with cursor pagination (100k-scale fixture, virtualized render completes Phase 4 on this contract)
- [ ] Exit: trash/restore round-trip E2E-tested
- [ ] Exit: unauthorized access rejected with audit event (
permission.denied)
TASK-0035
Title
Pages CRUD API (+ duplicate, archive)
Objective
POST/PATCH/GET/DELETE /api/v1/workspaces/:id/pages… — create (title/icon/cover/ teamId/parentId), update, read, soft delete (→ trash, TASK-0039), duplicate (full subtree), archive/unarchive — the page resource every later task builds on.
Background
Page model exists (workspaceId, teamId, parentId, grandparentId denormalized, position fractional, title, icon, coverUrl, description, isPrivate, isFavorite, archivedAt, deletedAt, trashedAt, restoredAt, createdBy, updatedBy, version for optimistic concurrency). Page.isFavorite is a legacy per-page flag — superseded by the per-user PageFavorite table (TASK-0040); the column is deprecated, never read by api logic (recorded decision, removal is Phase 15). Tenant scoping + permission guard conventions come from Phase 2 (TASK-0028 guard: page routes map to actions view/edit/manage_permissions via the permission mapping — extended here).
Requirements
apps/api/src/modules/page/{page.module.ts, page.controller.ts, page.service.ts, page.repository.ts}: -POST /api/v1/workspaces/:id/pagesbody{ title?, icon?, coverUrl?, description?, teamId?, parentId? }→ permission: effectiveEDITon the target (workspace/team per TASK-0028;teamId/parentIdmust be in this workspace);position=positionAfter(lastChild.position)under the parent (or under the workspace root when no parent);createdBy/updatedBy= caller;201 { page: PageDto }. -GET /api/v1/pages/:id→ fullPageDto(id, workspaceId, teamId, parentId, title, icon, coverUrl, description, isPrivate, trashedAt, archivedAt, createdAt, updatedAt, createdBy, updatedBy, childCount, isFavorite (current user's PageFavorite presence), version); permission: effectiveVIEW; trashed pages →404 page_not_found(the trash list is the only entry point, TASK-0039);grandparentIdmaintained by the service (parent's parentId) on create/move. -PATCH /api/v1/workspaces/:id/pages/:pageIdbody{ title?, icon?, coverUrl?, description?, isPrivate? }(move is separate, TASK-0036) →EDITon the page; optimistic concurrency: body may carryexpectedVersion; mismatch →409 version_conflict { details: { currentVersion } }(version +1 on every write). -POST /api/v1/pages/:pageId/duplicate→EDITon source andEDITon destination context (same workspace always — duplicate stays in-workspace): copy the page (title + " (copy)", position after the source, same team/icon/ cover/description) recursively with the whole subtree (children keep their relative order; depth-safe: iterative BFS copy, max 5000 nodes = the sibling bound's spirit —409 subtree_too_largebeyond); no blocks yet (Phase 4 copies them), no shares/favorites/versions copied (fresh); auditpage.duplicated { sourcePageId, newPageId }. -PATCH /api/v1/pages/:pageId/archivebody{ archived: true|false }→EDIT; archived pages hidden from the tree list (TASK-0036 filter) but not trashed; auditpage.archived/page.unarchived.PageDto+ zod DTOs inpackages/shared/src/dto/page.ts(shared with web).- Every mutation: log with
pageId+workspaceId; audit eventspage.created,page.updated,page.deleted(soft → TASK-0039 reuses the delete path) via the TASK-0020/0032 service; names added topackages/shared/src/audit-events.ts. - Sibling bound: creating under a parent with ≥ 5000 children →
409 too_many_siblings(the bound is shared with TASK-0036's move).
Acceptance Criteria
- Create root + nested (parent's child) pages: rows correct incl.
grandparentId,createdBy, position unique per parent;GETreturns the fullPageDtowithchildCountcorrect. - PATCH with matching
expectedVersion→ 200 + version incremented; mismatch → 409 withcurrentVersionin details (concurrency proven by two parallel integration calls). - Duplicate of a 3-level subtree (5 pages) creates 5 new pages, order preserved, " (copy)" on the root only, no shared
PageFavorite/Share/PageVersionrows (DB assertion); a > 5000-node subtree → 409. - Archive hides the page from the root list (TASK-0036's filter — a stub
includeArchived: falseflag on the repository method is added now so TASK-0036 only adds pagination); unarchive restores visibility. - A GUEST (effective VIEW) can GET but PATCH → 403
forbidden(TASK-0028 guard wired for page routes; thepermission.deniedaudit row is TASK-0041's guard extension — assert the 403 now, the audit row in TASK-0041's tests).
Backend Changes
apps/api/src/modules/page/*(5 files + dto);packages/shared/src/dto/page.ts; audit event names; permission mapping extended with the page routes (permission-mapping.ts).
Frontend Changes
apps/web/src/lib/queries/pages.ts(hooks:usePage(id),useCreatePage,usePatchPage,useDuplicatePage,useArchivePage— consumed by TASK-0037+).
Database Changes
prisma/schema.prisma:@@index([workspaceId, parentId, position])onPageif absent (the tree query pattern) +@@index([workspaceId, trashedAt])if absent → migration<ts>_page_tree_indexes(record which were missing).
API Changes
POST /workspaces/:id/pages,GET /pages/:id,PATCH /workspaces/:id/pages/:pageId,POST /pages/:pageId/duplicate,PATCH /pages/:pageId/archive— documented indocs/api/pages.md(new).
Tests
apps/api/src/modules/page/page.service.test.ts: grandparent maintenance, version conflict, duplicate tree copy (fixture 3-level), archive filter flag. —tests/integration/pages.test.ts: criteria 1–4 (parallel version-conflict call, duplicate DB assertions).
Security
- Permission-checked per route (view/edit); cross-workspace
parentId/teamIdrejected (400 invalid_parent); no PII in page DTOs beyond creator/updater ids.
Documentation
docs/api/pages.md(new): 5 endpoints, PageDto field table, theisFavorite-deprecation note, error codes.
Dependencies
- TASK-0028 (guard + mapping), TASK-0027 (teams as target), TASK-0020/0032 (audit), TASK-0036 (position helpers —
positionAfteralready exists from TASK-0027).
Definition of Done
5 endpoints + DTOs + indexes live; unit + integration tests green (incl. concurrency and subtree duplicate); docs written.
TASK-0036
Title
Page tree engine (positions, lazy endpoint, move + cycles)
Objective
The lazy page tree: GET /api/v1/workspaces/:id/pages (cursor pagination, ≤ 50/page, root or per-parent), POST /api/v1/pages/:pageId/move (new parent + position) with cycle prevention (cannot move into own descendant) and the 5000-sibling bound — the < 2 s deep-tree contract the exit criterion points at.
Background
Positions come exclusively from @factory/shared/positions (TASK-0027 shipped positionAfter/positionBetween/comparePositions if missing). The tree is lazy: the API never returns the whole tree; the client (TASK-0037) expands node by node. childCount per row (GROUP BY count, index-backed) drives the expand chevron. The < 2 s target is for a 100k-node fixture (integration performance probe, criterion 5) — the virtualized render of that tree is Phase 4.
Requirements
GET /api/v1/workspaces/:id/pagesquery{ parentId?, cursor?, limit?, includeArchived? }(limit ≤ 50 default 50) →{ items: [{ id, title, icon, coverUrl, position, childCount, hasChildren: childCount > 0, isFavorite, trashedAt: null, archivedAt }], nextCursor: string | null }: - noparentId→ workspace roots (parentless pages,teamIdfilter optional?teamId=); withparentId→ that page's children (404 if the parent is trashed/unknown); - order bypositionviacomparePositions(the comparator is total-ordered; the SQL orders by the raw string — the engine guarantees string order == rank order for generated positions; assert this invariant in a unit test with 1000 generated positions — if the engine can't guarantee lexicographic order, the query switches to a position-bucket key; recorded decision); - cursor = base64 of the last item's position (keyset pagination, stable under concurrent inserts); trashed + (unlessincludeArchived) archived excluded.POST /api/v1/pages/:pageId/movebody{ newParentId?, beforeId?, afterId? }(EDITon source + destination): - cycle check: ifnewParentIdis the page itself or a descendant of the page →409 cycle_detected(descendant check = BFS/DFS over children fromnewParentIdup to the workspace root — bounded by depth; the 5000-sibling bound makes the graph small per node; use iterative DFS with a visited set, unit-tested on a 10k-deep? no — on a 200-wide/50-deep fixture); -position = positionBetween(afterId?, beforeId?)(either null = start/end); - updateparentId+grandparentIdof the moved page; children keep their positions (no re-positioning of the subtree — the fractional engine makes this O(1)); - 5000-sibling bound on the destination (409 too_many_siblings); - move toteamIdchange: body may includeteamId(destination team, same rules as create).- Performance contract: single index scan per page of the lazy endpoint (EXPLAIN assertion in the integration test:
Index Scan using page_workspace_parent_idx— the index from TASK-0035);childCountvia one GROUP BY per result batch (not N+1 — aIN (ids)count query). - A maintenance guard:
packages/shared/positionsre-normalization hook — ifpositionAfterwould exceed 256 chars (TASK-0027 criterion 5 bound), the service logswarn position_length_high(Phase 15 re-normalizes; the hook exists and is unit-tested to fire).
Acceptance Criteria
- Root list of a workspace with 120 root pages + 3-level nesting: page 1 (50 items) →
nextCursor→ page 2 (50) → page 3 (20,nextCursor: null); ordering matchescomparePositionsexactly (snapshot assertion). - Expand a parent:
?parentId=Xreturns exactly X's children in order, each with correctchildCount/hasChildren(DB cross-check on a 3-level fixture). - Move: page A (2 children) moved under page B → A's
parentId/grandparentIdcorrect, A's children untouched (same positions); move A before a specific sibling → position between its neighbors (comparator-asserted). - Cycle: moving B under B → 409; moving B under B's grandchild → 409
cycle_detected(3-level fixture, both directions); moving a root to a child of an unrelated branch → 200. - 100k-node fixture (1000 parents × 100 children, generated in the test, seeded via direct SQL for speed): any random
?parentId=page request p95 < 2 s (100 requests, p95 asserted) + the EXPLAIN index-scan assertion passes; the fixture is dropped inafterAll.
Backend Changes
apps/api/src/modules/page/page-tree.service.ts(lazy list + keyset cursor),page-move.service.ts(cycle check + move),page.controller.ts(2 routes);packages/shared/src/positions.ts(length-guard hook if absent).
Frontend Changes
apps/web/src/lib/queries/pages.tsextension:usePageChildren(workspaceId, parentId?, cursor?)with a cursor-chaining hook (useInfinitePageTree— the expandable tree's data source, TASK-0037 consumes it).
Database Changes
None beyond TASK-0035's indexes (the EXPLAIN test pins them).
API Changes
GET /workspaces/:id/pages(tree, paginated),POST /pages/:pageId/move— documented indocs/api/pages.md(cursor format + the 5000 bound + the < 2 s contract stated as a performance requirement).
Tests
apps/api/src/modules/page/page-tree.service.test.ts: keyset cursor stability (insert between pages → cursor still valid), ordering invariant with 1000 generated positions, childCount batching (no N+1 — query count assertion). —apps/api/src/modules/page/page-move.service.test.ts: cycle matrix (self/direct child/grandchild/unrelated), position-between edges (start/end), grandparent update. —tests/integration/page-tree-perf.test.ts: criterion 5 (100k fixture, p95 < 2 s, EXPLAIN assertion).
Security
- Move is permission-checked on BOTH source and destination (a MEMBER can move their allowed pages but not into a team they lack EDIT on — covered by the guard's destination check, integration-tested); cycle prevention is the data-integrity backstop (no infinite tree traversal possible).
Documentation
docs/api/pages.md: tree endpoint (cursor contract) + move (cycle rule, bound). —docs/architecture/page-tree.md(new): lazy tree design, position-order invariant, cursor format, the Phase 4 virtualization plan against this contract.
Dependencies
- TASK-0035 (Page CRUD + indexes + DTOs), TASK-0027 (positions engine), TASK-0028 (guard mapping).
Definition of Done
Lazy endpoint + move with cycle prevention live; the 100k p95 < 2 s probe passes; order invariant + no-N+1 proven; docs written. Exit criterion "tree loads < 2 s" met at the contract level (virtualized render completes Phase 4).
TASK-0037
Title
Nested pages UI (sidebar tree, expand/collapse, indent)
Objective
The page tree in the sidebar under each teamspace and under Private: lazy expand/collapse per node, indent levels, empty states, and a clearly-marked drag-sort stub (drag lands in Phase 4) — the "sidebar renders tree from API" exit proof for pages.
Background
Data source: useInfinitePageTree (TASK-0036). Teams expand to their pages (replacing Phase 2's "No pages yet" stub — the new-page-in-team-<id> button now works: create under that team, no parent). Private = root pages with teamId: null AND isPrivate: true… — decision (recorded): Private = all root pages with teamId: null (the isPrivate flag is per-page share-restriction, not a sidebar bucket — Phase 2's "Private/Shared" sidebar semantics are fixed here: Private = teamless pages; Shared = pages with a non-private share, which is Phase 4+ (the Shared section stays an empty state with its Phase note — recorded, no data yet)). Indent is visual only (CSS padding-left: 12px * depth, max visual depth 8 with "…" continuation — deeper nodes still load, just capped visual indent).
Requirements
apps/web/src/components/sidebar/page-tree.tsx+page-node.tsx: - team rows now render their page subtree (lazy: children fetched on first expand viausePageChildren(teamId-as-parent? no — team pages are ROOT pages withteamId, notparentId=team— the tree API's?teamId=root filter from TASK-0036; the expand of a page uses?parentId=); - node row: chevron (aria-expanded, only whenhasChildren), icon (emoji inline / uploaded image 16 px / none), title (single line, ellipsis,titleattr full), depth indent; expand state per-node, persisted namespacedfactory.<ws>.expandedPages(JSON array of ids, TASK-0031 convention); - states per node: loading (chevron spinner, skeleton child rows), error (inline "Couldn't load — Retry" on the node, refetch button), empty expanded (a single muted "No sub-pages" row); -new-page-in-team-<id>(Phase 2's disabled button) now enabled: inline create row (input + Enter/blur,useCreatePage({ teamId }), optimistic insert at the end of the team's root list, rollback on error); a "New page" (+) hover affordance on each expanded node for children (same inline row pattern).- Private section (testid
nav-privatenow data-backed): the?teamId=-null root list rendered as a second tree block under the Private header; same node component; empty state "No private pages yet" + the inline create. - Drag-sort stub: each node shows a drag-handle glyph on hover (testid
page-drag-<id>) witharia-disabled+ tooltip "Reordering arrives in Phase 4"; no dnd-kit wiring yet (the stub is present so Phase 4 replaces it in place — recorded, the handle is the Phase 4 mount point). - Active page: the node for the current URL (
/w/<ws>/<pageId>, TASK-0044 route) is highlighted (aria-current="page") + auto-scrolled into view on load; clicking a node →router.push(no full reload); middle-click /ctrl+click→ new tab (wired to the context menu, TASK-0045 — both paths). - Performance: expanding a node with 50 children renders all 50 (the per-node page size — Phase 3's tree is small per node by design; the 100k-scale case is the Phase 4 virtualized block tree, the page tree stays lazy-per-node); keyboard: Enter expands, arrows move (roving tabindex per the Phase 2 sidebar a11y rules),
*selects all children (Notion parity — the selected set feeds Phase 4 bulk ops; Phase 3: selection state exists, no bulk UI).
Acceptance Criteria
- Seeded demo workspace (2 teams, 6 pages from TASK-0006/0011): expanding "Product" shows its root page ("Getting started" + siblings per the seed), expanding that page shows its 2 children — all from API calls (network assertion:
?teamId=once,?parentId=once per expanded page). - Expand/collapse persists across reload (namespaced key); switching workspace resets the expansion set (per-workspace namespacing, TASK-0031).
- Inline create under a team + under a page: new rows appear optimistically, persist (reload keeps them, correct parent), a 403 (as a GUEST) rolls back + toast.
- The drag handle is visible on hover, disabled (tooltip present, no drag started); the a11y checklist (Enter/arrows/selection) passes (E2E keyboard assertions in TASK-0046).
- Node loading/error/empty states render (mocked query states in component tests; the error-retry path E2E-tested via route abort in TASK-0046).
Backend Changes
None.
Frontend Changes
apps/web/src/components/sidebar/{page-tree.tsx, page-node.tsx, inline-create-row. tsx}; sidebar layout updated (teams → tree, Private data-backed,nav-favoritesstill Phase 3's TASK-0040, Shared stays the Phase 4+ stub);apps/web/src/stores/tree-store.ts(expansion + selection state).
Database Changes
None.
API Changes
None (consumes TASK-0035/0036).
Tests
- Component:
page-node.test.tsx(chevron a11y, indent levels 0–8 cap, states),page-tree.test.tsx(lazy fetch on expand, persistence keys, team vs private blocks),inline-create-row.test.tsx(optimistic + rollback). — E2E criteria 1–4 asserted in TASK-0046.
Security
- The tree never renders a page the api didn't return (no client-side filtering of hidden pages); the GUEST rollback test pins that a denied create is not visible.
Documentation
docs/ui/sidebar.md: "Page tree" section (node contract, persistence keys, selection state, the Phase 4 drag mount point, the Private/Shared semantics decision).
Dependencies
- TASK-0036 (tree API + query hooks), TASK-0035 (create), TASK-0029 (sidebar shell), TASK-0044 (the active-page URL — ordering note: the URL contract
/w/<ws>/<pageId>is fixed in this task's doc + TASK-0044 implements the route; the highlight logic works against the contract from day one).
Definition of Done
Tree + nodes + inline create live with all states; component tests green; the 5 E2E criteria ready for TASK-0046; doc written.
TASK-0038
Title
Page metadata UI (icon picker emoji + upload, cover, title, description)
Objective
The page header's metadata editing: emoji icon picker + icon upload (via the workspace icon service), cover upload + 6 gradient presets + remove, auto-resizing title, and the "Add a subtitle" description — wired to the page PATCH (TASK-0035).
Background
Page stores icon (emoji string OR file id — the TASK-0035 DTO uses icon: string | null where a file icon is the storage key; decision (recorded): file icons reuse the workspace icon upload endpoint's storage path under pages/<pageId>/ — a small POST /api/v1/pages/:id/icon endpoint added here, same content rules as workspace icons), coverUrl, title, description. Uploads go through the same @factory/storage service (TASK-0023).
Requirements
POST /api/v1/pages/:id/icon(multipart, pageEDIT): same content rules as workspace icons (png/jpeg/webp/avif, ≤ 2 MB, magic-byte sniff); storedpages/<pageId>/<uuid>.<ext>;PATCHsetsiconto the storage key (an emoji icon overwrites a file icon and vice versa — oneiconfield, the web distinguishes by value shape: pure emoji = emoji, otherwise storage key — documented indocs/api/pages.md); response{ icon }.- Icon picker (
apps/web/src/components/page/icon-picker.tsx): a grid popover (100 curated emoji + a "Upload…" row + a "Remove" row), keyboard navigable (arrows + Enter,role="listbox"), selecting an emoji → PATCH immediately (optimistic, rollback on error); the picker opens from the header icon slot (hover shows the picker; click toggles it persistently — both affordances). - Cover (
apps/web/src/components/page/cover-editor.tsx): header cover slot — "Add cover" on hover when empty → panel: 6 gradient presets (const array inpackages/shared/src/covers.ts:linear-gradientCSS strings stored ascoverUrlvalues prefixedgradient:— the web renders the prefix as a CSS background, uploads as<img>; documented), "Upload image" (same upload rules, ≤ 4 MB for covers, bucketfactory-fileskeycovers/<pageId>/…viaPOST /api/v1/pages/:id/cover), "Remove cover"; cover renders 200 px tall in the header, 16 px strip in sidebar rows (icon slot only — covers are header-only in Phase 3, sidebar strip is a Phase 4 polish — recorded). - Title: single-line auto-resizing
contentEditable(max 200 chars, Enter creates nothing in Phase 3 — Enter = blur/saves, the first block is Phase 4;aria-label="Page title", placeholder "Untitled" when empty, auto-sizes font from 1.5rem → 3.2rem by length (the "auto-resize")); saves on blur/debounce 500 ms viausePatchPagewithexpectedVersion(conflict → toast "Page changed — reload" + refetch). - Description: below the title, placeholder exactly "Add a subtitle", single-line input in Phase 3 (multiline is Phase 4 with blocks — recorded), max 500 chars, same save semantics.
- Permissions: a GUEST (VIEW) sees the header fully rendered but every control inert (no hover pickers, title/description not editable —
contentEditable=false+ no inputs, not just disabled — the components don't render, matching the TASK-0030 role-gating pattern from the effective level the API returns —PageDto.effectiveLevelis added here: theGETresponse includes the caller's effectivePermissionLevel(from the engine — the UI's single source for control gating; the server still enforces — AGENTS.md)).
Acceptance Criteria
- Emoji picker: selecting "🚀" patches the page; reload shows 🚀 in header + sidebar row; upload (1 MB png) →
icon= storage key, header renders the image; a 5 MB upload → 400 toast (server reason). - Cover: each of the 6 gradients renders + persists (
coverUrl=gradient:<id>); upload → image cover; remove → null; sidebar row unaffected (header-only). - Title: typing to 120 chars visibly shrinks the font (computed-style assertion); empty title → "Untitled" placeholder; save-on-blur persists; a concurrent edit (second context patches first) → the first context's blur gets 409 → toast + refetch (the E2E two-context conflict, in TASK-0046).
- Description placeholder is exactly "Add a subtitle" (text assertion); 500-char cap enforced client + server (
400 title_too_long/description_too_long— add the bounds to the shared zod dto). - As a GUEST: no picker/cover controls render (testid absence), title
contentEditable=false;PageDto.effectiveLevel= VIEW in the GET response.
Backend Changes
apps/api/src/modules/page/{icon-upload.service.ts (shared with the workspace icon service — refactor TASK-0023's icon.service intoapps/api/src/modules/storage/ file-icon.service.tsused by both), cover routes onpage.controller.ts};PageDto.effectiveLevel(the GET joins the engine — onecheckPermissioncall per fetch, cached per request);packages/shared/src/{covers.ts, dto/page.ts (bounds + effectiveLevel)}`.
Frontend Changes
apps/web/src/components/page/{icon-picker.tsx, cover-editor.tsx, page-title.tsx, page-description.tsx}; header assembly in TASK-0044's shell consumes them (this task ships the components + tests; TASK-0044 mounts them — the intra-phase ordering is documented).
Database Changes
None.
API Changes
POST /api/v1/pages/:id/icon,POST /api/v1/pages/:id/cover(2 routes);GET /pages/:idgainseffectiveLevel;iconvalue-shape contract + thegradient:cover prefix — documented indocs/api/pages.md.
Tests
apps/api/src/modules/page/page-icon-upload.test.ts: content rules, icon/cover overwrite semantics,effectiveLeveljoin (GUEST vs OWNER fixtures). — Component:icon-picker.test.tsx(keyboard nav, emoji vs upload),page-title.test.tsx(auto-resize thresholds, 409 path),cover-editor.test.tsx(6 gradients const-asserted, upload error).
Security
- Upload rules identical to workspace icons (sniff, size, uuid keys); the
effectiveLevelfield is advisory (the gate remains the guard — documented); gradient values are a fixed const list (no user-supplied CSS — XSS-safe by construction).
Documentation
docs/api/pages.md: icon/cover endpoints + value-shape contract;docs/ui/page-header.md(new): header inventory, theeffectiveLevelgating rule, the Phase 4 boundary (blocks take over title Enter + multiline).
Dependencies
- TASK-0035 (PATCH + PageDto), TASK-0041 (the engine call behind
effectiveLevel— ordering note:effectiveLeveluses the v2 engine; if TASK-0041 hasn't landed, this task uses the v1 role base — the field is stable either way; the task order is fixed: 0038 lands before 0041, so v1 semantics + the engine swap in 0041 is a one-line change recorded in that task), TASK-0023 (icon service).
Definition of Done
2 endpoints + 4 components + effectiveLevel live; component + unit tests green; the 5 E2E-able criteria ready for TASK-0046; docs written.
TASK-0039
Title
Trash (soft delete, restore, purge, 30-day job, UI)
Objective
The full trash lifecycle: DELETE → trashedAt (subtree), restore (subtree, order reconstruction), purge (hard delete, OWNER/ADMIN only), the 30-day auto-purge worker job, the trash list API, and the Trash section of the sidebar with restore/purge UI — the "trash/restore round-trip E2E-tested" exit criterion.
Background
Page has trashedAt + restoredAt (null when live). Trashing a parent trashes the subtree (Notion parity); restore is safe only when the parent chain is restorable (parent live or itself in trash). Purge is irreversible (the page row + cascade PageFavorite, PageVersion, Share, future blocks). The 30-day policy is const TRASH_RETENTION_DAYS = 30 in packages/shared/src/constants.ts (documented, Phase 14 makes it per-workspace retention).
Requirements
- API (
EDITon the page for delete/restore; purge OWNER/ADMIN via the guard'smanagetier — the permission mapping getspage.trash/page.purgeactions): -DELETE /api/v1/pages/:id→ subtree trash (iterative BFS from the page,trashedAt = nowon the page + all descendants,restoredAt = null); position is preserved (restore can be exact);200 { trashed: n }(n = nodes including the root); auditpage.trashed { pageId, nodeCount }. -GET /api/v1/workspaces/:id/pages/trash→ the workspace's trashed roots (trashed pages whose parent is live or untrashed… — definition (recorded): list pages withtrashedAt != nullwhose parent is NOT trashed (top of each trashed subtree) + each item carriesnodeCount(descendant count); cursor pagination like the tree endpoint; audit-free read. -POST /api/v1/pages/:id/restore→ if the page's parent chain is live → restore the subtree (trashedAt = null, restoredAt = nowon root + descendants, positions intact); if a parent is trashed →409 parent_in_trash(restore top-down — the UI's restore button on a nested trashed page is disabled with the reason);200 { restored: n }; auditpage.restored { pageId, nodeCount }. -POST /api/v1/pages/:id/purge(OWNER/ADMIN) → hard delete of the page row (Prisma cascade:PageFavorite,PageVersion,Share; blocks cascade in Phase 4 — the FK is already there); descendants already trashed separately stay (purge is per-root — documented);200 { purged: true }; auditpage.purged { pageId }(irreversible — the event is the only trail). - Worker:
apps/worker/src/jobs/trash-purge.processor.ts(queuemaintenance, cron0 4 * * *): purge all pages withtrashedAt < now - 30d(batch 500, loop until drained, progress log every batch); a unit test with injected clock (31-day-old trashed → purged; 29-day → kept). - UI (completing the Phase 2
nav-trashstub):apps/web/src/app/(app)/ [workspace]/trash/page.tsx— the trashed roots list (icon, title, "Trashed Xd ago" relative time, nodeCount "· N sub-pages"), per-row Restore (confirm ifnodeCount > 1— "This will restore N sub-pages too") + Purge (OWNER/ADMIN only, double-confirm dialog "This cannot be undone" — typed-confirm: type the page title, TASK-0030 dialog pattern); empty state "Nothing in the trash"; a banner "Pages are purged after 30 days" (the const, not a hardcoded string). - Sidebar:
nav-trashlinks to/w/<ws>/trash; a trashed page is removed from its team/private tree the moment it's trashed (query invalidation) — the round trip is visible without reload.
Acceptance Criteria
- Round trip (integration): create root R + child C → DELETE R →
trashed: 2, both rowstrashedAtset, R gone from the tree endpoint; trash list shows R withnodeCount: 1; POST restore R → both live, positions unchanged (tree order identical before/after — snapshot assertion). - Nested restore: trashed parent + trashed child → restoring the child first → 409
parent_in_trash; restoring the parent →restored: 2(both live). - Purge: OWNER purges a trashed page → row +
PageFavorite/PageVersion/Sharerows gone (DB assertion); MEMBER purge → 403; purging a live page → 404 (must be in trash first — the endpoint requirestrashedAt != null, documented). - The worker job purges a 31-day-old trashed page and keeps a 29-day-old one (job test, injected clock + real DB rows).
- UI: trash → restore via the sidebar round trip leaves the tree correct without reload (the E2E assertion in TASK-0046); the typed-confirm purge dialog requires the exact title (wrong title → button disabled).
Backend Changes
apps/api/src/modules/page/{trash.service.ts, trash.controller routes on page.controller.ts};apps/worker/src/jobs/trash-purge.processor.ts;packages/shared/src/constants.ts(TRASH_RETENTION_DAYS); audit names (page.trashed/restored/purged).
Frontend Changes
apps/web/src/app/(app)/[workspace]/trash/page.tsx;apps/web/src/components/trash/{trash-list.tsx, typed-confirm-dialog.tsx}; sidebarnav-trashwired; tree invalidation on delete.
Database Changes
None (existing columns; the TASK-0035 trashedAt index serves the job's scan).
API Changes
DELETE /pages/:id(now trash, not hard delete — the TASK-0035 contract updated in the doc),GET /workspaces/:id/pages/trash,POST /pages/:id/restore,POST /pages/:id/purge— documented indocs/api/pages.md.
Tests
apps/api/src/modules/page/trash.service.test.ts: subtree BFS, restore parent- chain rule, purge cascade. —tests/integration/trash.test.ts: criteria 1–3 (full round trip + purge cascade). —apps/worker/src/jobs/trash-purge.processor.test.ts: criterion 4.
Security
- Purge is irreversible + OWNER/ADMIN-only + audited (
page.purged); the 30-day job is the only non-user purge path and it logs every batch (observability); no endpoint hard-deletes a live page (the 404 rule makes accidental data loss via API impossible).
Documentation
docs/api/pages.md: the 4 trash routes + the top-of-subtree list definition;docs/ui/sidebar.md: trash section (typed-confirm, the 30-day banner const).
Dependencies
- TASK-0035 (delete path), TASK-0036 (tree invalidation + list conventions), TASK-0028 (purge permission tier), TASK-0008 (Testcontainers redis for the job).
Definition of Done
Trash API + worker job + trash page live; all 5 criteria proven (round trip E2E-ready); docs written. Exit criterion "trash/restore round-trip E2E-tested" is proven in TASK-0046 against this task.
TASK-0040
Title
Favorites (API + context menu + sidebar section)
Objective
Per-user favorites: add/remove (context menu + header star), ordered per-user sidebar section — the PageFavorite table (workspaceId, userId, pageId, position fractional) finally gets its API and UI.
Background
PageFavorite exists (unique per user+page implied — verify: if @@unique([userId, pageId]) is missing, this task adds it in a migration, backfill-safe: duplicates can't exist pre-Phase 3 because nothing wrote the table — the seed's one favorite from TASK-0006 is the only row). Ordering is position-fractional per user (the shared engine). Page.isFavorite stays deprecated (TASK-0035 decision) — the API's isFavorite field is computed from this table.
Requirements
- API (the page's
VIEWis the precondition — you can favorite what you can see): -POST /api/v1/pages/:id/favorite→ upsert withposition = positionAfter(last favorite's position)(append); idempotent (existing → 200 no-op);201 { favorite: { position } }(200 on no-op); auditpage.favorited. -DELETE /api/v1/pages/:id/favorite→200 { removed: true }(idempotent no-op if absent — documented); auditpage.unfavorited. -GET /api/v1/workspaces/:id/favorites→{ items: [PageDto (the TASK-0035 shape,isFavorite: truealways)], nextCursor }ordered byposition, filtered to pages the caller can still VIEW (a favorited page whose permission was later revoked is skipped, not 403'd — documented; trashed pages are hidden with aninTrash: trueflag instead of disappearing — the UI shows them dimmed with a "in trash" chip, restore happens in the trash view — recorded decision). - Sidebar:
nav-favorites(Phase 2 stub) becomes the data-backed list (useFavorites(workspaceId), the star row componentfavorite-row.tsx: star glyph + icon + title + unstar×on hover, click → page route; empty state "No favorites yet — star a page"; loading/error per the TASK-0037 state contract). - Context menu + header: the star toggle in the page context menu (TASK-0045 — this task ships the
useFavoriteTogglehook + the menu action definition; TASK-0045 renders it) and the header star button (page-favorite-star,aria-pressed); optimistic update both places (query cache is the single source — both read the samePageDto.isFavorite). - Migration (if the unique index is missing):
<ts>_favorite_unique;prisma/verify.tsunaffected (the seed favorite still validates).
Acceptance Criteria
- Favorite a page → sidebar section shows it (order: append, reload-stable); unfavorite → gone; favorite twice → idempotent (1 row, no error).
- A GUEST who can VIEW a page can favorite it; a user with NO access to a page (grant removed after favoriting) sees it in
GET favoriteswith the skip behavior (absent from items — or inTrash if trashed; the two cases asserted separately). - A trashed favorited page renders dimmed with the "in trash" chip; after restore it renders normal (no API change needed — the flag is computed on read).
- Per-user positions: two users favoriting the same 3 pages in different orders → each
GET favoritesreturns their own order (two-context integration test). - The migration (if any) applies clean; the seed favorite row still passes
pnpm db:verify.
Backend Changes
apps/api/src/modules/page/favorite.service.ts(+ 3 routes onpage.controller.ts);prisma/schema.prisma+ migration only if the unique index is missing; audit names (page.favorited/unfavorited).
Frontend Changes
apps/web/src/components/sidebar/favorite-row.tsx(sidebar section wired);apps/web/src/lib/queries/favorites.ts(useFavorites,useFavoriteToggle); the header star slot (the mount lives in TASK-0044's header — the component ships here:apps/web/src/components/page/favorite-star.tsx).
Database Changes
- Possibly
prisma/migrations/<ts>_favorite_unique/(check first, record finding).
API Changes
POST/DELETE /pages/:id/favorite,GET /workspaces/:id/favorites— documented indocs/api/pages.md(the skip/inTrash read contract).
Tests
apps/api/src/modules/page/favorite.service.test.ts: idempotency, per-user positions, the VIEW-filtered read (revoked grant case), inTrash flag. —tests/integration/favorites.test.ts: criteria 1–4 (two-context ordering).
Security
- Favorites are per-user (no cross-user read); the VIEW precondition is enforced (you can't star a page you can't see — a 403, not a silent no-op); audit rows carry the page + user.
Documentation
docs/api/pages.md: 3 routes + the read-filter contract;docs/ui/sidebar.md: favorites section.
Dependencies
- TASK-0035 (PageDto + isFavorite computation), TASK-0041 (the VIEW check uses the engine — v1 role base until 0041, swap recorded as in TASK-0038), TASK-0036 (position engine usage), TASK-0037 (sidebar state contract).
Definition of Done
3 endpoints + sidebar section + toggle hook live; all 5 criteria proven; docs written.
TASK-0041
Title
Page permissions (engine v2 inheritance + grants API)
Objective
packages/permissions v2: effective-level inheritance down the page parent chain (workspace base → team grants → page grants on the page or any ancestor, nearest- wins for explicit grants, NONE overrides), ≥ 25 new decision-matrix cases, the GET/PATCH/DELETE /pages/:id/permissions grants API, and the guard's permission.denied audit event (the Phase 3 exit criterion + the Phase 2 deferred decision).
Background
v1 (TASK-0028) handles workspace + teamspace resources with preloaded grants. v2 extends checkPermission to resourceType: "page" (and "database"/"record" in Phase 5/6 — the signature already accepts resourceType, so v2 is an internal extension, not a rewrite): the caller now preloads the page parent chain (page + ancestors' page grants) via a new repository query. Permission.inheritsFrom stays informational in v2 (chain reconstruction is done from Page.parentId, not from grant links — recorded decision: inheritsFrom is for the future policy- expression feature, Phase 14).
Requirements
packages/permissions/src/engine-v2.ts(v2 entry; v1 file unchanged, v2 re-exports v1 for workspace/teamspace — one publiccheckPermissionthat dispatches onresourceType): - Page input adds:pageChain: { pageId, ancestorIds: string[] },grantsnow includes page-grant rows for the page + every ancestor (preloaded by the guard's repository: one querypermissions WHERE workspaceId = ? AND ((resourceType='page' AND resourceId IN (chain)) OR (resourceType='workspace') OR (resourceType='teamspace' AND resourceId = page.teamId))— the index from TASK-0028 covers it). - Effective level resolution (deterministic, documented): 1. Workspace role base (v1 table) and any workspace-grant levels →wsLevel. 2. Team level: if the page has a team, v1's team-grant semantics for that teamspace →teamLevel(else NONE). 3. Page-grant chain: collect page grants whereresourceId ∈ chain; if the page ITSELF has any explicit grant → effective page level = max of the page's own grants (nearest wins — ancestor page grants ignored, except that aNONEon the page itself always denies); otherwise effective page level = max of ancestor grants (nearest ancestor with a grant wins for the max — i.e. max over the ancestor set; aNONEat ANY level of the chain denies — v1's NONE-override semantics extended to the chain). 4. Final: if final NONE → deny; else level = max(wsLevel, teamLevel, pageChainLevel);reason= which tier produced the max ("role_base" | "workspace_grant" | "team_grant" | "page_grant" | "ancestor_page_grant" | "deny"). - Pure/synchronous/deterministic as v1 (the chain is preloaded).- Matrix ≥ 25 new cases (
engine-v2.test.ts, table-driven, on top of v1's 40): role base only (MEMBER→COMMENT on a page); page grant raising GUEST→EDIT on one page (sibling page unaffected — no bleed); ancestor grant (grant on root, child inherits, grandchild inherits); page's own grant beats ancestor (ancestor EDIT, page FULL_ACCESS → FULL_ACCESS; ancestor NONE + page EDIT → deny — NONE anywhere in the chain denies); team grant vs page grant interplay (team EDIT + ancestor VIEW → EDIT); NONE on an ancestor denies the whole subtree; non-member with a page grant → still denied (membership precondition, v1 rule); chain of depth 5 with grants at 1, 3, 5 (max + nearest rules); the full (role × action) cell set re-asserted forresourceType: "page"(the action list includesview|comment|edit|manage_permissions—manage_members/manage_teams/ delete_workspaceare workspace actions only, denied on page resources — the table pins it). - Grants API (management requires effective
FULL_ACCESSon the page — the guard mapsmanage_permissions): -GET /api/v1/pages/:id/permissions→ the page's own grants + the inherited ones (each withsource: "this_page" | "ancestor:<pageId>" | "workspace" | "team"— the UI renders the inheritance explicitly). -PATCH /api/v1/pages/:id/permissionsbody{ granteeType, granteeId, level }→ upsert the page-level grant (granteeTypeuser|team— page grants don't supportrolein Phase 3;400 role_grants_page_in_phase14— recorded);200 { grant }; auditpermission.grant_changed(the TASK-0032 name, resourceTypepage). -DELETE /api/v1/pages/:id/permissions/:grantId→ only the page's OWN grants (ancestor grants →400 not_this_page); auditpermission.grant_removed. - Guard extension: on deny, the PermissionGuard writes
permission.denied { action, resourceType, resourceId }(fire-and-forget per the resilience rule;actorType: USER; workspaceId set) — throttled: max 1 deny-audit per (user, resource, action) per 60 s (an in-memory counter — a hostile client must not flood the audit table; documented; Redis-backed in Phase 15). - The
effectiveLevelfield (TASK-0038) now comes from v2 (the one-line swap).
Acceptance Criteria
pnpm test: v2 matrix green (≥ 25 new cases + v1's 40 still green);packages/permissionscoverage ≥ 80%.- Subtree inheritance (integration): grant EDIT to user X on root page R → X gets EDIT on R, child C, grandchild G (asserted via
effectiveLevelin each GET); add aNONEgrant on C → C and G denied (the deny propagates down the chain from that point — the "NONE anywhere in the chain denies" rule), R still EDIT. - Nearest-wins: ancestor EDIT + page's own VIEW → effective VIEW (the page's own grant wins, even when lower — recorded decision matching the "explicit on this page wins" rule; the test pins the surprising case explicitly).
- Grants API: PATCH upsert + GET shows
sourcelabels; DELETE of an ancestor grant via the child's endpoint → 400not_this_page; a MEMBER (effective EDIT) cannot manage grants → 403. permission.deniedaudit: a GUEST deniedediton a page → exactly one audit row within the action (and a 5th identical deny within 60 s adds NO row — the throttle, both asserted); a denied request's 403 body is unchanged.
Backend Changes
packages/permissions/src/{engine-v2.ts, types.ts (chain input), engine-v2.test.ts};apps/api/src/modules/page/permission-grant.service.ts(+ 3 routes);apps/api/src/common/guards/permission.guard.ts(chain preloading for page resources + deny-audit + throttle);packages/shared/src/audit-events.ts(permission.denied).
Frontend Changes
apps/web/src/components/page/permissions-panel.tsx(a simple list: grantee, level select, source chip, remove; the full Permissions settings tab lands Phase 3.5? No — it ships here as a header overflow action "Permissions…" → dialog; OWNER/FULL_ACCESS only, role-gated like TASK-0030) — minimal, the UI depth is Phase 14 (advanced permissions).
Database Changes
None (the TASK-0028 grants index covers the chain query; the EXPLAIN assertion in the integration test pins it).
API Changes
GET/PATCH/DELETE /pages/:id/permissions…— documented indocs/api/pages.md; the v2 resolution algorithm summarized (link to the engine README as the source of truth).
Tests
packages/permissions/src/engine-v2.test.ts(the matrix);tests/integration/page-permissions.test.ts: criteria 2–5 (subtree, nearest-wins, API, deny-audit throttle); the guard's chain query EXPLAIN-asserted (index scan).
Security
- The engine is the single source of effective levels (UI
effectiveLevel+ guard call the same pure function — drift impossible); NONE-override is the only de-escalation and it's chain-wide (documented); deny-audit is throttled (abuse defense) and carries no PII (ids only).
Documentation
packages/permissions/README.md: v2 section — the 4-step resolution algorithm, the nearest-wins + NONE rules, the inheritancesourcelabels, the Phase 14 (inheritsFrom/policy expressions) plan. —docs/security/audit-events.md:permission.denied+ the throttle.
Dependencies
- TASK-0028 (v1 engine + guard), TASK-0035 (page chain data), TASK-0036 (the chain query pattern), TASK-0020/0032 (audit service), TASK-0038 (the
effectiveLevelswap).
Definition of Done
v2 engine + matrix + grants API + deny-audit live; all 5 criteria proven; README + audit docs updated. Exit criterion "unauthorized access rejected with audit event" met by this task (proven E2E in TASK-0046).
TASK-0042
Title
Sharing scopes API + public read endpoint
Objective
POST /api/v1/pages/:id/share — one share config per page (scope private|workspace|team|users|public + token + optional password + expiry + the allowComments/allowDuplicate/allowSearchIndexing options) — and GET /api/v1/shares/:token/public: the unauthenticated public read (public scope, password gate, expiry) that returns the page's public DTO. The share UI is Phase 4+ (recorded); the API is live and E2E-tested now.
Background
Share model exists (workspaceId, pageId, teamId, token unique, scope: ShareScope PRIVATE|WORKSPACE|TEAM|USERS|PUBLIC, allowComments default true, allowDuplicate default false, allowSearchIndexing default false, passwordHash, expiresAt, createdBy). One active share per page (replace semantics — a page's sharing is a single config, Notion-style "Share" popover model; DELETE resets it to private). The public DTO must leak nothing the caller shouldn't see: title, icon, coverUrl, description, workspace name (the brand line), and no blocks until Phase 4 (the public DTO's content field ships empty now — Phase 4 fills it with the block tree filtered by share options; the field exists from day one so the contract doesn't move).
Requirements
POST /api/v1/pages/:id/share(effectiveEDITon the page — the page owner of sharing; FULL_ACCESS not required — recorded): body{ scope, teamId? (required when TEAM), userIds? (1–50, required when USERS — full user ids, the caller must be a workspace member to grant — membership check), password? (min 8, hashed with sha256 — the share password is not a bcrypt candidate, sha256 + constant-time compare), expiresAt? (ISO, min now+5min, max now+10y), allowComments?, allowDuplicate?, allowSearchIndexing? }→ upsert the page's Share (a new token on scope change to PUBLIC — public tokens are re-rolled on every PUBLIC save; non-public keeps the token — documented);200 { share: { scope, hasPassword, expiresAt, options, publicUrl: scope === PUBLIC ? /s/<token> : null } }; scopeprivate(orDELETE /api/v1/pages/:id/share) → the share row is soft-cleared (row deleted — token dies; documented). Audit:share.created(first),share.updated,share.revoked(names in the shared audit-events map).GET /api/v1/shares/:token/public?password=(@Public): - unknown token →404 share_not_found; scope not PUBLIC →403 share_not_public(the same endpoint serves TEAM/USERS for authenticated callers —Authorizationpresent + the caller in the team/user list + effective VIEW → 200 with the public DTO; documented dual-mode); - expired (expiresAtpast) →410 share_expired; - password set + missing/wrong →403 share_password_required(identical response for missing and wrong — no oracle; timing: sha256 +crypto.timingSafeEqualagainst the stored hash); - 200 →{ page: { id, title, icon, coverUrl, description }, workspace: { name, iconUrl }, options: { allowComments, allowDuplicate, allowSearchIndexing }, content: [] /* Phase 4: block tree */ }.- Rate limit the public endpoint: 60/min per IP (shared throttle store, TASK-0019 convention) — password-guessing is bounded; documented.
- Workspace-scope shares (scope WORKSPACE): no token, no endpoint change — the effect is on the normal page GET: a page shared WORKSPACE is VIEW-able by every workspace member regardless of team/role (the engine v2 integration: a WORKSPACE-scope share grants VIEW to the workspace role base — implemented as a v2 rule "active share with scope WORKSPACE ⇒ wsLevel ≥ VIEW"; one test case in TASK-0041's matrix file).
Acceptance Criteria
- As OWNER: set scope PUBLIC (no password) →
publicUrlreturned; the token works unauthenticated (curl, no auth) → 200 with the public DTO (title/icon/desc + workspace brand +content: []); the token is 48+ chars and never stored in clear (shares.tokenis the clear token per the schema — security fix (recorded): the schema storestokenclear for URL usability; this task adds the rule that tokens are unguessable (24 random bytes hex) and the endpoint never echoes the token in responses exceptpublicUrl— the DB-scan test in TASK-0032 style asserts no token in audit metadata). - Password: set a 10-char password → public read without it → 403
share_password_required; wrong → same 403 (body identical — asserted); right → 200. - Expiry: set
expiresAt = now+2min→ 200 now; advance the test clock past it → 410share_expired. - TEAM scope: a member of the team reads the page via the normal GET (engine rule) AND via
GET /shares/:token/publicwith auth (dual-mode); a non-member of the team → 403 on the token endpoint (auth present) / normal 403 on the page GET. - Re-save to PUBLIC re-rolls the token (old URL 404s);
DELETE /share→ token 404 + auditshare.revoked; the 60/min limit: 61st request in a minute → 429.
Backend Changes
apps/api/src/modules/share/{share.module.ts, share.controller.ts, share.service.ts}; engine v2 integration (the WORKSPACE-scope rule, one function inengine-v2.ts+ 1 matrix case); audit names; the public DTO inpackages/shared/src/dto/share.ts.
Frontend Changes
apps/web/src/app/s/[token]/page.tsx— the public page render (no app shell, no auth, no sidebar): workspace brand line, icon + title + cover, description, a "Share settings" note? No — the public page shows the content area (empty "This page's content arrives with the editor in the next phase" card — recorded Phase 4 note) + the password prompt state (when 403share_password_required: a centered password card,share-password-form, submit → retry with?password=) + the 404/410 states (not-found/expired cards); theme-aware, responsive, testidspublic-page,public-title,public-password-form.- No share UI on the page header in Phase 3 (the "Share" context-menu item is a stub per TASK-0045 — recorded).
Database Changes
None (existing shares table; the unique page-per-share is enforced in the service: upsert keyed on pageId — if the schema allows multiple rows per page, add @@unique([pageId]) + migration <ts>_share_page_unique (backfill: keep the newest row per page, delete the rest — the backfill SQL in the migration, recorded; no shares exist pre-Phase 3 except none — the table is empty, so the migration is safe as-is)).
API Changes
POST/DELETE /pages/:id/share,GET /shares/:token/public— documented indocs/api/sharing.md(new): the scope table, dual-mode, password contract, rate limit, the token-rollover rule.
Tests
apps/api/src/modules/share/share.service.test.ts: scope validation (TEAM needs teamId, USERS 1–50), token rollover, password hashing, expiry bounds. —tests/integration/sharing.test.ts: criteria 1–5 (incl. the 429 loop and the dual-mode team read); the public DTO leak scan (no user emails, no other pages' titles in the response).
Security
- Public surface: unguessable 24-byte tokens, bounded password guessing (rate limit + timing-safe compare + no oracle), expiry, and the DTO leak scan; share mutations are EDIT-gated + audited;
allowSearchIndexingdefaults false (a public page is not indexed — the Phase 8 indexer honors this flag, documented forward-reference).
Documentation
docs/api/sharing.md(new) as above;docs/ui/sidebar.mdnote: the public page lives outside the app shell (route/s/:token).
Dependencies
- TASK-0035 (page scoping/DTOs), TASK-0041 (the WORKSPACE-scope engine rule + EDIT gate), TASK-0019 (throttle store), TASK-0023 (storage for future public block assets — Phase 4).
Definition of Done
Share API + public endpoint + public page live; all 5 criteria proven; docs written. (The share UI is explicitly Phase 4+ — the context-menu stub is TASK-0045.)
TASK-0043
Title
Page version history (snapshots, list/restore, UI panel)
Objective
Page version history: manual snapshots, automatic snapshots every 100 block operations (the counter infrastructure + hook; block mutations arrive Phase 4), list/restore endpoints, and the history panel UI.
Background
PageVersion exists (pageId, version, snapshot Json, createdBy, note, createdAt; per-page version counter is 1-based — the next version number = max(version)+1, atomic via a DB sequence? no — the service does SELECT max(version) + 1 inside the transaction with a row lock on the page (SELECT … FOR UPDATE on the Page row — contention is low per page; documented). The snapshot shape (frozen contract, packages/shared/src/versioning.ts): { page: { title, icon, coverUrl, description, teamId, isPrivate }, blocks: [] } — blocks is [] in Phase 3 (Phase 4 fills the block tree + a schemaVersion: 1 field on the snapshot — the field exists now: { schemaVersion: 1, page, blocks } — Phase 4's codec reads schemaVersion).
Requirements
apps/api/src/modules/version/{version.module.ts, version.service.ts}: -createSnapshot(pageId, { note?, trigger: "manual" | "auto_block_ops", actorId })→ tx: lock page row,version = max+1, insertPageVersion(snapshot = current page fields +blocks: []+schemaVersion: 1); returns the version row. - Manual:POST /api/v1/pages/:id/versionsbody{ note? (≤ 200) }→EDITon the page;201 { version: { version, note, createdBy, createdAt } }; auditpage.version_created { pageId, version, trigger: "manual" }. - Auto: the counter infrastructure —reportBlockOperations(pageId, count = 1)(the Phase 4 call point; in Phase 3 it's callable + tested): a Redis counterpage:ops:<pageId>(INCRBY + EXPIRE 3600 — a rolling 1 h window); when the counter crosses a multiple of 100 (AUTO_SNAPSHOT_EVERY_OPS = 100, const inpackages/shared/src/constants.ts) →createSnapshot(trigger: "auto_block_ops", actorId: "system"); the crossing check isINCRBYreturn-value based (deterministic, race-safe: the crossing request takes the snapshot — two simultaneous crossings are impossible per key; documented). Phase 3 ships: the service + the call point + tests with a fake counter; Phase 4 wires the block mutation services toreportBlockOperations(the wiring task is listed in the Phase 4 file's scope note — recorded). - Retention: snapshots are kept (no cap in Phase 3 — Phase 15 adds retention; the list is paginated so unbounded storage is a data problem, not an API problem — documented).GET /api/v1/pages/:id/versions→VIEWon the page;{ items: [{ version, note, createdBy, createdAt, trigger }], nextCursor }(version desc, cursor = last version number, limit ≤ 50); the snapshot body is NOT in the list (the detail endpoint below — list responses stay small).GET /api/v1/pages/:id/versions/:version→VIEW;{ version, snapshot (full Json), note, createdBy, createdAt, trigger }; unknown version → 404.POST /api/v1/pages/:id/versions/:version/restore→EDIT; semantics: create a NEW snapshot from the CURRENT state first (so nothing is lost — the restore is always undoable), then apply the target snapshot'spagefields to the page (blocks restored in Phase 4),updatedBy= caller,version(page's optimistic-concurrency counter) bumped;200 { version: <new version number>, restoredFrom: <target> }; auditpage.version_restored { pageId, fromVersion, newVersion }.- UI — history panel (
apps/web/src/components/page/version-history-panel.tsx, opened from the header overflow "History…" —page-history-button; a slide-over panel,role="dialog", Esc closes): list (version number, note or "(no note)", trigger chipmanual/auto, relative time, creator name via a users-map query), per-row "Restore" (confirm dialog — "This creates a new version; the current state is saved first"), loading/error/empty states ("No versions yet — take a snapshot"), and a "Snapshot now" button at the top (version-snapshot-button, note input inline) — allEDIT-gated viaeffectiveLevel(VIEW users see the list read-only, no buttons — the components don't render).
Acceptance Criteria
- Manual snapshot:
POST versions→ 201 version 2 (after the implicit v1? — the page starts with version 1 inPage.versionbut noPageVersionrow; the first snapshot IS version 1? Decision (recorded):PageVersion.versionaligns withPage.versionat snapshot time — the first manual snapshot gets the page's current version number; the page's counter is NOT bumped by a snapshot (snapshots observe, they don't write the page). The test pins: create page → snapshot →PageVersion.version= 1 (the page's version), page row unchanged; patch title → snapshot → version 2.) - Auto:
reportBlockOperations(pageId, 99)→ no snapshot;+1→ exactly onePageVersionwithtrigger: "auto_block_ops"; the next 100 → the next (counter window asserted with a real redis in Testcontainers + a fake in unit). - Restore: snapshot v1 (title "A") → patch to "B" → snapshot v2 → restore v1 → title "A" + a NEW version row exists (v3, so the restore itself is undoable) + audit row; restore again v2 → "B" (the round trip).
- List pagination: 120 versions (generated) → 3 pages of 50/50/20,
nextCursorchain works; the list body contains no snapshot payloads (size assertion). - UI: panel opens, "Snapshot now" creates a row (appears at top without refetch — invalidation), restore flow works end-to-end (E2E in TASK-0046); a VIEW user sees the list with no action buttons (testid absence).
Backend Changes
apps/api/src/modules/version/*(module, service, 4 routes onpage.controller.tsor aversion.controller.ts— one controller per resource:version.controller.ts);packages/shared/src/{versioning.ts (snapshot contract), constants.ts (AUTO_SNAPSHOT_EVERY_OPS)}; audit names.
Frontend Changes
apps/web/src/components/page/{version-history-panel.tsx, restore-confirm-dialog. tsx}; header overflow wiring (the mount is in TASK-0044's header — the component ships here, ordering documented);apps/web/src/lib/queries/versions.ts.
Database Changes
prisma/schema.prisma:@@index([pageId, version])onPageVersionif absent → migration<ts>_version_index(record if it already exists — the Phase 0 migration may have it).
API Changes
POST/GET /pages/:id/versions,GET /pages/:id/versions/:version,POST /pages/:id/versions/:version/restore— documented indocs/api/pages.md(the snapshot contract + the restore-saves-first rule + the Phase 4blocksfill-in).
Tests
apps/api/src/modules/version/version.service.test.ts: version numbering decision (criterion 1 pinned), restore-save-first, counter crossing (fake), pagination. —tests/integration/versions.test.ts: criteria 1–4 (real redis counter, restore round trip, 120-version pagination).
Security
- Snapshot content is page data (no new PII class); restore is
EDIT-gated + audited with the full from→new trail; the auto path's actor isSYSTEM(auditable, no user impersonation); the Redis counter is per-key expiring (no unbounded keys).
Documentation
docs/api/pages.md: 4 version routes + the snapshot contract (linkpackages/shared/src/versioning.tsas source of truth);docs/ui/page-header.md: history panel section.
Dependencies
- TASK-0035 (page + version counter), TASK-0041 (effectiveLevel gating), TASK-0008 (Testcontainers redis), TASK-0038 (header overflow mount contract).
Definition of Done
Snapshot service + counter + 4 endpoints + panel live; all 5 criteria proven; docs written. (Phase 4 wires reportBlockOperations into block mutations — the call point is shipped and tested now.)
TASK-0044
Title
Page shell UI (route, breadcrumbs, header, states)
Objective
The page route (app)/[workspace]/[pageId]: the shell (breadcrumbs, header with the TASK-0038 metadata components + TASK-0043 history + TASK-0040 star, the empty content state "Add a subtitle"-adjacent — the body's empty state, loading skeleton, and the page-level 404 — assembling everything the phase built.
Background
All parts exist (tree TASK-0037, metadata TASK-0038, versions TASK-0043, favorites TASK-0040, the /w/<ws> gate TASK-0031). This task is the route + composition + state handling. The content area is intentionally empty in Phase 3: a card "Blocks arrive in Phase 4 — your page content will render here" (testid page-body-empty, recorded Phase 4 mount point page-body). The description placeholder "Add a subtitle" is TASK-0038's; the BODY empty state is distinct (recorded — the two placeholders must not be conflated in the UI review).
Requirements
apps/web/src/app/(app)/[workspace]/[pageId]/page.tsx+page-shell.tsx: - Resolution: theWorkspaceGate(TASK-0031) wraps the route; inside,usePage(pageId)(TASK-0035 hook) with states: loading → full-page skeleton (breadcrumbs placeholder + header skeleton + body skeleton, testidpage-loading, no layout shift — same heights as the real layout); 404 (unknown id OR trashed — the api returns 404 for trashed pages, TASK-0035) → the page not-found card (testidpage-not-found: "This page doesn't exist or was moved to the trash" + a "Open trash" link — the trashed case is indistinguishable to non-privileged callers by design, documented); 403 → the forbidden card (testidpage-forbidden: "You don't have permission to view this page" — no retry affordance, the permission is deterministic). - Breadcrumbs:workspace name / team (if any) / … / parent chain / current— built from the page'sparentIdchain fetched lazily (ausePageAncestorsquery: walkparentIdone hop at a time with a cache, cap 20 hops — the depth bound,…overflow collapses the middle with a "…" dropdown listing the hidden ancestors, testidbreadcrumb-…); the current crumb is the title (live — reflects an unsaved title edit? No — the crumb updates on save, documented); root crumbs are links (workspace crumb →/w/<ws>, team crumb →/w/<ws>?team=<slug>? No — teams have no dedicated route in Phase 3: the team crumb is a non-link text (recorded), page crumbs are links). - Header: cover (TASK-0038) → icon + title + description row (TASK-0038) + the action row: favorite star (TASK-0040), share button (stub:page-share- button, disabled + tooltip "Sharing UI arrives in Phase 4" — the API exists, TASK-0042 — recorded), history (TASK-0043), the "⋯" overflow (Permissions… dialog from TASK-0041, Duplicate, the context-menu equivalents — the menu component is TASK-0045, the overflow mounts it). - Body:page-body(empty state card now, Phase 4 editor mount).- Active-page sync: the URL's
pageIddrives the tree highlight (TASK-0037) — navigating from the tree sets the URL without reloading the page component (the shell reads the param reactively; switching pages within a workspace is a param change, not a mount — the breadcrumb chain cache makes it fast). - Keyboard:
/focuses… (Notion's/is the slash menu — Phase 4; in Phase 3Ctrl/Cmd+Kis reserved for search (Phase 8) — no shortcuts yet beyond the global ones; the page title is reachable via Tab (the first focusable element after the sidebar — the a11y order: sidebar → breadcrumbs → title → body). - Performance: the page route's initial data = ONE
GET /pages/:id(+ the ancestors walk ≤ 20 small GETs, cached) — no tree fetch on page load (the tree is already loaded in the sidebar); Lighthouse-style assertion in E2E: the page shell renders in < 300 ms on the dev stack after warm (the TTI budget, recorded as a dev-stack target, Phase 15 owns the production budget). - Theme + responsive: full theme parity (light/dark), 375 px: breadcrumbs truncate (the
…overflow from 20 hops also applies to width), header stacks (cover 120 px), no horizontal scroll.
Acceptance Criteria
/w/<ws>/<pageId>renders the full shell (breadcrumbs with the chain, header with icon/title/description/cover, action row, body empty card) — every testid present (the list is in the PR as the TASK-0046 assertion set).- Unknown pageId →
page-not-foundcard (with the trash link); a trashed page id → the same card (404 indistinguishability, TASK-0035 contract); a GUEST's denied page (no grant, not in their team) →page-forbiddencard — the two cards are distinct and both E2E-asserted. - Breadcrumbs: a 3-level page shows
Workspace / Team / Root / Mid / Current(5 crumbs, the team crumb non-link); at 24 ancestors (generated fixture) the middle collapses to…+ the dropdown lists the hidden ones (clicking one navigates). - Switching pages via the sidebar does not remount the shell (a render-count assertion via a module-level counter in the test) and the breadcrumb cache means ≤ 1 new ancestor query for a sibling page.
- 375 px + dark theme: no horizontal scroll, truncation active, theme tokens correct (computed styles); the
page-body-emptycard text is exact.
Backend Changes
None (one addition: GET /api/v1/pages/:id/ancestors?limit=20 → the parent chain as an array (id + title + teamId per hop, root first) — a single query walking the chain in SQL (recursive CTE or 20 indexed lookups server-side — the CTE, one round trip), VIEW per hop? No — the endpoint requires VIEW on the target page only (the chain is metadata, the page grant implies the ancestors are visible by inheritance — v2 rule; documented). Documented in docs/api/pages.md).
Frontend Changes
apps/web/src/app/(app)/[workspace]/[pageId]/{page.tsx, page-shell.tsx},apps/web/src/components/page/{breadcrumb-bar.tsx, page-body.tsx, not-found-cards.tsx, page-overflow-menu.tsx (mounts TASK-0045's menu)},apps/web/src/lib/queries/{page.ts, ancestors.ts}.
Database Changes
None.
API Changes
GET /pages/:id/ancestors— documented indocs/api/pages.md.
Tests
- Component:
breadcrumb-bar.test.tsx(collapse at 20, links, non-link team),page-shell.test.tsx(the 4 states, render-count on param change, focus order). —apps/api/src/modules/page/ancestors.test.ts: CTE depth cap, VIEW-only gate. — The 5 criteria' E2E assertions in TASK-0046.
Security
- The 404/403 split for pages (non-member → 404 via the guard's scoping, member-but-denied → 403 — the TASK-0028 split extended to pages; a trashed page is 404 to everyone — no trash enumeration via the page route, the trash list endpoint is the gated path — documented); the ancestors endpoint leaks chain titles only (no emails/settings).
Documentation
docs/ui/page-header.md: the full shell inventory (breadcrumbs, action row, states, the Phase 4 mount points:page-body, share button). —docs/api/pages.md: the ancestors endpoint.
Dependencies
- TASK-0031 (gate), TASK-0035 (GET + 404 contract), TASK-0036 (tree), TASK-0037 (highlight), TASK-0038 (metadata), TASK-0040 (star), TASK-0041 (permissions dialog + 403), TASK-0043 (history).
Definition of Done
Route + shell + states live; the ancestors endpoint pinned; component tests green; the 5 E2E criteria ready for TASK-0046; docs written.
TASK-0045
Title
Page context menu (12 actions wired to existing APIs)
Objective
The page context menu (right-click on tree nodes + the header "⋯" overflow) with the 12 actions: Open, Open in new tab, Duplicate, Rename, Move, Copy link, Favorite, Archive, Delete, Export (stub Phase 13), Share (stub Phase 4 UI), Permissions (TASK-0041 dialog) — every non-stub wired to the API that exists, role-gated by effectiveLevel, with the standard disabled-stub pattern for the two Phase 4/13 items.
Background
The Phase 2 stub pattern (disabled + tooltip + phase number) is the convention for Export/Share. "Rename" and "Move" need two small UI surfaces that don't exist yet: inline rename in the tree row (double-click / Enter on the title) and the move dialog (destination picker: root / team / existing page, one level deep with search — the move API is TASK-0036). Both ship here.
Requirements
apps/web/src/components/page/page-context-menu.tsx(role="menu", keyboard: arrows + Enter + Esc, opens on right-click (contextmenu) of a tree node and on the header "⋯" button; positioned at the cursor with viewport clamping; the same menu component for both mounts — single source): actions, their gates (the gate = the caller'seffectiveLevelfrom the page's GET/tree DTO — the tree row carries the level; the menu hides ungated actions entirely, the TASK-0030 pattern), and their wiring: - Open (menu-item-open):router.push(pageUrl)— always (if VIEW). - Open in new tab (menu-item-open-tab):window.open(pageUrl)— always. - Duplicate (menu-item-duplicate, EDIT):useDuplicatePage(TASK-0035) → the new page appears in the tree (invalidation) + toast with an "Open copy" action → navigates to the copy; busy state (subtree duplicates can take time — a spinner on the item, the menu closes on start, the toast tracks completion). - Rename (menu-item-rename, EDIT): opens inline rename on the tree row (or focuses the header title when invoked from the header — both target the samepage-titlecomponent; the tree rename is a single-line input replacing the title, Enter saves / Esc cancels / blur saves, 400/409 toasts). - Move (menu-item-move, EDIT): the move dialog (move-page-dialog.tsx): a search field + a destination tree (root " Workspace root", each team, and the current page's ancestors as a quick list — one level of nesting: team → its root pages; the search filters pages by title prefix, 20 results,usePageSearch? — no search API in Phase 3: the dialog lists teams + the page's own branch (the ancestor chain, which is already loaded) + "Workspace root" — a full page search picker is Phase 4 with the search engine — recorded scope note); confirm →useMovePage(TASK-0036) → success toast + the tree refetches the affected parents (the dialog passes the moved id; invalidation targets the old + new parent lists); acycle_detected409 → the dialog stays open with an inline error "Can't move a page into itself or its sub-pages" (the dialog's own guard pre-filters the current branch from the destination list — the API is the backstop, both tested). - Copy link (menu-item-copy-link):navigator.clipboard.writeText(pageUrl)(the URL =${origin}/w/<ws-slug>/<pageId>— TASK-0031 contract) → toast "Link copied"; always (VIEW — a VIEW user can share the link; whether the recipient can open it is the recipient's permission — documented). - Favorite (menu-item-favorite, VIEW): the toggle (TASK-0040 hook); label flips "Favorite" / "Remove from favorites" (the item shows a check when favorited). - Archive (menu-item-archive, EDIT):useArchivePage→ the page disappears from the tree (invalidation) + toast with an "Undo" action (calls unarchive — the undo window is the toast's life, 6 s, no server-side undo — documented). - Delete (menu-item-delete, EDIT — the trash API, TASK-0039): confirm dialog (subtree warning whenchildCount > 0: "This will move N sub-pages to the trash too") →useTrashPage→ toast "Moved to trash" + "Undo" (restore the root — the undo restores the whole subtree, the TASK-0039 round trip). - Export (menu-item-export, disabled stub): tooltip "Export arrives in Phase 13" (PDF/Markdown/CSV — the Phase 13 file scope). - Share (menu-item-share, disabled stub): tooltip "Sharing UI arrives in Phase 4" (the API exists — TASK-0042 — the stub's tooltip says exactly this, no false "coming soon"). - Permissions (menu-item-permissions, FULL_ACCESS effective): opens the TASK-0041 permissions panel dialog.- A11y: every item
role="menuitem",aria-disabledfor stubs, the menu is a focus trap (Esc closes + focus returns to the trigger), the trigger rows (tree nodes) expose the menu via a visible kebab button on hover/focus (touch parity — right-click has no touch equivalent: the kebab is the touch/keyboard path, testidpage-menu-kebab-<id>). - The menu never renders actions whose API call would 403 (the gate is
effectiveLevel— a VIEW user's menu has no Duplicate/Rename/Move/Archive/ Delete/Permissions; the E2E asserts the absence, not hidden).
Acceptance Criteria
- Right-click on a tree node (and the header ⋯) opens the same 12-item menu (item count asserted per role: GUEST → 5 items: Open, Open in tab, Copy link, Favorite, + the 2 stubs visible-disabled? — the stubs are visible to everyone (they're roadmap markers, disabled) — so GUEST → 7 items (5 + 2 stubs), MEMBER (EDIT) → 11 (all but Permissions), OWNER → 12 — the exact counts asserted).
- Duplicate via the menu: the copy appears under the same parent with " (copy)", the toast's "Open copy" navigates (E2E); a subtree duplicate shows the busy state then success.
- Rename inline (tree): save persists + the crumb/tree/
<title>all update; Esc cancels (no API call — network assertion); a 409 (concurrent edit) → toast + the row reverts to the server value. - Move dialog: moving a page to another team works (tree refetch shows it under the new team, old team list shrinks); moving into own descendant is pre-filtered (the branch is absent from the dialog) AND the API backstop 409 is mapped to the inline error (the unit test calls the API directly to prove the backstop).
- Copy link writes the exact URL format to the clipboard (E2E clipboard permission granted in the config, value asserted); Archive + Undo and Delete + Undo both round-trip via the menu (the trash/restore E2E in TASK-0046 uses these menu paths).
- Keyboard: full menu operation without a mouse (open via kebab focus + Enter, arrows, Enter, Esc) — the E2E keyboard pass in TASK-0046.
Backend Changes
None (all APIs exist: TASK-0035 duplicate/archive, TASK-0036 move, TASK-0039 trash/restore, TASK-0040 favorite, TASK-0041 permissions, TASK-0044 URL contract).
Frontend Changes
apps/web/src/components/page/{page-context-menu.tsx, move-page-dialog.tsx, inline-rename.tsx, delete-confirm-dialog.tsx (the subtree-warning variant), archive-undo-toast.tsx}; the tree node + header overflow mount the menu;apps/web/src/lib/queries/pages.ts(the move/duplicate/archive hooks' invalidation targets refined).
Database Changes
None.
API Changes
None.
Tests
- Component:
page-context-menu.test.tsx(the 3 role-variant item counts, stub tooltips, focus trap, aria-disabled),move-page-dialog.test.tsx(branch pre-filter, 409 mapping),inline-rename.test.tsx(save/cancel/409). — The 6 criteria' E2E assertions in TASK-0046 (menu is the primary actor there).
Security
- The gate hides actions by
effectiveLevel(presentation); the API is the enforcer — the E2E includes one deliberate "hidden action attempted via direct API call as a GUEST → 403" check (defense-in-depth proof); the clipboard URL carries no token (the page URL is permission-checked server-side on open — a copied link is only as good as the recipient's access, documented).
Documentation
docs/ui/page-header.md: the context menu inventory (action, gate, API, stub phase) — the single reference;docs/ui/sidebar.md: the kebab affordance.
Dependencies
- TASK-0035, 0036, 0039, 0040, 0041, 0044 (all the wiring targets), TASK-0037 (tree node mounts), TASK-0029 (the a11y menu conventions).
Definition of Done
Menu + move dialog + inline rename live, wired to all existing APIs, stubbed exactly for Export/Share; all 6 criteria (E2E-ready); docs written.
TASK-0046
Title
E2E page flows (tests/e2e/pages.spec.ts)
Objective
Prove the Phase 3 exit criteria with Playwright in tests/e2e/pages.spec.ts: create 3-level nested pages, rename, move, trash/restore round trip, favorite, GUEST permission denial with 403 + audit row, and a version snapshot visible in the UI — plus the tree/shell/menu coverage the earlier tasks flagged as E2E-ready.
Background
The harness + helpers are established (TASK-0008/0022/0033). This spec is the phase's final gate: it drives the UI (menu-driven where TASK-0045 built the menu) and asserts DB state through helpers/db.ts. Actors: A = the seeded demo user (owner of the seeded demo workspace — the spec works in a FRESH workspace it creates, slug e2e-pages-<ts>, for clean assertions), B = a second user A invites as MEMBER (TASK-0026 flow), C = a GUEST (A invites C at GUEST — the denial-assertion actor). Unique slugs/emails per run; the DB resets per CI run.
Requirements
tests/e2e/pages.spec.ts(serial), flows: - Flow 1 (tree creation): A creates workspace + teamspace "Dev" (settings → Teams tab); in the sidebar:new-page-in-team-<id>→ "Root" (Enter) → hover Root → + → "Child" (parent = Root) → Child + → "Grandchild" (parent = Child) → the 3-level tree renders with correct indent (depths 0/1/2 — the indent px assertion, TASK-0037) → DB: 3 rows,parentIdchain,grandparentIdon Grandchild, positions unique. - Flow 2 (rename + metadata): double-click "Root" → inline rename to "Root v2" → Enter → tree + DB + (open the page) breadcrumb +<title>all show "Root v2"; set an emoji icon (🚀) + a gradient cover + description "Sub title" via the page header → reload → all persist (TASK-0038 E2E criteria). - Flow 3 (move): context menu (right-click Child row) → Move → dialog → pick "Workspace root" → confirm → Child appears under the root list, gone from Root's children (the tree refetch, no reload) → DB:parentIdnull,grandparentIdnull. - Flow 4 (trash/restore — the exit criterion): menu on "Root v2" → Delete → confirm (the dialog shows "2 sub-pages" — Child + Grandchild) → the subtree vanishes from the sidebar (invalidation) → Trash page (nav-trash): Root v2 listed with "· 2 sub-pages" + "Trashed 0d ago" → Restore → confirm → the whole subtree is back under "Dev" with the original positions (the tree order snapshot before/after — the TASK-0039 round trip, UI-driven) → DB:trashedAtnull,restoredAtset, audit rowspage.trashed(nodeCount 3) +page.restoredpresent. - Flow 5 (favorite): menu on "Child" → Favorite → the sidebar Favorites section shows "Child" (star row) → open the page → header stararia-pressedtrue → menu → Remove from favorites → row gone (TASK-0040). - Flow 6 (GUEST denial + audit — the exit criterion): A invites C as GUEST (settings → Members → invite dialog, role GUEST) → C verifies + accepts (TASK-0022/0033 helper) → C opens/w/<ws>/→ the sidebar shows the teamspace (GUEST can view) → C opens "Root v2" → the page shell renders read-only (no rename editable, menu has 7 items — the TASK-0045 GUEST count) → C's direct APIPATCH /pages/<rootId>(title change, via helper) → 403forbidden→ DB:audit_logshas apermission.deniedrow (actionedit, resourceTypepage, the root id, actor C, C's ip) — asserted within 2 s of the 403 (fire-and-forget budget). - Flow 7 (versions visible): A on "Root v2": open History… → "Snapshot now" (note "before rename test") → the panel lists version N with the note +manualchip → rename the title to "Renamed" → the panel still shows the snapshot (list is historical) → restore it (confirm) → the title is back to "Root v2" (the restore via UI, TASK-0043 round trip) + the panel now shows a NEW version at top (the restore snapshot) → DB: 2 newPageVersionrows (manual + the restore's pre-restore snapshot) +page.version_restoredaudit. - Flow 8 (shell states): unknown pageId →page-not-foundcard (the trash link works → the Trash page); a page C cannot see (A creates a private page in "Dev" and revokes? — simpler: A's personal-workspace page: C's/w/<personal-ws>/<pageId>→ 404 (non-member) — the 404 card; and the 403 card: C on a GUEST-denied page? GUEST can VIEW all workspace pages by the v2 base level — so the 403 card case: create a page with aNONEpage-grant on C (via the Permissions dialog as A) → C opens it →page-forbiddencard + anotherpermission.deniedaudit row (this is the sharpest denial case — it replaces the Flow 6 API-only denial as the card assertion; Flow 6 keeps the API 403 + audit). - Flow 9 (performance probe): a 100k-page fixture? No — E2E stays fast: the tree perf is the integration probe (TASK-0036 criterion 5, CI); the E2E asserts the lighter contract: expanding "Dev" with 60 root pages (created in a setup via direct SQL for speed) renders the first 50 in < 2 s wall clock (the per-node page size) — the "loads in < 2 s" exit criterion's E2E proxy, the full 100k proof lives intests/integration/page-tree-perf.test.ts(both referenced from the PR's exit mapping).- Theme/responsive: the page shell at 375 px + dark (TASK-0044 criterion 5) — one screenshot pair + computed-style assertions (no new screenshots committed; the artifacts are on CI).
- Audit row assertions throughout use
helpers/db.tswith the exactactionstrings frompackages/shared/src/audit-events.ts(imported in the spec — no string drift). - The spec passes twice consecutively (unique timestamps; the fixture SQL setup is idempotent per run).
Acceptance Criteria
pnpm test:e2e(pages.spec.ts) green on the dev stack, clean machine (run recorded in the PR); all 9 flows complete in order.- Flow 4's trash/restore round trip is UI-driven end-to-end (menu Delete → Trash page → Restore → tree correct, positions intact) — the "trash/restore round- trip E2E-tested" exit criterion.
- Flow 6 + Flow 8: the 403 +
permission.deniedaudit row (API denial) and thepage-forbiddencard + audit row (UI denial) both proven — the "unauthorized access rejected with audit event" exit criterion. - Flow 1–3: the 3-level tree, rename propagation (4 surfaces), and the move (root) all correct in UI and DB.
- Flow 5 + 7: the favorite round trip and the snapshot-visible + restore round trip pass (2
PageVersionrows + audit). - Flow 8's state cards (not-found/forbidden) + Flow 9's < 2 s expand + the theme/responsive checks pass; the spec passes twice in a row.
Backend Changes
None (a gap found = a bug in an earlier Phase 3 task, fixed in that module in the same PR, recorded).
Frontend Changes
tests/e2e/pages.spec.ts;tests/e2e/helpers/fixture.ts(the 60-page SQL fixture + team setup, idempotent, used by Flow 9 + the setup).
Database Changes
None (fixture writes are test data in the E2E DB, reset per CI run).
API Changes
None.
Tests
- The spec is the deliverable;
docs/development/testing.mdgains the pages-E2E run section (like TASK-0033's).
Security
- The spec's actors are isolated (unique workspace); the GUEST denial flows assert the audit rows belong to C (actor id match); no production origin in the spec.
Documentation
- PR description: exit-criterion → flow mapping (Flow 9 + the integration perf probe → tree < 2 s; Flow 4 → trash round trip; Flow 6/8 → audit on denial). —
docs/development/testing.md: pages E2E section.
Dependencies
- TASK-0035–0045 (everything), TASK-0008 (harness), TASK-0009 (health), TASK-0022/0033 (actor helpers + dev-code reader), TASK-0026 (invite flows).
Definition of Done
tests/e2e/pages.spec.ts green twice locally + in CI; all 6 criteria proven; exit mapping recorded; docs updated. Phase 3 exit criteria fully met — Phase 4 may start.