.github/9
ISSUE_TEMPLATE/3bug_report.md config.yml feature_request.md
workflows/3ci.yml clean-clone.yml release.yml
CODEOWNERS dependabot.yml pull_request_template.md
apps/630
api/250
src/243
common/30
__tests__/3authorize.test.ts bigint-serialization.interceptor.test.ts files-limits.test.ts
filters/1all-exceptions.filter.ts
guards/3auth.guard.ts internal-service.guard.ts rate-limit.guard.ts
interceptors/2actor-context.interceptor.ts bigint-serialization.interceptor.ts
middleware/2idempotency.test.ts idempotency.ts
pipes/1zod-validation.pipe.ts
actor-context.ts audit.service.ts cache.test.ts cache.ts cookies.ts core.module.ts deprecation.ts email.service.ts errors.ts event-bus.ts log-scrubber.test.ts log-scrubber.ts logger.ts permission-cache-bridge.ts prisma.service.ts redis.service.ts trace-metadata.ts types.ts
config/1env.ts
lib/11
__tests__/5crypto.test.ts filter-builder.test.ts jwt.test.ts pagination.test.ts positions.test.ts
authorize.ts crypto.ts filter-builder.ts jwt.ts pagination.ts positions.ts
modules/197
ai/28
__tests__/4ai-writing.test.ts database-ai.test.ts generate-executor.test.ts prompts.test.ts
generator/4executor.ts generate.controller.ts generate.dto.ts generate.service.ts
ai-chat.service.ts ai-rate-limit.ts ai-writing.controller.ts ai-writing.dto.ts ai-writing.service.ts ai.controller.ts ai.module.ts ai.service.ts budget-guard.service.ts database-ai.controller.ts database-ai.dto.ts database-ai.service.ts db-prompts.ts dto.ts gateway-provider.ts ledger.service.ts prompts.ts rag.controller.ts rag.dto.ts rag.service.ts
api-keys/3api-keys.controller.ts api-keys.module.ts api-keys.service.ts
audit/2audit-log.controller.ts audit.module.ts
auth/4auth.controller.ts auth.module.ts auth.service.ts dto.ts
automations/5automations.controller.ts automations.module.ts automations.service.ts dto.ts webhook-incoming.controller.ts
blocks/5
__tests__/1batch-version-cas.test.ts
block.service.ts blocks.controller.ts blocks.module.ts dto.ts
comments/4comments.controller.ts comments.module.ts comments.service.ts dto.ts
databases/9
__tests__/3normalize-select-options.test.ts option-id-canonicalization.test.ts record-open-query-shape.test.ts
database.service.ts databases.controller.ts databases.module.ts dto.ts property-cache-schema.ts query.service.ts
docs/2docs.controller.ts docs.module.ts
files/10
__tests__/1quota.test.ts
dto.ts files.controller.ts files.module.ts files.service.ts quota.ts record.ts s3.ts uploads.controller.ts uploads.service.ts
forms/18debug.controller.ts form.dto.ts forms.controller.ts forms.module.ts forms.service.ts markdown.test.ts markdown.ts optional-auth.guard.ts public-forms.controller.ts public-forms.service.ts question.dto.ts questions.service.ts rate-limit.ts recaptcha.ts slug.ts submissions.controller.ts submissions.service.ts submit-schema.ts
health/3health.controller.ts health.module.ts health.service.ts
integrations/3integrations.controller.ts integrations.module.ts integrations.service.ts
internal/12
__tests__/2flush-query-shape.test.ts internal.endpoints.test.ts
ai-test-seam.controller.ts ai-test-seam.dto.ts ai-test-seam.service.ts internal.controller.ts internal.dto.ts internal.module.ts internal.service.ts test-seam.controller.ts test-seam.dto.ts test-seam.service.ts
notifications/3notifications.controller.ts notifications.module.ts notifications.service.ts
oauth/6
__tests__/1oauth.scopes.test.ts
oauth.controller.ts oauth.module.ts oauth.service.ts scopes.ts token-keys.ts
org/15audit.service.ts embed-preview.controller.ts guards.ts members.service.ts org-admin.controller.ts org-policy.service.ts org.module.ts overview.service.ts policy-effective.controller.ts policy.schema.ts policy.test.ts retention.service.ts totp.test.ts totp.ts two-factor.service.ts
orgs/5dto.ts orgs.controller.ts orgs.module.ts orgs.service.ts util.ts
pages/4dto.ts page.service.ts pages.controller.ts pages.module.ts
scim/15
__tests__/3email-mapping.test.ts etag.test.ts filters.test.ts
etag.ts filters.ts groups.service.ts schemas.ts scim-admin.controller.ts scim-admin.service.ts scim-auth.guard.ts scim-auth.service.ts scim-errors.ts scim.controller.ts scim.module.ts users.service.ts
search/4
__tests__/1search.service.test.ts
search.controller.ts search.module.ts search.service.ts
sessions/3sessions.controller.ts sessions.module.ts sessions.service.ts
share/3share.controller.ts share.module.ts share.service.ts
sso/16
__tests__/4enforcement.test.ts oidc.verify.test.ts provisioning.test.ts saml.validate.test.ts
enforcement.ts oidc.controller.ts oidc.service.ts oidc.ts provisioning.service.ts saml.controller.ts saml.ts schemas.ts sso-admin.controller.ts sso-admin.service.ts sso-secrets.ts sso.module.ts
teams/4dto.ts teams.controller.ts teams.module.ts teams.service.ts
users/4dto.ts users.controller.ts users.module.ts users.service.ts
webhooks/3webhooks.controller.ts webhooks.module.ts webhooks.service.ts
workspaces/4dto.ts workspaces.controller.ts workspaces.module.ts workspaces.service.ts
types/1xmldom.d.ts
app.module.ts constants.ts main.ts
_probe_rawbody.cjs .env.example Dockerfile package.json README.md tsconfig.json vitest.config.ts
realtime/33
src/17auth.ts comments.ts config.ts event-source.ts http-store.ts index.ts metrics.ts notifications.ts page-updated-source.ts presence.ts prisma-store.ts protocol.ts rate-limit.ts room-key.ts rooms.ts server.ts update-log.ts
test/11auth.test.ts config.test.ts event-source.test.ts http-store.test.ts metrics-merge.test.ts page-updated-source.test.ts presence.test.ts protocol.test.ts rate-limit.test.ts rooms.test.ts task-0094-builders.test.ts
.env.example Dockerfile package.json README.md tsconfig.json
web/255
src/244
app/45
__tests__/1theme-tokens.test.ts
(app)/26
w/25
[workspace]/25
ask/1page.tsx
databases/1
[id]/1page.tsx
favorites/1page.tsx
files/1page.tsx
notifications/1page.tsx
page/4
[id]/4
[block]/1page.tsx
share/1page.tsx
layout.tsx page.tsx
search/1page.tsx
settings/10
appearance/1page.tsx
automations/3
[id]/1page.tsx
new/1page.tsx
page.tsx
billing/1page.tsx
developers/1page.tsx
members/1page.tsx
permissions/1page.tsx
teams/1page.tsx
page.tsx
shared/1page.tsx
templates/1page.tsx
trash/1page.tsx
layout.tsx page.tsx
layout.tsx
(auth)/4
login/1page.tsx
register/1page.tsx
reset-password/1page.tsx
layout.tsx
api/2
metrics/1route.ts
v1/1
health/1route.ts
f/1
[slug]/1page.tsx
health/1
ready/1route.ts
org/2
[orgId]/2
admin/2
integrations/1page.tsx
page.tsx
share/1
[id]/1page.tsx
error.tsx global-error.tsx globals.css layout.tsx not-found.tsx page.tsx providers.tsx
components/118
admin/10
org-admin/8ApiKeysTab.tsx AuditTab.tsx MembersTab.tsx OverviewTab.tsx PoliciesTab.tsx RetentionTab.tsx WebhooksTab.tsx WorkspacePicker.tsx
ScimCard.tsx SsoTabs.tsx
ai/6ai-panel.tsx ask-ai-submenu.tsx db-ai-dialog.tsx generate-page-dialog.tsx writing-actions-menu.tsx writing-diff.tsx
automations/5action-editor.tsx condition-editor.tsx lib.ts run-history.tsx trigger-editor.tsx
collaboration/6CursorLayer.tsx OfflineBanner.tsx PresenceBar.tsx README.md ReviewFailedDialog.tsx StatusChip.tsx
comments/3comment-panel.tsx comment-thread.tsx mention-input.tsx
database/27
__tests__/2cell-editor.test.tsx cell-renderer.test.tsx
AiExtractDialog.tsx AiFormulaDialog.tsx AiGenerateDialog.tsx board-view.tsx buttons-panel.tsx calendar-view.tsx cell-editor.tsx cell-renderer.tsx chart-view.tsx database-page-view.tsx database-toolbar.tsx database-view-renderer.tsx db-embedded.tsx filter-builder.tsx form-builder-view.tsx formula-editor.tsx gallery-view.tsx group-builder.tsx list-view.tsx property-editor.tsx record-header.tsx record-panel.tsx sort-builder.tsx table-view.tsx timeline-view.tsx
dialogs/7
__tests__/1move-to-dialog.test.tsx
create-dialog.tsx dialog.tsx file-permissions-dialog.tsx invite-dialog.tsx move-to-dialog.tsx share-dialog.tsx
editor/19
__tests__/3code-block-text.test.ts code-block.test.tsx expandable.test.tsx
block-card.tsx block-renderer.tsx block-save-queue.ts block-toolbar.tsx context-menu.tsx expandable.tsx history-popover.tsx inline-editor.tsx insert-dialog.tsx page-canvas.tsx page-spinner.tsx page-view.tsx selection-toolbar.tsx slash-menu.tsx template-dialog.tsx type-menu.tsx
files/4FileBlock.tsx FileBrowser.tsx FilePicker.tsx PreviewModal.tsx
form/4form-field.tsx form-renderer.tsx form-responses-view.tsx form-view.tsx
layout/8
__tests__/1page-tree-icon.test.tsx
command-palette.tsx factory-logo.tsx page-tree-node.tsx sidebar.tsx theme-provider.tsx topbar.tsx workspace-shell.tsx
mentions/3
__tests__/1mention-model.test.ts
mention-model.ts MentionPopup.tsx
search/3ask-ai-search.tsx results-list.tsx search-dialog.tsx
settings/10
api-keys/4ApiKeyCreateDialog.tsx ApiKeyRevealDialog.tsx ApiKeyTable.tsx ScopesBadge.tsx
webhooks/5CurlExamples.tsx DeliveryInspector.tsx EventExplorer.tsx OverviewTab.tsx WebhooksTab.tsx
settings-shell.tsx
ui/3confirm-dialog.tsx form.tsx toaster.tsx
hooks/21
__tests__/4realtime-frame-handler.test.ts use-blocks.test.ts use-database-options.test.ts use-workspace.test.tsx
realtime-frame-handler.ts use-ai-stream.ts use-auth.ts use-blocks.ts use-database.ts use-db-ai-stream.ts use-forms.ts use-members.ts use-mention-sources.ts use-notifications.ts use-offline-status.ts use-pages.ts use-presence-announcer.ts use-presence-toasts.ts use-presence.ts use-realtime.ts use-workspace.ts
lib/50
__tests__/21ai-database.test.ts ai-generate.test.ts ai-sse.test.ts ai-writing.test.ts api-keys.test.ts api-members.test.ts auth-refresh.test.ts auth-store.test.ts batch-queue-drop.test.ts block-content.test.ts clipboard-import.test.ts code-highlight.test.ts offline-status.test.ts presence-announcer.test.ts presence-color.test.ts presence-cursor.test.ts presence-emitter.test.ts realtime-url.test.ts search.test.ts utils.test.ts writing-diff.test.ts
files/7
__tests__/2picker.test.ts upload.test.ts
api.ts picker.ts upload-store.ts upload.ts use-file-uploads.ts
ai-database.ts ai.ts api-keys.ts api.ts auth-store.ts batch-queue.ts block-content.ts clipboard-import.ts code-highlight.ts constants.ts offline-status.ts org-admin.ts presence-announcer.ts presence-color.ts presence-cursor.ts presence-editor-views.ts presence-emitter.ts query-client.ts realtime.ts search.ts types.ts utils.ts
stores/8
__tests__/1block-patch-store.test.ts
block-patch-store.ts comment-pill-store.ts database-store.ts editor-store.ts offlineQueue.ts presence-store.ts ui-store.ts
instrumentation.ts middleware.ts
.env.example Dockerfile next-env.d.ts next.config.mjs package.json postcss.config.mjs README.md tailwind.config.ts tsconfig.json tsconfig.tsbuildinfo vitest.config.ts
worker/92
src/87
__tests__/13ai-embedding.test.ts ai-request.test.ts ai-usage-budget.test.ts audit-export.test.ts email-decision.test.ts encrypt.test.ts health-search-index-lag.test.ts mime.test.ts retention.test.ts s3-sign.test.ts search-index.test.ts smtp.test.ts webhook-sign.test.ts
health/1server.ts
lib/12
__tests__/1clamav.test.ts
clamav.ts encrypt.ts job-otel.ts mime.ts notify.ts prisma.ts redis-stream.ts redis.ts s3.ts sharp.ts smtp.ts
processors/56
automations/28
__tests__/3condition-evaluator.spec.ts executors.spec.ts run-pipeline.spec.ts
executors/17assign-user.ts call-integration.ts change-status.ts classify.ts create-notification.ts create-page.ts email-cap.ts helpers.ts integration-registry.ts permission.ts record-write.ts registry.ts run-ai-action.ts send-email.ts send-webhook.ts types.ts update-record.ts
condition-evaluator.ts dead-letter.ts log.ts matcher.ts pipeline.ts run-state.ts schedule.ts watchdog.ts
files/15
__tests__/4errors.test.ts scan-gate.test.ts status.test.ts temp.test.ts
steps/4finalize.ts metadata.ts scan.ts thumbnails.ts
cleanup.ts errors.ts generateThumbs.ts processor.ts status.ts temp.ts types.ts
search-index/4builders.ts grants.ts index.ts processor.ts
ai-embedding.ts ai.processor.ts audit-export.ts automations.processor.ts email-decision.processor.ts email.processor.ts maintenance.processor.ts retention.ts webhooks.processor.ts
config.ts events.ts index.ts logger.ts queues.ts
.env.example Dockerfile package.json README.md tsconfig.json
docs/73
api/17ai.md authentication.md automations.md blocks.md comments.md databases.md files.md forms.md openapi.yaml org-admin.md pages.md scim.md search.md sso.md versioning.md webhooks.md workspaces.md
architecture/10ai-architecture.md automation.md backend-architecture.md database-architecture.md frontend-architecture.md realtime-architecture.md search-architecture.md security-architecture.md storage.md system-architecture.md
database/2database-architecture.md redis-keys.md
development/8admin-guide.md coding-standards.md deployment.md development-guide.md files-pipeline.md perf-report.md performance.md testing.md
product/4functional-requirements.md non-functional-requirements.md product-requirements.md ux-requirements.md
requirements/1requirements-matrix.md
security/5audit-report.md files.md forms.md license-allowlist.md security-architecture.md
tasks/16phase-00-foundation.md phase-01-authentication.md phase-02-workspace.md phase-03-pages.md phase-04-editor.md phase-05-databases.md phase-06-relations.md phase-07-collaboration.md phase-08-search.md phase-09-files.md phase-10-forms.md phase-11-automation.md phase-12-ai.md phase-13-api.md phase-14-enterprise.md phase-15-production.md
ui/9ai.md automations.md design-system.md developers.md files.md forms.md org-admin.md scim.md sso.md
support-matrix.md
infrastructure/71
caddy/1Caddyfile
docker/6
init/101-extensions.sql
opensearch/1init-indices.json
.env.docker docker-compose.yml opensearch-init.sh README.md
kubernetes/40
observability/20
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1sync-sources.mjs
alertmanager.yaml grafana.yaml kustomization.yaml namespace.yaml node-exporter.yaml otel-collector.yaml prometheus.yaml
postgres/7configmap.yaml cronjob-backup.yaml cronjob-opensearch-snapshot.yaml pgbouncer.yaml replica.yaml statefulset.yaml walg-iam-policy.yaml
api.yaml configmap.yaml ingress.yaml kustomization.yaml namespace.yaml networkpolicy.yaml pdb.yaml README.md realtime.yaml redis.yaml secret.example.yaml web.yaml worker.yaml
nginx/2nginx.conf README.md
observability/14
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1dashboard-completeness.mjs
README.md
terraform/8compute.tf data.tf main.tf network.tf outputs.tf README.md security.tf variables.tf
packages/289
ai/24
src/20
providers/7anthropic.ts google.ts http.ts index.ts openai-compatible.ts rag.test.ts writing.test.ts
schemas/2database.ts generate.ts
adapters.test.ts config.test.ts config.ts gateway.test.ts gateway.ts index.ts tools.test.ts tools.ts types.ts usage.test.ts usage.ts
package.json README.md tsconfig.json vitest.config.ts
auth/11
src/7index.ts passwords.test.ts passwords.ts sessions.test.ts sessions.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
collaboration/17
src/13awareness.ts codec.test.ts codec.ts index.ts lww.test.ts lww.ts mapping.ts offline.ts outbox.test.ts outbox.ts provider.test.ts provider.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
config/7
src/4eslint.base.js index.ts prettier.base.json tsconfig.base.json
package.json README.md tsconfig.json
database-client/5
src/2index.test.ts index.ts
package.json README.md tsconfig.json
database-engine/33
src/29
filter/2filter.test.ts index.ts
formula/8evaluator.ts formula.test.ts functions.ts index.ts lexer.ts parser.ts types.ts validate.ts
query/6group.ts index.ts paginate.ts pivot.ts query.test.ts sort.ts
relations/2index.ts relations.test.ts
rollups/2index.ts rollups.test.ts
values/5dates.ts index.ts registry.ts types.ts values.test.ts
views/3buttons.ts index.ts views.test.ts
index.ts
package.json README.md tsconfig.json vitest.config.ts
editor/25
src/21
marks/1highlight.ts
nodes/2equation.ts mention.ts
autosave.test.ts autosave.ts clipboard.test.ts clipboard.ts codec.test.ts codec.ts context-menu.test.ts context-menu.ts diff.test.ts diff.ts factory.ts history.test.ts history.ts index.ts positions.ts slash.test.ts slash.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
observability/20
src/16
__tests__/8db-redis-metrics.test.ts metrics.test.ts otel.test.ts pino-otel.test.ts prometheus.test.ts resource.test.ts scrubber.test.ts traceparent.test.ts
index.ts metrics.ts otel.ts pino-otel.ts prometheus.ts resource.ts scrubber.ts traceparent.ts
package.json README.md tsconfig.json vitest.config.ts
permissions/11
src/7index.ts levels.test.ts levels.ts resolve.test.ts resolve.ts rules.test.ts rules.ts
package.json README.md tsconfig.json vitest.config.ts
search/22
src/18chunking.test.ts chunking.ts client.test.ts client.ts embeddings.test.ts embeddings.ts hybrid.test.ts hybrid.ts index.ts indexer.test.ts indexer.ts mapping.test.ts mapping.ts queries.test.ts queries.ts rrf.test.ts rrf.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
shared/61
src/57
dto/17ai.dto.ts auth.dto.test.ts auth.dto.ts block.dto.ts browser-safety.test.ts comment.dto.ts database.dto.test.ts database.dto.ts file.dto.ts formula-ast.ts index.ts notification.dto.ts page.dto.ts search.dto.ts share.dto.ts webhook.dto.ts workspace.dto.ts
events/8audit-catalog.test.ts audit-catalog.ts audit-csv.test.ts audit-csv.ts audit-scope.test.ts audit-scope.ts catalog.test.ts catalog.ts
realtime/6comment-events.ts frames.test.ts frames.ts notification-policy.ts notification-prefs.ts task-0094.test.ts
automation.test.ts automation.ts constants.ts crypto.test.ts crypto.ts env.ts errors.ts events.test.ts events.ts forms.test.ts forms.ts index.ts markdown.test.ts markdown.ts mentions.test.ts mentions.ts net.test.ts net.ts positions.test.ts positions.ts presence.test.ts presence.ts security-headers.ts webhook-delivery.test.ts webhook-delivery.ts webhook-events.ts
package.json README.md tsconfig.json vitest.config.ts
storage/16
src/12client.test.ts client.ts index.ts keys.test.ts keys.ts mime.test.ts mime.ts presign.test.ts presign.ts thumbnails.test.ts thumbnails.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
ui/37
src/33
components/19avatar.tsx badge.tsx button.tsx checkbox.tsx command.tsx context-menu.tsx dialog.tsx dropdown-menu.tsx empty-state.tsx index.ts popover.tsx separator.tsx skeleton.tsx slider.tsx spinner.tsx switch.tsx tabs.tsx toast.tsx tooltip.tsx
color.ts command-filter.test.ts command-filter.ts focus.test.ts focus.ts formatters.test.ts formatters.ts index.ts theme.test.ts theme.ts toast-store.test.ts toast-store.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
prisma/16
migrations/13
20260927000000_init/1migration.sql
20260928000000_search_documents_content_hash/1migration.sql
20260929000000_workspace_usage_storage_bytes/1migration.sql
20260930000000_form_submitted_notification_type/1migration.sql
20261001000000_ai_phase12/1migration.sql
20261002000000_webhooks_secret_encrypted_dlq/1migration.sql
20261002111436_webhook_delivery_latency/1migration.sql
20261002120000_org_admin_2fa_retention_unique/1migration.sql
20261002215124_audit_export/1migration.sql
20261003210000_perf_indexes_hot_paths/1migration.sql
20261004004500_perf_indexes_hot_paths_2/1migration.sql
migration_lock.toml README.md
schema.prisma seed.ts verify.ts
scripts/7backup-health.ts docs-completeness.mjs generate-openapi.mjs minio-init.ts opensearch-reindex.ts storage-orphans.ts verify-clean-clone.sh
tests/169
e2e/63
.auth/1user.json
fixtures/7
files/6clip.mp4 clip.wav clip.webm hero-1400.png note.docx note.txt
realtime.ts
mocks/2oidc-issuer.ts saml-idp.ts
playwright-report/3
data/2a6557bae9c2714a658968d4eb8534195c08ebda8.png e3092d07d00a16063e46385c02a559a9054fa847.webm
index.html
reports/1files-security.json
specs/34ai-database.spec.ts ai-generate.spec.ts ai-rag.spec.ts ai-security.spec.ts ai-writing.spec.ts ai.spec.ts api-keys.spec.ts appearance-theme.spec.ts auth.spec.ts automation.spec.ts collab-two-tab.spec.ts comments.spec.ts databases.spec.ts default-workspace-alias.spec.ts developers.spec.ts editor.spec.ts files.spec.ts final-flow.spec.ts forms.spec.ts oauth.spec.ts org-admin.spec.ts pages.spec.ts performance.spec.ts presence-cursors.spec.ts qa-paste-toggle.spec.ts realtime.spec.ts scim.spec.ts search-index-lag.spec.ts search.spec.ts sharing.spec.ts sso.spec.ts task-0094-comments-notifications.spec.ts webhooks.spec.ts workspace.spec.ts
test-results-final-flow/3
final-flow-Release-v1-0-fi-6c06b-w-state-carries-sequential--chromium-desktop/2test-failed-1.png trace.zip
.last-run.json
utils/4api.ts dnd.ts search-lag.ts wait.ts
.gitignore fixtures.ts global-setup.ts package.json playwright.config.ts playwright.final-flow.config.ts README.md tsconfig.json
fixtures/1
files/1make-sparse.ts
integration/60
ai-capability/2ai-app.capability.test.ts gateway.capability.test.ts
drill/2seed-drill.mjs verify-restore.mjs
fixtures/5
files/5clip.mp4 clip.wav doc.pdf hero-1400.png note.txt
helpers/3api.ts containers.ts seed.ts
realtime/11control-channel.test.ts convergence.test.ts flush-rebase-http.test.ts handshake.test.ts heartbeat-limits.test.ts lifecycle.test.ts presence.test.ts rebase.test.ts resume.test.ts rt-helpers.ts task-0094-fanout.test.ts
ai-chat.integration.test.ts ai-database.integration.test.ts ai-generate.integration.test.ts ai-rag.integration.test.ts ai-writing.integration.test.ts api-keys.integration.test.ts audit-completeness.integration.test.ts audit-export-s3.integration.test.ts auth.integration.test.ts automation-pipeline.integration.test.ts automations.integration.test.ts blocks.integration.test.ts cache-invalidation.integration.test.ts comments.integration.test.ts databases.integration.test.ts files-lifecycle.integration.test.ts files-pipeline.integration.test.ts files-security.integration.test.ts files.integration.test.ts forms-responses.integration.test.ts forms.integration.test.ts oauth.integration.test.ts openapi.contract.test.ts org-admin.integration.test.ts package.json pages.integration.test.ts permissions.integration.test.ts README.md replica-routing.integration.test.ts retention-engine.integration.test.ts scim.integration.test.ts search.integration.test.ts sso.integration.test.ts tsconfig.json vitest.config.ts webhooks.integration.test.ts workspace.integration.test.ts
performance/28
fixtures/1search-100k.ts
helpers/2seed-100k.mjs seed-1k-load.mjs
reports/7api-load-smoke.json database-load-smoke.json editor-local.json realtime-load.json search-full.json search-last.json search-reduced.json
api-load.js api.js archive.tar database-load.js database.js editor-local.mjs extract-100k-env.mjs package.json README.md realtime-1k.js realtime-load.js realtime.js run-realtime-load.sh search-search.k6.js search-summary.json seed-100k.mjs threshold-breach-demo.js verify-zero-op-loss.mjs
security/16
helpers/1app.ts
audit-report.test.ts auth-paths.test.ts deps.test.ts formula-fuzz.test.ts gitleaks-config.test.ts headers.test.ts images.test.ts oracle.ts package.json permission-fuzz.test.ts rate-limits.test.ts README.md tsconfig.json upload-abuse.test.ts vitest.config.ts
README.md
.dockerignore .env .env.bak-task0125 .env.example .eslintrc.json .gitignore .npmrc .prettierignore .prettierrc.json AGENTS.md ARCHITECTURE.md CHANGELOG.md CONTRIBUTING.md gitleaks.toml LICENSE package.json pnpm-lock.yaml pnpm-workspace.yaml PRODUCT-SPEC.md README.md ROADMAP.md SECURITY.md tsconfig.json turbo.json
phase-03-pages.mddocs/tasks/phase-03-pages.md← tasks88,457 B← Back to the app

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.denied audit 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

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

  1. apps/api/src/modules/page/{page.module.ts, page.controller.ts, page.service.ts, page.repository.ts}: - POST /api/v1/workspaces/:id/pages body { title?, icon?, coverUrl?, description?, teamId?, parentId? } → permission: effective EDIT on the target (workspace/team per TASK-0028; teamId/parentId must 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 → full PageDto (id, workspaceId, teamId, parentId, title, icon, coverUrl, description, isPrivate, trashedAt, archivedAt, createdAt, updatedAt, createdBy, updatedBy, childCount, isFavorite (current user's PageFavorite presence), version); permission: effective VIEW; trashed pages → 404 page_not_found (the trash list is the only entry point, TASK-0039); grandparentId maintained by the service (parent's parentId) on create/move. - PATCH /api/v1/workspaces/:id/pages/:pageId body { title?, icon?, coverUrl?, description?, isPrivate? } (move is separate, TASK-0036) → EDIT on the page; optimistic concurrency: body may carry expectedVersion; mismatch → 409 version_conflict { details: { currentVersion } } (version +1 on every write). - POST /api/v1/pages/:pageId/duplicate → EDIT on source and EDIT on 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_large beyond); no blocks yet (Phase 4 copies them), no shares/favorites/versions copied (fresh); audit page.duplicated { sourcePageId, newPageId }. - PATCH /api/v1/pages/:pageId/archive body { archived: true|false } → EDIT; archived pages hidden from the tree list (TASK-0036 filter) but not trashed; audit page.archived / page.unarchived.
  2. PageDto + zod DTOs in packages/shared/src/dto/page.ts (shared with web).
  3. Every mutation: log with pageId + workspaceId; audit events page.created, page.updated, page.deleted (soft → TASK-0039 reuses the delete path) via the TASK-0020/0032 service; names added to packages/shared/src/audit-events.ts.
  4. Sibling bound: creating under a parent with ≥ 5000 children → 409 too_many_siblings (the bound is shared with TASK-0036's move).

Acceptance Criteria

  1. Create root + nested (parent's child) pages: rows correct incl. grandparentId, createdBy, position unique per parent; GET returns the full PageDto with childCount correct.
  2. PATCH with matching expectedVersion → 200 + version incremented; mismatch → 409 with currentVersion in details (concurrency proven by two parallel integration calls).
  3. Duplicate of a 3-level subtree (5 pages) creates 5 new pages, order preserved, " (copy)" on the root only, no shared PageFavorite/Share/PageVersion rows (DB assertion); a > 5000-node subtree → 409.
  4. Archive hides the page from the root list (TASK-0036's filter — a stub includeArchived: false flag on the repository method is added now so TASK-0036 only adds pagination); unarchive restores visibility.
  5. A GUEST (effective VIEW) can GET but PATCH → 403 forbidden (TASK-0028 guard wired for page routes; the permission.denied audit row is TASK-0041's guard extension — assert the 403 now, the audit row in TASK-0041's tests).

Backend Changes

Frontend Changes

Database Changes

API Changes

Tests

Security

Documentation

Dependencies

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

  1. GET /api/v1/workspaces/:id/pages query { 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 }: - no parentId → workspace roots (parentless pages, teamId filter optional ?teamId=); with parentId → that page's children (404 if the parent is trashed/unknown); - order by position via comparePositions (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 + (unless includeArchived) archived excluded.
  2. POST /api/v1/pages/:pageId/move body { newParentId?, beforeId?, afterId? } (EDIT on source + destination): - cycle check: if newParentId is the page itself or a descendant of the page → 409 cycle_detected (descendant check = BFS/DFS over children from newParentId up 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); - update parentId + grandparentId of 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 to teamId change: body may include teamId (destination team, same rules as create).
  3. 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); childCount via one GROUP BY per result batch (not N+1 — a IN (ids) count query).
  4. A maintenance guard: packages/shared/positions re-normalization hook — if positionAfter would exceed 256 chars (TASK-0027 criterion 5 bound), the service logs warn position_length_high (Phase 15 re-normalizes; the hook exists and is unit-tested to fire).

Acceptance Criteria

  1. 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 matches comparePositions exactly (snapshot assertion).
  2. Expand a parent: ?parentId=X returns exactly X's children in order, each with correct childCount/hasChildren (DB cross-check on a 3-level fixture).
  3. Move: page A (2 children) moved under page B → A's parentId/grandparentId correct, A's children untouched (same positions); move A before a specific sibling → position between its neighbors (comparator-asserted).
  4. 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.
  5. 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 in afterAll.

Backend Changes

Frontend Changes

Database Changes

None beyond TASK-0035's indexes (the EXPLAIN test pins them).

API Changes

Tests

Security

Documentation

Dependencies

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

  1. apps/web/src/components/sidebar/page-tree.tsx + page-node.tsx: - team rows now render their page subtree (lazy: children fetched on first expand via usePageChildren(teamId-as-parent? no — team pages are ROOT pages with teamId, not parentId=team— the tree API's?teamId=root filter from TASK-0036; the expand of a page uses?parentId=); - node row: chevron (aria-expanded, only when hasChildren), icon (emoji inline / uploaded image 16 px / none), title (single line, ellipsis, title attr full), depth indent; expand state per-node, persisted namespaced factory.<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).
  2. Private section (testid nav-private now 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.
  3. Drag-sort stub: each node shows a drag-handle glyph on hover (testid page-drag-<id>) with aria-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).
  4. 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).
  5. 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

  1. 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).
  2. Expand/collapse persists across reload (namespaced key); switching workspace resets the expansion set (per-workspace namespacing, TASK-0031).
  3. 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.
  4. 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).
  5. 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

Database Changes

None.

API Changes

None (consumes TASK-0035/0036).

Tests

Security

Documentation

Dependencies

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

  1. POST /api/v1/pages/:id/icon (multipart, page EDIT): same content rules as workspace icons (png/jpeg/webp/avif, ≤ 2 MB, magic-byte sniff); stored pages/<pageId>/<uuid>.<ext>; PATCH sets icon to the storage key (an emoji icon overwrites a file icon and vice versa — one icon field, the web distinguishes by value shape: pure emoji = emoji, otherwise storage key — documented in docs/api/pages.md); response { icon }.
  2. 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).
  3. Cover (apps/web/src/components/page/cover-editor.tsx): header cover slot — "Add cover" on hover when empty → panel: 6 gradient presets (const array in packages/shared/src/covers.ts: linear-gradient CSS strings stored as coverUrl values prefixed gradient: — the web renders the prefix as a CSS background, uploads as <img>; documented), "Upload image" (same upload rules, ≤ 4 MB for covers, bucket factory-files key covers/<pageId>/… via POST /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).
  4. 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 via usePatchPage with expectedVersion (conflict → toast "Page changed — reload" + refetch).
  5. 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.
  6. 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.effectiveLevel is added here: the GET response includes the caller's effective PermissionLevel (from the engine — the UI's single source for control gating; the server still enforces — AGENTS.md)).

