.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-02-workspace.mddocs/tasks/phase-02-workspace.md← tasks78,461 B← Back to the app

Phase 2 — Workspace: Task Specifications

Purpose: Build the multi-tenant workspace surface: workspace CRUD + icon upload, personal-workspace auto-creation on registration, members + roles with owner-transfer rules, email invitations, teams/teamspaces, the packages/permissions v1 engine with NestJS guard wiring, the sidebar, workspace settings UI, and the multi-user E2E proof. Tasks run strictly in order, TASK-0023 → TASK-0034. Phase exit criteria (verbatim from ROADMAP.md): multi-user workspace flow E2E-tested; permissions enforced server-side; sidebar renders tree from API.

Task Table

ID Title Est.
TASK-0023 Workspace CRUD (create/rename/soft-delete + icon upload) L
TASK-0024 Personal workspace auto-created on registration M
TASK-0025 Members + roles (Owner/Admin/Member/Guest, transfer) L
TASK-0026 Invitations (email → token link 7d → accept/decline/revoke) M
TASK-0027 Teams / teamspaces (CRUD + membership + sidebar positions) M
TASK-0028 packages/permissions v1 (role base + grants + guard) L
TASK-0029 Sidebar UI (sections, switcher, collapse, testids) L
TASK-0030 Workspace settings UI (Profile, Members, Teams, Security) M
TASK-0031 Workspace switcher + URL [workspace] slug resolution M
TASK-0032 Workspace audit events M
TASK-0033 E2E multi-user workspace flow (tests/e2e/workspace.spec.ts) L
TASK-0034 Integration tests (tenant isolation) M

Phase Completion Checklist

TASK-0023

Title

Workspace CRUD (create/rename/soft-delete + icon upload)

Objective

POST/PATCH/DELETE /api/v1/workspaces with slug uniqueness + validation, icon upload to object storage (MinIO), settings JSON, and soft delete (deletedAt) — the first real multi-tenant resource in the api.

Background

Phase 1 delivered auth + guard + audit infra. The Workspace model exists (slug unique, iconUrl, logoUrl, description, settings Json, isPersonal, ownerId, billingPlan, usageLimits, deletedAt nullable). Tenant scoping rule for this phase: every workspace query filters by deletedAt: null and membership (the guard from TASK-0028 enforces membership; this task establishes the query convention in the repository layer). Storage: MinIO via the @factory/storage package (S3 SDK + presigned URLs; the buckets factory-files/factory-avatars were created by the TASK-0004 compose init).

Requirements

  1. apps/api/src/modules/workspace/workspace.{module, controller,service}.repo.ts: - POST /api/v1/workspaces body { name, slug, description?, icon? } → creator becomes owner (WorkspaceMember role OWNER + Workspace.ownerId); slug: 2–48 chars [a-z0-9-], must start with a letter, reserved list rejected (admin, api, app, assets, billing, www, new, login — const in packages/shared/src/slug.ts, shared with web); duplicate → 409 slug_taken; response 201 { workspace: { id, name, slug, iconUrl, description, ownerId, createdAt } }. - PATCH /api/v1/workspaces/:id — rename (name), description, settings (JSON merge at top level only — no deep merge, no keys other than the documented set: defaultPublicShareScope, allowGuestComments — schema in packages/shared/src/dto/workspace.ts), icon (upload endpoint below). Role: OWNER/ADMIN only (via TASK-0028 guard when it lands; until then the service checks WorkspaceMember.role directly — the switch is recorded in the commit). - DELETE /api/v1/workspaces/:id — OWNER only; soft delete: deletedAt = now (cascade-free: memberships/teams/pages remain, hidden by the query convention); 200 { deleted: true }; re-deleting → 404 workspace_not_found.
  2. Icon upload: POST /api/v1/workspaces/:id/icon (multipart, OWNER/ADMIN): png/jpeg/webp/avif, ≤ 2 MB, dimension ≥ 64 px (checked after download from the request stream) → stored to factory-avatars bucket key workspaces/<wsId>/<uuid>.<ext> via @factory/storage putObject; response { iconUrl } (public URL via S3_PUBLIC_BASE_URL, or presigned when unset); sets Workspace.iconUrl + emits workspace.icon_changed audit (TASK-0032 name). Wrong type/oversize → 400 { code: "icon_invalid", details: { reason } }.
  3. List: GET /api/v1/workspaces → the caller's non-personal + personal workspaces ({ id, name, slug, iconUrl, isPersonal, role }), newest first, max 100; GET /api/v1/workspaces/:id (by id or slug — one param, documented) → full workspace + caller's role.
  4. Every mutation: pino log with workspaceId + requestId; zod DTOs in packages/shared/src/dto/workspace.ts (shared with the web forms).

Acceptance Criteria

  1. Create with valid body → 201, owner rows (Workspace.ownerId + WorkspaceMember OWNER) exist; duplicate slug (case-insensitive) → 409 slug_taken; reserved slug → 400; bad slug charset → 400 with field error.
  2. Rename + settings merge work for OWNER and ADMIN; MEMBER → 403 (service check, later guard); settings merge replaces only the top-level keys present in the patch and keeps the rest (documented semantics; test: patch {a:9} over {a:1,b:2} → {a:9,b:2}, and a patch that omits b never deletes it).
  3. Soft delete by OWNER: deletedAt set; GET /api/v1/workspaces no longer lists it; GET /api/v1/workspaces/<slug> → 404; the row still exists in the DB with memberships intact (asserted); a second delete → 404.
  4. Icon upload: a 1 MB png is stored in MinIO (object exists, correct key), iconUrl set and reachable (HTTP 200 from the compose minio); a 5 MB file → 400 icon_invalid too_large; a gif → 400 wrong_type.
  5. A user with no membership in the workspace gets 403/404 on every endpoint (cross-tenant isolation, full proof in TASK-0034).

Backend Changes

Frontend Changes

Database Changes

None (existing workspaces, workspace_members tables).

API Changes

Tests

Security

Documentation

Dependencies

Definition of Done

6 endpoints + icon upload live; unit + integration tests (incl. real MinIO) green; API docs + tenancy convention written.

TASK-0024

Title

Personal workspace auto-created on registration

Objective

Every new user automatically gets a personal workspace (isPersonal: true) the moment they register — so / always has a workspace to render and Phase 3 pages have a home before the user joins/creates anything else.

Background

TASK-0013 deliberately left a documented hook (onUserRegistered(user)) in the auth service. This task implements it: one transaction, no email, no invitation — the user is OWNER of their personal workspace. Personal workspaces are hidden from the "switch to another workspace" flow but shown in the switcher with a lock glyph (TASK-0031) and can never be transferred/invited (enforced by TASK-0025/0026 rules).

