.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
oauth.service.tsapps/api/src/modules/oauth/oauth.service.tsraw← oauth35,526 B← Back to the app
1import { Inject, Injectable } from "@nestjs/common";
2import type { Request } from "express";
3import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
4import { generateId } from "@factory/shared";
5import { PrismaService } from "../../common/prisma.service.js";
6import { RedisService } from "../../common/redis.service.js";
7import { AuditService } from "../../common/audit.service.js";
8import { ApiError } from "../../common/errors.js";
9import { assertRole } from "../../lib/authorize.js";
10import { loadEnv } from "../../config/env.js";
11import { signToken, verifyToken, parseTtl, TokenError } from "../../lib/jwt.js";
12import { sha256hex } from "../../lib/crypto.js";
13import { OAUTH_SCOPES, parseScopeList, serializeScopeList, pkceChallenge } from "./scopes.js";
14import { codeKey, rtKey, familyKey, jtiKey, isJtiRevoked, clientCodesKey, clientRtSetKey } from "./token-keys.js";
15import { v4 as uuid } from "uuid";
16 
17/**
18 * TASK-0129 — OAuth2 authorization-code + PKCE for third-party apps.
19 *
20 * Registered apps live in the `Integration` model (`provider: "oauth"`,
21 * `config.oauthApp`) — one app per row. The client secret is stored SHA-256
22 * hashed (never plaintext); public clients have no secret and MUST use PKCE.
23 *
24 * Authorization codes and rotating refresh tokens live in REDIS only (no DB
25 * tables, per the task's "Redis only" decision):
26 *   - code:   oauth:code:<code>   (SETEX 600, GETDEL on consume — single-use)
27 *   - rt:     oauth:rt:<sha256>   ({familyId, clientId, userId, scopes, revoked})
28 *   - family: oauth:family:<fid>  ("revoked") — the reuse-kill switch
29 *   - jti:    oauth:jti:<jti>     (access-token denylist)
30 *
31 * Refresh rotation follows security-architecture.md §1.2 EXACTLY as the user
32 * login flow does: a live token rotates (old → revoked tombstone, new issued
33 * in the same family); presenting an ALREADY-ROTATED token is a reuse attack
34 * → the whole family is revoked + audited. OAuth families are per-(user,
35 * client) so a reuse-kill can never collateral-revoke the user's login
36 * sessions (those are the DB refresh-token family — separate).
37 *
38 * Scope enforcement lives in `lib/authorize.ts` (`oauthScopeCap` branch):
39 * `*:read` → VIEW, any `*:write` → EDIT. There is no FULL_ACCESS scope —
40 * owner/admin-only routes stay out of reach for third-party apps (C-13.7).
41 */
42 
43/** The registered-app shape stored in `Integration.config.oauthApp`. */
44export interface OAuthAppRow {
45  clientId: string;
46  /** SHA-256 hex of the client secret. ABSENT for public clients (PKCE). */
47  clientSecretHash?: string;
48  name: string;
49  logoUrl?: string;
50  redirectUris: string[];
51  scopes: string[];
52  grantTypes: string[];
53  createdAt: string;
54}
55 
56/** A loaded app + its owning workspace (for token issuance). */
57export interface LoadedApp {
58  clientId: string;
59  workspaceId: string;
60  name: string;
61  logoUrl?: string;
62  redirectUris: string[];
63  scopes: string[];
64  clientSecretHash?: string;
65}
66 
67/** The result of `GET /oauth/authorize` — the controller renders/redirects. */
68export interface AuthorizeDecision {
69  type: "login" | "consent" | "error";
70  /** For type "error": the HTTP status (RFC 6749: 400 invalid_request, 401 unauthorized_client, 403 access_denied). */
71  status?: number;
72  /** For type "error": the RFC 6749 error code. */
73  error?: string;
74  description?: string;
75  /** For type "login": the /login?next=… URL to redirect to. */
76  nextUrl?: string;
77  /**
78   * For type "consent": a short-lived SIGNED consent token (CSRF nonce).
79   * The consent form echoes it in a hidden field; `POST /oauth/authorize`
80   * verifies it before issuing a code. It binds the RE-RESOLVED user (sub)
81   * to the exact authorize request (client, redirect_uri, scopes, PKCE
82   * challenge) for 10 minutes — a form posted from another origin (or a
83   * stale page) cannot mint a code, because the server re-validates every
84   * parameter against the signed payload, never the form.
85   */
86  consentToken?: string;
87  /** For type "consent": the data the consent screen needs. */
88  userId?: string;
89  app?: { name: string; logoUrl?: string; clientId: string };
90  requestedScopes?: string[];
91  redirectUri?: string;
92  state?: string;
93  codeChallenge?: string;
94  codeChallengeMethod?: string;
95}
96 
97/** The signed consent token's claim kind (distinct from access/session). */
98const CONSENT_KIND = "oauth_consent";
99/** Consent-token lifetime: the consent screen is a 10-minute window. */
100const CONSENT_TTL_SECONDS = 600;
101 
102/** The authorization-code window from env (`OAUTH_CODE_TTL`, default "10m"). */
103const codeTtlSeconds = (): number => parseTtl(loadEnv().OAUTH_CODE_TTL);
104 
105@Injectable()
106export class OauthService {
107  constructor(
108    @Inject(PrismaService) private readonly prisma: PrismaService,
109    @Inject(RedisService) private readonly redis: RedisService,
110    @Inject(AuditService) private readonly audit: AuditService
111  ) {}
112 
113  private redisClient() {
114    return this.redis.getRedis();
115  }
116  private refreshTtlSeconds(): number {
117    return parseTtl(loadEnv().REFRESH_TOKEN_TTL);
118  }
119 
120  // ============================================================
121  // App registration (Integration provider="oauth")
122  // ============================================================
123 
124  private async requireAdmin(workspaceId: string, userId: string) {
125    await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
126  }
127 
128  async listApps(workspaceId: string, userId: string) {
129    await this.requireAdmin(workspaceId, userId);
130    const rows = await this.prisma.integration.findMany({
131      where: { workspaceId, provider: "oauth" },
132      orderBy: { createdAt: "asc" }
133    });
134    return { data: rows.map((r) => this.publicAppView(r)) };
135  }
136 
137  async createApp(
138    workspaceId: string,
139    userId: string,
140    input: { name: string; logoUrl?: string; redirectUris: string[]; scopes: string[]; public?: boolean },
141    req: Request
142  ) {
143    await this.requireAdmin(workspaceId, userId);
144    for (const uri of input.redirectUris) {
145      const u = new URL(uri); // throws on malformed
146      if (u.protocol !== "http:" && u.protocol !== "https:") throw ApiError.validation("redirectUris must be http(s).", [{ field: "redirectUris", issue: "protocol" }]);
147    }
148    const scopes = input.scopes.filter((s) => (OAUTH_SCOPES as readonly string[]).includes(s));
149    if (scopes.length === 0) throw ApiError.validation("App must offer at least one valid scope.", [{ field: "scopes", issue: "no valid scopes" }]);
150    const isPublic = input.public ?? false;
151    const clientId = generateId("cli");
152    let clientSecret: string | undefined;
153    let clientSecretHash: string | undefined;
154    if (!isPublic) {
155      clientSecret = `cli_sec_${randomBytes(24).toString("hex")}`;
156      clientSecretHash = sha256hex(clientSecret);
157    }
158    const app: OAuthAppRow = {
159      clientId,
160      ...(clientSecretHash ? { clientSecretHash } : {}),
161      name: input.name,
162      ...(input.logoUrl ? { logoUrl: input.logoUrl } : {}),
163      redirectUris: input.redirectUris,
164      scopes,
165      grantTypes: ["authorization_code", "refresh_token"],
166      createdAt: new Date().toISOString()
167    };
168    const row = await this.prisma.integration.create({
169      data: {
170        workspaceId,
171        name: input.name,
172        provider: "oauth",
173        config: { oauthApp: app } as object,
174        status: "active",
175        createdById: userId
176      }
177    });
178    await this.audit.audit(req, req?.auth, {
179      action: "oauth.app_created",
180      resourceType: "oauth_app",
181      resourceId: clientId,
182      workspaceId,
183      metadata: { name: input.name, public: isPublic, scopes }
184    });
185    return {
186      id: row.id,
187      clientId,
188      name: input.name,
189      ...(clientSecret ? { clientSecret } : {}),
190      public: isPublic,
191      redirectUris: input.redirectUris,
192      scopes,
193      createdAt: row.createdAt
194    };
195  }
196 
197  async updateApp(
198    workspaceId: string,
199    userId: string,
200    clientId: string,
201    patch: { name?: string; logoUrl?: string | null; redirectUris?: string[]; scopes?: string[] },
202    req: Request
203  ) {
204    await this.requireAdmin(workspaceId, userId);
205    const row = await this.findAppRow(workspaceId, clientId);
206    if (!row) throw ApiError.notFound("App not found.");
207    const app = (row.config as unknown as { oauthApp: OAuthAppRow }).oauthApp;
208    if (patch.name !== undefined) app.name = patch.name;
209    if (patch.logoUrl !== undefined) {
210      if (patch.logoUrl === null) delete app.logoUrl;
211      else app.logoUrl = patch.logoUrl;
212    }
213    if (patch.redirectUris !== undefined) {
214      for (const uri of patch.redirectUris) {
215        const u = new URL(uri);
216        if (u.protocol !== "http:" && u.protocol !== "https:") throw ApiError.validation("redirectUris must be http(s).", [{ field: "redirectUris", issue: "protocol" }]);
217      }
218      app.redirectUris = patch.redirectUris;
219    }
220    if (patch.scopes !== undefined) {
221      const scopes = patch.scopes.filter((s) => (OAUTH_SCOPES as readonly string[]).includes(s));
222      if (scopes.length === 0) throw ApiError.validation("App must offer at least one valid scope.", [{ field: "scopes", issue: "no valid scopes" }]);
223      app.scopes = scopes;
224    }
225    const updated = await this.prisma.integration.update({
226      where: { id: row.id },
227      data: { config: { oauthApp: app } as object, name: app.name }
228    });
229    await this.audit.audit(req, req?.auth, {
230      action: "oauth.app_updated",
231      resourceType: "oauth_app",
232      resourceId: clientId,
233      workspaceId,
234      metadata: { name: app.name }
235    });
236    return this.publicAppView(updated);
237  }
238 
239  async deleteApp(workspaceId: string, userId: string, clientId: string, req: Request) {
240    await this.requireAdmin(workspaceId, userId);
241    const row = await this.findAppRow(workspaceId, clientId);
242    if (!row) throw ApiError.notFound("App not found.");
243    await this.prisma.integration.delete({ where: { id: row.id } });
244    // Drain every in-flight credential of this client: un-exchanged
245    // authorization codes AND all refresh tokens (+ their families). Best-
246    // effort per key — a partial drain still leaves the app itself gone
247    // (token issuance re-loads the app and 400s), but the short TTLs make a
248    // straggler worthless.
249    try {
250      const codes = await this.redisClient().smembers(clientCodesKey(clientId));
251      if (codes.length) await this.redisClient().del(...codes.map((c) => codeKey(c)));
252      const hashes = await this.redisClient().smembers(clientRtSetKey(clientId));
253      for (const h of hashes) {
254        try {
255          const metaRaw = await this.redisClient().get(`oauth:family-meta:${h}`);
256          if (metaRaw) {
257            const meta = JSON.parse(metaRaw) as { familyId: string; userId: string };
258            await this.revokeFamily(meta.familyId, meta.userId, clientId, "app_deleted", req);
259          }
260        } catch {
261          /* per-key best-effort */
262        }
263        await this.redisClient().del(rtKey(h));
264      }
265    } catch {
266      /* app row is already gone — the drain is an optimization */
267    }
268    await this.redisClient().del(clientCodesKey(clientId), clientRtSetKey(clientId));
269    await this.audit.audit(req, req?.auth, {
270      action: "oauth.app_deleted",
271      resourceType: "oauth_app",
272      resourceId: clientId,
273      workspaceId,
274      metadata: {}
275    });
276    return { deleted: true, clientId };
277  }
278 
279  /** Public client metadata (name, logo, offered scopes). null if unknown. */
280  async clientMetadata(clientId: string) {
281    const app = await this.loadAppAnyWorkspace(clientId);
282    return app ? { clientId: app.clientId, name: app.name, logoUrl: app.logoUrl ?? null, scopes: app.scopes } : null;
283  }
284 
285  private publicAppView(row: { id: string; createdAt: Date; config: unknown }) {
286    const app = (row.config as unknown as { oauthApp: OAuthAppRow }).oauthApp;
287    return {
288      id: row.id,
289      clientId: app.clientId,
290      name: app.name,
291      logoUrl: app.logoUrl ?? null,
292      public: !app.clientSecretHash,
293      redirectUris: app.redirectUris,
294      scopes: app.scopes,
295      grantTypes: app.grantTypes,
296      createdAt: row.createdAt
297    };
298  }
299 
300  private async findAppRow(workspaceId: string, clientId: string) {
301    const rows = await this.prisma.integration.findMany({ where: { workspaceId, provider: "oauth" } });
302    return rows.find((r) => (r.config as unknown as { oauthApp: OAuthAppRow }).oauthApp?.clientId === clientId) ?? null;
303  }
304 
305  private async loadAppAnyWorkspace(clientId: string): Promise<LoadedApp | null> {
306    const rows = await this.prisma.integration.findMany({ where: { provider: "oauth" } });
307    for (const r of rows) {
308      const app = (r.config as unknown as { oauthApp: OAuthAppRow }).oauthApp;
309      if (app?.clientId === clientId) {
310        return {
311          clientId: app.clientId,
312          workspaceId: r.workspaceId,
313          name: app.name,
314          logoUrl: app.logoUrl,
315          redirectUris: app.redirectUris,
316          scopes: app.scopes,
317          clientSecretHash: app.clientSecretHash
318        };
319      }
320    }
321    return null;
322  }
323 
324  // ============================================================
325  // Authorize
326  // ============================================================
327 
328  /**
329   * Read-only session check from the httpOnly refresh cookie (does NOT
330   * rotate — a consent-page GET must not advance the user's login rotation
331   * chain). Returns the logged-in userId or null.
332   */
333  private async cookieUser(cookieToken: string | undefined): Promise<string | null> {
334    if (!cookieToken) return null;
335    const row = await this.prisma.refreshToken.findUnique({
336      where: { tokenHash: sha256hex(cookieToken) },
337      select: { userId: true, revokedAt: true, expiresAt: true }
338    });
339    if (!row || row.revokedAt || row.expiresAt.getTime() < Date.now()) return null;
340    return row.userId;
341  }
342 
343  /** Decode a user Bearer JWT to its userId (null if absent/invalid). */
344  private bearerUserId(token: string | undefined): string | null {
345    if (!token) return null;
346    try {
347      const claims = verifyToken(token, loadEnv().JWT_SECRET);
348      return typeof claims.sub === "string" && claims.type !== "oauth" ? claims.sub : null;
349    } catch {
350      return null;
351    }
352  }
353 
354  async authorize(
355    params: Record<string, string | undefined>,
356    cookieToken: string | undefined,
357    bearerToken: string | undefined,
358    /** The API origin (`scheme://host[:port]`) — used to build an ABSOLUTE
359     *  login-resume URL (the flow crosses API → web /login → back to the
360     *  API authorize endpoint, so `next` must be absolute). */
361    apiOrigin: string
362  ): Promise<AuthorizeDecision> {
363    if (params.response_type !== "code") {
364      return { type: "error", status: 400, error: "unsupported_response_type", description: "response_type must be 'code'." };
365    }
366    const clientId = params.client_id;
367    if (!clientId) {
368      return { type: "error", status: 400, error: "invalid_request", description: "client_id is required." };
369    }
370    const app = await this.loadAppAnyWorkspace(clientId);
371    if (!app) {
372      return { type: "error", status: 401, error: "unauthorized_client", description: "Unknown client." };
373    }
374    const redirectUri = params.redirect_uri;
375    // EXACT match against the registered allow-list — open-redirect protection.
376    if (!redirectUri || !app.redirectUris.includes(redirectUri)) {
377      return { type: "error", status: 400, error: "invalid_request", description: "redirect_uri does not match a registered allow-list entry." };
378    }
379    const requested = parseScopeList(params.scope);
380    for (const s of requested) {
381      if (!app.scopes.includes(s)) {
382        return { type: "error", status: 400, error: "invalid_scope", description: `Scope '${s}' is not offered by this client.` };
383      }
384    }
385    const isPublic = !app.clientSecretHash;
386    if (isPublic) {
387      if (!params.code_challenge || params.code_challenge_method !== "S256") {
388        return { type: "error", status: 400, error: "invalid_request", description: "Public clients must use PKCE (code_challenge + code_challenge_method=S256)." };
389      }
390    }
391    // Resolve the user: a valid user Bearer JWT wins, else the login cookie.
392    const userId = this.bearerUserId(bearerToken) ?? (await this.cookieUser(cookieToken));
393    if (!userId) {
394      // Resume the FULL authorize URL after login (the web login page honors
395      // ?next=). BOTH hops are cross-origin, so both URLs must be ABSOLUTE:
396      //   - `nextUrl` → the WEB /login page (a relative /login in an API 302
397      //     would resolve against the API origin and 404);
398      //   - `next`    → the API /oauth/authorize endpoint (the login page's
399      //     resolveNext allows the API origin — see apps/web login page).
400      const qs = new URLSearchParams();
401      for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, v);
402      const next = `${apiOrigin}/api/v1/oauth/authorize?${qs.toString()}`;
403      const webOrigin = loadEnv().WEB_ORIGIN.replace(/\/$/, "");
404      return { type: "login", nextUrl: `${webOrigin}/login?next=${encodeURIComponent(next)}` };
405    }
406    return {
407      type: "consent",
408      userId,
409      app: { name: app.name, logoUrl: app.logoUrl, clientId: app.clientId },
410      requestedScopes: requested,
411      redirectUri,
412      state: params.state,
413      codeChallenge: params.code_challenge,
414      codeChallengeMethod: params.code_challenge_method,
415      consentToken: this.signConsentToken(userId, {
416        clientId: app.clientId,
417        redirectUri,
418        scopes: serializeScopeList(requested),
419        codeChallenge: params.code_challenge ?? null,
420        codeChallengeMethod: params.code_challenge_method ?? null
421      })
422    };
423  }
424 
425  /**
426   * Sign the consent CSRF token: a short-lived JWT (`kind: "oauth_consent"`)
427   * binding the RE-RESOLVED user to the exact authorize request. `sub` is
428   * the user, `v` pins the request parameters — verified with a constant-
429   * time comparison in {@link verifyConsentToken}.
430   */
431  signConsentToken(userId: string, v: { clientId: string; redirectUri: string; scopes: string; codeChallenge: string | null; codeChallengeMethod: string | null }): string {
432    return signToken({ sub: userId, kind: CONSENT_KIND, v }, CONSENT_TTL_SECONDS, loadEnv().JWT_SECRET);
433  }
434 
435  /**
436   * Consent CSRF check for `POST /oauth/authorize`: the token the consent
437   * form echoed back must (1) verify against the server secret, (2) be a
438   * `kind: "oauth_consent"` token whose `sub` is the RE-RESOLVED user (the
439   * form's identity fields are never trusted), and (3) bind the SAME
440   * request parameters that were just re-validated. Any mismatch → the
441   * posted form was not rendered for this session → refuse (400 page).
442   */
443  verifyConsentToken(
444    consentToken: string | undefined,
445    userId: string,
446    v: { clientId: string; redirectUri: string; scopes: string; codeChallenge: string | null; codeChallengeMethod: string | null }
447  ): boolean {
448    if (!consentToken) return false;
449    let claims;
450    try {
451      claims = verifyToken(consentToken, loadEnv().JWT_SECRET);
452    } catch {
453      return false;
454    }
455    if (claims.kind !== CONSENT_KIND || claims.sub !== userId) return false;
456    const got = claims.v as typeof v | undefined;
457    if (!got) return false;
458    const same = (a: string | null, b: string | null) => a === b;
459    return (
460      got.clientId === v.clientId &&
461      got.redirectUri === v.redirectUri &&
462      got.scopes === v.scopes &&
463      same(got.codeChallenge, v.codeChallenge) &&
464      same(got.codeChallengeMethod, v.codeChallengeMethod)
465    );
466  }
467 
468  /** Issue the code and return the final redirect Location (approve). */
469  async approveCode(
470    input: {
471      clientId: string;
472      userId: string;
473      scopes: string[];
474      redirectUri: string;
475      state?: string;
476      codeChallenge?: string;
477      codeChallengeMethod?: string;
478    },
479    req: Request
480  ): Promise<string> {
481    const app = await this.loadAppAnyWorkspace(input.clientId);
482    if (!app) throw ApiError.unauthorized("Unknown client.", [{ reason: "invalid_client" }]);
483    const code = generateId("oc");
484    const payload = {
485      clientId: input.clientId,
486      userId: input.userId,
487      workspaceId: app.workspaceId,
488      scopes: input.scopes,
489      codeChallenge: input.codeChallenge ?? null,
490      codeChallengeMethod: input.codeChallengeMethod ?? null,
491      redirectUri: input.redirectUri
492    };
493    await this.redisClient().set(codeKey(code), JSON.stringify(payload), "EX", codeTtlSeconds());
494    // Track the code per client so `DELETE …/oauth/apps/:clientId` can drain
495    // in-flight codes (task: "DELETE revokes app + in-flight codes/tokens").
496    await this.redisClient().sadd(clientCodesKey(input.clientId), code);
497    await this.redisClient().expire(clientCodesKey(input.clientId), codeTtlSeconds());
498    await this.audit.audit(req, req?.auth, {
499      action: "oauth.code_issued",
500      resourceType: "user",
501      resourceId: input.userId,
502      workspaceId: app.workspaceId,
503      metadata: { clientId: input.clientId, scopes: input.scopes }
504    });
505    const sep = input.redirectUri.includes("?") ? "&" : "?";
506    let location = `${input.redirectUri}${sep}code=${encodeURIComponent(code)}`;
507    if (input.state !== undefined) location += `&state=${encodeURIComponent(input.state)}`;
508    return location;
509  }
510 
511  async denyRedirect(redirectUri: string, state?: string): Promise<string> {
512    const sep = redirectUri.includes("?") ? "&" : "?";
513    let location = `${redirectUri}${sep}error=access_denied`;
514    if (state !== undefined) location += `&state=${encodeURIComponent(state)}`;
515    return location;
516  }
517 
518  // ============================================================
519  // Token
520  // ============================================================
521 
522  private parseClientAuth(req: Request, body: Record<string, unknown>): { clientId?: string; clientSecret?: string } {
523    const header = req.headers.authorization;
524    if (header && header.startsWith("Basic ")) {
525      const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
526      const idx = decoded.indexOf(":");
527      if (idx !== -1) return { clientId: decodeURIComponent(decoded.slice(0, idx)), clientSecret: decodeURIComponent(decoded.slice(idx + 1)) };
528    }
529    return {
530      clientId: typeof body.client_id === "string" ? body.client_id : undefined,
531      clientSecret: typeof body.client_secret === "string" ? body.client_secret : undefined
532    };
533  }
534 
535  private async authenticateClient(app: LoadedApp, clientId: string | undefined, clientSecret: string | undefined): Promise<void> {
536    if (!clientId || clientId !== app.clientId) {
537      throw new ApiError("INVALID_CLIENT", 400, "Client authentication failed.", [{ reason: "invalid_client" }]);
538    }
539    if (app.clientSecretHash) {
540      if (!clientSecret) throw ApiError.unauthorized("Client secret required.", [{ reason: "invalid_client" }]);
541      if (sha256hex(clientSecret) !== app.clientSecretHash) {
542        throw new ApiError("INVALID_CLIENT", 400, "Client authentication failed.", [{ reason: "invalid_client" }]);
543      }
544    }
545  }
546 
547  private async issueOAuthTokens(
548    app: LoadedApp,
549    userId: string,
550    scopes: string[],
551    familyId: string
552  ): Promise<{ access_token: string; refresh_token: string; scope: string; expires_in: number }> {
553    const env = loadEnv();
554    const accessTtl = parseTtl(env.OAUTH_ACCESS_TOKEN_TTL ?? "900s");
555    // Docs §8.2 claims: sub, client_id, scope, type:"oauth", jti.
556    const accessToken = signToken(
557      { sub: userId, client_id: app.clientId, scope: serializeScopeList(scopes), type: "oauth", jti: uuid() },
558      accessTtl,
559      env.JWT_SECRET
560    );
561    const refreshPlain = `rft_${randomBytes(32).toString("hex")}`;
562    const hash = sha256hex(refreshPlain);
563    await this.redisClient().set(rtKey(hash), JSON.stringify({ familyId, clientId: app.clientId, userId, scopes }), "EX", this.refreshTtlSeconds());
564    // Bookkeeping for classification + drain (reuse kill, app delete):
565    //  - client rt-set: every refresh hash this client holds (drained on
566    //    app delete);
567    //  - per-hash family meta: the family identity of a DEAD token (so a
568    //    replay can be classified + the kill audited).
569    await this.redisClient().sadd(clientRtSetKey(app.clientId), hash);
570    await this.redisClient().expire(clientRtSetKey(app.clientId), this.refreshTtlSeconds());
571    await this.redisClient().set(`oauth:family-meta:${hash}`, JSON.stringify({ familyId, clientId: app.clientId, userId }), "EX", this.refreshTtlSeconds());
572    return { access_token: accessToken, refresh_token: refreshPlain, scope: serializeScopeList(scopes), expires_in: accessTtl };
573  }
574 
575  async token(req: Request, body: Record<string, unknown>): Promise<Record<string, unknown>> {
576    const grantType = typeof body.grant_type === "string" ? body.grant_type : "";
577    const { clientId, clientSecret } = this.parseClientAuth(req, body);
578 
579    if (grantType === "authorization_code") {
580      const code = typeof body.code === "string" ? body.code : "";
581      const verifier = typeof body.code_verifier === "string" ? body.code_verifier : "";
582      const redirectUri = typeof body.redirect_uri === "string" ? body.redirect_uri : "";
583      // GETDEL = single-use: a second use (or a replay) gets nothing.
584      const raw = await this.redisClient().getdel(codeKey(code));
585      if (!raw) throw new ApiError("INVALID_GRANT", 400, "Authorization code is invalid or expired.", [{ reason: "invalid_grant" }]);
586      const stored = JSON.parse(raw) as {
587        clientId: string; userId: string; workspaceId: string; scopes: string[];
588        codeChallenge: string | null; codeChallengeMethod: string | null; redirectUri: string;
589      };
590      const app = await this.loadAppAnyWorkspace(stored.clientId);
591      if (!app) throw new ApiError("INVALID_GRANT", 400, "Client no longer exists.", [{ reason: "invalid_grant" }]);
592      await this.authenticateClient(app, clientId, clientSecret);
593      if (stored.codeChallenge) {
594        if (stored.codeChallengeMethod !== "S256" || !verifier || pkceChallenge(verifier) !== stored.codeChallenge) {
595          throw new ApiError("INVALID_GRANT", 400, "PKCE verification failed.", [{ reason: "invalid_grant" }]);
596        }
597      }
598      if (redirectUri && redirectUri !== stored.redirectUri) {
599        throw new ApiError("INVALID_GRANT", 400, "redirect_uri does not match the authorize step.", [{ reason: "invalid_grant" }]);
600      }
601      const tokens = await this.issueOAuthTokens(app, stored.userId, stored.scopes, uuid());
602      await this.audit.audit(req, undefined, {
603        action: "oauth.token_issued",
604        resourceType: "user",
605        resourceId: stored.userId,
606        workspaceId: stored.workspaceId,
607        metadata: { clientId: stored.clientId, scopes: stored.scopes, grant: "authorization_code" }
608      });
609      return {
610        access_token: tokens.access_token,
611        token_type: "Bearer",
612        expires_in: tokens.expires_in,
613        refresh_token: tokens.refresh_token,
614        scope: tokens.scope
615      };
616    }
617 
618    if (grantType === "refresh_token") {
619      const presented = typeof body.refresh_token === "string" ? body.refresh_token : "";
620      if (!presented) throw new ApiError("INVALID_GRANT", 400, "refresh_token is required.", [{ reason: "invalid_grant" }]);
621      const hash = sha256hex(presented);
622      // READ (not GETDEL) — classification must not consume the token.
623      const raw = await this.redisClient().get(rtKey(hash));
624      if (!raw) {
625        // The presented token is DEAD (rotated out, revoked, expired, or
626        // never valid). Classification (TASK-0129 acceptance criterion
627        // "refresh rotation + reuse detection"; security-architecture
628        // §1.2 lineage):
629        //   (a) KNOWN dead token, family STILL ACTIVE — the presented value
630        //       was rotated out (or consumed) and its value is back in
631        //       circulation → REUSE (token theft). Containment: KILL the
632        //       whole family (every remaining token dies) + security audit
633        //       `oauth.token_revoked { reason: "reuse_detected" }`.
634        //   (b) Family ALREADY killed (reuse / RFC 7009 revoke / app
635        //       delete) → plain INVALID_GRANT; the kill and its audit
636        //       already happened — do not double-report.
637        //   (c) UNKNOWN token (no metadata — never issued, TTL-expired, or
638        //       pre-dating this key) → plain INVALID_GRANT.
639        // Divergence from the LOGIN flow's §1.2 stale-retry nuance (a
640        // rotated-out token that is the direct parent of the live
641        // successor is a benign stale retry there, via the replacedBy
642        // chain): the OAuth task spec mandates the conservative containment
643        // — "reusing the old refresh after rotation → whole family
644        // revoked" — and a false kill here costs only a re-consent.
645        // Recorded as C-13.7e in docs/tasks/phase-13-api.md. OAuth
646        // families are per-(user, client), so the kill can never
647        // collateral-revoke the user's LOGIN sessions (DB refresh chain).
648        let meta: { familyId: string; clientId: string; userId: string } | null = null;
649        try {
650          const m = await this.redisClient().get(`oauth:family-meta:${hash}`);
651          if (m) meta = JSON.parse(m);
652        } catch {
653          /* metadata is advisory — fall through to the generic 400 */
654        }
655        if (meta) {
656          const familyRevoked = (await this.redisClient().get(familyKey(meta.familyId))) === "revoked";
657          if (!familyRevoked) {
658            // (a) REUSE of a rotated-out token → family kill + audit.
659            await this.revokeFamily(meta.familyId, meta.userId, meta.clientId, "reuse_detected", req);
660          }
661        }
662        throw new ApiError("INVALID_GRANT", 400, "Refresh token is invalid or expired.", [{ reason: "invalid_grant" }]);
663      }
664      const rt = JSON.parse(raw) as { familyId: string; clientId: string; userId: string; scopes: string[] };
665      const familyRevoked = (await this.redisClient().get(familyKey(rt.familyId))) === "revoked";
666      if (familyRevoked) {
667        // Family was killed (reuse / revoke_endpoint) and this token is a
668        // zombie → plain 400 (the kill + its audit already happened).
669        throw new ApiError("INVALID_GRANT", 400, "Refresh token is invalid or expired.", [{ reason: "invalid_grant" }]);
670      }
671      // Live token → rotate: consume it (DELETE — the single-use step), drop
672      // it from the family's live set, and issue a successor in the SAME
673      // family. A racing loser (two clients on the same old token) fails the
674      // DELETE → plain 400 stale retry; the winner's successor stays live.
675      const deleted = await this.redisClient().del(rtKey(hash));
676      if (deleted !== 1) {
677        throw new ApiError("INVALID_GRANT", 400, "Refresh token already rotated.", [{ reason: "invalid_grant" }]);
678      }
679      const app = await this.loadAppAnyWorkspace(rt.clientId);
680      if (!app) throw new ApiError("INVALID_GRANT", 400, "Client no longer exists.", [{ reason: "invalid_grant" }]);
681      await this.authenticateClient(app, clientId, clientSecret);
682      const tokens = await this.issueOAuthTokens(app, rt.userId, rt.scopes, rt.familyId);
683      await this.audit.audit(req, undefined, {
684        action: "oauth.token_issued",
685        resourceType: "user",
686        resourceId: rt.userId,
687        metadata: { clientId: rt.clientId, scopes: rt.scopes, grant: "refresh_token" }
688      });
689      return {
690        access_token: tokens.access_token,
691        token_type: "Bearer",
692        expires_in: tokens.expires_in,
693        refresh_token: tokens.refresh_token,
694        scope: tokens.scope
695      };
696    }
697 
698    throw new ApiError("INVALID_GRANT", 400, `Unsupported grant_type '${grantType || "(none)"}'.`, [{ reason: "invalid_grant" }]);
699  }
700 
701  // ============================================================
702  // Revoke (RFC 7009 — always 200, never 401/404)
703  // ============================================================
704  /**
705   * RFC 7009 revocation. `req` (optional) is forwarded to the audit: an
706   * explicit endpoint revoke IS a security operation (rule 27) and must be
707   * recorded — `oauth.token_revoked { reason: "revoked" }`.
708   */
709  async revoke(token: string | undefined, hint: string | undefined, req?: Request): Promise<void> {
710    if (!token) return;
711    try {
712      if (hint === "refresh_token" || !hint) {
713        const hash = sha256hex(token);
714        const raw = await this.redisClient().get(rtKey(hash));
715        if (raw) {
716          const rt = JSON.parse(raw) as { familyId: string; clientId: string; userId: string };
717          // Consume the token and KILL its family — the RFC 7009 semantics
718          // for a refresh token: the whole rotation chain stops, and any
719          // other token in the family dies with it (reuse-detection stays
720          // armed for replays of the old value via the family flag).
721          await this.redisClient().del(rtKey(hash));
722          await this.revokeFamily(rt.familyId, rt.userId, rt.clientId, "revoked", req);
723        }
724      }
725      if (hint === "access_token" || !hint) {
726        // Decode the JWT to get the jti (no full verification — revoke is
727        // best-effort and must never 401 an unknown token). The denylist
728        // entry lives only as long as the token could: remaining lifetime
729        // capped at the access TTL (the JWT can't outlive its exp).
730        const parts = token.split(".");
731        if (parts.length === 3) {
732          try {
733            const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
734            if (payload.jti) {
735              const remaining = typeof payload.exp === "number" ? payload.exp - Math.floor(Date.now() / 1000) : 0;
736              const ttl = Math.max(1, Math.min(remaining > 0 ? remaining : 1, parseTtl(loadEnv().OAUTH_ACCESS_TOKEN_TTL)));
737              await this.redisClient().set(jtiKey(payload.jti), "1", "EX", ttl);
738            }
739          } catch {
740            /* not a decodable JWT → ignore */
741          }
742        }
743      }
744    } catch {
745      /* RFC 7009: never surface a failure */
746    }
747  }
748 
749  /**
750   * Kill a refresh-token family (reuse attack or RFC 7009 revocation):
751   * set the family's revoked flag (so every remaining token in it is dead
752   * on presentation), drop its live-token set, and audit the security
753   * event (never tokens/hashes — only the family + client identity).
754   * Best-effort: Redis failure must not break the 400 response.
755   */
756  private async revokeFamily(
757    familyId: string,
758    userId: string,
759    clientId: string,
760    reason: string,
761    req: Request | undefined
762  ): Promise<void> {
763    try {
764      await this.redisClient().set(familyKey(familyId), "revoked", "EX", this.refreshTtlSeconds());
765    } catch {
766      /* the 400 above is what the client sees */
767    }
768    if (req) {
769      await this.audit
770        .audit(req, undefined, {
771          action: "oauth.token_revoked",
772          resourceType: "user",
773          resourceId: userId,
774          metadata: { clientId, reason, familyId }
775        })
776        .catch(() => undefined);
777    }
778  }
779 
780  /** Guard helper: is this access-token jti on the denylist? (token-keys is fail-open.) */
781  isAccessRevoked(jti: string | undefined): Promise<boolean> {
782    return isJtiRevoked(this.redisClient(), jti);
783  }
784}
785