Acceptance Criteria

  1. 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).
  2. Cover: each of the 6 gradients renders + persists (coverUrl = gradient:<id>); upload → image cover; remove → null; sidebar row unaffected (header-only).
  3. 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).
  4. 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).
  5. As a GUEST: no picker/cover controls render (testid absence), title contentEditable=false; PageDto.effectiveLevel = VIEW in the GET response.

Backend Changes

Frontend Changes

Database Changes

None.

API Changes

Tests

Security

Documentation

Dependencies

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

  1. API (EDIT on the page for delete/restore; purge OWNER/ADMIN via the guard's manage tier — the permission mapping gets page.trash/page.purge actions): - DELETE /api/v1/pages/:id → subtree trash (iterative BFS from the page, trashedAt = now on the page + all descendants, restoredAt = null); position is preserved (restore can be exact); 200 { trashed: n } (n = nodes including the root); audit page.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 with trashedAt != null whose parent is NOT trashed (top of each trashed subtree) + each item carries nodeCount (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 = now on 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 }; audit page.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 }; audit page.purged { pageId } (irreversible — the event is the only trail).
  2. Worker: apps/worker/src/jobs/trash-purge.processor.ts (queue maintenance, cron 0 4 * * *): purge all pages with trashedAt < 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).
  3. UI (completing the Phase 2 nav-trash stub): 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 if nodeCount > 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).
  4. Sidebar: nav-trash links 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

  1. Round trip (integration): create root R + child C → DELETE R → trashed: 2, both rows trashedAt set, R gone from the tree endpoint; trash list shows R with nodeCount: 1; POST restore R → both live, positions unchanged (tree order identical before/after — snapshot assertion).
  2. Nested restore: trashed parent + trashed child → restoring the child first → 409 parent_in_trash; restoring the parent → restored: 2 (both live).
  3. Purge: OWNER purges a trashed page → row + PageFavorite/PageVersion/Share rows gone (DB assertion); MEMBER purge → 403; purging a live page → 404 (must be in trash first — the endpoint requires trashedAt != null, documented).
  4. The worker job purges a 31-day-old trashed page and keeps a 29-day-old one (job test, injected clock + real DB rows).
  5. 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