Requirements

  1. apps/api/src/modules/workspace/personal-workspace.service.ts: ensurePersonalWorkspace(userId) — transaction: create Workspace (isPersonal: true, name = "My workspace", slug = personal-<cuid 8 chars lowercased> — unique by construction, retried once on the (unlikely) clash, never user-visible in URLs — the switcher shows the name, not the slug), ownerId = userId, settings: { defaultPublicShareScope: "private" } (personal default: nothing shared by default — documented decision), plus WorkspaceMember OWNER row. Idempotent: if the user already has one (any state), return it, create nothing.
  2. Wiring: TASK-0013's hook calls this after the user insert commits (not inside the same transaction — a personal-workspace failure must not roll back registration; on failure: logger.error with userId + a compensating retry via the BullMQ queue workspace.provision (new queue in apps/worker/src/jobs/ workspace-provision.processor.ts, 8 retries, exponential backoff, dead-letter log). The worker job runs the same ensurePersonalWorkspace (idempotent).
  3. Registration response unchanged (user object only — the workspace is not revealed by the register endpoint; the web / landing queries GET /api/v1/workspaces after login, which now always includes the personal one).
  4. Seeded users: prisma/seed.ts gains the personal workspaces for demo + admin via the same service logic (direct call in the seed — keep seed deterministic, no queue) — this replaces the ad-hoc personal-workspace creation of TASK-0006; prisma/verify.ts keeps asserting "exactly 2 personal workspaces" (now produced by the shared logic).
  5. GET /api/v1/workspaces marks personal workspaces (isPersonal: true) — the switcher (TASK-0031) renders them in a separate group.

Acceptance Criteria

  1. Registering a new user (integration test) results in exactly one personal workspace for them (owner rows correct) within 5 s (queue latency budget) and the GET /api/v1/workspaces includes it with isPersonal: true.
  2. ensurePersonalWorkspace is idempotent: calling it 3× for the same user creates exactly 1 workspace (unit + integration test).
  3. Failing the direct call (fault injection: make the insert throw) does NOT fail the registration (201 returned) and the queued job retries to success (worker test: poison the first 2 attempts, job succeeds on the 3rd — BullMQ attempt-count assertion).
  4. Seeded demo/admin users have exactly one personal workspace each produced by the shared service (seed + pnpm db:verify green after the change).
  5. Personal workspace slugs are never two identical across users (uniqueness test with 100 generated slugs — collision-retry logic covered).

Backend Changes

Frontend Changes

Database Changes

None (no schema change; seed/verify updated per criterion 4).

API Changes

None (existing endpoints now always return ≥ 1 workspace per user).

Tests

Security

Documentation

Dependencies

Definition of Done

Auto-provisioning + worker queue live; all 5 criteria proven; seed/verify updated and green; docs written.

TASK-0025

Title

Members + roles (Owner/Admin/Member/Guest, owner transfer)

Objective

Full member management with the 4-role model and its invariants: exactly one OWNER per workspace, owner transfer flow, no self-demote/remove of the last owner, role change auditing — endpoints + the rules engine in one place.

Background

WorkspaceMember (role: WorkspaceRole, invitedBy, joinedAt, unique [workspaceId, userId]) and Workspace.ownerId exist. The rules below are the canonical Phase 2 permission matrix (the packages/permissions engine, TASK-0028, will encode them; this task implements them in apps/api/src/modules/workspace/roles.service.ts so the engine has a reference behavior to match in tests).

