.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.service.tsapps/api/src/modules/files/files.service.tsraw← files36,148 B← Back to the app
1import { Inject, Injectable } from "@nestjs/common";
2import type { Request } from "express";
3import {
4  CONTENT_TYPE_OVERRIDES,
5  isAllowedMime,
6  presignGet,
7} from "@factory/storage";
8import { PrismaService } from "../../common/prisma.service.js";
9import { AuditService } from "../../common/audit.service.js";
10import { EventBus } from "../../common/event-bus.js";
11import { ApiError } from "../../common/errors.js";
12import { actorIsAdminKey, actorLevel, assertCan, assertRole, type Level } from "../../lib/authorize.js";
13import { loadEnv } from "../../config/env.js";
14import { deletePrefix, filesBucketConfig, makeFilesClient, makeThumbsClient, signExpirySeconds } from "./s3.js";
15import type {
16  ListFilesInput,
17  RenameFileInput,
18  RollbackFileInput,
19  SetPermissionsInput,
20} from "./dto.js";
21import { adjustWorkspaceStorageBytes } from "./quota.js";
22import { toFileRecord } from "./record.js";
23 
24/**
25 * TASK-0101/0104 — file reads + lifecycle:
26 *   reads    — metadata (fresh signed URLs), download (302, attachment),
27 *              preview (302, inline), list, rename
28 *   versions — list, rollback (key swap), delete-old-versions
29 *   trash    — soft delete, restore, purge (objects + thumbs + rows), list
30 *   perms    — file-level Permission rows (GET/PUT), explicit NONE = deny
31 *
32 * Authorization: every operation resolves the EFFECTIVE level through
33 * `effectiveLevel` with the FULL named chain (file + its page, TASK-0104):
34 * an explicit file grant wins by specificity, an explicit NONE on the file
35 * OR its page is a hard deny (overrides role/inheritance), everything else
36 * inherits the page/block access. Reads need ≥ VIEW; mutations need ≥ EDIT
37 * (rollback/permissions need ≥ EDIT on the file; purge needs FULL_ACCESS or
38 * a workspace Admin/Owner role). All denials are audited
39 * (`file.download_denied` carries actor + ip + path) per AGENTS.md §27.
40 *
41 * Signed URLs: 900 s (S3_SIGN_EXPIRY_SECONDS), signed for EXACTLY ONE object
42 * key (+ the disposition override, which is part of the signature). SVG is
43 * served as `attachment` with a text/plain content type (the storage layer's
44 * CONTENT_TYPE_OVERRIDES — the stored-XSS vector is closed here, not in the
45 * UI). VIRUS_DETECTED files are never signed (403 FILE_QUARANTINED).
46 *
47 * Storage quota (TASK-0104 §4): `Workspace.usageStorageBytes` = Σ
48 * FileVersion.size over non-deleted files, recomputed transactionally on
49 * delete-old-versions / purge (the only two operations that DELETE stored
50 * objects); complete() increments it (uploads.service). See `quota.ts`.
51 */
52@Injectable()
53export class FilesService {
54  constructor(
55    @Inject(PrismaService) private readonly prisma: PrismaService,
56    @Inject(AuditService) private readonly audit: AuditService,
57    @Inject(EventBus) private readonly events: EventBus,
58  ) {}
59 
60  /** `GET /files/:fileId` — metadata + fresh signed download/preview URLs. */
61  async get(fileId: string, userId: string, req: Request) {
62    // `allowQuarantined`: the DETAIL endpoint returns the record (status,
63    // scanReport) for a `VIRUS_DETECTED` file so the client can render the
64    // "Blocked by virus scan" card (docs/api/files.md §8.5: 200, no signed
65    // URLs). The bytes are still never served: `download`/`preview` (§8.6)
66    // keep the 403 `FILE_QUARANTINED`.
67    const file = await this.requireReadable(fileId, userId, req, false, true);
68    if (!file) throw ApiError.notFound("File not found.");
69    const versions = await this.prisma.fileVersion.findMany({
70      where: { fileId },
71      orderBy: { version: "asc" },
72      select: { id: true, version: true, size: true, createdAt: true },
73    });
74    const versionList = versions.map((v) => ({
75      id: v.id,
76      version: v.version,
77      size: v.size,
78      createdAt: v.createdAt.toISOString(),
79    }));
80    // A quarantined file is returned WITHOUT signed URLs (never signed, ever)
81    // so the client can still render the "Blocked by virus scan" card from
82    // `status`/`scanReport` — the fields are omitted, not null.
83    if (file.status === "VIRUS_DETECTED") {
84      return { ...toFileRecord(file, versions.length || 1), versions: versionList };
85    }
86    const urls = this.signedUrls(file);
87    return {
88      ...toFileRecord(file, versions.length || 1),
89      downloadUrl: urls.downloadUrl,
90      previewUrl: urls.previewUrl,
91      expiresAt: urls.expiresAt,
92      versions: versionList,
93    };
94  }
95 
96  /**
97   * `GET /files/:fileId/download` → 302 to a signed GET (900 s,
98   * `response-content-disposition: attachment; filename="…"` SIGNED into the
99   * URL). Audits `file.downloaded`; denials → 403 + `file.download_denied`.
100   */
101  async download(fileId: string, userId: string, req: Request): Promise<{ location: string; expiresAt: string }> {
102    const file = await this.requireReadable(fileId, userId, req, true);
103    if (!file) throw ApiError.notFound("File not found.");
104    const urls = this.signedUrls(file, "attachment");
105    await this.audit.audit(req, req.auth, {
106      action: "file.downloaded",
107      resourceType: "file",
108      resourceId: file.id,
109      workspaceId: file.workspaceId,
110      metadata: { name: file.name, size: file.size, mimeType: file.mimeType },
111    });
112    return { location: urls.downloadUrl, expiresAt: urls.expiresAt };
113  }
114 
115  /** `GET /files/:fileId/preview` → 302 signed GET, inline disposition
116   *  (image/video/audio/pdf). SVG stays attachment-only (override table). */
117  async preview(fileId: string, userId: string, req: Request): Promise<{ location: string; expiresAt: string }> {
118    const file = await this.requireReadable(fileId, userId, req, true);
119    if (!file) throw ApiError.notFound("File not found.");
120    const urls = this.signedUrls(file, "inline");
121    return { location: urls.previewUrl, expiresAt: urls.expiresAt };
122  }
123 
124  /**
125   * `GET /files?scope=…` — server-side permission-filtered list.
126   *
127   * Keyset (cursor) pagination: the opaque `cursor` encodes the last row of
128   * the previous page as `{id, <sortValue>}`; the next page is the rows
129   * strictly after it in the requested sort (ties broken by `id`, the
130   * stable unique tiebreaker). This is O(limit) regardless of page depth and
131   * is correct under concurrent inserts/deletes (unlike `OFFSET`).
132   *
133   * TODO(TASK-0104): permission-filter (explicit file grants / NONE deny on a
134   * per-page basis) once the file scope lands in effectiveLevel. For now the
135   * workspace VIEW floor (assertCan) is the gate — every workspace member sees
136   * workspace files, which matches the role-as-floor model (a non-member has
137   * no role → NONE → 403, so the workspace list is already member-only).
138   */
139  async list(workspaceId: string, userId: string, query: ListFilesInput) {
140    await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
141    const sort = query.sort;
142    const col: "name" | "size" | "createdAt" | "updatedAt" =
143      sort === "name" ? "name" : sort === "size" ? "size" : sort === "date" ? "createdAt" : "updatedAt";
144    const dir: "asc" | "desc" = sort === "name" ? "asc" : "desc";
145 
146    const after = query.cursor ? decodeCursor(query.cursor) : null;
147    // `size` is an Int column → the cursor value must be a number for the
148    // `lt`/`gt` filter; name/dates are compared as strings.
149    const cv = after ? (col === "size" ? Number(after.sortValue) : after.sortValue) : null;
150 
151    const files = await this.prisma.file.findMany({
152      where: {
153        workspaceId,
154        deletedAt: null,
155        ...(query.scope === "page" && query.pageId
156          ? { pageId: query.pageId }
157          : {}),
158        ...(query.status ? { status: query.status } : {}),
159        ...(query.search
160          ? { name: { contains: query.search, mode: "insensitive" as const } }
161          : {}),
162        ...(query.kind ? { mimeType: { in: mimesForKind(query.kind) } } : {}),
163        ...(after
164          ? {
165              // Keyset "after" predicate: strictly past the cursor's sortValue,
166              // or on the same sortValue with an id past the cursor's id.
167              // `size` is an Int column → coerce; name/dates stay strings.
168              OR: [
169                { [col]: dir === "asc" ? { gt: cv } : { lt: cv } },
170                { [col]: cv, id: dir === "asc" ? { gt: after.id } : { lt: after.id } },
171              ],
172            } : {}),
173      },
174      orderBy: [{ [col]: dir }, { id: dir }],
175      take: query.limit,
176    });
177 
178    // Enrich: uploader name, page name, version count (plain refs → batch
179    // lookups, not relations). Keep the response lean (no raw `key`).
180    const uploaderIds = [...new Set(files.map((f) => f.uploadedBy))];
181    const pageIds = [...new Set(files.map((f) => f.pageId).filter(Boolean))] as string[];
182    const [users, pages, versionCounts] = await Promise.all([
183      this.prisma.user.findMany({
184        where: { id: { in: uploaderIds } },
185        select: { id: true, name: true },
186      }),
187      this.prisma.page.findMany({
188        where: { id: { in: pageIds } },
189        select: { id: true, title: true },
190      }),
191      this.prisma.fileVersion.groupBy({
192        by: ["fileId"],
193        where: { fileId: { in: files.map((f) => f.id) } },
194        _count: { _all: true },
195      }),
196    ]);
197    const userName = new Map(users.map((u) => [u.id, u.name]));
198    const pageName = new Map(pages.map((p) => [p.id, p.title]));
199    const versionCount = new Map(versionCounts.map((v) => [v.fileId, v._count._all]));
200 
201    const last = files[files.length - 1];
202    const nextCursor =
203      files.length === query.limit && last
204        ? encodeCursor({
205            id: last.id,
206            // Date columns → ISO (what the Prisma DateTime filter expects);
207            // name → the string; size → the decimal string (Number() on read).
208            sortValue:
209              col === "createdAt" || col === "updatedAt"
210                ? (last[col] as Date).toISOString()
211                : String(last[col])
212          })
213        : null;
214 
215    return {
216      data: files.map((f) => ({
217        ...toFileRecord(f, versionCount.get(f.id) ?? 1),
218        uploadedByName: userName.get(f.uploadedBy) ?? null,
219        pageName: f.pageId ? (pageName.get(f.pageId) ?? null) : null,
220        versionCount: versionCount.get(f.id) ?? 1,
221      })),
222      nextCursor,
223    };
224  }
225 
226  /** `PATCH /files/:id` — rename only (size/mime are immutable). */
227  async rename(fileId: string, userId: string, input: RenameFileInput, req: Request) {
228    const file = await this.prisma.file.findUnique({ where: { id: fileId } });
229    if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
230    await this.requireFileLevel(file, userId, "EDIT");
231    if (!isAllowedMime(file.mimeType)) {
232      // The record is what it is; rename is still allowed (the type gate is
233      // an upload-time control, not a read-time one).
234    }
235    const updated = await this.prisma.file.update({
236      where: { id: file.id },
237      data: { name: input.name },
238    });
239    await this.audit.audit(req, req.auth, {
240      action: "file.renamed",
241      resourceType: "file",
242      resourceId: file.id,
243      workspaceId: file.workspaceId,
244      metadata: { from: file.name, to: input.name },
245    });
246    // TASK-0104 §8: the rename must converge in the search index WITHOUT
247    // waiting for the 15-min reconcile (the doc's title is File.name). The
248    // worker's search consumer re-upserts the doc on `file.updated`
249    // (buildFileDoc reads the fresh name; a deleted row returns null → no-op).
250    await this.events.publish("file.updated", {
251      workspaceId: file.workspaceId,
252      fileId: file.id,
253      name: updated.name,
254    });
255    return toFileRecord(updated, 1);
256  }
257 
258  // ── TASK-0104: versions ────────────────────────────────────────────────
259 
260  /** `GET /files/:id/versions` — list FileVersion rows (ascending). */
261  async versions(fileId: string, userId: string, req: Request) {
262    const file = await this.prisma.file.findUnique({ where: { id: fileId } });
263    if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
264    await this.requireFileLevel(file, userId, "VIEW");
265    const rows = await this.prisma.fileVersion.findMany({
266      where: { fileId },
267      orderBy: { version: "asc" },
268      include: { user: { select: { id: true, name: true } } },
269    });
270    const currentVersion = (await this.prisma.fileVersion.findFirst({
271      where: { fileId, key: file.key },
272      orderBy: { version: "desc" },
273    }))?.version ?? null;
274    // The raw `key` is NEVER returned (spec §Security: only signed URLs leave
275    // the server; `FileRecord` excludes it). The client needs version/size/
276    // created/current — the current version's content is reached via the
277    // detail's fresh signed URLs, not a per-version key.
278    return rows.map((v) => ({
279      id: v.id,
280      version: v.version,
281      size: v.size,
282      current: v.version === currentVersion,
283      createdBy: v.createdBy ?? null,
284      createdByName: v.user?.name ?? null,
285      createdAt: v.createdAt.toISOString(),
286    }));
287  }
288 
289  /**
290   * `POST /files/:id/rollback` — make version `n` current. KEY SWAP, not a
291   * copy: `File.key/size` are re-pointed at version n's existing object
292   * (no new object, no re-scan — the bytes already passed the pipeline).
293   * Requires ≥ EDIT on the file (spec: FULL_ACCESS on the file grant is the
294   * UI affordance; the effective-level floor is EDIT, matching rename).
295   */
296  async rollback(fileId: string, userId: string, input: RollbackFileInput, req: Request) {
297    const file = await this.prisma.file.findUnique({ where: { id: fileId } });
298    if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
299    await this.requireFileLevel(file, userId, "EDIT");
300    const target = await this.prisma.fileVersion.findUnique({
301      where: { fileId_version: { fileId: file.id, version: input.version } },
302    });
303    if (!target) {
304      throw new ApiError("NOT_FOUND", 404, `Version ${input.version} does not exist for this file.`, [
305        { reason: "version_not_found", version: input.version },
306      ]);
307    }
308    const updated = await this.prisma.file.update({
309      where: { id: file.id },
310      data: { key: target.key, size: target.size },
311    });
312    await this.audit.audit(req, req.auth, {
313      action: "file.rolled_back",
314      resourceType: "file",
315      resourceId: file.id,
316      workspaceId: file.workspaceId,
317      metadata: { name: file.name, version: input.version, fromKey: file.key, toKey: target.key },
318    });
319    return toFileRecord(updated, input.version);
320  }
321 
322  /**
323   * `POST /files/:id/versions/prune` — "Delete old versions" (admin action):
324   * delete every FileVersion row + its object + thumbnail EXCEPT the current
325   * version's row/object. Requires ≥ EDIT on the file (the UI gates it behind
326   * the permissions dialog / admin confirm). Quota counter recomputed in the
327   * same transaction (the pruned bytes leave the stored total).
328   */
329  async deleteOldVersions(fileId: string, userId: string, req: Request) {
330    const file = await this.prisma.file.findUnique({ where: { id: fileId } });
331    if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
332    await this.requireFileLevel(file, userId, "EDIT");
333    const s3 = makeFilesClient();
334    const s3Thumbs = makeThumbsClient();
335    const pruned = await this.prisma.$transaction(async (tx) => {
336      const rows = await tx.fileVersion.findMany({ where: { fileId: file.id } });
337      // Keep the row(s) whose key is the file's CURRENT key (usually exactly
338      // one; a rollback can leave the current key on an older version).
339      const keep = rows.filter((r) => r.key === file.key);
340      const drop = rows.filter((r) => r.key !== file.key);
341      for (const r of drop) {
342        await s3.deleteObject(r.key).catch(() => undefined);
343      }
344      // Thumbnails are version-less in the key (thumb files/{ws}/{fileId}/…),
345      // so prune only deletes the CURRENT thumbnail set — but the current set
346      // is what stays visible; old-version thumbnails share the current
347      // thumbnailKey (the worker rewrites it on finalize), so there is no
348      // per-version thumb to delete. (Recorded: thumbs are current-only.)
349      if (drop.length > 0) {
350        const droppedBytes = BigInt(drop.reduce((sum, r) => sum + r.size, 0));
351        await tx.fileVersion.deleteMany({ where: { id: { in: drop.map((r) => r.id) } } });
352        // Event counter: the pruned bytes leave the stored total (transactional).
353        await adjustWorkspaceStorageBytes(tx, file.workspaceId, -droppedBytes);
354      }
355      return drop.length;
356    });
357    if (pruned === 0) return { pruned: 0 };
358    await this.audit.audit(req, req.auth, {
359      action: "file.versions_pruned",
360      resourceType: "file",
361      resourceId: file.id,
362      workspaceId: file.workspaceId,
363      metadata: { name: file.name, pruned },
364    });
365    return { pruned };
366  }
367 
368  // ── TASK-0104: trash (soft delete / restore / purge) ───────────────────
369 
370  /**
371   * `DELETE /files/:id` (or `?purge=true`) — trash a file (soft) or purge it
372   * (hard). Soft: sets `deletedAt` (object + versions retained — restore is
373   * possible), emits `file.deleted` (search index removal), audit
374   * `file.deleted`. Requires ≥ EDIT on the file (the file's page or file
375   * grant, per spec §3).
376   */
377  async remove(fileId: string, userId: string, req: Request, purge = false) {
378    const file = await this.prisma.file.findUnique({ where: { id: fileId } });
379    if (!file) throw ApiError.notFound("File not found.");
380    if (purge) {
381      // Purge works on BOTH active and trashed files (the trash view purges
382      // trashed rows); a missing row is the only 404.
383      await this.purge(file, userId, req);
384      return { purged: true };
385    }
386    if (file.deletedAt) {
387      throw ApiError.conflict("File is already in the trash.", [{ reason: "already_trashed" }]);
388    }
389    await this.requireFileLevel(file, userId, "EDIT");
390    await this.prisma.file.update({ where: { id: file.id }, data: { deletedAt: new Date() } });
391    await this.audit.audit(req, req.auth, {
392      action: "file.deleted",
393      resourceType: "file",
394      resourceId: file.id,
395      workspaceId: file.workspaceId,
396      metadata: { name: file.name, size: file.size, key: file.key },
397    });
398    await this.events.publish("file.deleted", {
399      workspaceId: file.workspaceId,
400      fileId: file.id,
401      name: file.name,
402      size: file.size,
403      blockId: file.blockId,
404      pageId: file.pageId,
405    });
406    return { trashed: true, deletedAt: new Date().toISOString() };
407  }
408 
409  /**
410   * `POST /files/:id/restore` — bring a trashed file back: clears `deletedAt`,
411   * re-emits `file.uploaded` with `restored: true` (search re-index), audit
412   * `file.restored`. No quota change (the bytes were retained while trashed).
413   * Same permission as delete (≥ EDIT on the file).
414   */
415  async restore(fileId: string, userId: string, req: Request) {
416    const file = await this.prisma.file.findUnique({ where: { id: fileId } });
417    if (!file || !file.deletedAt) throw ApiError.notFound("File not found (or not in trash).");
418    await this.requireFileLevel(file, userId, "EDIT");
419    await this.prisma.file.update({ where: { id: file.id }, data: { deletedAt: null } });
420    await this.audit.audit(req, req.auth, {
421      action: "file.restored",
422      resourceType: "file",
423      resourceId: file.id,
424      workspaceId: file.workspaceId,
425      metadata: { name: file.name, size: file.size, key: file.key },
426    });
427    await this.events.publish("file.uploaded", {
428      workspaceId: file.workspaceId,
429      fileId: file.id,
430      name: file.name,
431      size: file.size,
432      mimeType: file.mimeType,
433      blockId: file.blockId,
434      pageId: file.pageId,
435      restored: true,
436    });
437    return toFileRecord(file, 1);
438  }
439 
440  /**
441   * Purge (hard delete) — the ONLY path that deletes stored objects.
442   * Deletes the current object + every version object + the thumbnail set +
443   * the `File` and `FileVersion` rows; audit `file.purged`. Requires
444   * FULL_ACCESS on the file (effective level) OR the workspace Admin/Owner
445   * role (spec §3 "FULL_ACCESS or workspace Admin/Owner").
446   */
447  private async purge(file: { id: string; workspaceId: string; name: string; key: string; pageId: string | null }, userId: string, req: Request): Promise<void> {
448    // FULL_ACCESS via the file+page effective level, OR the workspace role
449    // (Admin/Owner) — a workspace admin can purge any file in the workspace
450    // from the trash view without a per-file grant. `deletedAt` rows ARE
451    // purgeable (that is the trash view's main use); a missing row is 404.
452    const fileRow = await this.prisma.file.findUnique({ where: { id: file.id } });
453    if (!fileRow) throw ApiError.notFound("File not found (or already purged).");
454    // TASK-0127: actor-aware — an API key's purge right comes from its
455    // SCOPE (ADMIN → FULL_ACCESS), never the creator's membership.
456    const level = await actorLevel(this.prisma, {
457      workspaceId: fileRow.workspaceId,
458      userId,
459      resource: { fileId: fileRow.id, pageId: fileRow.pageId ?? undefined },
460    });
461    const isRoleAdmin =
462      actorIsAdminKey(fileRow.workspaceId) || (await this.workspaceRole(fileRow.workspaceId, userId));
463    if (level !== "FULL_ACCESS" && !isRoleAdmin) {
464      await this.audit.audit(req, req.auth, {
465        action: "file.purge_denied",
466        resourceType: "file",
467        resourceId: file.id,
468        workspaceId: fileRow.workspaceId,
469        metadata: { name: file.name, level, reason: "purge_requires_full_access_or_admin" },
470      });
471      throw new ApiError("FORBIDDEN", 403, "Purging requires FULL_ACCESS on the file or a workspace Admin/Owner role.", [
472        { reason: "purge_requires_full_access_or_admin", have: level },
473      ]);
474    }
475    const s3 = makeFilesClient();
476    const s3Thumbs = makeThumbsClient();
477    const workspaceId = fileRow.workspaceId;
478    const thumbPrefix = `thumbs/${workspaceId}/${file.id}/`;
479    let versionCount = 0;
480    await this.prisma.$transaction(async (tx) => {
481      const rows = await tx.fileVersion.findMany({ where: { fileId: file.id } });
482      versionCount = rows.length;
483      for (const r of rows) {
484        await s3.deleteObject(r.key).catch(() => undefined);
485      }
486      // The WHOLE thumbnail set, not just the single recorded `thumbnailKey`:
487      // a file can own several artifacts (160/480/1280 for an image) under
488      // `thumbs/{ws}/{fileId}/{size}/…`; deleting only the 480 primary would
489      // orphan the rest (spec §3 "…+ thumbnails + DB rows").
490      await deletePrefix(s3Thumbs, thumbPrefix).catch(() => 0);
491      await tx.fileVersion.deleteMany({ where: { fileId: file.id } });
492      await tx.file.delete({ where: { id: file.id } });
493      // Event counter: every retained version's bytes leave the stored total.
494      const purgedBytes = BigInt(rows.reduce((sum, r) => sum + r.size, 0));
495      if (purgedBytes > 0n) await adjustWorkspaceStorageBytes(tx, workspaceId, -purgedBytes);
496    });
497    await this.audit.audit(req, req.auth, {
498      action: "file.purged",
499      resourceType: "file",
500      resourceId: file.id,
501      workspaceId,
502      metadata: { name: file.name, key: fileRow.key, thumbPrefix, versions: versionCount },
503    });
504  }
505 
506  /** `GET /files/trash` — workspace files with `deletedAt` set. */
507  async trashList(workspaceId: string, userId: string) {
508    await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
509    const files = await this.prisma.file.findMany({
510      where: { workspaceId, deletedAt: { not: null } },
511      orderBy: { deletedAt: "desc" },
512      take: 200,
513    });
514    const uploaderIds = [...new Set(files.map((f) => f.uploadedBy))];
515    const users = await this.prisma.user.findMany({ where: { id: { in: uploaderIds } }, select: { id: true, name: true } });
516    const userName = new Map(users.map((u) => [u.id, u.name]));
517    return files.map((f) => ({
518      ...toFileRecord(f, 1),
519      deletedAt: f.deletedAt?.toISOString() ?? null,
520      uploadedByName: userName.get(f.uploadedBy) ?? null,
521    }));
522  }
523 
524  // ── TASK-0104: file-level permission overrides ─────────────────────────
525 
526  /** `GET /files/:id/permissions` — the file's explicit grants. */
527  async listPermissions(fileId: string, userId: string, req: Request) {
528    const file = await this.prisma.file.findUnique({ where: { id: fileId } });
529    if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
530    await this.requireFileLevel(file, userId, "EDIT");
531    const grants = await this.prisma.permission.findMany({
532      where: { workspaceId: file.workspaceId, resourceType: "file", resourceId: file.id },
533    });
534    return this.hydrateGrants(file.workspaceId, grants);
535  }
536 
537  /**
538   * `PUT /files/:id/permissions` — replace the file's explicit grants with
539   * `grants` (add/remove). `level` may be NONE (explicit deny). Each change
540   * audits `file.permission_changed` and emits `permission.changed`
541   * (search re-index). Requires ≥ EDIT on the file.
542   */
543  async setPermissions(fileId: string, userId: string, input: SetPermissionsInput, req: Request) {
544    const file = await this.prisma.file.findUnique({ where: { id: fileId } });
545    if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
546    await this.requireFileLevel(file, userId, "EDIT");
547    const wanted = new Set(input.grants.map((g) => `${g.granteeType}:${g.granteeId}:${g.level}`));
548    const existing = await this.prisma.permission.findMany({
549      where: { workspaceId: file.workspaceId, resourceType: "file", resourceId: file.id },
550    });
551    const existingSet = new Set(existing.map((g) => `${g.granteeType}:${g.granteeId}:${g.level}`));
552    // Remove rows that are not in the wanted set.
553    for (const g of existing) {
554      if (!wanted.has(`${g.granteeType}:${g.granteeId}:${g.level}`)) {
555        await this.prisma.permission.delete({ where: { id: g.id } });
556      }
557    }
558    // Add rows that are new.
559    for (const g of input.grants) {
560      if (!existingSet.has(`${g.granteeType}:${g.granteeId}:${g.level}`)) {
561        await this.prisma.permission.upsert({
562          where: {
563            workspaceId_granteeType_granteeId_resourceType_resourceId: {
564              workspaceId: file.workspaceId,
565              granteeType: g.granteeType,
566              granteeId: g.granteeId,
567              resourceType: "file",
568              resourceId: file.id,
569            },
570          },
571          update: { level: g.level },
572          create: {
573            workspaceId: file.workspaceId,
574            granteeType: g.granteeType,
575            granteeId: g.granteeId,
576            resourceType: "file",
577            resourceId: file.id,
578            level: g.level,
579          },
580        });
581      }
582    }
583    await this.audit.audit(req, req.auth, {
584      action: "file.permission_changed",
585      resourceType: "file",
586      resourceId: file.id,
587      workspaceId: file.workspaceId,
588      metadata: { name: file.name, grants: input.grants },
589    });
590    // TASK-0133 audit catalog: a file-grant change IS a permission change —
591    // fire the catalog's `permission.changed` (required metadata: `change`,
592    // `targetId`) in addition to the legacy row. Only when the set actually
593    // changed (idempotent re-sends are not a security-relevant change).
594    if (wanted.size !== existingSet.size || [...wanted].some((k) => !existingSet.has(k))) {
595      await this.audit.audit(req, req.auth, {
596        action: "permission.changed",
597        resourceType: "file",
598        resourceId: file.id,
599        workspaceId: file.workspaceId,
600        metadata: { change: "file_grants", targetId: file.id, grants: input.grants },
601      });
602    }
603    await this.events.publish("permission.changed", {
604      workspaceId: file.workspaceId,
605      resourceType: "file",
606      resourceId: file.id,
607      userId,
608    });
609    return this.listPermissions(file.id, userId, req);
610  }
611 
612  // ── internals ────────────────────────────────────────────────────────
613 
614  /**
615   * Resolve the effective level of `userId` for THIS FILE (file + its page)
616   * and assert ≥ `need`. The file scope is what gives explicit file grants
617   * (and explicit NONE denies) their effect — a page-scoped walk alone would
618   * ignore file-level overrides (TASK-0104 §2/§5).
619   */
620  private async requireFileLevel(
621    file: { id: string; workspaceId: string; pageId: string | null },
622    userId: string,
623    need: Exclude<Level, "NONE">
624  ): Promise<Level> {
625    return assertCan(this.prisma, {
626      workspaceId: file.workspaceId,
627      userId,
628      resource: { fileId: file.id, pageId: file.pageId ?? undefined },
629    }, need);
630  }
631 
632  /** The workspace role for `userId` (null when not a member). */
633  private async workspaceRole(workspaceId: string, userId: string): Promise<boolean> {
634    const m = await this.prisma.workspaceMember.findUnique({
635      where: { workspaceId_userId: { workspaceId, userId } },
636    });
637    return !!m && (m.role === "ADMIN" || m.role === "OWNER");
638  }
639 
640  /** Hydrate Permission rows with grantee display names (user/team/role). */
641  private async hydrateGrants(
642    workspaceId: string,
643    grants: { id: string; granteeType: string; granteeId: string; level: string; inheritsFrom: string | null }[]
644  ) {
645    const userIds = grants.filter((g) => g.granteeType === "user").map((g) => g.granteeId);
646    const teamIds = grants.filter((g) => g.granteeType === "team").map((g) => g.granteeId);
647    const [users, teams] = await Promise.all([
648      userIds.length
649        ? this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, email: true } })
650        : [],
651      teamIds.length
652        ? this.prisma.team.findMany({ where: { id: { in: teamIds } }, select: { id: true, name: true } })
653        : [],
654    ]);
655    const userName = new Map(users.map((u) => [u.id, u.name]));
656    const userEmail = new Map(users.map((u) => [u.id, u.email]));
657    const teamName = new Map(teams.map((t) => [t.id, t.name]));
658    return grants.map((g) => ({
659      id: g.id,
660      granteeType: g.granteeType,
661      granteeId: g.granteeId,
662      level: g.level,
663      inheritsFrom: g.inheritsFrom,
664      granteeName:
665        g.granteeType === "user" ? userName.get(g.granteeId) ?? g.granteeId
666        : g.granteeType === "team" ? teamName.get(g.granteeId) ?? g.granteeId
667        : g.granteeId,
668      granteeEmail: g.granteeType === "user" ? userEmail.get(g.granteeId) ?? null : null,
669    }));
670  }
671 
672  /**
673   * Resolve the file and enforce ≥ VIEW. Returns null (→ 404) when absent.
674   * `auditDenied` records `file.download_denied` on a permission failure.
675   * `allowQuarantined` (detail endpoint only): a `VIRUS_DETECTED` file is
676   * still RESOLVED (so the client can render the "Blocked by virus scan"
677   * card, docs/api/files.md §8.5) but is never signed — the byte-serving
678   * endpoints (`download`/`preview`, §8.6) keep the 403 `FILE_QUARANTINED`.
679   */
680  private async requireReadable(
681    fileId: string,
682    userId: string,
683    req: Request,
684    auditDenied = false,
685    allowQuarantined = false,
686  ) {
687    const file = await this.prisma.file.findUnique({ where: { id: fileId } });
688    if (!file || file.deletedAt) return null;
689 
690    // VIRUS_DETECTED: quarantine — never signed, ever (spec TASK-0102 §Security).
691    if (file.status === "VIRUS_DETECTED" && !allowQuarantined) {
692      if (auditDenied) {
693        await this.audit.audit(req, req.auth, {
694          action: "file.download_denied",
695          resourceType: "file",
696          resourceId: file.id,
697          workspaceId: file.workspaceId,
698          metadata: { reason: "quarantined", name: file.name },
699        });
700      }
701      throw new ApiError("FILE_QUARANTINED", 403,
702        "This file was blocked by the virus scan and cannot be downloaded.",
703        [{ reason: "file_quarantined" }]);
704    }
705 
706    try {
707      // TASK-0104: resolve with the FULL named chain — the file itself plus
708      // its page. `effectiveLevel` gives the file grant the most specificity,
709      // an explicit NONE on the file OR its page is a hard deny (returned as
710      // NONE, so VIEW is never satisfied), and otherwise the level inherits
711      // from the page/block (the Phase-3 walk). Passing only `pageId` here was
712      // the pre-TASK-0104 behavior (no file scope, no NONE deny).
713      await assertCan(this.prisma, {
714        workspaceId: file.workspaceId,
715        userId,
716        resource: { fileId: file.id, pageId: file.pageId ?? undefined },
717      }, "VIEW");
718      return file;
719    } catch (err) {
720      if (auditDenied) {
721        await this.audit.audit(req, req.auth, {
722          action: "file.download_denied",
723          resourceType: "file",
724          resourceId: file.id,
725          workspaceId: file.workspaceId,
726          metadata: { reason: "no_view", name: file.name, path: req.originalUrl },
727        });
728      }
729      throw err;
730    }
731  }
732 
733  /** Fresh 900 s signed URLs for the file's current object key. */
734  private signedUrls(
735    file: { key: string; name: string; mimeType: string },
736    disposition: "attachment" | "inline" = "attachment",
737  ): { downloadUrl: string; previewUrl: string; expiresAt: string } {
738    const env = loadEnv();
739    const expiresSec = signExpirySeconds(env);
740    const now = new Date();
741    const config = filesBucketConfig(env);
742 
743    // SVG: attachment-only + text/plain (the storage-layer override —
744    // no inline render, ever).
745    const override = CONTENT_TYPE_OVERRIDES[file.mimeType];
746    const disp = (d: "attachment" | "inline") =>
747      `${override ? "attachment" : d}; filename="${escapeQuotes(file.name)}"`;
748 
749    const dl = presignGet({
750      config,
751      key: file.key,
752      expiresSec,
753      now,
754      responseContentDisposition: disp("attachment"),
755    });
756    const pv = presignGet({
757      config,
758      key: file.key,
759      expiresSec,
760      now,
761      responseContentDisposition: disp("inline"),
762    });
763    return {
764      downloadUrl: dl.url,
765      previewUrl: pv.url,
766      expiresAt: new Date(now.getTime() + expiresSec * 1000).toISOString(),
767    };
768  }
769}
770 
771function escapeQuotes(name: string): string {
772  return name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
773}
774 
775// ── keyset cursor (opaque to the client) ────────────────────────────────────
776// base64url(JSON {id, sortValue}). `sortValue` is the stringified sort-column
777// value of the last row (dates → ISO string, sizes → decimal).
778 
779interface FileCursor {
780  id: string;
781  sortValue: string;
782}
783 
784function encodeCursor(c: FileCursor): string {
785  return Buffer.from(JSON.stringify(c), "utf8").toString("base64url");
786}
787 
788function decodeCursor(raw: string): FileCursor | null {
789  try {
790    const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
791    if (
792      parsed &&
793      typeof parsed.id === "string" &&
794      typeof parsed.sortValue === "string" &&
795      parsed.id.length > 0
796    ) {
797      return parsed as FileCursor;
798    }
799    return null;
800  } catch {
801    // A corrupt/foreign cursor must not 500 the list — treat as "first page".
802    return null;
803  }
804}
805 
806const KIND_MIMES: Record<string, string[]> = {
807  image: ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"],
808  video: ["video/mp4", "video/webm", "video/quicktime"],
809  audio: ["audio/mpeg", "audio/wav", "audio/mp4", "audio/x-wav"],
810  pdf: ["application/pdf"],
811  doc: [
812    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
813    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
814    "application/vnd.openxmlformats-officedocument.presentationml.presentation",
815    "text/markdown",
816    "text/plain",
817    "text/csv",
818  ],
819  other: ["application/zip", "application/octet-stream"],
820};
821 
822function mimesForKind(kind: string): string[] {
823  return KIND_MIMES[kind] ?? [];
824}
825