Frontend Changes

Database Changes

None (existing columns; the TASK-0035 trashedAt index serves the job's scan).

API Changes

Tests

Security

Documentation

Dependencies

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

  1. API (the page's VIEW is the precondition — you can favorite what you can see): - POST /api/v1/pages/:id/favorite → upsert with position = positionAfter(last favorite's position) (append); idempotent (existing → 200 no-op); 201 { favorite: { position } } (200 on no-op); audit page.favorited. - DELETE /api/v1/pages/:id/favorite → 200 { removed: true } (idempotent no-op if absent — documented); audit page.unfavorited. - GET /api/v1/workspaces/:id/favorites → { items: [PageDto (the TASK-0035 shape, isFavorite: true always)], nextCursor } ordered by position, 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 an inTrash: true flag instead of disappearing — the UI shows them dimmed with a "in trash" chip, restore happens in the trash view — recorded decision).
  2. Sidebar: nav-favorites (Phase 2 stub) becomes the data-backed list (useFavorites(workspaceId), the star row component favorite-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).
  3. Context menu + header: the star toggle in the page context menu (TASK-0045 — this task ships the useFavoriteToggle hook + 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 same PageDto.isFavorite).
  4. Migration (if the unique index is missing): <ts>_favorite_unique; prisma/verify.ts unaffected (the seed favorite still validates).

