.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
ai-writing.integration.test.tstests/integration/ai-writing.integration.test.tsraw← integration28,260 B← Back to the app
1/**
2 * TASK-0123 — AI writing tools (SSE) + apply integration suite.
3 *
4 * Full stack (AppModule + Postgres + Redis) against the deterministic
5 * `MockProvider` (`AI_PROVIDER=mock`; the writing mode emits a single
6 * `generateBlocks` tool call derived from the `[SELECTED …]` markers).
7 *
8 * Covers the spec's integration acceptance criteria:
9 *   - validation: >50 blocks → 422 `too_many_blocks`; missing action → 400;
10 *   - the >100k-char selected-text cap → 413 `PAYLOAD_TOO_LARGE`;
11 *   - permission granularity (design decision 1, per-page): a view-only
12 *     member can SUGGEST (reads content) but apply fails per block with
13 *     `FORBIDDEN {reason:"requires_EDIT"}` while the rest apply (per-block
14 *     failure isolation);
15 *   - server-side tool-call validation: the mock `forceUnknownId` seam makes
16 *     the model emit a forged blockId — it is DROPPED (noted, never applied);
17 *   - the concurrent-edit 409 path: apply with a stale suggestion-time
18 *     version → per-block `409 CONFLICT` entry carrying `actualVersion`;
19 *   - budget exhaustion → 402 AI_BUDGET_EXCEEDED (pre-stream JSON);
20 *   - cost logging: the assistant row carries `metadata.feature="writing"`,
21 *     `action`, `tokensIn/Out` and `model`; the hidden per-call conversation
22 *     never appears in the conversation list;
23 *   - apply no-smuggling: blockIds outside the stored suggestion → 422
24 *     `unknown_block_id`; ownership-scoped 404 for a foreign suggestion.
25 */
26// Config must be fixed BEFORE the app boots (loadEnv + the workspace cache).
27process.env.AI_PROVIDER = "mock";
28process.env.SEARCH_VECTOR_DIMENSION = "8";
29// Pacing keeps the suite fast but lets the SSE reader frame cleanly
30// (one delta per word of the note; the tool call is a single chunk).
31process.env.AI_MOCK_DELTA_DELAY_MS = "25";
32 
33import { afterAll, beforeAll, describe, expect, it } from "vitest";
34import { createTestApp, type TestAppHandle } from "./helpers/api";
35import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
36// Deep import (no `.js` — vite resolution), the same convention as the
37// ai-capability suite: the budget guard reads workspaces through a 30 s
38// cache, so the direct-DB budget writes in this file need the reset to
39// take effect immediately.
40const { resetWorkspaceCache } = await import("@factory/api/src/modules/ai/gateway-provider");
41 
42let app: TestAppHandle;
43 
44beforeAll(async () => {
45  app = await createTestApp();
46}, 180_000);
47afterAll(async () => {
48  await app?.close();
49});
50 
51// ---------------------------------------------------------------------------
52// helpers
53// ---------------------------------------------------------------------------
54 
55type SseEvent = {
56  type: "delta" | "tool_call" | "usage" | "error" | "done";
57  text?: string;
58  id?: string;
59  name?: string;
60  arguments?: Record<string, unknown>;
61  tokensIn?: number;
62  tokensOut?: number;
63  code?: string;
64  message?: string;
65  retryable?: boolean;
66  retryAfterSeconds?: number;
67  assistantMessageId?: string;
68  userMessageId?: string;
69  reason?: string;
70};
71 
72/** POST the writing SSE endpoint, collect the frames. */
73async function streamWriting(
74  accessToken: string,
75  workspaceId: string,
76  body: Record<string, unknown>,
77): Promise<{ status: number; events: SseEvent[]; contentType: string }> {
78  const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${workspaceId}/ai/writing`, {
79    method: "POST",
80    headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
81    body: JSON.stringify(body),
82  });
83  const contentType = res.headers.get("content-type") ?? "";
84  const events: SseEvent[] = [];
85  if (!res.body) return { status: res.status, events, contentType };
86  const reader = res.body.getReader();
87  const decoder = new TextDecoder();
88  let buf = "";
89  for (;;) {
90    const chunk = await reader.read();
91    if (chunk.done) break;
92    buf += decoder.decode(chunk.value, { stream: true });
93    let idx: number;
94    while ((idx = buf.indexOf("\n\n")) !== -1) {
95      const frame = buf.slice(0, idx);
96      buf = buf.slice(idx + 2);
97      for (const line of frame.split("\n")) {
98        if (!line.startsWith("data:")) continue; // skip `: keepalive`
99        events.push(JSON.parse(line.slice(5).trim()) as SseEvent);
100      }
101    }
102  }
103  return { status: res.status, events, contentType };
104}
105 
106const json = async (res: Response): Promise<Record<string, unknown>> =>
107  (await res.json().catch(() => ({}))) as Record<string, unknown>;
108 
109/** A page with N PARAGRAPH blocks (distinctive, ≥2-word texts so the mock's
110 *  summarize/rewrite transforms are observable). Returns ids in page order. */
111async function createPageWithBlocks(
112  accessToken: string,
113  workspaceId: string,
114  title: string,
115  texts: string[],
116): Promise<{ pageId: string; blockIds: string[] }> {
117  const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
118    headers: authHeaders(accessToken),
119    body: { title },
120  });
121  expect(pageRes.status).toBe(201);
122  const page = (await pageRes.json()) as { id: string };
123  let prevId: string | undefined;
124  const blockIds: string[] = [];
125  for (const text of texts) {
126    const r = await app.request("POST", `/api/v1/pages/${page.id}/blocks`, {
127      headers: authHeaders(accessToken),
128      body: { type: "PARAGRAPH", content: { text }, ...(prevId ? { afterBlockId: prevId } : {}) },
129    });
130    expect(r.status).toBe(201);
131    const b = (await r.json()) as { id: string };
132    blockIds.push(b.id);
133    prevId = b.id;
134  }
135  return { pageId: page.id, blockIds };
136}
137 
138/** A fresh workspace with an OWNER user. */
139async function freshWorkspace(name: string) {
140  const owner = await createUser(app.request, { name });
141  const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), `${name} WS`);
142  return { owner, workspaceId, h: authHeaders(owner.accessToken!) };
143}
144 
145/** The suggestion persisted by a finished stream. */
146async function suggestionFrom(events: SseEvent[]) {
147  const done = events.find((e) => e.type === "done");
148  expect(done?.reason).toBe("ok");
149  const suggestionId = done!.assistantMessageId!;
150  const row = await app.prisma.aiMessage.findUniqueOrThrow({ where: { id: suggestionId } });
151  return { done, suggestionId, row };
152}
153 
154// ---------------------------------------------------------------------------
155// happy path
156// ---------------------------------------------------------------------------
157 
158describe("TASK-0123 writing — SSE happy path", () => {
159  it("summarize 3 blocks → suggestion; apply → all 3 updated as NORMAL block updates (audit + versions + positions unchanged)", async () => {
160    const { owner, workspaceId, h } = await freshWorkspace("Writing Owner");
161    const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Doc", [
162      "The first paragraph talks about the launch plan and its milestones in detail.",
163      "The second paragraph explains the budget, the staffing model, and the risks ahead.",
164      "The third paragraph summarizes the decision criteria the team agreed to follow.",
165    ]);
166 
167    const out = await streamWriting(owner.accessToken!, workspaceId, {
168      action: "summarize",
169      pageId,
170      blockIds,
171    });
172    expect(out.status).toBe(200);
173    expect(out.contentType).toContain("text/event-stream");
174    const types = out.events.map((e) => e.type);
175    expect(types[types.length - 1]).toBe("done");
176    expect(types).toContain("tool_call");
177    expect(types).toContain("usage");
178    // the mock's summarize transform is observable in the suggested text
179    const tool = out.events.find((e) => e.type === "tool_call")!;
180    expect(tool.name).toBe("generateBlocks");
181    const suggested = (tool.arguments as { blocks: Array<{ blockId: string; newText: string }> }).blocks;
182    expect(suggested.map((b) => b.blockId)).toEqual(blockIds);
183    for (const b of suggested) expect(b.newText).toMatch(/^Summarized: /);
184    // usage frame + persisted tokens (the cost ledger input)
185    const usage = out.events.find((e) => e.type === "usage")!;
186    expect(usage.tokensIn).toBeGreaterThan(0);
187    expect(usage.tokensOut).toBeGreaterThan(0);
188 
189    // ── the persisted suggestion (suggestionId = the assistant message id) ──
190    const { suggestionId, row } = await suggestionFrom(out.events);
191    expect(row.role).toBe("assistant");
192    const meta = row.metadata as { feature: string; action: string };
193    expect(meta.feature).toBe("writing");
194    expect(meta.action).toBe("summarize");
195    expect(row.tokensIn).toBe(usage.tokensIn);
196    expect(row.tokensOut).toBe(usage.tokensOut);
197    expect(row.model).toBeTruthy();
198    const suggestion = row.toolResults as { blocks: Array<{ blockId: string; newText: string; version: number; content: { text: string }; text: string }>; droppedIds: string[] };
199    expect(suggestion.droppedIds).toEqual([]);
200    expect(suggestion.blocks.map((b) => b.blockId)).toEqual(blockIds);
201    // the suggestion snapshots the ORIGINAL text (the UI's diff old-side)
202    expect(suggestion.blocks[0]!.text).toBe("The first paragraph talks about the launch plan and its milestones in detail.");
203    expect(suggestion.blocks[0]!.content.text).toBe("The first paragraph talks about the launch plan and its milestones in detail.");
204 
205    // the hidden per-call conversation must NOT be listed
206    const listRes = await app.request("GET", `/api/v1/workspaces/${workspaceId}/ai/conversations`, { headers: h });
207    const list = (await json(listRes)) as { data: Array<{ id: string; title: string }> };
208    expect(list.data.map((c) => c.title)).not.toContain("writing:summarize");
209 
210    // ── positions before (apply must not move blocks) ──
211    const before = await app.prisma.block.findMany({ where: { id: { in: blockIds } }, orderBy: { position: "asc" } });
212    const positionsBefore = before.map((b) => b.position);
213    const versionsBefore = before.map((b) => b.version);
214 
215    // ── apply (all blocks) ──
216    const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
217      headers: h,
218      body: { suggestionId },
219    });
220    expect(applyRes.status).toBe(200);
221    const apply = (await json(applyRes)) as { results: Array<{ blockId: string; status: string }> };
222    expect(apply.results).toHaveLength(3);
223    for (const r of apply.results) expect(r.status).toBe("applied");
224 
225    // ── the writes landed as NORMAL block updates ──
226    const after = await app.prisma.block.findMany({ where: { id: { in: blockIds } }, orderBy: { position: "asc" } });
227    expect(after.map((b) => b.position)).toEqual(positionsBefore); // positions unchanged
228    expect(after.map((b) => b.version)).toEqual(versionsBefore.map((v) => v + 1)); // versions bumped
229    const byId = new Map(blockIds.map((id, i) => [id, after[i]!]));
230    for (const b of suggested) {
231      const block = byId.get(b.blockId)!;
232      expect((block.content as { text: string }).text).toBe(b.newText);
233      expect(block.updatedBy).toBe(owner.userId); // a normal update, not "ai"
234    }
235 
236    // audit: three `block.updated` (normal) + one `ai.writing_applied`
237    const audit = await app.prisma.auditLog.findMany({
238      where: { workspaceId, action: { in: ["block.updated", "ai.writing_applied"] } },
239    });
240    expect(audit.filter((a) => a.action === "block.updated")).toHaveLength(3);
241    const applied = audit.find((a) => a.action === "ai.writing_applied");
242    expect(applied).toBeTruthy();
243    expect((applied!.metadata as { applied: number; failed: number }).applied).toBe(3);
244  });
245 
246  it("translate with a language: the language rides into the prompt + the suggestion row", async () => {
247    const { owner, workspaceId, h } = await freshWorkspace("Translate Owner");
248    const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Intl", [
249      "Hello there, this is a friendly greeting for the international reader.",
250    ]);
251    const out = await streamWriting(owner.accessToken!, workspaceId, {
252      action: "translate",
253      language: "Spanish",
254      pageId,
255      blockIds,
256    });
257    const tool = out.events.find((e) => e.type === "tool_call")!;
258    const suggested = (tool.arguments as { blocks: Array<{ newText: string }> }).blocks;
259    expect(suggested[0]!.newText).toBe("Translated to Spanish: Hello there, this is a friendly greeting for the international reader.");
260    const { row } = await suggestionFrom(out.events);
261    const meta = row.metadata as { feature: string; action: string; language: string };
262    expect(meta).toEqual({ feature: "writing", action: "translate", language: "Spanish" });
263    // apply the single block
264    const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
265      headers: h,
266      body: { suggestionId: row.id },
267    });
268    expect(applyRes.status).toBe(200);
269  });
270});
271 
272// ---------------------------------------------------------------------------
273// validation (pre-stream JSON envelope)
274// ---------------------------------------------------------------------------
275 
276describe("TASK-0123 writing — pre-stream validation", () => {
277  it(">50 blocks → 422 too_many_blocks (the spec's canonical code, not 400)", async () => {
278    const { owner, workspaceId, h } = await freshWorkspace("Cap Owner");
279    const tooMany = Array.from({ length: 51 }, (_, i) => `fake-block-id-${i}`);
280    const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
281      headers: h,
282      body: { action: "improve", pageId: "page-x", blockIds: tooMany },
283    });
284    expect(res.status).toBe(422);
285    const body = await json(res);
286    expect(body.error.code).toBe("VALIDATION_ERROR");
287    const reason = (body.error.details as Array<{ reason?: string }>)?.[0]?.reason;
288    expect(reason).toBe("too_many_blocks");
289  });
290 
291  it("bad action → 400 VALIDATION_ERROR (zod enum)", async () => {
292    const { owner, workspaceId, h } = await freshWorkspace("Enum Owner");
293    const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
294      headers: h,
295      body: { action: "definitely-not-an-action", pageId: "page-x", blockIds: ["b1"] },
296    });
297    expect(res.status).toBe(400);
298  });
299 
300  it("selected text over 100k chars → 413 PAYLOAD_TOO_LARGE (before any SSE byte)", async () => {
301    const { owner, workspaceId, h } = await freshWorkspace("Big Owner");
302    const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Big", [
303      "z".repeat(100_001),
304    ]);
305    const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
306      headers: h,
307      body: { action: "rewrite", pageId, blockIds },
308    });
309    expect(res.status).toBe(413);
310    const body = await json(res);
311    expect(body.error.code).toBe("PAYLOAD_TOO_LARGE");
312  });
313 
314  it("no editable blocks (all missing) → 422 no_editable_blocks", async () => {
315    const { owner, workspaceId, h } = await freshWorkspace("Empty Owner");
316    const { pageId } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Empty", ["some text here"]);
317    const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
318      headers: h,
319      body: { action: "rewrite", pageId, blockIds: ["never-existed-1", "never-existed-2"] },
320    });
321    expect(res.status).toBe(422);
322    const body = await json(res);
323    const reason = (body.error.details as Array<{ reason?: string }>)?.[0]?.reason;
324    expect(reason).toBe("no_editable_blocks");
325  });
326});
327 
328// ---------------------------------------------------------------------------
329// permissions (per-page granularity — design decision 1)
330// ---------------------------------------------------------------------------
331 
332describe("TASK-0123 writing — per-page permission granularity", () => {
333  it("a view-only member can SUGGEST; apply fails per block with FORBIDDEN {reason:'requires_EDIT'}", async () => {
334    const { owner, workspaceId, h } = await freshWorkspace("Perm Owner");
335    const guest = await createUser(app.request, { name: "View Guest" });
336    await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
337    const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Shared", [
338      "Shared document paragraph one with some meaningful text inside it.",
339    ]);
340 
341    // the GUEST has workspace-wide VIEW (GUEST role) → suggest works
342    const out = await streamWriting(guest.accessToken!, workspaceId, {
343      action: "improve",
344      pageId,
345      blockIds,
346    });
347    expect(out.status).toBe(200);
348    const tool = out.events.find((e) => e.type === "tool_call")!;
349    expect((tool.arguments as { blocks: unknown[] }).blocks).toHaveLength(1);
350    const { row } = await suggestionFrom(out.events);
351 
352    // apply → the per-block EDIT re-check fails (GUEST = VIEW)
353    const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
354      headers: authHeaders(guest.accessToken!),
355      body: { suggestionId: row.id },
356    });
357    expect(applyRes.status).toBe(200); // per-block isolation, not a hard fail
358    const apply = (await json(applyRes)) as { results: Array<{ blockId: string; status: string; code: string }> };
359    expect(apply.results[0]).toMatchObject({ status: "failed", code: "FORBIDDEN" });
360 
361    // and the block is untouched
362    const block = await app.prisma.block.findUniqueOrThrow({ where: { id: blockIds[0]! } });
363    expect((block.content as { text: string }).text).toBe("Shared document paragraph one with some meaningful text inside it.");
364  });
365 
366  it("a page-level EDIT grant lets the same member apply (grant beats role)", async () => {
367    const { owner, workspaceId, h } = await freshWorkspace("Grant Owner");
368    const guest = await createUser(app.request, { name: "Edit Grant Guest" });
369    await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
370    const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Grant Page", [
371      "Grant document paragraph with a sentence the guest should be able to improve.",
372    ]);
373 
374    // owner grants the guest EDIT on THIS page only. (The public grant API
375    // does not exist in this codebase yet — `permissions.integration.test.ts`
376    // is one of the known-failing integration specs — so the grant row is
377    // seeded through the DB, exactly like the budget-limit writes here.)
378    await app.prisma.permission.create({
379      data: {
380        workspaceId,
381        granteeType: "user",
382        granteeId: guest.userId,
383        resourceType: "page",
384        resourceId: pageId,
385        level: "EDIT",
386      },
387    });
388 
389    const out = await streamWriting(guest.accessToken!, workspaceId, { action: "improve", pageId, blockIds });
390    expect(out.status).toBe(200);
391    const { row } = await suggestionFrom(out.events);
392    const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
393      headers: authHeaders(guest.accessToken!),
394      body: { suggestionId: row.id },
395    });
396    expect(applyRes.status).toBe(200);
397    const apply = (await json(applyRes)) as { results: Array<{ status: string }> };
398    expect(apply.results[0]!.status).toBe("applied");
399  });
400 
401  it("apply of a foreign user's suggestion → 404 (existence hidden)", async () => {
402    const a = await freshWorkspace("A");
403    const b = await freshWorkspace("B");
404    const { pageId, blockIds } = await createPageWithBlocks(a.owner.accessToken!, a.workspaceId, "A", ["Something to summarize here"]);
405    const out = await streamWriting(a.owner.accessToken!, a.workspaceId, { action: "summarize", pageId, blockIds });
406    const { row } = await suggestionFrom(out.events);
407    const res = await app.request("POST", `/api/v1/workspaces/${b.workspaceId}/ai/writing/apply`, {
408      headers: b.h,
409      body: { suggestionId: row.id },
410    });
411    expect(res.status).toBe(404);
412  });
413});
414 
415// ---------------------------------------------------------------------------
416// server-side tool-call validation (the unknown-ID seam)
417// ---------------------------------------------------------------------------
418 
419describe("TASK-0123 writing — tool-call validation", () => {
420  it("a forged blockId from the model is DROPPED + noted (never applied)", async () => {
421    const { owner, workspaceId, h } = await freshWorkspace("Forged Owner");
422    const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Forge", [
423      "Forging test paragraph one with a full sentence for the mock to transform.",
424      "Forging test paragraph two with another full sentence for the mock.",
425    ]);
426    // arm the seam (gated like every other internal surface)
427    const armRes = await app.request("POST", `/internal/workspaces/${workspaceId}/ai/mock-unknown-id`, {
428      headers: { Authorization: `Bearer ${app.internalServiceToken}` },
429      body: { unknownId: "FORGED_BLOCK_ID" },
430    });
431    expect(armRes.status).toBe(200);
432 
433    const out = await streamWriting(owner.accessToken!, workspaceId, { action: "rewrite", pageId, blockIds });
434    const tool = out.events.find((e) => e.type === "tool_call")!;
435    // the raw model call DOES contain the forged id
436    const raw = (tool.arguments as { blocks: Array<{ blockId: string }> }).blocks;
437    expect(raw.map((b) => b.blockId)).toContain("FORGED_BLOCK_ID");
438 
439    const { row } = await suggestionFrom(out.events);
440    const suggestion = row.toolResults as { blocks: Array<{ blockId: string }>; droppedIds: string[] };
441    // …but the PERSISTED suggestion drops it + records it
442    expect(suggestion.droppedIds).toEqual(["FORGED_BLOCK_ID"]);
443    expect(suggestion.blocks.map((b) => b.blockId)).toEqual(blockIds);
444    expect((row.metadata as { droppedIds: string[] }).droppedIds).toEqual(["FORGED_BLOCK_ID"]);
445 
446    // apply: only the real blocks are touched
447    const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
448      headers: h,
449      body: { suggestionId: row.id },
450    });
451    expect(applyRes.status).toBe(200);
452    const apply = (await json(applyRes)) as { results: Array<{ status: string }> };
453    expect(apply.results).toHaveLength(2);
454    for (const r of apply.results) expect(r.status).toBe("applied");
455    const forged = await app.prisma.block.findUnique({ where: { id: "FORGED_BLOCK_ID" } });
456    expect(forged).toBeNull();
457  });
458});
459 
460// ---------------------------------------------------------------------------
461// concurrent edits (the 409 rebase path)
462// ---------------------------------------------------------------------------
463 
464describe("TASK-0123 writing — version conflicts (rebase)", () => {
465  it("a concurrent edit bumps the version → apply fails that block with 409 + actualVersion, the rest apply", async () => {
466    const { owner, workspaceId, h } = await freshWorkspace("Rebase Owner");
467    const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Race", [
468      "Racing paragraph one that someone else will edit between suggest and apply.",
469      "Racing paragraph two that stays untouched so it can still be applied.",
470    ]);
471    const out = await streamWriting(owner.accessToken!, workspaceId, { action: "improve", pageId, blockIds });
472    const { row } = await suggestionFrom(out.events);
473    const versionAtSuggest = blockIds.map((id) =>
474      app.prisma.block.findUniqueOrThrow({ where: { id }, select: { version: true } }),
475    );
476    const v1 = (await Promise.all(versionAtSuggest))[0]!.version;
477 
478    // the "concurrent user" edits block #1 (no baseVersion → unconditional bump)
479    const patchRes = await app.request("PATCH", `/api/v1/blocks/${blockIds[0]}`, {
480      headers: h,
481      body: { content: { text: "Concurrently edited by another user." } },
482    });
483    expect([200, 201]).toContain(patchRes.status);
484 
485    const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
486      headers: h,
487      body: { suggestionId: row.id },
488    });
489    expect(applyRes.status).toBe(200);
490    const apply = (await json(applyRes)) as {
491      results: Array<{ blockId: string; status: string; code?: string; actualVersion?: number }>;
492    };
493    const failed = apply.results.find((r) => r.blockId === blockIds[0]!)!;
494    expect(failed).toMatchObject({ status: "failed", code: "CONFLICT" });
495    expect(failed.actualVersion).toBe(v1 + 1);
496    // failure isolation: block #2 applied fine
497    expect(apply.results.find((r) => r.blockId === blockIds[1]!)!.status).toBe("applied");
498    // and the failed block kept the CONCURRENT user's text (not the AI's)
499    const b1 = await app.prisma.block.findUniqueOrThrow({ where: { id: blockIds[0]! } });
500    expect((b1.content as { text: string }).text).toBe("Concurrently edited by another user.");
501  });
502});
503 
504// ---------------------------------------------------------------------------
505// budget + no-smuggling
506// ---------------------------------------------------------------------------
507 
508describe("TASK-0123 writing — budget + apply validation", () => {
509  it("an exhausted budget → 402 AI_BUDGET_EXCEEDED (pre-stream, no suggestion persisted)", async () => {
510    const { owner, workspaceId, h } = await freshWorkspace("Budget Owner");
511    const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Budget", [
512      "Budget test paragraph with a sentence long enough for the mock to count.",
513    ]);
514    // 1-token budget → the first call's estimate blows it
515    await app.prisma.workspace.update({
516      where: { id: workspaceId },
517      data: { usageLimits: { aiMonthlyTokens: 1 } },
518    });
519    resetWorkspaceCache(); // the guard reads the workspace through a 30 s cache
520 
521    const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
522      headers: h,
523      body: { action: "improve", pageId, blockIds },
524    });
525    expect(res.status).toBe(402);
526    const body = await json(res);
527    expect(body.error.code).toBe("AI_BUDGET_EXCEEDED");
528    // nothing persisted (the guard failed before the conversation was created)
529    const convs = await app.prisma.aiConversation.findMany({ where: { workspaceId, title: "writing:improve" } });
530    expect(convs).toHaveLength(0);
531    // restore
532    await app.prisma.workspace.update({
533      where: { id: workspaceId },
534      data: { usageLimits: {} },
535    });
536    resetWorkspaceCache();
537  });
538 
539  it("apply rejects smuggled blockIds (422 unknown_block_id) and accepts a SUBSET (accept-one)", async () => {
540    const { owner, workspaceId, h } = await freshWorkspace("Subset Owner");
541    const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Subset", [
542      "Subset paragraph one that will be accepted through the subset path.",
543      "Subset paragraph two that the owner decides to keep as-is.",
544    ]);
545    const out = await streamWriting(owner.accessToken!, workspaceId, { action: "rewrite", pageId, blockIds });
546    const { row } = await suggestionFrom(out.events);
547 
548    // smuggling: an id outside the suggestion
549    const smuggle = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
550      headers: h,
551      body: { suggestionId: row.id, blockIds: [blockIds[0]!, "smuggled-id"] },
552    });
553    expect(smuggle.status).toBe(422);
554    const smuggleBody = await json(smuggle);
555    const reason = (smuggleBody.error.details as Array<{ reason?: string }>)?.[0]?.reason;
556    expect(reason).toBe("unknown_block_id");
557 
558    // accept ONE: only the first block is applied
559    const subset = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
560      headers: h,
561      body: { suggestionId: row.id, blockIds: [blockIds[0]!] },
562    });
563    expect(subset.status).toBe(200);
564    const apply = (await json(subset)) as { results: Array<{ blockId: string; status: string }> };
565    expect(apply.results).toHaveLength(1);
566    expect(apply.results[0]!.status).toBe("applied");
567    // the other block is untouched
568    const b2 = await app.prisma.block.findUniqueOrThrow({ where: { id: blockIds[1]! } });
569    expect((b2.content as { text: string }).text).toBe("Subset paragraph two that the owner decides to keep as-is.");
570  });
571});
572