.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.integration.test.tstests/integration/files.integration.test.tsraw← integration29,229 B← Back to the app
1/**
2 * AI Software Factory — Integration: Files (Phase 9, TASK-0101)
3 *
4 * The upload contract against the REAL stack: NestJS API + Testcontainers
5 * Postgres/Redis + the host MinIO (default S3_ENDPOINT http://localhost:9000,
6 * buckets `factory-files` / `factory-thumbs`). Per the spec (docs/tasks/
7 * phase-09-files.md, TASK-0101 §Acceptance) every scenario below is an
8 * acceptance criterion — not an implementation detail:
9 *
10 *   A. plan boundary — 60 MB → 413 FILE_TOO_LARGE (free) vs 201 (paid)
11 *   B. size mismatch — PUT ≠ declared size → 422 UPLOAD_INCOMPLETE, no READY row
12 *   C. content-type mismatch — the presigned PUT is SIGNED with the claimed
13 *      content-type; a different Content-Type → S3 403s the PUT before any
14 *      object exists → completion reports 410 UPLOAD_EXPIRED.
15 *      (REQUIREMENT CONFLICT — see the task file, recorded 2026-09-29: the
16 *       spec text says "wrong Content-Type … fails completion with
17 *       UPLOAD_INCOMPLETE/422"; because content-type is part of the
18 *       signature, S3 rejects first and completion can only observe an
19 *       absent object. The security property — the wrong-typed bytes never
20 *       become stored/downloadable — holds under either status.)
21 *   D. MIME spoof — text bytes under an image/png CLAIM pass S3 (the body
22 *      is UNSIGNED-PAYLOAD), then completion's authoritative magic-byte
23 *      sniff → 415 MIME_MISMATCH + the object is DELETED from the bucket
24 *      (asserted via an independent storage-client list) + row FAILED.
25 *   E. happy path — 1 KB PNG claimed image/png → 201 → SCANNING +
26 *      `file.uploaded` audit + spec key layout. (READY is TASK-0102.)
27 *   F. signed GET — member: 302 → URL that (a) works, (b) 403s with a
28 *      tampered signature, (c) 403s when reused against ANOTHER object's
29 *      key (key binding); non-member: 403 FORBIDDEN + `file.download_denied`.
30 *   G. signed-URL expiry — a REAL shortened signed window: PUT after expiry
31 *      → S3 403, completion → 410 UPLOAD_EXPIRED (the 900 s value is the
32 *      default; 2 s exercises the identical server-side path).
33 *   H. bucket privacy — unsigned GETs on raw keys → 403 in BOTH buckets.
34 *   I. safeName — the key for `../../etc/passwd` is the sanitized `passwd`
35 *      (the full matrix is unit-tested in @factory/storage keys.test.ts).
36 *
37 * The worker pipeline (SCANNING → READY) is TASK-0102; this spec asserts up
38 * to the SCANNING hand-off, which is this task's contract.
39 */
40import { afterAll, beforeAll, describe, expect, it } from "vitest";
41import path from "node:path";
42import { fileURLToPath, pathToFileURL } from "node:url";
43import { S3Client, type S3Config } from "@factory/storage";
44import { createTestApp, type TestAppHandle } from "./helpers/api";
45import { authHeaders, createWorkspace, createUser } from "./helpers/seed";
46 
47const HERE = path.dirname(fileURLToPath(import.meta.url));
48 
49// Env singleton reset (the API memoizes loadEnv(); test G needs a fresh
50// read with a shortened sign expiry). Import the SOURCE file via an
51// absolute file:// URL — the same pattern helpers/api.ts uses for the app
52// module — so vite resolves the SAME module instance the app uses.
53const { resetEnvCache } = await import(
54  /* webpackIgnore: true */
55  pathToFileURL(path.resolve(HERE, "..", "..", "apps", "api", "src", "config", "env.ts")).href
56);
57 
58// ── MinIO gate ──────────────────────────────────────────────────────────────
59 
60/** The object store the API talks to (the API's S3_ENDPOINT default). */
61const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, "");
62 
63async function minioUp(url: string): Promise<boolean> {
64  try {
65    const res = await fetch(`${url}/minio/health/live`, { signal: AbortSignal.timeout(4_000) });
66    return res.ok;
67  } catch {
68    return false;
69  }
70}
71 
72/** Collection-time gate: the acceptance criteria are untestable without the
73 *  object store, so (like the search spec) the whole suite skips cleanly. */
74const minioReady = await minioUp(S3_ENDPOINT);
75 
76// ── storage client (test-side; independent of the API's own client) ────────
77 
78const filesConfig: S3Config = {
79  endpoint: S3_ENDPOINT,
80  region: process.env.S3_REGION ?? "us-east-1",
81  bucket: process.env.S3_BUCKET_FILES ?? "factory-files",
82  accessKeyId: process.env.S3_ACCESS_KEY ?? "minioadmin",
83  secretAccessKey: process.env.S3_SECRET_KEY ?? "minioadmin",
84  forcePathStyle: true,
85};
86const thumbsConfig: S3Config = { ...filesConfig, bucket: process.env.S3_BUCKET_THUMBS ?? "factory-thumbs" };
87const filesClient = new S3Client({ config: filesConfig });
88 
89// ── fixtures ────────────────────────────────────────────────────────────────
90 
91/** 1×1 red pixel, 67 bytes — a REAL png signature (magic bytes verify). */
92const PNG_BYTES = new Uint8Array([
93  0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // signature
94  0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR
95  0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
96  0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
97  0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT
98  0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
99  0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
100  0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND
101  0x44, 0xae, 0x42, 0x60,
102]);
103 
104/** 1 KB of plain text — NO magic signature (the spoof fixture). */
105const TEXT_BYTES = new TextEncoder().encode(
106  "this is definitely a text file, not a png ".repeat(16).slice(0, 1024),
107);
108 
109const FREE_LIMIT = 50 * 1024 * 1024; // 50 MB
110const OVER_FREE = 60 * 1024 * 1024; // 60 MB — over free, under paid (500 MB)
111 
112// ── helpers ─────────────────────────────────────────────────────────────────
113 
114let app: TestAppHandle;
115 
116const json = async (res: Response): Promise<Record<string, unknown>> =>
117  (await res.json().catch(() => ({}))) as Record<string, unknown>;
118 
119const errCode = (body: Record<string, unknown>): string =>
120  ((body.error as { code?: string } | undefined)?.code ?? "");
121 
122/** A fresh user + workspace (owner = OWNER, billingPlan = "free").
123 *
124 *  `headers` binds to THIS workspace via `x-factory-workspace`: the auth
125 *  guard resolves the workspace from that header when present, otherwise from
126 *  `claims.workspaces[0]` — which is the FIRST membership a user accumulates
127 *  across a test run. Without the header, a second `newWorkspace()` for the
128 *  same user would silently route uploads to the first (free) workspace,
129 *  defeating plan-boundary tests. */
130async function newWorkspace(name = "Files WS"): Promise<{
131  headers: Record<string, string>;
132  workspaceId: string;
133  userId: string;
134}> {
135  const user = await createUser(app.request, { name: "File Owner" });
136  const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), name);
137  return {
138    headers: { ...authHeaders(user.accessToken!), "x-factory-workspace": workspaceId },
139    workspaceId,
140    userId: user.userId,
141  };
142}
143 
144/** Flip a workspace to a billing plan (test-side; the billing admin
145 *  endpoints are out of scope for TASK-0101). */
146async function setPlan(workspaceId: string, plan: string): Promise<void> {
147  await app.prisma.workspace.update({ where: { id: workspaceId }, data: { billingPlan: plan } });
148}
149 
150interface Upload {
151  file: { id: string; name: string; status?: string };
152  uploadUrl: string;
153  method: string;
154  headers: Record<string, string>;
155  expiresAt: string;
156}
157 
158/** Open an upload session; asserts 201 and returns the payload. */
159async function openUpload(
160  headers: Record<string, string>,
161  body: Record<string, unknown>,
162): Promise<{ status: number; upload?: Upload; raw: Record<string, unknown> }> {
163  const res = await app.request("POST", "/api/v1/files/uploads", { headers, body });
164  const raw = await json(res);
165  if (res.status !== 201) return { status: res.status, raw };
166  return { status: 201, upload: raw.upload as Upload, raw };
167}
168 
169/** PUT bytes to a presigned URL with EXACTLY the headers the API returned
170 *  (content-type is signed — do not add or change headers). */
171async function putToUrl(url: string, bytes: Uint8Array, headers: Record<string, string>): Promise<Response> {
172  return fetch(url, {
173    method: "PUT",
174    headers: headers as Record<string, string>,
175    body: bytes as unknown as BodyInit,
176  });
177}
178 
179async function complete(headers: Record<string, string>, uploadId: string): Promise<{ status: number; body: Record<string, unknown> }> {
180  const res = await app.request("PUT", `/api/v1/files/uploads/${uploadId}/complete`, { headers, body: {} });
181  return { status: res.status, body: await json(res) };
182}
183 
184async function fileRow(fileId: string) {
185  return app.prisma.file.findUnique({ where: { id: fileId } });
186}
187 
188async function cancel(headers: Record<string, string>, uploadId: string): Promise<number> {
189  const res = await app.request("POST", `/api/v1/files/uploads/${uploadId}/cancel`, { headers, body: {} });
190  return res.status;
191}
192 
193/** Does the key exist in the files bucket? */
194async function objectExists(key: string): Promise<boolean> {
195  const { keys } = await filesClient.listObjects(key, 5);
196  return keys.includes(key);
197}
198 
199/** Upload + PUT + complete a real PNG; returns the file id (the happy path). */
200async function uploadPng(headers: Record<string, string>, name = "pixel.png"): Promise<string> {
201  const { status, upload } = await openUpload(headers, {
202    name,
203    mimeType: "image/png",
204    size: PNG_BYTES.length,
205  });
206  expect(status).toBe(201);
207  const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
208  expect(put.status).toBe(200);
209  const c = await complete(headers, upload!.file.id);
210  expect(c.status).toBe(200);
211  return upload!.file.id;
212}
213 
214describe.skipIf(!minioReady)(
215  "Files integration (TASK-0101 — storage contract)",
216  () => {
217    beforeAll(async () => {
218      app = await createTestApp();
219      // The API reads S3_* from the environment; the defaults already point
220      // at the host MinIO with the two spec buckets (minio-init bootstrap).
221    }, 180_000);
222    afterAll(async () => {
223      await app?.close();
224    });
225 
226    // ── A. plan boundary (free 50 MB vs paid 500 MB) ────────────────────
227 
228    it("A: 60 MB session → 413 FILE_TOO_LARGE (free), 201 (paid) — plan boundary proven", async () => {
229      const { headers, workspaceId } = await newWorkspace("Plan Boundary WS");
230 
231      const free = await openUpload(headers, {
232        name: "big.bin",
233        mimeType: "application/octet-stream",
234        size: OVER_FREE,
235      });
236      expect(free.status).toBe(413);
237      expect(errCode(free.raw)).toBe("FILE_TOO_LARGE");
238      const details = (free.raw.error as { details?: { maxBytes?: number }[] } | undefined)?.details ?? [];
239      expect(details[0]?.maxBytes).toBe(FREE_LIMIT); // names 50 MB
240 
241      const row = await app.prisma.file.findFirst({ where: { workspaceId, name: "big.bin" } });
242      expect(row).toBeNull(); // no row for a rejected session
243 
244      await setPlan(workspaceId, "paid");
245      const paid = await openUpload(headers, {
246        name: "big.bin",
247        mimeType: "application/octet-stream",
248        size: OVER_FREE,
249      });
250      expect(paid.status).toBe(201);
251      expect(paid.upload?.file.name).toBe("big.bin");
252      await cancel(headers, paid.upload!.file.id); // session never completed
253    });
254 
255    // ── B. size mismatch → 422 UPLOAD_INCOMPLETE ────────────────────────
256 
257    it("B: PUT smaller than declared → 422 UPLOAD_INCOMPLETE; row never READY", async () => {
258      const { headers } = await newWorkspace();
259      const { status, upload } = await openUpload(headers, {
260        name: "declared-10kb.png",
261        mimeType: "image/png",
262        size: PNG_BYTES.length + 5_000, // declare more than we send
263      });
264      expect(status).toBe(201);
265      const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
266      expect(put.status).toBe(200); // S3 accepts; the size is re-checked at complete
267 
268      const c = await complete(headers, upload!.file.id);
269      expect(c.status).toBe(422);
270      expect(errCode(c.body)).toBe("UPLOAD_INCOMPLETE");
271 
272      const row = await fileRow(upload!.file.id);
273      expect(row?.status).toBe("UPLOADING"); // never advanced to SCANNING/READY
274    });
275 
276    // ── C. content-type mismatch (SIGNED) — requirement-conflict case ───
277 
278    it("C: PUT with a WRONG Content-Type → S3 403 (signature) → 410 UPLOAD_EXPIRED; object never lands", async () => {
279      const { headers } = await newWorkspace();
280      const { status, upload } = await openUpload(headers, {
281        name: "ct.png",
282        mimeType: "image/png",
283        size: PNG_BYTES.length,
284      });
285      expect(status).toBe(201);
286 
287      // The URL is signed for content-type: image/png. A different
288      // Content-Type breaks the SigV4 verification BEFORE any object exists.
289      const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, {
290        ...upload!.headers,
291        "Content-Type": "text/plain",
292      });
293      expect(put.status).toBe(403);
294 
295      const key = (await fileRow(upload!.file.id))!.key;
296      expect(await objectExists(key)).toBe(false); // nothing stored
297 
298      const c = await complete(headers, upload!.file.id);
299      // CONFLICT RECORDED in the task file (2026-09-29): the spec text
300      // expects 422 UPLOAD_INCOMPLETE here; because content-type is signed
301      // into the PUT, completion can only observe the absent object → 410
302      // UPLOAD_EXPIRED. The security property (no wrong-typed object) holds.
303      expect(c.status).toBe(410);
304      expect(errCode(c.body)).toBe("UPLOAD_EXPIRED");
305    });
306 
307    // ── D. MIME spoof → 415 MIME_MISMATCH + object deleted ──────────────
308 
309    it("D: text bytes claimed image/png → 415 MIME_MISMATCH; object DELETED from bucket; row FAILED", async () => {
310      const { headers } = await newWorkspace();
311      const { status, upload } = await openUpload(headers, {
312        name: "really-text.png",
313        mimeType: "image/png",
314        size: TEXT_BYTES.length,
315      });
316      expect(status).toBe(201);
317 
318      const put = await putToUrl(upload!.uploadUrl, TEXT_BYTES, upload!.headers);
319      expect(put.status).toBe(200); // body is UNSIGNED-PAYLOAD — S3 accepts
320 
321      const c = await complete(headers, upload!.file.id);
322      expect(c.status).toBe(415);
323      expect(errCode(c.body)).toBe("MIME_MISMATCH");
324 
325      const row = await fileRow(upload!.file.id);
326      expect(await objectExists(row!.key)).toBe(false); // deleted from the bucket
327      expect(row?.status).toBe("FAILED");
328      const scan = (row?.scanReport ?? {}) as Record<string, unknown>;
329      expect(scan.result).toBe("mismatch");
330 
331      // security-sensitive rejection → audit row (AGENTS.md §27)
332      const audit = await app.prisma.auditLog.findFirst({
333        where: { action: "file.mime_mismatch", resourceId: upload!.file.id },
334      });
335      expect(audit).not.toBeNull();
336    });
337 
338    // ── E. happy path → SCANNING + audit + spec key layout ──────────────
339 
340    it("E: 1 KB PNG claimed image/png → SCANNING + file.uploaded audit + files/{ws}/{fileId}/1/{name} key", async () => {
341      const { headers } = await newWorkspace();
342      const { status, upload } = await openUpload(headers, {
343        name: "pixel.png",
344        mimeType: "image/png",
345        size: PNG_BYTES.length,
346      });
347      expect(status).toBe(201);
348 
349      // session URL shape: PUT method, signed headers, 900 s expiry
350      expect(upload!.method).toBe("PUT");
351      expect(upload!.headers["Content-Type"]).toBe("image/png");
352      expect(new URL(upload!.uploadUrl).searchParams.get("X-Amz-Expires")).toBe("900");
353 
354      const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
355      expect(put.status).toBe(200);
356 
357      const c = await complete(headers, upload!.file.id);
358      expect(c.status).toBe(200);
359      expect((c.body as { status: string }).status).toBe("SCANNING");
360 
361      const row = await fileRow(upload!.file.id);
362      expect(row?.status).toBe("SCANNING");
363      expect(row?.size).toBe(PNG_BYTES.length);
364      expect(await objectExists(row!.key)).toBe(true);
365      expect(row!.key).toBe(`files/${row!.workspaceId}/${row!.id}/1/pixel.png`);
366 
367      const audit = await app.prisma.auditLog.findFirst({
368        where: { action: "file.uploaded", resourceId: upload!.file.id },
369      });
370      expect(audit).not.toBeNull();
371      expect((audit?.metadata as Record<string, unknown>).size).toBe(PNG_BYTES.length);
372    });
373 
374    // ── F. signed GET: works + sig tamper + key binding + authorization ──
375 
376    it("F: member 302→signed GET works; tampered sig 403; foreign key 403; non-member 403 + audit", async () => {
377      const owner = await newWorkspace("Signed GET WS");
378      // a completely different tenant (no membership → no VIEW)
379      const stranger = await createUser(app.request, { name: "Outsider" });
380      const strangerHeaders = authHeaders(stranger.accessToken!);
381 
382      const fileId = await uploadPng(owner.headers, "signed.png");
383 
384      // 1) member: 302 + a working signed GET (200, same bytes).
385      //    redirect:"manual" — the helper's fetch follows redirects by
386      //    default and would otherwise chase the 302 straight to MinIO
387      //    (reporting 200) before we can inspect the Location.
388      const dl = await app.request("GET", `/api/v1/files/${fileId}/download`, {
389        headers: owner.headers,
390        redirect: "manual",
391      });
392      expect(dl.status).toBe(302);
393      const location = dl.headers.get("location")!;
394      expect(location).toContain("X-Amz-Signature=");
395      expect(location).toContain("X-Amz-Expires=900");
396 
397      const got = await fetch(location, { redirect: "manual" });
398      expect(got.status).toBe(200);
399      const body = new Uint8Array(await got.arrayBuffer());
400      expect(body.length).toBe(PNG_BYTES.length);
401      expect(body[0]).toBe(0x89); // still a png
402 
403      // 2) tamper the signature → S3 rejects (the URL is bound to its sig)
404      const tampered = location.replace(/X-Amz-Signature=[0-9a-f]+/, `X-Amz-Signature=${"0".repeat(64)}`);
405      const bad = await fetch(tampered, { redirect: "manual" });
406      expect(bad.status).toBe(403);
407 
408      // 3) KEY BINDING: the signature covers the exact object key — reuse
409      //    the SAME signature against a different object's key → 403.
410      const otherKey = (await fileRow(fileId))!.key;
411      const second = await uploadPng(owner.headers, "second.png");
412      const secondKey = (await fileRow(second))!.key;
413      expect(secondKey).not.toBe(otherKey);
414      const u = new URL(location);
415      // Object keys are raw path segments (not URI-encoded) in the signed
416      // URL, so swap them verbatim — the signature still covers the OLD key.
417      u.pathname = u.pathname.replace(otherKey, secondKey);
418      const cross = await fetch(u.toString(), { redirect: "manual" });
419      expect(cross.status).toBe(403);
420 
421      // 4) non-member → 403 FORBIDDEN + file.download_denied audit
422      const denied = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: strangerHeaders });
423      expect(denied.status).toBe(403);
424      expect(errCode(await json(denied))).toBe("FORBIDDEN");
425      const audit = await app.prisma.auditLog.findFirst({
426        where: { action: "file.download_denied", resourceId: fileId },
427      });
428      expect(audit).not.toBeNull();
429      expect(audit?.actorId).toBe(stranger.userId);
430    });
431 
432    // ── G. real signed-URL expiry (the 900 s contract, 2 s in the test) ──
433 
434    it("G: PUT after the signed window closes → S3 403 → completion 410 UPLOAD_EXPIRED", async () => {
435      // Shorten the expiry for this ONE session: the API reads
436      // S3_SIGN_EXPIRY_SECONDS at session-creation time, and loadEnv() is
437      // memoized — reset the cache so the new value is seen. 2 s exercises
438      // the identical server-side path the 900 s default would.
439      const prev = process.env.S3_SIGN_EXPIRY_SECONDS;
440      process.env.S3_SIGN_EXPIRY_SECONDS = "2";
441      resetEnvCache();
442      try {
443        const { headers } = await newWorkspace("Expiry WS");
444        const { upload } = (await openUpload(headers, {
445          name: "expiring.png",
446          mimeType: "image/png",
447          size: PNG_BYTES.length,
448        })) as { upload?: Upload };
449        expect(new URL(upload!.uploadUrl).searchParams.get("X-Amz-Expires")).toBe("2");
450 
451        // wait out the signed window (+1 s clock-skew margin)
452        await new Promise((r) => setTimeout(r, 3_000));
453        const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
454        expect(put.status).toBe(403); // RequestExpired — the signature is dead
455 
456        const c = await complete(headers, upload!.file.id);
457        expect(c.status).toBe(410);
458        expect(errCode(c.body)).toBe("UPLOAD_EXPIRED");
459      } finally {
460        if (prev === undefined) delete process.env.S3_SIGN_EXPIRY_SECONDS;
461        else process.env.S3_SIGN_EXPIRY_SECONDS = prev;
462        resetEnvCache();
463      }
464    });
465 
466    // ── H. bucket privacy (both buckets) ─────────────────────────────────
467 
468    it("H: unsigned GETs on raw keys → 403 in BOTH the files and thumbs buckets", async () => {
469      const { headers } = await newWorkspace("Privacy WS");
470      const fileId = await uploadPng(headers, "private.png");
471      const key = (await fileRow(fileId))!.key;
472      const wsId = (await fileRow(fileId))!.workspaceId;
473 
474      // files bucket: anonymous GET on a REAL object key → 403 (private)
475      const rawFiles = await fetch(`${filesConfig.endpoint}/${filesConfig.bucket}/${key}`, { redirect: "manual" });
476      expect(rawFiles.status).toBe(403);
477 
478      // thumbs bucket: no thumb exists yet (the worker writes them —
479      // TASK-0102), but a private bucket 403s anonymous callers on any key,
480      // including the exact key layout a 160-px thumb would use
481      // (stem + same-format ext: private.png).
482      const thumbKey = `thumbs/${wsId}/${fileId}/160/private.png`;
483      const rawThumbs = await fetch(`${thumbsConfig.endpoint}/${thumbsConfig.bucket}/${thumbKey}`, { redirect: "manual" });
484      expect(rawThumbs.status).toBe(403);
485    });
486 
487    // ── I. safeName in the real key (traversal is squashed) ──────────────
488 
489    it("I: a traversal name gets the SANITIZED key (…/etc/passwd → …/1/passwd)", async () => {
490      const { headers } = await newWorkspace("Traversal WS");
491      const { upload } = (await openUpload(headers, {
492        name: "../../etc/passwd",
493        mimeType: "text/plain",
494        size: 100,
495      })) as { upload?: Upload };
496      expect(upload).toBeDefined();
497      const row = await fileRow(upload!.file.id);
498      // the stored NAME keeps the client's string; the KEY never does.
499      expect(row!.name).toBe("../../etc/passwd");
500      expect(row!.key).toBe(`files/${row!.workspaceId}/${row!.id}/1/passwd`);
501      expect(row!.key).not.toContain("..");
502      // (the full 12-case safeName matrix is unit-tested in @factory/storage
503      //  keys.test.ts — unit vs integration split, AGENTS.md §9)
504      await cancel(headers, upload!.file.id);
505    });
506 
507    // ── cancel (session hygiene) ─────────────────────────────────────────
508 
509    it("cancel: deletes the partial object AND the row", async () => {
510      const { headers } = await newWorkspace();
511      const { upload } = (await openUpload(headers, {
512        name: "partial.png",
513        mimeType: "image/png",
514        size: PNG_BYTES.length,
515      })) as { upload?: Upload };
516      await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
517      const key = (await fileRow(upload!.file.id))!.key;
518 
519      const status = await cancel(headers, upload!.file.id);
520      expect(status).toBe(200);
521      expect(await objectExists(key)).toBe(false);
522      expect(await fileRow(upload!.file.id)).toBeNull();
523    });
524 
525    // ── J. list endpoint (TASK-0103: filters + enrichment + keyset cursor) ──
526 
527    it("J: list — kind/status/search filters, page scope, uploader/page-name enrichment, keyset cursor (name + size), workspace isolation", async () => {
528      const owner = await newWorkspace("List WS");
529      // a second, independent tenant (workspace isolation)
530      const stranger = await newWorkspace("List WS — other tenant");
531 
532      // two real pages (page-scope + pageName enrichment)
533      const pageA = await app.prisma.page.create({
534        data: { workspaceId: owner.workspaceId, title: "Page A" },
535      });
536      const pageB = await app.prisma.page.create({
537        data: { workspaceId: owner.workspaceId, title: "Page B" },
538      });
539 
540      // 6 PNGs: a0..a3 on page A, b0 on page B, n0 on no page — distinct
541      // sizes (100..105) so the size-sort cursor is deterministic.
542      const names = ["a0.png", "a1.png", "a2.png", "a3.png", "b0.png", "n0.png"];
543      const ids: string[] = [];
544      for (let i = 0; i < names.length; i++) {
545        const fileId = await uploadPng(owner.headers, names[i]);
546        ids.push(fileId);
547        await app.prisma.file.update({
548          where: { id: fileId },
549          data: {
550            pageId: names[i].startsWith("a") ? pageA.id : names[i].startsWith("b") ? pageB.id : null,
551            size: 100 + i,
552          },
553        });
554      }
555 
556      const list = async (qs: string, headers = owner.headers) => {
557        const res = await app.request("GET", `/api/v1/files?${qs}`, { headers });
558        expect(res.status).toBe(200);
559        return (await res.json()) as { data: Record<string, unknown>[]; nextCursor: string | null };
560      };
561 
562      // 1) kind filter — all 6 are pngs: image → 6, video → 0
563      expect((await list("scope=workspace&kind=image&limit=50")).data).toHaveLength(6);
564      expect((await list("scope=workspace&kind=video&limit=50")).data).toHaveLength(0);
565 
566      // 2) status filter — all rows are SCANNING (no worker in this app)
567      expect((await list("status=SCANNING&limit=50")).data).toHaveLength(6);
568      expect((await list("status=READY&limit=50")).data).toHaveLength(0);
569 
570      // 3) search — case-insensitive contains
571      const searched = await list("search=A2&limit=50");
572      expect(searched.data).toHaveLength(1);
573      expect(searched.data[0].name).toBe("a2.png");
574 
575      // 4) page scope — page A returns exactly its 4 files (not page B, not no-page)
576      const pageAScope = await list(`scope=page&pageId=${pageA.id}&limit=50`);
577      expect(pageAScope.data).toHaveLength(4);
578      expect(pageAScope.data.every((d) => d.pageId === pageA.id)).toBe(true);
579 
580      // 5) enrichment — uploader name + page name + version count
581      const one = (await list("search=a0&limit=50")).data[0];
582      expect(one.uploadedByName).toBe("File Owner");
583      expect(one.pageName).toBe("Page A");
584      expect(one.versionCount).toBe(1);
585 
586      // 6) keyset cursor (name asc, limit 3 → 2 pages: no overlap, no omission)
587      const p1 = await list("sort=name&limit=3");
588      expect(p1.data).toHaveLength(3);
589      expect(p1.nextCursor).toBeTruthy();
590      const p2 = await list(`sort=name&limit=3&cursor=${encodeURIComponent(p1.nextCursor!)}`);
591      expect(p2.data).toHaveLength(3);
592      const page1Names = p1.data.map((d) => d.name as string);
593      const page2Names = p2.data.map((d) => d.name as string);
594      expect(page1Names.filter((n) => page2Names.includes(n))).toHaveLength(0); // disjoint
595      expect([...page1Names, ...page2Names].sort()).toEqual([...names].sort()); // complete
596      expect(page1Names).toEqual([...page1Names].sort()); // ordered
597 
598      // 7) keyset cursor on the Int (size) column — desc, limit 2 → pages strictly decrease
599      const s1 = await list("sort=size&limit=2");
600      expect(s1.data).toHaveLength(2);
601      expect(s1.nextCursor).toBeTruthy();
602      const s2 = await list(`sort=size&limit=2&cursor=${encodeURIComponent(s1.nextCursor!)}`);
603      expect(s2.data).toHaveLength(2);
604      expect(Math.min(...(s1.data.map((d) => d.size as number)))).toBeGreaterThan(
605        Math.max(...(s2.data.map((d) => d.size as number)))
606      );
607 
608      // 8) workspace isolation — the stranger's list (their own active
609      //    workspace) never shows the owner's 6 files, and vice versa.
610      const strangerList = await list("scope=workspace&limit=50", stranger.headers);
611      expect(strangerList.data).toHaveLength(0);
612      expect(strangerList.data.some((d) => ids.includes(d.id as string))).toBe(false);
613      const ownerList = await list("scope=workspace&limit=50");
614      expect(ownerList.data.every((d) => (d.workspaceId as string) === owner.workspaceId)).toBe(true);
615    });
616  },
617);
618