Acceptance Criteria

  1. Favorite a page → sidebar section shows it (order: append, reload-stable); unfavorite → gone; favorite twice → idempotent (1 row, no error).
  2. 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 favorites with the skip behavior (absent from items — or inTrash if trashed; the two cases asserted separately).
  3. 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).
  4. Per-user positions: two users favoriting the same 3 pages in different orders → each GET favorites returns their own order (two-context integration test).
  5. The migration (if any) applies clean; the seed favorite row still passes pnpm db:verify.

Backend Changes

Frontend Changes

Database Changes

API Changes

Tests

Security

Documentation

Dependencies

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

  1. packages/permissions/src/engine-v2.ts (v2 entry; v1 file unchanged, v2 re-exports v1 for workspace/teamspace — one public checkPermission that dispatches on resourceType): - Page input adds: pageChain: { pageId, ancestorIds: string[] }, grants now includes page-grant rows for the page + every ancestor (preloaded by the guard's repository: one query permissions 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 where resourceId ∈ 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 a NONE on 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; a NONE at 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).
  2. 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 for resourceType: "page" (the action list includes view|comment|edit|manage_permissions — manage_members/manage_teams/ delete_workspace are workspace actions only, denied on page resources — the table pins it).
  3. Grants API (management requires effective FULL_ACCESS on the page — the guard maps manage_permissions): - GET /api/v1/pages/:id/permissions → the page's own grants + the inherited ones (each with source: "this_page" | "ancestor:<pageId>" | "workspace" | "team" — the UI renders the inheritance explicitly). - PATCH /api/v1/pages/:id/permissions body { granteeType, granteeId, level } → upsert the page-level grant (granteeType user|team — page grants don't support role in Phase 3; 400 role_grants_page_in_phase14 — recorded); 200 { grant }; audit permission.grant_changed (the TASK-0032 name, resourceType page). - DELETE /api/v1/pages/:id/permissions/:grantId → only the page's OWN grants (ancestor grants → 400 not_this_page); audit permission.grant_removed.
  4. 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).
  5. The effectiveLevel field (TASK-0038) now comes from v2 (the one-line swap).

