create-ailk 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. package/templates/database/__tests__/build.test.ts +0 -131
@@ -34,6 +34,10 @@ Every endpoint here has a 1:1 MCP tool in `apps/mcp`. When you add, change, or r
34
34
 
35
35
  ## Endpoint conventions
36
36
 
37
+ The root index sits outside `/v1/*`:
38
+
39
+ - `/` (GET) — the anonymous root index (`src/server.ts`): `{ name, version, description, modules, endpoints }`. `name` and `description` introduce the site from `_site.mdx` (#5307); `endpoints` is derived at request time from the routes this process registered (#5314). **`modules` (#7501)** is the sorted list of optional modules this process registered routes for — `servedModules()` filtering the `OPTIONAL_MODULES` vocabulary (`commerce`, `content-write`, `multi-tenant`, `scheduling`, `signal-collectors`, `workspace`) against `AILK_ENABLED_MODULES`, both in `src/config/modules.ts`. It is computed once in `build()` (at boot), before any module route registers, so it reports what registered rather than re-reading the env per request; filtering to the vocabulary keeps the anonymous index from echoing whatever an operator typed into the env var. `docs` is deliberately absent from the vocabulary: no apps/api route registers under it, so reporting it would break "modules this process registered routes for". `content-write` is reported only alongside `workspace` or `multi-tenant` — the predicate `server.ts` registers the editor's content routes under; it stays a literal there for the slice-registration lint, is repeated in `servedModules()`, and `src/__tests__/root-modules.test.ts` asserts the two agree. The Admin reads `modules` to hide the surfaces whose module is off; a failed read, or an older API with no `modules` field, hides nothing. No MCP tool mirrors this index.
40
+
37
41
  Routes are versioned under `/v1/*`:
38
42
 
39
43
  - `/v1/leads/{submit,subscribe,callback}` — lead capture, newsletter subscription, callback request. Where a lead goes is `src/lib/lead-delivery.ts` (#6730): `DATABASE_URL` set → the database (the inbox); no database but `RESEND_API_KEY` + `AILK_LEAD_NOTIFICATION_TO` + `AILK_LEAD_NOTIFICATION_FROM` → the owner email IS the delivery (`persisted: "email"`, a refused send is `503 delivery_failed`); neither outside production → the local file fallback; neither in production → `503 lead_capture_not_configured` before any write. A newsletter signup needs a database (or the local file). A lead is never reported captured when nothing stored or delivered it.
@@ -42,27 +46,31 @@ Routes are versioned under `/v1/*`:
42
46
  - `/v1/flows/:slug` (GET), `/v1/flows/:slug/steps` (POST) — serve a flow config + submit a step's answers; server-side per-step partial-state persistence (#3956). The GET serves the stored config **minus `completionEmail`** (#7519 D5, SEC-7519-I1): `serveFlow` projects the owner's completion-email copy out, while completion reads the stored snapshot and sends it (`sendCompletionEmail`, for a flow with no `deliverables`). Module: `signal-collectors` (D-48, #5366) — registers only when the module is enabled; MCP twins `get_flow`/`submit_flow_step` carry the same `module` tag (D7).
43
47
  - `/v1/flows/:slug/definition` (PUT) — a registered spoke seeds one of its declared `flows/<slug>.json` into its OWN workspace (#7876, `src/routes/flows/index.ts` over `services/flow-engine.ts`'s `seedDeclaredFlow`). No MCP twin — recorded exception: a seed is a deploy-time write by the site's owner, and a spoke's `apps/mcp` acts through the same key for every anonymous agent, so a twin would give each of them write access to the site's flows. Off until the hub's operator sets `AILK_SPOKE_FLOW_SEED` (`403 flow_seed_disabled`, answered before the key or the database is read). The workspace comes from the `x-ailk-spoke-key` alone, through `resolveCallerSite`: a keyless call is `403 spoke_key_required` on any deployment, and a refused key `503 tenant_unresolved`. The body is a flow config of `slug`, `defaultLocale`, `steps`, `thankYou` and `flags` only — every block the hub acts on outside the spoke's own pages (emails, deliverables, pricing, registers, listings) is `400 field_not_seedable`; a slug a listing owns, or a stored config the Admin extended with one of those blocks, is `409 slug_owned`, so a seed never deletes them. A config equal to the stored one (keys sorted) answers `unchanged` and writes nothing; otherwise `created` or `updated` through `upsertFlowDefinition`, so `kind`, `title` and `status` are written on create only. Route-level `bodyLimit` 256 KiB; the config is never echoed or logged. Module: `signal-collectors`.
44
48
  - `/v1/flows/:slug/aggregate` (GET) — anonymous, site-scoped per-question response counts across all completed respondents for the results reveal (#4771): respondents are completed `FlowSession` rows (not `Lead` rows — a recommending-mode respondent has no Lead until the email moment), question metadata + the chart flag come from the `WaitlistExperiment` register, and only `resultsReveal`-flagged `single`/`multi` questions are returned (`src/services/flow-aggregate.ts`). No individual answer, email, name, session id, or score in the response; no auth. Module: `signal-collectors`. No MCP twin — recorded exception (an agent walking a flow already gets everything it needs from `submit_flow_step`'s own response; "how everyone else answered" changes none of it).
45
- - `/v1/deliverables/:grantId` (GET) — the capability-token download route for gated PDFs and reports, including client deliverables (#3961 D5); the token itself is the sole gate, no auth preHandler applies (`src/routes/deliverables/index.ts`). Module: `signal-collectors` (D-48, #5366). No MCP twin — identity-bound capability-link download (#3591 exception, the same shape as `/v1/waitlist-signups/me`). **Three grant classes since #6310**, keyed on `grant.assetId`'s ADR-0031 prefix: `playbook:` (unchanged, the FEAT-074 U4b virtual asset); `asset_` — a workspace `Asset`, resolved through the named boundary `src/lib/deliverable-asset-store.ts`, scoped by `grant.workspaceId` (a serve-time tenancy re-check restored for this class — D15) — served `attachment` with an extension pinned to the stored content type, `nosniff`, a non-raster `Content-Security-Policy` (for EVERY content type, including raster — never `inline`), and `Cache-Control: private, no-store` so a deleted asset stops downloading immediately; `dasset_` — the LEGACY `DeliverableAsset` store (unchanged, tried only when a magnet's `assetKey` does not resolve against `Asset`). Every miss on the `asset_` class — a NULL `workspaceId`, no row, a foreign-workspace row — folds into the SAME uniform 404 as every other class; there is deliberately no redirect to the public `GET /v1/assets/:assetId` (that route's year-long `immutable` cache would defeat deletion as a revocation lever, and would collapse every lead's capability onto one workspace-canonical id — spec #6310 §9 SEC-001/SEC-002).
49
+ - `/v1/deliverables/:grantId` (GET) — the capability-token download route for gated PDFs and reports, including client deliverables (#3961 D5); the token itself is the sole gate, no auth preHandler applies (`src/routes/deliverables/index.ts`). Module: `signal-collectors` (D-48, #5366). No MCP twin — identity-bound capability-link download (#3591 exception, the same shape as `/v1/waitlist-signups/me`). **Three grant classes since #6310**, keyed on `grant.assetId`'s ADR-0031 prefix: `playbook:` (unchanged, the FEAT-074 U4b virtual asset); `asset_` — a workspace `Asset`, resolved through the named boundary `src/lib/deliverable-asset-store.ts`, scoped by `grant.workspaceId` (a serve-time tenancy re-check restored for this class — D15) — served `attachment` with an extension pinned to the stored content type, `nosniff`, a non-raster `Content-Security-Policy` (for EVERY content type, including raster — never `inline`), and `Cache-Control: private, no-store` so a deleted asset stops downloading immediately; `dasset_` — the LEGACY `DeliverableAsset` store (unchanged, tried only when a magnet's `assetKey` does not resolve against `Asset`). A client's file — tagged `private`, attached to a member-area message, or (#8171 D3) named by a delivered piece version — is never minted or served as a deliverable. Every miss on the `asset_` class — a NULL `workspaceId`, no row, a foreign-workspace row — folds into the SAME uniform 404 as every other class; there is deliberately no redirect to the public `GET /v1/assets/:assetId` (that route's year-long `immutable` cache would defeat deletion as a revocation lever, and would collapse every lead's capability onto one workspace-canonical id — spec #6310 §9 SEC-001/SEC-002).
46
50
  - `/v1/flows/:slug/save` (POST), `/v1/flows/:slug/checkout` (POST) — the recommender's email moment (#4841): `save` creates the FlowSession + Lead (+ the T+0 DeliverableGrant) for the permalink's answers; `checkout` does the same, records the `FlowCheckout` buyer row at a SERVER-recomputed `price()` (the body carries no amount — `flowCheckoutInputSchema` is strict), and opens a Stripe TEST-mode Checkout Session whose metadata carries the permalink + offer key (`src/routes/flows/recommender.ts`, `src/services/recommender-capture.ts`, `packages/stripe-client`'s `createQuotedCheckoutSession`). Anonymous, rate-limited; a non-`sk_test_` key is refused before any side effect. No MCP twin — recorded exception: an agent walking a flow has no email to give and no card to pay with; the anonymous MCP surface is identity-blind.
47
- - `/v1/workspaces/:workspaceId/magnets` (GET, POST), `/v1/workspaces/:workspaceId/magnets/:id` (GET, PATCH), `/v1/workspaces/:workspaceId/magnets/:id/signups` (GET), `/v1/workspaces/:workspaceId/magnets/:id/subscribers` (GET), `/v1/workspaces/:workspaceId/magnets/:id/messages` (GET) — the tenant-scoped magnet Admin authoring surface over the flow engine's `FlowDefinition` rows (#6157 S5c, `src/routes/magnets/index.ts`): list (cursor-paginated, fixed 50-row page), create, get, update, and the three per-magnet captured-lead reads (#6246 S6h). Reads and writes only through `tenantDb(tenant).flows` — never `prisma.flowDefinition`, `currentSiteId()`, or `upsertFlowDefinition` (the PUBLIC/seed path below, which the admin path never calls). No DELETE — the reversible alternative is `status: "draft"` (#6156), which stops a NEW walk from starting while an in-flight walk finishes on its pinned snapshot. `slug` lives only inside `config.slug` and is immutable through this surface after create (`400 slug_immutable` on a mismatched PATCH). The two operator-writable config URLs (`abandonEmail.resumeUrl`, `followUps[].cta.href`) pass an exact-origin gate against `FRONTEND_URL` before any write, the same discipline the recommender's checkout redirect already applies (#4912). Role floors from `@working-theory/validation`'s permission table (#7636 D2) (D5): list and get serve the full row to `magnets-manage` (owner, admin) and, to a caller holding only `magnets-read` (a member), a LIVE-only summary of exactly `{ id, slug, kind, title, landingPage }` — a member's list is read to completion (at most 20 pages of 50, a `warn` at the cap) and answers `nextCursor: null`, a member's `?cursor=` is a 400, and a draft's get is the same 404 as an absent id — because a magnet's `config` carries `scoring` thresholds, `offers`/`rateTable` and `deliverables` asset keys and no draft is a member's; POST and PATCH need `magnets-manage`; the per-magnet captured-people lists need `inbox-read` (every role), and for a caller without `magnets-manage` a magnet that is not live is the same 404. Both writes declare a route-level `bodyLimit` (256 KiB) and each records one `recordAgentAction` audit row. Module: `multi-tenant || signal-collectors`, the same gate the review-surface dashboard (`/v1/waitlist-experiments/*`, `/v1/project-listings/*`) already registers under. No MCP twin on any of the seven — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes); the public `/v1/flows/*` routes and their two MCP tools are unchanged, pinned by `src/routes/flows/__tests__/parity.test.ts`. **The three new list routes (#6246 S6h)** join every magnet through `Lead.source = 'flow:' + <the resolved magnet's slug>` — the literal string `flow-engine.ts`'s `completeSubmission` stamps on every flow-engine `Lead` row regardless of kind, including `newsletter`/`waitlist` (`Subscriber` is written only by the separate, pre-flow-engine `subscribe_newsletter` path, unrelated to a magnet) — via ONE new `tenantDb(tenant).leads.listBySource(source, { limit })` accessor, an exact-source-equality sibling of #6204's allowlist-scoped `listUnhandledMessages`, served by a new `(workspaceId, source)` composite index on `Lead`. `/signups` expects `kind: "waitlist"`, `/subscribers` expects `"newsletter"`, `/messages` expects `"contact"`; a magnet id absent from the caller's workspace OR resolved to the WRONG kind for the called route is `404 not_found`, indistinguishably (mirrors the existing GET's absent-or-foreign fold). `/signups` and `/subscribers` serve four keys (`id`, `name`, `email`, `createdAt`); `/messages` serves those four plus `handledAt`. Bounded `?limit=` (default 50, max 200), newest-first — mirrors `workspaces/messages.ts`'s own query schema. Applications (Listicle) is deliberately OUT of this surface — `ProjectListing` carries no reverse link to the listicle-kind `FlowDefinition` that captured it, so "scoped to that magnet" cannot be honored without a schema change (split to #6314).
51
+ - `/v1/workspaces/:workspaceId/magnets` (GET, POST), `/v1/workspaces/:workspaceId/magnets/:id` (GET, PATCH), `/v1/workspaces/:workspaceId/magnets/:id/signups` (GET), `/v1/workspaces/:workspaceId/magnets/:id/subscribers` (GET), `/v1/workspaces/:workspaceId/magnets/:id/messages` (GET) — the tenant-scoped magnet Admin authoring surface over the flow engine's `FlowDefinition` rows (#6157 S5c, `src/routes/magnets/index.ts`): list (cursor-paginated, fixed 50-row page), create, get, update, and the three per-magnet captured-lead reads (#6246 S6h). Reads and writes only through `tenantDb(tenant).flows` — never `prisma.flowDefinition`, `currentSiteId()`, or `upsertFlowDefinition` (the PUBLIC/seed path below, which the admin path never calls). No DELETE — the reversible alternative is `status: "draft"` (#6156), which stops a NEW walk from starting while an in-flight walk finishes on its pinned snapshot. `slug` lives only inside `config.slug` and is immutable through this surface after create (`400 slug_immutable` on a mismatched PATCH). The two operator-writable config URLs (`abandonEmail.resumeUrl`, `followUps[].cta.href`) pass an exact-origin gate against `FRONTEND_URL` before any write, the same discipline the recommender's checkout redirect already applies (#4912). Role floors from `@working-theory/validation`'s permission table (#7636 D2) (D5): list and get serve the full row to `magnets-manage` (owner, admin) and, to a caller holding only `magnets-read` (a member), a LIVE-only summary of exactly `{ id, slug, kind, title, landingPage }` — a member's list is read to completion (at most 20 pages of 50, a `warn` at the cap) and answers `nextCursor: null`, a member's `?cursor=` is a 400, and a draft's get is the same 404 as an absent id — because a magnet's `config` carries `scoring` thresholds, `offers`/`rateTable` and `deliverables` asset keys and no draft is a member's; POST and PATCH need `magnets-manage`; the per-magnet captured-people lists need `inbox-read` (every role), and for a caller without `magnets-manage` a magnet that is not live is the same 404. Both writes declare a route-level `bodyLimit` (256 KiB) and each records one `recordAgentAction` audit row. **`customersEnabled` (#8171 D6)** — the member-area customer switch — is served on every full row and written by the PATCH (a PATCH of it alone skips the plan check); the member summary row never carries it. Module: `multi-tenant || signal-collectors`, the same gate the review-surface dashboard (`/v1/waitlist-experiments/*`, `/v1/project-listings/*`) already registers under. No MCP twin on any of the seven — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes); the public `/v1/flows/*` routes and their two MCP tools are unchanged, pinned by `src/routes/flows/__tests__/parity.test.ts`. **The three new list routes (#6246 S6h)** join every magnet through `Lead.source = 'flow:' + <the resolved magnet's slug>` — the literal string `flow-engine.ts`'s `completeSubmission` stamps on every flow-engine `Lead` row regardless of kind, including `newsletter`/`waitlist` (`Subscriber` is written only by the separate, pre-flow-engine `subscribe_newsletter` path, unrelated to a magnet) — via ONE new `tenantDb(tenant).leads.listBySource(source, { limit })` accessor, an exact-source-equality sibling of #6204's allowlist-scoped `listUnhandledMessages`, served by a new `(workspaceId, source)` composite index on `Lead`. `/signups` expects `kind: "waitlist"`, `/subscribers` expects `"newsletter"`, `/messages` expects `"contact"`; a magnet id absent from the caller's workspace OR resolved to the WRONG kind for the called route is `404 not_found`, indistinguishably (mirrors the existing GET's absent-or-foreign fold). `/signups` and `/subscribers` serve four keys (`id`, `name`, `email`, `createdAt`); `/messages` serves those four plus `handledAt`. Bounded `?limit=` (default 50, max 200), newest-first — mirrors `workspaces/messages.ts`'s own query schema. Applications (Listicle) is deliberately OUT of this surface — `ProjectListing` carries no reverse link to the listicle-kind `FlowDefinition` that captured it, so "scoped to that magnet" cannot be honored without a schema change (split to #6314).
48
52
  - `/v1/content/{types,:type,:type/:slug}` — public content reads
49
53
  - `/v1/auth/tokens`, `/v1/user/{profile,data/:key}` — local auth + user data
50
- - `/v1/hub-sign-in/codes` (POST), `/v1/hub-sign-in/token` (POST), `/v1/hub-sign-in/revoke` (POST), `/v1/hub-sign-in/me` (GET) — the hub signs in its spokes (#7894, `src/routes/auth/hub-sign-in.ts` over `src/services/hub-sign-in.ts`; spec `docs/specs/7894-the-hub-signs-in-its-spec.md`): an authorization code with PKCE between a spoke (a confidential client whose `OAuthClient.clientId` IS its `Spoke.id`) and this hub. `codes` is in `authScope` and answers a COOKIE session only (a personal `ApiToken` is 403, an agent key 403 at the preHandler, a hub credential 404): it mints a single-use 60 s code only for an email-verified person and a callback in the client's registered `redirectUris` (exact match), 30 a user per 10 minutes. `token` and `revoke` are in their OWN scope outside `authScope` (per-IP `rateLimitMiddleware`, scope `hubsignin`, 60/min; plus a per-client bucket of 120 exchanges a minute keyed on the VERIFIED client): the caller authenticates with `Authorization: Basic clientId:secret` AND `x-ailk-spoke-key`, which must resolve to the unrevoked spoke whose id is that client — either alone is `401 invalid_client`. `token` consumes the code first and exactly once, then checks the client, the callback and the PKCE verifier (`400 invalid_grant` for any failure, the code gone either way) and issues `ailk_hst_…`, a 24 h credential stored only as a SHA-256 digest in `OAuthAuthorization` (one row per person and spoke, so a new sign-in rotates it); it writes no `Session` row and sets no cookie. `revoke` ends a credential or burns an unexpired code of that client (`{ revoked }`, never an error). `me` answers the credential's person, read live, and is reachable only by that credential. **The credential is admitted by one branch of `middleware/auth.ts`** — only beside the same spoke's key, only while its grant is unrevoked and unexpired, its client enabled and its person's email verified, and only on `HUB_SESSION_ROUTES`, an exact list of four method + URL pairs (`GET /v1/hub-sign-in/me` and #7877's `GET /v1/member/{pieces,messages,messages/:id/file}`); every other route answers `404` with no handler run (the status the account area's `read()` loads as "nothing for this caller"), and the list is extended only by a reviewed edit. A completed password reset (`apps/web/auth.ts` `onPasswordReset`) revokes every credential of that person. The operator registers a spoke's client with `spokes sign-in-client --id <spoke_id> --redirect-uri <url>` (`bin/spokes.ts`; `--disable` switches it off) — `lib/hub-sign-in-store.ts` is the only file that names `prisma.oAuthClient` and `prisma.oAuthAuthorization`. No MCP twin for any of the four — a credential exchange between a site and its hub is not an agent-facing call (#3591 exception, the `/v1/auth/tokens` footing). The browser step lives on the hub's web app: `apps/web/app/api/auth/hub/authorize/route.ts`.
54
+ - `/v1/hub-sign-in/codes` (POST), `/v1/hub-sign-in/token` (POST), `/v1/hub-sign-in/revoke` (POST), `/v1/hub-sign-in/me` (GET) — the hub signs in its spokes (#7894, `src/routes/auth/hub-sign-in.ts` over `src/services/hub-sign-in.ts`; spec `docs/specs/7894-the-hub-signs-in-its-spec.md`): an authorization code with PKCE between a spoke (a confidential client whose `OAuthClient.clientId` IS its `Spoke.id`) and this hub. `codes` is in `authScope` and answers a COOKIE session only (a personal `ApiToken` is 403, an agent key 403 at the preHandler, a hub credential 404): it mints a single-use 60 s code only for an email-verified person and a callback in the client's registered `redirectUris` (exact match), 30 a user per 10 minutes. `token` and `revoke` are in their OWN scope outside `authScope` (per-IP `rateLimitMiddleware`, scope `hubsignin`, 60/min; plus a per-client bucket of 120 exchanges a minute keyed on the VERIFIED client): the caller authenticates with `Authorization: Basic clientId:secret` AND `x-ailk-spoke-key`, which must resolve to the unrevoked spoke whose id is that client — either alone is `401 invalid_client`. `token` consumes the code first and exactly once, then checks the client, the callback and the PKCE verifier (`400 invalid_grant` for any failure, the code gone either way) and issues `ailk_hst_…`, a 24 h credential stored only as a SHA-256 digest in `OAuthAuthorization` (one row per person and spoke, so a new sign-in rotates it); it writes no `Session` row and sets no cookie. `revoke` ends a credential or burns an unexpired code of that client (`{ revoked }`, never an error). `me` answers the credential's person, read live, and is reachable only by that credential. **The credential is admitted by one branch of `middleware/auth.ts`** — only beside the same spoke's key, only while its grant is unrevoked and unexpired, its client enabled and its person's email verified, and only on `HUB_SESSION_ROUTES`, an exact list of 18 method + registered-URL strings, never a prefix (#8220): `GET /v1/hub-sign-in/me`, #7877's three member reads, #8169's eight member reads and #8170's six member writes; every other route answers `404` with no handler run (the status the account area's `read()` loads as "nothing for this caller"), and the list is extended only by a reviewed edit. **The per-person budget (#8220 D2, SEC-8169-4):** each person at each spoke gets 60 requests and 10 writes a minute and 30 writes an hour (a fixed hour window from the first admitted write, so up to twice that across a window edge), keyed on the VERIFIED spoke and person and charged only after admission (a refused credential or an off-list route never gets a bucket); over budget is `429 rate_limited` with `Retry-After`. The write limits refuse only writes, and a refused request is not counted. Every line the request logger writes for an admitted request after admission carries `spokeId` (never the key or the credential); a helper logging through the module-level `logger` carries no request binding. The server-wide per-IP limiter still counts all of one spoke's members together — a named residual whose follow-up (the visitor forward key on member calls) must land before Phase 3 (#8163) ships to a live spoke. A bearer write skips the origin check, so the cross-site-request protection for the member writes is the spoke's: its server actions that send a sealed credential carry it (Phase 3, #8163; SEC-8220-11). A completed password reset (`apps/web/auth.ts` `onPasswordReset`) revokes every credential of that person. The operator registers a spoke's client with `spokes sign-in-client --id <spoke_id> --redirect-uri <url>` (`bin/spokes.ts`; `--disable` switches it off) — `lib/hub-sign-in-store.ts` is the only file that names `prisma.oAuthClient` and `prisma.oAuthAuthorization`. No MCP twin for any of the four — a credential exchange between a site and its hub is not an agent-facing call (#3591 exception, the `/v1/auth/tokens` footing). The browser step lives on the hub's web app: `apps/web/app/api/auth/hub/authorize/route.ts`.
51
55
  - `/v1/checkout/sessions` — start a Stripe checkout
52
56
  - `/v1/workspaces/:workspaceId/{theme,identity,seo}` (GET/PUT) — per-workspace site config (multi-tenant module). PUT needs `workspace-settings` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)), checked before the body parse; GET stays open to every member.
53
- - `/v1/workspaces/:workspaceId/meetings` (GET/PUT) — the operator's enabled-slug gate for meeting types (#6183 S9b, `src/routes/workspaces/meetings.ts`). Beside the three siblings above in URL shape and tenant-isolation posture, but registered in its OWN scope, gated on `isModuleEnabled("scheduling")` ALONE — not the `workspace`/`multi-tenant` scope its siblings share, because its GET reaches `services/scheduling/` (see below), which a scaffold without that module does not carry (`tools/lint-slice-registration.ts`, #5701). PUT needs `meeting-types-manage` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)) because this blob decides which pages the public site serves. GET answers `meeting-types-manage` with the full join and `meeting-types-read` (every role, a member included) with the ENABLED rows only (#7636 D6) — since it does NOT just return the stored blob, it echoes the deployment's whole provider catalog, including the rows the operator chose not to publish (security review, 2026-09-19), which a member never sees. GET is the one segment in this file's family that does NOT just return the stored blob: it ALSO lists the scheduling provider's own bookable event types (`listEventTypes`, #6182), joined with `enabled: boolean`; an unconfigured/unreachable provider answers the same `SchedulingErrorKind` outcome `/v1/schedule/*` uses, distinct from an empty list. No MCP twin — identity-bound tenant route (#3591 exception).
57
+ - `/v1/workspaces/:workspaceId/meetings` (GET/PUT) — the operator's enabled-slug gate for meeting types (#6183 S9b, `src/routes/workspaces/meetings.ts`). Beside the three siblings above in URL shape and tenant-isolation posture, but registered in its OWN scope, gated on `isModuleEnabled("scheduling")` ALONE — not the `workspace`/`multi-tenant` scope its siblings share, because its GET reaches `services/scheduling/` (see below), which a scaffold without that module does not carry (`tools/lint-slice-registration.ts`, #5701). PUT needs `meeting-types-manage` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)) because this blob decides which pages the public site serves. GET answers `meeting-types-manage` with the full join and `meeting-types-read` (every role, a member included) with the ENABLED rows only (#7636 D6) — since it does NOT just return the stored blob, it echoes the deployment's whole provider catalog, including the rows the operator chose not to publish (security review, 2026-09-19), which a member never sees. GET is the one segment in this file's family that does NOT just return the stored blob: it ALSO lists the scheduling provider's own bookable event types (`listEventTypes`, #6182), joined with `enabled: boolean`; an unconfigured/unreachable provider answers the same `SchedulingErrorKind` outcome `/v1/schedule/*` uses, distinct from an empty list. **`customerSlugs` (#8171 D5)** — who may book from the member area, independent of `enabledSlugs`: `GET ?include=customers` (exactly that value) adds `customer: boolean` per row, and without it the body is unchanged; the PUT carries `customerSlugs`, and a PUT without it keeps the stored list (a read then a write, last-writer-wins), so the public toggle never erases it. `enabledSlugs` keeps its whole-list meaning, so a writer of `customerSlugs` sends the `enabledSlugs` it read. An agent's PUT is audited `meetings.set_customers` when it carries `customerSlugs`, else `meetings.set_enabled`. No MCP twin — identity-bound tenant route (#3591 exception).
54
58
  - `/v1/workspaces/:workspaceId/meetings/connection` (GET, PUT, DELETE) — the workspace's own scheduling connection (#7533, `src/routes/workspaces/meetings.ts`; data access and the cipher in `src/services/scheduling/connections.ts`, the only file that touches `prisma.schedulingConnection`). Same registration, scope and tenant guard as the meetings route above. GET, PUT and DELETE all need `scheduling-connection` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2), the operator's 2026-10-04 decision overturning SEC-7533-11's owner-only write). GET answers `null` or a summary — `provider`, `baseUrl`, `webhookPath`, `createdAt`, `updatedAt` — with no credential, secret, ciphertext or hint of one. PUT takes a strict `{ provider, apiKey, baseUrl? }`; create and replace are one operation, and every PUT mints a new webhook token and secret, returning the secret this once (`Cache-Control: no-store`) so the operator can paste it into the provider — a replace cuts off the previous entry point. `baseUrl` must be `https://api.cal.com` or the deployment's own `CAL_API_BASE_URL` origin, with no path, query or userinfo (`400 base_url_not_allowed`); only the bare origin is stored. The credential and the secret are AES-256-GCM ciphertext under `SCHEDULING_CREDENTIAL_KEY`, bound to the workspace and the column; with the key unset, PUT answers `503 credential_key_not_configured` before any write. DELETE is idempotent (`{ revoked }`) and unconfigures the workspace at once. Because a connection replaces the account every provider call uses, every successful PUT, and every DELETE that revoked a row, writes one `recordAgentAction` audit row (`scheduling.connection.{connect,replace,remove}`; a PUT is `replace` whenever the workspace was served by any provider before it — its own row, or the env credential on a one-workspace deployment) and emails every OTHER owner of the organization through `@working-theory/email`'s `sendSchedulingConnectionChangedEmail` (#7636 D7). Neither step can fail the write: a failed audit is logged at `error`, and the notify step runs in its own `try`, so the one-time secret is always returned; with no sender address or no Resend key it is skipped with a `warn`. Logs carry the recipient count, never an address. The meetings GET above, and the three anonymous `/v1/schedule/*` provider routes below, resolve their provider through `src/services/scheduling/resolve.ts`: a workspace's own row always wins; `CAL_API_KEY` serves a workspace only on a one-workspace deployment; otherwise `not_configured`. No MCP twin — identity-bound tenant routes (#3591 exception).
55
59
  - `/v1/workspaces/:workspaceId/general` (GET/PUT) — the workspace's own display name, the Pro `workspace` module's general-settings surface (#5371). Registered under `isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")` alongside the site-config routes above, behind the same tenant guard; the scope is always `request.tenant.workspaceId`, never the path param (the guard 403s a mismatch before any handler runs). PUT takes a strict `{ name }` body (1–200 chars, trimmed) and answers `{ name }`; GET answers `{ name, emailFrom }` (#7519 D6), where `emailFrom` is the deployment's sender (`deliverableSenderAddress()` — `AILK_DELIVERABLE_EMAIL_FROM ?? AILK_LEAD_NOTIFICATION_FROM`, the resolver the fulfillment service sends with) or `null` when neither is set, so the Admin's magnet wizard can show it read-only. Role floor on PUT: `workspace-settings` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)) — renaming is an org-level settings write; GET stays open to every member. No MCP twin — identity-bound tenant route (#3591 exception, same shape as `/v1/billing/*`).
56
60
  - `/v1/workspaces/:workspaceId/messages` (GET, `?limit=`), `/v1/workspaces/:workspaceId/messages/:leadId` (GET, PATCH) — the Inbox message API (#6216 S7e, `src/routes/workspaces/messages.ts`): the workspace's unhandled messages newest first, the item read (resolves a message handled or not, serving `handledAt`), and the one-way idempotent handled stamp over #6204's `markMessageHandled`. Registered under `isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")`, the same scope as the two routes above. A message is a `Lead` whose `source` is in #6204's closed `MESSAGE_LEAD_SOURCE_LIST` allowlist — checked only in `tenantDb(tenant).leads`, never in this file; a non-message lead is a 404 on the item read and the PATCH and absent from the list, the SAME 404 body as an absent id, another workspace's id, or an unbackfilled `workspaceId`. **One served row across all three routes** — `id`, `name`, `email`, `source`, `createdAt`, `handledAt` — and nothing else: no `payload`, no message body (operator ruling, 2026-09-19; `leads-inbox.ts`'s PII boundary is unchanged, not widened). PATCH takes `{ "handled": true }` only, `.strict()`; there is no un-handle path (#6204 D4), so `{ "handled": false }` is a 400. The stamp records the actor via `tenant.userId` in `Lead.handledBy` — written, not served. Role floor: `inbox-read` on the GETs and `inbox-act` on PATCH and accept (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included), the SAME rows `services/inbox-messages.ts` re-checks, checked before any read or write. The role is not self-servable: since #7523 `multi-tenant`'s null-binding fallback grants the default tenant only to the pinned, verified operator, the same check the base `workspace`-only guard applies (see the `AILK_OPERATOR_EMAIL` paragraph below). No anonymous MCP twin — identity-bound tenant routes, and the anonymous `/mcp` carries no caller identity. Each route's twin is a signed-in tool on `/mcp/workspace` (#3591 D13), which forwards the caller's agent key here: `list_inbox_messages` (list), `get_inbox_message` (item read), `mark_inbox_message_done` (PATCH) and `accept_inbox_message` (`POST …/:leadId/accept`).
57
61
  - `/v1/workspaces/:workspaceId/submissions` (GET, `?limit=&state=unhandled&magnetId=`), `/v1/workspaces/:workspaceId/submissions/:leadId` (GET, PATCH) — the Inbox submissions API (#7535, `src/routes/workspaces/submissions.ts` over `src/services/inbox-submissions.ts`): every flow completion and waitlist signup of the caller's active workspace, newest first and bounded (`?limit=` 1–200, default 50), the item read (handled or not), and the one-way idempotent handled stamp (`{ "handled": true }` only, `.strict()`; a second PATCH returns the first `handledAt`). Same registration, scope and tenant guard as the messages bullet above. A submission is a `Lead` whose `source` is a fixed submission source (`SUBMISSION_FIXED_SOURCE_LIST` in `@working-theory/validation`) or `flow:<slug>` for a NON-newsletter magnet of THIS workspace — decided only in `tenantDb(tenant).leads`, never in the route; a `flow:` row naming no magnet of the workspace never lists. The list row is seven keys — the message row's six plus `magnet: { slug, title } | null` — and never `payload`. `?magnetId=` narrows to one magnet (limit 1–50) and adds `answers`, `answersTruncated` and `deliverableSentAt` per row; the item read serves the same three. `answers` is `payload` narrowed by `toAnswers`: server-owned keys dropped, control and bidi characters stripped, every key, value and the entry count bounded. `?state=unhandled` filters `handledAt IS NULL` in the query (the Admin's For-you read); any other `state` is a 400. Every miss on the item read and the PATCH — a malformed, absent or foreign id, a message, an application, a newsletter-magnet lead — is the SAME `404 No such submission`; a `?magnetId=` that is absent, foreign or a newsletter magnet is `404 No such magnet`. Every success body is re-parsed against its own `.strict()` schema before it is sent. Role floor: `inbox-read` on the GETs and `inbox-act` on PATCH (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included), checked before any read or write. The handlers and the service never log a lead id, email, name or answer. No anonymous MCP twin — identity-bound tenant routes. Each route's twin is a signed-in tool on `/mcp/workspace` (#3591 D13): `list_inbox_submissions` (list), `get_inbox_submission` (item read) and `mark_inbox_submission_done` (PATCH).
58
62
  - `/v1/workspaces/:workspaceId/subscribers` (GET, `?limit=`) — the workspace's newsletter signups, read-only (#7535, `src/routes/workspaces/subscribers.ts`): `{ count, items }`, where `count` is every signup and `items` the newest (1–200, default 50), each `{ id, email, source, subscribedAt, magnet: { slug, title } | null }`. A signup is a `Subscriber` row of this workspace or the `Lead` a newsletter-kind magnet of this workspace wrote, both from `tenantDb(tenant).subscribers`, called directly. No item route and no write: a subscriber is never an item to open or mark handled. Same registration, scope, tenant guard and `inbox-read` floor as the submissions bullet above. No MCP twin — identity-bound tenant route (#3591 exception).
59
- - `/v1/workspaces/:workspaceId/people` (GET, `?limit=&cursor=`), `/v1/workspaces/:workspaceId/people/:personId` (GET) — the workspace's clients (#852, `src/routes/workspaces/people.ts` over `tenantDb(tenant).people`; the Person writer is `src/services/person.ts`). A **Person** is one client of one workspace, keyed on a lowercased, trimmed email unique per workspace and created only by `findOrCreatePerson` — called by lead capture, the hub's own Stripe checkout webhook (a live-mode event's `session.customer` only), appointment creation and the inbox accept; `Person.userId` is stamped only on a verified-email match (`stampPersonUser`), never overwritten. The list serves `{ items, nextCursor }` — five keys per row (`id`, `email`, `name`, `planOffering`, `createdAt`), newest first, `limit` 1–200 (default 50); a cursor that does not resolve to a Person of this workspace is `400 invalid_cursor`. The record serves `{ person, timeline, timelineTruncated }`: the Person (`userId` projected to `hasAccount`, never served; the billing pair served) over a timeline derived at read time — leads and appointments by `personId`, flow sessions, checkouts, deliverable grants and project listings by the Person's newest 200 lead ids, plus an exact-normalized-email fallback for lead-less flow sessions and listings older than the Person backfill, and (since #7877) the Person's member-area messages as `message` entries carrying the subject, `hasFile` and `readAt` only — newest first, at most 50 entries; a malformed, absent or foreign id is one `404 not_found`. Never selected or served: a grant id, a flow-session id, a checkout id, a lead `payload`, a permalink, a Stripe session id, an attendee email, a founder email. Role floor: `people-read` (owner, admin — `@working-theory/validation`'s permission table) via `allows`, checked before any read, so an agent key granted `people-read` (the sixth `AGENT_GRANTABLE_PERMISSIONS` entry, clamped to its person's role) reads both in its one workspace. An operator read only: a Person groups rows by an unverified email, so no client-facing read may key on that grouping (#7877's feed keys on a verified link). Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). No MCP twin, by decision — People MCP tools are a later phase of the CRM nucleus (#852 AC 6), recorded in `tests/parity/README.md`.
63
+ - `/v1/workspaces/:workspaceId/people` (GET, `?limit=&cursor=`), `/v1/workspaces/:workspaceId/people/:personId` (GET) — the workspace's clients (#852, `src/routes/workspaces/people.ts` over `tenantDb(tenant).people`; the Person writer is `src/services/person.ts`). A **Person** is one client of one workspace, keyed on a lowercased, trimmed email unique per workspace and created only by `findOrCreatePerson` — called by lead capture, the hub's own Stripe checkout webhook (a live-mode event's `session.customer` only), appointment creation and the inbox accept; `Person.userId` is stamped only on a verified-email match (`stampPersonUser`), never overwritten. The list serves `{ items, nextCursor }` — five keys per row (`id`, `email`, `name`, `planOffering`, `createdAt`), newest first, `limit` 1–200 (default 50); a cursor that does not resolve to a Person of this workspace is `400 invalid_cursor`. The record serves `{ person, timeline, timelineTruncated }`: the Person (`userId` projected to `hasAccount`, never served; the billing pair served) over a timeline derived at read time — leads and appointments by `personId`, flow sessions, checkouts, deliverable grants and project listings by the Person's newest 200 lead ids, plus an exact-normalized-email fallback for lead-less flow sessions and listings older than the Person backfill, (since #7877) the Person's member-area messages as `message` entries carrying the subject, `hasFile` and `readAt` only, and (since #8171 D4) the Person's inbound member rows as `request` entries (`requestKind`, `subject`) and the versions delivered to them as `version` entries (`pieceId`, `version`, `review`; `at` is `deliveredAt`) — keyed by `personId`, never the email grouping, and never a body, link, quote answer, note or review comment — newest first, at most 50 entries; a malformed, absent or foreign id is one `404 not_found`. Never selected or served: a grant id, a flow-session id, a checkout id, a lead `payload`, a permalink, a Stripe session id, an attendee email, a founder email. Role floor: `people-read` (owner, admin — `@working-theory/validation`'s permission table) via `allows`, checked before any read, so an agent key granted `people-read` (the sixth `AGENT_GRANTABLE_PERMISSIONS` entry, clamped to its person's role) reads both in its one workspace. An operator read only: a Person groups rows by an unverified email, so no client-facing read may key on that grouping (#7877's feed keys on a verified link). Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). No MCP twin, by decision — People MCP tools are a later phase of the CRM nucleus (#852 AC 6), recorded in `tests/parity/README.md`.
60
64
  - `/v1/workspaces/:workspaceId/people/:personId/pieces` (GET, POST), `…/people/:personId/pieces/:pieceId` (PATCH), `/v1/workspaces/:workspaceId/piece-stages` (GET, PUT), `…/people/:personId/plan` (PUT), `…/people/:personId/messages` (GET, POST) — the member area as an operator keeps it (#7877, `src/routes/workspaces/{pieces,plan,messages-out}.ts` over `tenantDb(tenant).memberArea`; spec `docs/specs/7877-the-member-area-contract-a-spec.md`). A **Piece** is one deliverable for one client: a title (1–200), a **stage** drawn from the workspace's OWN declared list (`WorkspacePieceStages`, one row per workspace; no row is the product default `requested, in-progress, in-review, delivered`, which lives only in `lib/tenant-db.ts`'s `DEFAULT_PIECE_STAGES` — there is no stage enum anywhere), a `needsClient` flag and `updatedBy`. The **plan** is `Person.planOffering` (#852's field — never a Plan model), written only by `services/person.ts`'s `setPersonPlanOffering`. A **message** is an `Interaction` on the `member` channel, direction `outbound`: a plain-text subject (1–200) and body (1–10000), both stripped of control and bidi characters (the body keeps `\n`, `\t`), and an optional attachment that must be an `Asset` of THIS workspace tagged `private` with a raster-image or PDF type (never SVG) — `400 asset_not_found` / `400 asset_not_attachable` otherwise. Both records reference a Person of the same workspace through a composite `(person_id, workspace_id)` foreign key. Role floors from `@working-theory/validation`'s permission table: the two list reads need `people-read`, the stage PUT `workspace-settings`, the stage GET the guard alone, each checked with `can(tenant.role, …)` before any read, so an agent's `people-read` grant does not reach pieces or message bodies; the four writes — piece POST and PATCH, plan PUT, message POST — need `member-pieces-manage` (owner, admin), checked with `allows(tenant, "member-pieces-manage")` before any read, so an agent key granted it (#7878, the seventh `AGENT_GRANTABLE_PERMISSIONS` entry) keeps them in its one workspace, attaching only an asset the message's own checks admit. `people-write` (owner, admin) is never agent-grantable and is no longer read by these routes — the Admin person page reads it to render its forms. A malformed, absent or foreign Person or piece is one `404 not_found`; a stage outside the declared list `400 invalid_stage`; a stage-list PUT that drops a stage a piece still holds `409 stage_in_use` with nothing written — piece writes and stage changes take one per-workspace advisory lock first, in one transaction. Lists hold at most 50 rows, newest first. Every write that reaches the store records one `recordAuditAction` row naming the actor (`people.piece.create`, `people.piece.update`, `people.stages.set`, `people.plan.set`, `people.message.create`); no audit summary or log line carries a title, subject, body or offering. The operator's message read never stamps `readAt`, and serves an attachment by its sanitized download name, never its asset id. **The checkout plan rule:** a `POST /v1/leads/submit` from a registered spoke (`viaSpoke`) whose `Idempotency-Key` is a LIVE Checkout Session id (`cs_live_…`), with `source: "web"`, `payload.page: "checkout"`, a string `payload.offering` and `payload.site`, and an `amountTotal` that is absent, `null` or positive, sets its Person's plan to the offering minus a leading `<site>_` (`services/lead-routing.ts`) — never on a replay, never refusing or changing the lead, and with no spoke change. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). No MCP twin, by decision — member-area MCP tools are a later phase of the CRM nucleus, recorded in `tests/parity/README.md`.
61
65
  - `/v1/member/pieces` (GET), `/v1/member/messages` (GET), `/v1/member/messages/:id/file` (GET) — the member reads: a client's OWN pieces and messages, served to the spoke that signed them in (#7877, `src/routes/member/{pieces,messages}.ts`). Registered in `authScope` only (auth, NOT tenant-guarded — a client holds no membership; an agent key is 403 at the preHandler) under the same `workspace || multi-tenant` predicate, at the exact URLs `HUB_SESSION_ROUTES` admits a hub credential on. Who and where come only from `services/member-area.ts`'s `resolveMember`, before any member read: the caller through `middleware/auth.ts`'s `memberCaller` — the hub's own cookie session or a hub credential, never a personal `ApiToken` (`401 { error: "unauthorized" }`); the workspace from the calling spoke's `x-ailk-spoke-key` (a keyless call only on a one-workspace deployment that is not a hub), and a hub credential must name that same workspace, else `503 tenant_unresolved`; an unverified email `403 email_unverified`. The Person is the one linked by `Person.userId`, else the one whose email is the caller's verified email, linked on that first read by `stampPersonUser`'s rule; no Person is `items: []`. Every read goes through the named boundary `src/lib/member-store.ts`, scoped by that resolved workspace AND Person: at most 50 rows, newest first; a message serves `{ id, subject, body, file: { filename, contentType } | null, createdAt, readAt }` — never an asset, creator, person or workspace id — and the first read stamps `readAt` ("shown to the client's spoke", not "read"; served as it stood before the stamp). The file read streams the attachment only when the message is the caller's own and its stored type is attachable — any other case is one `404 not_found` — `attachment` with a sanitized, extension-pinned name, `nosniff`, the sandbox `Content-Security-Policy` and `Cache-Control: private, no-store`. `@working-theory/api-client`'s `member.*` is the spoke's caller; a spoke that re-serves the bytes sends those five headers unchanged. No MCP twin, by decision — identity-bound client reads behind a hub credential; the anonymous MCP surface is identity-blind, recorded in `tests/parity/README.md`.
66
+ - `/v1/member/purchases`, `/v1/member/appointments`, `/v1/member/artifacts`, `/v1/member/artifacts/:pieceId/versions/:version/file`, `/v1/member/meeting-types`, `/v1/member/availability`, `/v1/member/request-form`, `/v1/member/billing` (all GET) — the Phase 1 member reads (#8169, `src/routes/member/{purchases,appointments,artifacts,meeting-types,availability,request-form,billing}.ts`; spec `docs/specs/8169-member-reads-spec.md`). Same caller seam, refusals and registration as the bullet above, each read through one `src/lib/member-store.ts` function, each success body re-parsed against its `@working-theory/validation` `member.ts` schema. All eight are in `HUB_SESSION_ROUTES` (#8220). Appointments, artifacts and the file read are scoped by the resolved workspace AND Person (≤ 50, never an attendee's name, email or time zone; never a version or asset id; the file read is one `404 not_found` for every miss, a version above the 32-bit column included, and sends `messages.ts`'s five headers). **Purchases and billing read at organization grain** (the operator's #8140 ruling): only Persons whose `userId` is ALREADY the caller, inside the calling workspace's organization — they link nothing, so a caller with no Person here still sees a linked Person elsewhere. That grain rests on one precondition nothing enforces (SEC-8169-2, stated at `lib/member-store.ts`'s `linkedPersonsInOrganization` and pinned by `src/__tests__/member-isolation.test.ts`): every workspace of one organization belongs to one owner, so a deployment must never put two businesses' workspaces in one organization. A purchase is a completed hub `FlowCheckout` whose lead carries its Person's own email, or (#8185) a `Lead` the hub marked `spokeCheckout` when it was written — a registered spoke's unrelayed (`x-ailk-relayed` absent), LIVE paid checkout, the #7877 plan rule's own predicate evaluated in `services/lead-routing.ts` before the create and never updated, served with the same Person-workspace and Person-email rules, its offering kept only if it is a plan-offering key (SEC-8169-6); an anonymous, relayed, test-mode or pre-#8185 lead is never marked and never served (no backfill), and `tests/architecture/idempotency-key-producers.test.ts` fails if any spoke-side file, MCP tool or client wrapper starts sending an `Idempotency-Key`. **Billing answers a hub credential only**: any other caller gets `404 not_found` before `resolveMember` and before any read, and no log line ever names a `cus_` id; a hub credential reads it at the organization grain, under that precondition. Meeting types and availability join the workspace's `customerSlugs` (independent of `enabledSlugs`) to the scheduling provider — a provider failure is its `SCHEDULING_ERROR_STATUS`, never an empty list; a slug outside `customerSlugs` is `404 meeting_type_not_offered` with no provider call — and register under `scheduling && (workspace || multi-tenant)`, because only the `scheduling` module delivers `services/scheduling/`. The request form is the `request-quote` contact row while `customersEnabled`, whatever its status, else `{ form: null }`. No MCP twin, by decision — the same reason, recorded in `tests/parity/README.md`.
67
+ - `/v1/member/requests` (POST), `/v1/member/messages` (POST), `/v1/member/bookings` (POST), `/v1/member/artifacts/:pieceId/review` (POST), `/v1/member/profile` (PATCH), `/v1/member/deletion-request` (POST) — the Phase 1 member writes (#8170, `src/routes/member/{requests,messages,bookings,artifact-review,profile,deletion-request}.ts`; spec `docs/specs/8170-member-writes-spec.md`). Same caller seam and refusals as the reads, and a cookie write must carry a trusted `Origin` (the preHandler's 401). Nothing in a body, path or query names a person, user or workspace; every body parses its `@working-theory/validation` `member.ts` schema (`400 invalid_input`, or `400 link_not_https` when a link is why) and every success body is re-parsed before it is sent. **Requests, messages and reviews are each ONE inbound `Interaction` on the caller's own Person** — channel `member`, direction `inbound`, `createdBy` the caller — and never a `Lead`: a request is checked against the `request-quote` magnet's CURRENT field steps (`400 answers_invalid` for a key the form does not ask, a missing required answer, an array answer or a failed field rule; `409 request_form_off` when the form is off or the caller has no Person) and stores `{ flowSlug, items }` with each label as the form shows it; a message stores `{ link }` when one is sent (`404 not_found` with no Person). **The cap:** every inbound write runs in one transaction whose first statement is the Person's advisory lock (`ailk:member-inbound:<personId>`), then counts the Person's inbound rows of the last hour — 30 or more is `429 too_many_requests` with nothing written. **The review** lands only on the piece's LATEST version (`409 version_not_latest`), only once per version and never after `Piece.approvedAt` is set (`409 review_closed`); a missing, foreign or approved piece is refused from one read before any lock, otherwise the Person lock then the workspace's piece lock (`lib/tenant-db.ts`'s `pieceStagesLockKey`, the key the operator's piece writes take), and it writes the version's `review`, `reviewComment`, `reviewedAt`, the piece's `approvedAt` on an approval and the `review` Interaction in that one transaction — never a title, stage, link, asset or note; asking for changes stays open on every new latest version (#8140 disposition 3). **The booking** books an offered `customerSlugs` meeting type at `<handle>/<slug>` as the caller (verified email, own name), refuses a Person holding 10 upcoming non-cancelled appointments `429` before any provider call, answers a provider failure through `SCHEDULING_ERROR_STATUS`, and binds a spoke caller's booking to its workspace as `/v1/schedule/book` does; it registers under `scheduling && (workspace || multi-tenant)`. **The profile** writes only the caller's own `User.name` / `User.image` (https only) and answers `{ name, image }`. **The deletion request** is filed through `routeLead` as a `web` message lead (kind `deletion`, the caller's verified email, no idempotency key) into `resolveSiteWorkspaceId()` — the deployment's own site workspace, so a spoke caller's request reaches the hub operator (`sentTo: "hub-operator"`), never the spoke's. Every data write goes through `src/lib/member-store.ts`; every write logs only `{ event, outcome }` and a `ref` of at most 8 characters (`services/member-area.ts`'s `memberWriteLog`), never a subject, body, answer, link, note, name, email or full id. All six are in `HUB_SESSION_ROUTES` (#8220): a spoke's deletion request reaches the hub operator, and a spoke's profile write changes the name and photo the hub's `me` answers. No MCP twin, by decision — identity-bound client writes, recorded in `tests/parity/README.md`.
68
+ - `/v1/workspaces/:workspaceId/requests` (GET, `?limit=`), `/v1/workspaces/:workspaceId/people/:personId/pieces/:pieceId/versions` (POST) — the provider routes (#8171, `src/routes/workspaces/{requests,pieces}.ts` over `tenantDb(tenant).memberArea`; spec `docs/specs/8171-provider-routes-the-workspace-requests-spec.md`). **The Requests read** lists the workspace's inbound member rows (#8170's messages, quote requests and reviews), newest first, `limit` 1–50 (default 50, no cursor), behind `inbox-read` (every role) checked with `can(tenant.role, …)`. It is tiered: a role holding `people-read` (owner, admin) gets the full row — the subject, body and the kind's own fields (a message's `link`, a quote's `items`, a review's `pieceId`, `version`, `review`); every other caller, every agent key included, gets `{ id, kind, person, createdAt }` and the content is never selected. A row whose payload fails its kind's schema, or whose Person is gone, is left out and counted in one `warn`. **Deliver a version** numbers version n+1 under the piece lock (`withPieceStagesLock`), behind `member-pieces-manage` through `allows` (an agent granted it delivers); the body is exactly one of an `https` link (`isHttpsLink`, else `400 link_not_https`) and a private attachable asset of this workspace (`400 asset_not_found` / `asset_not_attachable`), plus an optional note; `version`, `deliveredBy` and `deliveredAt` are the server's (a body naming one is `400 validation_failed`); a piece with `approvedAt` set is `409 piece_approved`; a malformed, absent or foreign Person or piece is `404 not_found`. There is no round count, limit or remaining count (#8140 disposition 3). It answers `201 { version: { id, pieceId, version, file, link, note, deliveredAt } }` — the file's download name, never an asset id or `deliveredBy` — and every call that reaches the store writes one `people.version.create` audit row. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). No MCP twin, by decision — member-area MCP tools are a later phase of the CRM nucleus, recorded in `tests/parity/README.md`.
62
69
  - `/v1/workspaces/:workspaceId/members` (GET) — the org's member roster (`userId`, `name`, `email`, `role`), the Pro `workspace` module's Team tab (#5371). Read-only by design: granting a Membership is the invite flow, which is its own spec with a mandatory security review (§2.3), so no POST/PUT/DELETE is routed at this path. Scoped to `request.tenant.organizationId` — Membership is org-level — server-resolved from the validated session. Role floor: `members-read` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)): the roster carries every member's email, and who the other members are is not a member's job. No MCP twin — identity-bound tenant route (#3591 exception).
63
70
  - `/v1/workspaces/:workspaceId/agents` (GET `?userId=`, POST), `/v1/workspaces/:workspaceId/agents/:agentId/key` (POST), `/v1/workspaces/:workspaceId/agents/:agentId` (PATCH `{ enabled }`), `PUT /v1/workspaces/:workspaceId/agents/:agentId/grants` (`{ grants }`, #7864), and `/v1/agent/context` (GET) — the agent seat (#3591, `src/routes/workspaces/agents.ts` over `src/lib/agent-store.ts`, `// oss: false`). An agent is a stored delegate of one person, bound to one workspace. The five management routes refuse an agent caller outright (403), check `agents-manage` (owner, admin) before any read, and scope every read and write to `tenant.organizationId`, never a body or query value; an agent of another organization is the same 404 as a missing one. Create returns 201 with the key shown once; re-key returns the new key once. Each successful create, re-key, switch and grant change writes an `agents.*` audit record. **Grants (#7864, `docs/specs/7864-agent-key-grants-spec.md`).** An agent acts at `member`, plus any of seven permissions an owner or admin granted it one by one — `content-edit`, `content-publish`, `assets-write`, `magnets-manage`, `meeting-types-manage`, `people-read` since #852, and `member-pieces-manage` since #7878 (`@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, held to that set by the `agents_grants_allowed` CHECK). The grant PUT replaces the whole set (an empty list removes every grant) and refuses a grant the agent's person's own role does not hold, `409 grant_exceeds_person`. The clamp is one function both tenant guards call (`services/default-tenant.ts`'s `agentTenant`): an agent holds a stored grant only while its person's role holds it, so a demoted person's agent keeps the row but not the permission. The routes for the seven check `allows(tenant, "<permission>")`, which reads the grants; every other check keeps `can(tenant.role, …)`, so no other owner or admin permission is reachable. Every agent write on content, assets and meeting types writes one `recordAgentWrite` row naming the agent and its person (magnets already did); a person's writes there stay unaudited. The member-area writes audit every caller through `recordAuditAction`, whose actor names an agent as the agent. No grant raises the ceiling. `/v1/agent/context` is the agent's own read, unchanged by grants, and needs no permission beyond the workspace guard. Same registration scope as the routes above (`workspace || multi-tenant`).
64
71
  - `/v1/workspaces/:workspaceId/content` (GET, POST), `/v1/workspaces/:workspaceId/content/:type/:slug` (GET, PATCH), `/v1/workspaces/:workspaceId/content/:type/:slug/{publish,unpublish}` (POST) — the tenant-guarded content editor's write surface (#5853, `src/routes/workspaces/content.ts`): list, create, read/save the draft tip (under `If-Match`), and publish/unpublish, all over `Artifact`/`ArtifactVersion`. `:type` is validated against the page-type registry (`z.enum(SCHEMA_ORG_TYPES)`) rather than accepted as a free string on every addressed operation — an unregistered type is a 400 before any lookup, since it is echoed back and passed to `notifyRevalidate` as the site's cache tag. Role floors from `@working-theory/validation`'s permission table (#7636 D2): publish and unpublish need `content-publish`, and create, read and save need `content-edit` (both owner, admin — an admin builds, a member operates). The list serves `content-edit` the editor's full view and a caller holding only `docs-read` (a member) a published-only projection (D4): draft rows dropped, every row `status: "published"`, `version: null`, `valid: true`, and a `published_with_draft` page's `type`/`tags` read from its PUBLISHED version — applied before every filter, so a member's `?status=draft` or a draft tip's `?tag=` matches nothing. **The GET list is a UNION of page artifacts and DOCUMENT assets** (#6093, D1/D9 — "one store, two views"): every row carries `tags: string[]` (never null, read from the draft tip's stored content for a page, from `Asset.tags` for a file) and a DERIVED `kind: "page" | "post" | "file"` (`BlogPosting` alone maps to `"post"`; an asset row is always `"file"`) — an optional `?tag=<slug>` filters the union AFTER it is built, so a file is returned under either its own tag or a shared one. The asset half of the union (`DOCUMENT_CONTENT_TYPES`, i.e. `application/pdf`) is skipped entirely when `locale`/`type` is supplied or `status` names anything but `"published"`, since a file row can never satisfy those. A foreign `:workspaceId` costs zero artifact **and** zero asset queries. No MCP twin — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes).
65
- - `/v1/workspaces/:workspaceId/assets` (GET, POST), `/v1/workspaces/:workspaceId/assets/:assetId` (PATCH, DELETE), `/v1/assets/:assetId` (GET, public/anonymous) — the workspace asset store (#5854 upload/read; #6093 list/PATCH/DELETE, `src/routes/assets/{index,upload,read,manage}.ts`). Upload accepts an allowlisted union of three named classes (`src/routes/assets/index.ts`): five RASTER images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`), one VECTOR image (`image/svg+xml`, admitted under the operator's 2026-09-18 `allow-svg-assets` ruling — issue #6093 comment `5731573595` — superseding #5854's original SVG exclusion) and one DOCUMENT type (`application/pdf`), all under the existing 20 MB cap; `?tags=<slug>[,<slug>…]` stores tags on the row. The public read serves a RASTER type `inline` exactly as before; every OTHER admitted type (SVG, PDF) is served `attachment`, with the filename's extension pinned to the stored content type (closing extension spoofing) and a five-directive `Content-Security-Policy` (`default-src 'none'; sandbox; frame-ancestors 'none'; form-action 'none'; base-uri 'none'`) that makes the bytes never a document on this origin — this is what makes admitting SVG safe with NO server-side sanitizer. `GET …/assets` (`assets-read` — every role, a member included) lists a workspace's assets, metadata only, never `bytes`, with an optional `?tag=<slug>` filter; this is what narrows the store's prior "nothing lists assets" premise to its precise form — no ANONYMOUS and no CROSS-TENANT enumeration, both still true. `PATCH …/assets/:assetId` updates `title`/`description`/`tags` (`assets-write`, owner and admin, the upload's permission too; `null` clears a text field to SQL `NULL`, `""` stores empty; the asset's `id` and public URL are untouched). `DELETE …/assets/:assetId` (`assets-write`) removes the row and the bytes at the ORIGIN only — already-cached copies elsewhere are not recallable under the year-long `immutable` promise, and no reference check protects a page that still embeds the URL; absent and foreign ids both take the same 404. Every tenant route here is identity-bound; no MCP twin (#3591 exception). The public read has no MCP twin either — it serves bytes to an `<img>`/download, the same boundary `upload.ts` records for its own sibling. **A client's file is never in the library's public life (#7877 D18):** the default `GET …/assets` listing leaves out every asset tagged `private` — only `?tag=private` lists them (the Admin's Compose) — and the public read answers its uniform 404 for an asset tagged `private` or attached to any member-area message, so such a file's bytes are served only by its owner's `GET /v1/member/messages/:id/file`.
72
+ - `/v1/workspaces/:workspaceId/assets` (GET, POST), `/v1/workspaces/:workspaceId/assets/:assetId` (PATCH, DELETE), `/v1/assets/:assetId` (GET, public/anonymous) — the workspace asset store (#5854 upload/read; #6093 list/PATCH/DELETE, `src/routes/assets/{index,upload,read,manage}.ts`). Upload accepts an allowlisted union of three named classes (`src/routes/assets/index.ts`): five RASTER images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`), one VECTOR image (`image/svg+xml`, admitted under the operator's 2026-09-18 `allow-svg-assets` ruling — issue #6093 comment `5731573595` — superseding #5854's original SVG exclusion) and one DOCUMENT type (`application/pdf`), all under the existing 20 MB cap; `?tags=<slug>[,<slug>…]` stores tags on the row. The public read serves a RASTER type `inline` exactly as before; every OTHER admitted type (SVG, PDF) is served `attachment`, with the filename's extension pinned to the stored content type (closing extension spoofing) and a five-directive `Content-Security-Policy` (`default-src 'none'; sandbox; frame-ancestors 'none'; form-action 'none'; base-uri 'none'`) that makes the bytes never a document on this origin — this is what makes admitting SVG safe with NO server-side sanitizer. `GET …/assets` (`assets-read` — every role, a member included) lists a workspace's assets, metadata only, never `bytes`, with an optional `?tag=<slug>` filter; this is what narrows the store's prior "nothing lists assets" premise to its precise form — no ANONYMOUS and no CROSS-TENANT enumeration, both still true. `PATCH …/assets/:assetId` updates `title`/`description`/`tags` (`assets-write`, owner and admin, the upload's permission too; `null` clears a text field to SQL `NULL`, `""` stores empty; the asset's `id` and public URL are untouched). `DELETE …/assets/:assetId` (`assets-write`) removes the row and the bytes at the ORIGIN only — already-cached copies elsewhere are not recallable under the year-long `immutable` promise, and no reference check protects a page that still embeds the URL; absent and foreign ids both take the same 404. Every tenant route here is identity-bound; no MCP twin (#3591 exception). The public read has no MCP twin either — it serves bytes to an `<img>`/download, the same boundary `upload.ts` records for its own sibling. **A client's file is never in the library's public life (#7877 D18):** the default `GET …/assets` listing leaves out every asset tagged `private` — only `?tag=private` lists them (the Admin's Compose) — and the public read answers its uniform 404 for an asset tagged `private`, attached to any member-area message, or (#8171 D3) named by a delivered piece version (one `pieceVersion.count`, since the tag can be removed after delivery), so such a file's bytes are served only by its owner's member reads.
73
+ - `/v1/subscription/me` (GET) — `?include=billing` (#8171 D7), from a cookie session only, adds the optional `planName` and `paymentMethod` (brand, last four, expiry) read from Stripe through `lib/stripe.ts`'s `readCustomerBillingSummary` for the caller's own resolved customer: cached per customer for 60 s (a failure too), one read in flight per customer, bounded at 3 s; a failure leaves both keys absent, a value outside the schema's bounds is `null`, and no id is served or logged. A bearer token, another value or none answers exactly as before.
66
74
  - `/v1/billing` (GET), `/v1/billing/portal-session` (POST) — org-scoped billing read + Stripe customer-portal session creation. The read stays open to every member (the Admin's plan gate reads it); the portal needs `billing-manage` — the owner alone (`@working-theory/validation`'s permission table (#7636 D2); an admin is refused before any Stripe call). `/v1/billing/usage` (GET) needs `billing-read` (owner, admin).
67
75
  - `/v1/schedule/availability` (GET), `/v1/schedule/event-meta` (GET), `/v1/schedule/book` (POST) — anonymous scheduling proxy behind the provider seam (`src/services/scheduling/`); Cal.com v2 today, a self-hosted engine later, same three calls. `event-meta` is the meta-only read (title · duration · description · price) so rendering an event's facts never fetches an availability window (#4633). `CAL_API_KEY` is read server-side only. Which account answers is `resolvePublicSchedulingProvider` (#7533 D4), from `resolvePublicWorkspace()` and never the request: off the database adapter, the env credential as before; a resolved workspace, its own connection (or the env credential on a one-workspace deployment); an unresolved one, `503 not_configured` — fail closed. MCP twins: `list_availability` / `get_event_meta` / `create_booking` (#4416, #4633), unchanged — same arguments, same result shape. **A hub's spokes (#7944):** a call carrying a spoke key passes one gate before any event, slot or booking call — every calLink's slug must be one its workspace has enabled (no provider call at all), and every calLink's owner must equal the handle of the account the workspace's credential belongs to (`SchedulingProvider.getAccountHandle()`, Cal `GET /v2/me`, remembered 60 s per key, an empty answer never remembered); a refusal is `404 event_not_found`, an unreadable handle the read's own kind (`502 provider_error`), and each refusal logs one `warn` of `{ route, spokeId, reason }` and nothing else. A refused key keeps the answer each route gave it before; keyless callers skip the gate and never read the handle. The MCP twins forward no visitor identity, so every MCP caller shares the MCP host's one per-IP `schedule` bucket on the hub (`availability`, `event-meta` and `book` counted together): one caller's reads can make `create_booking` answer 429 for every other MCP caller of that host. This is accepted (#7944 D6) — the site's own booker carries the visitor's identity through the relay and is unaffected; the reopen trigger is recorded in `apps/mcp/src/tools/create_booking.ts`'s header
68
76
  - `/v1/schedule/meeting-types` (GET) — the enabled-meeting-slugs public read (#6183 S9b, `src/routes/schedule/meeting-types.ts`): the session-less read the public `/meet` surface needs to honour the operator's enabled-slug gate (`/v1/workspaces/:workspaceId/meetings`, above), since a public page holds no session. Resolves its workspace via `resolvePublicWorkspace()` (the same fail-closed predicate the public content read uses — no client-suppliable workspace, ever) and reads through a new named public-read boundary, `src/lib/public-meetings-config.ts` (modelled on `lib/asset-store.ts`): one `findUnique` by the resolved `workspaceId`, no write, no list. `{ slugs: string[] | null }` — `null` means "no stored list" (an unconfigured deployment, or the non-database adapter); the caller (`apps/web`'s `enabledMeetingSlugs()`) applies the `SCHEDULING_CAL_LINK`-derived singleton fallback for that state, since that env var is `apps/web`'s, not this app's. Reads no client input. No MCP twin — deferred to a named follow-up rather than silently skipped (the route's own header, §10 Q3 of the spec).
@@ -167,7 +175,17 @@ sweep's own DB constraint (never a caller-side lock).
167
175
  - Don't put session state in `apps/api`. It's stateless.
168
176
  - Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
169
177
 
170
- Last updated: 2026-10-09 — an agent key may be granted `member-pieces-manage` (#7878): the member-area bullet's four writes read it through `allows`, and the agent-seat bullet lists seven grantable permissions — verified against `apps/api/src/routes/workspaces/{pieces,plan,messages-out}.ts` and `packages/validation/src/schemas/permissions.ts`.
178
+ Last updated: 2026-10-11 — the spoke member admission (#8220): the hub-sign-in bullet names `HUB_SESSION_ROUTES`' 18 entries by group, the per-(person, spoke) budget, the `spokeId` log binding, the per-IP residual and the spoke's cross-site-request obligation; the #8169 and #8170 bullets say their routes are admitted, and the #8169 bullet states the one-owner-per-organization precondition — verified against `apps/api/src/middleware/auth.ts`, `apps/api/src/lib/member-store.ts`, `apps/api/src/routes/member/billing.ts` and `apps/api/src/__tests__/member-isolation.test.ts`.
179
+
180
+ Prior: 2026-10-11 — the provider routes (#8171): added the bullet for the Requests read and Deliver a version, and extended the People (`request` and `version` timeline kinds), meetings (`customerSlugs`), magnets (`customersEnabled`), deliverables and asset-store (a version's file) bullets and added a `/v1/subscription/me` note (`?include=billing`) — verified against `apps/api/src/routes/workspaces/{requests,pieces,meetings}.ts`, `apps/api/src/routes/magnets/index.ts`, `apps/api/src/routes/subscription/me.ts`, `apps/api/src/services/subscription.ts` and `apps/api/src/lib/{tenant-db,asset-store,deliverable-asset-store,stripe}.ts`.
181
+
182
+ Prior: 2026-10-11 — the Phase 1 member writes (#8170): added the bullet for the six `/v1/member/*` writes (the inbound Interaction rows, the hourly cap under the Person lock, the review under the Person then piece lock, the booking bound, the deletion destination, the log rule) — verified against `apps/api/src/routes/member/*.ts`, `apps/api/src/lib/member-store.ts`, `apps/api/src/services/member-area.ts` and `apps/api/src/server.ts`.
183
+
184
+ Prior: 2026-10-11 — the root index's `modules` report (#7546, after #7501): added the `/` (GET) bullet at the head of Endpoint conventions — the field, its `servedModules()` / `OPTIONAL_MODULES` source, `docs` absent from the vocabulary, `content-write` reported only alongside `workspace` or `multi-tenant` — verified against `apps/api/src/config/modules.ts`, `apps/api/src/server.ts` and `apps/api/src/__tests__/root-modules.test.ts`.
185
+
186
+ Prior: 2026-10-10 — the Phase 1 member reads (#8169): added the bullet for the eight `/v1/member/*` GETs (the organization grain of purchases and billing, the hub-credential-only billing stance, the scheduling-gated pair) — verified against `apps/api/src/routes/member/*.ts`, `apps/api/src/lib/member-store.ts` and `apps/api/src/server.ts`.
187
+
188
+ Prior: 2026-10-09 — an agent key may be granted `member-pieces-manage` (#7878): the member-area bullet's four writes read it through `allows`, and the agent-seat bullet lists seven grantable permissions — verified against `apps/api/src/routes/workspaces/{pieces,plan,messages-out}.ts` and `packages/validation/src/schemas/permissions.ts`.
171
189
 
172
190
  Prior: 2026-10-09 — a spoke's schedule calls bind the event owner to its workspace (#7944): the `/v1/schedule/*` bullet gains the spoke gate and the accepted shared MCP schedule budget — verified against `apps/api/src/routes/schedule/index.ts`, `apps/api/src/services/scheduling/{types,cal-provider}.ts` and `apps/api/src/middleware/rate-limit.ts`.
173
191
 
@@ -1,3 +1,4 @@
1
+
1
2
  Apache License
2
3
  Version 2.0, January 2004
3
4
  http://www.apache.org/licenses/
@@ -137,8 +138,8 @@
137
138
 
138
139
  6. Trademarks. This License does not grant permission to use the trade
139
140
  names, trademarks, service marks, or product names of the Licensor,
140
- except as required for describing the origin of the Work and
141
- reproducing the content of the NOTICE file.
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
142
143
 
143
144
  7. Disclaimer of Warranty. Unless required by applicable law or
144
145
  agreed to in writing, Licensor provides the Work (and each
@@ -162,7 +163,7 @@
162
163
  other commercial damages or losses), even if such Contributor
163
164
  has been advised of the possibility of such damages.
164
165
 
165
- 9. Accepting Warranty or Support. While redistributing
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
167
  the Work or Derivative Works thereof, You may choose to offer,
167
168
  and charge a fee for, acceptance of support, warranty, indemnity,
168
169
  or other liability obligations and/or rights consistent with this
@@ -171,7 +172,7 @@
171
172
  of any other Contributor, and only if You agree to indemnify,
172
173
  defend, and hold each Contributor harmless for any liability
173
174
  incurred by, or claims asserted against, such Contributor by reason
174
- of your accepting any such warranty or support.
175
+ of your accepting any such warranty or additional liability.
175
176
 
176
177
  END OF TERMS AND CONDITIONS
177
178
 
@@ -186,7 +187,7 @@
186
187
  same "printed page" as the copyright notice for easier
187
188
  identification within third-party archives.
188
189
 
189
- Copyright 2026 Tailwind AI
190
+ Copyright [yyyy] [name of copyright owner]
190
191
 
191
192
  Licensed under the Apache License, Version 2.0 (the "License");
192
193
  you may not use this file except in compliance with the License.
@@ -0,0 +1,2 @@
1
+ AI Launch Kit
2
+ Copyright 2026 SalesSmyth LLC
@@ -5,7 +5,7 @@ module.exports = {
5
5
  testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(test).ts'],
6
6
  testPathIgnorePatterns: ['/node_modules/', '.*\\.spec\\.ts$'],
7
7
  // Ignore node_modules except jose (ESM-only, loaded transitively via @working-theory/license → @working-theory/schema-pack-registry → @working-theory/schema).
8
- transformIgnorePatterns: ['/node_modules/(?!(\\.pnpm/)?jose@?[^/]*?(/|$))'],
8
+ transformIgnorePatterns: ['/node_modules/(?!(\\.pnpm/)?(jose@?[^/]*?|cookie@?[^/]*?|@vercel(?:\\+analytics@[^/]*?|/analytics)|@working-theory(?:\\+[^/]*?@?[^/]*?|/[^/]+))(/|$))'],
9
9
  moduleFileExtensions: ['ts', 'js', 'json'],
10
10
  setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
11
11
  collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/__tests__/**'],
@@ -20,9 +20,8 @@ module.exports = {
20
20
  },
21
21
  },
22
22
  moduleNameMapper: {
23
- // Mock server-only in Jest environments — the package throws by design
24
- // outside Next.js. The real guard runs in production Next.js builds.
25
- '^server-only$': '<rootDir>/../../__mocks__/server-only.ts',
23
+ // @working-theory/* resolve from the installed packages; their ESM
24
+ // dist is converted by the transform below.
26
25
  // Map Prisma client import for testing (must come before the generic .js stripper).
27
26
  // Phase C (#710): the v7 prisma-client generator emits TypeScript source files
28
27
  // (client.ts) that use `import.meta.url`, which Jest's CommonJS preset can't
@@ -41,31 +40,7 @@ module.exports = {
41
40
  // The shared listing-diagnostic scoring register (#5001) — same
42
41
  // real-TS-source rationale as flows/presets above.
43
42
  '^@working-theory/database/flows/listing-diagnostic$': '<rootDir>/../../database/flows/listing-diagnostic.ts',
44
- // Map workspace packages to their TypeScript source so ts-jest compiles them
45
- // directly (the published dist is ESM, which Jest's CJS preset can't load).
46
- '^@working-theory/aeo/fetch$': '<rootDir>/../../packages/aeo/src/fetch/index.ts',
47
- '^@working-theory/aeo$': '<rootDir>/../../packages/aeo/src/index.ts',
48
- '^@working-theory/analytics/server$': '<rootDir>/../../packages/analytics/src/server/index.ts',
49
- '^@vercel/analytics/server$': '<rootDir>/../../packages/analytics/__mocks__/vercel-analytics-server.ts',
50
- '^@working-theory/common$': '<rootDir>/../../packages/common/src/index.ts',
51
- '^@working-theory/content-adapters$': '<rootDir>/../../packages/content-adapters/src/index.ts',
52
- '^@working-theory/content-projection$': '<rootDir>/../../packages/content-projection/src/index.ts',
53
- '^@working-theory/content-projection/node$': '<rootDir>/../../packages/content-projection/src/node.ts',
54
- '^@working-theory/email$': '<rootDir>/../../packages/email/src/index.ts',
55
- '^@working-theory/ids$': '<rootDir>/../../packages/ids/src/index.ts',
56
- '^@working-theory/license$': '<rootDir>/../../packages/license/src/index.ts',
57
- '^@working-theory/observability$': '<rootDir>/../../packages/observability/src/index.ts',
58
- '^@working-theory/schema/faq$': '<rootDir>/../../packages/schema/src/faq.ts',
59
- // #5830 — see the `/faq` line above: source, not the ESM `dist/`.
60
- '^@working-theory/schema/categories$':
61
- '<rootDir>/../../packages/schema/src/categories.ts',
62
- '^@working-theory/schema$': '<rootDir>/../../packages/schema/src/index.ts',
63
- '^@working-theory/site-config/schema$': '<rootDir>/../../packages/site-config/src/schema/index.ts',
64
- '^@working-theory/schema-pack-registry$': '<rootDir>/../../packages/schema-pack-registry/src/index.ts',
65
- '^@working-theory/schema-validator$': '<rootDir>/../../packages/schema-validator/src/index.ts',
66
- '^@working-theory/stripe-client$': '<rootDir>/../../packages/stripe-client/src/index.ts',
67
- '^@working-theory/metadata$': '<rootDir>/../../packages/metadata/src/index.ts',
68
- '^@working-theory/validation$': '<rootDir>/../../packages/validation/src/index.ts',
43
+ '^@working-theory/analytics/server$': '<rootDir>/node_modules/@working-theory/analytics/dist/server/index.js',
69
44
  '^(\\.{1,2}/.*)\\.js$': '$1',
70
45
  },
71
46
  transform: {
@@ -8,6 +8,7 @@
8
8
  "scripts": {
9
9
  "dev": "tsx watch --exclude \"../../node_modules/**\" src/cli.ts",
10
10
  "abandon:sweep": "tsx src/bin/abandon-sweep.ts",
11
+ "member-request:match": "tsx src/bin/member-request-match.ts",
11
12
  "followup:sweep": "tsx src/bin/followup-sweep.ts",
12
13
  "listing-csv:sweep": "tsx src/bin/listing-csv-sweep.ts",
13
14
  "retention:sweep": "tsx src/bin/session-retention-sweep.ts",
@@ -321,6 +321,15 @@ export const prisma = {
321
321
  upsert: jest.fn(),
322
322
  count: jest.fn(async () => 0),
323
323
  },
324
+ // #8171 D3 — a piece's delivered versions. Calls live in lib/tenant-db.ts
325
+ // (`memberArea.deliverVersion`, the timeline), lib/member-store.ts and,
326
+ // for `count`, the two asset readers. Each defaults to an empty result.
327
+ pieceVersion: {
328
+ findMany: jest.fn(async () => []),
329
+ findFirst: jest.fn(async () => null),
330
+ create: jest.fn(),
331
+ count: jest.fn(async () => 0),
332
+ },
324
333
  // ── Config namespace (WorkspacePieceStages — #7877 D17) ──────────────────
325
334
  workspacePieceStages: {
326
335
  findMany: jest.fn(async () => []),
@@ -184,3 +184,41 @@ describe("module boundary contract (§1.14 / D16)", () => {
184
184
  );
185
185
  });
186
186
  });
187
+
188
+ // #8171 D3 (AC-6): a delivered version's file is never a magnet deliverable —
189
+ // untagged, attached to no message, and still `null` from both reads.
190
+ describe("a delivered version's file (#8171 D3)", () => {
191
+ beforeEach(() => {
192
+ jest.clearAllMocks();
193
+ (prisma.interaction.count as jest.Mock).mockResolvedValue(0);
194
+ });
195
+
196
+ it("findDeliverableAssetIdByKey returns null, counting within the workspace", async () => {
197
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID, tags: [] });
198
+ (prisma.pieceVersion.count as jest.Mock).mockResolvedValueOnce(1);
199
+
200
+ const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "draft-2.pdf");
201
+
202
+ expect(prisma.pieceVersion.count).toHaveBeenCalledWith({
203
+ where: { assetId: ASSET_ID, workspaceId: WORKSPACE_ID },
204
+ });
205
+ expect(result).toBeNull();
206
+ });
207
+
208
+ it("readDeliverableAsset returns null, counting within the workspace", async () => {
209
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({
210
+ filename: "draft-2.pdf",
211
+ contentType: "application/pdf",
212
+ bytes: new Uint8Array([1, 2, 3]),
213
+ tags: [],
214
+ });
215
+ (prisma.pieceVersion.count as jest.Mock).mockResolvedValueOnce(1);
216
+
217
+ const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
218
+
219
+ expect(prisma.pieceVersion.count).toHaveBeenCalledWith({
220
+ where: { assetId: ASSET_ID, workspaceId: WORKSPACE_ID },
221
+ });
222
+ expect(result).toBeNull();
223
+ });
224
+ });
@@ -24,8 +24,8 @@
24
24
  * workspace's bytes.
25
25
  *
26
26
  * Neither ever resolves a client file (SEC-7877-A, #7877): an asset tagged
27
- * `private`, or one a member-area message attaches, is the same `null` a
28
- * miss is — so a magnet pointed at a client file's key mints nothing and a
27
+ * `private`, one a member-area message attaches, or one a delivered piece
28
+ * version names (#8171 D3), is the same `null` a miss is — so a magnet pointed at a client file's key mints nothing and a
29
29
  * grant minted before the file became one serves nothing.
30
30
  *
31
31
  * No write, no `findMany`, no list, no unscoped read — both functions take
@@ -36,14 +36,15 @@ import { PRIVATE_ASSET_TAG } from "@working-theory/validation";
36
36
 
37
37
  import { prisma } from "./prisma.js";
38
38
 
39
- /** A client file (SEC-7877-A) — `private`-tagged, or attached to a message. */
39
+ /** A client file (SEC-7877-A) — `private`-tagged, attached to a message, or a version's file (#8171 D3). */
40
40
  async function isClientFile(
41
41
  assetId: string,
42
42
  workspaceId: string,
43
43
  tags: string[],
44
44
  ): Promise<boolean> {
45
45
  if (tags.includes(PRIVATE_ASSET_TAG)) return true;
46
- return (await prisma.interaction.count({ where: { assetId, workspaceId } })) > 0;
46
+ if ((await prisma.interaction.count({ where: { assetId, workspaceId } })) > 0) return true;
47
+ return (await prisma.pieceVersion.count({ where: { assetId, workspaceId } })) > 0;
47
48
  }
48
49
 
49
50
  /** What the mint-time resolution needs, and nothing more. */
@@ -73,3 +73,84 @@ export type {
73
73
  export async function expireCheckoutSession(stripeSessionId: string): Promise<void> {
74
74
  await getStripeClient().checkout.sessions.expire(stripeSessionId);
75
75
  }
76
+
77
+ /** #8171 D7 — what the Billing page shows: the plan's name and the card on file. */
78
+ export type CustomerBillingSummary = {
79
+ planName: string | null;
80
+ paymentMethod: { brand: string; last4: string; expMonth: number; expYear: number } | null;
81
+ };
82
+
83
+ /** A subscription status that still names the customer's plan (#8171 C10). */
84
+ const CURRENT_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due", "unpaid"]);
85
+ const PLAN_NAME_MAX = 200;
86
+
87
+ /**
88
+ * Read one customer's plan name and card summary (#8171 D7, C10): the newest
89
+ * subscription that is `active`, `trialing`, `past_due` or `unpaid`; its
90
+ * first item's product name (else the price nickname); and its default
91
+ * payment method (else the customer's invoice default). At most three reads,
92
+ * no expand path deeper than three levels (SEC-8171-3). Built on
93
+ * `getStripeClient`, so it inherits the `sk_test_` allow-list: a live or
94
+ * missing key throws `StripeConfigError`. It logs nothing and returns no id.
95
+ */
96
+ export async function readCustomerBillingSummary(
97
+ customerId: string,
98
+ ): Promise<CustomerBillingSummary> {
99
+ const stripe = getStripeClient();
100
+ const subscriptions = await stripe.subscriptions.list({
101
+ customer: customerId,
102
+ status: "all",
103
+ limit: 10,
104
+ expand: ["data.default_payment_method"],
105
+ });
106
+ const current = subscriptions.data
107
+ .filter((sub) => CURRENT_SUBSCRIPTION_STATUSES.has(sub.status))
108
+ .sort((a, b) => b.created - a.created)[0];
109
+
110
+ let planName: string | null = null;
111
+ const price = current?.items.data[0]?.price;
112
+ if (price) {
113
+ let name: string | null = null;
114
+ if (typeof price.product === "string") {
115
+ const product = await stripe.products.retrieve(price.product);
116
+ name = product.name;
117
+ } else if (price.product && !("deleted" in price.product && price.product.deleted)) {
118
+ name = (price.product as { name?: string }).name ?? null;
119
+ }
120
+ const trimmed = (name ?? price.nickname ?? "").trim().slice(0, PLAN_NAME_MAX);
121
+ planName = trimmed === "" ? null : trimmed;
122
+ }
123
+
124
+ let method: unknown = current?.default_payment_method ?? null;
125
+ if (cardOf(method) === null) {
126
+ const customer = await stripe.customers.retrieve(customerId, {
127
+ expand: ["invoice_settings.default_payment_method"],
128
+ });
129
+ method = "deleted" in customer && customer.deleted
130
+ ? null
131
+ : (customer as { invoice_settings?: { default_payment_method?: unknown } }).invoice_settings
132
+ ?.default_payment_method ?? null;
133
+ }
134
+
135
+ return { planName, paymentMethod: cardOf(method) };
136
+ }
137
+
138
+ /** A `card` payment method's presentation fields, or `null` for anything else. */
139
+ function cardOf(method: unknown): CustomerBillingSummary["paymentMethod"] {
140
+ if (typeof method !== "object" || method === null) return null;
141
+ const { type, card } = method as {
142
+ type?: unknown;
143
+ card?: { brand?: unknown; last4?: unknown; exp_month?: unknown; exp_year?: unknown };
144
+ };
145
+ if (type !== "card" || !card) return null;
146
+ const { brand, last4, exp_month: expMonth, exp_year: expYear } = card;
147
+ if (
148
+ typeof brand !== "string" ||
149
+ typeof last4 !== "string" ||
150
+ typeof expMonth !== "number" ||
151
+ typeof expYear !== "number"
152
+ ) {
153
+ return null;
154
+ }
155
+ return { brand, last4, expMonth, expYear };
156
+ }