.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
files-security.integration.test.tstests/integration/files-security.integration.test.tsraw← integration44,130 B← Back to the app
1/**
2 * AI Software Factory — Integration: FILES SECURITY SUITE (Phase 9, TASK-0105)
3 *
4 * The Phase-9 exit criteria + the file security model, asserted against the
5 * REAL stack the same way the TASK-0104 lifecycle spec does: NestJS API
6 * (Testcontainers Postgres/Redis) + the worker's files pipeline + search-index
7 * processor driven IN-PROCESS against the container DB + the host MinIO
8 * (+ gated host OpenSearch).
9 *
10 * Scenarios (docs/tasks/phase-09-files.md TASK-0105, one `it` per requirement):
11 *
12 *   S1  500 MB paid upload — a SPARSE 500,000,000-byte file (one 4 KB block
13 *       on disk, tests/fixtures/files/make-sparse.ts) uploads through the
14 *       real presigned PUT and reaches READY with the EXACT size (the ROADMAP
15 *       exit criterion "upload of a 500 MB file works (multipart)").
16 *   S2  MIME spoof — text bytes declared `image/png` → `MIME_MISMATCH` at
17 *       complete, status FAILED, the object DELETED from the bucket, audit
18 *       `file.mime_mismatch`. (CONFLICT recorded in the task file: the spec
19 *       line said 422; the implemented contract is 415 — AGENTS.md rule 29.)
20 *   S3  Path traversal — a property-style sweep over 200 generated malicious
21 *       names: every stored object key is `files/{ws}/{fileId}/{v}/<safe>`
22 *       (no `..`, no leading `/`, no NUL, no backslash) + two REAL uploads
23 *       prove the DB key, the bucket key, and the wire (no raw key leak).
24 *   S4  Unauthorized-access matrix — 6 cases with per-case audit assertions
25 *       (actor is the requesting user, never "system"; IP present):
26 *         a. non-member GET metadata → 403
27 *         b. non-member download     → 403 + file.download_denied
28 *         c. member, no access to the page → 403 (+ download denial audit)
29 *         d. explicit file VIEW grant to a non-member → 200 / 302
30 *         e. grant revoked → 403 + denial audit (search doc unaffected —
31 *            file-level grants are read-time only; rule-29 note)
32 *         f. signed URL after 900 s → 403 from storage; signed URL for file
33 *            A used against file B's key → 403 (key binding, TASK-0101)
34 *
35 * Reporting (TASK-0105 §7): the suite writes
36 * `tests/e2e/reports/files-security.json` (scenarios, pass/fail, timing) for
37 * CI artifacts. A performance guard fails the run when the total suite
38 * runtime exceeds the 15-minute budget (CI-regression canary; the 500 MB
39 * upload is included in the budget).
40 */
41import { afterAll, beforeAll, describe, expect, it } from "vitest";
42import path from "node:path";
43import os from "node:os";
44import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
45import { fileURLToPath } from "node:url";
46import {
47  S3Client as StorageS3Client,
48  buildFileKey,
49  sanitizeFilename,
50  presignGet,
51  type S3Config as StorageS3Config,
52} from "@factory/storage";
53import { SearchClient } from "@factory/search";
54import { PrismaClient } from "@prisma/client";
55import { Redis } from "ioredis";
56import { createTestApp, type TestAppHandle } from "./helpers/api";
57import { getDatabaseUrl, getRedisUrl } from "./helpers/containers";
58import { authHeaders, createUser, createWorkspace, addMemberViaSeam } from "./helpers/seed";
59import { makeSparse500, SPARSE_500_MB } from "../fixtures/files/make-sparse";
60 
61const HERE = path.dirname(fileURLToPath(import.meta.url));
62const REPORT_DIR = path.resolve(HERE, "..", "e2e", "reports");
63const REPORT_PATH = path.join(REPORT_DIR, "files-security.json");
64 
65// The worker's files pipeline + storage client + search-index processor, from
66// SOURCE (same convention as the TASK-0104 lifecycle spec).
67const { runFilesPipeline } = await import(
68  /* webpackIgnore: true */
69  fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "files", "processor.ts"))
70);
71const { S3Client: WorkerS3Client } = await import(
72  /* webpackIgnore: true */
73  fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "lib", "s3.ts"))
74);
75const { handleSearchEvent, runReindex } = await import(
76  /* webpackIgnore: true */
77  fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "search-index", "processor.ts"))
78);
79 
80function fileUrl(p: string): string {
81  return `file://${p.startsWith("/") ? p : `/${p}`}`;
82}
83 
84// ── host gates ──────────────────────────────────────────────────────────────
85 
86const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, "");
87const SEARCH_URL = process.env.SEARCH_URL ?? "http://localhost:9200";
88const S3_ACCESS = process.env.S3_ACCESS_KEY ?? "minioadmin";
89const S3_SECRET = process.env.S3_SECRET_KEY ?? "minioadmin";
90const S3_REGION = process.env.S3_REGION ?? "us-east-1";
91const FILES_BUCKET = process.env.S3_BUCKET_FILES ?? "factory-files";
92const THUMBS_BUCKET = process.env.S3_BUCKET_THUMBS ?? "factory-thumbs";
93 
94async function httpUp(url: string): Promise<boolean> {
95  try {
96    const r = await fetch(url, { signal: AbortSignal.timeout(3_000) });
97    return r.ok;
98  } catch {
99    return false;
100  }
101}
102const minioReady = await httpUp(`${S3_ENDPOINT}/minio/health/live`);
103const searchReady = await httpUp(`${SEARCH_URL}/_cluster/health`);
104 
105// ── fixtures ────────────────────────────────────────────────────────────────
106 
107/** 1×1 red pixel, 67 bytes — a REAL png (magic bytes verify on both sides). */
108const PNG_BYTES = new Uint8Array([
109  0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
110  0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
111  0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
112  0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
113  0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
114  0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
115  0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
116  0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
117  0x44, 0xae, 0x42, 0x60,
118]);
119const TXT_BYTES = new Uint8Array(
120  Buffer.from("this is not a png — plain ascii text for the spoof/traversal tests", "utf8"),
121);
122 
123// ── state ───────────────────────────────────────────────────────────────────
124 
125let app: TestAppHandle;
126let prisma: PrismaClient;
127let redis: Redis;
128let workerFiles: InstanceType<typeof WorkerS3Client>;
129let workerThumbs: InstanceType<typeof WorkerS3Client>;
130let storageFiles: InstanceType<typeof StorageS3Client>;
131let search: SearchClient | null;
132let env: import("../../apps/worker/src/config").WorkerEnv;
133let tempDir: string;
134 
135// ── report collector (TASK-0105 §7) ─────────────────────────────────────────
136 
137interface ScenarioResult {
138  scenario: string;
139  passed: boolean;
140  durationMs: number;
141}
142const scenarioResults: ScenarioResult[] = [];
143const suiteStart = Date.now();
144 
145function scenario(id: string, fn: () => Promise<void>): () => Promise<void> {
146  return async () => {
147    const t0 = Date.now();
148    let passed = false;
149    try {
150      await fn();
151      passed = true;
152    } finally {
153      scenarioResults.push({ scenario: id, passed, durationMs: Date.now() - t0 });
154    }
155  };
156}
157 
158// ── helpers ─────────────────────────────────────────────────────────────────
159 
160const json = async (res: Response): Promise<Record<string, unknown>> =>
161  (await res.json().catch(() => ({}))) as Record<string, unknown>;
162const errCode = (body: Record<string, unknown>): string =>
163  ((body.error as { code?: string } | undefined)?.code ?? "");
164 
165interface Ws {
166  headers: Record<string, string>;
167  workspaceId: string;
168  userId: string;
169}
170async function newWorkspace(name = "Sec WS"): Promise<Ws> {
171  const user = await createUser(app.request, { name: "Sec Owner" });
172  const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), name);
173  return {
174    headers: { ...authHeaders(user.accessToken!), "x-factory-workspace": workspaceId },
175    workspaceId,
176    userId: user.userId,
177  };
178}
179 
180interface Upload {
181  file: { id: string; name: string; status?: string };
182  uploadUrl: string;
183  method: string;
184  headers: Record<string, string>;
185  expiresAt: string;
186}
187async function openUpload(
188  headers: Record<string, string>,
189  body: Record<string, unknown>,
190): Promise<{ status: number; upload?: Upload; raw: Record<string, unknown> }> {
191  const res = await app.request("POST", "/api/v1/files/uploads", { headers, body });
192  const raw = await json(res);
193  if (res.status !== 201) return { status: res.status, raw };
194  return { status: 201, upload: raw.upload as Upload, raw };
195}
196async function putToUrl(url: string, bytes: Uint8Array, headers: Record<string, string>): Promise<Response> {
197  return fetch(url, { method: "PUT", headers, body: bytes as unknown as BodyInit });
198}
199async function complete(headers: Record<string, string>, uploadId: string) {
200  const res = await app.request("PUT", `/api/v1/files/uploads/${uploadId}/complete`, { headers, body: {} });
201  return { status: res.status, body: await json(res) };
202}
203async function fileRow(fileId: string) {
204  return app.prisma.file.findUnique({ where: { id: fileId } });
205}
206 
207/** Drive the in-process files pipeline to a terminal state. No scanner is
208 *  configured here (the CLAMAV half of S2's EICAR story is covered by the
209 *  TASK-0102 pipeline spec + the E2E virus card); this keeps the 500 MB run
210 *  deterministic — the download+metadata+finalize steps are what matter. */
211async function runPipeline(fileId: string, version: number, workspaceId: string) {
212  return runFilesPipeline(
213    { prisma, s3: workerFiles, s3Thumbs: workerThumbs, redis, env },
214    { fileId, version, workspaceId },
215  );
216}
217async function toReady(fileId: string, version: number, workspaceId: string): Promise<void> {
218  const res = await runPipeline(fileId, version, workspaceId);
219  expect(res.kind, `pipeline ended ${res.kind}`).toBe("ready");
220  const row = await fileRow(fileId);
221  expect(row?.status).toBe("READY");
222}
223 
224/** Full happy path: open + PUT + complete + pipeline → READY. */
225async function uploadToReady(
226  headers: Record<string, string>,
227  name: string,
228  mimeType: string,
229  bytes: Uint8Array,
230  extra: Record<string, unknown> = {},
231): Promise<string> {
232  const { status, upload } = await openUpload(headers, { name, mimeType, size: bytes.length, ...extra });
233  expect(status, `session: ${JSON.stringify(extra)}`).toBe(201);
234  const put = await putToUrl(upload!.uploadUrl, bytes, upload!.headers);
235  expect(put.status).toBe(200);
236  const c = await complete(headers, upload!.file.id);
237  expect(c.status).toBe(200);
238  await toReady(upload!.file.id, 1, headers["x-factory-workspace"]!);
239  return upload!.file.id;
240}
241 
242/** All object keys under a prefix in the files bucket. */
243async function keysIn(prefix: string): Promise<string[]> {
244  const { keys } = await storageFiles.listObjects(prefix, 1000);
245  return keys;
246}
247 
248/** An audit row for `action` + `resourceId`, asserting the ACTOR is the
249 *  requesting user (no "system" misattribution) and the IP is present. */
250async function assertAudit(action: string, resourceId: string, actorId?: string): Promise<void> {
251  const row = await app.prisma.auditLog.findFirst({
252    where: { action, resourceId },
253    orderBy: { createdAt: "desc" },
254  });
255  expect(row, `audit row ${action} for ${resourceId}`).not.toBeNull();
256  expect(row!.actorId, `${action}: actorId`).toBeTruthy();
257  expect(row!.actorId).not.toBe("system");
258  if (actorId) expect(row!.actorId, `${action}: actor is the requesting user`).toBe(actorId);
259  expect(row!.ipAddress, `${action}: ip present`).toBeTruthy();
260}
261 
262async function searchEvent(event: string, payload: Record<string, unknown>): Promise<void> {
263  if (!search) return;
264  await handleSearchEvent({ search, prisma, connection: redis, env }, { event, payload });
265  // The bulk index has no `refresh` and the index refresh_interval is 1 s —
266  // force one so immediate `_mget` assertions are deterministic (rule 25).
267  await search.refresh();
268}
269async function docInSearch(docId: string): Promise<boolean> {
270  if (!search) return false;
271  const present = await search.docExists([docId]);
272  return present.has(docId);
273}
274function searchOps() {
275  return { search: search!, prisma, connection: redis, env };
276}
277 
278/** A signed GET the API would mint for `key` (same signer/config as the
279 *  API's `signedUrls`, exposed here for the expiry + key-binding cases). */
280function mintSignedGet(key: string, expiresSec: number, now = new Date()): string {
281  const config: StorageS3Config = {
282    endpoint: S3_ENDPOINT,
283    region: S3_REGION,
284    bucket: FILES_BUCKET,
285    accessKeyId: S3_ACCESS,
286    secretAccessKey: S3_SECRET,
287    forcePathStyle: true,
288  };
289  return presignGet({ config, key, expiresSec, now, responseContentDisposition: `attachment; filename="x"` }).url;
290}
291 
292/** Deterministic 200 hostile names (the spec's four + a seeded generator).
293 *  Pure function of `count` — identical input sequence every run (the spec's
294 *  "200 generated malicious filenames" property). The generator emits names
295 *  whose SAFE key always ends in a recognizable word (the "recognizability"
296 *  assertion below is exact, not heuristic). */
297function hostileNames(count: number): string[] {
298  const base = [
299    "../../../etc/cron.d/x",
300    "..\\..\\win.ini",
301    "a/../../b.png",
302    "bad\x00name.png",
303  ];
304  // mulberry32 — a tiny deterministic PRNG (seeded: same 200 names every run).
305  let a = 0x9e3779b9;
306  const rnd = () => {
307    a |= 0;
308    a = (a + 0x6d2b79f5) | 0;
309    let t = Math.imul(a ^ (a >>> 15), 1 | a);
310    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
311    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
312  };
313  const pick = <T>(arr: readonly T[]): T => arr[Math.floor(rnd() * arr.length)]!;
314  const dirs = ["..", ".", "/", "\\", "C:\\", "etc", "tmp", "\\\\server\\share"];
315  const words = ["win", "ini", "passwd", "payload", "secret", "data", "cron"];
316  const exts = [".png", ".txt", ".bin", "", ".", "..", ".PNG", ".dmp", ".tmp"];
317  const controls = ["", "\x00", "\x01\x02", "\r\n", "\t", "\x7f", "\u00e9\u00fc", "\x1b[31m"];
318  const out: string[] = [...base];
319  while (out.length < count) {
320    const depth = 1 + Math.floor(rnd() * 5);
321    let n = "";
322    for (let d = 0; d < depth; d++) n += pick(dirs);
323    n += pick(words) + pick(controls);
324    if (rnd() < 0.5) n += pick(["/", "\\"]);
325    n += pick(words) + pick(controls) + pick(exts);
326    if (!out.includes(n)) out.push(n);
327  }
328  return out.slice(0, count);
329}
330 
331/** The invariants every key must satisfy (spec req. 5). The traversal
332 *  property is SEGMENT-LEVEL: a `..` *path component* escapes the directory;
333 *  a name that merely contains the dot-dot SUBSTRING (`a..b`) is a single
334 *  harmless object name in our directory — S3 keys are flat, and S1–S4's
335 *  real-upload assertions prove the object physically lands in-bucket. */
336function assertSafeKey(key: string, workspaceId: string, name: string): void {
337  expect(key.startsWith(`files/${workspaceId}/`), `key escapes the workspace prefix: ${name} → ${key}`).toBe(true);
338  expect(key.includes("\x00"), `key contains NUL: ${name} → ${key}`).toBe(false);
339  expect(key.startsWith("/"), `key is absolute: ${name} → ${key}`).toBe(false);
340  expect(key.includes("\\"), `key contains backslash: ${name} → ${key}`).toBe(false);
341  const tail = key.slice(`files/${workspaceId}/`.length);
342  const segs = tail.split("/");
343  expect(segs.length, `key segment count for ${name}`).toBe(3); // fileId / version / name
344  for (const s of segs) {
345    expect(s, `empty segment for ${name}`).not.toBe("");
346    expect(s, `traversal segment for ${name} → ${key}`).not.toBe("..");
347    expect(s, `self-reference segment for ${name} → ${key}`).not.toBe(".");
348    expect(/[\u0000-\u001f\u007f]/.test(s), `control char in segment for ${name} → ${key}`).toBe(false);
349  }
350  const nameSeg = segs[2]!;
351  expect(nameSeg.length).toBeGreaterThan(0);
352  expect(nameSeg.length).toBeLessThanOrEqual(180);
353}
354 
355// ── suite ───────────────────────────────────────────────────────────────────
356 
357describe.skipIf(!minioReady)(
358  "Files security integration (TASK-0105 — 500MB, MIME spoof, traversal, unauthorized, signed URLs)",
359  () => {
360    beforeAll(async () => {
361      // S3 must be configured BEFORE createTestApp (it copies process.env).
362      app = await createTestApp();
363      const [dbUrl, redisUrl] = await Promise.all([getDatabaseUrl(), getRedisUrl()]);
364      prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } });
365      redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
366      const cfg = { endpoint: S3_ENDPOINT, region: S3_REGION, accessKey: S3_ACCESS, secretKey: S3_SECRET };
367      workerFiles = new WorkerS3Client({ ...cfg, bucket: FILES_BUCKET });
368      workerThumbs = new WorkerS3Client({ ...cfg, bucket: THUMBS_BUCKET });
369      const scfg: StorageS3Config = {
370        endpoint: S3_ENDPOINT,
371        region: S3_REGION,
372        bucket: FILES_BUCKET,
373        accessKeyId: S3_ACCESS,
374        secretAccessKey: S3_SECRET,
375        forcePathStyle: true,
376      };
377      storageFiles = new StorageS3Client({ config: scfg });
378      if (searchReady) search = new SearchClient({ baseUrl: SEARCH_URL });
379      tempDir = await mkdtemp(path.join(os.tmpdir(), "factory-files-sec-it-"));
380      // ≥1 GB free before the 500 MB download lands (the disk guard).
381      env = {
382        NODE_ENV: "test",
383        REDIS_URL: "ignored",
384        DATABASE_URL: "ignored",
385        S3_ENDPOINT,
386        S3_REGION,
387        S3_BUCKET_FILES: FILES_BUCKET,
388        S3_BUCKET_THUMBS: THUMBS_BUCKET,
389        S3_BUCKET: FILES_BUCKET,
390        S3_ACCESS_KEY: S3_ACCESS,
391        S3_SECRET_KEY: S3_SECRET,
392        S3_PUBLIC_BASE: "http://localhost:9001",
393        SEARCH_URL,
394        SEARCH_USERNAME: "admin",
395        SEARCH_PASSWORD: "changeme",
396        SEARCH_VECTOR_DIMENSION: 1536,
397        SEARCH_KNN_M: 16,
398        SEARCH_KNN_EF_CONSTRUCTION: 100,
399        MASTER_ENCRYPTION_KEY: "",
400        AI_API_KEY: "",
401        AI_PROVIDER: "openai",
402        AI_BASE_URL: "",
403        AI_MODEL: "gpt-4o-mini",
404        AI_EMBEDDING_PROVIDER: "",
405        AI_EMBEDDING_MODEL: "text-embedding-3-small",
406        SMTP_HOST: "",
407        SMTP_PORT: 587,
408        SMTP_USER: "",
409        SMTP_PASS: "",
410        SMTP_FROM: "noreply@factory.local",
411        SMTP_STARTTLS: true,
412        CLAMAV_HOST: "",
413        CLAMAV_PORT: 3310,
414        CLAMD_SOCKET: "", // deterministic READY (scan is TASK-0102's contract)
415        FILES_WORKER_CONCURRENCY: 4,
416        CLAMAV_TIMEOUT_MS: 30_000,
417        FILES_TEMP_DIR: tempDir,
418        FILES_TEMP_MAX_AGE_MS: 600_000,
419        FILES_TEMP_FREE_MIN_BYTES: 1024 ** 3,
420        PUBLIC_URL: "http://localhost:3000",
421        WORKER_CONCURRENCY: 10,
422        WORKER_ID: "files-security-test-worker",
423        WORKER_RETENTION_EXECUTE: false,
424        HEALTH_PORT: 4003,
425      } as unknown as import("../../apps/worker/src/config").WorkerEnv;
426    }, 240_000);
427    afterAll(async () => {
428      // Report artifact (TASK-0105 §7) — CI uploads tests/e2e/reports/**.
429      await mkdir(REPORT_DIR, { recursive: true }).catch(() => {});
430      const report = {
431        suite: "files-security (TASK-0105)",
432        generatedAt: new Date().toISOString(),
433        totalDurationMs: Date.now() - suiteStart,
434        budgetMs: 15 * 60 * 1000,
435        withinBudget: Date.now() - suiteStart <= 15 * 60 * 1000,
436        scenarios: scenarioResults,
437        passed: scenarioResults.length > 0 && scenarioResults.every((s) => s.passed),
438      };
439      await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), "utf8").catch(() => {});
440      await rm(tempDir, { recursive: true, force: true }).catch(() => {});
441      await redis?.quit().catch(() => {});
442      await prisma?.$disconnect().catch(() => {});
443      await app?.close();
444    }, 60_000);
445 
446    // ── S1. 500 MB paid upload (ROADMAP exit criterion) ────────────────────
447 
448    it(
449      "S1: 500 MB sparse upload on the paid plan → READY with exact size (multipart-friendly presigned PUT)",
450      scenario("S1-500MB-paid-upload", async () => {
451        const ws = await newWorkspace("500MB WS");
452        // Paid plan: 500 MiB per-file limit ≥ 500,000,000 bytes (the decimal
453        // spec ceiling — the free plan's 50 MiB would trip FILE_TOO_LARGE).
454        await app.prisma.workspace.update({
455          where: { id: ws.workspaceId },
456          data: { billingPlan: "paid" },
457        });
458 
459        // The sparse fixture: 500,000,000 LOGICAL bytes, one 4 KB block on
460        // disk (assert the sparseness — the whole point for CI runners).
461        const sparse = await makeSparse500();
462        try {
463          expect(sparse.logicalSize).toBe(SPARSE_500_MB);
464          expect(sparse.allocatedBytes, "sparse file allocates ~one block, not 500 MB").toBeLessThan(1024 * 1024);
465 
466          // Open the session for the EXACT 500 MB size.
467          const { status, upload } = await openUpload(ws.headers, {
468            name: "big.bin",
469            mimeType: "application/octet-stream",
470            size: SPARSE_500_MB,
471          });
472          expect(status, "the paid plan accepts a 500,000,000-byte session").toBe(201);
473          const fileId = upload!.file.id;
474 
475          // Stream the real 500 MB to the presigned PUT (the "multipart"
476          // story: one large signed object, no chunked-PUT API needed).
477          const put = await putToUrl(upload!.uploadUrl, sparse.bytes, upload!.headers);
478          expect(put.status, `presigned PUT returned ${put.status}`).toBe(200);
479 
480          const c = await complete(ws.headers, fileId);
481          expect(c.status).toBe(200);
482          await toReady(fileId, 1, ws.workspaceId);
483 
484          // EXACT size end to end (DB row + the object in the bucket).
485          const row = (await fileRow(fileId))!;
486          expect(row.size).toBe(SPARSE_500_MB);
487          expect(row.status).toBe("READY");
488          const head = await workerFiles.headObject(row.key);
489          expect(head.size).toBe(SPARSE_500_MB);
490          // The key follows the contract even for the boundary object.
491          assertSafeKey(row.key, ws.workspaceId, row.name);
492          // Audit: the session + upload are attributed to the actor.
493          await assertAudit("file.upload_session_created", fileId, ws.userId);
494          const uploaded = await app.prisma.auditLog.findFirst({
495            where: { action: "file.uploaded", resourceId: fileId },
496          });
497          expect(uploaded, "file.uploaded audit").not.toBeNull();
498          expect((uploaded!.metadata as { size?: number }).size).toBe(SPARSE_500_MB);
499        } finally {
500          await rm(sparse.path, { force: true }).catch(() => {});
501        }
502      }),
503      900_000,
504    );
505 
506    // ── S2. MIME spoof → FAILED + object deleted ───────────────────────────
507 
508    it(
509      "S2: MIME spoof (text bytes declared image/png) → MIME_MISMATCH, FAILED, object deleted from the bucket",
510      scenario("S2-mime-spoof-deleted", async () => {
511        const ws = await newWorkspace("Spoof WS");
512        // Declare a signature-bearing type (png), upload text bytes: the
513        // completion-time sniff verdict is "mismatch".
514        const { status, upload } = await openUpload(ws.headers, {
515          name: "evil.png",
516          mimeType: "image/png",
517          size: TXT_BYTES.length,
518        });
519        expect(status).toBe(201);
520        const fileId = upload!.file.id;
521        const key = (await fileRow(fileId))!.key;
522        expect(key).toBe(buildFileKey({ workspaceId: ws.workspaceId, fileId, version: 1, name: "evil.png" }));
523 
524        const put = await putToUrl(upload!.uploadUrl, TXT_BYTES, upload!.headers);
525        expect(put.status).toBe(200);
526        expect(await keysIn(`files/${ws.workspaceId}/${fileId}/`), "object landed (pre-complete)").toEqual([key]);
527 
528        // complete → the API sniffs the first 8 KiB → mismatch.
529        const c = await complete(ws.headers, fileId);
530        expect(c.status, `complete returned ${c.status}: ${JSON.stringify(c.body)}`).toBe(415);
531        expect(errCode(c.body)).toBe("MIME_MISMATCH");
532 
533        // Row FAILED (never SCANNING — the pipeline never runs on spoofed
534        // bytes) + the object is DELETED from the bucket (the bytes never
535        // become downloadable).
536        const row = (await fileRow(fileId))!;
537        expect(row.status).toBe("FAILED");
538        expect(row.metadata?.pipeline).toMatchObject({ error: "mime_mismatch" });
539        expect(await keysIn(`files/${ws.workspaceId}/${fileId}/`), "object deleted from the bucket").toEqual([]);
540        // Audit (actor = the uploader, IP present).
541        await assertAudit("file.mime_mismatch", fileId, ws.userId);
542        // The wire never exposed the key (the detail endpoint omits `key`).
543        const detail = await app.request("GET", `/api/v1/files/${fileId}`, { headers: ws.headers });
544        expect(detail.status).toBe(200);
545        expect((await detail.json()) as Record<string, unknown>).not.toHaveProperty("key");
546      }),
547      120_000,
548    );
549 
550    // ── S3. Path traversal property test (200 names) ───────────────────────
551 
552    it(
553      "S3: 200 malicious names → 100% keys within files/{ws}/ (no .., NUL, absolute paths) + real uploads",
554      scenario("S3-traversal-200-names", async () => {
555        const ws = await newWorkspace("Traversal WS");
556        const fileId = "fil_trav_test";
557 
558        // (a) property sweep over 200 generated hostile names: the stored key
559        //     (buildFileKey = exactly what create() stores) is always safe, and
560        //     the name segment keeps the BASENAME recognizable (spec: the key
561        //     "remains recognizable as the user's file", sanitized). The
562        //     basename is what a user perceives as the file name — the path
563        //     part IS the attack — so recognizability is asserted on it:
564        //     a 3+ char word of the basename survives into the safe segment,
565        //     or (a basename shorter than that, e.g. `x`) the safe segment
566        //     equals the fully sanitized basename. Deterministic, no sampling.
567        const names = hostileNames(200);
568        expect(names).toHaveLength(200);
569        let recognized = 0;
570        for (const name of names) {
571          const key = buildFileKey({ workspaceId: ws.workspaceId, fileId, version: 1, name });
572          assertSafeKey(key, ws.workspaceId, name);
573          const lastSeg = key.split("/").pop()!;
574          const lastSep = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
575          const basename = lastSep === -1 ? name : name.slice(lastSep + 1);
576          const runs = basename.match(/[A-Za-z0-9]+/g) ?? [];
577          const longest = runs.reduce((m, r) => (r.length > m.length ? r : m), "");
578          const ok =
579            longest.length >= 3
580              ? lastSeg.includes(longest)
581              : lastSeg === sanitizeFilename(basename);
582          if (ok) recognized++;
583        }
584        expect(recognized, "every name stays recognizable as its basename in the key").toBe(200);
585 
586        // The spec's explicit cases (pin the exact shapes):
587        expect(buildFileKey({ workspaceId: "w1", fileId: "f1", version: 1, name: "..\\..\\win.ini" })).toBe(
588          "files/w1/f1/1/win.ini",
589        );
590        expect(buildFileKey({ workspaceId: "w1", fileId: "f1", version: 1, name: "a/../../b.png" })).toBe(
591          "files/w1/f1/1/b.png",
592        );
593        const nulKey = buildFileKey({ workspaceId: "w1", fileId: "f1", version: 1, name: "bad\x00name.png" });
594        expect(nulKey).toBe("files/w1/f1/1/bad_name.png");
595        expect(nulKey).not.toContain("\x00");
596 
597        // (b) REAL uploads through the API: the DB key = the safe key, the
598        //     bucket object lives at that exact key, and the display name is
599        //     the original (sanitized key ≠ display name).
600        const hostile = ["../../../etc/cron.d/x.png", "C:\\..\\..\\win.ini.png"];
601        for (let i = 0; i < hostile.length; i++) {
602          const name = hostile[i]!;
603          const { status, upload } = await openUpload(ws.headers, {
604            name,
605            mimeType: "image/png",
606            size: PNG_BYTES.length,
607          });
608          expect(status, `session for hostile name ${JSON.stringify(name)}`).toBe(201);
609          const fId = upload!.file.id;
610          await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
611          const c = await complete(ws.headers, fId);
612          expect(c.status).toBe(200);
613          await toReady(fId, 1, ws.workspaceId);
614 
615          const row = (await fileRow(fId))!;
616          // DB key = the safe key for this name (deterministic → comparable).
617          expect(row.key).toBe(buildFileKey({ workspaceId: ws.workspaceId, fileId: fId, version: 1, name }));
618          assertSafeKey(row.key, ws.workspaceId, name);
619          // The object physically sits at that key in the bucket.
620          expect(await keysIn(`files/${ws.workspaceId}/${fId}/`)).toEqual([row.key]);
621          // Display name: the ORIGINAL name (recognizable), not the safe key.
622          expect(row.name).toBe(name);
623          const detail = (await json(
624            await app.request("GET", `/api/v1/files/${fId}`, { headers: ws.headers }),
625          )) as { name?: string };
626          expect(detail.name).toBe(name);
627        }
628      }),
629      180_000,
630    );
631 
632    // ── S4. Unauthorized-access matrix (6 cases) ───────────────────────────
633 
634    it(
635      "S4: unauthorized matrix — 6 cases with audit (non-member, page-scoped, grant, revoke, expiry, key-binding)",
636      scenario("S4-unauthorized-matrix", async () => {
637        const ws = await newWorkspace("Matrix WS");
638        // A page the member (B) is explicitly locked out of: a PAGE-level
639        // NONE grant (files inherit the page scope — the spec's case c).
640        const page = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, {
641          headers: ws.headers,
642          body: { title: "Matrix Page", parentId: null },
643        });
644        expect(page.status).toBe(201);
645        const pageId = ((await page.json()) as { id: string }).id;
646 
647        const fileId = await uploadToReady(
648          { ...ws.headers },
649          "matrix.png",
650          "image/png",
651          PNG_BYTES,
652          { pageId },
653        );
654        const ownerHeaders = ws.headers;
655        if (searchReady) {
656          await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId, pageId });
657          expect(await docInSearch(`file:${fileId}`)).toBe(true);
658        }
659 
660        const stranger = await createUser(app.request, { name: "Stranger" });
661        const strangerHeaders = { ...authHeaders(stranger.accessToken!), "x-factory-workspace": ws.workspaceId };
662        const member = await createUser(app.request, { name: "Member" });
663        await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER");
664        const memberHeaders = { ...authHeaders(member.accessToken!), "x-factory-workspace": ws.workspaceId };
665        // Lock member B out of the page (explicit NONE — the hard stop the
666        // file-read inheritance chain walks). DIRECT DB WRITE (the seed.ts
667        // convention: state the API cannot create — Phase 9 exposes only
668        // FILE-level grants, `PUT /files/:id/permissions`; page-level grants
669        // have no public route. The API's ENFORCEMENT of the row is what is
670        // under test here, and nothing bypasses authorization.)
671        await prisma.permission.create({
672          data: {
673            workspaceId: ws.workspaceId,
674            granteeType: "user",
675            granteeId: member.userId,
676            resourceType: "page",
677            resourceId: pageId,
678            level: "NONE",
679          },
680        });
681 
682        // (a) non-member GET metadata → 403.
683        const a = await app.request("GET", `/api/v1/files/${fileId}`, { headers: strangerHeaders });
684        expect(a.status).toBe(403);
685        expect(errCode(await json(a))).toBe("FORBIDDEN");
686 
687        // (b) non-member download → 403 + file.download_denied (actor+ip).
688        const b = await app.request("GET", `/api/v1/files/${fileId}/download`, {
689          headers: strangerHeaders,
690          redirect: "manual",
691        });
692        expect(b.status).toBe(403);
693        await assertAudit("file.download_denied", fileId, stranger.userId);
694 
695        // (c) member B, no access to the page → 403 (metadata) + a download
696        //     attempt audits the denial.
697        const c = await app.request("GET", `/api/v1/files/${fileId}`, { headers: memberHeaders });
698        expect(c.status, "page NONE hard-stops the member's inherited EDIT").toBe(403);
699        const c2 = await app.request("GET", `/api/v1/files/${fileId}/download`, {
700          headers: memberHeaders,
701          redirect: "manual",
702        });
703        expect(c2.status).toBe(403);
704        const cAudit = await app.prisma.auditLog.findFirst({
705          where: { action: "file.download_denied", resourceId: fileId },
706          orderBy: { createdAt: "desc" },
707        });
708        expect(cAudit?.actorId).toBe(member.userId);
709 
710        // (d) explicit FILE VIEW grant to the non-member → 200 (metadata) +
711        //     302 (download). The file grant has the highest specificity —
712        //     it grants despite the user having no workspace role.
713        const putGrant = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
714          headers: ownerHeaders,
715          body: { grants: [{ granteeType: "user", granteeId: stranger.userId, level: "VIEW" }] },
716        });
717        expect(putGrant.status).toBe(200);
718        const d = await app.request("GET", `/api/v1/files/${fileId}`, { headers: strangerHeaders });
719        expect(d.status, "file VIEW grant lets the non-member read").toBe(200);
720        const d2 = await app.request("GET", `/api/v1/files/${fileId}/download`, {
721          headers: strangerHeaders,
722          redirect: "manual",
723        });
724        expect(d2.status).toBe(302);
725        expect(d2.headers.get("location")).toContain("X-Amz-Signature=");
726        await assertAudit("file.downloaded", fileId, stranger.userId);
727 
728        // (e) grant revoked → 403 + denial audit. Search is UNCHANGED (the
729        //     rule-29 note: file-level grants are read-time only; search is
730        //     workspace/page-scoped — the doc stays indexed for members).
731        const putRevoke = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
732          headers: ownerHeaders,
733          body: { grants: [] },
734        });
735        expect(putRevoke.status).toBe(200);
736        const e = await app.request("GET", `/api/v1/files/${fileId}`, { headers: strangerHeaders });
737        expect(e.status).toBe(403);
738        const e2 = await app.request("GET", `/api/v1/files/${fileId}/download`, {
739          headers: strangerHeaders,
740          redirect: "manual",
741        });
742        expect(e2.status).toBe(403);
743        const eAudit = await app.prisma.auditLog.findFirst({
744          where: { action: "file.download_denied", resourceId: fileId },
745          orderBy: { createdAt: "desc" },
746        });
747        expect(eAudit?.actorId, "revoked download denial audited to the requester").toBe(stranger.userId);
748        if (searchReady) {
749          // The resync a permission.changed would run leaves the doc in
750          // place (workspace-floor visibility is unchanged by design).
751          await runReindex(searchOps(), ws.workspaceId);
752          await search.refresh(); // deterministic _mget (refresh_interval 1 s)
753          expect(await docInSearch(`file:${fileId}`), "search doc survives the revoke (read-time model)").toBe(true);
754        }
755 
756        // (f) signed URLs — expiry + key binding (TASK-0101 contract, minted
757        //     with the same signer/config the API uses).
758        const row = (await fileRow(fileId))!;
759        // fresh URL → 200 (sanity: the mint works).
760        const fresh = mintSignedGet(row.key, 900);
761        const f0 = await fetch(fresh);
762        expect(f0.status).toBe(200);
763        // expired (minted 900 s in the PAST) → 403 from storage.
764        const expired = mintSignedGet(row.key, 900, new Date(Date.now() - 901_000));
765        const f1 = await fetch(expired);
766        expect(f1.status, `expired URL → ${f1.status}`).toBe(403);
767        expect(await f1.text()).toMatch(/AccessDenied|ExpiredToken|SignatureDoesNotMatch/i);
768        // cross-key: file A's URL against file B's key → 403 (the signature
769        // covers the exact key — a leaked URL is useless on any other file).
770        // The signer encodes the key segment-wise into the path
771        // (`objectRequestParts`); cuid keys are unreserved, so the path
772        // carries the raw key followed by `?` — swap exactly that span.
773        const bFileId = await uploadToReady(ownerHeaders, "other.png", "image/png", PNG_BYTES, { pageId: null });
774        const bRow = (await fileRow(bFileId))!;
775        const aUrl = mintSignedGet(row.key, 900);
776        const cross = aUrl.replace(`/${row.key}?`, `/${bRow.key}?`);
777        expect(cross, "the path now carries file B's key").not.toBe(aUrl);
778        const f2 = await fetch(cross);
779        expect(f2.status, `cross-key URL → ${f2.status}`).toBe(403);
780        expect(await f2.text()).toMatch(/SignatureDoesNotMatch/i);
781        // And the legitimate URL for B's key works (it was signed for B).
782        const f3 = await fetch(mintSignedGet(bRow.key, 900));
783        expect(f3.status).toBe(200);
784      }),
785      300_000,
786    );
787 
788    // ── S5. EICAR quarantine (Requirement 3 / DoD item 3) ──────────────────
789 
790    it(
791      "S5: EICAR → VIRUS_DETECTED (ClamAV socket), no thumbs, no search doc, download 403 FILE_QUARANTINED + audit, detail 200 unsigned",
792      scenario("S5-eicar-quarantine", async () => {
793        // The 69-byte standard anti-virus test signature (SAFE — it is a
794        // known, non-malicious marker string ClamAV is shipped to detect).
795        const EICAR = Buffer.from(
796          "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*",
797          "latin1",
798        );
799        const ws = await newWorkspace("EICAR WS");
800        const { status, upload } = await openUpload(ws.headers, {
801          name: "eicar.txt",
802          mimeType: "text/plain",
803          size: EICAR.length,
804        });
805        expect(status).toBe(201);
806        const fileId = upload!.file.id;
807        await putToUrl(upload!.uploadUrl, new Uint8Array(EICAR), upload!.headers);
808        const c = await complete(ws.headers, fileId);
809        expect(c.status).toBe(200);
810 
811        // Drive the pipeline WITH the ClamAV socket (zCONTSCAN over the unix
812        // socket — the worker's production path; the host socket is the one
813        // the running worker is configured with).
814        const clamEnv = {
815          ...env,
816          CLAMD_SOCKET: "/var/run/clamav/clamd.ctl",
817          CLAMAV_HOST: "",
818          CLAMAV_PORT: 3310,
819        } as unknown as typeof env;
820        const res = await runFilesPipeline(
821          { prisma, s3: workerFiles, s3Thumbs: workerThumbs, redis, env: clamEnv },
822          { fileId, version: 1, workspaceId: ws.workspaceId },
823        );
824        expect(res.kind, `pipeline ended ${res.kind}`).toBe("virus");
825 
826        // Status + scan report (the engine REALLY scanned — not a dev-skip).
827        // The virus finalize writes `scanReport` to the TOP-LEVEL File column
828        // (not `metadata.pipeline`) + `metadata.pipeline.error = "virus_detected"`.
829        const row = (await fileRow(fileId))!;
830        expect(row.status).toBe("VIRUS_DETECTED");
831        const scan = (row.scanReport as Record<string, unknown> | null) ?? undefined;
832        expect(scan, "scanReport recorded (top-level File column)").toBeTruthy();
833        expect(scan?.engine).toBe("clamav");
834        expect(scan?.infected).toBe(true);
835        expect(String(scan?.signature ?? "")).toMatch(/Eicar/i);
836        expect(row.metadata).toMatchObject({ pipeline: { error: "virus_detected" } });
837        // Audit: the detection is attributed to the system (no actor) but the
838        // resource + workspace are recorded.
839        const virusAudit = await app.prisma.auditLog.findFirst({
840          where: { action: "file.virus_detected", resourceId: fileId },
841        });
842        expect(virusAudit, "file.virus_detected audit").not.toBeNull();
843 
844        // No thumbnails were generated (the virus path never reaches the
845        // thumbnail step) — assert the whole prefix is empty in the thumbs
846        // bucket.
847        const thumbCfg = {
848          endpoint: S3_ENDPOINT,
849          region: S3_REGION,
850          bucket: THUMBS_BUCKET,
851          accessKeyId: S3_ACCESS,
852          secretAccessKey: S3_SECRET,
853          forcePathStyle: true,
854        } as StorageS3Config;
855        const thumbsClient = new StorageS3Client({ config: thumbCfg });
856        const { keys: thumbKeys } = await thumbsClient.listObjects(
857          `thumbs/${ws.workspaceId}/${fileId}/`,
858          100,
859        );
860        expect(thumbKeys, "no thumbnail keys for a quarantined file").toEqual([]);
861 
862        // Absent from search: the virus path publishes `file.virus_detected`
863        // (never `file.uploaded`/`file.finalized`), and the search processor
864        // has no event that indexes a non-READY file — so no doc exists.
865        if (searchReady) {
866          expect(await docInSearch(`file:${fileId}`), "no search doc for a quarantined file").toBe(false);
867        }
868 
869        // The quarantined file is never signed: download → 403
870        // FILE_QUARANTINED + audit (actor = the requester, reason=quarantined);
871        // detail (allowQuarantined) → 200 with NO signed URLs.
872        const dl = await app.request("GET", `/api/v1/files/${fileId}/download`, {
873          headers: ws.headers,
874          redirect: "manual",
875        });
876        expect(dl.status, `quarantined download → ${dl.status}`).toBe(403);
877        expect(errCode(await json(dl))).toBe("FILE_QUARANTINED");
878        const denied = await app.prisma.auditLog.findFirst({
879          where: { action: "file.download_denied", resourceId: fileId },
880          orderBy: { createdAt: "desc" },
881        });
882        expect(denied, "file.download_denied audit").not.toBeNull();
883        expect(denied!.actorId).toBe(ws.userId);
884        expect((denied!.metadata as { reason?: string }).reason).toBe("quarantined");
885        const detailRes = await app.request("GET", `/api/v1/files/${fileId}`, { headers: ws.headers });
886        // The detail endpoint returns 200 for a quarantined file (allowQuarantined
887        // = the client renders the blocked card) but with NO signed URLs.
888        expect(detailRes.status, `quarantined detail → ${detailRes.status}`).toBe(200);
889        const detail = (await detailRes.json()) as { downloadUrl?: string; previewUrl?: string; status?: string };
890        expect(detail.status).toBe("VIRUS_DETECTED");
891        expect(detail.downloadUrl, "no signed download URL for a quarantined file").toBeUndefined();
892        expect(detail.previewUrl, "no signed preview URL for a quarantined file").toBeUndefined();
893      }),
894      120_000,
895    );
896 
897    // ── performance guard (suite < 15 min, 500 MB included) ────────────────
898 
899    it(
900      "PERF: total suite runtime within the 15-minute budget (500 MB upload included)",
901      scenario("perf-15min-budget", async () => {
902        const total = Date.now() - suiteStart;
903        expect(
904          total,
905          `suite took ${Math.round(total / 1000)} s — the 15-minute CI budget is a regression canary`,
906        ).toBeLessThanOrEqual(15 * 60 * 1000);
907      }),
908      30_000,
909    );
910  },
911);
912