Acceptance Criteria

  1. pnpm test: v2 matrix green (≥ 25 new cases + v1's 40 still green); packages/permissions coverage ≥ 80%.
  2. Subtree inheritance (integration): grant EDIT to user X on root page R → X gets EDIT on R, child C, grandchild G (asserted via effectiveLevel in each GET); add a NONE grant 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.
  3. 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).
  4. Grants API: PATCH upsert + GET shows source labels; DELETE of an ancestor grant via the child's endpoint → 400 not_this_page; a MEMBER (effective EDIT) cannot manage grants → 403.
  5. permission.denied audit: a GUEST denied edit on 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

Frontend Changes

Database Changes

None (the TASK-0028 grants index covers the chain query; the EXPLAIN assertion in the integration test pins it).

API Changes

Tests

Security

Documentation

Dependencies

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

  1. POST /api/v1/pages/:id/share (effective EDIT on 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 } }; scope private (or DELETE /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).
  2. 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 — Authorization present + the caller in the team/user list + effective VIEW → 200 with the public DTO; documented dual-mode); - expired (expiresAt past) → 410 share_expired; - password set + missing/wrong → 403 share_password_required (identical response for missing and wrong — no oracle; timing: sha256 + crypto.timingSafeEqual against the stored hash); - 200 → { page: { id, title, icon, coverUrl, description }, workspace: { name, iconUrl }, options: { allowComments, allowDuplicate, allowSearchIndexing }, content: [] /* Phase 4: block tree */ }.
  3. Rate limit the public endpoint: 60/min per IP (shared throttle store, TASK-0019 convention) — password-guessing is bounded; documented.
  4. 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

  1. As OWNER: set scope PUBLIC (no password) → publicUrl returned; 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.token is the clear token per the schema — security fix (recorded): the schema stores token clear 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 except publicUrl — the DB-scan test in TASK-0032 style asserts no token in audit metadata).
  2. Password: set a 10-char password → public read without it → 403 share_password_required; wrong → same 403 (body identical — asserted); right → 200.
  3. Expiry: set expiresAt = now+2min → 200 now; advance the test clock past it → 410 share_expired.
  4. TEAM scope: a member of the team reads the page via the normal GET (engine rule) AND via GET /shares/:token/public with auth (dual-mode); a non-member of the team → 403 on the token endpoint (auth present) / normal 403 on the page GET.
  5. Re-save to PUBLIC re-rolls the token (old URL 404s); DELETE /share → token 404 + audit share.revoked; the 60/min limit: 61st request in a minute → 429.

