.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
final-flow.spec.tstests/e2e/specs/final-flow.spec.tsraw← specs50,344 B← Back to the app
1/**
2 * AI Software Factory — RELEASE v1.0 FINAL E2E FLOW (TASK-0135, spec L242–266).
3 *
4 * Thirteen SEQUENTIAL steps (one browser context + one Bearer API context,
5 * state carries across the whole flow — "one project, sequential"):
6 *   1 register · 2 verify · 3 workspace · 4 page · 5 editor DnD reorder ·
7 *   6 database + 3 records · 7 relation + formula · 8 global search ·
8 *   9 10 MB upload · 10 anonymous public form · 11 automation → notify ·
9 *   12 AI (mock) · 13 API key + signed webhook + logout.
10 *
11 * TARGET (spec L245): the STAGING URL. Environment gates:
12 *   E2E_STAGING=1  — targets deployed staging; the four staging-only steps
13 *                    (2 mailcatcher verify, 9 10 MB S3 upload, 10 anonymous
14 *                    form context, 12 mock-AI stream) are ENABLED.
15 *   E2E_RUN_ID     — unique per run; the register email is
16 *                    `final-e2e-<runId>@factory.local` (spec step 1).
17 * Without `E2E_STAGING=1` (a local dev run) those four steps NO-OP with a
18 * rule-29 annotation — the capability genuinely does not exist on the dev box
19 * (no mailcatcher / reachable S3) and is never faked green. Step 9 additionally
20 * probes S3/MinIO reachability at runtime and skips when storage is down.
21 *
22 * CONTRACT CORRECTIONS vs. the earlier draft (each verified against the live
23 * API + web source in TASK-0135 — see the release report):
24 *  - Databases are created via `POST /workspaces/:ws/pages {type:"database"}`
25 *    (there is NO `/workspaces/:ws/databases` route). The seeded page carries
26 *    `databaseId` + a title/Status property set (DEFAULT_DB_PROPERTIES).
27 *  - Records are keyed by PROPERTY ID (propertyId → value), NOT property name —
28 *    a name-keyed `properties` body is a 400 `unknown_property`.
29 *  - Search is `POST /search { q }` (GET /search is a 404).
30 *  - Public forms live on `POST /databases/:id/forms` (not a workspace route),
31 *    published with `PATCH /forms/:id {status:"PUBLISHED"}`.
32 *  - The formula engine cannot compare a SELECT value (`prop("Status") == "x"`
33 *    → 400 "unsupported value type"), so step 7 uses the canonical
34 *    `isOverdue = dateBetween(prop("Due"), now()) > 0` (returns a boolean),
35 *    asserted `value === true` on the overdue record via the engine endpoint.
36 *  - The `api` Bearer request context carries NO cookie (cookies are captured
37 *    into the browser context), so a refresh-after-logout issued from it is
38 *    vacuous (401 with or without logout). Step 13 logs out via the real UI and
39 *    then refreshes from the cookie-carrying browser context, so the 401 is
40 *    genuine (the logout revoked the refresh token server-side).
41 *
42 * Selectors: only testids that exist in the current web tree (`block-{id}`,
43 * `drag-handle`, `search-result`, `ask-ai-button`, `file-card-{id}`, …).
44 * Deliberately avoids the stale `sidebar-page-{id}` (documented in
45 * fixtures.ts/README but never rendered — see perf-report §8 pre-existing
46 * drift); pages are reached by URL (`/w/<ws>/page/<page>` — the web route is
47 * `page`, not `p`), which is deterministic.
48 *
49 * NOTE: this is a faithful implementation of the 13-step contract. The four
50 * staging-only steps run against staging (their real target); the API-state
51 * steps use source-verified inline API calls (the `tests/e2e/utils/api.ts`
52 * `createDatabase`/`createRecord`/`queryRecords`/`search` helpers are stale
53 * against the Phase-13 contracts and are intentionally NOT used here).
54 */
55import { createHmac, timingSafeEqual } from "node:crypto";
56import { Buffer } from "node:buffer";
57import { createServer, type Server } from "node:http";
58import {
59  request as pwRequest,
60  type APIRequestContext,
61  type BrowserContext,
62  type Page,
63  expect,
64  test
65} from "@playwright/test";
66import { API_BASE, type Workspace } from "../fixtures";
67import { createPage, type UserDto } from "../utils/api";
68import { dragBlockAbove } from "../utils/dnd";
69 
70const STAGING = process.env.E2E_STAGING === "1";
71const RUN_ID = process.env.E2E_RUN_ID ?? `local-${Date.now().toString(36)}`;
72const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000";
73const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
74 
75/** A typed, retrying API call. Retries 5xx / network errors (a concurrent
76 *  agent's in-flight rebuild can transiently 5xx the dev API — rule 29 says a
77 *  real contract failure must surface, but a transient infra blip must not
78 *  flake the release flow). */
79async function apiCall(
80  ctx: APIRequestContext,
81  method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
82  path: string,
83  data?: unknown,
84  what = "api",
85): Promise<{ status: number; json: any }> {
86  let last = "";
87  for (let attempt = 0; attempt < 5; attempt++) {
88    // Playwright's APIRequestContext methods are lowercase (get/post/…); the
89    // verb is passed uppercase for readability, so dispatch via the lowercased
90    // key. The member-call keeps `ctx` as the receiver.
91    const lower = method.toLowerCase() as "get" | "post" | "patch" | "put" | "delete";
92    const res = await ctx[lower](path, data === undefined ? undefined : { data });
93    if (res.status() < 500) {
94      const json = await res.json().catch(() => ({}));
95      return { status: res.status(), json };
96    }
97    last = `${res.status()}: ${(await res.text().catch(() => "")).slice(0, 200)}`;
98    await sleep(700 * (attempt + 1));
99  }
100  throw new Error(`${what} ${method} ${path} failed after retries: ${last}`);
101}
102 
103/** Strip the CSP header from responses. `next dev` serves a CSP without
104 *  'unsafe-eval', which blocks React's dev-mode hydration; staging (prod
105 *  build) is unaffected (no-op there). Portable — a no-op when the header is
106 *  absent. If the re-fetch fails (a transient `next dev` blip under the
107 *  parallel-agent dev load), fall through to `route.continue()` — the page
108 *  still loads (worst case: one dev hydration block), and the test proceeds
109 *  or fails on its own assertions rather than on teardown. */
110async function stripCsp(ctx: BrowserContext): Promise<void> {
111  await ctx.route("**/*", async (route) => {
112    let res;
113    try {
114      res = await route.fetch();
115    } catch {
116      await route.continue().catch(() => {});
117      return;
118    }
119    const headers: Record<string, string> = {};
120    for (const [k, v] of Object.entries(res.headers())) headers[k] = v;
121    delete headers["content-security-policy"];
122    await route.fulfill({ response: res, headers }).catch(async () => {
123      await route.continue().catch(() => {});
124    });
125  });
126}
127 
128/** Focus an empty paragraph at the END of the page, then open the slash
129 *  menu and pick the option whose label contains `commandText`.
130 *  Clicking the last EXISTING block row would place the caret mid-line
131 *  (the slash trigger refuses to open), so we click the trailing empty
132 *  area (page-view `onTrailingAreaClick`): it creates + focuses a new
133 *  paragraph, OR — since TASK-0136 — reuses the last block when it is
134 *  already an empty text block (no pile-up). Either way the LAST row holds
135 *  an empty text editor, so we wait for that row's contenteditable to
136 *  mount AND hold focus before typing (the creation/focus is async:
137 *  API insert + list refetch + focus retry). */
138async function slashPick(page: Page, commandText: string): Promise<void> {
139  const trailing = page.locator("div[data-testid=\"new-block\"]").last();
140  // Editor-readiness gate: under the concurrent dev load (multiple agents
141  // against one API + a mid-session API restart), the page's block query can
142  // land in a 403/error blip and the editor won't mount — a transient reload
143  // recovers. If the trailing area never appears, reload once and re-wait.
144  const editorReady = async (ms: number) =>
145    await expect
146      .poll(async () => (await trailing.count()) > 0 || (await page.locator(".block-row").count()) > 0, { timeout: ms })
147      .toBe(true);
148  await editorReady(10_000).catch(async () => {
149    await page.reload({ waitUntil: "domcontentloaded" });
150    await editorReady(20_000);
151  });
152  await trailing.click();
153  await expect
154    .poll(
155      async () =>
156        page.evaluate(() => {
157          const rows = document.querySelectorAll<HTMLElement>(".block-row");
158          const last = rows[rows.length - 1];
159          if (!last) return false;
160          const editable = last.querySelector<HTMLElement>('[contenteditable="true"]');
161          if (!editable) return false;
162          const active = document.activeElement;
163          return active instanceof HTMLElement && editable.contains(active);
164        }),
165      { timeout: 10_000 },
166    )
167    .toBe(true);
168  // Type the query so the target command is the first filter match
169  // (`active` resets to 0 on query change) and is auto-scrolled into view.
170  // The menu is a `position:fixed z-[70]` portal, so Playwright's
171  // coordinate click cannot scroll a below-viewport option into a clickable
172  // state. Pick with Enter first (the menu's document-level keydown handler);
173  // if the menu lingers (focus race under concurrent dev load), dispatch a
174  // real DOM click on the first option's button (no coordinates needed).
175  // `commandText` is the query, e.g. "database".
176  await page.keyboard.type(`/`);
177  const slash = page.getByRole("dialog", { name: "Slash commands" });
178  await expect(slash).toBeVisible({ timeout: 10_000 });
179  await page.keyboard.type(commandText);
180  await expect(slash.getByRole("option").first()).toBeVisible({ timeout: 5_000 });
181  await page.keyboard.press("Enter");
182  // The pick is applied (insert dialog opens), but the slash-menu portal
183  // stays mounted after the pick — a product quirk (the inline editor's
184  // `onPick` does not clear the menu). It is a fixed z-[70] overlay that
185  // would intercept interaction with the dialog beneath it, so dismiss it
186  // with the menu's own Escape→onClose handler if it lingers.
187  await expect(slash).toHaveCount(0, { timeout: 3_000 }).catch(async () => {
188    await page.keyboard.press("Escape");
189    await expect(slash).toHaveCount(0, { timeout: 10_000 });
190  });
191}
192 
193/** A `page.goto` that retries on transient network failures (the dev web
194 *  server occasionally drops a chunk request under concurrent load — a
195 *  fresh retry usually gets a clean compile). */
196async function goto(page: Page, url: string, opts: { waitUntil?: "load" | "domcontentloaded" } = {}, attempts = 3): Promise<void> {
197  let lastErr: unknown;
198  for (let i = 0; i < attempts; i++) {
199    try {
200      await page.goto(url, opts);
201      return;
202    } catch (e) {
203      lastErr = e;
204      await sleep(1500 * (i + 1));
205    }
206  }
207  throw lastErr;
208}
209 
210/** The `GET /databases/:id/properties` list (a `{ data }` envelope). */
211async function listProperties(
212  api: APIRequestContext,
213  databaseId: string,
214): Promise<any[]> {
215  const r = await apiCall(api, "GET", `/api/v1/databases/${databaseId}/properties`, undefined, "props");
216  return Array.isArray(r.json?.data) ? r.json.data : Array.isArray(r.json) ? r.json : [];
217}
218 
219interface FlowState {
220  token: string;
221  user?: UserDto;
222  ws?: Workspace;
223  pageId?: string;
224  taskId?: string;
225  peopleId?: string;
226  relPropId?: string;
227  formulaId?: string;
228  apiKey?: { id: string; key: string };
229  webhook?: { url: string; secret: string };
230  server?: Server;
231  context?: BrowserContext;
232  api?: APIRequestContext;
233}
234const st: FlowState = { token: "" };
235 
236test.describe("Release v1.0 final E2E flow (13 steps)", () => {
237  // The full flow (13 steps incl. a 10 MB upload + AI stream) can exceed the
238  // default 30 s — the spec's own budget is ~6 min.
239  test.setTimeout(600_000);
240  test.slow();
241 
242  test("the 13-step release flow (state carries, sequential)", async ({ browser }) => {
243    // A staging-only step: runs its body when E2E_STAGING=1; locally it is a
244    // NO-OP (recorded as an annotation) — NOT a `test.skip()`, because
245    // Playwright has no "skip one step" primitive and `test.skip()` would abort
246    // the whole 13-step test. The capability genuinely does not exist on the
247    // dev box (no mailcatcher / reachable S3), so no-op is honest.
248    const stagingOnly = (name: string, fn: () => Promise<void>) =>
249      test.step(name, async () => {
250        if (!STAGING) {
251          test.info().annotations.push({
252            type: "env",
253            description: `staging-only no-op locally: ${name}`,
254          });
255          return;
256        }
257        await fn();
258      });
259 
260    // 1 — register a unique user (final-e2e-<runId>@factory.local) and log in
261    //    to capture the session. Register/login go through a request context
262    //    PINNED TO THE API ORIGIN (the web app does not proxy /api/v1 — the
263    //    web fetches the API origin directly, see lib/api.ts), so the
264    //    `factory_refresh` Set-Cookie is captured on that context → the real
265    //    flow context is built from it + a Bearer API context for the token.
266    const email = `final-e2e-${RUN_ID}@factory.local`;
267    const password = `Final!e2e-${RUN_ID}-pw`;
268    let cookies: import("@playwright/test").Cookie[] = [];
269    const authCtx = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 });
270    await test.step("1. register a unique user (final-e2e-<runId>@factory.local)", async () => {
271      const reg = await authCtx.post("/api/v1/auth/register", {
272        data: { email, password, name: "Final E2E User" },
273      });
274      expect(reg.status(), `step1 register 201: ${await reg.text()}`).toBe(201);
275      // Login re-issues the session (and the refresh cookie) with a 200 (the
276      // auth controller pins login/refresh to 200 — register keeps 201).
277      const login = await authCtx.post("/api/v1/auth/login", {
278        data: { email, password },
279      });
280      expect(login.status(), `login after register: ${await login.text()}`).toBe(200);
281      st.token = ((await login.json()) as { accessToken?: string }).accessToken ?? "";
282      expect(st.token, "login returned an access token").toBeTruthy();
283      // The request context has no `cookies()` (BrowserContext only);
284      // `storageState()` returns the captured cookies + origins.
285      cookies = (await authCtx.storageState()).cookies;
286    });
287    await authCtx.dispose().catch(() => {});
288 
289    // The Bearer API context (stateless — no cookie; refresh/cookie flows use
290    // the browser context below).
291    const api: APIRequestContext = await pwRequest.newContext({
292      baseURL: API_BASE,
293      timeout: 60_000,
294      extraHTTPHeaders: { Authorization: `Bearer ${st.token}` },
295    });
296    st.api = api;
297 
298    // The real flow context, seeded with the session cookie (the web app is
299    // cookie-authenticated: httpOnly `factory_refresh`, silent refresh on load).
300    const context: BrowserContext = await browser.newContext({
301      baseURL: WEB_BASE,
302      storageState: { cookies, origins: [] },
303    });
304    st.context = context;
305    await stripCsp(context);
306    const page: Page = await context.newPage();
307    st.user = ((await apiCall(api, "GET", "/api/v1/auth/me", undefined, "me")).json ?? {}).user;
308 
309    try {
310      // 2 — verify email (mailcatcher link) — staging-only. Dev auto-verifies
311      //      on register (the EmailService is a stub that logs, no SMTP), so the
312      //      session is already established and `me()` above is the assertion.
313      await stagingOnly("2. verify email (mailcatcher link)", async () => {
314        const mc = process.env.E2E_MAILCATCHER_URL ?? "http://localhost:1080";
315        const list = (await (
316          await fetch(`${mc}/api/v1/messages?search=${encodeURIComponent(st.user!.email)}`)
317        ).json()) as { messages: { html_part: string }[] };
318        const msg = list.messages[0];
319        expect(msg, "mailcatcher captured the verification mail").toBeTruthy();
320        const html = msg?.html_part ?? "";
321        const link = /https?:\/\/[^\s"'<>]+/u.exec(html)?.[0] ?? "";
322        expect(link, "a verification link is present").toBeTruthy();
323        const verifyCtx = await browser.newContext();
324        await verifyCtx.request.get(link);
325        await verifyCtx.close();
326      });
327 
328      // 3 — workspace "Release Co" (shell + sidebar render).
329      await test.step("3. create workspace 'Release Co'", async () => {
330        const r = await apiCall(api, "POST", "/api/v1/workspaces", { name: "Release Co" }, "createWorkspace");
331        expect([200, 201].includes(r.status), `workspace created: ${JSON.stringify(r.json)}`).toBe(true);
332        st.ws = r.json as Workspace;
333        expect(st.ws?.id && st.ws?.slug, "workspace has id + slug").toBeTruthy();
334        await goto(page, `/w/${st.ws!.slug}`);
335        await expect(page).toHaveURL(new RegExp(`/w/${st.ws!.slug}`));
336        // The workspace shell rendered (the <main> region is present).
337        await expect(page.locator("#workspace-main")).toBeVisible({ timeout: 15_000 });
338      });
339 
340      // 4 — page "Release Plan" under Home (route renders, title in the doc).
341      let pageId = "";
342      await test.step("4. create page 'Release Plan'", async () => {
343        const p = await createPage(api, st.ws!.id, { title: "Release Plan" });
344        pageId = p.id;
345        st.pageId = pageId;
346        await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
347        // The page title is an <input aria-label="Page title"> (not a heading)
348        // — page-view.tsx. Assert the rendered value.
349        await expect(page.getByLabel("Page title")).toHaveValue("Release Plan", { timeout: 20_000 });
350      });
351 
352      // 5 — editor: 5 blocks, drag the 3rd below the 1st; order persists.
353      const TEXTS = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
354      await test.step("5. editor: 5 blocks, DnD reorder (persists across reload)", async () => {
355        for (let i = 1; i <= 5; i++) {
356          const b = await apiCall(
357            api,
358            "POST",
359            `/api/v1/pages/${pageId}/blocks`,
360            { type: "paragraph", content: { text: TEXTS[i - 1] } },
361            `createBlock ${i}`,
362          );
363          expect([200, 201].includes(b.status), `block ${i} created`).toBe(true);
364        }
365        // The page renders a TITLE block as the first draggable row, then the
366        // five content blocks (Alpha…Echo). Read the ordered block ids from the
367        // API so the DnD target is the exact 3rd content block (Charlie).
368        const before = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json;
369        const ordered: { id: string; content?: { text?: string } }[] = before?.data ?? [];
370        const byText = (t: string) => ordered.find((b) => b.content?.text === t)?.id;
371        const aId = byText("Alpha");
372        const cId = byText("Charlie");
373        expect(aId && cId, "Alpha + Charlie blocks exist").toBeTruthy();
374        // DnD (the real drag-handle path, not the reorder menu). Uses the
375        // manual-mouse helper: Playwright's `dragTo()` does NOT trigger
376        // dnd-kit's PointerSensor drop in this app (no /move request fires),
377        // and the grip lives in a gutter that is pointer-events-none until the
378        // row is hovered — both handled inside `dragBlockAbove`.
379        await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
380        await dragBlockAbove(page, cId!, aId!);
381        await page.reload({ waitUntil: "domcontentloaded" });
382        // After `arrayMove(sorted, idx(Charlie), idx(Alpha))`: [title, Charlie,
383        // Alpha, Bravo, Delta, Echo]. Assert Charlie now precedes Alpha.
384        const after = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json;
385        const ids: string[] = (after?.data ?? []).map((b: { id: string }) => b.id);
386        expect(ids.indexOf(cId!), "Charlie present after reload").toBeGreaterThanOrEqual(0);
387        expect(
388          ids.indexOf(cId!) < ids.indexOf(aId!),
389          "Charlie (3rd) now precedes Alpha (1st) after reload",
390        ).toBe(true);
391      });
392 
393      // 6 — database "Tasks" (3 props: title/status/due) + 3 records (3 rows).
394      let taskId = "";
395      let titlePropId = "";
396      let statusPropId = "";
397      let duePropId = "";
398      await test.step("6. database 'Tasks' + 3 records (3 rows)", async () => {
399        // Create the database THROUGH the UI (slash → Add database → dialog).
400        await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
401        await slashPick(page, "database");
402        const insert = page.locator("#db-title");
403        await expect(insert).toBeVisible({ timeout: 10_000 });
404        await insert.fill("Tasks");
405        await page.getByRole("button", { name: "Create database" }).click();
406        // The insert dialog creates a database PAGE and inserts an inline
407        // DATABASE block that references it by `properties.databaseId` (the
408        // dialog passes `page.id` — a database page IS its database, id
409        // parity). It does NOT navigate, so the new table renders inline on
410        // THIS page. The table's DOM mount lags the API create (under the
411        // concurrent dev load the block-list refetch + DatabaseBlock
412        // hydration can take several seconds), so poll the API for the
413        // DATABASE block — the state the table renders — before gating the
414        // UI interactions on the table's own visibility.
415        await expect
416          .poll(async () => {
417            const blocks = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json;
418            const db = (blocks?.data ?? []).find((b: any) => b.type === "DATABASE");
419            return Boolean(db?.properties?.databaseId);
420          }, { timeout: 20_000 })
421          .toBe(true);
422        await expect(page.locator('[data-testid^="record-row-"], button[aria-label="Add property"]'))
423          .toBeVisible({ timeout: 20_000 });
424        // The db id is on the DATABASE block (properties.databaseId — the
425        // field the web's DatabaseBlock reads; the API seeds the page's
426        // database with that same id).
427        const dbBlocks = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json;
428        const dbBlock = (dbBlocks?.data ?? []).find((b: any) => b.type === "DATABASE");
429        taskId = dbBlock?.properties?.databaseId ?? "";
430        st.taskId = taskId;
431        expect(taskId, "Tasks database id resolved from the page's database block").toBeTruthy();
432 
433        // Add the "Due" DATE property. Title + Status are the seeded
434        // DEFAULT_DB_PROPERTIES. PRODUCT BUG (not owned here — apps/web): the
435        // web's PropertyEditor.save() posts `type` in LOWERCASE (e.g. "date")
436        // but the API's CreatePropertyDto validates `type: z.enum(PROPERTY_TYPES)`
437        // (UPPERCASE, e.g. "DATE") → the UI "Add property" 400s
438        // ("Invalid enum value. Expected 'TITLE' | 'TEXT' | ... received 'date'"),
439        // so the dialog stays open and the property is never created. Create
440        // the property through the API (the wire contract the UI should use)
441        // instead — the 3 records below still go through the real UI.
442        const existing = await listProperties(api, taskId);
443        duePropId = existing.find((p) => p.name === "Due")?.id ?? "";
444        if (!duePropId) {
445          const due = await apiCall(
446            api,
447            "POST",
448            `/api/v1/databases/${taskId}/properties`,
449            { name: "Due", type: "DATE", config: {}, isVisible: true, required: false, default: null },
450            "Due property",
451          );
452          expect(due.status, `Due DATE property created: ${JSON.stringify(due.json)}`).toBe(201);
453          duePropId = due.json?.id ?? "";
454        }
455        const propsAfter = await listProperties(api, taskId);
456        titlePropId = propsAfter.find((p) => p.isTitle || p.type === "TITLE")?.id ?? "";
457        statusPropId = propsAfter.find((p) => p.type === "SELECT")?.id ?? "";
458        duePropId = propsAfter.find((p) => p.name === "Due")?.id ?? "";
459        expect(titlePropId && statusPropId && duePropId, "Title + Status + Due props exist").toBeTruthy();
460 
461        // Three records via the UI (add-row → title cell → type → Enter).
462        // `addRow` seeds the title "New record" (table-view addRow). The
463        // title cell is a single click-to-edit <button> whose aria-label
464        // ends with "(click to edit)"; the first such cell in a row is the
465        // title column (first data column after the checkbox).
466        const titleCellWrapper = (row: import("@playwright/test").Locator) =>
467          row.locator("button[aria-label$='cell (click to edit)']").first();
468        for (const title of ["Ship release", "Write notes", "Tag v1.0.0"]) {
469          // The record list refetches on every commit and the NEW row is
470          // appended (position-asc → last). Wait for the count to actually
471          // grow before targeting `.last()` — otherwise iterations 2+ open the
472          // *previous* row's editor and race the fill (the row we want to edit
473          // isn't the last one yet when the prior commit's refetch lands).
474          const rowsBefore = await page.locator('[data-testid^="record-row-"]').count();
475          await page.getByTestId("add-row").click();
476          await expect
477            .poll(() => page.locator('[data-testid^="record-row-"]').count(), { timeout: 10_000 })
478            .toBeGreaterThan(rowsBefore);
479          const newRow = page.locator('[data-testid^="record-row-"]').last();
480          await expect(newRow).toBeVisible({ timeout: 10_000 });
481          const cellBtn = titleCellWrapper(newRow);
482          await expect(cellBtn).toBeVisible({ timeout: 10_000 });
483          await cellBtn.focus();
484          await page.keyboard.press("Enter");
485          const cellInput = newRow.locator('input[placeholder="Add a title…"]').first();
486          await expect(cellInput).toBeVisible({ timeout: 5_000 });
487          await cellInput.fill(title);
488          await page.keyboard.press("Enter");
489          await sleep(500);
490        }
491        // 3 rows render + the API agrees (records are keyed by property id).
492        const rows = page.locator('[data-testid^="record-row-"]');
493        await expect(rows).toHaveCount(3, { timeout: 15_000 });
494        const recs = (await apiCall(api, "GET", `/api/v1/databases/${taskId}/records`, undefined, "records")).json;
495        expect(recs?.data?.length, "3 records exist").toBe(3);
496      });
497 
498      // 7 — relation Tasks→People + the isOverdue formula (value true via API).
499      await test.step("7. relation + formula (value via API)", async () => {
500        const peoplePage = await apiCall(
501          api,
502          "POST",
503          `/api/v1/workspaces/${st.ws!.id}/pages`,
504          { title: "People", type: "database" },
505          "peoplePage",
506        );
507        st.peopleId = peoplePage.json?.databaseId ?? "";
508        expect(st.peopleId, "People database id").toBeTruthy();
509        const jane = await apiCall(api, "POST", `/api/v1/databases/${st.peopleId}/records`, { title: "Jane" }, "jane");
510        expect(jane.status, "Jane record created").toBe(201);
511        // Relation Tasks→People (dedicated endpoint; returns propertyId).
512        const rel = await apiCall(api, "POST", `/api/v1/databases/${taskId}/relations`, {
513          name: "Assignee",
514          targetDatabaseId: st.peopleId,
515          twoWay: true,
516        }, "relation");
517        expect([200, 201].includes(rel.status), `relation created: ${JSON.stringify(rel.json)}`).toBe(true);
518        st.relPropId = rel.json?.propertyId ?? "";
519        expect(st.relPropId, "relation propertyId").toBeTruthy();
520        // The isOverdue formula (the canonical expression; the engine cannot
521        // compare a SELECT value, so the status-based `IsDone` form is invalid).
522        const f = await apiCall(api, "POST", `/api/v1/databases/${taskId}/formulas`, {
523          name: "isOverdue",
524          expression: "dateBetween(prop(\"Due\"), now()) > 0",
525        }, "formula");
526        expect([200, 201].includes(f.status), `formula created: ${JSON.stringify(f.json)}`).toBe(true);
527        st.formulaId = f.json?.id ?? "";
528        expect(st.formulaId, "formula id").toBeTruthy();
529        // Make the first record overdue + linked to Jane (property-id keyed).
530        const recs = (await apiCall(api, "GET", `/api/v1/databases/${taskId}/records`, undefined, "records")).json;
531        const recA = (recs?.data ?? [])[0];
532        expect(recA?.id, "a record exists to evaluate the formula on").toBeTruthy();
533        // Non-null local so the computed key type-checks (relPropId is set +
534        // asserted truthy above).
535        const relKey = st.relPropId as string;
536        const upd = await apiCall(api, "PATCH", `/api/v1/records/${recA.id}`, {
537          properties: { [duePropId]: "2026-09-01" },
538          links: { [relKey]: [jane.json?.id] },
539        }, "recA update");
540        expect([200, 201].includes(upd.status), `record updated: ${JSON.stringify(upd.json)}`).toBe(true);
541        // The formula evaluates to `true` on the overdue record (engine endpoint).
542        const fv = await apiCall(api, "GET", `/api/v1/records/${recA.id}/formula/${st.formulaId}`, undefined, "formula eval");
543        expect(fv.status, `formula evaluation: ${JSON.stringify(fv.json)}`).toBe(200);
544        expect(fv.json?.value, "isOverdue formula value is true on the overdue record").toBe(true);
545        // The relation column renders in the UI (header + the Jane chip).
546        await goto(page, `/w/${st.ws!.slug}/databases/${taskId}`, { waitUntil: "domcontentloaded" });
547        const table = page.locator('[data-testid^="record-row-"]').first();
548        await expect(table).toBeVisible({ timeout: 15_000 });
549        const tableRegion = page.locator("#workspace-main");
550        await expect(tableRegion.getByText("Jane", { exact: true }).first()).toBeVisible({ timeout: 15_000 });
551      });
552 
553      // 8 — global search finds the page + a record (result links resolve).
554      await test.step("8. global search (page + record)", async () => {
555        await goto(page, `/w/${st.ws!.slug}/search`, { waitUntil: "domcontentloaded" });
556        // `exact: true` targets the search input (aria-label="Search") and not
557        // the toolbar button (aria-label="Search (Command K)") — a non-exact
558        // label match resolves to both (strict-mode violation).
559        const input = page.getByLabel("Search", { exact: true });
560        await expect(input).toBeVisible({ timeout: 15_000 });
561        // The search input debounces (225 ms); the worker indexes pages
562        // asynchronously, so bound-wait for the result row within ~30 s.
563        await input.fill("Release Plan");
564        const pageRow = page.getByTestId("search-result").filter({ hasText: "Release Plan" });
565        await expect(pageRow.first()).toBeVisible({ timeout: 30_000 });
566        await expect(pageRow.first().getByTestId("search-result-type-page")).toBeVisible();
567        // The page result deep-links to the page route.
568        await pageRow.first().click();
569        await expect(page).toHaveURL(new RegExp(`/w/${st.ws!.slug}/page/${pageId}`), { timeout: 15_000 });
570 
571        // A unique record title resolves to the record result → databases route.
572        await goto(page, `/w/${st.ws!.slug}/search`, { waitUntil: "domcontentloaded" });
573        const input2 = page.getByLabel("Search", { exact: true });
574        await expect(input2).toBeVisible({ timeout: 15_000 });
575        await input2.fill("Tag v1.0.0");
576        const recRow = page.getByTestId("search-result").filter({ hasText: "Tag v1.0.0" });
577        await expect(recRow.first()).toBeVisible({ timeout: 30_000 });
578        await expect(recRow.first().getByTestId("search-result-type-record")).toBeVisible();
579        await recRow.first().click();
580        await expect(page).toHaveURL(new RegExp(`/w/${st.ws!.slug}/databases/`), { timeout: 15_000 });
581      });
582 
583      // 9 — 10 MB file upload (metadata via API) — staging-only (S3/MinIO).
584      await stagingOnly("9. upload 10 MB file (metadata via API)", async () => {
585        // Probe S3/MinIO first: an egress-blocked dev box cannot store objects,
586        // so the upload would hang the presigned PUT — skip honestly.
587        let storageUp = false;
588        try {
589          const s3 = process.env.E2E_S3_ENDPOINT ?? "http://localhost:9000";
590          const h = await fetch(`${s3}/minio/health/live`);
591          storageUp = h.ok;
592        } catch {
593          storageUp = false;
594        }
595        if (!storageUp) {
596          test.info().annotations.push({ type: "env", description: "step9 skipped: S3/MinIO not reachable" });
597          return;
598        }
599        const bytes = Buffer.alloc(10 * 1024 * 1024);
600        for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251;
601        // Add a file block to the page, then upload through the real UI input.
602        await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
603        await slashPick(page, "file");
604        const fileInput = page.locator('[data-testid="file-input"]');
605        // The file block renders its input on insertion — wait for it before
606        // setting the file (avoids a race with the block's mount).
607        await expect(fileInput).toBeAttached({ timeout: 15_000 });
608        await fileInput.setInputFiles({ name: "release-assets.bin", mimeType: "application/octet-stream", buffer: bytes });
609        const card = page.locator('[data-testid^="file-card-"]').first();
610        await expect(card).toBeVisible({ timeout: 60_000 });
611        // Metadata via the API (size 10 MB + mime recorded, status READY).
612        const list = (await apiCall(api, "GET", "/api/v1/files", undefined, "files")).json;
613        const file = (list?.data ?? []).find((f: any) => f.name === "release-assets.bin");
614        expect(file, "the uploaded file is in the API file list").toBeTruthy();
615        expect(file.size, "file size is 10 MB").toBe(10 * 1024 * 1024);
616        expect(file.mimeType, "mime recorded").toBe("application/octet-stream");
617        expect(file.status, "file READY").toBe("READY");
618      });
619 
620      // 10 — public form: submit anonymously (record + form.submitted audit).
621      await stagingOnly("10. anonymous public form submit", async () => {
622        // Forms live on the database (POST /databases/:id/forms). One question,
623        // a SHORT_TEXT mapped to the title property → the record title is the
624        // answer (public-forms.service maps TITLE → record title).
625        const qLabel = "Release feedback?";
626        const form = await apiCall(api, "POST", `/api/v1/databases/${taskId}/forms`, {
627          name: "Release feedback",
628          slug: `release-feedback-${RUN_ID}`,
629          questions: [{ type: "SHORT_TEXT", label: qLabel, propertyId: titlePropId }],
630        }, "form");
631        expect(form.status, `form created: ${JSON.stringify(form.json)}`).toBe(201);
632        const formId = form.json?.id as string;
633        const qId = form.json?.questions?.[0]?.id as string;
634        expect(formId && qId, "form + question ids").toBeTruthy();
635        const pub = await apiCall(api, "PATCH", `/api/v1/forms/${formId}`, { status: "PUBLISHED" }, "publish");
636        expect(pub.status, `form published: ${JSON.stringify(pub.json)}`).toBe(200);
637        // Anonymous (context-isolated) submission through the real /f page.
638        const anon = await browser.newContext({ baseURL: WEB_BASE });
639        await stripCsp(anon);
640        const anonPage = await anon.newPage();
641        await goto(anonPage, `/f/release-feedback-${RUN_ID}`, { waitUntil: "domcontentloaded" });
642        await anonPage.getByLabel(qLabel).fill("Anonymous Fan");
643        await anonPage.getByRole("button", { name: "Submit" }).click();
644        await expect(anonPage.getByText(/Thanks|recorded/i).first()).toBeVisible({ timeout: 15_000 });
645        await anon.close();
646        // The record was created (title = the answer) + the audit fired.
647        const recs = (await apiCall(api, "GET", `/api/v1/databases/${taskId}/records`, undefined, "records")).json;
648        expect(
649          (recs?.data ?? []).some((r: any) => r.title === "Anonymous Fan"),
650          "the anonymous submission created a record",
651        ).toBe(true);
652        const audit = (await apiCall(api, "GET", `/api/v1/workspaces/${st.ws!.id}/audit`, undefined, "audit")).json;
653        expect(
654          (audit?.data ?? []).some((a: any) => a.action === "form.submitted"),
655          "a form.submitted audit row exists",
656        ).toBe(true);
657      });
658 
659      // 11 — automation: RECORD_CREATED(Tasks) → CREATE_NOTIFICATION (SUCCEEDED + Inbox).
660      await test.step("11. automation → in-app notify (SUCCEEDED)", async () => {
661        // CreateAutomationDto: top-level databaseId scopes it; triggers/actions
662        // are inline. CREATE_NOTIFICATION config requires { to, type, body }.
663        const auto = await apiCall(api, "POST", `/api/v1/workspaces/${st.ws!.id}/automations`, {
664          name: "Notify on new task",
665          databaseId: taskId,
666          triggers: [{ type: "RECORD_CREATED", config: { databaseId: taskId } }],
667          actions: [
668            { type: "CREATE_NOTIFICATION", config: { to: "automation_creator", type: "DATABASE_CHANGE", body: "new task recorded" } },
669          ],
670        }, "automation");
671        expect([200, 201].includes(auto.status), `automation created: ${JSON.stringify(auto.json)}`).toBe(true);
672        const autoId = auto.json?.id as string;
673        // Automations default to DRAFT — activate before it can fire.
674        const act = await apiCall(api, "POST", `/api/v1/automations/${autoId}/status`, { status: "ACTIVE" }, "activate");
675        expect([200, 204].includes(act.status), `automation activated: ${JSON.stringify(act.json)}`).toBe(true);
676        // Create a 4th record via the API → triggers RECORD_CREATED.
677        const created = await apiCall(api, "POST", `/api/v1/databases/${taskId}/records`, { title: "Trigger record" }, "trigger record");
678        expect(created.status, "trigger record created").toBe(201);
679        // Poll runs for a SUCCEEDED run for this trigger (listRuns → { data }).
680        let status = "";
681        const deadline = Date.now() + 40_000;
682        while (Date.now() < deadline) {
683          const runs = (await apiCall(api, "GET", `/api/v1/automations/${autoId}/runs?limit=1`, undefined, "runs")).json;
684          status = runs?.data?.[0]?.status ?? "";
685          if (status === "SUCCEEDED" || status === "FAILED" || status === "DEAD_LETTER") break;
686          await sleep(1000);
687        }
688        expect(status, "automation run reached a terminal state").not.toBe("");
689        expect(status, "automation run SUCCEEDED").toBe("SUCCEEDED");
690        // The notification is in the user's Inbox (body = the automation body).
691        let found = false;
692        const nDeadline = Date.now() + 15_000;
693        while (Date.now() < nDeadline && !found) {
694          const notes = (await apiCall(api, "GET", "/api/v1/notifications", undefined, "notifications")).json;
695          found = (notes?.data ?? []).some((n: any) => n.title === "Notify on new task" && n.body === "new task recorded");
696          if (!found) await sleep(1000);
697        }
698        expect(found, "the automation notification is in the Inbox").toBe(true);
699      });
700 
701      // 12 — AI: Ask AI on the page (mock, streamed + citation) — staging-only.
702      await stagingOnly("12. AI (mock) streamed answer + citation", async () => {
703        const QUESTION = "Summarize the content on this page";
704        const ECHO = QUESTION.slice(0, 40);
705        await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
706        await page.getByTestId("ask-ai-button").click();
707        const panel = page.getByTestId("ai-panel");
708        await expect(panel).toBeVisible({ timeout: 15_000 });
709        // Context badge = the page title (the AI is bound to this page).
710        await expect(page.getByTestId("ai-context-badge")).toContainText("Release Plan");
711        const input = page.getByTestId("ai-input");
712        await expect(input).toBeVisible();
713        await input.fill(QUESTION);
714        await page.getByTestId("ai-send").click();
715        // The LIVE bubble grows across ≥3 distinct incremental frames (the
716        // mock streams deltas; the "Thinking…" placeholder shares the testid).
717        const live = page.getByTestId("ai-message-live");
718        const seen = new Set<string>();
719        await expect
720          .poll(
721            async () => {
722              if ((await live.count()) > 0) {
723                const t = (await live.first().textContent())?.trim() ?? "";
724                if (t && !/thinking/i.test(t)) seen.add(t);
725              }
726              return seen.size;
727            },
728            { timeout: 20_000, intervals: [25] },
729          )
730          .toBeGreaterThanOrEqual(3);
731        const assistant = page.getByTestId("ai-message-assistant").last();
732        await expect(assistant).toBeVisible({ timeout: 20_000 });
733        await expect(assistant).toContainText(ECHO);
734 
735        // The /ask page (RAG): the mock answer cites the page; the [1] chip
736        // links to it (the same pattern ai-rag.spec.ts verifies against the
737        // real /ask surface).
738        await goto(page, `/w/${st.ws!.slug}/ask`, { waitUntil: "domcontentloaded" });
739        const askInput = page.getByTestId("ask-ai-input");
740        await expect(askInput).toBeVisible({ timeout: 15_000 });
741        await askInput.fill("What is the Release Plan page about?");
742        await page.getByTestId("ask-ai-submit").click();
743        const answer = page.getByTestId("ask-ai-answer");
744        await expect(answer).toContainText("Release Plan", { timeout: 30_000 });
745        await expect(page.getByTestId("ask-ai-sources")).toBeVisible();
746        await expect(page.getByTestId("ask-ai-source-1")).toBeVisible();
747        const chip = page.getByTestId("ask-ai-chip-1");
748        await expect(chip).toBeVisible();
749        const href = await chip.getAttribute("href");
750        expect(href, "the citation chip must be a link").toBeTruthy();
751        expect(href, `chip href should target the page (got ${href})`).toBe(`/w/${st.ws!.slug}/page/${pageId}`);
752      });
753 
754      // 13 — API key (UI reveal) + signed webhook (HMAC) + logout.
755      await test.step("13. API key + signed webhook (HMAC) + logout", async () => {
756        // Create the API key THROUGH the UI and read the raw key from the
757        // one-time reveal dialog (the key is never retrievable after).
758        await goto(page, `/w/${st.ws!.slug}/settings/developers`, { waitUntil: "domcontentloaded" });
759        // A fresh workspace has no keys → the empty-state "Create API key"
760        // button (apikey-empty-create). Click it via the element's real
761        // `click()` (no coordinates) to stay immune to the hit-test flakes
762        // the webhooks suite documents for this settings page.
763        const createBtn = page.getByRole("button", { name: "Create API key" }).first();
764        await expect(createBtn).toBeVisible({ timeout: 15_000 });
765        await createBtn.click().catch(async () => {
766          await expect(createBtn).toBeAttached();
767          await createBtn.evaluate((el) => (el as HTMLElement).click());
768        });
769        const create = page.getByRole("dialog", { name: "Create API key" });
770        await expect(create).toBeVisible({ timeout: 10_000 });
771        await create.locator("#apikey-name").fill("final-e2e-key");
772        // Scopes: READ + WRITE (API_KEY_SCOPES order: READ, WRITE, ADMIN; the
773        // checkboxes are label-wrapped, so select by accessible name).
774        await create.getByRole("checkbox", { name: /READ/ }).check();
775        await create.getByRole("checkbox", { name: /WRITE/ }).check();
776        await create.getByRole("button", { name: "Create key" }).click();
777        const reveal = page.locator("#apikey-raw");
778        await expect(reveal).toBeVisible({ timeout: 10_000 });
779        const rawKey = (await reveal.inputValue().catch(() => "")).trim();
780        expect(/^fk_live_/.test(rawKey), `the raw API key is revealed (got ${rawKey.slice(0, 12)}…)`).toBe(true);
781        st.apiKey = { id: "", key: rawKey };
782        // The reveal gate: tick "I have stored this key securely" + confirm.
783        await page.getByTestId("apikey-stored-confirm").check();
784        await page.getByRole("button", { name: "I have stored it" }).click();
785        await expect(reveal).toHaveCount(0);
786 
787        // In-process webhook receiver (same pattern as webhooks.spec.ts). Two
788        // separate promises: `started` resolves when the server is listening
789        // (so `st.webhook.url` is set before we register), and `delivery`
790        // resolves on the FIRST request (the actual page.created event).
791        const started = new Promise<void>((resolve) => {
792          const server = createServer((req, res) => {
793            const chunks: Buffer[] = [];
794            req.on("data", (c) => chunks.push(c));
795            req.on("end", () => {
796              res.writeHead(200).end("ok");
797              resolveDelivery({
798                headers: req.headers as Record<string, string>,
799                body: Buffer.concat(chunks).toString("utf8"),
800              });
801            });
802          });
803          st.server = server;
804          server.listen(0, "127.0.0.1", () => {
805            const a = server.address();
806            const port = typeof a === "object" && a ? a.port : 0;
807            st.webhook = { url: `http://127.0.0.1:${port}/hook`, secret: `whsec_${RUN_ID}` };
808            resolve();
809          });
810        });
811        let resolveDelivery: (d: { headers: Record<string, string>; body: string }) => void = () => {};
812        const delivery = new Promise<{ headers: Record<string, string>; body: string }>((r) => {
813          resolveDelivery = r;
814        });
815        await started;
816        const wh = await apiCall(api, "POST", `/api/v1/workspaces/${st.ws!.id}/webhooks`, {
817          url: st.webhook!.url,
818          secret: st.webhook!.secret,
819          events: ["page.created"],
820        }, "webhook");
821        expect([200, 201].includes(wh.status), `webhook registered: ${JSON.stringify(wh.json)}`).toBe(true);
822 
823        // Create a page with the API key (Bearer fk_live → 201).
824        const created = await fetch(`${API_BASE}/api/v1/workspaces/${st.ws!.id}/pages`, {
825          method: "POST",
826          headers: { "Content-Type": "application/json", Authorization: `Bearer ${rawKey}` },
827          body: JSON.stringify({ title: "Webhook trigger page" }),
828        });
829        expect(created.status, "fetch with the API key → 201").toBe(201);
830 
831        // The receiver gets page.created ≤ 15 s with a valid signature.
832        const got = await Promise.race([
833          delivery,
834          new Promise<never>((_, rej) => setTimeout(() => rej(new Error("webhook delivery > 15 s")), 15_000)),
835        ]);
836        const parsed = JSON.parse(got.body) as { type?: string };
837        // The delivery envelope carries the event name in `type` (docs/api/
838        // webhooks.md §4 — `{ id, type, workspaceId, occurredAt, data }`);
839        // the implementation's `buildDeliveryEnvelope` matches, and there is
840        // no `event` field.
841        expect(parsed.type, "the webhook payload carries the event").toBe("page.created");
842        const sig = got.headers["x-factory-signature"] ?? "";
843        const t = /t=(\d+)/.exec(sig)?.[1] ?? "";
844        const v1 = /v1=([0-9a-f]+)/.exec(sig)?.[1] ?? "";
845        expect(t, "signature carries a timestamp").toBeTruthy();
846        const expectV1 = createHmac("sha256", st.webhook!.secret).update(`${t}.${got.body}`).digest("hex");
847        expect(
848          v1.length === expectV1.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expectV1)),
849          "HMAC v1 matches the recomputed signature",
850        ).toBe(true);
851 
852        // Log out via the real UI (revokes the refresh token + clears the
853        // cookie), then prove the session is gone: a refresh from the
854        // cookie-carrying browser context must 401. (Refreshing from the
855        // Bearer-only `api` context would be vacuous — it never had the cookie.)
856        //
857        // The URL is ABSOLUTE (`${API_BASE}`): `context.request` is bound to
858        // the browser context's baseURL — `WEB_BASE` (:3000, the Next app) —
859        // and a relative `/api/v1/auth/refresh` would hit the web origin,
860        // which has no such route and 307s to /login (→ 200). The API lives
861        // at `API_BASE` (:4000); `context.request` still shares the context's
862        // cookies, so the (now-cleared) refresh cookie flow is what we test.
863        //
864        // Mobile: the user menu (and its logout button) lives in the sidebar
865        // DRAWER (docs/product/ux-requirements.md — the sidebar is a drawer
866        // below the mobile breakpoint, closed by default). A real user opens
867        // the drawer with the topbar hamburger first; the desktop column is
868        // always rendered, so no opener is needed there.
869        const vp = page.viewportSize() ?? { width: 1280, height: 800 };
870        if (vp.width < 768) {
871          // The drawer is a `role="dialog"` overlay; scope to it because BOTH
872          // the drawer column and the (display:none) desktop column render a
873          // `user-menu-logout` — unscoped locators would trip strict mode.
874          const drawer = page.getByRole("dialog", { name: "Navigation" });
875          await page.getByRole("button", { name: "Toggle sidebar" }).click();
876          await expect(drawer.getByTestId("user-menu-logout")).toBeVisible({ timeout: 10_000 });
877          await drawer.getByTestId("user-menu-logout").click();
878        } else {
879          await page.getByTestId("user-menu-logout").click();
880        }
881        await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
882        await sleep(1000); // let the revocation commit server-side
883        const refresh = await context.request.post(`${API_BASE}/api/v1/auth/refresh`, { data: {} });
884        expect(refresh.status(), "refresh after logout → 401 (session revoked)").toBe(401);
885      });
886    } finally {
887      st.server?.close();
888      st.server?.closeAllConnections?.();
889      await api.dispose().catch(() => {});
890      // Bounded teardown: a wedged network (e.g. a dropped dev-server chunk)
891      // must not hang the runner's process-exit path.
892      await Promise.race([
893        context.close().catch(() => {}),
894        sleep(15_000).then(() => context.close({ reason: "teardown-timeout" }).catch(() => {})),
895      ]);
896    }
897  });
898});
899