.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-lifecycle.integration.test.tstests/integration/files-lifecycle.integration.test.tsraw← integration35,954 B← Back to the app
1/**
2 * AI Software Factory — Integration: Files LIFECYCLE (Phase 9, TASK-0104)
3 *
4 * The file-management contract against the REAL stack: NestJS API
5 * (Testcontainers Postgres/Redis + the host MinIO) + the worker's files
6 * pipeline + search-index processor run IN-PROCESS against the container
7 * DB + the host MinIO + (gated) the host OpenSearch.
8 *
9 * Why in-process? The API (test app) runs on ephemeral Testcontainers
10 * Postgres/Redis on a RANDOM mapped port, while the host worker (:4003) reads
11 * `localhost:5432` — a DIFFERENT database. So the host worker never sees the
12 * test app's `files` queue jobs / `factory:events`. The repo's own TASK-0102
13 * pipeline spec establishes the pattern: drive the worker processors
14 * in-process (runFilesPipeline / handleSearchEvent) against the container
15 * DB. This spec follows it.
16 *
17 * Per docs/tasks/phase-09-files.md TASK-0104 §Acceptance, each block is an
18 * acceptance criterion:
19 *
20 *   1. versions   — re-upload → FileVersion v2 + both keys in the bucket +
21 *                   File re-points to v2; rollback → v1's key; GET /versions
22 *                   lists both (bare array, NO raw `key`).
23 *   2. audit      — non-member download → 403 + `file.download_denied`
24 *                   (actorId + ip); member download → 302 + `file.downloaded`.
25 *   3. permissions — explicit NONE on a file → 403 (beats inherited EDIT);
26 *                   VIEW grant to a non-member → 302 (grant grants).
27 *   4. trash      — delete → deletedAt set + absent from GET /files + search
28 *                   doc removed + `file.deleted`; restore → back + re-indexed;
29 *                   purge → objects + rows + thumbs gone + audit in order.
30 *   5. quota      — 1 GB quota, 950 MB used → 60 MB session 413, 40 MB 201 +
31 *                   column = 990 MB (assert the COLUMN, not a recompute).
32 *   6. purge/orphan — purging a 2-version file removes exactly its object
33 *                   keys + thumb; `runOrphanSweep({fileId})` reports 0.
34 *   7. re-index   — add a grant → `GET /permissions` shows it + audit
35 *                   `file.permission_changed` + `permission.changed` published
36 *                   + the doc re-indexes cleanly. (CONFLICT recorded: file-
37 *                   level grants do NOT narrow search visibility — search is
38 *                   workspace/page-scoped by design; file-level access is
39 *                   enforced at read time. See the task file.)
40 *   8. rename     — PATCH changes only the name (size/mime/metadata
41 *                   unchanged) + `file.updated` published + doc title
42 *                   re-indexed.
43 */
44import { afterAll, beforeAll, describe, expect, it } from "vitest";
45import path from "node:path";
46import os from "node:os";
47import { mkdtemp, readFile } from "node:fs/promises";
48import { fileURLToPath, pathToFileURL } from "node:url";
49import { S3Client as StorageS3Client, type S3Config as StorageS3Config } from "@factory/storage";
50import { SearchClient } from "@factory/search";
51import { PrismaClient } from "@prisma/client";
52import { Redis } from "ioredis";
53import { createTestApp, type TestAppHandle } from "./helpers/api";
54import { getDatabaseUrl, getRedisUrl } from "./helpers/containers";
55import { authHeaders, createUser, createWorkspace, addMemberViaSeam } from "./helpers/seed";
56 
57const HERE = path.dirname(fileURLToPath(import.meta.url));
58 
59// Import the worker's files pipeline + search-index processor + storage client
60// from SOURCE (the worker ships no build for the monorepo test path — the same
61// convention helpers/api.ts + the TASK-0102 pipeline spec use).
62const { runFilesPipeline } = await import(
63  /* webpackIgnore: true */
64  fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "files", "processor.ts"))
65);
66const { S3Client: WorkerS3Client } = await import(
67  /* webpackIgnore: true */
68  fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "lib", "s3.ts"))
69);
70const { handleSearchEvent, runReindex } = await import(
71  /* webpackIgnore: true */
72  fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "search-index", "processor.ts"))
73);
74const { runOrphanSweep } = await import(
75  /* webpackIgnore: true */
76  fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "files", "cleanup.ts"))
77);
78 
79function fileUrl(p: string): string {
80  return `file://${p.startsWith("/") ? p : `/${p}`}`;
81}
82 
83// ── host gates ──────────────────────────────────────────────────────────────
84 
85const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, "");
86const SEARCH_URL = process.env.SEARCH_URL ?? "http://localhost:9200";
87const S3_ACCESS = process.env.S3_ACCESS_KEY ?? "minioadmin";
88const S3_SECRET = process.env.S3_SECRET_KEY ?? "minioadmin";
89const FILES_BUCKET = process.env.S3_BUCKET_FILES ?? "factory-files";
90const THUMBS_BUCKET = process.env.S3_BUCKET_THUMBS ?? "factory-thumbs";
91 
92async function httpUp(url: string): Promise<boolean> {
93  try {
94    const r = await fetch(url, { signal: AbortSignal.timeout(3_000) });
95    return r.ok;
96  } catch {
97    return false;
98  }
99}
100const minioReady = await httpUp(`${S3_ENDPOINT}/minio/health/live`);
101const searchReady = await httpUp(`${SEARCH_URL}/_cluster/health`);
102 
103// ── fixtures ────────────────────────────────────────────────────────────────
104 
105/** 1×1 red pixel, 67 bytes — a REAL png signature (magic bytes verify). */
106const PNG_BYTES = new Uint8Array([
107  0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
108  0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
109  0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
110  0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
111  0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
112  0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
113  0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
114  0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
115  0x44, 0xae, 0x42, 0x60,
116]);
117/** The real hero PNG fixture (1400×900, a VALID png the pipeline re-sniffs +
118 *  thumbnails cleanly) — used as the v2 re-upload body so the re-upload is a
119 *  genuine, byte-distinct new version. Loaded in beforeAll (HERO_BYTES). */
120const FIX = path.join(HERE, "fixtures", "files");
121 
122// ── state ───────────────────────────────────────────────────────────────────
123 
124let app: TestAppHandle;
125let prisma: PrismaClient;
126let redis: Redis;
127let workerFiles: InstanceType<typeof WorkerS3Client>;
128let workerThumbs: InstanceType<typeof WorkerS3Client>;
129let storageFiles: InstanceType<typeof StorageS3Client>; // listObjects (orphan/bucket asserts)
130let storageThumbs: InstanceType<typeof StorageS3Client>;
131let search: SearchClient | null;
132let env: import("../../apps/worker/src/config").WorkerEnv;
133let tempDir: string;
134let HERO_BYTES: Uint8Array; // the v2 re-upload body (a real, valid PNG)
135 
136// ── helpers ─────────────────────────────────────────────────────────────────
137 
138const json = async (res: Response): Promise<Record<string, unknown>> =>
139  (await res.json().catch(() => ({}))) as Record<string, unknown>;
140const errCode = (body: Record<string, unknown>): string =>
141  ((body.error as { code?: string } | undefined)?.code ?? "");
142 
143interface Ws {
144  headers: Record<string, string>;
145  workspaceId: string;
146  userId: string;
147}
148async function newWorkspace(name = "Lifecycle WS"): Promise<Ws> {
149  const user = await createUser(app.request, { name: "LC Owner" });
150  const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), name);
151  return {
152    headers: { ...authHeaders(user.accessToken!), "x-factory-workspace": workspaceId },
153    workspaceId,
154    userId: user.userId,
155  };
156}
157 
158interface Upload {
159  file: { id: string; name: string; status?: string };
160  uploadUrl: string;
161  method: string;
162  headers: Record<string, string>;
163  expiresAt: string;
164}
165async function openUpload(
166  headers: Record<string, string>,
167  body: Record<string, unknown>,
168): Promise<{ status: number; upload?: Upload; raw: Record<string, unknown> }> {
169  const res = await app.request("POST", "/api/v1/files/uploads", { headers, body });
170  const raw = await json(res);
171  if (res.status !== 201) return { status: res.status, raw };
172  return { status: 201, upload: raw.upload as Upload, raw };
173}
174async function putToUrl(url: string, bytes: Uint8Array, headers: Record<string, string>): Promise<Response> {
175  return fetch(url, { method: "PUT", headers, body: bytes as unknown as BodyInit });
176}
177async function complete(headers: Record<string, string>, uploadId: string) {
178  const res = await app.request("PUT", `/api/v1/files/uploads/${uploadId}/complete`, { headers, body: {} });
179  return { status: res.status, body: await json(res) };
180}
181async function fileRow(fileId: string) {
182  return app.prisma.file.findUnique({ where: { id: fileId } });
183}
184 
185/** Drive the in-process files pipeline to a terminal state (no scanner →
186 *  deterministic READY + FileVersion + thumbnails; the scan is covered by the
187 *  TASK-0102 pipeline spec). */
188async function toReady(fileId: string, version: number, workspaceId: string): Promise<void> {
189  const res = await runFilesPipeline(
190    { prisma, s3: workerFiles, s3Thumbs: workerThumbs, redis, env },
191    { fileId, version, workspaceId },
192  );
193  expect(res.kind).toBe("ready");
194  const row = await fileRow(fileId);
195  expect(row?.status).toBe("READY");
196}
197 
198/** Full happy path: open + PUT + complete + pipeline→READY. Returns file id. */
199async function uploadPng(headers: Record<string, string>, name = "lifecycle.png", bytes = PNG_BYTES): Promise<string> {
200  const { status, upload } = await openUpload(headers, {
201    name,
202    mimeType: "image/png",
203    size: bytes.length,
204  });
205  expect(status).toBe(201);
206  const put = await putToUrl(upload!.uploadUrl, bytes, upload!.headers);
207  expect(put.status).toBe(200);
208  const c = await complete(headers, upload!.file.id);
209  expect(c.status).toBe(200);
210  await toReady(upload!.file.id, 1, headers["x-factory-workspace"]!);
211  return upload!.file.id;
212}
213 
214/** All object keys under a prefix (files or thumbs bucket). */
215async function keysIn(client: InstanceType<typeof StorageS3Client>, prefix: string): Promise<string[]> {
216  const { keys } = await client.listObjects(prefix, 1000);
217  return keys;
218}
219 
220/** Fire one search-index event in-process (mirrors the worker consumer). */
221async function searchEvent(event: string, payload: Record<string, unknown>): Promise<void> {
222  if (!search) return;
223  await handleSearchEvent(
224    { search, prisma, connection: redis, env },
225    { event, payload },
226  );
227}
228async function docInSearch(docId: string): Promise<boolean> {
229  if (!search) return false;
230  const present = await search.docExists([docId]);
231  return present.has(docId);
232}
233/** The SearchIndexOps bundle (for runReindex — the convergence op a
234 *  permission.changed workspace-resync job performs). */
235function searchOps() {
236  return { search: search!, prisma, connection: redis, env };
237}
238/** True if the `factory:events` stream carries an entry for `event` whose
239 *  payload.resourceId === resourceId (proves the API published it). The event
240 *  bus XADDs `[event, payload(JSON), ts]` per entry. */
241async function streamHasEvent(event: string, resourceId: string): Promise<boolean> {
242  const entries = (await redis.xrange("factory:events", "-", "+")) as [string, unknown[]][];
243  return entries.some(([, fields]) => {
244    const arr = fields as unknown[];
245    const get = (k: string): unknown => arr[arr.indexOf(k) + 1];
246    if (get("event") !== event) return false;
247    const payload = get("payload");
248    if (typeof payload !== "string") return false;
249    try {
250      return (JSON.parse(payload) as { resourceId?: string }).resourceId === resourceId;
251    } catch {
252      return false;
253    }
254  });
255}
256 
257// ── suite ───────────────────────────────────────────────────────────────────
258 
259describe.skipIf(!minioReady)(
260  "Files lifecycle integration (TASK-0104 — versions/audit/trash/quota/permissions)",
261  () => {
262    beforeAll(async () => {
263      app = await createTestApp();
264      const [dbUrl, redisUrl] = await Promise.all([getDatabaseUrl(), getRedisUrl()]);
265      prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } });
266      redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
267      const cfg = {
268        endpoint: S3_ENDPOINT,
269        region: process.env.S3_REGION ?? "us-east-1",
270        accessKey: S3_ACCESS,
271        secretKey: S3_SECRET,
272      };
273      workerFiles = new WorkerS3Client({ ...cfg, bucket: FILES_BUCKET });
274      workerThumbs = new WorkerS3Client({ ...cfg, bucket: THUMBS_BUCKET });
275      const scfg: StorageS3Config = {
276        endpoint: S3_ENDPOINT,
277        region: process.env.S3_REGION ?? "us-east-1",
278        bucket: FILES_BUCKET,
279        accessKeyId: S3_ACCESS,
280        secretAccessKey: S3_SECRET,
281        forcePathStyle: true,
282      };
283      storageFiles = new StorageS3Client({ config: scfg });
284      storageThumbs = new StorageS3Client({ config: { ...scfg, bucket: THUMBS_BUCKET } });
285      if (searchReady) search = new SearchClient({ baseUrl: SEARCH_URL });
286      tempDir = await mkdtemp(path.join(os.tmpdir(), "factory-lifecycle-it-"));
287      HERO_BYTES = new Uint8Array(await readFile(path.join(FIX, "hero-1400.png")));
288      env = {
289        NODE_ENV: "test",
290        REDIS_URL: "ignored",
291        DATABASE_URL: "ignored",
292        S3_ENDPOINT,
293        S3_REGION: process.env.S3_REGION ?? "us-east-1",
294        S3_BUCKET_FILES: FILES_BUCKET,
295        S3_BUCKET_THUMBS: THUMBS_BUCKET,
296        S3_BUCKET: FILES_BUCKET,
297        S3_ACCESS_KEY: S3_ACCESS,
298        S3_SECRET_KEY: S3_SECRET,
299        S3_PUBLIC_BASE: "http://localhost:9001",
300        SEARCH_URL,
301        SEARCH_USERNAME: "admin",
302        SEARCH_PASSWORD: "changeme",
303        SEARCH_VECTOR_DIMENSION: 1536,
304        SEARCH_KNN_M: 16,
305        SEARCH_KNN_EF_CONSTRUCTION: 100,
306        MASTER_ENCRYPTION_KEY: "",
307        AI_API_KEY: "",
308        AI_PROVIDER: "openai",
309        AI_BASE_URL: "",
310        AI_MODEL: "gpt-4o-mini",
311        AI_EMBEDDING_PROVIDER: "",
312        AI_EMBEDDING_MODEL: "text-embedding-3-small",
313        SMTP_HOST: "",
314        SMTP_PORT: 587,
315        SMTP_USER: "",
316        SMTP_PASS: "",
317        SMTP_FROM: "noreply@factory.local",
318        SMTP_STARTTLS: true,
319        CLAMAV_HOST: "",
320        CLAMAV_PORT: 3310,
321        CLAMD_SOCKET: "", // no scanner — deterministic READY (scan is TASK-0102's contract)
322        FILES_WORKER_CONCURRENCY: 4,
323        CLAMAV_TIMEOUT_MS: 30_000,
324        FILES_TEMP_DIR: tempDir,
325        FILES_TEMP_MAX_AGE_MS: 600_000,
326        FILES_TEMP_FREE_MIN_BYTES: 1,
327        PUBLIC_URL: "http://localhost:3000",
328        WORKER_CONCURRENCY: 10,
329        WORKER_ID: "lifecycle-test-worker",
330        WORKER_RETENTION_EXECUTE: false,
331        HEALTH_PORT: 4003,
332      } as unknown as import("../../apps/worker/src/config").WorkerEnv;
333    }, 180_000);
334    afterAll(async () => {
335      await redis?.quit().catch(() => {});
336      await prisma?.$disconnect().catch(() => {});
337      await app?.close();
338    });
339 
340    // ── 1. versions ────────────────────────────────────────────────────────
341 
342    it("1: re-upload → v2 (FileVersion v2, both keys in bucket, F→v2); rollback→v1; GET /versions lists both (bare array, no key)", async () => {
343      const ws = await newWorkspace("Versions WS");
344      const f1 = await uploadPng(ws.headers, "v1.png", PNG_BYTES);
345      const row1 = (await fileRow(f1))!;
346      const v1key = row1.key;
347 
348      // Re-upload to the SAME file as a new version (replaceFileId).
349      const { status, upload } = await openUpload(ws.headers, {
350        name: "v2.png",
351        mimeType: "image/png",
352        size: HERO_BYTES.length,
353        replaceFileId: f1,
354      });
355      expect(status).toBe(201);
356      expect(upload!.file.id).toBe(f1); // SAME file row re-pointed
357      const put = await putToUrl(upload!.uploadUrl, HERO_BYTES, upload!.headers);
358      expect(put.status).toBe(200);
359      const c = await complete(ws.headers, f1);
360      expect(c.status).toBe(200);
361      await toReady(f1, 2, ws.workspaceId);
362 
363      const row2 = (await fileRow(f1))!;
364      const v2key = row2.key;
365      expect(v2key).not.toBe(v1key); // a NEW object key
366 
367      // Both keys exist in the bucket; the File points at v2.
368      const bucketKeys = new Set(await keysIn(storageFiles, `files/${ws.workspaceId}/${f1}/`));
369      expect(bucketKeys.has(v1key)).toBe(true);
370      expect(bucketKeys.has(v2key)).toBe(true);
371      expect(row2.key).toBe(v2key);
372 
373      // FileVersion rows: v1 + v2.
374      const versions = await app.prisma.fileVersion.findMany({
375        where: { fileId: f1 },
376        orderBy: { version: "asc" },
377      });
378      expect(versions.map((v) => v.version)).toEqual([1, 2]);
379 
380      // GET /versions → bare array, v2 current, NO raw `key`.
381      const res = await app.request("GET", `/api/v1/files/${f1}/versions`, { headers: ws.headers });
382      expect(res.status).toBe(200);
383      const list = (await res.json()) as unknown[];
384      expect(Array.isArray(list)).toBe(true);
385      expect(list).toHaveLength(2);
386      const items = list as Array<Record<string, unknown>>;
387      expect(items.every((i) => !("key" in i))).toBe(true); // no raw key
388      expect(items.map((i) => i.version)).toEqual([1, 2]);
389      expect(items.find((i) => i.version === 2)?.current).toBe(true);
390      expect(items.find((i) => i.version === 1)?.current).toBe(false);
391 
392      // Rollback to v1 → F points at v1's key (key swap, no new object).
393      // @Post without @HttpCode → 201.
394      const rb = await app.request("POST", `/api/v1/files/${f1}/rollback`, {
395        headers: ws.headers,
396        body: { version: 1 },
397      });
398      expect(rb.status).toBe(201);
399      const row3 = (await fileRow(f1))!;
400      expect(row3.key).toBe(v1key);
401      // still exactly the same 2 objects (no copy on rollback)
402      expect(new Set(await keysIn(storageFiles, `files/${ws.workspaceId}/${f1}/`)).size).toBe(2);
403    });
404 
405    // ── 2. download/preview audit ──────────────────────────────────────────
406 
407    it("2: non-member download → 403 + file.download_denied (actorId+ip); member download → 302 + file.downloaded", async () => {
408      const ws = await newWorkspace("Audit WS");
409      const fileId = await uploadPng(ws.headers, "audit.png");
410 
411      // A non-member of the workspace (a plain registered user).
412      const stranger = await createUser(app.request, { name: "Stranger" });
413      const strangerHeaders = { ...authHeaders(stranger.accessToken!), "x-factory-workspace": ws.workspaceId };
414 
415      // non-member → 403 FORBIDDEN + denial audit (actorId = stranger, ip set).
416      const denied = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: strangerHeaders });
417      expect(denied.status).toBe(403);
418      expect(errCode(await json(denied))).toBe("FORBIDDEN");
419      const denAudit = await app.prisma.auditLog.findFirst({
420        where: { action: "file.download_denied", resourceId: fileId },
421      });
422      expect(denAudit).not.toBeNull();
423      expect(denAudit?.actorId).toBe(stranger.userId);
424      expect(denAudit?.ipAddress).toBeTruthy();
425 
426      // member (owner) → 302 signed URL + success audit.
427      const dl = await app.request("GET", `/api/v1/files/${fileId}/download`, {
428        headers: ws.headers,
429        redirect: "manual",
430      });
431      expect(dl.status).toBe(302);
432      expect(dl.headers.get("location")).toContain("X-Amz-Signature=");
433      const okAudit = await app.prisma.auditLog.findFirst({
434        where: { action: "file.downloaded", resourceId: fileId },
435      });
436      expect(okAudit).not.toBeNull();
437      expect(okAudit?.actorId).toBe(ws.userId);
438    });
439 
440    // ── 3. file-level permission overrides ─────────────────────────────────
441 
442    it("3: NONE grant on a file → 403 (beats inherited EDIT); VIEW grant to a non-member → 302 (grant grants)", async () => {
443      const ws = await newWorkspace("Perm WS");
444      const fileId = await uploadPng(ws.headers, "perm.png");
445 
446      // A MEMBER (inherited EDIT via the MEMBER role) — grant the file NONE.
447      const member = await createUser(app.request, { name: "Member" });
448      await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER");
449      const memberHeaders = { ...authHeaders(member.accessToken!), "x-factory-workspace": ws.workspaceId };
450      // Sanity: a MEMBER CAN read the file before the NONE grant (inherited EDIT ≥ VIEW).
451      const before = await app.request("GET", `/api/v1/files/${fileId}`, { headers: memberHeaders });
452      expect(before.status).toBe(200);
453      // Grant NONE (explicit deny) — replace the full grant set.
454      const putNone = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
455        headers: ws.headers,
456        body: { grants: [{ granteeType: "user", granteeId: member.userId, level: "NONE" }] },
457      });
458      expect(putNone.status).toBe(200);
459      const after = await app.request("GET", `/api/v1/files/${fileId}/download`, {
460        headers: memberHeaders,
461        redirect: "manual",
462      });
463      expect(after.status).toBe(403); // explicit NONE beats the MEMBER baseline EDIT
464 
465      // A NON-MEMBER with an explicit VIEW grant → can read (grant grants).
466      const outsider = await createUser(app.request, { name: "Outsider" });
467      const outHeaders = { ...authHeaders(outsider.accessToken!), "x-factory-workspace": ws.workspaceId };
468      // No grant yet → denied.
469      const noGrant = await app.request("GET", `/api/v1/files/${fileId}`, { headers: outHeaders });
470      expect(noGrant.status).toBe(403);
471      // Grant VIEW (add to the existing set — keep the NONE for the member too).
472      await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
473        headers: ws.headers,
474        body: {
475          grants: [
476            { granteeType: "user", granteeId: member.userId, level: "NONE" },
477            { granteeType: "user", granteeId: outsider.userId, level: "VIEW" },
478          ],
479        },
480      });
481      const viewOk = await app.request("GET", `/api/v1/files/${fileId}`, { headers: outHeaders });
482      expect(viewOk.status).toBe(200); // explicit VIEW grants the non-member
483    });
484 
485    // ── 4. trash → restore → purge ─────────────────────────────────────────
486 
487    it("4+6: delete→deletedAt+absent+doc-removed; restore→back+doc; purge→objects/rows/thumbs gone, audit in order, 0 orphans", async () => {
488      const ws = await newWorkspace("Trash WS");
489      // A 2-version file (so purge must remove 2 object keys).
490      const f1 = await uploadPng(ws.headers, "trash.png", PNG_BYTES);
491      const { upload } = await openUpload(ws.headers, {
492        name: "trash-v2.png",
493        mimeType: "image/png",
494        size: HERO_BYTES.length,
495        replaceFileId: f1,
496      });
497      await putToUrl(upload!.uploadUrl, HERO_BYTES, upload!.headers);
498      await complete(ws.headers, f1);
499      await toReady(f1, 2, ws.workspaceId);
500      const versionRows = await app.prisma.fileVersion.findMany({ where: { fileId: f1 } });
501      const versionKeys = versionRows.map((v) => v.key).sort();
502      const versionSizesSum = BigInt(versionRows.reduce((s, v) => s + v.size, 0));
503      expect(versionKeys.length).toBe(2);
504      // counter after both versions are complete (= Σ version bytes; fresh ws)
505      const counterAfterUpload = (await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } })).usageStorageBytes;
506      expect(counterAfterUpload).toBe(versionSizesSum);
507 
508      if (searchReady) await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId: f1 });
509      const docBefore = await docInSearch(`file:${f1}`);
510 
511      // ── delete (soft trash) ──
512      const del = await app.request("DELETE", `/api/v1/files/${f1}`, { headers: ws.headers });
513      expect(del.status).toBe(200);
514      const delBody = await json(del);
515      expect(delBody.trashed).toBe(true);
516      expect(delBody.deletedAt).toBeTruthy();
517      const trashed = (await fileRow(f1))!;
518      expect(trashed.deletedAt).not.toBeNull();
519      // absent from GET /files (workspace scope)
520      const listRes = await app.request("GET", "/api/v1/files", { headers: ws.headers });
521      const listBody = await json(listRes);
522      const listed = (listBody.data as Array<{ id: string }> | undefined) ?? [];
523      expect(listed.find((f) => f.id === f1)).toBeUndefined();
524      // present in the trash
525      const trashRes = await app.request("GET", "/api/v1/files/trash", { headers: ws.headers });
526      const trashList = (await trashRes.json()) as unknown[];
527      expect(Array.isArray(trashList)).toBe(true);
528      expect(trashList.find((f) => (f as { id?: string }).id === f1)).toBeTruthy();
529      // search doc removed
530      if (searchReady) {
531        await searchEvent("file.deleted", { workspaceId: ws.workspaceId, fileId: f1 });
532        const docAfterDel = await docInSearch(`file:${f1}`);
533        expect(docBefore).toBe(true);
534        expect(docAfterDel).toBe(false);
535      }
536 
537      // ── restore ── ( @Post without @HttpCode → 201 )
538      const restore = await app.request("POST", `/api/v1/files/${f1}/restore`, { headers: ws.headers });
539      expect(restore.status).toBe(201);
540      const restored = (await fileRow(f1))!;
541      expect(restored.deletedAt).toBeNull();
542      const list2 = await json(await app.request("GET", "/api/v1/files", { headers: ws.headers }));
543      expect(((list2.data as Array<{ id: string }>) ?? []).find((f) => f.id === f1)).toBeTruthy();
544      // search doc re-added
545      if (searchReady) {
546        await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId: f1, restored: true });
547        expect(await docInSearch(`file:${f1}`)).toBe(true);
548      }
549      // counter unchanged by trash→restore (bytes retained while trashed, ±0)
550      const counterAfterRestore = (await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } })).usageStorageBytes;
551      expect(counterAfterRestore).toBe(counterAfterUpload);
552 
553      // ── purge (hard) ──
554      const purge = await app.request("DELETE", `/api/v1/files/${f1}?purge=true`, { headers: ws.headers });
555      expect(purge.status).toBe(200);
556      const purgeBody = await json(purge);
557      expect(purgeBody.purged).toBe(true);
558      // File + FileVersion rows gone
559      expect(await fileRow(f1)).toBeNull();
560      expect(await app.prisma.fileVersion.count({ where: { fileId: f1 } })).toBe(0);
561      // BOTH version objects + the thumbnail set gone from the bucket
562      const filesLeft = await keysIn(storageFiles, `files/${ws.workspaceId}/${f1}/`);
563      expect(filesLeft.length).toBe(0);
564      for (const k of versionKeys) expect(filesLeft).not.toContain(k);
565      const thumbsLeft = await keysIn(storageThumbs, `thumbs/${ws.workspaceId}/${f1}/`);
566      expect(thumbsLeft.length).toBe(0);
567      // counter decremented by the purged version bytes (fresh ws → back to 0)
568      const counterAfterPurge = (await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } })).usageStorageBytes;
569      expect(counterAfterPurge).toBe(counterAfterUpload - versionSizesSum);
570 
571      // audit trail: deleted → restored → purged, in order (by createdAt)
572      const trail = await app.prisma.auditLog.findMany({
573        where: {
574          resourceId: f1,
575          action: { in: ["file.deleted", "file.restored", "file.purged"] },
576        },
577        orderBy: { createdAt: "asc" },
578      });
579      expect(trail.map((t) => t.action)).toEqual(["file.deleted", "file.restored", "file.purged"]);
580 
581      // ── orphan check: a sweep scoped to this (now purged) file reports 0 ──
582      const report = await runOrphanSweep({
583        s3: storageFiles,
584        prisma,
585        dryRun: true,
586        fileId: f1,
587      });
588      expect(report.orphansFound).toBe(0);
589      expect(report.keys).toEqual([]);
590    });
591 
592    // ── 5. quota boundary (decimal numbers per the spec) ───────────────────
593 
594    it("5: 1 GB quota, 950 MB used → 60 MB session 413 QUOTA_EXCEEDED; 40 MB 201 + column = 990 MB (assert the column)", async () => {
595      const ws = await newWorkspace("Quota WS");
596      // DECIMAL 1 GB override — tightens BELOW the paid plan's 10 GiB default so
597      // the effective quota is exactly 1,000,000,000 (effectiveStorageQuota =
598      // min(plan, override)). The spec's numbers (950/60/40/990 MB) are decimal.
599      //
600      // `billingPlan: "paid"` is load-bearing: the free plan's per-file limit is
601      // 50 MiB, which would trip FILE_TOO_LARGE (413) on the 60 MB file BEFORE
602      // the quota check. Paid's 500 MiB per-file limit lets 60 MB through to the
603      // quota gate — which is the contract under test.
604      await app.prisma.workspace.update({
605        where: { id: ws.workspaceId },
606        data: { billingPlan: "paid", usageLimits: { storageQuota: 1_000_000_000 } },
607      });
608      await app.prisma.workspace.update({
609        where: { id: ws.workspaceId },
610        data: { usageStorageBytes: BigInt(950_000_000) },
611      });
612 
613      // 60 MB > 1,000,000,000 − 950,000,000 = 50,000,000 → 413.
614      const over = await openUpload(ws.headers, {
615        name: "big.bin",
616        mimeType: "application/octet-stream",
617        size: 60_000_000,
618      });
619      expect(over.status).toBe(413);
620      expect(errCode(over.raw)).toBe("QUOTA_EXCEEDED");
621 
622      // 40 MB < 50,000,000 → 201; PUT + complete bumps the COLUMN to 990 MB.
623      const ok = await openUpload(ws.headers, {
624        name: "small.bin",
625        mimeType: "application/octet-stream",
626        size: 40_000_000,
627      });
628      expect(ok.status).toBe(201);
629      // PUT exactly 40 MB of zeros (octet-stream has no magic signature).
630      const zeros = new Uint8Array(40_000_000);
631      const put = await putToUrl(ok.upload!.uploadUrl, zeros, ok.upload!.headers);
632      expect(put.status).toBe(200);
633      const c = await complete(ws.headers, ok.upload!.file.id);
634      expect(c.status).toBe(200);
635 
636      // The COLUMN (not a recompute) is 990,000,000.
637      const wsRow = await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } });
638      expect(wsRow.usageStorageBytes).toBe(BigInt(990_000_000));
639    });
640 
641    // ── 7. permissions re-index + audit ────────────────────────────────────
642 
643    it("7: add a grant → GET /permissions shows it + audit file.permission_changed + permission.changed published + doc re-indexes", async () => {
644      const ws = await newWorkspace("Reindex WS");
645      const fileId = await uploadPng(ws.headers, "reindex.png");
646      const team = await app.prisma.team.create({
647        data: { workspaceId: ws.workspaceId, name: "LC Team", slug: `lc-team-${Date.now()}` },
648      });
649 
650      // Start with no explicit grants.
651      const empty = await app.request("GET", `/api/v1/files/${fileId}/permissions`, { headers: ws.headers });
652      expect(empty.status).toBe(200);
653      expect(Array.isArray(await empty.json())).toBe(true);
654 
655      // Add a TEAM VIEW grant.
656      const put = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
657        headers: ws.headers,
658        body: { grants: [{ granteeType: "team", granteeId: team.id, level: "VIEW" }] },
659      });
660      expect(put.status).toBe(200);
661      // GET /permissions now lists it (the dialog's source of truth).
662      const got = await app.request("GET", `/api/v1/files/${fileId}/permissions`, { headers: ws.headers });
663      const grants = (await got.json()) as Array<Record<string, unknown>>;
664      expect(grants).toHaveLength(1);
665      expect(grants[0]?.granteeType).toBe("team");
666      expect(grants[0]?.granteeId).toBe(team.id);
667      expect(grants[0]?.level).toBe("VIEW");
668      // audit
669      const audit = await app.prisma.auditLog.findFirst({
670        where: { action: "file.permission_changed", resourceId: fileId },
671      });
672      expect(audit).not.toBeNull();
673 
674      // The API published `permission.changed` to the event bus (the worker
675      // consumes it and enqueues a workspace-resync). Assert the event landed
676      // on the stream — the contract under test. (We do NOT consume the
677      // BullMQ enqueue itself: it is a side effect of the worker consumer, and
678      // file-level grants do NOT change search visibility by design — search
679      // is workspace/page-scoped, file-level access is enforced at read time.
680      // See the TASK-0104 rule-29 note in docs/tasks/phase-09-files.md.)
681      expect(await streamHasEvent("permission.changed", fileId)).toBe(true);
682 
683      // The re-index convergence a permission.changed resync performs: a
684      // full re-index leaves the file doc present (workspace-floor visibility
685      // is unchanged) and the mirror row converged.
686      if (searchReady) {
687        const counts = await runReindex(searchOps(), ws.workspaceId);
688        expect(counts.docs).toBeGreaterThanOrEqual(1);
689        expect(await docInSearch(`file:${fileId}`)).toBe(true);
690        const mirror = await app.prisma.searchDocument.findUnique({
691          where: { type_refId: { type: "file", refId: fileId } },
692        });
693        expect(mirror).not.toBeNull();
694      }
695    });
696 
697    // ── 8. rename ──────────────────────────────────────────────────────────
698 
699    it("8: PATCH changes only the name (size/mime/metadata unchanged) + file.updated published + doc title re-indexed", async () => {
700      const ws = await newWorkspace("Rename WS");
701      const fileId = await uploadPng(ws.headers, "before.png");
702      const before = (await fileRow(fileId))!;
703 
704      if (searchReady) await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId });
705      expect(await docInSearch(`file:${fileId}`)).toBe(searchReady ? true : false);
706 
707      const patch = await app.request("PATCH", `/api/v1/files/${fileId}`, {
708        headers: ws.headers,
709        body: { name: "after.png" },
710      });
711      expect(patch.status).toBe(200);
712      const afterRow = (await fileRow(fileId))!;
713      expect(afterRow.name).toBe("after.png");
714      // only the name changed
715      expect(afterRow.size).toBe(before.size);
716      expect(afterRow.mimeType).toBe(before.mimeType);
717      expect(afterRow.metadata).toEqual(before.metadata);
718      expect(afterRow.key).toBe(before.key);
719 
720      // audit file.renamed
721      const audit = await app.prisma.auditLog.findFirst({
722        where: { action: "file.renamed", resourceId: fileId },
723      });
724      expect(audit).not.toBeNull();
725 
726      // file.updated published → doc title re-indexed (buildFileDoc reads the
727      // fresh File.name).
728      if (searchReady) {
729        await searchEvent("file.updated", { workspaceId: ws.workspaceId, fileId, name: "after.png" });
730        const mirror = await app.prisma.searchDocument.findUnique({
731          where: { type_refId: { type: "file", refId: fileId } },
732        });
733        expect(mirror?.title).toBe("after.png");
734      }
735    });
736  },
737);
738