Backend Changes

Frontend Changes

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

Tests

Security

Documentation

Dependencies

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

  1. 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, insert PageVersion (snapshot = current page fields + blocks: [] + schemaVersion: 1); returns the version row. - Manual: POST /api/v1/pages/:id/versions body { note? (≤ 200) } → EDIT on the page; 201 { version: { version, note, createdBy, createdAt } }; audit page.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 counter page:ops:<pageId> (INCRBY + EXPIRE 3600 — a rolling 1 h window); when the counter crosses a multiple of 100 (AUTO_SNAPSHOT_EVERY_OPS = 100, const in packages/shared/src/constants.ts) → createSnapshot(trigger: "auto_block_ops", actorId: "system"); the crossing check is INCRBY return-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 to reportBlockOperations (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).
  2. GET /api/v1/pages/:id/versions → VIEW on 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).
  3. GET /api/v1/pages/:id/versions/:version → VIEW; { version, snapshot (full Json), note, createdBy, createdAt, trigger }; unknown version → 404.
  4. 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's page fields to the page (blocks restored in Phase 4), updatedBy = caller, version (page's optimistic-concurrency counter) bumped; 200 { version: <new version number>, restoredFrom: <target> }; audit page.version_restored { pageId, fromVersion, newVersion }.
  5. 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 chip manual/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) — all EDIT-gated via effectiveLevel (VIEW users see the list read-only, no buttons — the components don't render).

Acceptance Criteria

  1. Manual snapshot: POST versions → 201 version 2 (after the implicit v1? — the page starts with version 1 in Page.version but no PageVersion row; the first snapshot IS version 1? Decision (recorded): PageVersion.version aligns with Page.version at 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.)
  2. Auto: reportBlockOperations(pageId, 99) → no snapshot; +1 → exactly one PageVersion with trigger: "auto_block_ops"; the next 100 → the next (counter window asserted with a real redis in Testcontainers + a fake in unit).
  3. 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).
  4. List pagination: 120 versions (generated) → 3 pages of 50/50/20, nextCursor chain works; the list body contains no snapshot payloads (size assertion).
  5. 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

