.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
automation.tspackages/shared/src/automation.tsraw← src41,893 B← Back to the app
1/**
2 * @factory/shared/automation — Phase-11 automation engine contracts.
3 *
4 * Single source of truth shared by the API (save-time validation), the worker
5 * (runtime execution) and the UI (form metadata). Contents:
6 *
7 *  - enum value lists (trigger / action / status / run-status),
8 *  - trigger config zod schemas (one per trigger type),
9 *  - the condition expression schema (AND/OR/NOT tree, 14 operators) +
10 *    `validateConditionTree` (depth ≤ 5, nodes ≤ 100),
11 *  - the 9 action config zod schemas + `AUTOMATION_ACTIONS` /
12 *    `AUTOMATION_TRIGGERS` display metadata (the UI renders from these),
13 *  - cron validation (cron-parser; rejects intervals < 60 s),
14 *  - the status machine (valid transitions table),
15 *  - loop-prevention helpers (depth cap 5, self-trigger block),
16 *  - incoming-webhook signature build/verify (HMAC-SHA256 + timestamp,
17 *    constant-time compare),
18 *  - interpolation (the 7 key families),
19 *  - JSON truncation (64 KB / 128 KB run-storage caps),
20 *  - run-state helpers (terminal set, backoff schedule).
21 *
22 * The worker re-declares no automation constants: everything runtime
23 * decisions need lives here (TASK-0110/0111/0112/0113 contract).
24 */
25 
26import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";
27import { z } from "zod";
28// NOTE: cron-parser is CJS (`module.exports = CronParser` — a whole-object
29// export), so Node's ESM named-import interop CANNOT see its named exports
30// (cjs-module-lexer fails on `module.exports = X`). The default import
31// (= module.exports) works under both CJS (the API) and Node ESM (the
32// worker) runtimes.
33// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
34import cronParser from "cron-parser";
35 
36// ---------------------------------------------------------------------------
37// Enums (mirror the Prisma enums — keep in sync with prisma/schema.prisma)
38// ---------------------------------------------------------------------------
39 
40export const AUTOMATION_TRIGGER_TYPES = [
41  "RECORD_CREATED",
42  "RECORD_UPDATED",
43  "PROPERTY_CHANGED",
44  "PAGE_CREATED",
45  "FORM_SUBMITTED",
46  "SCHEDULE",
47  "WEBHOOK",
48  "MANUAL"
49] as const;
50export type AutomationTriggerType = (typeof AUTOMATION_TRIGGER_TYPES)[number];
51 
52export const AUTOMATION_ACTION_TYPES = [
53  "UPDATE_RECORD",
54  "CREATE_PAGE",
55  "SEND_EMAIL",
56  "SEND_WEBHOOK",
57  "CREATE_NOTIFICATION",
58  "ASSIGN_USER",
59  "CHANGE_STATUS",
60  "CALL_INTEGRATION",
61  "RUN_AI_ACTION"
62] as const;
63export type AutomationActionType = (typeof AUTOMATION_ACTION_TYPES)[number];
64 
65export const AUTOMATION_STATUSES = ["DRAFT", "ACTIVE", "PAUSED", "ARCHIVED"] as const;
66export type AutomationStatus = (typeof AUTOMATION_STATUSES)[number];
67 
68export const AUTOMATION_RUN_STATUSES = [
69  "PENDING",
70  "RUNNING",
71  "SUCCEEDED",
72  "FAILED",
73  "DEAD_LETTER"
74] as const;
75export type AutomationRunStatus = (typeof AUTOMATION_RUN_STATUSES)[number];
76 
77/** Terminal run states (SUCCEEDED | FAILED | DEAD_LETTER). */
78export const TERMINAL_RUN_STATUSES: readonly AutomationRunStatus[] = [
79  "SUCCEEDED",
80  "FAILED",
81  "DEAD_LETTER"
82];
83 
84// ---------------------------------------------------------------------------
85// Engine limits (frozen by the phase spec)
86// ---------------------------------------------------------------------------
87 
88/** Max actions stored on an automation (save-time cap, 422 TOO_MANY_ACTIONS). */
89export const AUTOMATION_MAX_ACTIONS = 50;
90/** Max triggers per automation (OR-semantics). */
91export const AUTOMATION_MAX_TRIGGERS = 3;
92/** Run-level retry cap: exponential backoff, then dead-letter. */
93export const AUTOMATION_MAX_RETRIES = 8;
94/** Loop-prevention depth cap (events at depth >= 5 are dropped). */
95export const AUTOMATION_MAX_DEPTH = 5;
96/** Condition tree: max nesting depth. */
97export const CONDITION_MAX_DEPTH = 5;
98/** Condition tree: max total nodes. */
99export const CONDITION_MAX_NODES = 100;
100/** Max condition groups stored on an automation row set (0..1 tree). */
101export const AUTOMATION_MAX_CONDITIONS = 1;
102/** Max automation name length. */
103export const AUTOMATION_NAME_MAX = 100;
104/** Max description length. */
105export const AUTOMATION_DESCRIPTION_MAX = 500;
106/** Action retry count bounds (per action). */
107export const AUTOMATION_ACTION_RETRIES = { min: 1, max: 8, default: 3 } as const;
108/** Action timeout bounds (ms, per action). */
109export const AUTOMATION_ACTION_TIMEOUT_MS = { min: 1_000, max: 300_000, default: 30_000 } as const;
110/** Run storage truncation caps (bytes, UTF-8). */
111export const RUN_INPUT_MAX_BYTES = 64 * 1024;
112export const RUN_OUTPUT_MAX_BYTES = 128 * 1024;
113/** Incoming-webhook body cap (bytes) + stored payload cap. */
114export const WEBHOOK_BODY_MAX_BYTES = 256 * 1024;
115export const WEBHOOK_PAYLOAD_MAX_BYTES = 64 * 1024;
116/** Webhook signature timestamp tolerance (±5 min). */
117export const WEBHOOK_TS_TOLERANCE_MS = 5 * 60 * 1000;
118/** Webhook per-endpoint rate limit (requests / minute). */
119export const WEBHOOK_RATE_PER_MINUTE = 60;
120/** Manual-run input cap (bytes). */
121export const MANUAL_RUN_INPUT_MAX_BYTES = 64 * 1024;
122/** Cron minimum interval: reject expressions firing more than once a minute. */
123export const CRON_MIN_INTERVAL_MS = 60_000;
124/** Automation emails per workspace per day (SEND_EMAIL cap). */
125export const AUTOMATION_EMAIL_DAILY_CAP = 500;
126 
127/**
128 * Run-level retry backoff schedule (seconds): attempt n (1-based, the FIRST
129 * retry is attempt 1) waits 2^(n-1) s capped at 600 s. TASK-0113 unit-test
130 * pins this; the worker multiplies by AUTO_BACKOFF_BASE_MS in test profiles.
131 */
132export function runBackoffSeconds(attempt: number): number {
133  if (!Number.isInteger(attempt) || attempt < 1) return 0;
134  return Math.min(2 ** (attempt - 1), 600);
135}
136 
137// ---------------------------------------------------------------------------
138// Status machine
139// ---------------------------------------------------------------------------
140 
141/**
142 * Valid status transitions (TASK-0110 §2). Anything else → 409:
143 *   DRAFT → ACTIVE
144 *   ACTIVE ↔ PAUSED
145 *   ACTIVE/PAUSED → ARCHIVED
146 *   ARCHIVED → ACTIVE
147 * (DRAFT/PAUSED → ARCHIVED allowed too? No — the spec allows
148 *  ACTIVE/PAUSED → ARCHIVED and ARCHIVED → ACTIVE; DRAFT → ARCHIVED is not in
149 *  the spec's valid set. The 5 invalid pairs the tests assert include
150 *  DRAFT → PAUSED.)
151 */
152/**
153 * Status machine (TASK-0110 §2) — exactly 6 valid transitions:
154 * DRAFT→ACTIVE, ACTIVE↔PAUSED, ACTIVE/PAUSED→ARCHIVED, ARCHIVED→ACTIVE.
155 * DRAFT→ARCHIVED is NOT a transition (a DRAFT row is removed via the DELETE
156 * endpoint, which archives it as a separate operation — not a status change).
157 */
158export const AUTOMATION_STATUS_TRANSITIONS: Record<AutomationStatus, AutomationStatus[]> = {
159  DRAFT: ["ACTIVE"],
160  ACTIVE: ["PAUSED", "ARCHIVED"],
161  PAUSED: ["ACTIVE", "ARCHIVED"],
162  ARCHIVED: ["ACTIVE"]
163};
164 
165export function isValidAutomationTransition(from: AutomationStatus, to: AutomationStatus): boolean {
166  if (from === to) return false; // no-op transition is invalid (client bug)
167  return (AUTOMATION_STATUS_TRANSITIONS[from] ?? []).includes(to);
168}
169 
170// ---------------------------------------------------------------------------
171// Trigger schemas
172// ---------------------------------------------------------------------------
173 
174const cuid = z
175  .string()
176  .min(1)
177  .max(64)
178  .refine((s) => !s.includes(" ") && s.length >= 8, "expected an id");
179 
180export const TriggerConfigSchemas = {
181  RECORD_CREATED: z.object({
182    databaseId: cuid.optional()
183  }).strict(),
184  RECORD_UPDATED: z
185    .object({
186      databaseId: cuid.optional(),
187      /** Optional narrowing: only these property names (empty/absent = all). */
188      properties: z.array(z.string().min(1).max(100)).max(20).optional()
189    })
190    .strict(),
191  PROPERTY_CHANGED: z
192    .object({
193      databaseId: cuid.optional(),
194      /** Required: fires when any of these properties changed. */
195      properties: z.array(z.string().min(1).max(100)).min(1).max(20)
196    })
197    .strict(),
198  PAGE_CREATED: z.object({}).strict(),
199  FORM_SUBMITTED: z.object({ databaseId: cuid.optional() }).strict(),
200  SCHEDULE: z
201    .object({
202      cron: z.string().min(2).max(120),
203      timeZone: z.string().min(1).max(64).optional()
204    })
205    .strict()
206    .refine((c) => cronCheck(c.cron).ok, { message: "invalid cron" }),
207  WEBHOOK: z
208    .object({
209      /** Server-generated public id (cuid) — returned once with the secret. */
210      webhookId: cuid.optional(),
211      /** sha256(secret) hex — for display/regeneration bookkeeping. */
212      secretHash: z.string().regex(/^[0-9a-f]{64}$/).optional(),
213      /** KMS-wrapped secret (see webhook-secret.ts) — server-managed. */
214      secretEncrypted: z.string().min(8).optional()
215    })
216    .strict(),
217  MANUAL: z.object({}).strict()
218} as const;
219 
220export const AUTOMATION_TRIGGER_TYPES_Z = z.enum(AUTOMATION_TRIGGER_TYPES);
221 
222export const AutomationTriggerDto = z.object({
223  type: AUTOMATION_TRIGGER_TYPES_Z,
224  config: z
225    .object({})
226    .passthrough()
227    .default({})
228});
229 
230/**
231 * Validate a trigger config against its type-specific schema. Used by the
232 * API (save-time, 422 with path-located errors) — the worker trusts stored
233 * rows but re-checks SCHEDULE cron at reconcile time (corrupt-row defense).
234 */
235export function validateTriggerConfig(
236  type: AutomationTriggerType,
237  config: unknown
238): { ok: true } | { ok: false; errors: { path: string; message: string }[] } {
239  const schema = TriggerConfigSchemas[type];
240  const parsed = schema.safeParse(config ?? {});
241  if (parsed.success) return { ok: true };
242  return {
243    ok: false,
244    errors: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))
245  };
246}
247 
248// ---------------------------------------------------------------------------
249// Cron
250// ---------------------------------------------------------------------------
251 
252export interface CronCheckResult {
253  ok: boolean;
254  /** "CRON_INVALID" | "CRON_TOO_FREQUENT" | null. */
255  code: "CRON_INVALID" | "CRON_TOO_FREQUENT" | null;
256  message: string | null;
257  /** Next fire time (UTC) when valid. */
258  nextFire?: Date;
259}
260 
261/**
262 * Validate a 5/6-field cron expression (cron-parser; seconds field optional —
263 * the spec's "6-field, seconds optional" = 5-field minute-precision or
264 * 6-field second-precision; 4-field expressions are rejected even though
265 * cron-parser itself accepts them). Rejects unparseable expressions and any
266 * interval < 60 s between two consecutive fires (CRON_TOO_FREQUENT).
267 */
268export function cronCheck(expr: string): CronCheckResult {
269  const fields = expr.trim().split(/\s+/).filter(Boolean);
270  if (fields.length !== 5 && fields.length !== 6) {
271    return { ok: false, code: "CRON_INVALID", message: `cron must have 5 or 6 fields (got ${fields.length})` };
272  }
273  let intervalMs = Infinity;
274  try {
275    const it = cronParser.parseExpression(expr);
276    const next1 = it.next().toDate();
277    const next2 = it.next().toDate();
278    intervalMs = next2.getTime() - next1.getTime();
279    return {
280      ok: intervalMs >= CRON_MIN_INTERVAL_MS,
281      code: intervalMs >= CRON_MIN_INTERVAL_MS ? null : "CRON_TOO_FREQUENT",
282      message:
283        intervalMs >= CRON_MIN_INTERVAL_MS
284          ? null
285          : `cron fires every ${Math.round(intervalMs / 1000)} s (minimum 60 s)`,
286      nextFire: next1
287    };
288  } catch (err) {
289    return {
290      ok: false,
291      code: "CRON_INVALID",
292      message: `invalid cron expression: ${(err as Error).message}`
293    };
294  }
295}
296 
297// ---------------------------------------------------------------------------
298// Condition expression (TASK-0111)
299// ---------------------------------------------------------------------------
300 
301export const CONDITION_OPERATORS = [
302  "eq",
303  "neq",
304  "contains",
305  "not_contains",
306  "starts_with",
307  "ends_with",
308  "gt",
309  "gte",
310  "lt",
311  "lte",
312  "in",
313  "not_in",
314  "is_empty",
315  "is_not_empty"
316] as const;
317export type ConditionOperator = (typeof CONDITION_OPERATORS)[number];
318 
319const predicateSchema = z
320  .object({
321    op: z.never().optional(),
322    field: z
323      .object({
324        ref: z.enum(["event", "property"]),
325        name: z.string().min(1).max(200)
326      })
327      .strict(),
328    operator: z.enum(CONDITION_OPERATORS),
329    value: z
330      .union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number(), z.boolean()]))])
331      .optional()
332  })
333  .strict();
334 
335/**
336 * Condition expression tree (frozen shape — stored on
337 * AutomationCondition.expression, evaluated worker-side).
338 *
339 *   { op: "AND" | "OR", children: Expr[] }   (AND/OR need ≥ 2 children)
340 *   { op: "NOT", child: Expr }               (single child — spec TASK-0111)
341 *   { field: { ref, name }, operator, value? }   predicate
342 *
343 * `value` is required for every operator except is_empty / is_not_empty.
344 * The zod schema is a save-time shape check only — the authoritative
345 * structural check is {@link validateConditionTree} (depth/node caps +
346 * per-group child-count rules), which is what the API calls.
347 */
348export const CONDITION_EXPRESSION_SCHEMA: z.ZodType<unknown> = z.lazy(() =>
349  z.union([
350    z
351      .object({
352        op: z.enum(["AND", "OR"]),
353        children: z.array(CONDITION_EXPRESSION_SCHEMA).min(1).max(CONDITION_MAX_NODES)
354      })
355      .strict(),
356    z.object({ op: z.literal("NOT"), child: CONDITION_EXPRESSION_SCHEMA }).strict(),
357    predicateSchema
358  ])
359);
360 
361export interface ConditionTreeCheck {
362  ok: boolean;
363  code: "CONDITION_TOO_COMPLEX" | "CONDITION_INVALID" | null;
364  message: string | null;
365  depth: number;
366  nodes: number;
367}
368 
369/**
370 * Structural validation of a stored condition tree:
371 *  - well-formed (groups have children arrays; AND/OR ≥ 2, NOT == 1),
372 *  - depth ≤ CONDITION_MAX_DEPTH (counting group levels; a bare predicate = 1),
373 *  - total nodes ≤ CONDITION_MAX_NODES (groups count as 1 each).
374 * Never throws — returns a check result (the API maps failures to 422,
375 * the worker to CONDITION_INVALID at evaluation time).
376 */
377export function validateConditionTree(tree: unknown): ConditionTreeCheck {
378  if (tree === null || tree === undefined) return { ok: true, code: null, message: null, depth: 0, nodes: 0 };
379  let depth = 0; // max GROUP nesting level (a bare predicate = 0)
380  let nodes = 0;
381  let valid = true;
382  let message: string | null = null;
383  let code: "CONDITION_TOO_COMPLEX" | "CONDITION_INVALID" | null = null;
384 
385  /** `enclosing` = number of group ancestors of `node`. */
386  const walk = (node: unknown, enclosing: number): void => {
387    if (!valid) return;
388    nodes += 1;
389    if (nodes > CONDITION_MAX_NODES) {
390      valid = false;
391      code = "CONDITION_TOO_COMPLEX";
392      message = `condition tree exceeds ${CONDITION_MAX_NODES} nodes`;
393      return;
394    }
395    if (typeof node !== "object" || node === null) {
396      valid = false;
397      code = "CONDITION_INVALID";
398      message = "condition node must be an object";
399      return;
400    }
401    const n = node as Record<string, unknown>;
402    if (n.op === "AND" || n.op === "OR" || n.op === "NOT") {
403      const level = enclosing + 1;
404      if (level > CONDITION_MAX_DEPTH) {
405        valid = false;
406        code = "CONDITION_TOO_COMPLEX";
407        message = `condition tree exceeds depth ${CONDITION_MAX_DEPTH}`;
408        return;
409      }
410      let children: unknown[];
411      if (n.op === "NOT") {
412        // Spec shape: { op: "NOT", child: Expr }.
413        if (n.child === undefined) {
414          valid = false;
415          code = "CONDITION_INVALID";
416          message = "NOT group needs a `child`";
417          return;
418        }
419        children = [n.child];
420      } else {
421        if (!Array.isArray(n.children) || n.children.length === 0) {
422          valid = false;
423          code = "CONDITION_INVALID";
424          message = `${n.op} group needs children`;
425          return;
426        }
427        if (n.children.length < 2) {
428          valid = false;
429          code = "CONDITION_INVALID";
430          message = `${n.op} group needs ≥ 2 children`;
431          return;
432        }
433        children = n.children;
434      }
435      depth = Math.max(depth, level);
436      for (const c of children) walk(c, level);
437      return;
438    }
439    // Predicate leaf.
440    const f = n.field;
441    if (
442      typeof f !== "object" ||
443      f === null ||
444      !["event", "property"].includes((f as { ref?: unknown }).ref as string) ||
445      typeof (f as { name?: unknown }).name !== "string"
446    ) {
447      valid = false;
448      code = "CONDITION_INVALID";
449      message = "predicate needs field { ref: event|property, name }";
450      return;
451    }
452    const op = n.operator;
453    if (typeof op !== "string" || !(CONDITION_OPERATORS as readonly string[]).includes(op)) {
454      valid = false;
455      code = "CONDITION_INVALID";
456      message = `unknown operator: ${String(op)}`;
457      return;
458    }
459    if (op !== "is_empty" && op !== "is_not_empty" && n.value === undefined) {
460      valid = false;
461      code = "CONDITION_INVALID";
462      message = `operator ${op} requires a value`;
463      return;
464    }
465  };
466 
467  walk(tree, 0);
468  if (!valid) return { ok: false, code, message, depth, nodes };
469  return { ok: true, code: null, message: null, depth, nodes };
470}
471 
472/**
473 * Normalize a stored property value for condition comparison (the spec's
474 * value-shape table). SELECT/STATUS → option name (or null);
475 * MULTI_SELECT → sorted option-name array; PERSON → user-id array;
476 * DATE → ISO string; NUMBER → number; CHECKBOX → boolean; TITLE/TEXT →
477 * string. Unknown shapes pass through (the operators fail closed on them).
478 */
479export function normalizeConditionValue(
480  type: string,
481  config: Record<string, unknown> | null | undefined,
482  value: unknown
483): unknown {
484  const options: { id: string; name: string }[] = Array.isArray(config?.options)
485    ? (config!.options as { id: string; name: string }[])
486    : [];
487  const nameOf = (id: unknown): string =>
488    typeof id === "string" ? (options.find((o) => o.id === id)?.name ?? id) : String(id);
489 
490  switch (type) {
491    case "SELECT":
492    case "STATUS":
493      return typeof value === "string" ? nameOf(value) : value ?? null;
494    case "MULTI_SELECT":
495      return Array.isArray(value) ? [...value].map(nameOf).sort() : value ?? null;
496    case "PERSON":
497      return Array.isArray(value) ? value.map(String) : value ?? null;
498    case "DATE":
499      if (value && typeof value === "object" && "start" in value && "end" in value) {
500        return `${(value as { start: unknown }).start}..${(value as { end: unknown }).end}`;
501      }
502      return typeof value === "string" || typeof value === "number" ? String(value) : value ?? null;
503    case "NUMBER":
504      return typeof value === "number" && Number.isFinite(value) ? value : value ?? null;
505    case "CHECKBOX":
506      return typeof value === "boolean" ? value : value ?? null;
507    case "TITLE":
508    case "TEXT":
509      return typeof value === "string" ? value : value ?? null;
510    default:
511      return value ?? null;
512  }
513}
514 
515// ---------------------------------------------------------------------------
516// Property value coercion (TASK-0112 executors write Phase-5 envelopes)
517// ---------------------------------------------------------------------------
518 
519/**
520 * Coerce an automation action's raw property value into the Phase-5 stored
521 * envelope shape (the single source of truth the API's record write path uses
522 * — mirror of `query.service.coerceValue`, shared so executors and the API
523 * never drift):
524 *   TEXT/TITLE/RICH_TEXT → { text, marks: [] }   NUMBER/EMAIL/PHONE → { value }
525 *   SELECT/STATUS → { optionId }                 MULTI_SELECT → { optionIds }
526 *   PERSON → { userIds }                          DATE/DATE_RANGE → { start, end }
527 *   CHECKBOX → { value }                          URL → { value, title: null }
528 * The input may already be an envelope (passed through) or a bare value
529 * (SELECT: option NAME is resolved by the CALLER — the shared layer sees the
530 * optionId string). Returns null for a null input.
531 *
532 * @throws {PropertyCoercionError} (code `PROPERTY_NOT_WRITABLE` /
533 *   `PROPERTY_VALUE_INVALID`) on read-only types or uncoercible values.
534 */
535export class PropertyCoercionError extends Error {
536  constructor(
537    public readonly code: "PROPERTY_NOT_WRITABLE" | "PROPERTY_VALUE_INVALID",
538    message: string
539  ) {
540    super(message);
541    this.name = "PropertyCoercionError";
542  }
543}
544 
545const READ_ONLY_PROPERTY_TYPES = new Set([
546  "FORMULA",
547  "ROLLUP",
548  "CREATED_TIME",
549  "CREATED_BY",
550  "LAST_EDITED_TIME",
551  "LAST_EDITED_BY",
552  "RELATION",
553  "BUTTON",
554  "UNIQUE_ID"
555]);
556 
557export function coercePropertyWriteValue(
558  type: string,
559  raw: unknown
560): Record<string, unknown> | null {
561  if (raw === null || raw === undefined) return null;
562  if (READ_ONLY_PROPERTY_TYPES.has(type)) {
563    throw new PropertyCoercionError("PROPERTY_NOT_WRITABLE", `${type} is read-only`);
564  }
565  const asStringArray = (v: unknown): string[] => {
566    const arr = Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
567    if (!arr.every((x) => typeof x === "string")) {
568      throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "string array required");
569    }
570    return arr;
571  };
572  switch (type) {
573    case "TEXT":
574    case "RICH_TEXT":
575    case "TITLE": {
576      if (typeof raw === "string") return { text: raw, marks: [] };
577      if (typeof raw === "object" && raw !== null && "text" in (raw as object)) return raw as Record<string, unknown>;
578      throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "text value required");
579    }
580    case "NUMBER":
581    case "EMAIL":
582    case "PHONE":
583    case "CHECKBOX": {
584      if (typeof raw === "number" || typeof raw === "string" || typeof raw === "boolean") return { value: raw };
585      if (typeof raw === "object" && raw !== null && "value" in (raw as object)) return raw as Record<string, unknown>;
586      throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", `${type} value required`);
587    }
588    case "SELECT":
589    case "STATUS": {
590      if (typeof raw === "string") return { optionId: raw };
591      if (typeof raw === "object" && raw !== null && "optionId" in (raw as object)) return raw as Record<string, unknown>;
592      throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "optionId value required");
593    }
594    case "MULTI_SELECT":
595      return { optionIds: asStringArray(raw) };
596    case "PERSON":
597      return { userIds: asStringArray(raw) };
598    case "FILES":
599      return { fileIds: asStringArray(raw) };
600    case "DATE":
601    case "DATE_RANGE": {
602      if (typeof raw === "string") return { start: raw, end: null };
603      if (typeof raw === "object" && raw !== null && "start" in (raw as object)) return raw as Record<string, unknown>;
604      throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "date value required");
605    }
606    case "URL": {
607      if (typeof raw === "string") return { value: raw, title: null };
608      if (typeof raw === "object" && raw !== null && "value" in (raw as object)) return raw as Record<string, unknown>;
609      throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "url value required");
610    }
611    default:
612      throw new PropertyCoercionError("PROPERTY_NOT_WRITABLE", `type ${type} does not accept direct writes`);
613  }
614}
615 
616// ---------------------------------------------------------------------------
617// Action schemas (TASK-0112)
618// ---------------------------------------------------------------------------
619 
620/** Interpolable string field: max length + `{{…}}` interpolation allowed. */
621const interpString = (max: number) =>
622  z
623    .string()
624    .min(1)
625    .max(max)
626    .refine((s) => {
627      // Reject interpolation paths deeper than 3 segments (spec §9).
628      const paths = [...s.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)].map((m) => (m[1] as string).split("."));
629      return paths.every((p) => p.length <= 3);
630    }, "interpolation path too deep (max 3 segments)");
631 
632const fromTrigger = z
633  .literal("from_trigger")
634  .or(cuid)
635  .or(z.literal("trigger_user"))
636  .or(z.literal("automation_creator"));
637 
638export const ActionConfigSchemas: Record<AutomationActionType, z.ZodTypeAny> = {
639  UPDATE_RECORD: z
640    .object({
641      recordId: z
642        .union([z.literal("from_trigger"), cuid])
643        .default("from_trigger"),
644      /** property NAME → raw value (coerced at runtime via the record service). */
645      propertyValues: z
646        .record(z.string().min(1).max(100), z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]))
647        .refine((o) => Object.keys(o).length >= 1, "propertyValues must have ≥ 1 entry")
648        .refine((o) => Object.keys(o).length <= 20, "propertyValues allows ≤ 20 entries")
649    })
650    .strict(),
651  CREATE_PAGE: z
652    .object({
653      title: interpString(200),
654      parentId: z.union([z.literal("from_trigger_page"), cuid]).optional(),
655      databaseId: cuid.optional(),
656      /** Phase-19 templates — optional, validated if set (spec §2). */
657      templateId: cuid.optional(),
658      icon: z
659        .string()
660        .min(1)
661        .max(8)
662        .optional()
663        .refine((s) => [...(s ?? "")].length <= 2, "icon must be an emoji (≤ 2 codepoints)"),
664      /** Optional property values for a record page (databaseId set). */
665      propertyValues: z
666        .record(
667          z.string().min(1).max(100),
668          z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))])
669        )
670        .optional()
671    })
672    .strict(),
673  SEND_EMAIL: z
674    .object({
675      to: z
676        .union([
677          z.string().email(),
678          z.literal("trigger_user"),
679          z.literal("automation_creator")
680        ])
681        .default("automation_creator"),
682      subject: interpString(200),
683      body: z.string().min(1).max(20_000),
684      cc: z
685        .array(z.string().email())
686        .max(5)
687        .optional()
688    })
689    .strict(),
690  SEND_WEBHOOK: z
691    .object({
692      url: z
693        .string()
694        .url()
695        .max(2048)
696        .refine((u) => u.startsWith("https://"), "webhook URL must be https (SSRF guard)"),
697      headers: z
698        .record(z.string().min(1).max(128), z.string().max(512))
699        .refine((h) => Object.keys(h).length <= 20, "≤ 20 headers")
700        .optional(),
701      payload: z
702        .record(z.string(), z.unknown())
703        .optional()
704        .refine((p) => JSON.stringify(p ?? {}).length <= WEBHOOK_PAYLOAD_MAX_BYTES, "payload ≤ 64 KB")
705    })
706    .strict(),
707  CREATE_NOTIFICATION: z
708    .object({
709      to: z
710        .union([cuid, z.literal("trigger_user"), z.literal("automation_creator")])
711        .default("automation_creator"),
712      type: z.enum([
713        "MENTION",
714        "FORM_SUBMITTED",
715        "COMMENT",
716        "ASSIGNMENT",
717        "PAGE_SHARED",
718        "TASK_ASSIGNED",
719        "DATABASE_CHANGE",
720        "AUTOMATION",
721        "INTEGRATION"
722      ]),
723      body: interpString(500),
724      /** Internal path only ("/…") — no host. */
725      link: z
726        .string()
727        .max(500)
728        .refine((l) => l.startsWith("/") && !l.startsWith("//"), "link must be an internal path (starts with /)")
729        .optional()
730    })
731    .strict(),
732  ASSIGN_USER: z
733    .object({
734      recordId: z.union([z.literal("from_trigger"), cuid]).default("from_trigger"),
735      /**
736       * PERSON property id (spec TASK-0112). The API verifies at save that the
737       * property exists, belongs to the automation's database, and is of
738       * type PERSON (422 otherwise); the worker re-checks at runtime.
739       */
740      propertyId: cuid,
741      userId: fromTrigger
742    })
743    .strict(),
744  CHANGE_STATUS: z
745    .object({
746      recordId: z.union([z.literal("from_trigger"), cuid]).default("from_trigger"),
747      /** STATUS or SELECT property id (verified at save; 422 otherwise). */
748      propertyId: cuid,
749      /** Option NAME (verified against the property's options at save). */
750      value: z.string().min(1).max(100)
751    })
752    .strict(),
753  CALL_INTEGRATION: z
754    .object({
755      integrationId: cuid,
756      operation: z.string().min(1).max(100),
757      params: z.record(z.string(), z.unknown()).optional()
758    })
759    .strict(),
760  RUN_AI_ACTION: z
761    .object({
762      prompt: interpString(4_000),
763      task: z.enum(["summarize", "classify", "extract"])
764    })
765    .strict()
766};
767 
768export interface AutomationActionDtoInput {
769  type: AutomationActionType;
770  config: Record<string, unknown>;
771  retries?: number;
772  timeoutMs?: number;
773}
774 
775/**
776 * Validate one action entry (type + config + per-action knobs). The API uses
777 * this for `PUT .../actions` / create/update; the worker re-validates at
778 * runtime (corrupt-row defense → fatal UNKNOWN/INVALID config errors).
779 */
780export function validateActionDto(
781  action: AutomationActionDtoInput
782): { ok: true; normalized: { type: AutomationActionType; config: Record<string, unknown>; retries: number; timeoutMs: number } } | {
783  ok: false;
784  errors: { path: string; message: string }[];
785} {
786  if (!(AUTOMATION_ACTION_TYPES as readonly string[]).includes(action.type)) {
787    return { ok: false, errors: [{ path: "type", message: `unknown action type: ${action.type}` }] };
788  }
789  const schema = ActionConfigSchemas[action.type];
790  const parsed = schema.safeParse(action.config ?? {});
791  if (!parsed.success) {
792    return {
793      ok: false,
794      errors: parsed.error.issues.map((i) => ({ path: `config.${i.path.join(".")}`, message: i.message }))
795    };
796  }
797  const retries = action.retries ?? AUTOMATION_ACTION_RETRIES.default;
798  const timeoutMs = action.timeoutMs ?? AUTOMATION_ACTION_TIMEOUT_MS.default;
799  if (!Number.isInteger(retries) || retries < AUTOMATION_ACTION_RETRIES.min || retries > AUTOMATION_ACTION_RETRIES.max) {
800    return { ok: false, errors: [{ path: "retries", message: `retries must be ${AUTOMATION_ACTION_RETRIES.min}..${AUTOMATION_ACTION_RETRIES.max}` }] };
801  }
802  if (!Number.isInteger(timeoutMs) || timeoutMs < AUTOMATION_ACTION_TIMEOUT_MS.min || timeoutMs > AUTOMATION_ACTION_TIMEOUT_MS.max) {
803    return {
804      ok: false,
805      errors: [{ path: "timeoutMs", message: `timeoutMs must be ${AUTOMATION_ACTION_TIMEOUT_MS.min}..${AUTOMATION_ACTION_TIMEOUT_MS.max}` }]
806    };
807  }
808  return { ok: true, normalized: { type: action.type, config: parsed.data as Record<string, unknown>, retries, timeoutMs } };
809}
810 
811// ---------------------------------------------------------------------------
812// UI metadata (TASK-0114 renders FROM these — no duplicated type lists)
813// ---------------------------------------------------------------------------
814 
815export interface AutomationTriggerMeta {
816  type: AutomationTriggerType;
817  label: string;
818  description: string;
819  /** Domain events this trigger type can be sourced from (info for the UI). */
820  events: string[];
821  /** Whether the UI needs a database scope pick for this trigger. */
822  needsDatabase: boolean;
823}
824 
825export const AUTOMATION_TRIGGERS: readonly AutomationTriggerMeta[] = [
826  { type: "RECORD_CREATED", label: "When a record is created", description: "Fires when a new record appears in a database.", events: ["record.created"], needsDatabase: true },
827  { type: "RECORD_UPDATED", label: "When a record is updated", description: "Fires on any record update (optionally narrowed to properties).", events: ["record.updated"], needsDatabase: true },
828  { type: "PROPERTY_CHANGED", label: "When a property changes", description: "Fires when one of the listed properties changes.", events: ["record.updated"], needsDatabase: true },
829  { type: "PAGE_CREATED", label: "When a page is created", description: "Fires when a page is created in the workspace.", events: ["page.created"], needsDatabase: false },
830  { type: "FORM_SUBMITTED", label: "When a form is submitted", description: "Fires on a public form submission (creates a record).", events: ["form.submitted"], needsDatabase: true },
831  { type: "SCHEDULE", label: "On a schedule", description: "Runs on a cron schedule (minimum 60 s).", events: ["schedule"], needsDatabase: false },
832  { type: "WEBHOOK", label: "When a webhook is received", description: "Fires on a signed POST to the automation's public URL.", events: ["webhook"], needsDatabase: false },
833  { type: "MANUAL", label: "Manually", description: "Can be run from the Run now button or the API.", events: ["manual"], needsDatabase: false }
834];
835 
836export interface AutomationActionMeta {
837  type: AutomationActionType;
838  label: string;
839  description: string;
840  icon: string;
841  /** Stub handoff badge (Phase 12 AI / Phase 13 integration) — savable as DRAFT. */
842  stub: "phase-12" | "phase-13" | null;
843}
844 
845export const AUTOMATION_ACTIONS: readonly AutomationActionMeta[] = [
846  { type: "UPDATE_RECORD", label: "Update record", description: "Set property values on a record.", icon: "pencil", stub: null },
847  { type: "CREATE_PAGE", label: "Create page", description: "Create a page (or a record page).", icon: "file-plus", stub: null },
848  { type: "SEND_EMAIL", label: "Send email", description: "Email a user or role.", icon: "mail", stub: null },
849  { type: "SEND_WEBHOOK", label: "Send webhook", description: "POST a JSON payload to a public URL.", icon: "webhook", stub: null },
850  { type: "CREATE_NOTIFICATION", label: "Notify user", description: "Create an in-app notification.", icon: "bell", stub: null },
851  { type: "ASSIGN_USER", label: "Assign user", description: "Set a person property to a user.", icon: "user-plus", stub: null },
852  { type: "CHANGE_STATUS", label: "Change status", description: "Set a status/select property to an option.", icon: "refresh-cw", stub: null },
853  { type: "CALL_INTEGRATION", label: "Call integration", description: "Call a connected integration (Phase 13).", icon: "plug", stub: "phase-13" },
854  { type: "RUN_AI_ACTION", label: "Run AI action", description: "Summarize / classify / extract with AI (Phase 12).", icon: "sparkles", stub: "phase-12" }
855];
856 
857/** Action types that fail at runtime until their phase lands (stubs). */
858export const STUB_ACTION_TYPES: readonly AutomationActionType[] = [
859  "CALL_INTEGRATION",
860  "RUN_AI_ACTION"
861];
862 
863// ---------------------------------------------------------------------------
864// Loop prevention (TASK-0113 contract — enforced in the worker)
865// ---------------------------------------------------------------------------
866 
867/** The automationContext carried on events emitted BY automation actions. */
868export interface AutomationContext {
869  runId: string;
870  automationId: string;
871  depth: number;
872}
873 
874export type LoopGuardDecision =
875  | { blocked: false }
876  | { blocked: true; reason: "self_trigger" | "depth_limit"; depth: number };
877 
878/**
879 * Decide whether an event should be allowed to trigger `automationId`:
880 *  - self-trigger: the event was emitted by the SAME automation → always
881 *    blocked (its own record.updated cannot re-fire it);
882 *  - depth limit: events at depth ≥ AUTOMATION_MAX_DEPTH are dropped
883 *    (a chain A→B→C→D→E stops at E's side effects).
884 * No context (a human/API-originated event) → allowed at depth 0.
885 */
886export function loopGuard(
887  incoming: { automationId?: string; depth?: number } | null | undefined,
888  automationId: string
889): LoopGuardDecision {
890  const depth = typeof incoming?.depth === "number" ? incoming.depth : 0;
891  if (incoming && typeof incoming.automationId === "string" && incoming.automationId === automationId) {
892    return { blocked: true, reason: "self_trigger", depth };
893  }
894  if (depth >= AUTOMATION_MAX_DEPTH) {
895    return { blocked: true, reason: "depth_limit", depth };
896  }
897  return { blocked: false };
898}
899 
900/** Depth stamp for an event emitted by an action of a run at `depth`. */
901export function nextAutomationContext(
902  runId: string,
903  automationId: string,
904  depth: number
905): AutomationContext {
906  return { runId, automationId, depth: depth + 1 };
907}
908 
909// ---------------------------------------------------------------------------
910// Interpolation (7 key families — single implementation, TASK-0112 §9)
911// ---------------------------------------------------------------------------
912 
913export interface InterpolationVars {
914  record?: Record<string, unknown> | null;
915  event?: Record<string, unknown> | null;
916  input?: Record<string, unknown> | null;
917  automation?: { id?: string; name?: string } | null;
918  run?: { id?: string } | null;
919  [key: string]: unknown;
920}
921 
922/**
923 * Replace `{{dotted.path}}` tokens from the vars object.
924 *  - `record.<Property>`, `record.id`, `event.type`, `event.payload.<path>`,
925 *    `input.<path>`, `automation.id`/`automation.name`, `run.id`,
926 *  - dot paths capped at 3 segments (save-time rejected),
927 *  - unknown key → "" + a warning entry in the returned warnings list,
928 *  - object values are JSON-stringified.
929 */
930export function interpolate(
931  template: string,
932  vars: InterpolationVars
933): { value: string; warnings: string[] } {
934  const warnings: string[] = [];
935  const value = template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, path: string) => {
936    const parts = path.split(".").slice(0, 3);
937    let cur: unknown = vars;
938    for (const p of parts) {
939      if (cur === null || cur === undefined || typeof cur !== "object") {
940        cur = undefined;
941        break;
942      }
943      cur = (cur as Record<string, unknown>)[p];
944    }
945    if (cur === undefined || cur === null) {
946      if (!/^(record|event|input|automation|run)\./.test(path)) {
947        warnings.push(`unknown interpolation key: {{${path}}}`);
948      }
949      return "";
950    }
951    if (typeof cur === "object") return JSON.stringify(cur);
952    return String(cur);
953  });
954  return { value, warnings };
955}
956 
957// ---------------------------------------------------------------------------
958// Truncation (run storage)
959// ---------------------------------------------------------------------------
960 
961/**
962 * Truncate a JSON-serializable value to a UTF-8 byte budget. When truncated,
963 * the result is an object { [key]: …, truncated: true, truncatedBytes: n }
964 * (top-level objects) or the string form with a "…" tail (non-objects).
965 */
966export function truncateJson(value: unknown, maxBytes: number): { value: unknown; truncated: boolean } {
967  const enc = new TextEncoder();
968  const raw = JSON.stringify(value ?? null);
969  if (raw === undefined) return { value: null, truncated: false };
970  if (enc.encode(raw).length <= maxBytes) return { value, truncated: false };
971 
972  if (value !== null && typeof value === "object" && !Array.isArray(value)) {
973    // Drop the largest leaf values first until it fits.
974    const obj = { ...(value as Record<string, unknown>) };
975    const entries = Object.entries(obj).sort((a, b) => JSON.stringify(b[1]).length - JSON.stringify(a[1]).length);
976    for (const [k, v] of entries) {
977      if (enc.encode(JSON.stringify(obj)).length <= maxBytes) break;
978      obj[k] = `[truncated]`;
979    }
980    obj.truncated = true;
981    return { value: obj, truncated: true };
982  }
983  // Non-object: cut the serialized string to fit, preserving valid JSON.
984  let s = raw;
985  while (enc.encode(s).length > maxBytes - 24) {
986    s = s.slice(0, Math.max(0, s.length - 1024));
987  }
988  return { value: s + ` …[truncated]`, truncated: true };
989}
990 
991// ---------------------------------------------------------------------------
992// Incoming webhook signature (TASK-0110 §5)
993// ---------------------------------------------------------------------------
994 
995export interface WebhookSignatureParts {
996  t: number; // unix seconds
997  v1: string; // hex hmac
998}
999 
1000/** Build `X-FS-Signature: t=<unix-ts>,v1=<hex(hmac_sha256(secret, t + "." + rawBody))>`. */
1001export function buildWebhookSignature(secret: string, unixTsSeconds: number, rawBody: string): string {
1002  const mac = createHmac("sha256", secret).update(`${unixTsSeconds}.${rawBody}`).digest("hex");
1003  return `t=${unixTsSeconds},v1=${mac}`;
1004}
1005 
1006export function parseWebhookSignature(header: string): WebhookSignatureParts | null {
1007  const m = header.match(/t=(\d+),v1=([0-9a-f]{64})/);
1008  if (!m || m[1] === undefined || m[2] === undefined) return null;
1009  return { t: Number(m[1]), v1: m[2] };
1010}
1011 
1012/**
1013 * Verify an incoming-webhook signature in constant time.
1014 * `nowMs` is injectable for tests. Returns the failure reason code:
1015 *   null = valid; "WEBHOOK_SIGNATURE_INVALID" | "WEBHOOK_TIMESTAMP_STALE".
1016 */
1017export function verifyWebhookSignature(
1018  secret: string,
1019  header: string | undefined,
1020  rawBody: string,
1021  nowMs: number = Date.now()
1022): null | "WEBHOOK_SIGNATURE_INVALID" | "WEBHOOK_TIMESTAMP_STALE" {
1023  const parts = header ? parseWebhookSignature(header) : null;
1024  if (!parts) return "WEBHOOK_SIGNATURE_INVALID";
1025  const tsMs = parts.t * 1000;
1026  if (Math.abs(nowMs - tsMs) > WEBHOOK_TS_TOLERANCE_MS) return "WEBHOOK_TIMESTAMP_STALE";
1027  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
1028  const a = Buffer.from(expected, "hex");
1029  const b = Buffer.from(parts.v1, "hex");
1030  if (a.length !== b.length || !timingSafeEqual(a, b)) return "WEBHOOK_SIGNATURE_INVALID";
1031  return null;
1032}
1033 
1034/** Generate a fresh webhook signing secret: "whsec_" + 32 random bytes hex. */
1035export function generateWebhookSecret(): string {
1036  return "whsec_" + randomBytes(32).toString("hex");
1037}
1038 
1039/** sha256(secret) hex — stored for bookkeeping; the wrapped secret is what verifies. */
1040export function webhookSecretHash(secret: string): string {
1041  return createHmac("sha256", "hash").update(secret).digest("hex");
1042}
1043 
1044// ---------------------------------------------------------------------------
1045// Redis key helpers (dedupe / rate limit / email cap)
1046// ---------------------------------------------------------------------------
1047 
1048/** Matcher dedupe key: one run per (automation, event). TTL 10 min. */
1049export const DEDUPE_TTL_SECONDS = 600;
1050export function dedupeKey(automationId: string, eventId: string): string {
1051  return `auto:dedupe:${automationId}:${eventId}`;
1052}
1053 
1054/** Per-webhookId rate-limit key (sliding 60 s window). */
1055export function webhookRateKey(webhookId: string): string {
1056  return `auto:webhook-rate:${webhookId}`;
1057}
1058 
1059/** Per-workspace daily automation-email cap key (UTC date). */
1060export function emailCapKey(workspaceId: string, date: Date = new Date()): string {
1061  return `auto:email:${workspaceId}:${date.toISOString().slice(0, 10)}`;
1062}
1063