.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.spec.tstests/e2e/specs/files.spec.tsraw← specs26,477 B← Back to the app
1/**
2 * AI Software Factory — E2E: Files & Uploads (Phase 9, TASK-0103)
3 *
4 * Covers the real Phase-9 presigned contract (see docs/api/files.md §8):
5 *
6 *  1. image upload through the UI file-picker block (the hidden
7 *     `[data-testid="file-input"]`) → the block re-renders in place with the
8 *     image (the picker block uploads its first file onto itself,
9 *     `updateBlockId`).
10 *  2. PDF uploaded via the API + attached to a file block → renders the
11 *     preview iframe.
12 *  3. oversized file (declared size above the plan limit) → rejected at
13 *     session creation with `FILE_TOO_LARGE` 413.
14 *  4. MIME spoof (declared `image/png`, text bytes) → rejected at complete
15 *     with `MIME_MISMATCH` 415.
16 *  5. a non-member cannot read the file (403 on `GET /files/:id` and
17 *     `GET /files/:id/download`; 403 on listing a foreign workspace).
18 *  6. video mp4 → the file block renders a native `<video>` that decodes
19 *     (videoWidth > 0 after loadedmetadata).
20 *  7. audio wav → the file block renders a native `<audio>` with the name in
21 *     its text alternative.
22 *  8. document (text/plain) → the file block renders the download card
23 *     (`file-name-{id}` + `file-download-{id}` button, mime · human size).
24 *  9. EICAR (ClamAV test signature) → the block renders the red
25 *     "Blocked by virus scan" card with NO download affordance (the
26 *     TASK-0103 "virus block state rendered" DoD item; the 500 MB /
27 *     path-traversal / audit parts of the security suite are TASK-0105).
28 * 10. the file block's "Add file" button (upload path c) — the REAL browser
29 *     upload flow: set a file on the picker's hidden input → the block
30 *     uploads via the presigned XHR PUT and re-renders in place with the
31 *     image (proves the browser→MinIO round trip, not just the API helper).
32 *
33 * Selector convention (tests/README.md §Selector):
34 *   data-testid="editor-root"             editor canvas (also the drop target)
35 *   data-testid="file-input"              hidden <input type="file"> (picker)
36 *   data-testid="file-card-{fileId}"      rendered file block (per kind)
37 *   data-testid="file-name-{fileId}"      filename on doc/other cards
38 *   data-testid="file-download-{fileId}"  download button on doc/other cards
39 *
40 * The full security suite (500 MB upload, EICAR/malware, path traversal) is
41 * TASK-0105's scope per the spec; this spec owns the upload → render →
42 * download happy paths + the two rejection cases above.
43 */
44import * as fs from "node:fs";
45import * as os from "node:os";
46import * as path from "node:path";
47import { test, expect } from "../fixtures";
48import {
49  uploadFile,
50  createPage,
51  createBlock,
52  getFile,
53  openUploadSession,
54  putPresigned,
55  workspaceHeader,
56} from "../utils/api";
57 
58/** Read a binary fixture from `tests/e2e/fixtures/files/`. */
59function fixture(name: string): Buffer {
60  return fs.readFileSync(path.join(__dirname, "..", "fixtures", "files", name));
61}
62 
63// A tiny valid PNG (1x1 red pixel).
64const TINY_PNG = Buffer.from(
65  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
66  "base64",
67);
68 
69// A minimal but valid PDF (one blank page).
70const TINY_PDF = Buffer.from(
71  "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
72    "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
73    "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n" +
74    "xref\n0 4\n0000000000 65535 f \n" +
75    "trailer<</Size 4/Root 1 0 R>>\nstartxref\n9\n%%EOF",
76  "latin1",
77);
78 
79// The standard 69-byte ClamAV test signature (identical to the integration
80// fixture) — the worker flags it VIRUS_DETECTED via `zCONTSCAN`.
81const EICAR = Buffer.from(
82  "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*",
83  "latin1",
84);
85 
86test.describe("Files — Phase 9 (TASK-0103)", () => {
87  test("image renders in a file block (picker input present for upload path c)", async ({
88    authPage,
89    api,
90    freshWorkspace,
91  }) => {
92    const ws = await freshWorkspace("Image Space");
93    const page = await createPage(api, ws.id, { title: "Image Page" });
94    const file = await uploadFile(api, "pixel.png", "image/png", TINY_PNG, ws.id);
95    expect(file.status).toBe("READY");
96    // A `file` block with a fileId renders the image; an empty `file` block
97    // renders the picker ("Add file") — upload path c. Block type is the
98    // UPPERCASE wire enum (API zod `BLOCK_TYPES`; the realtime layer maps it
99    // to the editor codec's lowercase on read). Both created before the single
100    // navigation.
101    await createBlock(api, page.id, { type: "FILE", properties: { fileId: file.id } });
102    await createBlock(api, page.id, { type: "FILE", properties: {} });
103 
104    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
105    const card = authPage.getByTestId(`file-card-${file.id}`);
106    await expect(card).toBeVisible({ timeout: 45_000 });
107    const img = card.locator("img").first();
108    await expect(img).toBeVisible();
109    await expect(img).toHaveAttribute("src", /.+/);
110 
111    // The empty file block exposes the picker's hidden input.
112    await expect(authPage.getByTestId("file-input")).toBeAttached({ timeout: 10_000 });
113  });
114 
115  test("upload PDF via API → file block renders the preview iframe", async ({
116    authPage,
117    api,
118    freshWorkspace,
119  }) => {
120    const ws = await freshWorkspace("Pdf Space");
121    const page = await createPage(api, ws.id, { title: "Pdf Page" });
122    const file = await uploadFile(api, "spec.pdf", "application/pdf", TINY_PDF, ws.id);
123    expect(file.status).toBe("READY");
124    await createBlock(api, page.id, {
125      type: "file",
126      properties: { fileId: file.id },
127    });
128 
129    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
130    const card = authPage.getByTestId(`file-card-${file.id}`);
131    await expect(card).toBeVisible({ timeout: 45_000 });
132    const frame = card.locator("iframe").first();
133    await expect(frame).toBeVisible();
134    await expect(frame).toHaveAttribute("src", /.+/);
135  });
136 
137  test("oversized file (declared size above plan limit) → 413 FILE_TOO_LARGE", async ({
138    api,
139    freshWorkspace,
140  }) => {
141    const ws = await freshWorkspace("Oversize Space");
142    // The free plan caps at 50 MB; declare well over it (no bytes are sent —
143    // the rejection happens at session creation).
144    const res = await openUploadSession(
145      api,
146      ws.id,
147      "huge.bin",
148      "application/octet-stream",
149      120 * 1024 * 1024,
150    );
151    expect(res.status).toBe(413);
152    expect(res.code).toBe("FILE_TOO_LARGE");
153  });
154 
155  test("MIME spoof (declared png, text bytes) → 415 MIME_MISMATCH at complete", async ({
156    api,
157    freshWorkspace,
158  }) => {
159    const ws = await freshWorkspace("Spoof Space");
160    // Declare image/png but upload plain-text bytes; the worker's magic-byte
161    // check rejects at complete() and deletes the object.
162    const spoof = Buffer.from("this is not a png, just plain text bytes for the spoof test");
163    const res = await openUploadSession(api, ws.id, "spoof.png", "image/png", spoof.length);
164    expect(res.status).toBeLessThan(300);
165    const fileId = (res.body as { upload: { file: { id: string } } }).upload.file.id;
166    const putUrl = (res.body as { upload: { uploadUrl: string } }).upload.uploadUrl;
167    const ct =
168      (res.body as { upload: { headers: Record<string, string> } }).upload.headers[
169        "Content-Type"
170      ] ?? "image/png";
171    const putStatus = await putPresigned(putUrl, spoof, ct);
172    expect(putStatus, `presigned PUT returned ${putStatus}`).toBeLessThan(300);
173    const complete = await api.put(`/api/v1/files/uploads/${fileId}/complete`, {
174      data: {},
175    });
176    expect(complete.status()).toBe(415);
177    const err = (await complete.json()) as { error: { code: string } };
178    expect(err.error.code).toBe("MIME_MISMATCH");
179  });
180 
181  test("non-member cannot read or download the file (403)", async ({
182    api,
183    secondUser,
184    freshWorkspace,
185  }) => {
186    const ws = await freshWorkspace("Private Files Space");
187    const file = await uploadFile(api, "secret.png", "image/png", TINY_PNG, ws.id);
188    expect(file.status).toBe("READY");
189 
190    // An outsider (no membership in `ws`) is rejected on the file and its
191    // download, and on listing a foreign workspace.
192    const { api: outsiderApi } = await secondUser();
193    expect((await outsiderApi.get(`/api/v1/files/${file.id}`)).status()).toBe(403);
194    const dl = await outsiderApi.get(`/api/v1/files/${file.id}/download`);
195    expect(dl.status()).toBe(403);
196    const listForeign = await outsiderApi.get("/api/v1/files", {
197      headers: { "x-factory-workspace": ws.id },
198    });
199    expect(listForeign.status()).toBe(403);
200 
201    // The owner still has the signed URLs.
202    const detail = await getFile(api, file.id, ws.id);
203    expect(detail.downloadUrl).toBeTruthy();
204    expect(detail.previewUrl).toBeTruthy();
205  });
206 
207  test("video (webm/VP9) → file block renders a native <video> that decodes", async ({
208    authPage,
209    api,
210    freshWorkspace,
211  }) => {
212    const ws = await freshWorkspace("Video Space");
213    const page = await createPage(api, ws.id, { title: "Video Page" });
214    // VP9/webm (a spec-supported video kind — mp4/webm/mov): Playwright's
215    // open-source Chromium ships WITHOUT the H.264 decoder, so an mp4's
216    // metadata never loads there (`canPlayType("…avc1…")` === ""). webm is
217    // the decode-able kind in this environment (see the recorded conflict in
218    // the Phase 9 Notes).
219    const file = await uploadFile(api, "clip.webm", "video/webm", fixture("clip.webm"), ws.id);
220    expect(file.status).toBe("READY");
221    await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
222 
223    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
224    const card = authPage.getByTestId(`file-card-${file.id}`);
225    await expect(card).toBeVisible({ timeout: 45_000 });
226    const video = card.locator("video").first();
227    await expect(video).toBeVisible();
228    await expect(video).toHaveAttribute("src", /.+/);
229    // The native <video> decodes: once metadata is loaded, videoWidth > 0
230    // (the AC's "videoWidth > 0" — read at loadedmetadata, the correct
231    // signal for dimensions, rather than awaiting play()).
232    await video.evaluate(async (v: HTMLVideoElement) => {
233      if (v.readyState < 1) {
234        await new Promise<void>((resolve, reject) => {
235          const t = setTimeout(() => reject(new Error("video metadata timeout")), 20_000);
236          v.addEventListener(
237            "loadedmetadata",
238            () => {
239              clearTimeout(t);
240              resolve();
241            },
242            { once: true },
243          );
244        });
245      }
246    });
247    expect(await video.evaluate((v: HTMLVideoElement) => v.videoWidth)).toBeGreaterThan(0);
248  });
249 
250  test("audio wav → file block renders a native <audio> with the name", async ({
251    authPage,
252    api,
253    freshWorkspace,
254  }) => {
255    const ws = await freshWorkspace("Audio Space");
256    const page = await createPage(api, ws.id, { title: "Audio Page" });
257    const file = await uploadFile(api, "clip.wav", "audio/wav", fixture("clip.wav"), ws.id);
258    expect(file.status).toBe("READY");
259    await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
260 
261    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
262    const card = authPage.getByTestId(`file-card-${file.id}`);
263    await expect(card).toBeVisible({ timeout: 45_000 });
264    const audio = card.locator("audio").first();
265    await expect(audio).toBeVisible();
266    await expect(audio).toHaveAttribute("src", /.+/);
267    // A11y (Requirements §7): the player carries a text alternative (name).
268    await expect(audio).toHaveAttribute("aria-label", /clip\.wav/);
269  });
270 
271  test("document (text/plain) → download card with name + download button", async ({
272    authPage,
273    api,
274    freshWorkspace,
275  }) => {
276    const ws = await freshWorkspace("Docs Space");
277    const page = await createPage(api, ws.id, { title: "Docs Page" });
278    const file = await uploadFile(api, "note.txt", "text/plain", fixture("note.txt"), ws.id);
279    expect(file.status).toBe("READY");
280    await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
281 
282    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
283    const card = authPage.getByTestId(`file-card-${file.id}`);
284    await expect(card).toBeVisible({ timeout: 45_000 });
285    await expect(card.getByTestId(`file-name-${file.id}`)).toHaveText("note.txt");
286    // mime · human size line.
287    await expect(card).toContainText("text/plain");
288    // The download affordance is a real <button> (not a styled div).
289    const dl = card.getByTestId(`file-download-${file.id}`);
290    await expect(dl).toBeVisible();
291    await expect(dl).toBeAttached();
292    expect(await dl.evaluate((el) => el instanceof HTMLButtonElement)).toBe(true);
293  });
294 
295  test("EICAR → red \"Blocked by virus scan\" card, no download affordance", async ({
296    authPage,
297    api,
298    freshWorkspace,
299  }) => {
300    const ws = await freshWorkspace("Virus Space");
301    const page = await createPage(api, ws.id, { title: "Virus Page" });
302    const file = await uploadFile(api, "eicar.txt", "text/plain", EICAR, ws.id);
303    expect(file.status).toBe("VIRUS_DETECTED");
304    await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
305 
306    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
307    // The virus card renders a red alert (no download/download-card).
308    const blocked = authPage.getByText("Blocked by virus scan");
309    await expect(blocked).toBeVisible({ timeout: 45_000 });
310    // No download affordance for a virus file.
311    await expect(authPage.getByTestId(`file-card-${file.id}`)).toHaveCount(0);
312    await expect(authPage.getByTestId(`file-download-${file.id}`)).toHaveCount(0);
313  });
314 
315  test("file block \"Add file\" button (upload path c) — browser upload → renders", async ({
316    authPage,
317    api,
318    freshWorkspace,
319  }) => {
320    // This is the longest test: page load + browser session→PUT→complete +
321    // ClamAV scan + poll + reload + re-render. Give it its own budget.
322    test.setTimeout(120_000);
323    const ws = await freshWorkspace("Button Upload Space");
324    const page = await createPage(api, ws.id, { title: "Button Upload Page" });
325    // An empty `file` block → the picker ("Add file").
326    const picker = await createBlock(api, page.id, { type: "FILE", properties: {} });
327    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
328 
329    const input = authPage.getByTestId("file-input");
330    await expect(input).toBeAttached({ timeout: 15_000 });
331 
332    // Drive the REAL browser upload: set a file on the picker's input. The
333    // block's `onFilesMulti` → `useFileUploads.start(file, { updateBlockId })`
334    // opens a presigned session, PUTs the bytes from the BROWSER to MinIO,
335    // calls complete, and updates THIS block's properties to { fileId, name }
336    // (a PATCH /blocks/:id carrying the lowercase codec type — the case
337    // reconciliation under test).
338    await input.setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: TINY_PNG });
339 
340    // Wait for the browser upload to fully settle server-side: the block
341    // gains properties.fileId (at session open) and the file reaches READY.
342    const deadline = Date.now() + 60_000;
343    let fileId: string | undefined;
344    for (;;) {
345      const body = (await (
346        await api.get(`/api/v1/pages/${page.id}/blocks`)
347      ).json()) as { data: { id: string; properties: { fileId?: string } }[] };
348      const props = body.data.find((b) => b.id === picker.id)?.properties ?? {};
349      if (props.fileId) {
350        fileId = props.fileId as string;
351        const detail = await getFile(api, fileId, ws.id);
352        if (detail.status === "READY") break;
353        if (detail.status === "FAILED" || detail.status === "VIRUS_DETECTED") {
354          throw new Error(`path c: upload ended ${detail.status}`);
355        }
356      }
357      if (Date.now() > deadline) throw new Error("path c: upload did not settle in 60s");
358      await new Promise((r) => setTimeout(r, 1000));
359    }
360    expect(fileId).toBeTruthy();
361 
362    // The transfer is complete; reload to refetch the block (its properties
363    // now carry the fileId) so it switches from the picker to the render.
364    await authPage.reload();
365    const card = authPage.getByTestId(`file-card-${fileId}`);
366    await expect(card).toBeVisible({ timeout: 45_000 });
367    await expect(card.locator("img").first()).toBeVisible();
368  });
369 
370  // ── TASK-0105 E2E scenarios (Requirements §1: paste, DOCX, free-60MB,
371  //    unauthorized block state, picker thumbnail) ─────────────────────────
372 
373  test("paste an image from the clipboard → uploads + renders a new file block", async ({
374    authPage,
375    api,
376    freshWorkspace,
377  }) => {
378    test.setTimeout(90_000);
379    const ws = await freshWorkspace("Paste Space");
380    const page = await createPage(api, ws.id, { title: "Paste Page" });
381    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
382    // The editor must be active before the window paste listener will accept
383    // an image (it gates on `activePageId === pageId`).
384    await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 30_000 });
385    // No file blocks yet.
386    expect(await authPage.getByTestId(/^file-card-/).count()).toBe(0);
387 
388    // Simulate Ctrl+V carrying an image file: build a real `File` in the page,
389    // put it on a `DataTransfer`, and dispatch a `paste` event on `window`
390    // (the handler the editor registers). This drives the real
391    // `startUpload(file)` → presigned session → XHR PUT → complete path.
392    //
393    // `ClipboardEventInit` has no `clipboardData` member and the event's
394    // `clipboardData` is a read-only getter, so the constructor cannot carry
395    // our `DataTransfer` — shadow the instance property explicitly.
396    await authPage.evaluate((b64: string) => {
397      const bin = atob(b64);
398      const bytes = new Uint8Array(bin.length);
399      for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
400      const file = new File([bytes], "pasted.png", { type: "image/png" });
401      const dt = new DataTransfer();
402      dt.items.add(file);
403      const ev = new ClipboardEvent("paste", { bubbles: true, cancelable: true });
404      Object.defineProperty(ev, "clipboardData", { value: dt, configurable: true });
405      window.dispatchEvent(ev);
406    }, TINY_PNG.toString("base64"));
407 
408    // A NEW file block appears and renders the pasted image (the paste path
409    // creates a block — it has no `updateBlockId`).
410    const card = authPage.getByTestId(/^file-card-/);
411    await expect(card.first()).toBeVisible({ timeout: 60_000 });
412    await expect(card.first().locator("img").first()).toBeVisible();
413    await expect(card.first().locator("img").first()).toHaveAttribute("src", /.+/);
414  });
415 
416  test("DOCX → download card (name + button) and Download delivers the exact bytes", async ({
417    authPage,
418    api,
419    freshWorkspace,
420  }) => {
421    test.setTimeout(90_000);
422    const ws = await freshWorkspace("Docx Space");
423    const page = await createPage(api, ws.id, { title: "Docx Page" });
424    const docx = fixture("note.docx");
425    const file = await uploadFile(
426      api,
427      "note.docx",
428      "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
429      docx,
430      ws.id,
431    );
432    expect(file.status).toBe("READY");
433    await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
434 
435    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
436    const card = authPage.getByTestId(`file-card-${file.id}`);
437    await expect(card).toBeVisible({ timeout: 45_000 });
438    // doc kind → the download card (name + mime + Download button).
439    await expect(card.getByTestId(`file-name-${file.id}`)).toHaveText("note.docx");
440    await expect(card).toContainText("application/vnd.openxmlformats-officedocument");
441    const dl = card.getByTestId(`file-download-${file.id}`);
442    await expect(dl).toBeVisible();
443 
444    // The Download button navigates to the signed attachment URL — capture
445    // the download and assert the BYTES round-trip exactly (the AC "Download
446    // delivers the file"). The signed GET is `attachment;`, so Playwright
447    // emits a `download` event rather than a navigation.
448    const [download] = await Promise.all([authPage.waitForEvent("download"), dl.click()]);
449    expect(download.suggestedFilename()).toBe("note.docx");
450    const path = await download.path();
451    const delivered = await fs.promises.readFile(path!);
452    expect(delivered.equals(docx)).toBe(true);
453  });
454 
455  test("60 MB file on the free plan → client-side \"File too large\" toast, no session opened", async ({
456    authPage,
457    api,
458    freshWorkspace,
459  }) => {
460    test.setTimeout(90_000);
461    const ws = await freshWorkspace("Free 60MB Space");
462    const page = await createPage(api, ws.id, { title: "60MB Page" });
463    // An empty `file` block → the picker's hidden input (upload path c).
464    await createBlock(api, page.id, { type: "FILE", properties: {} });
465    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
466    const input = authPage.getByTestId("file-input");
467    await expect(input).toBeAttached({ timeout: 30_000 });
468 
469    // The free plan caps per-file uploads at 50 MB. The web pre-check rejects
470    // a 60 MB File BEFORE opening a presigned session (no bytes are sent) and
471    // shows the "File too large" toast (Requirement §1 / TASK-0105 §3).
472    // Playwright refuses an in-memory buffer > 50 MB, so write a 60 MB file to
473    // disk (sparse — one write + truncate, ~0 disk cost) and pass its path.
474    const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "e2e-big-"));
475    const bigPath = path.join(dir, "big.bin");
476    const fd = await fs.promises.open(bigPath, "w");
477    try {
478      await fd.truncate(60 * 1024 * 1024); // 62,914,560 bytes = 60 MiB > 50 MiB limit
479    } finally {
480      await fd.close();
481    }
482    try {
483      // Plain path string: Playwright infers the mime from the extension and
484      // uses the basename as the name. The pre-check keys off `file.size`
485      // only (no mime/session), so this is sufficient.
486      await input.setInputFiles(bigPath);
487 
488      const toast = authPage.getByRole("status").filter({ hasText: /File too large/ });
489      await expect(toast).toBeVisible({ timeout: 25_000 });
490      await expect(toast).toContainText(/60|50 MB per-file limit/);
491 
492      // No file block was created and no upload session was opened server-side
493      // (the pre-check short-circuits before any request). Only the empty
494      // picker block exists, which does NOT carry a `file-card-{id}` testid.
495      expect(await authPage.getByTestId(/^file-card-/).count()).toBe(0);
496      const list = (await (await api.get("/api/v1/files", { headers: workspaceHeader(ws.id) })).json()) as {
497        data: unknown[];
498      };
499      expect(list.data ?? []).toHaveLength(0);
500    } finally {
501      await fs.promises.rm(dir, { recursive: true, force: true });
502    }
503  });
504 
505  test("unauthorized: a non-member cannot see the file block (block state)", async ({
506    secondUser,
507    api,
508    freshWorkspace,
509  }) => {
510    const ws = await freshWorkspace("Unauthorized UI Space");
511    const page = await createPage(api, ws.id, { title: "Private Page" });
512    const file = await uploadFile(api, "locked.png", "image/png", TINY_PNG, ws.id);
513    expect(file.status).toBe("READY");
514    await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
515 
516    // The outsider is not a member of `ws`. Their browser's workspace query
517    // 403s → the workspace layout redirects them off the private page, so the
518    // file block is never rendered for them (Requirement §1 unauthorized block
519    // state). The API 403 is covered by the integration matrix + the TASK-0103
520    // non-member test; here we assert the BLOCK never appears in the UI.
521    const { page: outsider } = await secondUser();
522    await outsider.goto(`/w/${ws.id}/page/${page.id}`);
523    // The workspace layout bounces non-members (useWorkspace 403 →
524    // router.replace("/w/default")); wait for that redirect to settle.
525    await expect.poll(() => outsider.url(), {
526      timeout: 20_000,
527      message: "non-member is bounced off the private page",
528    }).not.toContain(`/page/${page.id}`);
529    // And the file block is never present in their DOM.
530    await expect(outsider.getByTestId(`file-card-${file.id}`)).toHaveCount(0);
531  });
532 
533  test("file picker shows the uploaded image as a thumbnail", async ({
534    authPage,
535    api,
536    freshWorkspace,
537  }) => {
538    const ws = await freshWorkspace("Picker Thumb Space");
539    const page = await createPage(api, ws.id, { title: "Picker Page" });
540    // A real multi-size image (the hero PNG) — a 1×1 png may not yield a
541    // thumbnail artifact, but the worker thumbnails a 1400×900 image at
542    // 160/480/1280, so it has a `thumbnailUrl`.
543    const file = await uploadFile(api, "hero.png", "image/png", fixture("hero-1400.png"), ws.id);
544    expect(file.status).toBe("READY");
545    expect(file.thumbnailUrl, "an image gets a thumbnail").toBeTruthy();
546    await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
547 
548    await authPage.goto(`/w/${ws.id}/page/${page.id}`);
549    await expect(authPage.getByTestId("files-picker-toggle")).toBeVisible({ timeout: 30_000 });
550 
551    // Open the page file picker (role="dialog" aria-label="Files").
552    await authPage.getByTestId("files-picker-toggle").click();
553    const panel = authPage.getByRole("dialog", { name: "Files" });
554    await expect(panel).toBeVisible({ timeout: 15_000 });
555 
556    // The picker defaults to the "In this page" scope, but `hero.png` was
557    // uploaded workspace-scoped (pageId: null) via the API helper — switch to
558    // "In workspace" so the tile is in the list (the scope filter is the
559    // product behavior we are exercising).
560    await panel.getByRole("button", { name: "In workspace" }).click();
561 
562    // The uploaded image renders as a real <img> thumbnail in its tile
563    // (FileTile: `file.thumbnailUrl && kind === "image"` → <img>), not the
564    // generic FileImage icon.
565    const tile = panel.getByRole("option", { name: /hero\.png/ });
566    await expect(tile).toBeVisible({ timeout: 15_000 });
567    const thumb = tile.locator("img").first();
568    await expect(thumb).toBeVisible();
569    await expect(thumb).toHaveAttribute("src", /.+/);
570  });
571});
572