Frontend Changes

Database Changes

API Changes

Tests

Security

Documentation

Dependencies

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

  1. apps/web/src/app/(app)/[workspace]/[pageId]/page.tsx + page-shell.tsx: - Resolution: the WorkspaceGate (TASK-0031) wraps the route; inside, usePage(pageId) (TASK-0035 hook) with states: loading → full-page skeleton (breadcrumbs placeholder + header skeleton + body skeleton, testid page-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 (testid page-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 (testid page-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's parentId chain fetched lazily (a usePageAncestors query: walk parentId one hop at a time with a cache, cap 20 hops — the depth bound, … overflow collapses the middle with a "…" dropdown listing the hidden ancestors, testid breadcrumb-…); 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).
  2. Active-page sync: the URL's pageId drives 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).
  3. Keyboard: / focuses… (Notion's / is the slash menu — Phase 4; in Phase 3 Ctrl/Cmd+K is 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).
  4. 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).
  5. 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

  1. /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).
  2. Unknown pageId → page-not-found card (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-forbidden card — the two cards are distinct and both E2E-asserted.
  3. 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).
  4. 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.
  5. 375 px + dark theme: no horizontal scroll, truncation active, theme tokens correct (computed styles); the page-body-empty card 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

Database Changes

None.

API Changes

Tests

Security

Documentation

Dependencies

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

  1. 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's effectiveLevel from 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 same page-title component; 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); a cycle_detected 409 → 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 when childCount > 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.
  2. A11y: every item role="menuitem", aria-disabled for 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, testid page-menu-kebab-<id>).
  3. 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

  1. 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).
  2. 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.
  3. 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.
  4. 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).
  5. 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).
  6. 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