Requirements

  1. Endpoints (all scoped to the workspace, caller must be a member): - GET /api/v1/workspaces/:id/members → { id, userId, email, name, role, joinedAt, invitedBy } (join users; max 500, cursor pagination ?cursor=&limit=). - PATCH /api/v1/workspaces/:id/members/:userId body { role } → change role; rules below; 200 { member }. - DELETE /api/v1/workspaces/:id/members/:userId → remove; same rules; 200 { removed: true }. - POST /api/v1/workspaces/:id/transfer-ownership body { newOwnerId } → the target must be a current MEMBER+ (any role except the target's current OWNER), transaction: Workspace.ownerId = newOwnerId + target's WorkspaceMember.role = OWNER + old owner's role → ADMIN (documented: the old owner becomes ADMIN, never drops to nothing — prevents accidental lockout) + audit.
  2. Role rules (enforced in roles.service.ts, unit-tested exhaustively): - GUEST cannot be promoted directly to OWNER (must pass through MEMBER/ADMIN — documented policy; 400 { code: "invalid_role_change", details: { from, to } }). - Exactly one OWNER: demoting/removing the current OWNER → 409 owner_transfer_required (point at transfer-ownership). - Only OWNER/ADMIN manage roles; ADMIN cannot promote to OWNER, cannot demote an OWNER, cannot remove an OWNER (403); OWNER can do everything. - A member cannot change/remove their own role — except OWNER self-demote is forbidden outright (409 owner_transfer_required) and MEMBER+ self-remove is allowed (leaving a workspace is a user right, no audit-exception). - You cannot add a role to a non-member via these endpoints (that is invitations, TASK-0026).
  3. Every role change / removal / transfer writes the audit event (TASK-0032 names: member.role_changed metadata { from, to, targetUserId }, member.removed { targetUserId }, workspace.owner_transferred { fromUserId, toUserId }).
  4. Removing a member also removes their TeamMember rows in that workspace (cascade service call — teams land with full CRUD in TASK-0027; this task wires the cleanup against the existing model).

Acceptance Criteria

  1. Role matrix test (integration): OWNER→all 3 roles OK; ADMIN→MEMBER/GUEST OK, ADMIN→OWNER 403, ADMIN touching an OWNER 403; MEMBER/GUEST any management → 403.
  2. GUEST→OWNER direct → 400 invalid_role_change; GUEST→MEMBER→ADMIN→OWNER chain works step by step.
  3. Demoting/removing the only OWNER → 409 owner_transfer_required (both endpoints); transfer-ownership then works: new owner is OWNER, old owner is ADMIN, Workspace.ownerId updated, audit row present.
  4. Member self-remove works and cascades TeamMember rows (asserted); self role change by a non-OWNER → 403.
  5. All audit rows land with correct from/to metadata (DB assertion per action).

Backend Changes

Frontend Changes

Database Changes

None.

API Changes

Recorded conflict (members wire shape — AGENTS.md rule 29, 2026-10)

The API (and this task's own spec above) emit the member row flat: { userId, email, name, displayName, avatarUrl, role, joinedAt }. Two other sources disagree with it: docs/api/workspaces.md §3.1 documents a nested { user: { id, name, email, image } } shape, and the web's WorkspaceMember type (with 7 consumers reading m.user.name / m.user.email) expects nested. The web consumed the flat rows as-is and the Settings + Members pages crashed under the Next.js dev error overlay ("cannot read properties of undefined (reading 'name')"). Resolution (this pass, web-only): the web normalizes flat → nested at the single seam workspaceApi.members() via normalizeWorkspaceMember() (apps/web/src/lib/api.ts, unit-tested in src/lib/__tests__/api-members.test.ts); the API serializer is untouched because the e2e WorkspaceMemberDto (tests/e2e/utils/api.ts), workspace.spec.ts and the API integration tests pin the flat shape. Aligning docs/api/workspaces.md §3.1 with the flat wire (or changing the API to nested + updating all three test suites) is a separate, deliberate change.

Tests

Security

Documentation

Dependencies

Definition of Done

4 endpoints + rule matrix live; matrix + integration tests prove all 5 criteria; shared role constants shipped; docs written.

TASK-0026

Title

Invitations (email → token link 7d → accept/decline/revoke)

Objective

Email invitations with a 7-day single-purpose token link: invite an email at a role, the recipient (registered or not) accepts by following the link, which creates the membership; pending invitations can be revoked; expired/declined states are surfaced in the Members tab.

Background

Invitation model + InvitationStatus (PENDING/ACCEPTED/DECLINED/REVOKED/EXPIRED) exist, with @@unique([workspaceId, email, role]) — the same email cannot be invited twice at the same role concurrently (a re-invite at a new role is a new row; re-inviting the same role revokes the old PENDING row first — documented). The MailService (TASK-0013) sends; dev mode logs the link.

Requirements

  1. Endpoints: - POST /api/v1/workspaces/:id/invitations (OWNER/ADMIN) body { email, role } (role ∈ MEMBER|GUEST|ADMIN — never OWNER via invite; owner is only via transfer — 400 role_not_invitable otherwise) → dedupe: member already → 409 already_member; existing PENDING for same email+role → refresh its expiresAt (202 + resend: true); otherwise create PENDING row (token = 32 random bytes hex, tokenHash stored, expiresAt = now + 7d) + email with link ${FRONTEND_URL}/invite/<token>; 201 { invitation }. - GET /api/v1/invite/:token (@Public) → token lookup by hash: unknown → 404 invite_not_found; PENDING+unexpired → { workspace: { id, name, slug, iconUrl }, email, role, invitedBy: { id, name } }; REVOKED → 404; EXPIRED/expired-now → 410 invite_expired (lazy status flip to EXPIRED in the same read — one place); ACCEPTED → 200 { status: "accepted" } (the UI tells them to log in and open the workspace). - POST /api/v1/invite/:token/accept (Bearer auth required — they must be logged in; if the logged-in user's email ≠ invitation email → 403 email_mismatch) → transaction: create WorkspaceMember (role, invitedBy, joinedAt = now), invitation → ACCEPTED + acceptedAt; idempotent: if already a member, 200 { alreadyMember: true }. - POST /api/v1/invite/:token/decline (Bearer, same email-match rule) → invitation → DECLINED; 200 { declined: true }. - DELETE /api/v1/workspaces/:id/invitations/:invitationId (OWNER/ADMIN) → PENDING only → REVOKED + revokedAt; 200 { revoked: true }; non-PENDING → 404. - GET /api/v1/workspaces/:id/invitations (OWNER/ADMIN) → PENDING + recently (ACCEPTED/DECLINED/REVOKED within 30 d) with { id, email, role, status, invitedBy, createdAt, expiresAt }, newest first, max 100.
  2. Expiry: a daily worker job (apps/worker/src/jobs/invite-expiry.processor.ts, queue maintenance, cron 0 3 * * *) flips PENDING→EXPIRED past expiresAt (batch 1000); the lazy flip in GET /invite/:token covers the sub-day gap.
  3. Email content: workspace name, inviter name, role, link, "expires in 7 days"; dev mode logs the link (same mechanism as TASK-0013).
  4. Rate limits: invite endpoint 20/hour per workspace per inviter (in-memory throttle, documented); accept endpoint not limited (single-purpose token).

Acceptance Criteria

  1. Invite flow (integration): invite B at MEMBER → B's email has the link (dev log) → B (logged in) opens GET /invite/:token (sees ws name + role) → accepts → WorkspaceMember row exists (role MEMBER, joinedAt set, invitedBy correct) + invitation ACCEPTED.
  2. Unknown token → 404; expired (set expiresAt past in test) → 410 + row flipped to EXPIRED; revoked → 404 (no state leak between the three).
  3. A different logged-in user (email mismatch) accepting → 403 email_mismatch; inviting OWNER → 400 role_not_invitable; inviting an existing member → 409 already_member.
  4. Re-inviting the same email+role refreshes the expiry (single PENDING row, new expiresAt); the same email at a different role creates a second row (the unique-constraint semantics hold).
  5. The worker job flips expired rows (job test: seed 3 expired PENDING rows, run the processor, assert 3 EXPIRED + log line).

Backend Changes

Frontend Changes

Database Changes

None (existing invitations table).

API Changes

Tests

Security

Documentation

Dependencies

Definition of Done

6 endpoints + invite page + expiry job live; all 5 criteria proven; docs written.

TASK-0027

Title

Teams / teamspaces (CRUD + membership + sidebar positions)

Objective

Teams ("teamspaces") as first-class workspace sections: CRUD with unique slugs, team membership with a team-level role, fractional sidebar ordering — the container that Phase 3 pages hang off of (Page.teamId).

Background

Team model exists (workspaceId, name, slug, description, color, iconUrl, position fractional default a0, isPrivate, unique implied by workspace+slug — verify the unique constraint: if @@unique([workspaceId, slug]) is missing from schema.prisma, this task adds it in a migration — first schema change of Phase 2 after TASK-0020's audit index). TeamMember (teamId, workspaceMemberId, userId, role default MEMBER) exists.

Requirements

  1. Endpoints (workspace-scoped, caller a member): - POST /api/v1/workspaces/:id/teams body { name, slug, description?, color? } (OWNER/ADMIN) → 201 { team }; slug 2–48 [a-z0-9-] unique per workspace (409 slug_taken); position = @factory/shared/positions positionAfter(lastTeam.position) (append to the end of the sidebar). - GET /api/v1/workspaces/:id/teams → { id, name, slug, description, color, iconUrl, position, memberCount, isMember }, ordered by position; any member may list. - PATCH /api/v1/workspaces/:id/teams/:teamId (OWNER/ADMIN or team role ADMIN — team role semantics: team-level ADMIN manages the team, not the workspace — documented) → rename/description/color/icon (icon = emoji string ≤ 8 codepoints or upload via the same icon service as workspaces — bucket key teams/<teamId>/…). - DELETE /api/v1/workspaces/:id/teams/:teamId (OWNER/ADMIN) → soft behavior: teams have no deletedAt column — hard delete with precondition: the team must have 0 pages (Phase 3) — in Phase 2 this is simply "0 records in Page.teamId" which is always true pre-Phase-3 → delete team + cascade TeamMember rows; 200 { deleted: true }. (Recorded decision: teams are deletable only while empty; Phase 3 keeps the rule — moving pages out first.) - POST /api/v1/workspaces/:id/teams/:teamId/members body { userId, role } (workspace OWNER/ADMIN, or team ADMIN for role ≤ team-ADMIN): adds a TeamMember (the target must be a workspace member — 409 not_workspace_member); PATCH/DELETE the membership similarly. - POST /api/v1/workspaces/:id/teams/:teamId/move body { beforeTeamId?, afterTeamId? } (OWNER/ADMIN) → new fractional position via the shared engine (positionBetween(prev, next); either bound may be null = start/end); 200 { position }.
  2. Positions: all team ordering through @factory/shared/positions only (no manual strings); a unit test here + TASK-0028's engine tests pin the engine's API (if the positions module lacks positionAfter/positionBetween/comparePositions, add them to packages/shared/src/positions.ts now — they're needed by Phase 3 too).
  3. Team.slug uniqueness: add @@unique([workspaceId, slug]) to schema.prisma if absent → migration <ts>_team_slug_unique.

Acceptance Criteria

  1. Create 3 teams → positions strictly increasing per comparePositions; duplicate slug → 409; rename works; delete of a team with members cascades TeamMember rows (0 left).
  2. Team membership: add a workspace member at team role MEMBER (default) / ADMIN; adding a non-workspace user → 409; a team ADMIN can add/remove team members at role ≤ ADMIN but not promote to team OWNER (no team-OWNER exists — the top team role is ADMIN — 400 on any higher role).
  3. Move: team B moved before team A → positions swap correctly (assert ordering via GET list before/after); move with both bounds null → appends at end.
  4. The migration (if required) applies on a fresh Testcontainers db; existing seeded teams (from TASK-0006 seed) are untouched by it (backfill-safe: the seed already creates teams with unique slugs per workspace — verify).
  5. Fractional position values never exceed 256 chars (engine invariant — a test generating 1000 successive positionAfter calls stays within the bound; if the engine can't guarantee it, document the re-normalization hook — the hook is Phase 15, the bound is asserted now).

Backend Changes

Frontend Changes

Database Changes

API Changes

Tests

Security

Documentation

Dependencies

Definition of Done

Team CRUD + membership + move live; positions only via the shared engine; migration (if needed) proven backfill-safe; all 5 criteria; docs written.

TASK-0028

Title

packages/permissions v1 (role base level + explicit grants + guard)

Objective

The RBAC engine in packages/permissions: a pure checkPermission function combining the workspace role base level with explicit grants (the Permission table), a decision-matrix unit test suite of ≥ 40 cases, and the NestJS PermissionGuard that wires it into apps/api for every workspace-scoped route.

Background

Models exist: WorkspaceMember.role (WorkspaceRole) and Permission (workspaceId, granteeType: "user"|"team"|"role", granteeId, resourceType: "workspace"|"teamspace"|"page"|"database"|"record"|"file", resourceId, level: PermissionLevel NONE|VIEW|COMMENT|EDIT|FULL_ACCESS, inheritsFrom). Phase 3 extends this engine to v2 (page-parent-chain inheritance) — v1 covers workspace + teamspace resources; the function signature is designed so v2 adds a resource-resolver argument, not a rewrite. This task is the server-side authorization backbone for the Phase 2 exit criterion "permissions enforced server-side".

Requirements

  1. packages/permissions/src/engine.ts: checkPermission(input: PermissionInput): PermissionDecision where PermissionInput = { workspaceId; resourceId; resourceType: "workspace"|"teamspace"; userId; workspaceRole?: WorkspaceRole; teamRoles?: Record<teamId, WorkspaceRole>; grants: Grant[] /* rows for (granteeType, granteeId) matching user/team/role, preloaded by the caller — the engine is pure, no DB */ ; action: "view"|"comment"|"edit"|"manage_members"|"manage_teams"|"delete_workspace"|"manage_permissions" } and PermissionDecision = { allowed: boolean; level: PermissionLevel; reason: "role_base" | "explicit_grant" | "deny" }. - Role base levels: OWNER → FULL_ACCESS for all actions; ADMIN → EDIT for content actions, manage_members/manage_teams allowed, delete_workspace DENIED (only OWNER deletes — matches TASK-0023), manage_permissions DENIED (OWNER only); MEMBER → COMMENT + edit on their own content (own-content resolution is Phase 3; v1: MEMBER → COMMENT on workspace/teamspace resources); GUEST → VIEW. - Explicit grants: the effective level = max(roleBase, any matching grant level); granteeType role matches by role name (e.g. a GUEST role granted EDIT in a workspace acts as EDIT for that user in that workspace); team grants apply when the resourceType is teamspace and the user is in that team (or a team grant applies to all teams if granteeId is * — documented wildcard); user grants are per-user. - NONE grant = explicit deny that overrides the role base (the only override direction in v1; recorded decision, matches the schema comment). - Pure + synchronous + deterministic (no clock, no IO) — documented; the NestJS guard is the only IO boundary.
  2. Decision matrix ≥ 40 cases (packages/permissions/src/engine.test.ts, table-driven): every (role × action) base-level cell (4×7 = 28) + grant interactions (user grant raising GUEST→EDIT; team grant on a teamspace for a member of the team and for a non-member; role grant on GUEST; NONE overriding EDIT; max-of-two-grants; wildcard team grant; grant on a different workspace ignored; missing workspaceRole (non-member) → deny regardless of grants (grants without membership = denied, recorded decision)) → 40+ distinct cases, each asserting allowed, level, reason.
  3. NestJS wiring: apps/api/src/common/guards/permission.guard.ts (@Permissions(action, { resource: "workspace" | "teamspace" }) decorator) — resolves the resource from the route params, loads membership + grants (two indexed queries: workspace_members by [workspaceId, userId], permissions by [workspaceId] filtered in memory by grantee — the grants table is small per workspace; cache in Redis for 60 s keyed perm:<workspaceId>:<userId> — Phase 15 hardens, the invalidation on member/role/grant mutation is a bus event permissions.invalidated published by the services in TASK-0025/0027), then calls checkPermission; deny → 403 { code: "forbidden", details: { action } } (403, not 404 — membership exists; non-members get 404 from the scoping layer — documented split).
  4. Retrofit Phase 2 endpoints onto the guard (replacing the inline role checks of TASK-0023/0025/0027 — same behavior, one mechanism): workspace rename/settings → manage_members? No — rename/settings → action edit at FULL_ACCESS tier: define the endpoint→action map in apps/api/src/modules/workspace/permission-mapping.ts (const, table-documented) and unit-test that every workspace-scoped route has exactly one mapped action (route-map extension of TASK-0019's test).
  5. apps/api/src/modules/workspace/grant.service.ts + endpoints: GET /api/v1/workspaces/:id/permissions (OWNER), PATCH /api/v1/workspaces/:id/ permissions (OWNER) body { granteeType, granteeId, resourceType, resourceId, level } (upsert) and DELETE …/permissions/:id (OWNER) — the management surface the engine enforces (no UI in Phase 2 — the Permissions tab of settings is a Phase 3+ placeholder; the API is live now).

Acceptance Criteria

  1. pnpm test runs the matrix green: ≥ 40 cases, 100% of the (role × action) cells covered, all 6 grant interaction classes covered; packages/permissions coverage ≥ 80% lines/branches.
  2. A GUEST with a user-grant EDIT on a teamspace gets allowed: true, level: EDIT, reason: explicit_grant on edit for that teamspace and reason: role_base denied for manage_members.
  3. A NONE grant on a workspace for a user with role ADMIN → edit denied with reason: deny (override proven).
  4. Guard retrofit: the full Phase 2 endpoint set still passes its integration tests unchanged (behavior-preserving) plus: a MEMBER calling a workspace rename → 403 forbidden with the correct action in details (new test); a non-member calling any workspace-scoped route → 404 (not 403).
  5. Grant endpoints: upsert + delete by OWNER only (403 otherwise); after a PATCH, the next checkPermission for the grantee reflects the new level within the cache TTL (test waits for invalidation — the mutation publishes the event synchronously, so immediate).
  6. Route-map test: every workspace-scoped route has exactly one permission action mapped; an unmapped route fails the test.

Backend Changes

Frontend Changes

None (no settings Permissions tab in Phase 2 — recorded; Phase 3 adds it).

Database Changes

API Changes

Tests

Security

Documentation

Dependencies

Definition of Done

Engine + ≥ 40-case matrix + guard + grant endpoints live; retrofit proven behavior-preserving; all 6 criteria; engine README + tenancy docs written. Phase 2 exit criterion "permissions enforced server-side" is met by this task.

TASK-0029

Title

Sidebar UI (sections, switcher, collapse, testids)

Objective

The workspace sidebar in apps/web: Home, Inbox, Search, Favorites, Teamspaces (expandable, from the teams API), Private, Shared, Trash sections with a new-page button, the workspace switcher, and responsive collapse — the navigation shell every later phase fills with content.

Background

Phase 2 has real data for Teamspaces (TASK-0027) and the switcher (TASK-0031); Favorites/Private/Shared/Trash are data-backed where Phase 2 provides it and stubbed-empties otherwise (favorites API lands in Phase 3 — the section renders an empty state, not a fake list; the requirement text below marks each section's data source). Home/Inbox/Search are navigation stubs (link targets land in Phases 3/7/8 — the entries exist and are keyboard-reachable, the pages are 404-redirects to / with a "coming in Phase N" note — recorded decision).

Requirements

  1. Layout: apps/web/src/app/(app)/layout.tsx — app shell: left sidebar (data-testid="sidebar", 260 px expanded / 56 px collapsed / hidden < 768 px behind a hamburger), main content area. The shell renders only for authenticated routes (TASK-0018's middleware already guards /).
  2. Sections (top→bottom, each a nav list with data-testid per entry): - Top: workspace switcher button (sidebar-switcher) → popover with the workspace list (personal grouped under "Personal", others under "Workspaces", icon + name + "current" check, "Create workspace…" → the create dialog from TASK-0030) — data-testid="switcher-popover", items switcher-item-<slug>. - Home (link /, testid nav-home), Inbox (nav-inbox, Phase 7 stub), Search (nav-search, Phase 8 stub — button that opens the search overlay placeholder: an input that shows "Search arrives in Phase 8" — not a fake results list), Favorites (nav-favorites, empty state "No favorites yet — Phase 3"). - TEAMSPACES group (header testid nav-teamspaces): each team from GET /workspaces/:id/teams as an expandable row (team-row-<id> with team-name, expand chevron, emoji icon or colored dot from color, team-pages-<id> child list — Phase 2: child pages don't exist yet, so each team expands to an empty state "No pages yet — add one in Phase 3" + a new-page-in-team-<id> button that is disabled with a tooltip (Phase 3 wires it — disabled, not hidden, so the layout is stable); a + New teamspace row (new-team) opening the create-team dialog (name + slug, from the shared zod dto). - Private (nav-private, Phase 3 empty state), Shared (nav-shared, Phase 3 empty state), Trash (nav-trash, Phase 3 empty state). - Bottom: new-page button (new-page, primary, disabled + tooltip "Phase 3" — same pattern) + settings gear (sidebar-settings → workspace settings, TASK-0030) + the user menu (avatar, name, "Log out" wired to TASK-0015).
  3. Data: TanStack Query — useWorkspaces(), useTeams(workspaceId) (refetch on workspace switch; staleTime 30 s; loading = skeleton rows (testid sidebar-loading), error = retry row (testid sidebar-error), empty teams = "No teamspaces yet" state (testid teams-empty)); the active workspace comes from the URL (/w/[workspace]/…, TASK-0031) — the sidebar re-renders per workspace without a full page reload.
  4. Collapse: chevron on the switcher row toggles 260→56 px (icons only, aria- expanded on the trigger, persisted to localStorage key factory.sidebar); mobile: sidebar becomes an overlay drawer (hamburger sidebar-toggle, Esc/backdrop closes it, focus trapped while open); keyboard: full Tab order top→bottom, arrow keys move within a list (WAI-ARIA role="navigation" + aria-label per section), all mouse actions have keyboard paths (AGENTS.md DoD accessibility).
  5. Theme-aware via @factory/ui tokens (light/dark verified); no horizontal scroll at 375 px.

Acceptance Criteria

  1. With the seeded demo workspace (2 teams), the sidebar renders: switcher, the 4 top entries, both team rows, Private/Shared/Trash empty states, new-page (disabled), settings — all testids present (E2E assertion list in TASK-0033).
  2. Creating a team via new-team → the row appears in the TEAMSPACES group ordered by position (last = bottom) without a manual refetch (query invalidation on mutation).
  3. Switching workspace via the switcher: URL changes to the other workspace's prefix and the team list swaps to that workspace's teams (two-workspace test: personal vs demo — personal shows teams-empty).
  4. Collapse toggle persists (reload keeps the state); mobile drawer opens/closes with focus trap (E2E: open → focus stays inside → Esc closes → focus returns to the hamburger).
  5. Loading and error states render (mock the teams query: pending → skeleton; 500 → retry row, retry click refetches).
  6. Keyboard-only walkthrough: Tab reaches switcher → all sections → new-page → settings → user menu; Enter/Space activates each; no outline: none anywhere (focus-visible styling asserted in E2E by computed style on focus).

Backend Changes

None (consumes existing endpoints: workspaces, teams, switcher data).

Frontend Changes

Database Changes

None.

API Changes

None.

Tests

Security

Documentation

Dependencies

Definition of Done

Shell + sidebar + switcher + dialogs live with all testids; component tests green; the 6 E2E criteria ready for TASK-0033; stub policy documented.

TASK-0030

Title

Workspace settings UI (Profile, Members, Teams, Security)

Objective

The workspace settings page with 4 tabs — Profile (name/description/icon), Members (role select + invite dialog + pending list), Teams (list + create/edit/delete), Security (placeholder) — consuming the Phase 2 APIs.

Background

All backing endpoints exist: TASK-0023 (profile/icon), TASK-0025 (members/roles/ transfer), TASK-0026 (invitations), TASK-0027 (teams). This task is pure UI + optimistic-update wiring. The settings route is /w/[workspace]/settings (TASK-0031 prefix) and requires a member; the tabs render per role (the shared packages/shared/src/roles.ts constants drive which controls appear — the server still enforces).

Requirements

  1. apps/web/src/app/(app)/[workspace]/settings/page.tsx + tabs/ {profile-tab.tsx, members-tab.tsx, teams-tab.tsx, security-tab.tsx}; tab switch in the URL (?tab=members — deep-linkable, testid settings-tab-<name>).
  2. Profile tab: name input (zod: 2–100), description (≤ 500), icon: current preview + "Upload new" (file input → POST /workspaces/:id/icon, progress bar, error toast on 400 with the reason) + "Remove" (PATCH iconUrl: null — the backend accepts an empty string → null, documented) + slug display (read-only, with a "changing slugs is Phase 13" note — recorded decision: slug is immutable in Phase 2). Save = single PATCH (dirty-state detection, settings-save button disabled when clean, success toast, field errors from 400 mapped).
  3. Members tab: table (avatar/initials, name, email, role select, joined date, row actions) — role select options = the caller's allowed transitions from roles.ts (a MEMBER sees only "—" for everyone: no options; an OWNER sees all transitions except the forbidden ones — the 409/403 from the server is the backstop and its error code maps to a toast: owner_transfer_required → "Transfer ownership first" + a "Transfer ownership…" action opening the transfer dialog (member picker → confirm → mutation)); remove = row trash button → confirm dialog (self-remove allowed, owner-remove hidden); pending invitations section below the table (email, role, status chip, invited by, "Copy link" (copies the invite URL — the token link is exposed to OWNER/ADMIN here; recorded security decision with the 7-day expiry as the control), "Revoke" → delete endpoint); invite dialog (invite-dialog: email + role select (MEMBER/GUEST/ADMIN, OWNER never — matches TASK-0026) → mutation → success state "Invitation sent" + the pending row appears).
  4. Teams tab: list (name, emoji/color, member count, position drag handle — disabled in Phase 2, tooltip "Reordering in sidebar is Phase 4" — the move API exists but the UI affordance waits for the DnD framework; recorded), create dialog (name+slug, shared dto), edit (same dialog prefilled), delete (confirm — "Team must be empty" error state maps the future 409).
  5. Security tab: placeholder card — "2FA, SSO (Phase 14), audit log viewer (Phase 14), data retention (Phase 14)" as a disabled feature list (testid security-placeholder) — explicit placeholder policy.
  6. States per DoD: every tab has loading (skeleton), error (retry), empty (members can't be empty — at least the owner; teams empty state; pending invites empty state); toasts for all mutations (success/error with the server's message); theme-aware; responsive (tables → cards < 768 px).

Acceptance Criteria

  1. Profile: rename + description save persist (reload shows the new values); icon upload shows progress and the new icon everywhere (switcher + sidebar) after success; an oversized file shows the server's too_large message as a toast.
  2. Members (as OWNER): changing a MEMBER's role to GUEST persists + shows in the table; attempting to demote the OWNER shows the owner_transfer_required toast + the transfer dialog; completing a transfer (to the second user) updates both roles (old owner → ADMIN chip visible).
  3. Invitations: inviting a new email → pending row appears + (dev) the logged link can be copied from "Copy link"; revoking removes the row (status REVOKED not listed); a 409 already_member from the API → field error under the email.
  4. Teams: create via dialog → row appears (ordered last); edit renames; delete of a team with members is impossible in Phase 2 (always empty pre-Phase-3) → row disappears.
  5. Role-gated UI: a MEMBER sees no role options, no invite control, no transfer button (asserted in E2E by absence of the testids, not just hidden — the components don't render).
  6. Every tab: skeleton on load, retry on forced 500 (E2E route-abort), correct empty states; mobile 375 px: no horizontal scroll, tables as cards.

Backend Changes

Frontend Changes

Database Changes

None.

API Changes

Tests

Security

Documentation

Dependencies

Definition of Done

4 tabs + 3 dialogs live; all component tests green; the 6 E2E criteria ready for TASK-0033; docs written.

TASK-0031

Title

Workspace switcher + URL [workspace] slug resolution

Objective

Make the workspace a URL-level context: /w/[workspace]/… resolves the slug to a workspace the caller may access, with correct 404/403 states, redirect rules (bare / → current workspace's Home), and the switcher popover wired to it.

Background

TASK-0029's sidebar needs the /w/[workspace] prefix to be a stable contract. The switcher data (TASK-0029 requirement 2) and the membership check (api: GET /api/v1/workspaces/:idOrSlug) already exist — this task builds the route layer, the server-side resolution, and the redirect/404/403 semantics so later phases only add routes under (app)/[workspace]/….

Requirements

  1. Routes: apps/web/src/app/(app)/[workspace]/layout.tsx — server component: resolve params.workspace (slug) via GET /api/v1/workspaces/:slug (server-side fetch with the user's bearer — the web app proxies through a server route to avoid client-side 401 races: apps/web/src/app/api/proxy/workspaces/[slug]/ route.ts is NOT introduced — instead the layout uses the existing api with the user's token from the session cookie? No: Phase 1 stores tokens client-side. Decision (recorded): the layout does the lookup client-side in a <WorkspaceGate> component (suspense + skeleton) — the api returns 404 for non-members (the scoping convention), so the gate renders: 404 → not-found page (testid workspace-not-found, "Workspace not found — check the link"), 403 → forbidden page (testid workspace-forbidden, "You don't have access") — the split exists in the api (member-but-forbidden vs non-member, TASK-0028), so both pages are reachable and distinct.
  2. Redirects: bare / (authenticated) → /w/<first-workspace>/ (personal workspace first — deterministic: isPersonal desc, then createdAt asc — documented); /w/<slug>/ trailing → same (no extra segment = Home, which is the Phase 3 page route; in Phase 2 Home renders the landing content: greeting + workspace name + a "Pages arrive in Phase 3" card — testid home-landing); unknown slug → the not-found page above (not a redirect loop).
  3. Switcher wiring (completing TASK-0029 requirement 2): selecting switcher-item-<slug> → router.push("/w/<slug>"); the current workspace in the popover gets a check (aria-current="true"); the "Create workspace…" flow → dialog (name + auto-slug preview from the name, editable, shared dto validation) → on success router.push("/w/<new-slug>") + switcher refresh.
  4. Per-workspace state isolation: the sidebar's queries are keyed by workspace id (TanStack Query key includes the slug — switching never flashes another workspace's teams); localStorage per-workspace UI state (e.g. expanded teams) is namespaced factory.<workspaceSlug>.<key> (documented).
  5. The (auth) routes are unaffected; the middleware (TASK-0018) still guards everything; a logged-out /w/<slug> → /login?next=/w/<slug> (deep link preserved — criterion in E2E).

Acceptance Criteria

  1. /w/demo (seeded) renders the shell + home landing for the demo user; /w/does-not-exist → not-found page (testid present, HTTP 200 soft-404 — documented SPA choice, no server-side 404 status required in Phase 2, recorded); /w/<other-user-workspace> (a workspace the caller has no membership in — the api returns 404 for non-members per the scoping convention, so this case renders the not-found page — the forbidden page is reachable when the caller IS a member but the guard denies an action; both pages exist and are distinct — criterion proven with a crafted member-but-403 via a GUEST on a settings route) — the exact reachable-case matrix is asserted in TASK-0033.
  2. / for a user with a personal workspace + the demo workspace → /w/personal-… (personal first); after switching to demo, reload stays on demo (the redirect is one-shot to the first workspace, never sticky — documented: no "last workspace" persistence in Phase 2; the switcher is the mechanism).
  3. Switcher: selecting a workspace updates the URL + swaps sidebar data (teams) without a full reload (asserted by a query-key test: no fetch for the old workspace after switch; the new workspace's teams fetched once).
  4. Logged-out /w/demo → /login?next=/w/demo; login lands back on /w/demo.
  5. Create-workspace flow: dialog → created → URL on the new workspace's home; the slug preview updates live as the name changes (typing "My Team" → my-team); an invalid slug (uppercase typed) is normalized + shown (client) and the server re-validates (400 path tested in the component test).
  6. Per-workspace state: expand team A in workspace 1, switch to workspace 2, switch back → team A still expanded (namespaced key), and no other workspace's state leaks (test with two workspaces).

Backend Changes

Frontend Changes

Database Changes

None.

API Changes

None.

Tests

Security

Documentation

Dependencies

Definition of Done

Route layer + gate + redirects + switcher wiring live; all 6 criteria (1, 2, 4 locally verifiable; 3, 5, 6 via TASK-0033); docs written.

TASK-0032

Title

Workspace audit events

Objective

Complete the Phase 2 audit surface: workspace.created, workspace.updated, workspace.icon_changed, workspace.deleted, member.invited, member.invitation_accepted, member.invitation_revoked, member.role_changed, member.removed, workspace.owner_transferred, team.created, team.updated, team.deleted, team.member_added, team.member_removed, permission.grant_changed, permission.grant_removed — all with actor/ip/ua via the TASK-0020 service.

Background

TASK-0020 established the audit module + the 6 auth events + the resilience rule (audit failure never breaks the request). Several Phase 2 services already emit events per their task requirements (TASK-0025 role changes, TASK-0026 invites, TASK-0023 icon) — this task centralizes the names in packages/shared/src/audit-events.ts (extending the TASK-0020 const map), audits the emission points for completeness/correctness, and closes the gaps (workspace create/update/delete, team events, grant events).

Requirements

  1. Extend packages/shared/src/audit-events.ts with the 17 names above as a typed const map (AuditAction.WORKSPACE_CREATED = "workspace.created" etc.) + the metadata field contract per event (table in the doc, e.g. member.role_changed: { from, to, targetUserId }, workspace.owner_transferred: { fromUserId, toUserId }, permission.grant_changed: { granteeType, granteeId, resourceType, resourceId, level, previousLevel? }, team.created: { teamId } — all metadata values are ids/roles/levels, never names or emails — the single PII exception is member.invited metadata { email } (needed for the invite trail; recorded, consistent with TASK-0020's register exception).
  2. Emission audit (checklist, each = a code inspection + a test): workspace create (TASK-0023 service), workspace rename/description/settings (workspace.updated, metadata { changedFields: string[] }), icon (workspace.icon_changed), delete (workspace.deleted — before the deletedAt write, the row still exists for the workspaceId ref), member invite/accept/revoke (TASK-0026 — check member.invitation_accepted fires on accept, not on the accept page load), role change/remove (TASK-0025), transfer (TASK-0025), team create/update/delete + member add/remove (TASK-0027), grant upsert/delete (TASK-0028 grant.service). Missing emissions are added here (no new behavior in other tasks).
  3. Every event: workspaceId set (the events are workspace-scoped by definition), actorType: USER (no system actor in Phase 2 — the worker jobs that fire in Phase 2 — invite expiry — write no audit row: an expired invite is a state change without an actor; recorded decision, the expiry is visible in the invitations list status).
  4. The no-secret DB scan from TASK-0020 is extended to the Phase 2 strings (invite token, new workspace names with special characters — the scan queries metadata::text for the known sensitive values from the test run).
  5. Ordering/consistency: audit rows are written within the same unit of work as the mutation where the service can (transactional insert where Prisma's transaction spans it — documented per-service which ones are transactional vs fire-and-forget per the resilience rule).

Acceptance Criteria

  1. A full scripted sequence (integration test: create ws → invite → accept → role change → transfer → team create/add/delete → grant add/delete → icon → rename → delete) produces exactly 17 audit rows (one per event) with correct action/actorId/workspaceId/ipAddress/userAgent (asserted row-by-row).
  2. Every event name used in the api comes from the shared const map (typecheck: string literals not in the map fail compile — a deliberate-violation demo in the PR body).
  3. No audit row's metadata contains a token, password, or free-text email except the two documented exceptions (register, invite) — the DB scan passes.
  4. Failing the audit insert (fault injection) leaves the corresponding mutation successful (200/201) + an error log line (re-verified for 3 Phase 2 services, not just auth).
  5. workspace.updated metadata lists exactly the changed fields (rename-only patch → ["name"]; rename+settings → ["name","settings"]).

Backend Changes

Frontend Changes

None (the audit viewer is Phase 14).

Database Changes

None (the TASK-0020/0027/0028 indexes cover the query patterns; no new schema).

API Changes

None.

Tests

Security

Documentation

Dependencies

Definition of Done

All 17 events emitted from the const map; the sequence test proves 17 correct rows; fault-injection + DB scan pass; docs written.

TASK-0033

Title

E2E multi-user workspace flow (tests/e2e/workspace.spec.ts)

Objective

Prove the Phase 2 exit criteria with Playwright in tests/e2e/workspace.spec.ts: user A creates a workspace → invites B → B accepts → B sees the sidebar → role enforcement (B cannot delete the workspace) → A transfers ownership to B — plus the settings UI flows, sidebar rendering, switcher, and the 404/403 states.

Background

The E2E harness (TASK-0008) + helpers (api.ts typed fetch/token storage, db.ts Prisma assertions) + the dev-mail log reader (TASK-0022) are available. Two browser contexts (A and B) against the same dev stack. Unique emails/slugs per run (e2e-<timestamp>) keep the seeded DB clean (no teardown delete — the E2E DB is reset per CI run).

Requirements

  1. tests/e2e/workspace.spec.ts (serial), actors: A = seeded demo user (has the seeded demo workspace — but the spec creates a fresh workspace to keep the assertions clean), B = newly registered user (register → verify via dev code → login, reusing the TASK-0022 helper for the code). - Flow 1 (create): A on /w/<personal> → switcher → "Create workspace…" → dialog (name "E2E Flow", slug e2e-flow-<ts> preview) → submit → URL on /w/e2e-flow-…/ → home landing renders the workspace name (testid home-landing) → DB: workspace row + A's OWNER membership. - Flow 2 (invite + accept): A → settings → Members tab → invite dialog (invite-dialog) with B's email + role MEMBER → pending row appears → "Copy link" → the link's token is extracted (clipboard read or the logged dev link) → B's context: logged in as B → navigate to the invite link → invite-card shows "E2E Flow" + "Member" role → click invite-accept → "accepted" state → DB: B's WorkspaceMember (MEMBER, joinedAt set) + invitation ACCEPTED + audit row member.invitation_accepted. - Flow 3 (B's sidebar): B navigates to /w/e2e-flow-… → sidebar renders with the workspace name in the switcher (current check), Home, the teams-empty state (no teams yet), Private/Shared/Trash empty states — full testid assertion list (the TASK-0029 criterion-1 list). - Flow 4 (role enforcement): B (MEMBER): the settings Members tab renders with NO role options / no invite control / no transfer button (TASK-0030 criterion 5 testids absent); B's direct API call DELETE /api/v1/workspaces/<id> → 403 forbidden with details.action present (helper call, not the UI — the UI has no delete button for a MEMBER, recorded); B's PATCH rename → 403; a DB scan: no new audit rows from B's denied attempts — recorded decision: Phase 2 does NOT write a permission.denied audit row (the ROADMAP's Phase 3 exit "unauthorized access rejected with audit event" is scoped to Phase 3's page permissions, TASK-0041; Phase 2's 403s are covered by the guard's warn log (TASK-0010/0028) + the E2E assertion of the 403 itself). - Flow 5 (transfer): A → Members tab → "Transfer ownership…" → dialog → pick B → confirm → both role chips update (B → OWNER, A → ADMIN); A's DELETE attempt now also 403 (A is no longer owner — admin can't delete, the TASK-0025 rule); B's DELETE /api/v1/workspaces/<id> → 200 (soft delete) → A navigates to /w/e2e-flow-… → not-found page (testid workspace-not-found) — the round trip proves the delete. - Flow 6 (switcher + states): with a second A-owned workspace created (quick API call via helper): switcher shows both + A's personal (grouped), current check on the right one; logged-out context: /w/e2e-flow-… → /login?next=/w/e2e-flow-… → login → back on the workspace (TASK-0031 criterion 4); unknown slug → not-found page.
  2. Sidebar rendering criterion (exit): Flow 3's assertions ARE the "sidebar renders tree from API" proof — plus the teams case: A creates a team via the Teams tab (settings) before Flow 3 → B's sidebar shows the team row (team-row-<id>); the row's data comes from the API (assert the team name matches the DB row).
  3. Theme/responsive: the settings page + sidebar at 375 px (no horizontal scroll) and in dark scheme (computed style check, TASK-0018 pattern).
  4. Every DB assertion through helpers/db.ts; audit rows asserted for: workspace.created, member.invited, member.invitation_accepted, member.role_changed (the transfer's old-owner→ADMIN demote), workspace.owner_transferred, workspace.deleted (6 rows minimum, exact action values).
  5. The spec passes twice consecutively locally (unique timestamps make it idempotent; no shared state between runs).

Acceptance Criteria

  1. pnpm test:e2e (workspace.spec.ts) green on the dev stack, clean machine — run recorded in the PR; the 6 flows complete in order.
  2. Flow 4's 403 assertions pass (B cannot delete/rename; no permission.denied audit row exists — the recorded decision asserted by absence).
  3. Flow 5's full transfer + delete + not-found round trip passes; the 6 audit rows exist with correct metadata (from/to user ids).
  4. Flow 3's sidebar testid list is complete (the exact list from docs/ui/sidebar.md) including the team row sourced from the API.
  5. Flow 6: switcher grouping/current-check correct; the next deep-link round trip works; unknown slug → not-found.
  6. Theme + 375 px checks pass; the spec passes twice in a row.

Backend Changes

None (a gap found = a bug in an earlier Phase 2 task, fixed in that module in the same PR, recorded).

Frontend Changes

Database Changes

None.

API Changes

None.

Tests

Security

Documentation

Dependencies

Definition of Done

tests/e2e/workspace.spec.ts green twice locally + in CI; all 6 criteria proven; exit mapping recorded; docs updated. Phase 2 exit criteria fully met — Phase 3 may start.

TASK-0034

Title

Integration tests (tenant isolation)

Objective

Pin the multi-tenant isolation contract with dedicated integration tests: a user C who is a member of no workspace (and a user D who is a member of a different workspace) cannot read, write, or enumerate anything in workspace W — across every Phase 2 endpoint, with the 404-vs-403 split enforced.

Background

Isolation was asserted piecemeal (TASK-0023 criterion 5, TASK-0025, TASK-0033's 403s). This task makes it a systematic property test: every workspace-scoped endpoint is enumerated (route map from TASK-0019/0028) and hit by non-members with every method — no endpoint may return 2xx for a non-member, and the response code must match the documented split (non-member → 404; member-but-unauthorized action → 403). A regression here is a tenant breach, so the test is exhaustive and CI-blocking.

Requirements

  1. tests/integration/tenant-isolation.test.ts: setup — workspace W (user A owner, user B member), workspace W2 (user D owner only), user C (no memberships beyond their personal workspace); a team in W with a page-attached? (pages are Phase 3 — teams only); one grant in W (user-grant to B); one pending invitation in W.
  2. The enumeration: import the route map (the same source TASK-0028's test uses — the Nest app's RouterExplorer) filtered to workspace-scoped routes (path contains /workspaces/:id… or the token routes) × methods — for each (route, method): C → expect 404 (or 401 if unauthenticated — C is authenticated, so 404); D → expect 404 on W's routes (D is not a member of W — the same 404); B → 2xx on read routes, 403 on write routes not allowed for MEMBER (the permission-mapping table drives the expected action), 404 on team routes of W2. Token routes: C with W's invite token → 403 email_mismatch (the token is W's, C is authenticated but not the invitee) — the split for token routes is documented in the test's header comment.
  3. Data-leak assertions (beyond status codes): for every 404/403 response, the body contains none of W's secrets — a helper asserts the response JSON does not contain W's workspace id, any member's email, the invite token, or the grant's granteeId (the no-leak scan, parameterized over the responses collected in the enumeration).
  4. Query-level isolation spot checks (the repository convention, not just the guard): GET /api/v1/workspaces for C returns only C's personal workspace (W and W2 absent — by id AND slug AND name in the response); GET /api/v1/workspaces/:id/ members for C on W → 404 (no partial member list).
  5. The test runs against Testcontainers (postgres + redis); a CI gate: it is part of pnpm test:integration (already so by location) — and it fails the build on any 2xx for a non-member (no soft-fail mode).
  6. A "breach canary": the test suite ends by inserting a Workspace row via direct SQL visible only to A and asserting C's list still excludes it (guards against a future query that forgets the membership join — the canary is a different row shape: isPersonal: false, created outside the api).

Acceptance Criteria

  1. The enumeration covers ≥ 30 (route × method) pairs (the count is asserted in the test — if a new workspace-scoped route is added in a later phase, the count grows and the test still checks it; a hard floor of 30 for Phase 2).
  2. Zero 2xx responses for C or D on W's routes across the whole enumeration; the 404-vs-403 split holds per the documented rule (asserted per route, not just in aggregate).
  3. The no-leak scan passes for every collected 4xx body (none of the 4 secret strings present).
  4. C's GET /api/v1/workspaces returns exactly 1 workspace (their personal) — W, W2 absent by id, slug, and name.
  5. The canary row is invisible to C and visible to A (direct SQL insert → two API calls → assertions).
  6. The suite runs in < 5 min on Testcontainers (the enumeration is HTTP-level, fast) — the runtime is logged in the test output.

Backend Changes

None (this task adds no endpoints; if the enumeration finds a 2xx for a non-member, that's a bug fix in the responsible Phase 2 service, same PR, recorded).

Frontend Changes

None.

Database Changes

None (Testcontainers only; no schema).

API Changes

None.

Tests

Security

Documentation

Dependencies

Definition of Done

Isolation suite live with ≥ 30 route pairs proven; no-leak + canary passing; route-map helper shared; docs written. Phase 2 is complete when TASK-0033 and this task are both green — start Phase 3.