.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
api.tsapps/web/src/lib/api.tsraw← lib68,169 B← Back to the app
1"use client";
2 
3/**
4 * Typed fetch client for the Factory REST API.
5 *
6 * - Base URL: NEXT_PUBLIC_API_URL (see .env.example).
7 * - Auth: Bearer access token from lib/auth-store (memory only). On 401 the
8 *   client performs a **single-flight** POST /auth/refresh (the httpOnly
9 *   `factory_refresh` cookie travels automatically with credentials:
10 *   "include"; the API rotates the cookie and returns the new access token)
11 *   and retries the original request once.
12 * - Errors: JSON `{ error: { code, message, status, details?, requestId? } }`
13 *   (the `@factory/shared` error model) → thrown as `ApiError`.
14 */
15import { authStore } from "./auth-store";
16import type { SavedSearch as SavedSearchRow } from "./search";
17import { API_BASE } from "./constants";
18import type { Canvas } from "@factory/whiteboard";
19 
20/**
21 * Client-safe request id (TASK-0135 propagation: web → api). A v4 UUID built
22 * from Math.random (no Node `crypto` in a "use client" module) — only
23 * uniqueness matters; the API's middleware adopts it into the span/`requestId`
24 * log binding and echoes it back in the error envelope.
25 */
26function newRequestId(): string {
27  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
28    const r = (Math.random() * 16) | 0;
29    const v = c === "x" ? r : (r & 0x3) | 0x8;
30    return v.toString(16);
31  });
32}
33import {
34  enqueueMutation,
35  registerOutboxTransport,
36  settleMutationFailure,
37  settleMutationSuccess
38} from "@/stores/offlineQueue";
39import type { OutboxItem, OutboxResult } from "@factory/collaboration/outbox";
40import { BLOCK_TYPES } from "./types";
41import type {
42  ActivityRow,
43  Block,
44  BlockPatch,
45  BlockType,
46  Comment,
47  Database,
48  DatabaseRecord,
49  DatabaseProperty,
50  DatabaseView,
51  FileMeta,
52  Notification,
53  Page,
54  PageShare,
55  PageTreeEntry,
56  PageVersion,
57  VersionDiff,
58  VersionPreview,
59  Position,
60  PropertyType,
61  SearchQueryInput,
62  SearchQueryResult,
63  SearchResult,
64  SearchSuggestion,
65  SearchType,
66  PageReview,
67  Task,
68  Team,
69  TemplateInfo,
70  TemplateVersionInfo,
71  User,
72  ViewType,
73  Workspace,
74  WorkspaceInvite,
75  WorkspaceMember,
76  WorkspaceRole,
77  WorkspaceStats
78} from "./types";
79 
80/* ---------------------------------------------------------------- errors */
81 
82export class ApiError extends Error {
83  readonly code: string;
84  readonly status: number;
85  readonly details?: unknown;
86  readonly requestId?: string;
87 
88  constructor(code: string, status: number, message: string, details?: unknown, requestId?: string) {
89    super(message);
90    this.name = "ApiError";
91    this.code = code;
92    this.status = status;
93    this.details = details;
94    this.requestId = requestId;
95  }
96}
97 
98/** True for "safe" errors worth retrying after a transient failure. */
99export function isApiError(err: unknown): err is ApiError {
100  return err instanceof ApiError;
101}
102 
103/**
104 * True when the server answered a refresh with 401 + reason
105 * `refresh_token_stale` — the presented refresh token was already rotated by
106 * a CONCURRENT refresh (security-architecture.md §1.2 "stale retry"). The
107 * rotation chain moved on without the session being killed: the httpOnly
108 * cookie in this tab's jar may already hold the live successor (when the
109 * racing rotation's response was observed by the jar), so the tab is usually
110 * still fully authenticated. Callers may retry ONCE; a second stale answer
111 * (or any other failure) is terminal.
112 */
113export function isStaleRefreshError(err: unknown): boolean {
114  if (!isApiError(err) || err.status !== 401) return false;
115  const details = err.details;
116  if (!Array.isArray(details)) return false;
117  return details.some(
118    (d) => typeof d === "object" && d !== null && (d as { reason?: string }).reason === "refresh_token_stale"
119  );
120}
121 
122/* -------------------------------------------------------------- helpers */
123 
124export function buildQuery(params: Record<string, string | number | boolean | null | undefined>): string {
125  const parts: string[] = [];
126  for (const [key, value] of Object.entries(params)) {
127    if (value === null || value === undefined) continue;
128    parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
129  }
130  return parts.length > 0 ? `?${parts.join("&")}` : "";
131}
132 
133export interface ApiInit {
134  method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
135  body?: unknown;
136  signal?: AbortSignal;
137  /** Set true to skip the Authorization header (auth endpoints). */
138  noAuth?: boolean;
139  /** Extra headers (e.g. Content-Type overrides). */
140  headers?: Record<string, string>;
141  /** The per-resource ordering key (the outbox's FIFO chain) for mutations. */
142  resource?: { type: string; id: string };
143  /**
144   * The idempotency token (INTERNAL — only the outbox transport sets this, on
145   * a replay). A fresh online write never sets it (the API middleware is a
146   * no-op without the header). Do not pass this from feature code.
147   */
148  idempotencyKey?: string;
149}
150 
151/**
152 * Single-flight silent refresh (cookie → new access token), shared by BOTH
153 * the 401-retry path in `doFetch` AND the startup bootstrap (authApi.refresh
154 * / use-auth.ts). One tab must never rotate its refresh cookie twice in
155 * parallel: the server rotates the cookie on every refresh, so two in-flight
156 * rotations race — the loser's response carries a rotated-out token that the
157 * httpOnly jar then plays back on the next load, tripping reuse detection
158 * (or, under the §1.2 stale-retry branch, a benign 401). Dedupe here so a
159 * burst of 401s AND a double-mounted (StrictMode) bootstrap each trigger
160 * exactly one POST /auth/refresh.
161 */
162let refreshInFlight: Promise<AuthTokens> | null = null;
163 
164function refreshAccessToken(): Promise<AuthTokens> {
165  if (!refreshInFlight) {
166    refreshInFlight = (async () => {
167      try {
168        // No Authorization header on purpose: the endpoint is on the auth
169        // whitelist and authenticates via the httpOnly refresh cookie.
170        // (The Bearer token that 401'd is expired — sending it would only
171        // mask the failure mode.)
172        const res = await fetch(`${API_BASE}/auth/refresh`, {
173          method: "POST",
174          credentials: "include"
175        });
176        if (!res.ok) {
177          // Parse the error body for the reason code (stale vs reused vs
178          // expired) — doFetch's retry and the bootstrap's single retry both
179          // branch on `refresh_token_stale`.
180          let code = "UNAUTHORIZED";
181          let details: unknown;
182          try {
183            const body = (await res.json()) as {
184              error?: { code?: string; details?: unknown };
185            };
186            code = body?.error?.code ?? code;
187            details = body?.error?.details;
188          } catch {
189            /* non-JSON error body — keep the defaults */
190          }
191          throw new ApiError(code, res.status, `Refresh failed (${res.status})`, details);
192        }
193        // The API contract (auth.service.ts `refresh` → `issueAccess` +
194        // rotation): { accessToken, refreshToken, expiresIn, session? } —
195        // NO user object. Keep the current user; refresh rotates the cookie
196        // and re-issues the access token.
197        const json = (await res.json()) as AuthTokens;
198        if (!json.accessToken) throw new ApiError("UNAUTHORIZED", 200, "Refresh response missing access token");
199        authStore.setAccessToken(json.accessToken);
200        return json;
201      } finally {
202        refreshInFlight = null;
203      }
204    })();
205  }
206  return refreshInFlight;
207}
208 
209async function doFetch(path: string, init: ApiInit, retried: boolean): Promise<Response> {
210  const headers: Record<string, string> = {
211    Accept: "application/json",
212    ...init.headers
213  };
214  if (!init.noAuth) {
215    const token = authStore.accessToken;
216    if (token) headers.Authorization = `Bearer ${token}`;
217  }
218  // TASK-0135: web → api correlation. The API middleware adopts the inbound
219  // id (span attr + requestId log binding) and echoes it in error envelopes.
220  if (!("X-Request-Id" in headers)) headers["X-Request-Id"] = newRequestId();
221  // The outbox's idempotency token (R1): sent ONLY on a replay (an offline
222  // mutation) — a fresh online write has no key (the API middleware is a
223  // no-op without the header, so existing endpoints are unaffected).
224  if (init.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey;
225  // Callers pass `body` either as an object (stringify it) or as an ALREADY-
226  // STRINGIFIED string (the use-database hooks pass `body: JSON.stringify(x)`).
227  // Without this guard the string body is JSON.stringify'd a SECOND time,
228  // producing a double-encoded body the server rejects with 400
229  // (VALIDATION_ERROR: `Unexpected token '"'`). Send string bodies verbatim.
230  const rawBody = init.body;
231  const body =
232    rawBody === undefined || rawBody === null
233      ? undefined
234      : typeof rawBody === "string"
235        ? rawBody
236        : JSON.stringify(rawBody);
237  if (body !== undefined) headers["Content-Type"] = "application/json";
238 
239  const res = await fetch(`${API_BASE}${path}`, {
240    method: init.method ?? "GET",
241    headers,
242    body,
243    credentials: "include",
244    signal: init.signal
245  });
246 
247  // 401 → single-flight refresh, then retry exactly once. A stale-retry
248  // 401 from the refresh itself is NOT an infinite loop: `refreshAccessToken`
249  // re-throws the server's reason, and `doFetch` is only re-entered on a
250  // SUCCESSFUL rotation (fresh access token in the store).
251  if (res.status === 401 && !init.noAuth && !retried) {
252    try {
253      await refreshAccessToken();
254      return doFetch(path, init, true);
255    } catch (err) {
256      // Refresh failed (stale, reused, expired, network). Surface the
257      // original 401 so the caller's ApiError carries the right reason —
258      // but only for errors that are NOT the original (non-refresh) 401.
259      // A reused-token kill means the whole session is gone: the caller
260      // gets a 401 and use-auth.ts drives the re-login.
261      if (isStaleRefreshError(err)) throw err; // caller can retry once
262      return res;
263    }
264  }
265  return res;
266}
267 
268/**
269 * Core typed request. `path` may include a query string (use buildQuery).
270 * 204 → resolves undefined. JSON `{error}` or non-2xx → throws ApiError.
271 */
272export async function apiFetch<T>(path: string, init: ApiInit = {}): Promise<T> {
273  const res = await doFetch(path, init, false);
274 
275  if (res.status === 204) return undefined as T;
276 
277  let json: unknown = null;
278  const text = await res.text();
279  if (text.length > 0) {
280    try {
281      json = JSON.parse(text);
282    } catch {
283      json = null;
284    }
285  }
286 
287  if (!res.ok) {
288    const err = (json as { error?: { code?: string; message?: string; details?: unknown; requestId?: string } } | null)?.error;
289    throw new ApiError(
290      err?.code ?? "INTERNAL",
291      res.status,
292      err?.message ?? `Request failed with status ${res.status}`,
293      err?.details,
294      err?.requestId
295    );
296  }
297  return json as T;
298}
299 
300/**
301 * A RAW response (the body is not JSON — the CSV export returns `text/csv`).
302 * Reuses `doFetch` so the Bearer token + 401-refresh behavior is identical to
303 * `apiFetch`; the caller owns the body (`.text()` / `.json()`) and any error
304 * envelope.
305 */
306export async function apiFetchRaw(path: string, init: ApiInit = {}): Promise<Response> {
307  return doFetch(path, init, false);
308}
309 
310/* --------------------------------------------------- offline outbox (R1) */
311 
312/**
313 * The offline gate (R1): a MUTATING call checks connectivity — online →
314 * `apiFetch` (send now, no key — a fresh online write needs no idempotency
315 * token); OFFLINE → `enqueueMutation` (the mutation suspends in the outbox,
316 * replayed on reconnect with its `Idempotency-Key`). GETs are never tagged.
317 */
318export function isOffline(): boolean {
319  return typeof navigator !== "undefined" && navigator.onLine === false;
320}
321 
322/**
323 * A REST mutation (the wrapped `apiFetch` for every POST/PATCH/PUT/DELETE).
324 * `resource` scopes the per-resource FIFO ordering; when omitted the outbox
325 * store derives it from the path (see `deriveOutboxResource`).
326 */
327export async function mutatingApiFetch<T>(
328  path: string,
329  init: ApiInit,
330  resource?: { type: string; id: string }
331): Promise<T> {
332  const method = init.method ?? "POST";
333  if (method === "GET" || !isOffline() || !authStore.accessToken) {
334    // Online (or an auth-flow mutation with no session) → send now.
335    return apiFetch<T>(path, init);
336  }
337  // Offline + a mutation + a session → the outbox (replayed on reconnect with
338  // the Idempotency-Key; resolves with the real 2xx body, rejects on a 4xx).
339  return enqueueMutation<T>({
340    method,
341    path,
342    body: (init.body ?? undefined) as Record<string, unknown> | undefined,
343    ...(resource ? { resource } : {}),
344    ...(init.idempotencyKey ? { idempotencyKey: init.idempotencyKey } : {})
345  });
346}
347 
348/**
349 * The outbox TRANSPORT (registered once) — sends a persisted item with ITS
350 * OWN `Idempotency-Key` (the item's key; a 5xx retry reuses it, a user Retry
351 * gets a fresh one from `OutboxQueue.retry`). The response settles the
352 * caller promise (2xx → resolve; a 4xx → reject + the failed list; a 5xx /
353 * network → keep pending for the backoff retry).
354 */
355registerOutboxTransport(async (item: OutboxItem): Promise<OutboxResult> => {
356  try {
357    const body = await apiFetch<unknown>(item.path, {
358      method: item.method,
359      body: item.body,
360      idempotencyKey: item.key
361    });
362    settleMutationSuccess(item, body);
363    return { ok: true, status: 200 };
364  } catch (err) {
365    if (isApiError(err) && err.status >= 400 && err.status < 500) {
366      // A 4xx (e.g. a permission revoked while offline) → the failed list.
367      // (The title-consent 409 path routes here; the UI surfaces the LWW
368      // outcome — see offlineQueue's failed dialog + `lww`.)
369      settleMutationFailure(item, err);
370    }
371    return { ok: false, status: isApiError(err) ? err.status : undefined, error: err instanceof Error ? err.message : String(err) };
372  }
373});
374 
375/* ---------------------------------------------------------------- auth */
376 
377/**
378 * Auth API shapes — aligned to the real API (auth.service.ts, S10):
379 * - login  → 200 { user, accessToken, refreshToken, expiresIn, session }
380 * - register → 201 { user, workspace, personalWorkspace, accessToken, … }
381 *   (workspace = the team workspace when one was created at registration,
382 *   else the personal workspace; the personal one is ALSO always returned)
383 * - me     → 200 { user, workspaces }  (Bearer-gated — NO token in the body;
384 *   the access token is re-issued via /auth/refresh, silent refresh)
385 * - logout → 201 { loggedOut }  (Bearer-gated: the API resolves the user
386 *   from the token to scope the session revocation; the httpOnly refresh
387 *   cookie rides along and is cleared)
388 * The stale `{ token, workspaceId }` shapes (W4) were the Phase-1 draft
389 * contract that never matched the implemented API.
390 */
391export interface LoginInput {
392  email: string;
393  password: string;
394}
395export interface RegisterInput {
396  name: string;
397  email: string;
398  password: string;
399  workspaceName: string;
400}
401export interface AuthTokens {
402  accessToken: string;
403  refreshToken: string;
404  expiresIn: number;
405  session: { id: string; expiresAt?: string };
406}
407export interface LoginResult extends AuthTokens {
408  user: User;
409}
410export interface RegisterResult extends AuthTokens {
411  user: User;
412  /** The workspace to land in (team workspace when created, else personal). */
413  workspace: { id: string; name: string };
414  personalWorkspace: { id: string; name: string };
415}
416export interface MeWorkspace {
417  id: string;
418  name: string;
419  slug: string;
420  isPersonal: boolean;
421  role: "OWNER" | "ADMIN" | "MEMBER" | "GUEST";
422}
423export interface MeResult {
424  user: User;
425  workspaces: MeWorkspace[];
426}
427 
428export const authApi = {
429  login(input: LoginInput): Promise<LoginResult> {
430    return mutatingApiFetch<LoginResult>("/auth/login", { method: "POST", body: input, noAuth: true });
431  },
432  /**
433   * TASK-0131 — ANONYMOUS SSO discovery for the login page. Minimal-info by
434   * design: the response reveals ONLY the org slugs that have SSO enabled +
435   * a protocol-agnostic start URL (the server-side `/sso/start/:slug`
436   * dispatcher does the protocol 302). It never returns the provider type,
437   * the org id, or any URL a caller could use to fingerprint the IdP.
438   * Unknown/empty email → `{ orgs: [] }` (no enumeration). `noAuth: true`
439   * because the login page is anonymous.
440   */
441  ssoOptions(email: string): Promise<{ orgs: Array<{ slug: string; ssoAvailable: boolean; startUrl: string }> }> {
442    return apiFetch<{ orgs: Array<{ slug: string; ssoAvailable: boolean; startUrl: string }> }>(
443      `/auth/sso/options?email=${encodeURIComponent(email)}`,
444      { noAuth: true }
445    );
446  },
447  register(input: RegisterInput): Promise<RegisterResult> {
448    return mutatingApiFetch<RegisterResult>("/auth/register", { method: "POST", body: input, noAuth: true });
449  },
450  /**
451   * Bearer-gated (NOT on the API whitelist) — the API needs the token to
452   * resolve which user's session to revoke. The httpOnly refresh cookie is
453   * cleared by the API on the response. `noAuth: true` here was the W-class
454   * bug: it always 401'd, and the caller swallowed it.
455   */
456  logout(): Promise<{ loggedOut: true }> {
457    return mutatingApiFetch<{ loggedOut: true }>("/auth/logout", { method: "POST" });
458  },
459  /** Bearer-gated (W3 — was `noAuth: true`, which always 401'd). No token in the body. */
460  me(): Promise<MeResult> {
461    return apiFetch<MeResult>("/auth/me");
462  },
463  /**
464   * The single-flight silent refresh (cookie → new access token). Exposed for
465   * the startup bootstrap in use-auth.ts. BOTH this and the 401-retry path in
466   * doFetch now funnel through the SAME `refreshAccessToken` primitive, so a
467   * StrictMode double-mounted bootstrap AND a burst of 401s each trigger
468   * exactly one POST /auth/refresh per tab. (Previously this bypassed the
469   * single-flight via mutatingApiFetch — the root of the double-rotation race
470   * that orphaned live refresh tokens.) Resolves the full AuthTokens on
471   * success; REJECTS with an ApiError (reason `refresh_token_stale` /
472   * `refresh_token_reused` / …) on failure so the caller can branch — callers
473   * that want "stay signed out on failure" must catch it.
474   */
475  refresh(): Promise<AuthTokens> {
476    return refreshAccessToken();
477  },
478  // Real API routes: /auth/reset-password + /auth/reset-password/confirm
479  // (auth.controller.ts). Both always 200 (no user enumeration); confirm
480  // returns { reset: true } — the old /auth/reset/* paths 404'd.
481  resetRequest(email: string): Promise<{ sent: true }> {
482    return mutatingApiFetch<{ sent: true }>("/auth/reset-password", { method: "POST", body: { email }, noAuth: true });
483  },
484  resetConfirm(email: string, code: string, newPassword: string): Promise<{ reset: true }> {
485    return mutatingApiFetch<{ reset: true }>("/auth/reset-password/confirm", {
486      method: "POST",
487      body: { email, code, newPassword },
488      noAuth: true
489    });
490  }
491};
492 
493/* ---------------------------------------------------------------- SSO admin */
494 
495/** SAML SP config (mirrors `SamlConfigSchema` in the api — config only, the
496 *  encrypted cert/secret are separate reveal-once fields). */
497export interface SsoSamlConfig {
498  entityId: string;
499  ssoUrl: string;
500  acsUrl: string;
501  x509CertificateRef?: "current" | "previous";
502  nameIdAttribute?: string;
503  emailAttribute?: string;
504  emailIsVerified?: boolean;
505  metadataUrl?: string;
506  idpEntityId?: string;
507}
508/** OIDC client config (mirrors `OidcConfigSchema`). */
509export interface SsoOidcConfig {
510  issuer: string;
511  clientId: string;
512  authorizationEndpoint: string;
513  tokenEndpoint: string;
514  jwksUri?: string;
515  redirectUri: string;
516  scope?: string;
517}
518/** The masked provider read (secrets NEVER returned — booleans only). */
519export interface SsoProviderView {
520  id: string;
521  name: string;
522  type: "SAML" | "OIDC";
523  enforce: boolean;
524  config: SsoSamlConfig | SsoOidcConfig;
525  certificateConfigured?: boolean;
526  clientSecretConfigured?: boolean;
527  createdAt: string;
528  updatedAt: string;
529}
530export interface SsoTestResult {
531  ok: boolean;
532  reason?: string;
533  details?: Record<string, unknown>;
534}
535 
536/**
537 * TASK-0131 — org SSO provider admin (org-owner gated for now; the role gate
538 * lands in TASK-0133). The secrets (SAML cert PEM / OIDC client secret) are
539 * REVEAL-ONCE: they are POSTed on create/rotate and the server encrypts them
540 * (AES-256-GCM) — the read returns only `certificateConfigured` /
541 * `clientSecretConfigured` booleans, never the values.
542 */
543export const ssoApi = {
544  get(orgId: string): Promise<{ provider: SsoProviderView | null }> {
545    return apiFetch<{ provider: SsoProviderView | null }>(`/orgs/${orgId}/sso`);
546  },
547  create(
548    orgId: string,
549    body:
550      | { type: "SAML"; name: string; config: SsoSamlConfig; certificate: string; previousCertificate?: string }
551      | { type: "OIDC"; name: string; config: SsoOidcConfig; clientSecret: string }
552  ): Promise<{ provider: SsoProviderView }> {
553    return mutatingApiFetch<{ provider: SsoProviderView }>(`/orgs/${orgId}/sso`, { method: "POST", body });
554  },
555  update(
556    orgId: string,
557    body: {
558      name?: string;
559      config?: Record<string, unknown>;
560      certificate?: string;
561      previousCertificate?: string;
562      clientSecret?: string;
563    }
564  ): Promise<{ provider: SsoProviderView }> {
565    return mutatingApiFetch<{ provider: SsoProviderView }>(`/orgs/${orgId}/sso`, { method: "PATCH", body });
566  },
567  remove(orgId: string): Promise<{ deleted: true }> {
568    return mutatingApiFetch<{ deleted: true }>(`/orgs/${orgId}/sso`, { method: "DELETE" });
569  },
570  setEnforce(orgId: string, enforce: boolean): Promise<{ enforce: boolean }> {
571    return mutatingApiFetch<{ enforce: boolean }>(`/orgs/${orgId}/sso/enforce`, {
572      method: "PATCH",
573      body: { enforce },
574    });
575  },
576  test(orgId: string): Promise<SsoTestResult> {
577    return mutatingApiFetch<SsoTestResult>(`/orgs/${orgId}/sso/test`, { method: "POST" });
578  },
579};
580 
581/* --------------------------------------------------------------- SCIM */
582 
583/** The masked SCIM read (the raw token is NEVER returned after enable/rotate). */
584export interface ScimConfigView {
585  enabled: boolean;
586  tokenHint?: string;
587  userEmailDomain?: string | null;
588  defaultRole?: "GUEST" | "MEMBER";
589  autoJoinOrgWorkspaces?: boolean;
590  enabledAt?: string;
591  endpointUrl?: string;
592  curlExample?: string;
593}
594export interface ScimTestResult {
595  ok: boolean;
596  detail: string;
597}
598 
599/**
600 * TASK-0132 — org SCIM 2.0 admin (org-owner gated now; the `requireOrgAdmin`
601 * role gate lands in TASK-0133). The bearer token is REVEAL-ONCE: it is
602 * generated server-side on `enable`/`rotate` and returned exactly once in the
603 * mutation response — the read (`get`) returns only `tokenHint` (last 4 chars).
604 */
605export const scimApi = {
606  get(orgId: string): Promise<ScimConfigView> {
607    return apiFetch<ScimConfigView>(`/orgs/${orgId}/scim`);
608  },
609  enable(
610    orgId: string,
611    body: { userEmailDomain?: string; defaultRole?: "GUEST" | "MEMBER"; autoJoinOrgWorkspaces?: boolean } = {}
612  ): Promise<ScimConfigView & { token: string }> {
613    return mutatingApiFetch<ScimConfigView & { token: string }>(`/orgs/${orgId}/scim`, { method: "POST", body });
614  },
615  update(
616    orgId: string,
617    body: {
618      rotate?: boolean;
619      disable?: boolean;
620      userEmailDomain?: string | null;
621      defaultRole?: "GUEST" | "MEMBER";
622      autoJoinOrgWorkspaces?: boolean;
623    }
624  ): Promise<ScimConfigView & { token?: string }> {
625    return mutatingApiFetch<ScimConfigView & { token?: string }>(`/orgs/${orgId}/scim`, { method: "PATCH", body });
626  },
627  test(orgId: string, token: string): Promise<ScimTestResult> {
628    return mutatingApiFetch<ScimTestResult>(`/orgs/${orgId}/scim/test`, { method: "POST", body: { token } });
629  },
630};
631 
632 
633/* ------------------------------------------------------------ workspaces */
634 
635/**
636 * Wire shape of `GET /workspaces/:id/members` (workspaces.service.ts
637 * `listMembers`) — a FLAT row. The web model is nested (`WorkspaceMember`);
638 * `normalizeWorkspaceMember` bridges the two. Exported for the unit test.
639 */
640export interface WorkspaceMemberWire {
641  userId: string;
642  email: string;
643  name: string;
644  displayName?: string | null;
645  avatarUrl?: string | null;
646  role: string;
647  joinedAt: string | null;
648}
649 
650/**
651 * Bridge the flat members wire row into the nested web `WorkspaceMember`.
652 * `role` is lowercased (the API emits the raw UPPERCASE enum; the core type is
653 * lowercase — the same casing seam noted in use-members.ts). `joinedAt`
654 * defaults to epoch (never undefined: consumers call `.toLocaleDateString`
655 * on it). `avatarUrl` maps to the core `image` field (there is no other
656 * avatar field on the web `User` type).
657 */
658export function normalizeWorkspaceMember(m: WorkspaceMemberWire): WorkspaceMember {
659  return {
660    userId: m.userId,
661    user: { id: m.userId, name: m.name, email: m.email, image: m.avatarUrl ?? null },
662    role: m.role.toLowerCase() as WorkspaceRole,
663    joinedAt: m.joinedAt ?? new Date(0).toISOString()
664  };
665}
666 
667export const workspaceApi = {
668  // The API returns `{ data: Workspace[] }` (workspaces.service.ts
669  // `listMine` → cursor-shaped envelope) — unwrap to the bare array the
670  // switcher/hooks consume (S10 contract alignment).
671  /** T-136: directory with optional filters. No params → today's behavior. */
672  list(params: { search?: string; type?: string; archived?: boolean; sort?: string } = {}): Promise<Workspace[]> {
673    return apiFetch<{ data: Workspace[] }>(`/workspaces${buildQuery({
674      search: params.search,
675      type: params.type,
676      archived: params.archived ? "true" : undefined, // only "true" shows archived
677      sort: params.sort
678    })}`).then((r) => r.data);
679  },
680  get(idOrSlug: string): Promise<Workspace> {
681    return apiFetch<Workspace>(`/workspaces/${encodeURIComponent(idOrSlug)}`);
682  },
683  create(name: string, emoji?: string): Promise<Workspace> {
684    return mutatingApiFetch<Workspace>("/workspaces", { method: "POST", body: { name, ...(emoji ? { emoji } : {}) } });
685  },
686  update(id: string, patch: Partial<Pick<Workspace, "name" | "emoji" | "type">>): Promise<Workspace> {
687    return mutatingApiFetch<Workspace>(`/workspaces/${id}`, { method: "PATCH", body: patch });
688  },
689  /** T-136: archive a space (owner/admin only). */
690  archive(id: string): Promise<Workspace> {
691    return mutatingApiFetch<Workspace>(`/workspaces/${id}/archive`, { method: "POST" });
692  },
693  /** T-136: restore an archived space (owner/admin only). */
694  restoreArchive(id: string): Promise<Workspace> {
695    return mutatingApiFetch<Workspace>(`/workspaces/${id}/archive/restore`, { method: "POST" });
696  },
697  /** T-137: space usage stats. */
698  stats(id: string): Promise<WorkspaceStats> {
699    return apiFetch<WorkspaceStats>(`/workspaces/${id}/stats`);
700  },
701  remove(id: string): Promise<void> {
702    return mutatingApiFetch<void>(`/workspaces/${id}`, { method: "DELETE" });
703  },
704  pages(workspaceId: string, params: { parent?: string | null; limit?: number } = {}): Promise<Page[]> {
705    // TASK-0100 S5: the API returns the `{ data, nextCursor }` envelope
706    // (cursor-paginated — pages.service.ts `list`); the caller expects a bare
707    // Page[] (sortByPosition does [...items] — the envelope is not iterable,
708    // which crashed the workspace index into the 404 boundary). Unwrap like
709    // list()/teams() below.
710    return apiFetch<{ data: Page[] }>(`/workspaces/${workspaceId}/pages${buildQuery({ parent: params.parent ?? null, limit: params.limit })}`).then((r) => r.data);
711  },
712  pageTree(workspaceId: string, params: { parentId?: string | null } = {}): Promise<PageTreeEntry[]> {
713    // The tree IS the documented sibling-list endpoint (docs/api/pages.md §3
714    // "List & Tree": `GET /workspaces/:id/pages?parentId=`, roots when the
715    // param is omitted — `buildQuery` drops nulls). The former `/page-tree`
716    // route was never built on the API (recorded in
717    // docs/tasks/phase-15-production.md "Still-open pre-existing notes") and
718    // 404'd, so the sidebar tree rendered empty and newly created/renamed
719    // pages never appeared — even though every write persisted fine.
720    //
721    // Map the list row → PageTreeEntry: the Page model has no `type` field
722    // (a database is a page with a seeded Database row — see the `Page.type`
723    // conflict note in types.ts), so `databaseId` decides; `hasMore` comes
724    // from `childrenCount` so the expand caret + lazy child load work.
725    return apiFetch<{ data: Array<Page & { childrenCount?: number }> }>(
726      `/workspaces/${workspaceId}/pages${buildQuery({ parentId: params.parentId ?? null })}`
727    ).then((r) =>
728      r.data.map((p) => ({
729        id: p.id,
730        parentId: p.parentId,
731        title: p.title,
732        icon: p.icon ?? null,
733        type: p.databaseId ? ("database" as const) : ("page" as const),
734        position: p.position,
735        hasMore: (p.childrenCount ?? 0) > 0,
736        updatedAt: p.updatedAt
737      }))
738    );
739  },
740  members(id: string): Promise<WorkspaceMember[]> {
741    // TASK-0100 S5: the API wraps the member list in `{ data }`.
742    //
743    // The API returns a FLAT row (`{ userId, email, name, displayName,
744    // avatarUrl, role, joinedAt }`), but the web `WorkspaceMember` model — and
745    // every consumer of it (settings page, members page, invite dialog,
746    // file-permissions, comments, mention sources, teams) — expects a NESTED
747    // `user` object. Reading `m.user.name` on the flat row threw
748    // "Cannot read properties of undefined (reading 'name')" and crashed the
749    // whole page under the Next.js dev error overlay (the "grey screen +
750    // popup"). Normalize the flat wire shape into the nested model here, at
751    // the single seam, so all consumers are fixed at once without touching the
752    // API (whose flat shape its own e2e contract + integration tests pin).
753    return apiFetch<{ data: WorkspaceMemberWire[] }>(`/workspaces/${id}/members`).then(
754      (r) => (r.data ?? []).map(normalizeWorkspaceMember)
755    );
756  },
757  /** The workspace's teamspace list (the mention popup's Teams group — R3). */
758  teams(id: string): Promise<Team[]> {
759    return apiFetch<{ data: Team[] }>(`/workspaces/${id}/teams`).then((r) => r.data);
760  },
761  /** The workspace's databases (the mention popup's Databases group — R3). */
762  databases(id: string, params: { limit?: number } = {}): Promise<Database[]> {
763    return apiFetch<Database[]>(`/workspaces/${id}/databases${buildQuery({ limit: params.limit })}`);
764  },
765  invite(id: string, email: string, role: WorkspaceRole): Promise<WorkspaceInvite> {
766    return mutatingApiFetch<WorkspaceInvite>(`/workspaces/${id}/invites`, { method: "POST", body: { email, role } });
767  },
768  invites(id: string): Promise<WorkspaceInvite[]> {
769    return apiFetch<WorkspaceInvite[]>(`/workspaces/${id}/invites`);
770  }
771};
772 
773/* ---------------------------------------------------------------- pages */
774 
775export interface CreatePageInput {
776  workspaceId: string;
777  parentId?: string | null;
778  title?: string;
779  position?: Position;
780  type?: "page" | "database" | "whiteboard";
781}
782 
783export const pageApi = {
784  get(id: string): Promise<Page> {
785    // TASK-0100 S5: GET /pages/:id returns the envelope
786    // { page, workspace, topLevelBlocks, database, childrenCount } — unwrap
787    // `.page` so callers (page-view title input, topbar breadcrumbs, the
788    // page layout) see a real Page. Previously the whole envelope was treated
789    // as the Page, so `page.id`/`page.title` were `undefined`: the title
790    // input's `if (page)` guard passed (the envelope is truthy) but the
791    // mutation fired `PATCH /pages/undefined` and the controlled value was
792    // always `""` — the title never persisted (the doc indexed "(untitled
793    // page)").
794    //
795    // The inner `page` object does NOT carry `databaseId` (the Page model has
796    // no such column) — only the list endpoint and the envelope's `database`
797    // sibling do. Surface it here so `page.databaseId` matches the `Page`
798    // interface ("non-null ⇔ this page is a database") for the route canvas
799    // (page-canvas.tsx branches on it) and any page-level DB badge.
800    return apiFetch<{ page: Page & { coverUrl?: string | null }; database?: { id: string } | null }>(`/pages/${id}`).then(
801      (r) => ({
802        ...r.page,
803        // The server stores the cover as `coverUrl` (UpdatePageDto) but the
804        // web `Page` type spells it `cover` — normalize here so `page.cover`
805        // is populated for the page-cover band + pickers (recorded conflict,
806        // T-192: the API exposes only `coverUrl`, never `cover`).
807        cover: r.page.coverUrl ?? r.page.cover ?? null,
808        databaseId: r.database?.id ?? r.page.databaseId ?? null
809      }),
810    );
811  },
812  /** List a level of the workspace (`GET /workspaces/:id/pages` — the
813   *  `{ data, nextCursor }` envelope, unwrapped to a bare Page[]).
814   *
815   *  `trashed: true` lists the trash (the trashed ROOTS — a trashed subtree
816   *  appears once, at its root) and is server-gated to Admin/Owner;
817   *  `favorites: true` lists the caller's favorited pages. The two are
818   *  mutually exclusive server-side (docs/api/pages.md §3). Rows carry
819   *  `databaseId` (null for plain pages) so the UI can badge database pages —
820   *  the Page model's `type` field does not exist in the API (a database is a
821   *  page with a Database row, not a distinct type — recorded conflict).
822   */
823  list(workspaceId: string, params: { parentId?: string | null; trashed?: boolean; favorites?: boolean; limit?: number } = {}): Promise<Page[]> {
824    return apiFetch<{ data: Page[] }>(
825      `/workspaces/${workspaceId}/pages${buildQuery({
826        parentId: params.parentId ?? null,
827        trashed: params.trashed,
828        favorites: params.favorites,
829        limit: params.limit
830      })}`
831    ).then((r) => r.data);
832  },
833  // `type: "database"` pages carry the seeded Database row's id in the
834  // response (`page.service.create` returns `{ ...page, databaseId }`) —
835  // the Database row has its OWN cuid, it is NOT id-parity with the page.
836  create(input: CreatePageInput): Promise<Page & { databaseId?: string; blockIds?: string[] }> {
837    // TASK-0100 S5: the API route is the nested POST /workspaces/:workspaceId/pages
838    // (workspace from the path; CreatePageDto has no workspaceId field — the old
839    // global POST /pages 404'd). `position` is kept for client ergonomics but the
840    // DTO strips it; the server assigns the position from the last sibling.
841    const { workspaceId, ...body } = input;
842    return mutatingApiFetch<Page & { databaseId?: string; blockIds?: string[] }>(`/workspaces/${workspaceId}/pages`, { method: "POST", body });
843  },
844  // `archived` (TASK-0035 archive endpoint, wired by the TASK-0045 context
845  // menu) + `coverUrl` (server field name for `Page.cover`) ride along —
846  // the PATCH DTO accepts both even though the web `Page` type spells them
847  // differently.
848  update(id: string, patch: Partial<Pick<Page, "title" | "icon" | "cover" | "parentId" | "position" | "type">> & { archived?: boolean }): Promise<Page> {
849    // `cover` (web field) → `coverUrl` (server field) is mapped HERE, at the
850    // boundary, so callers (useUpdatePage, pickers) pass `cover` uniformly
851    // (recorded conflict, T-192: the API exposes only `coverUrl`).
852    // The response is normalized the same way `get` is (coverUrl → cover) so
853    // the `["pages", id]` cache the mutation's onSuccess writes keeps `cover`
854    // populated (otherwise the page-cover band disappears right after a pick).
855    const { cover, ...rest } = patch;
856    return mutatingApiFetch<Page & { coverUrl?: string | null }>(`/pages/${id}`, {
857      method: "PATCH",
858      body: { ...rest, ...(cover !== undefined ? { coverUrl: cover } : {}) }
859    }).then((p) => ({ ...p, cover: p.coverUrl ?? p.cover ?? null }));
860  },
861  remove(id: string): Promise<void> {
862    return mutatingApiFetch<void>(`/pages/${id}`, { method: "DELETE" });
863  },
864  restore(id: string): Promise<Page> {
865    return mutatingApiFetch<Page>(`/pages/${id}/restore`, { method: "POST" });
866  },
867  blocks(id: string, params: { parentId?: string | null; depth?: number; limit?: number } = {}): Promise<Block[]> {
868    // TASK-0100 S5: the API wraps list endpoints in `{ data, tree, total }`
869    // (block.service `list`); the editor does sorted.map over a bare Block[] —
870    // the envelope crashed the page view ("Cannot read ... of undefined").
871    return apiFetch<{ data: Block[] }>(`/pages/${id}/blocks${buildQuery({ parentId: params.parentId ?? null, depth: params.depth, limit: params.limit })}`).then((r) => mapBlocks(r.data));
872  },
873  versions(id: string): Promise<PageVersion[]> {
874    // TASK-0100 S5: same `{ data }` envelope (page.service `listVersions`).
875    return apiFetch<{ data: PageVersion[] }>(`/pages/${id}/versions`).then((r) => r.data);
876  },
877  /** T-192: manual snapshot (TASK-0043 / Phase 3). Phase 3 ships MANUAL
878   *  versions only — auto-snapshot on block edits is deferred to Phase 4
879   *  (the 100-op counter). This is the only way to create a version in
880   *  Phase 3, so the E2E "version snapshot visible" test drives it here. */
881  snapshotVersion(id: string, note?: string): Promise<PageVersion> {
882    return mutatingApiFetch<PageVersion>(`/pages/${id}/versions`, { method: "POST", body: note ? { note } : {} });
883  },
884  restoreVersion(id: string, versionId: string): Promise<Page> {
885    return mutatingApiFetch<Page>(`/pages/${id}/versions/${versionId}/restore`, { method: "POST" });
886  },
887  /** T-150: block-level diff between two versions. `from`/`to` are the
888   * version NUMBERS (the list rows' `version` field), not ids. */
889  compareVersions(id: string, from: number, to: number): Promise<VersionDiff> {
890    return apiFetch<VersionDiff>(`/pages/${id}/versions/compare${buildQuery({ from, to })}`);
891  },
892  /** T-151: read-only snapshot at a version id. */
893  previewVersion(id: string, versionId: string): Promise<VersionPreview> {
894    return apiFetch<VersionPreview>(`/pages/${id}/versions/${versionId}`);
895  },
896  toggleFavorite(id: string): Promise<{ favorite: boolean }> {
897    return mutatingApiFetch<{ favorite: boolean }>(`/pages/${id}/favorite`, { method: "POST" });
898  },
899  removeFavorite(id: string): Promise<{ favorited: boolean }> {
900    return mutatingApiFetch<{ favorited: boolean }>(`/pages/${id}/favorite`, { method: "DELETE" });
901  },
902  /** T-157: watchlist — is the caller watching this page? (VIEW-level.) */
903  isWatching(id: string): Promise<{ watching: boolean }> {
904    return apiFetch<{ watching: boolean }>(`/pages/${id}/watch`);
905  },
906  watch(id: string): Promise<{ watching: boolean }> {
907    return mutatingApiFetch<{ watching: boolean }>(`/pages/${id}/watch`, { method: "POST" });
908  },
909  unwatch(id: string): Promise<{ watching: boolean }> {
910    return mutatingApiFetch<{ watching: boolean }>(`/pages/${id}/watch`, { method: "DELETE" });
911  },
912  share(id: string, input: { level: "view" | "edit" | "public"; password?: string | null; expiresAt?: string | null }): Promise<PageShare> {
913    return mutatingApiFetch<PageShare>(`/pages/${id}/share`, { method: "PUT", body: input });
914  },
915  invite(id: string, input: { email: string; role: WorkspaceRole }): Promise<WorkspaceInvite> {
916    return mutatingApiFetch<WorkspaceInvite>(`/pages/${id}/invites`, { method: "POST", body: input });
917  },
918  /** T-138: recursive duplicate (blocks, child pages, database snapshot).
919   *  The endpoint returns the BARE `{ id, sourcePageId }` (the tested T-138
920   *  contract) — fetch the new page by id for the full row. */
921  duplicate(id: string, input: { parentId?: string | null; title?: string } = {}): Promise<{ id: string; sourcePageId: string }> {
922    return mutatingApiFetch<{ id: string; sourcePageId: string }>(`/pages/${id}/duplicate`, { method: "POST", body: input });
923  },
924  /** T-140: permission-filtered backlinks (pages that reference this one). */
925  backlinks(id: string): Promise<Array<{ pageId: string; title: string; icon: string | null; kind: string }>> {
926    return apiFetch<{ data: Array<{ pageId: string; title: string; icon: string | null; kind: string }> }>(`/pages/${id}/backlinks`).then((r) => r.data);
927  },
928  /** T-180 (Phase 8): export the page as Markdown. Returns the RAW response
929   *  (body = `text/markdown`, the `Content-Disposition` header carries the
930   *  filename) — the caller triggers the browser download (keeps this layer
931   *  a thin HTTP wrapper; the topbar owns the blob/anchor). Re-uses
932   *  `apiFetchRaw` so the Bearer + 401-refresh behavior is identical; a
933   *  non-2xx (e.g. 403 for a restricted page) throws an ApiError so the UI
934   *  can toast it. */
935  async exportMarkdown(id: string, includeChildren = false): Promise<Response> {
936    const res = await apiFetchRaw(`/pages/${id}/export/markdown${includeChildren ? "?includeChildren=1" : ""}`, {
937      headers: { Accept: "text/markdown" }
938    });
939    if (!res.ok) {
940      const body = (await res.json().catch(() => null)) as { error?: { code?: string; message?: string } } | null;
941      throw new ApiError(
942        body?.error?.code ?? "INTERNAL",
943        res.status,
944        body?.error?.message ?? `Export failed with status ${res.status}`
945      );
946    }
947    return res;
948  },
949  /** T-149: cross-workspace move (EDIT required in BOTH workspaces). */
950  move(id: string, input: { toWorkspaceId: string; toParentId?: string | null }): Promise<{ moved: true; pageId: string; pageIds: number }> {
951    return mutatingApiFetch(`/pages/${id}/move`, { method: "POST", body: input });
952  },
953  /** T-139: bulk action. `workspaceId` goes in the X-Factory-Workspace header
954   *  (the API requires it). */
955  bulk(workspaceId: string, action: "trash" | "archive" | "unarchive" | "favorite" | "unfavorite", ids: string[]): Promise<{ succeeded: string[]; failed: Array<{ id: string; code: string }> }> {
956    return mutatingApiFetch(`/pages/bulk`, { method: "POST", body: { action, ids }, headers: { "x-factory-workspace": workspaceId } });
957  }
958};
959 
960/* ------------------------------------------------------------------ whiteboard */
961 
962/** T-167 — `GET /pages/:id/whiteboard` response (the canvas + validity flag). */
963export interface WhiteboardCanvasResponse {
964  pageId: string;
965  schemaVersion: number;
966  canvas: Canvas;
967  /** false → the stored document failed validation: render read-only (banner). */
968  valid: boolean;
969  updatedAt: string;
970  updatedBy: string | null;
971}
972 
973/** T-168 — whiteboard canvas client. The canvas document is the full v1
974 *  `@factory/whiteboard` shape; the server validates it (`validateCanvas`) and
975 *  400s with field-level issues otherwise. Saves are debounced by the
976 *  renderer; the route-scoped 2 MB body cap (413) guards oversized boards. */
977export const whiteboardApi = {
978  get(pageId: string): Promise<WhiteboardCanvasResponse> {
979    return apiFetch<WhiteboardCanvasResponse>(`/pages/${pageId}/whiteboard`);
980  },
981  save(pageId: string, canvas: Canvas): Promise<{ pageId: string; schemaVersion: number; canvas: Canvas; updatedAt: string }> {
982    return mutatingApiFetch(`/pages/${pageId}/whiteboard`, { method: "PUT", body: canvas });
983  },
984  /** T-170 save & restore: snapshot the current state (the server snapshots
985   *  the canvas sidecar for whiteboard pages). */
986  saveVersion(pageId: string, note?: string): Promise<{ id: string; version: number }> {
987    return mutatingApiFetch(`/pages/${pageId}/versions`, { method: "POST", body: note ? { note } : undefined });
988  },
989  restoreVersion(pageId: string, versionId: string): Promise<{ restored: boolean; version: number }> {
990    return mutatingApiFetch(`/pages/${pageId}/versions/${versionId}/restore`, { method: "POST" });
991  },
992  /** T-171: convert a selection of canvas elements into a real page (or
993   *  task page) — the server re-reads the canvas and maps text elements to
994   *  blocks; returns the created page (and task sidecar row, if any). */
995  convertSelection(
996    pageId: string,
997    body: { elementIds: string[]; target: "page" | "task"; title?: string; parentId?: string | null }
998  ): Promise<{ page: { id: string; kind: string; title: string }; task: { id: string; status: string; pageId: string } | null; blockCount: number }> {
999    return mutatingApiFetch(`/pages/${pageId}/whiteboard/convert`, { method: "POST", body });
1000  }
1001};
1002 
1003/* ---------------------------------------------------------------- blocks */
1004 
1005/**
1006 * Block type case reconciliation at the REST read boundary.
1007 *
1008 * The DB/REST wire enum is UPPERCASE (API `constants.BLOCK_TYPES`), but the
1009 * web editor codec is lowercase (`types.BLOCK_TYPES`), and the editor's
1010 * `BlockRenderer` switches on the lowercase codec case. The realtime gateway
1011 * already reconciles the two at its boundary (`prisma-store.ts`
1012 * `dbToCodecType`); the REST client now does the same on READ so a block
1013 * fetched from `GET /pages/:id/blocks` renders identically to one delivered
1014 * by a realtime frame. The write side needs no transform: the API's block
1015 * DTO is case-tolerant (see `apps/api` `blocks/dto.ts`, mirroring the
1016 * gateway's `codecToDbType`), so whatever case the editor sends is stored as
1017 * the DB case. Lowercase input is a no-op here (backward compatible); unknown
1018 * values pass through untouched so a genuine server-side change surfaces
1019 * rather than being silently mangled.
1020 */
1021function dbToBlockType(raw: string): BlockType {
1022  const lowered = raw.toLowerCase();
1023  return (BLOCK_TYPES as readonly string[]).includes(lowered) ? (lowered as BlockType) : (raw as BlockType);
1024}
1025 
1026function withBlockType(b: Block): Block {
1027  return b.type ? { ...b, type: dbToBlockType(b.type) } : b;
1028}
1029 
1030function mapBlocks(blocks: Block[]): Block[] {
1031  return blocks.map(withBlockType);
1032}
1033 
1034export interface InsertBlockInput {
1035  pageId: string;
1036  parentId: string | null;
1037  afterBlockId?: string | null;
1038  beforeBlockId?: string | null;
1039  type: BlockType;
1040  content?: unknown;
1041  properties?: Record<string, unknown>;
1042  position?: Position;
1043  /** Optional client-chosen id (paste imports reference nested parents by
1044   *  id within one batch); omitted → server assigns. */
1045  id?: string;
1046}
1047 
1048export interface BatchBlockOp {
1049  op: "upsert" | "insert" | "update" | "delete" | "move";
1050  blockId?: string;
1051  block?: Omit<InsertBlockInput, "pageId" | "parentId"> & { parentId: string | null };
1052  patch?: BlockPatch;
1053  parentId?: string | null;
1054  position?: Position;
1055}
1056 
1057export const blockApi = {
1058  list(pageId: string, params: { parentId?: string | null; depth?: number | "all" } = {}): Promise<Block[]> {
1059    // TASK-0100 S5: same `{ data, tree, total }` envelope as pageApi.blocks.
1060    return apiFetch<{ data: Block[] }>(`/pages/${pageId}/blocks${buildQuery({ parentId: params.parentId ?? null, depth: params.depth })}`).then((r) => mapBlocks(r.data));
1061  },
1062  insert(input: InsertBlockInput): Promise<Block> {
1063    // The API route is `POST /pages/:pageId/blocks` (BlocksController.create)
1064    // — there is no `POST /api/v1/blocks`. The body carries the full
1065    // CreateBlockInput (the server reads `pageId` from the path).
1066    return mutatingApiFetch<Block>(`/pages/${input.pageId}/blocks`, { method: "POST", body: input });
1067  },
1068  update(id: string, patch: BlockPatch): Promise<Block> {
1069    return mutatingApiFetch<Block>(`/blocks/${id}`, { method: "PATCH", body: patch });
1070  },
1071  // The API resolves the new position SERVER-side: pass neighbor block ids
1072  // (`beforeBlockId`/`afterBlockId`) and let the server's canonical base-62
1073  // engine compute the position, OR pass an explicit `position`. Position
1074  // computation must NOT happen client-side — the web's base-36 shared engine
1075  // rejects the server's base-62 positions (uppercase) and threw on every
1076  // reorder (TASK-0135).
1077  move(
1078    id: string,
1079    input: { parentId: string | null; position?: Position; beforeBlockId?: string | null; afterBlockId?: string | null },
1080  ): Promise<Block> {
1081    return mutatingApiFetch<Block>(`/blocks/${id}/move`, { method: "POST", body: input });
1082  },
1083  batch(pageId: string, ops: BatchBlockOp[]): Promise<{ applied: number }> {
1084    return mutatingApiFetch<{ applied: number }>("/blocks/batch", { method: "POST", body: { pageId, ops } });
1085  },
1086  remove(id: string): Promise<void> {
1087    return mutatingApiFetch<void>(`/blocks/${id}`, { method: "DELETE" });
1088  },
1089  /** Duplicate a block's subtree. `afterBlockId` anchors the copy (server
1090   *  computes the fractional position — client position math threw on the
1091   *  API's base-62 positions). Omitted → directly below the source. The API
1092   *  returns `{ duplicated, newRootId, blockIds }` — map to a Block-ish
1093   *  shape with the new root id (callers only need the id). */
1094  duplicate(id: string, input: { parentId?: string | null; afterBlockId?: string } = {}): Promise<{ id: string }> {
1095    return mutatingApiFetch<{ newRootId: string }>(`/blocks/${id}/duplicate`, {
1096      method: "POST",
1097      body: Object.keys(input).length > 0 ? input : undefined
1098    }).then((r) => ({ id: r.newRootId }));
1099  }
1100};
1101 
1102/* ------------------------------------------------------------- databases */
1103 
1104export const databaseApi = {
1105  get(id: string): Promise<Database> {
1106    return apiFetch<Database>(`/databases/${id}`);
1107  },
1108  properties: {
1109    list(id: string): Promise<DatabaseProperty[]> {
1110      return apiFetch<DatabaseProperty[]>(`/databases/${id}/properties`);
1111    },
1112    create(id: string, input: { name: string; type: PropertyType; config?: Record<string, unknown> }): Promise<DatabaseProperty> {
1113      return mutatingApiFetch<DatabaseProperty>(`/databases/${id}/properties`, { method: "POST", body: input });
1114    }
1115  },
1116  views: {
1117    list(id: string): Promise<DatabaseView[]> {
1118      return apiFetch<DatabaseView[]>(`/databases/${id}/views`);
1119    },
1120    create(id: string, input: { name: string; type: ViewType }): Promise<DatabaseView> {
1121      return mutatingApiFetch<DatabaseView>(`/databases/${id}/views`, { method: "POST", body: input });
1122    }
1123  },
1124  records: {
1125    list(id: string, params: { viewId?: string | null; limit?: number; cursor?: string | null } = {}): Promise<DatabaseRecord[]> {
1126      return apiFetch<DatabaseRecord[]>(`/databases/${id}/records${buildQuery({ viewId: params.viewId ?? null, limit: params.limit, cursor: params.cursor ?? null })}`);
1127    },
1128    get(databaseId: string, recordId: string): Promise<DatabaseRecord> {
1129      return apiFetch<DatabaseRecord>(`/databases/${databaseId}/records/${recordId}`);
1130    },
1131    bulk(databaseId: string, ops: { op: "create" | "update" | "delete"; recordId?: string; values?: Record<string, unknown> }[]): Promise<{ applied: number }> {
1132      return mutatingApiFetch<{ applied: number }>(`/databases/${databaseId}/records/bulk`, { method: "POST", body: { ops } });
1133    },
1134    export(id: string, format: "csv" | "json"): Promise<{ url: string }> {
1135      return mutatingApiFetch<{ url: string }>(`/databases/${id}/records/export`, { method: "POST", body: { format } });
1136    }
1137  }
1138};
1139 
1140/* --------------------------------------------------------------- comments */
1141 
1142export const commentApi = {
1143  list(pageId: string): Promise<Comment[]> {
1144    return apiFetch<Comment[]>(`/pages/${pageId}/comments`);
1145  },
1146  create(input: { blockId: string | null; pageId: string; parentId?: string | null; body: string }): Promise<Comment> {
1147    return mutatingApiFetch<Comment>("/comments", { method: "POST", body: input });
1148  },
1149  update(id: string, patch: { body?: string; resolved?: boolean }): Promise<Comment> {
1150    return mutatingApiFetch<Comment>(`/comments/${id}`, { method: "PATCH", body: patch });
1151  },
1152  remove(id: string): Promise<void> {
1153    return mutatingApiFetch<void>(`/comments/${id}`, { method: "DELETE" });
1154  }
1155};
1156 
1157/* ----------------------------------------------------------------- files */
1158 
1159/**
1160 * TASK-0103 migration: the old `fileApi` object targeted the pre-Phase-9
1161 * draft endpoints (`POST /files/upload`, `POST /files/:id/complete`,
1162 * `GET /files/:id/url`, `GET /workspaces/:id/files`) which 404/405 against the
1163 * live API and had zero external consumers. It is removed. `uploadFile` is
1164 * now a thin ADAPTER over the real upload machine
1165 * (`@/lib/files/upload.ts`: plan pre-check → presigned session → XHR PUT with
1166 * progress → complete → poll) and returns the legacy `FileMeta` shape so the
1167 * pre-Phase-9 consumers (insert-dialog image tab, form FILE field, database
1168 * cell file upload) keep working against the real backend with no churn.
1169 *
1170 * The full client (list / get / upload session / cancel + the FileRecord
1171 * wire types) lives in `@/lib/files/api` — use that for anything new.
1172 */
1173export async function uploadFile(
1174  file: File,
1175  onProgress?: (fraction: number) => void,
1176  opts: { workspaceId?: string; pageId?: string; blockId?: string } = {}
1177): Promise<FileMeta> {
1178  // Imported lazily: `@/lib/files/upload` → `@/lib/files/api` → `@/lib/api`,
1179  // so a top-level import here would be a module cycle. The cycle is
1180  // runtime-only (used inside the function body) and safe.
1181  const { uploadFile: uploadFileImpl } = await import("@/lib/files/upload");
1182  const { MAX_FILE_SIZE_HARD } = await import("@/lib/files/api");
1183  // uploadFile returns an UploadHandle {fileId, promise, cancel}; the result
1184  // (the FileDetail) resolves from `promise`.
1185  const handle = uploadFileImpl(file, {
1186    workspaceId: opts.workspaceId,
1187    pageId: opts.pageId,
1188    blockId: opts.blockId,
1189    // The legacy contract had NO client pre-check (the server enforced the
1190    // plan limit). Pass the hard ceiling so only > 500 MB is rejected
1191    // client-side; plan-specific over-limit files still 413 server-side with
1192    // a clear message (the new block uploader fetches the plan for a friendlier
1193    // toast — see use-file-uploads).
1194    maxFileSize: MAX_FILE_SIZE_HARD,
1195    onProgress: onProgress ? (p) => onProgress(p.fraction) : undefined,
1196  });
1197  const result = await handle.promise;
1198  const d = result.file;
1199  const status: FileMeta["status"] =
1200    d.status === "READY" ? "ready" : d.status === "FAILED" || d.status === "VIRUS_DETECTED" ? "error" : "processing";
1201  return {
1202    id: d.id,
1203    workspaceId: d.workspaceId,
1204    name: d.name,
1205    mime: d.mimeType,
1206    size: d.size,
1207    // Signed inline (preview) URL — the only content path (spec §Security).
1208    url: d.previewUrl,
1209    status,
1210    createdAt: d.createdAt,
1211  };
1212}
1213 
1214/* ---------------------------------------------------------------- search */
1215 
1216/**
1217 * Search API — Phase-8 contract (TASK-0098; the API agent implements the
1218 * backend against these exact shapes):
1219 * - `search`  → POST /search   { q, types?, workspaceId?, … } →
1220 *   { data: SearchResult[], nextCursor, tookMs } (one ResultItem shape).
1221 * - `query`   → GET /search/query?q=&workspaceId=&limit=&cursor= → BARE
1222 *   array of SearchResult (the command palette consumes this).
1223 * - `suggest` → GET /search/suggest?q=&limit= → BARE array of
1224 *   { id, type, title } (the suggestion row shape — a bare `title`, not a
1225 *   full ResultItem).
1226 * - recent    → GET/PUT/DELETE /search/recent, the server (Redis) is the
1227 *   ONLY source of truth for recents (no localStorage duplicate).
1228 *
1229 * Errors: 503 `{ error: { code: "SEARCH_UNAVAILABLE" } }` when OpenSearch is
1230 * down — callers show "Search is temporarily unavailable", they never crash.
1231 */
1232export const searchApi = {
1233  /** Paged full-text search (the /search page's primary endpoint). */
1234  search(input: SearchQueryInput, signal?: AbortSignal): Promise<SearchQueryResult> {
1235    return apiFetch<SearchQueryResult>(
1236      "/search",
1237      { method: "POST", body: input, signal }
1238    );
1239  },
1240  /** Quick query (⌘K dialog results + the command palette). Bare array. */
1241  query(
1242    q: string,
1243    params: { types?: SearchType[]; workspaceId?: string; limit?: number; cursor?: string | null } = {},
1244    signal?: AbortSignal
1245  ): Promise<SearchResult[]> {
1246    return apiFetch<SearchResult[]>(
1247      `/search/query${buildQuery({
1248        q,
1249        workspaceId: params.workspaceId,
1250        limit: params.limit,
1251        cursor: params.cursor ?? null,
1252        types: params.types?.join(",") ?? null
1253      })}`,
1254      { signal }
1255    );
1256  },
1257  /** Live title-prefix suggestions (bare array of { id, type, title }). */
1258  suggest(q: string, limit = 8, signal?: AbortSignal): Promise<SearchSuggestion[]> {
1259    return apiFetch<SearchSuggestion[]>(`/search/suggest${buildQuery({ q, limit })}`, { signal });
1260  },
1261  /** The user's recent searches (newest first, server cap 20). */
1262  recent(): Promise<string[]> {
1263    return apiFetch<{ data: string[] }>("/search/recent").then((r) => r.data);
1264  },
1265  /**
1266   * Record a search (server dedupes, cap 20, newest first). Returns the
1267   * updated list. Fire-and-forget friendly (the dialog's recency writes do
1268   * NOT await this — the e2e closes the dialog the moment a result appears).
1269   */
1270  recordRecent(q: string): Promise<string[]> {
1271    return mutatingApiFetch<{ data: string[] }>("/search/recent", { method: "PUT", body: { q } }).then((r) => r.data);
1272  },
1273  /** Clear all recently searched queries. */
1274  clearRecent(): Promise<void> {
1275    return mutatingApiFetch<void>("/search/recent", { method: "DELETE" });
1276  }
1277};
1278 
1279/* --------------------------------------------------------- me (T-153/T-154) */
1280 
1281export interface RecentViewItem {
1282  id: string;
1283  itemType: "page" | "database" | "whiteboard" | "task";
1284  refId: string;
1285  at: string;
1286}
1287 
1288// The canonical saved-search row lives in lib/search (its `queryJson` mirrors
1289// the POST /search body). Reuse it so the client and the ⌘K home agree on the
1290// shape (a local re-definition here drifted and broke the dialog's state).
1291export type SavedSearch = SavedSearchRow;
1292 
1293/** T-153/T-154 — the caller's recently-VIEWED content (a separate store from
1294 *  the recent-SEARCHES above) and saved searches. All strictly per-user
1295 *  (the server keys everything by the JWT subject; a foreign id is 404). */
1296export const meApi = {
1297  /** The caller's recently-viewed items, newest first (server cap 50). */
1298  recentViewed(): Promise<RecentViewItem[]> {
1299    return apiFetch<{ data: RecentViewItem[] }>("/users/me/recent").then((r) => r.data);
1300  },
1301  /**
1302   * Record a view (upsert + bump, cap 50). Fire-and-forget: page opens never
1303   * await it (a failed record must not block the editor). A non-viewable
1304   * "page" ref is a 404 the caller swallows (no-leak — never surfaces).
1305   */
1306  recordView(itemType: "page" | "database" | "whiteboard" | "task", refId: string): Promise<void> {
1307    return mutatingApiFetch<void>("/users/me/recent", { method: "PUT", body: { itemType, refId } });
1308  },
1309  listSavedSearches(): Promise<SavedSearch[]> {
1310    return apiFetch<{ data: SavedSearch[] }>("/users/me/saved-searches").then((r) => r.data);
1311  },
1312  createSavedSearch(input: { name: string; workspaceId?: string | null; query: Record<string, unknown> }): Promise<SavedSearch> {
1313    return apiFetch<SavedSearch>("/users/me/saved-searches", { method: "POST", body: input });
1314  },
1315  deleteSavedSearch(id: string): Promise<void> {
1316    return mutatingApiFetch<void>(`/users/me/saved-searches/${id}`, { method: "DELETE" });
1317  },
1318  /** T-158: the caller's activity feed (their actions + their workspaces'
1319   *  rows; other workspaces never leak). Keyset-paginated. */
1320  myActivity(params: { cursor?: string; limit?: number } = {}): Promise<{ data: ActivityRow[]; nextCursor: string | null }> {
1321    return apiFetch<{ data: ActivityRow[]; nextCursor: string | null }>(
1322      `/users/me/activity${buildQuery({ cursor: params.cursor, limit: params.limit })}`
1323    );
1324  }
1325};
1326 
1327/** T-158: a workspace's (space) activity feed — membership-gated (403 for
1328 *  non-members, the same no-leak contract as stats/archive). */
1329export function workspaceActivity(
1330  workspaceId: string,
1331  params: { cursor?: string; limit?: number } = {}
1332): Promise<{ data: ActivityRow[]; nextCursor: string | null }> {
1333  return apiFetch<{ data: ActivityRow[]; nextCursor: string | null }>(
1334    `/workspaces/${workspaceId}/activity${buildQuery({ cursor: params.cursor, limit: params.limit })}`
1335  );
1336}
1337 
1338/* ---------------------------------------------------------- notifications */
1339 
1340export interface NotificationPage {
1341  data: Notification[];
1342  nextCursor: string | null;
1343}
1344 
1345export const notificationApi = {
1346  // The API's `GET /notifications` filters on `read` (not `unreadOnly`) and
1347  // returns `{ data, nextCursor }` (cursor-paginated — TASK-0094 R5).
1348  list(params: { limit?: number; unreadOnly?: boolean; cursor?: string | null } = {}): Promise<NotificationPage> {
1349    return apiFetch<NotificationPage>(
1350      `/notifications${buildQuery({
1351        limit: params.limit,
1352        read: params.unreadOnly ? "false" : params.unreadOnly === false ? "true" : undefined,
1353        cursor: params.cursor ?? undefined
1354      })}`
1355    );
1356  },
1357  /** The bell's count (R5: cheaper than a list fetch). */
1358  unreadCount(): Promise<{ count: number }> {
1359    return apiFetch<{ count: number }>("/notifications/unread-count");
1360  },
1361  markRead(id: string): Promise<{ read: boolean; id: string }> {
1362    return mutatingApiFetch<{ read: boolean; id: string }>(`/notifications/${id}/read`, { method: "POST" });
1363  },
1364  markAllRead(): Promise<{ marked: number }> {
1365    return mutatingApiFetch<{ marked: number }>("/notifications/read-all", { method: "POST" });
1366  }
1367};
1368 
1369/* --------------------------------------------------------------- templates */
1370 
1371export const templateApi = {
1372  /** Wire shape of the Template rows `GET /workspaces/:id/templates` returns
1373   * (`page.service.listTemplates`) — a template is NOT a Page (it has `name`,
1374   * no `title`). The editor's insert dialog renders `t.title`/`t.icon`; the
1375   * templates page renders the real fields, so this returns the wire shape.
1376   * T-141: optional category/search/type filters. */
1377  list(workspaceId: string, params: { category?: string; search?: string; type?: string } = {}): Promise<TemplateInfo[]> {
1378    return apiFetch<{ data: TemplateInfo[] }>(
1379      `/workspaces/${workspaceId}/templates${buildQuery({ category: params.category, search: params.search, type: params.type })}`
1380    ).then((r) => r.data);
1381  },
1382  /** Snapshot a page subtree into a template (`POST /pages/:id/templates`).
1383   *  NOTE: the earlier `POST /templates` route does not exist (404) — the
1384   *  controller mounts create under the page. */
1385  saveFromPage(pageId: string, name: string, icon?: string): Promise<TemplateInfo> {
1386    return mutatingApiFetch<TemplateInfo>(`/pages/${pageId}/templates`, { method: "POST", body: { name, ...(icon ? { icon } : {}) } });
1387  },
1388  /** Instantiate a template (`POST /templates/:id/apply`). `workspaceId` is
1389   *  REQUIRED (the controller rejects an empty body/header — `doFetch` never
1390   *  sets `X-Factory-Workspace`, so it must come from the body). The server
1391   *  returns the BARE created page row. */
1392  /** T-142: `values` supplies typed variables for variable templates. */
1393  apply(templateId: string, input: { workspaceId: string; parentId?: string | null; title?: string; values?: Record<string, unknown> }): Promise<Page> {
1394    return mutatingApiFetch<Page>(`/templates/${templateId}/apply`, { method: "POST", body: input });
1395  },
1396  /** T-141: read-only preview of the CURRENT snapshot. ID-scoped (no
1397   *  workspace header) — the directory is a cross-workspace union whose rows
1398   *  carry no workspaceId; the server authorizes against the template's OWN
1399   *  workspace membership. `snapshot` is the frozen `{ page, blocks,
1400   *  children }` (loose — rendered read-only by the preview dialog). */
1401  preview(templateId: string): Promise<{ snapshot: unknown }> {
1402    return apiFetch<{ data: { snapshot: unknown } }>(`/templates/${templateId}/preview`).then((r) => r.data);
1403  },
1404  /** T-145: append-only version history (workspace header required). */
1405  versions(templateId: string, workspaceId: string): Promise<TemplateVersionInfo[]> {
1406    return apiFetch<{ data: TemplateVersionInfo[] }>(`/templates/${templateId}/versions`, { headers: { "x-factory-workspace": workspaceId } }).then((r) => r.data);
1407  },
1408  createVersion(templateId: string, workspaceId: string, note?: string): Promise<TemplateVersionInfo> {
1409    return mutatingApiFetch<TemplateVersionInfo>(`/templates/${templateId}/versions`, { method: "POST", body: note ? { note } : {}, headers: { "x-factory-workspace": workspaceId } });
1410  },
1411  restoreVersion(templateId: string, versionId: string, workspaceId: string): Promise<TemplateVersionInfo> {
1412    return mutatingApiFetch<TemplateVersionInfo>(`/templates/${templateId}/versions/${versionId}/restore`, { method: "POST", headers: { "x-factory-workspace": workspaceId } });
1413  }
1414};
1415 
1416/* ------------------------------------------------------------------ shares */
1417 
1418/** Wire shape of `GET /shares/:workspaceId` (share.service `list`) — the
1419 * shares the CALLER created in the workspace (the Phase 4+ "shared with me"
1420 * reverse lookup is not built yet). */
1421export interface ShareRow {
1422  id: string;
1423  token: string;
1424  scope: string;
1425  pageId: string | null;
1426  recordId: string | null;
1427  allowComments: boolean;
1428  expiresAt: string | null;
1429  createdAt: string;
1430}
1431 
1432/**
1433 * T-163 — the page review workflow (A-003). `get` returns the banner state
1434 * (`{ state, open, lastReview }`); `act` performs a review action
1435 * (`submit`/`approve`/`request_changes`/`cancel` — the server enforces the
1436 * transition matrix + the reviewer-of-record gate).
1437 */
1438export const reviewApi = {
1439  get(pageId: string): Promise<PageReview> {
1440    return apiFetch<PageReview>(`/pages/${pageId}/review`);
1441  },
1442  act(
1443    pageId: string,
1444    input: { action: "submit" | "approve" | "request_changes" | "cancel"; reviewerIds?: string[]; comment?: string; deadline?: string | null }
1445  ): Promise<{ state: string }> {
1446    return mutatingApiFetch<{ state: string }>(`/pages/${pageId}/review`, { method: "POST", body: input });
1447  }
1448};
1449 
1450/**
1451 * T-164 — first-class tasks (A-006). A task is a `kind="task"` page + a 1:1
1452 * sidecar; the routes address the sidecar id (`task.id`), the page id lives at
1453 * `task.pageId`. `list` returns the `{ data, nextCursor }` envelope with the
1454 * task page attached (`t.page` / `t.pageTitle`).
1455 */
1456export const taskApi = {
1457  list(
1458    workspaceId: string,
1459    params: {
1460      scope?: "inbox" | "all";
1461      status?: string;
1462      dueBefore?: string;
1463      dueAfter?: string;
1464      month?: string;
1465      overdue?: boolean;
1466      cursor?: string;
1467      limit?: number
1468    } = {}
1469  ): Promise<{ data: Task[]; nextCursor: string | null }> {
1470    return apiFetch<{ data: Task[]; nextCursor: string | null }>(
1471      `/workspaces/${workspaceId}/tasks${buildQuery({
1472        scope: params.scope,
1473        status: params.status,
1474        dueBefore: params.dueBefore,
1475        dueAfter: params.dueAfter,
1476        month: params.month,
1477        overdue: params.overdue,
1478        cursor: params.cursor,
1479        limit: params.limit
1480      })}`
1481    );
1482  },
1483  get(workspaceId: string, id: string): Promise<Task> {
1484    return apiFetch<Task>(`/workspaces/${workspaceId}/tasks/${id}`);
1485  },
1486  create(
1487    workspaceId: string,
1488    input: { title: string; parentId?: string | null; status?: string; dueAt?: string | null; assigneeIds?: string[] }
1489  ): Promise<Task> {
1490    return mutatingApiFetch<Task>(`/workspaces/${workspaceId}/tasks`, { method: "POST", body: input });
1491  },
1492  update(
1493    workspaceId: string,
1494    id: string,
1495    patch: { title?: string; status?: string; dueAt?: string | null; assigneeIds?: string[] | null }
1496  ): Promise<Task> {
1497    return mutatingApiFetch<Task>(`/workspaces/${workspaceId}/tasks/${id}`, { method: "PATCH", body: patch });
1498  },
1499  remove(workspaceId: string, id: string): Promise<{ trashed: boolean }> {
1500    return mutatingApiFetch<{ trashed: boolean }>(`/workspaces/${workspaceId}/tasks/${id}`, { method: "DELETE" });
1501  }
1502};
1503 
1504export const sharesApi = {
1505  /** Shares I created in this workspace (`GET /shares/:workspaceId`). */
1506  list(workspaceId: string): Promise<ShareRow[]> {
1507    return apiFetch<{ data: ShareRow[] }>(`/shares/${workspaceId}`).then((r) => r.data);
1508  },
1509  /** Revoke a share (`DELETE /shares/:id`). */
1510  remove(id: string): Promise<void> {
1511    return mutatingApiFetch<void>(`/shares/${id}`, { method: "DELETE" });
1512  }
1513};
1514