Database Changes

None.

API Changes

None.

Tests

Security

Documentation

Dependencies

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

  1. 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, parentId chain, grandparentId on 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: parentId null, grandparentId null. - 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: trashedAt null, restoredAt set, audit rows page.trashed (nodeCount 3) + page.restored present. - Flow 5 (favorite): menu on "Child" → Favorite → the sidebar Favorites section shows "Child" (star row) → open the page → header star aria-pressed true → 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 API PATCH /pages/<rootId> (title change, via helper) → 403 forbidden → DB: audit_logs has a permission.denied row (action edit, resourceType page, 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 + manual chip → 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 new PageVersion rows (manual + the restore's pre-restore snapshot) + page.version_restored audit. - Flow 8 (shell states): unknown pageId → page-not-found card (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 a NONE page-grant on C (via the Permissions dialog as A) → C opens it → page-forbidden card + another permission.denied audit 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 in tests/integration/page-tree-perf.test.ts (both referenced from the PR's exit mapping).
  2. 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).
  3. Audit row assertions throughout use helpers/db.ts with the exact action strings from packages/shared/src/audit-events.ts (imported in the spec — no string drift).
  4. The spec passes twice consecutively (unique timestamps; the fixture SQL setup is idempotent per run).

Acceptance Criteria

  1. pnpm test:e2e (pages.spec.ts) green on the dev stack, clean machine (run recorded in the PR); all 9 flows complete in order.
  2. 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.
  3. Flow 6 + Flow 8: the 403 + permission.denied audit row (API denial) and the page-forbidden card + audit row (UI denial) both proven — the "unauthorized access rejected with audit event" exit criterion.
  4. Flow 1–3: the 3-level tree, rename propagation (4 surfaces), and the move (root) all correct in UI and DB.
  5. Flow 5 + 7: the favorite round trip and the snapshot-visible + restore round trip pass (2 PageVersion rows + audit).
  6. 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

Database Changes

None (fixture writes are test data in the E2E DB, reset per CI run).

API Changes

None.

Tests

Security

Documentation

Dependencies

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.