create-ailk 0.24.0 → 0.25.1

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 (599) hide show
  1. package/README.md +42 -20
  2. package/component-catalog.json +3 -3
  3. package/component-catalog.md +3 -3
  4. package/dist/add-module.js +4 -2
  5. package/dist/add-route.d.ts +12 -1
  6. package/dist/add-route.js +31 -3
  7. package/dist/cli.js +12 -7
  8. package/dist/derive-architecture.d.ts +3 -4
  9. package/dist/derive-architecture.js +65 -29
  10. package/dist/lib/design-checks.d.ts +9 -4
  11. package/dist/lib/design-checks.js +13 -7
  12. package/dist/lib/extract-module-bundle.js +29 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +1 -1
  14. package/dist/lib/fetch-template-snapshot.js +7 -3
  15. package/dist/lib/import-graph.d.ts +1 -1
  16. package/dist/lib/import-graph.js +4 -4
  17. package/dist/lib/module-license-gate.d.ts +12 -1
  18. package/dist/lib/module-license-gate.js +34 -12
  19. package/dist/module-architecture.d.ts +20 -3
  20. package/dist/module-architecture.js +117 -550
  21. package/dist/paid-sections.d.ts +25 -1
  22. package/dist/paid-sections.js +88 -3
  23. package/dist/surfaces.js +10 -2
  24. package/dist/template-record.d.ts +14 -4
  25. package/dist/template-record.js +23 -5
  26. package/dist/topology.d.ts +16 -3
  27. package/dist/topology.js +31 -19
  28. package/dist/update.d.ts +10 -1
  29. package/dist/update.js +540 -16
  30. package/package.json +3 -3
  31. package/release-scope.json +1 -1
  32. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  33. package/templates/.claude/hooks/template-guard.sh +137 -0
  34. package/templates/.claude/rules/extending.md +106 -0
  35. package/templates/.claude/rules/loop-contract.md +2 -2
  36. package/templates/.claude/settings.json +11 -0
  37. package/templates/.env.example +5 -5
  38. package/templates/.github/workflows/ci.yml +7 -4
  39. package/templates/CLAUDE.md +11 -1
  40. package/templates/CONVENTIONS.md +8 -10
  41. package/templates/README.md +14 -13
  42. package/templates/apps/api/.env.example +2 -2
  43. package/templates/apps/api/CLAUDE.md +102 -76
  44. package/templates/apps/api/jest.config.cjs +15 -5
  45. package/templates/apps/api/package.json +16 -20
  46. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  47. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  48. package/templates/apps/api/src/index.ts +1 -1
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
  50. package/templates/apps/api/src/modules/commerce.ts +10 -0
  51. package/templates/apps/api/src/modules/content-write.ts +10 -0
  52. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  53. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  54. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  55. package/templates/apps/api/src/modules/workspace.ts +10 -0
  56. package/templates/apps/api/src/server.ts +117 -1882
  57. package/templates/apps/api/vercel.json +2 -2
  58. package/templates/apps/mcp/CLAUDE.md +67 -20
  59. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  60. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  61. package/templates/apps/mcp/jest.config.cjs +6 -1
  62. package/templates/apps/mcp/package.json +5 -13
  63. package/templates/apps/mcp/src/app.ts +8 -7
  64. package/templates/apps/mcp/src/index.ts +6 -34
  65. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  66. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  67. package/templates/apps/mcp/src/site.ts +12 -0
  68. package/templates/apps/web/.env.example +1 -1
  69. package/templates/apps/web/CLAUDE.md +10 -3
  70. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  71. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  72. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  73. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
  74. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  75. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  76. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  77. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  78. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  79. package/templates/apps/web/auth.ts +2 -2
  80. package/templates/apps/web/jest.config.cjs +1 -0
  81. package/templates/apps/web/lib/auth-email.ts +1 -1
  82. package/templates/apps/web/lib/relay.ts +2 -2
  83. package/templates/apps/web/lib/release-scope.ts +47 -0
  84. package/templates/apps/web/lib/server-api.ts +1 -1
  85. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  86. package/templates/customizations.yaml +37 -0
  87. package/templates/database/prisma.config.ts +10 -14
  88. package/templates/database/seed-test.ts +2 -2
  89. package/templates/database/seed.ts +5 -5
  90. package/templates/package.json +12 -6
  91. package/templates/pnpm-workspace.yaml +0 -1
  92. package/templates/project.yaml +16 -8
  93. package/templates/tools/jest/reporters.cjs +65 -0
  94. package/templates/tools/lint-architecture.ts +553 -7
  95. package/templates/tsconfig.json +1 -2
  96. package/versions.json +48 -35
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  98. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  99. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  100. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  101. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  102. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  103. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  104. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  105. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  106. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  107. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  108. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  109. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  110. package/templates/apps/api/src/bin/spokes.ts +0 -198
  111. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  112. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  113. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  114. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  115. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  116. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  117. package/templates/apps/api/src/config/index.ts +0 -596
  118. package/templates/apps/api/src/config/modules.ts +0 -83
  119. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  120. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  121. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  122. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  123. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  124. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  125. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  126. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  127. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  128. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  129. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  130. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  131. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  132. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  133. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  134. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  135. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  136. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  137. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  138. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
  139. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  140. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  141. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  142. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  143. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  144. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  145. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  146. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  147. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  148. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  149. package/templates/apps/api/src/lib/errors.ts +0 -95
  150. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  151. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  152. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  153. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  154. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  155. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  156. package/templates/apps/api/src/lib/locales.ts +0 -201
  157. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  158. package/templates/apps/api/src/lib/prisma.ts +0 -21
  159. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  160. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  161. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  162. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  163. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  164. package/templates/apps/api/src/lib/site.ts +0 -257
  165. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  166. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  167. package/templates/apps/api/src/lib/stripe.ts +0 -321
  168. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  169. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  170. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  171. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  172. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  173. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  174. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  175. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  176. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  177. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  178. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  179. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  180. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  181. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  182. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  183. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  184. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  185. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  186. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  187. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  188. package/templates/apps/api/src/middleware/auth.ts +0 -864
  189. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  190. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  191. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
  192. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  193. package/templates/apps/api/src/openapi/index.ts +0 -18
  194. package/templates/apps/api/src/openapi/spec.ts +0 -2148
  195. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  196. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  197. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  198. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  199. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  200. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  201. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  202. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  203. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  204. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  205. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  206. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  207. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  208. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
  209. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  210. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
  211. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  212. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  213. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  214. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  215. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  216. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  217. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  218. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  219. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  220. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  221. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  222. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  223. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  224. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  225. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  226. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  227. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  228. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  229. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  230. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  231. package/templates/apps/api/src/routes/content/get-page.ts +0 -183
  232. package/templates/apps/api/src/routes/content/index.ts +0 -49
  233. package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
  234. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  235. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  236. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  237. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  238. package/templates/apps/api/src/routes/content/search.ts +0 -100
  239. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  240. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  241. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  242. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  243. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  244. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  245. package/templates/apps/api/src/routes/leads/index.ts +0 -639
  246. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  247. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  248. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  249. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  250. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
  251. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
  252. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  253. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  254. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  255. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  256. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  257. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  258. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  259. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  260. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  261. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  262. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  263. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  264. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  265. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  266. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  267. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  268. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  269. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  270. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  271. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  272. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  273. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  274. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
  275. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  276. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  277. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  278. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  279. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  280. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  281. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  282. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  283. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
  284. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  285. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  286. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  287. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
  288. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
  289. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  290. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  291. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  292. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  293. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  294. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  295. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  296. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
  297. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  298. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  299. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  300. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  301. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  302. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  303. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  304. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  305. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  306. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
  307. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
  308. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
  309. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
  310. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  311. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  312. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  313. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  314. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  315. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  316. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  317. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  318. package/templates/apps/api/src/services/abandon-email.ts +0 -336
  319. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  320. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  321. package/templates/apps/api/src/services/audit-log.ts +0 -961
  322. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  323. package/templates/apps/api/src/services/consent.ts +0 -834
  324. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  325. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
  326. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  327. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  328. package/templates/apps/api/src/services/entitlements.ts +0 -338
  329. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  330. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  331. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  332. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  333. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  334. package/templates/apps/api/src/services/listing-config.ts +0 -178
  335. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  336. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
  337. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  338. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
  339. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  340. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
  341. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
  342. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  343. package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
  344. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
  345. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  346. package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
  347. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  348. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  349. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  350. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  351. package/templates/apps/api/src/services/person.ts +0 -1211
  352. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  353. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  354. package/templates/apps/api/src/services/project-listings.ts +0 -2085
  355. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  356. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  357. package/templates/apps/api/src/services/revalidation.ts +0 -235
  358. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  359. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  360. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  361. package/templates/apps/api/src/services/verified-email.ts +0 -71
  362. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  363. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  364. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  365. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  366. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  367. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  368. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  369. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  370. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  371. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  372. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  373. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  374. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  375. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  376. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  377. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  378. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
  379. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  380. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  381. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  382. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
  383. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
  384. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  385. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  386. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
  387. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  388. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
  389. package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
  390. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  391. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  392. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
  393. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
  394. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
  395. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
  396. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  397. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  398. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
  399. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
  400. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  401. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  402. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  403. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  404. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  405. package/templates/apps/mcp/src/abuse/index.ts +0 -349
  406. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  407. package/templates/apps/mcp/src/boot.ts +0 -167
  408. package/templates/apps/mcp/src/config/modules.ts +0 -43
  409. package/templates/apps/mcp/src/http-transport.ts +0 -564
  410. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  411. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  412. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
  413. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
  414. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  415. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  416. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  417. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  418. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  419. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  420. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  421. package/templates/apps/mcp/src/lib/site-config.ts +0 -53
  422. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  423. package/templates/apps/mcp/src/request-context.ts +0 -52
  424. package/templates/apps/mcp/src/server.ts +0 -343
  425. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  426. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  427. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
  428. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  429. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  430. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  431. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  432. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  433. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  434. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  435. package/templates/apps/mcp/src/tools/index.ts +0 -138
  436. package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
  437. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  438. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  439. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  440. package/templates/apps/mcp/src/tools/read_content.ts +0 -81
  441. package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
  442. package/templates/apps/mcp/src/tools/search_content.ts +0 -63
  443. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
  444. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  445. package/templates/database/CHANGELOG.md +0 -981
  446. package/templates/database/LICENSE +0 -202
  447. package/templates/database/NOTICE +0 -2
  448. package/templates/database/README.md +0 -243
  449. package/templates/database/__tests__/agents.db.test.ts +0 -320
  450. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  451. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  452. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  453. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  454. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  455. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  456. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  457. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  458. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  459. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  460. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  461. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  462. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  463. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  464. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  465. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  466. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  467. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  468. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  469. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  470. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  471. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  472. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  473. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  474. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  475. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  476. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  477. package/templates/database/__tests__/person.db.test.ts +0 -586
  478. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  479. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  480. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  481. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  482. package/templates/database/__tests__/seed.test.ts +0 -272
  483. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  484. package/templates/database/__tests__/tenancy.test.ts +0 -141
  485. package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
  486. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  487. package/templates/database/_gitignore +0 -8
  488. package/templates/database/auth/.gitkeep +0 -0
  489. package/templates/database/auth/schema.prisma +0 -641
  490. package/templates/database/config/.gitkeep +0 -0
  491. package/templates/database/config/schema.prisma +0 -441
  492. package/templates/database/content/.gitkeep +0 -0
  493. package/templates/database/content/schema.prisma +0 -339
  494. package/templates/database/eslint.config.mjs +0 -69
  495. package/templates/database/flows/listing-diagnostic.ts +0 -182
  496. package/templates/database/flows/presets.ts +0 -315
  497. package/templates/database/inbox/.gitkeep +0 -0
  498. package/templates/database/inbox/README.md +0 -3
  499. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  500. package/templates/database/inbox/schema.prisma +0 -1447
  501. package/templates/database/jest.config.cjs +0 -36
  502. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  503. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  504. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  505. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  506. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  507. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  508. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  509. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  510. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  511. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  512. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  513. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  514. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  515. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  516. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  517. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  518. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  519. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  520. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  521. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  522. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  523. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  524. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  525. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  526. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  527. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  528. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  529. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  530. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  531. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  532. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  533. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  534. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  535. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  536. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  537. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  538. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  539. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  540. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  541. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  542. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  543. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  544. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  545. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  546. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  547. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  548. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  549. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  550. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  551. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  552. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  553. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  554. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  555. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  556. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  557. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  558. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  559. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  560. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  561. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  562. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  563. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  564. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  565. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  566. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  567. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  568. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  569. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  570. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  571. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  572. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  573. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  574. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  575. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  576. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  577. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  578. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  579. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  580. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  581. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  582. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  583. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  584. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  585. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  586. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
  587. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
  588. package/templates/database/migrations/migration_lock.toml +0 -3
  589. package/templates/database/ops/.gitkeep +0 -0
  590. package/templates/database/ops/schema.prisma +0 -43
  591. package/templates/database/package.json +0 -89
  592. package/templates/database/schema.prisma +0 -71
  593. package/templates/database/scripts/db-generate-locked.sh +0 -80
  594. package/templates/database/scripts/person-backfill.sql +0 -132
  595. package/templates/database/tenancy.ts +0 -183
  596. package/templates/database/tsconfig.build.json +0 -16
  597. package/templates/database/tsconfig.flows.json +0 -15
  598. package/templates/database/tsconfig.json +0 -16
  599. package/templates/database/tsconfig.tenancy.json +0 -14
@@ -1,1907 +1,142 @@
1
1
  /**
2
- * AI Launch Kit API Server
3
- *
4
- * Local-only backend for auth (sessions, API tokens) stored in the customer DB.
5
- * Business-data proxying has been removed; AILK-native routes will be added
6
- * in subsequent issues.
7
- *
8
- * This file exports a build() function for both:
9
- * - Vercel serverless deployment (api/index.ts)
2
+ * AI Launch Kit API Server — the boot's composition (#9564 D5).
3
+ *
4
+ * The server is `@working-theory/api` (#9563 D8 PR 1): the base routes,
5
+ * services, middleware and stores, and `buildServer({ modules, topology,
6
+ * config })`, live in the package. This file is what a site owns: it loads
7
+ * the six module objects and hands them to the package, which registers
8
+ * every row of the registration table whose predicate holds over the
9
+ * registered set — the modules `AILK_ENABLED_MODULES` selects whose claim
10
+ * read answered `claimed` (#9565 D4/D5). The route table a build serves is
11
+ * the one it served before the move (`src/__tests__/registration-table.test.ts`).
12
+ *
13
+ * Exports the same zero-argument `build()` for both:
14
+ * - Vercel serverless deployment (api/index.ts → src/vercel-handler.ts)
10
15
  * - Local development (src/index.ts)
16
+ *
17
+ * ONE MODULE-LEVEL CONSTANT PER MODULE OBJECT — the #5304 form. The specifier
18
+ * is never written at the `import()` call site, so `tsc` does not resolve it
19
+ * and the public and keyless trees, which carry no module file, still
20
+ * compile; it IS a module-level constant dereferenced into `import()`, so
21
+ * `@vercel/nft` constant-propagates it and the deployed artifact carries the
22
+ * file. `scripts/enumerate-slice-specifiers.ts` and
23
+ * `scripts/check-slice-reachability.ts` re-measure both properties on every
24
+ * run. Every load site routes through `loadSlice()` so a bundled build says
25
+ * so at boot rather than failing mute (#5334; `apps/api/CLAUDE.md` § Deploy
26
+ * path). All six are loaded and passed on every boot (#9565 D5): the
27
+ * selection is `AILK_ENABLED_MODULES`, read by the package, and the claim is
28
+ * each package's own licence read — a passed module that is not selected
29
+ * registers nothing.
30
+ *
31
+ * Each `./modules/<name>.ts` file is a one-statement `// oss: false` re-export
32
+ * of `module: ApiModule` from `@working-theory/api-<name>` — the paid package
33
+ * that owns the routes, the plugin map and the claim read.
11
34
  */
12
- import cors from "@fastify/cors";
13
- import rateLimit from "@fastify/rate-limit";
14
- import { logger } from "@working-theory/observability";
15
- import Fastify from "fastify";
16
- import type {
17
- FastifyPluginAsync,
18
- FastifyPluginCallback,
19
- FastifyReply,
20
- FastifyRequest,
21
- } from "fastify";
22
-
23
- import { config } from "./config/index.js";
24
- import { isModuleEnabled, servedModules } from "./config/modules.js";
25
- import { redactLogPath, registerErrorSerializer } from "./lib/error-serializer.js";
26
- import { validateLeadCaptureDir } from "./lib/lead-capture-store.js";
27
- import { registerLicenseKeyStore } from "./lib/license-key-store.js";
28
- import { requireCommerceClaim } from "./lib/require-commerce-claim.js";
29
- import { assertSiteAliasResolves } from "./lib/site.js";
30
- import { loadSlice } from "./lib/slice-load.js";
31
- import { authPreHandlerPlugin } from "./middleware/auth.js";
32
- import { defaultTenantPreHandlerPlugin } from "./middleware/default-tenant.js";
33
- import {
34
- rateLimitMiddleware,
35
- buildTrustedKeyDigests,
36
- DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE,
37
- DEFAULT_PER_IP_PER_MINUTE_STRICT,
38
- DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
39
- resolveRateLimitIdentity,
40
- SITE_KEY_IDENTITY_PREFIX,
41
- siteKeyReader,
42
- } from "./middleware/rate-limit.js";
43
- import { openapiRoutes } from "./openapi/index.js";
44
- import {
45
- deriveRootEndpoints,
46
- REGISTERED_ROUTES_DECORATOR,
47
- API_VERSION,
48
- apiDescription,
49
- apiDisplayName,
50
- resolveApiIdentity,
51
- } from "./openapi/spec.js";
52
- import type { RegisteredRoute } from "./openapi/surface-drift.js";
53
- import { exampleAeoRoute, scoreAeoRoute } from "./routes/aeo/index.js";
54
35
  import {
55
- hubSignInClientRoutes,
56
- hubSignInPublicRoutes,
57
- hubSignInSessionRoutes,
58
- } from "./routes/auth/hub-sign-in.js";
59
- import { tokenRoutes } from "./routes/auth/tokens.js";
60
- import { transactionalEmailRoute } from "./routes/auth/transactional-email.js";
61
- import { userRoutes } from "./routes/auth/user.js";
62
- import {
63
- consentAccountRoutes,
64
- consentCookieRoute,
65
- consentSignUpRoute,
66
- consentUnsubscribeRoute,
67
- } from "./routes/consent/index.js";
68
- import { getPageRoute } from "./routes/content/get-page.js";
69
- import { listPagesRoute } from "./routes/content/list-pages.js";
70
- import { listTypesRoute } from "./routes/content/list-types.js";
71
- import { offeringsRoute } from "./routes/content/offerings.js";
72
- import { pricingRoute } from "./routes/content/pricing.js";
73
- import {
74
- CONTENT_RATE_LIMIT_AUTH,
75
- CONTENT_RATE_LIMIT_UNAUTH,
76
- CONTENT_RATE_LIMIT_WINDOW,
77
- } from "./routes/content/rate-limit.js";
78
- import { searchContentRoute } from "./routes/content/search.js";
79
- import {
80
- shownOfferingsRoute,
81
- type ShownOfferingsRouteOptions,
82
- } from "./routes/content/shown-offerings.js";
83
- import {
84
- leadCallbackRoute,
85
- leadCaptureStatusRoute,
86
- leadSubmitRoute,
87
- leadSubscribeRoute,
88
- } from "./routes/leads/index.js";
89
- import { leadListRoute } from "./routes/leads/list.js";
90
- import { requestRoutes } from "./routes/requests/index.js";
91
- import {
92
- registerTestAuthRoute,
93
- shouldMountTestAuth,
94
- } from "./routes/test/auth/session.js";
95
- import { consentLinkSecretConfigured } from "./services/consent.js";
96
- import { stripeCatalogSourceFor } from "./services/offerings/source.js";
97
- import { resolveCallerSite } from "./services/public-site-resolver.js";
36
+ buildServer,
37
+ config,
38
+ OPTIONAL_MODULES,
39
+ type ApiHub,
40
+ type ApiModule,
41
+ type OptionalModule,
42
+ } from "@working-theory/api";
43
+ import { loadSlice } from "@working-theory/api/lib/slice-load.js";
44
+ import { logger } from "@working-theory/observability";
45
+ import type { FastifyInstance } from "fastify";
98
46
 
99
47
  /**
100
- * What a request looks like in a log line — Fastify's own default shape, minus
101
- * the QUERY STRING (#5970 security review).
102
- *
103
- * Exported so the property can be asserted directly rather than inferred from
104
- * a captured line, because the thing it protects is easy to regress silently:
105
- * pino's stock `req` serializer logs `req.raw.url` verbatim, and a route may
106
- * carry its credential there. `GET /v1/requests/:id/confirm?token=…` does,
107
- * since a link in an email has nowhere else to put one, and that token is live
108
- * for seven days and mints a chargeable Stripe artifact.
109
- *
110
- * Nothing that identifies a request is lost: a route parameter such as `:id`
111
- * lives in the PATH and survives. Only the secret after `?` is dropped.
112
- *
113
- * Path segments that are themselves credentials or identifiers — a `cs_…`
114
- * Checkout Session id (#6084 D14), a `lead_…` id (#6216 D11), a `person_…` id
115
- * (#8438 SEC-8438-10) and a webhook connection token (#8932 D10) — are
116
- * redacted by `redactLogPath` (`lib/error-serializer.ts`), the ONE path
117
- * redactor this serializer and the global error handler share, so a request
118
- * line and an error line can never disagree about what a path may carry.
119
- * Fastify's automatic "incoming request" line is emitted by its internal hook
120
- * BEFORE any route hook can intervene, so no route-level discipline can fix
121
- * it; this is the only place that can.
48
+ * The hub package (#9571 D5), the same specifier-constant form as the module
49
+ * objects below: `tsc` never resolves it, so the public tree — which carries
50
+ * no `packages-paid/` — still compiles, and `@vercel/nft` traces it.
51
+ * INSTALLED means the package's manifest (its `./package.json` export)
52
+ * resolves from this composition; the manifest imports nothing, so a
53
+ * not-found there is exactly "the package is absent". Any error loading the
54
+ * package itself — a missing transitive dependency, an untraced file — is
55
+ * rethrown and stops the boot: a broken install never boots as a deployment
56
+ * with no hub (SEC-9571-5).
122
57
  */
123
- export function requestLogSerializer(request: FastifyRequest) {
124
- return {
125
- method: request.method,
126
- url: redactLogPath(request.url),
127
- host: request.hostname,
128
- remoteAddress: request.ip,
129
- };
58
+ const HUB_PACKAGE = "@working-theory/api-hub";
59
+ const HUB_MANIFEST = "@working-theory/api-hub/package.json";
60
+
61
+ const MODULE_NOT_FOUND_CODES = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]);
62
+
63
+ async function loadInstalledHub(): Promise<ApiHub | undefined> {
64
+ try {
65
+ await import(HUB_MANIFEST, { with: { type: "json" } });
66
+ } catch (error) {
67
+ const code = (error as { code?: unknown } | null | undefined)?.code;
68
+ if (typeof code === "string" && MODULE_NOT_FOUND_CODES.has(code)) return undefined;
69
+ throw error;
70
+ }
71
+ const mod = (await import(HUB_PACKAGE)) as { hub?: ApiHub };
72
+ if (!mod.hub) {
73
+ throw new Error(`hub export missing from ${HUB_PACKAGE}`);
74
+ }
75
+ return mod.hub;
130
76
  }
131
77
 
132
- /**
133
- * Fastify's default 404, with the logged path redacted (#8932 SEC-8932-10).
134
- * The default handler logs `Route METHOD:URL not found` with the raw URL in
135
- * the message, where `requestLogSerializer` never reaches, so a mis-pasted
136
- * webhook URL — a trailing slash, a double slash, an extra segment — would
137
- * log its connection token on every Stripe retry. The answer is the default
138
- * one, byte for byte; only the log line changes.
139
- */
140
- export function notFoundHandler(request: FastifyRequest, reply: FastifyReply) {
141
- const { method, url = "" } = request.raw;
142
- request.log.info(`Route ${method}:${redactLogPath(url)} not found`);
143
- return reply.code(404).send({
144
- message: `Route ${method}:${url} not found`,
145
- error: "Not Found",
146
- statusCode: 404,
147
- });
148
- }
78
+ const MULTI_TENANT_MODULE = "./modules/multi-tenant.js";
79
+ const WORKSPACE_MODULE = "./modules/workspace.js";
80
+ const SCHEDULING_MODULE = "./modules/scheduling.js";
81
+ const SIGNAL_COLLECTORS_MODULE = "./modules/signal-collectors.js";
82
+ const COMMERCE_MODULE = "./modules/commerce.js";
83
+ const CONTENT_WRITE_MODULE = "./modules/content-write.js";
149
84
 
150
85
  /**
151
- * Paid-module SLICE entry points, keyed by the export each branch needs
152
- * (#5182).
153
- *
154
- * Every value here names a file the OSS strip removes. A shipping file may not
155
- * name a stripped file in ANY import form (spec D1): `import()` defers
156
- * EVALUATION, not RESOLUTION, so `tsc` resolves a string-literal specifier
157
- * whether or not the `isModuleEnabled()` branch containing it can ever run. The
158
- * ten literal `await import()` calls that used to sit at these call sites were
159
- * therefore ten TS2307 errors on the public tree, and the static
160
- * `./middleware/tenant.js` import was an eleventh — measured on a stripped
161
- * tree, not argued.
162
- *
163
- * Keeping the specifier off the `import()` call site is what defeats that
164
- * resolution, and the INDIRECTION — not the type of the value — is what does
165
- * the work. WHICH FORM of indirection is not free, and #5304 is the correction:
166
- * reading the specifier out of a TABLE, as this file did until then, hid it
167
- * from the deploy-time file tracer as well as from `tsc`, so the files were
168
- * compiled and never shipped. The form below — a module-level constant
169
- * dereferenced into `import()` — is unresolvable to `tsc` and traceable to
170
- * `@vercel/nft`, the only pair of properties that satisfies both the strip and
171
- * the deployment. S1's `isModuleEnabled()` gate is unchanged and still decides
172
- * whether the branch runs; `AILK_ENABLED_MODULES` unset still means every
173
- * module on, so a default build registers exactly what it registered before.
174
- *
175
- * This mechanism is sound here because `apps/api` is a `tsc`-only surface with
176
- * no bundler. It is NOT sound in `packages/ui`: webpack cannot statically
177
- * analyse a non-literal `import()` either, and compiles it to an async context
178
- * with no members, so the module silently fails to load on the tree that HAS
179
- * it. See the note above the imports in `packages/ui/src/sections/SectionRenderer.tsx`.
180
- *
181
- * BUNDLING `apps/api` IS NOT A SUPPORTED DEPLOY PATH, and that is a property of
182
- * the analysers rather than a posture (#5334). esbuild bundles a dynamic
183
- * `import()` only when its argument is a string literal at the call site —
184
- * exactly the predicate `tsc` resolves on, and exactly the one the strip
185
- * forbids. So no expression shape can satisfy both, and in a bundled build
186
- * these specifiers survive verbatim and resolve against the BUNDLE OUTPUT
187
- * directory instead of their source module's: `"./list.js"` in
188
- * `routes/project-listings/index.ts` becomes `dist/list.js` and is not there.
189
- * esbuild does not warn. `build()` then throws during CONSTRUCTION, so every
190
- * route 500s rather than one — measured in production, nine hours of total API
191
- * outage on a consuming site. The supported path is what the scaffold ships:
192
- * plain `tsc` output traced by `@vercel/node` (`apps/api/vercel.json`,
193
- * `framework: null`). If you are syncing this file into a repo that bundles,
194
- * that is the constraint you have just inherited; `apps/api/CLAUDE.md`
195
- * § Deploy path says what to do instead. Every load site below routes through
196
- * `loadSlice()` so a bundled build says so at boot rather than failing mute.
197
- *
198
- * `./routes/project-listings/index.js` carries two independent load paths on
199
- * purpose: its five anonymous-surface exports are reached below through
200
- * `PROJECT_LISTINGS_INDEX` / `SLICE_PLUGINS`, now that signal-collectors gates
201
- * their registration (D4), while the tenant scope's own `import()` of the same
202
- * specifier stays a literal — the file is a MIXED directory that always ships
203
- * (multi-tenant's `TENANT_HANDLERS` table lives in the same file), so a literal
204
- * there resolves fine on every tree regardless of module selection.
205
- */
206
- /**
207
- * One module-level constant per slice module. The call sites below read the
208
- * loader out of `SLICE_PLUGINS`; the specifier itself is never written at an
209
- * `import()` call site, which is what keeps `tsc` from resolving it (#5182 D1).
210
- *
211
- * The constant is not a style choice — it is the mechanism (#5304 D3).
212
- * `@vercel/nft` constant-propagates a module-level binding into `import()` and
213
- * emits the file; it cannot evaluate a table lookup or a for-of destructure,
214
- * which is what the previous `Record<string, string>` shape forced it to do.
215
- * So the old shape was invisible to the DEPLOY tracer as well as to `tsc`, the
216
- * files went untraced and unshipped, and `build()` threw ERR_MODULE_NOT_FOUND
217
- * on the first slice it reached — measured in production, nine hours of total
218
- * API outage on a consuming site. Both analysers are re-measured on every run
219
- * by `scripts/__tests__/enumerate-slice-specifiers.test.ts`, and
220
- * `scripts/check-slice-reachability.ts` fails when any specifier here stops
221
- * being traced.
222
- */
223
- const WORKSPACES_INDEX = "./routes/workspaces/index.js";
224
- const WORKSPACES_SESSIONS = "./routes/workspaces/sessions.js";
225
- const WORKSPACES_CONFIG = "./routes/workspaces/config.js";
226
- const WORKSPACES_GENERAL = "./routes/workspaces/general.js";
227
- const WORKSPACES_MEMBERS = "./routes/workspaces/members.js";
228
- // #6216 — a SIBLING specifier registered in the SAME workspace/multi-tenant
229
- // scope as WORKSPACES_GENERAL/WORKSPACES_MEMBERS below, not the
230
- // multi-tenant-only family WORKSPACES_MEETINGS/WORKSPACES_CONFIG sit in.
231
- const WORKSPACES_MESSAGES = "./routes/workspaces/messages.js";
232
- // #7535 — the Inbox submissions and subscribers reads, siblings of
233
- // WORKSPACES_MESSAGES in the same workspace/multi-tenant scope.
234
- const WORKSPACES_SUBMISSIONS = "./routes/workspaces/submissions.js";
235
- // #9170 — the Inbox orders read, the SAME scope.
236
- const WORKSPACES_ORDERS = "./routes/workspaces/orders.js";
237
- const WORKSPACES_SUBSCRIBERS = "./routes/workspaces/subscribers.js";
238
- // #852 D8 — the People reads (the workspace's clients), the SAME scope.
239
- const WORKSPACES_PEOPLE = "./routes/workspaces/people.js";
240
- // #7877 D9 — a client's pieces, plan and outbound messages, and the
241
- // workspace's piece stages: the writes owners, admins and (#8514) members
242
- // make, the SAME scope.
243
- const WORKSPACES_PIECES = "./routes/workspaces/pieces.js";
244
- const WORKSPACES_PLAN = "./routes/workspaces/plan.js";
245
- const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
246
- // #8171 D8 — the workspace's Requests read (the inbound member rows), the
247
- // SAME scope.
248
- const WORKSPACES_REQUESTS = "./routes/workspaces/requests.js";
249
- // #9254 DD5 — the workspace's Callbacks read (request-and-confirm rows), the
250
- // SAME scope.
251
- const WORKSPACES_CALLBACKS = "./routes/workspaces/callbacks.js";
252
- // #9287 DD2 — the owner's Start, Get and Extend trial, the SAME scope.
253
- const WORKSPACES_TRIAL = "./routes/workspaces/trial.js";
254
- // #7877 D11 — the member reads: auth only, under the SAME module predicate.
255
- const MEMBER_PIECES = "./routes/member/pieces.js";
256
- const MEMBER_MESSAGES = "./routes/member/messages.js";
257
- // #8169 D9 — the Phase 1 member reads. Five under the SAME predicate; the two
258
- // that reach services/scheduling/ under scheduling && (workspace ||
259
- // multi-tenant), because only the scheduling module delivers that directory.
260
- const MEMBER_PURCHASES = "./routes/member/purchases.js";
261
- const MEMBER_APPOINTMENTS = "./routes/member/appointments.js";
262
- const MEMBER_ARTIFACTS = "./routes/member/artifacts.js";
263
- const MEMBER_REQUEST_FORM = "./routes/member/request-form.js";
264
- const MEMBER_BILLING = "./routes/member/billing.js";
265
- const MEMBER_MEETING_TYPES = "./routes/member/meeting-types.js";
266
- const MEMBER_AVAILABILITY = "./routes/member/availability.js";
267
- // #8170 D11 — the member writes. Four under the tenancy predicate (the
268
- // message POST rides MEMBER_MESSAGES); the booking, which reaches
269
- // services/scheduling/, under scheduling && (workspace || multi-tenant).
270
- const MEMBER_REQUESTS = "./routes/member/requests.js";
271
- const MEMBER_ARTIFACT_REVIEW = "./routes/member/artifact-review.js";
272
- const MEMBER_PROFILE = "./routes/member/profile.js";
273
- const MEMBER_DELETION_REQUEST = "./routes/member/deletion-request.js";
274
- const MEMBER_BOOKINGS = "./routes/member/bookings.js";
275
- // #8438 D6 — the member's own consents, under the tenancy predicate beside the
276
- // other member routes. D8 — the Admin owner's consents to Working Theory,
277
- // under multi-tenant alone (the module that contributes the Admin app).
278
- const MEMBER_CONSENTS = "./routes/member/consents.js";
279
- // #8515 D13 — the client's onboarding read and write, under the tenancy
280
- // predicate beside the request-quote pair.
281
- const MEMBER_ONBOARDING = "./routes/member/onboarding.js";
282
- const CONSENT_PLATFORM = "./routes/consent/platform.js";
283
- // #3591 PR-B — the agent seat's management routes and an agent's own
284
- // context, siblings of WORKSPACES_MEMBERS in the same workspace/multi-tenant
285
- // scope.
286
- const WORKSPACES_AGENTS = "./routes/workspaces/agents.js";
287
- // #6183 S9b — a SEPARATE specifier from WORKSPACES_CONFIG: this route is
288
- // registered under isModuleEnabled("scheduling") alone, never the
289
- // workspace/multi-tenant scope config.js shares with general.js/members.js
290
- // (see config.ts's own header for why).
291
- const WORKSPACES_MEETINGS = "./routes/workspaces/meetings.js";
292
- const WORKSPACES_CONTENT = "./routes/workspaces/content.js";
293
- const ASSETS_UPLOAD = "./routes/assets/upload.js";
294
- const ASSETS_READ = "./routes/assets/read.js";
295
- const ASSETS_MANAGE = "./routes/assets/manage.js";
296
- const BILLING_INDEX = "./routes/billing/index.js";
297
- const WORKSPACES_OFFERINGS = "./routes/workspaces/offerings.js";
298
- const WAITLIST_EXPERIMENTS_INDEX = "./routes/waitlist-experiments/index.js";
299
- const SCHEDULE_INDEX = "./routes/schedule/index.js";
300
- const FLOWS_INDEX = "./routes/flows/index.js";
301
- const DELIVERABLES_INDEX = "./routes/deliverables/index.js";
302
- const PROJECT_LISTINGS_INDEX = "./routes/project-listings/index.js";
303
- const SLACK_INDEX = "./routes/slack/index.js";
304
- const WAITLIST_SIGNUPS_INDEX = "./routes/waitlist-signups/index.js";
305
- const CHECKOUT_INDEX = "./routes/checkout/index.js";
306
- const WEBHOOKS_INDEX = "./routes/webhooks/index.js";
307
- const FLOW_CHECKOUTS_INDEX = "./routes/flow-checkouts/index.js";
308
- const SUBSCRIPTION_INDEX = "./routes/subscription/index.js";
309
- const RECOMMENDER_INDEX = "./routes/flows/recommender.js";
310
- const MAGNETS_INDEX = "./routes/magnets/index.js";
311
-
312
- /**
313
- * Paid-module SLICE entry points, keyed by the export each branch needs.
314
- *
315
- * `specifier` is carried alongside `load` for the drift message below, and both
316
- * read the SAME constant, so the two can never disagree. `load` is what runs:
317
- * an arrow closing over that constant, which is the only shape both analysers
86
+ * `specifier` is carried alongside `load` for `loadSlice()`'s diagnostic, and
87
+ * both read the SAME constant, so the two can never disagree. `load` is what
88
+ * runs: an arrow closing over that constant, the only shape both analysers
318
89
  * treat as intended — unresolvable to `tsc`, traceable to `nft`.
319
90
  */
320
- const SLICE_PLUGINS = {
321
- workspaceContextRoutes: {
322
- specifier: WORKSPACES_INDEX,
323
- load: () => import(WORKSPACES_INDEX),
324
- },
325
- workspaceSessionRoutes: {
326
- specifier: WORKSPACES_INDEX,
327
- load: () => import(WORKSPACES_INDEX),
328
- },
329
- sessionRoutes: {
330
- specifier: WORKSPACES_SESSIONS,
331
- load: () => import(WORKSPACES_SESSIONS),
332
- },
333
- workspaceConfigRoutes: {
334
- specifier: WORKSPACES_CONFIG,
335
- load: () => import(WORKSPACES_CONFIG),
336
- },
337
- workspaceGeneralRoutes: {
338
- specifier: WORKSPACES_GENERAL,
339
- load: () => import(WORKSPACES_GENERAL),
340
- },
341
- workspaceMembersRoutes: {
342
- specifier: WORKSPACES_MEMBERS,
343
- load: () => import(WORKSPACES_MEMBERS),
344
- },
345
- // #6216 — the Inbox message API, the SAME scope as the two above.
346
- workspaceMessageRoutes: {
347
- specifier: WORKSPACES_MESSAGES,
348
- load: () => import(WORKSPACES_MESSAGES),
349
- },
350
- // #7535 — the Inbox submissions and subscribers reads, the SAME scope.
351
- workspaceSubmissionRoutes: {
352
- specifier: WORKSPACES_SUBMISSIONS,
353
- load: () => import(WORKSPACES_SUBMISSIONS),
354
- },
355
- // #9170 — the Inbox orders read, the SAME scope.
356
- workspaceOrderRoutes: {
357
- specifier: WORKSPACES_ORDERS,
358
- load: () => import(WORKSPACES_ORDERS),
359
- },
360
- workspaceSubscriberRoutes: {
361
- specifier: WORKSPACES_SUBSCRIBERS,
362
- load: () => import(WORKSPACES_SUBSCRIBERS),
363
- },
364
- // #852 D8 — the People list and Person record, the SAME scope.
365
- workspacePeopleRoutes: {
366
- specifier: WORKSPACES_PEOPLE,
367
- load: () => import(WORKSPACES_PEOPLE),
368
- },
369
- // #7877 D9 — the member area's operator routes (owners, admins and, since
370
- // #8514, members), the SAME scope.
371
- workspacePieceRoutes: {
372
- specifier: WORKSPACES_PIECES,
373
- load: () => import(WORKSPACES_PIECES),
374
- },
375
- // #8171 D8 — the Requests read, the SAME scope.
376
- workspaceRequestRoutes: {
377
- specifier: WORKSPACES_REQUESTS,
378
- load: () => import(WORKSPACES_REQUESTS),
379
- },
380
- // #9254 DD5 — the Callbacks read, the SAME scope.
381
- workspaceCallbackRoutes: {
382
- specifier: WORKSPACES_CALLBACKS,
383
- load: () => import(WORKSPACES_CALLBACKS),
384
- },
385
- // #9287 DD2 — the trial routes, the SAME scope.
386
- workspaceTrialRoutes: {
387
- specifier: WORKSPACES_TRIAL,
388
- load: () => import(WORKSPACES_TRIAL),
389
- },
390
- workspacePlanRoutes: {
391
- specifier: WORKSPACES_PLAN,
392
- load: () => import(WORKSPACES_PLAN),
393
- },
394
- workspaceOutboundMessageRoutes: {
395
- specifier: WORKSPACES_MESSAGES_OUT,
396
- load: () => import(WORKSPACES_MESSAGES_OUT),
397
- },
398
- // #7877 D11 — the member reads, in authScope under the same predicate.
399
- memberPieceRoutes: {
400
- specifier: MEMBER_PIECES,
401
- load: () => import(MEMBER_PIECES),
402
- },
403
- memberMessageRoutes: {
404
- specifier: MEMBER_MESSAGES,
405
- load: () => import(MEMBER_MESSAGES),
406
- },
407
- // #8169 D9 — the Phase 1 member reads, in authScope beside the two above.
408
- memberPurchaseRoutes: {
409
- specifier: MEMBER_PURCHASES,
410
- load: () => import(MEMBER_PURCHASES),
411
- },
412
- memberAppointmentRoutes: {
413
- specifier: MEMBER_APPOINTMENTS,
414
- load: () => import(MEMBER_APPOINTMENTS),
415
- },
416
- memberArtifactRoutes: {
417
- specifier: MEMBER_ARTIFACTS,
418
- load: () => import(MEMBER_ARTIFACTS),
419
- },
420
- memberRequestFormRoutes: {
421
- specifier: MEMBER_REQUEST_FORM,
422
- load: () => import(MEMBER_REQUEST_FORM),
423
- },
424
- memberBillingRoutes: {
425
- specifier: MEMBER_BILLING,
426
- load: () => import(MEMBER_BILLING),
427
- },
428
- // The two that reach services/scheduling/ (scheduling-owned, D9).
429
- memberMeetingTypeRoutes: {
430
- specifier: MEMBER_MEETING_TYPES,
431
- load: () => import(MEMBER_MEETING_TYPES),
432
- },
433
- memberAvailabilityRoutes: {
434
- specifier: MEMBER_AVAILABILITY,
435
- load: () => import(MEMBER_AVAILABILITY),
436
- },
437
- // #8170 D11 — the member writes, in authScope beside the reads.
438
- memberRequestRoutes: {
439
- specifier: MEMBER_REQUESTS,
440
- load: () => import(MEMBER_REQUESTS),
441
- },
442
- // #8515 D13 — the onboarding read and write.
443
- memberOnboardingRoutes: {
444
- specifier: MEMBER_ONBOARDING,
445
- load: () => import(MEMBER_ONBOARDING),
446
- },
447
- memberArtifactReviewRoutes: {
448
- specifier: MEMBER_ARTIFACT_REVIEW,
449
- load: () => import(MEMBER_ARTIFACT_REVIEW),
450
- },
451
- memberProfileRoutes: {
452
- specifier: MEMBER_PROFILE,
453
- load: () => import(MEMBER_PROFILE),
454
- },
455
- memberDeletionRequestRoutes: {
456
- specifier: MEMBER_DELETION_REQUEST,
457
- load: () => import(MEMBER_DELETION_REQUEST),
458
- },
459
- // #8438 D6 — the member's own consents, the SAME scope as the writes above.
460
- memberConsentRoutes: {
461
- specifier: MEMBER_CONSENTS,
462
- load: () => import(MEMBER_CONSENTS),
463
- },
464
- // #8438 D8 — the owner's consents to Working Theory (multi-tenant only).
465
- consentPlatformRoutes: {
466
- specifier: CONSENT_PLATFORM,
467
- load: () => import(CONSENT_PLATFORM),
468
- },
469
- // The one that reaches services/scheduling/ (scheduling-owned, D11).
470
- memberBookingRoutes: {
471
- specifier: MEMBER_BOOKINGS,
472
- load: () => import(MEMBER_BOOKINGS),
473
- },
474
- // #3591 PR-B — the agent seat, the SAME scope.
475
- workspaceAgentRoutes: {
476
- specifier: WORKSPACES_AGENTS,
477
- load: () => import(WORKSPACES_AGENTS),
478
- },
479
- // #6183 S9b — registered in the scheduling scope, not the
480
- // workspace/multi-tenant scope its siblings above share.
481
- workspaceMeetingsRoutes: {
482
- specifier: WORKSPACES_MEETINGS,
483
- load: () => import(WORKSPACES_MEETINGS),
484
- },
485
- workspaceContentRoutes: {
486
- specifier: WORKSPACES_CONTENT,
487
- load: () => import(WORKSPACES_CONTENT),
488
- },
489
- workspaceAssetUploadRoute: {
490
- specifier: ASSETS_UPLOAD,
491
- load: () => import(ASSETS_UPLOAD),
492
- },
493
- publicAssetReadRoute: {
494
- specifier: ASSETS_READ,
495
- load: () => import(ASSETS_READ),
496
- },
497
- workspaceAssetManageRoutes: {
498
- specifier: ASSETS_MANAGE,
499
- load: () => import(ASSETS_MANAGE),
500
- },
501
- billingRoutes: {
502
- specifier: BILLING_INDEX,
503
- load: () => import(BILLING_INDEX),
504
- },
505
- // #8926 D1 — the workspace Offerings routes, multi-tenant only.
506
- workspaceOfferingsRoutes: {
507
- specifier: WORKSPACES_OFFERINGS,
508
- load: () => import(WORKSPACES_OFFERINGS),
509
- },
510
- waitlistExperimentRoutes: {
511
- specifier: WAITLIST_EXPERIMENTS_INDEX,
512
- load: () => import(WAITLIST_EXPERIMENTS_INDEX),
513
- },
514
- listAvailabilityRoute: {
515
- specifier: SCHEDULE_INDEX,
516
- load: () => import(SCHEDULE_INDEX),
517
- },
518
- getEventMetaRoute: {
519
- specifier: SCHEDULE_INDEX,
520
- load: () => import(SCHEDULE_INDEX),
521
- },
522
- createBookingRoute: {
523
- specifier: SCHEDULE_INDEX,
524
- load: () => import(SCHEDULE_INDEX),
525
- },
526
- // #6183 S9b D2/D12 — the enabled-meeting-slugs public read; same
527
- // specifier as the three anonymous schedule routes above (§ D6's
528
- // precedent, no new SLICE_PLUGINS specifier constant needed).
529
- meetingTypesRoute: {
530
- specifier: SCHEDULE_INDEX,
531
- load: () => import(SCHEDULE_INDEX),
532
- },
533
- // #8843 D1 — the meetings read; same specifier as its schedule siblings.
534
- listMeetingsRoute: {
535
- specifier: SCHEDULE_INDEX,
536
- load: () => import(SCHEDULE_INDEX),
537
- },
538
- // #6085 — the tenant-guarded appointments read; same specifier as the
539
- // three anonymous schedule routes above (§ D6, no new SLICE_PLUGINS
540
- // specifier constant needed).
541
- listAppointmentsRoute: {
542
- specifier: SCHEDULE_INDEX,
543
- load: () => import(SCHEDULE_INDEX),
544
- },
545
- getFlowRoute: {
546
- specifier: FLOWS_INDEX,
547
- load: () => import(FLOWS_INDEX),
548
- },
549
- submitFlowStepRoute: {
550
- specifier: FLOWS_INDEX,
551
- load: () => import(FLOWS_INDEX),
552
- },
553
- getFlowAggregateRoute: {
554
- specifier: FLOWS_INDEX,
555
- load: () => import(FLOWS_INDEX),
556
- },
557
- // #7876 D1 — a spoke seeds its declared flows; off until
558
- // AILK_SPOKE_FLOW_SEED (the route answers 403 flow_seed_disabled).
559
- putFlowDefinitionRoute: {
560
- specifier: FLOWS_INDEX,
561
- load: () => import(FLOWS_INDEX),
562
- },
563
- // #6157 — the tenant-scoped magnet Admin authoring surface. ONE plugin,
564
- // ONE specifier (D2): all four routes register through this one entry.
565
- magnetRoutes: {
566
- specifier: MAGNETS_INDEX,
567
- load: () => import(MAGNETS_INDEX),
568
- },
569
- deliverableDownloadRoute: {
570
- specifier: DELIVERABLES_INDEX,
571
- load: () => import(DELIVERABLES_INDEX),
572
- },
573
- // #8848 D5 — an ungated file's bytes, in its own `deliverableopen` scope.
574
- deliverableOpenRoute: {
575
- specifier: DELIVERABLES_INDEX,
576
- load: () => import(DELIVERABLES_INDEX),
577
- },
578
- projectListingStartRoute: {
579
- specifier: PROJECT_LISTINGS_INDEX,
580
- load: () => import(PROJECT_LISTINGS_INDEX),
581
- },
582
- projectListingDraftPatchRoute: {
583
- specifier: PROJECT_LISTINGS_INDEX,
584
- load: () => import(PROJECT_LISTINGS_INDEX),
585
- },
586
- projectListingSubmitRoute: {
587
- specifier: PROJECT_LISTINGS_INDEX,
588
- load: () => import(PROJECT_LISTINGS_INDEX),
589
- },
590
- // #5407 — the resume link's verification write. Anonymous surface,
591
- // module-conditional (signal-collectors, D-48) like its four siblings.
592
- projectListingVerifyEmailRoute: {
593
- specifier: PROJECT_LISTINGS_INDEX,
594
- load: () => import(PROJECT_LISTINGS_INDEX),
595
- },
596
- projectListingPublicRoute: {
597
- specifier: PROJECT_LISTINGS_INDEX,
598
- load: () => import(PROJECT_LISTINGS_INDEX),
599
- },
600
- // #7521 — the landing-page directory read; its own rate-limited scope below.
601
- projectListingDirectoryRoute: {
602
- specifier: PROJECT_LISTINGS_INDEX,
603
- load: () => import(PROJECT_LISTINGS_INDEX),
604
- },
605
- projectListingMeRoute: {
606
- specifier: PROJECT_LISTINGS_INDEX,
607
- load: () => import(PROJECT_LISTINGS_INDEX),
608
- },
609
- slackActionsRoute: {
610
- specifier: SLACK_INDEX,
611
- load: () => import(SLACK_INDEX),
612
- },
613
- waitlistSignupRoutes: {
614
- specifier: WAITLIST_SIGNUPS_INDEX,
615
- load: () => import(WAITLIST_SIGNUPS_INDEX),
616
- },
617
- // Commerce (D-47, #5364), re-cut by D-64 (#5796). `checkoutRoutes` and
618
- // `webhooksRoutes` are the base-tree PRIMITIVES now: they register
619
- // UNCONDITIONALLY and gate at call time on the `commerce` license claim
620
- // (lib/require-commerce-claim.ts), so they are no longer slice-gated even
621
- // though they still load through this table. `flowCheckoutRoutes` and the
622
- // two recommender entries below stay module-conditional (isModuleEnabled
623
- // "commerce") — they are commerce OVERLAY, not primitives.
624
- checkoutRoutes: {
625
- specifier: CHECKOUT_INDEX,
626
- load: () => import(CHECKOUT_INDEX),
91
+ const MODULE_OBJECTS: Record<
92
+ OptionalModule,
93
+ { specifier: string; load: () => Promise<unknown> }
94
+ > = {
95
+ "multi-tenant": {
96
+ specifier: MULTI_TENANT_MODULE,
97
+ load: () => import(MULTI_TENANT_MODULE),
627
98
  },
628
- webhooksRoutes: {
629
- specifier: WEBHOOKS_INDEX,
630
- load: () => import(WEBHOOKS_INDEX),
99
+ workspace: {
100
+ specifier: WORKSPACE_MODULE,
101
+ load: () => import(WORKSPACE_MODULE),
631
102
  },
632
- flowCheckoutRoutes: {
633
- specifier: FLOW_CHECKOUTS_INDEX,
634
- load: () => import(FLOW_CHECKOUTS_INDEX),
103
+ scheduling: {
104
+ specifier: SCHEDULING_MODULE,
105
+ load: () => import(SCHEDULING_MODULE),
635
106
  },
636
- // #6038 — the buyer-scoped subscription-status route pair (the
637
- // update-your-card banner's read + portal handoff). Commerce OVERLAY,
638
- // same posture as flowCheckoutRoutes immediately above.
639
- subscriptionRoutes: {
640
- specifier: SUBSCRIPTION_INDEX,
641
- load: () => import(SUBSCRIPTION_INDEX),
107
+ "signal-collectors": {
108
+ specifier: SIGNAL_COLLECTORS_MODULE,
109
+ load: () => import(SIGNAL_COLLECTORS_MODULE),
642
110
  },
643
- saveRecommendationRoute: {
644
- specifier: RECOMMENDER_INDEX,
645
- load: () => import(RECOMMENDER_INDEX),
111
+ commerce: {
112
+ specifier: COMMERCE_MODULE,
113
+ load: () => import(COMMERCE_MODULE),
646
114
  },
647
- checkoutRecommendationRoute: {
648
- specifier: RECOMMENDER_INDEX,
649
- load: () => import(RECOMMENDER_INDEX),
115
+ "content-write": {
116
+ specifier: CONTENT_WRITE_MODULE,
117
+ load: () => import(CONTENT_WRITE_MODULE),
650
118
  },
651
- } satisfies Record<string, { specifier: string; load: () => Promise<unknown> }>;
652
-
653
- type SlicePluginName = keyof typeof SLICE_PLUGINS;
654
-
655
- /**
656
- * Load one slice route plugin. Every entry above is a `FastifyPluginAsync`
657
- * export, which is the cast's whole content — `import(nonLiteral)` yields
658
- * `any`, so this boundary is the load-bearing line and names the exact shape
659
- * rather than spreading `any` into `register()`.
660
- *
661
- * Throws rather than skipping if the export is missing: the branch only runs
662
- * when the module is declared enabled, so an absent export is a build/packaging
663
- * fault to surface at boot, not a condition to degrade through.
664
- */
665
- async function sliceRoutePlugin(
666
- name: SlicePluginName,
667
- ): Promise<FastifyPluginAsync> {
668
- const { specifier, load } = SLICE_PLUGINS[name];
669
- const mod = (await loadSlice(specifier, load)) as Record<
670
- string,
671
- FastifyPluginAsync | undefined
672
- >;
673
- const plugin = mod[name];
674
- if (!plugin) {
675
- throw new Error(`slice export ${name} missing from ${specifier}`);
676
- }
677
- return plugin;
678
- }
119
+ };
679
120
 
680
121
  /**
681
- * The multi-tenant preHandler plugin — the one slice entry that is not a
682
- * route plugin. `FastifyPluginCallback`, registered with no options
683
- * (apps/api/src/middleware/tenant.ts:83).
684
- */
685
- async function tenantPreHandlerSlicePlugin(): Promise<FastifyPluginCallback> {
686
- const specifier: string = "./middleware/tenant.js";
687
- const mod = (await loadSlice(specifier, () => import(specifier))) as {
688
- tenantPreHandlerPlugin?: FastifyPluginCallback;
689
- };
690
- const plugin = mod.tenantPreHandlerPlugin;
691
- if (!plugin) {
692
- throw new Error(
693
- "slice export tenantPreHandlerPlugin missing from " + specifier,
694
- );
695
- }
696
- return plugin;
697
- }
698
-
699
- /**
700
- * The tenant guard THIS build runs (#5677). Exactly one of the two, never
701
- * both: the multi-tenant module's `resolveActiveTenant` guard when the module
702
- * is enabled, and the base-build default-workspace guard
703
- * (`middleware/default-tenant.ts`) otherwise.
704
- *
705
- * Precedence, not parallelism. On a build WITH the module this returns the
706
- * identical plugin every tenant scope registered before, so multi-tenant
707
- * resolution and behavior are unchanged. On a build without it, the base guard
708
- * resolves the seeded default Workspace (`database/tenancy.ts`) under the same
709
- * default-deny posture — which is what makes the already-built review
710
- * dashboard reachable at Pro instead of leaving its routes unregistered.
711
- *
712
- * The base guard is a STATIC import, not a slice load: it always ships.
713
- */
714
- async function tenantPreHandlerForBuild(): Promise<FastifyPluginCallback> {
715
- return isModuleEnabled("multi-tenant")
716
- ? await tenantPreHandlerSlicePlugin()
717
- : defaultTenantPreHandlerPlugin;
718
- }
719
-
720
- /**
721
- * The commerce boot-time key admission — the one other slice entry that is
722
- * not a route plugin, mirroring `tenantPreHandlerSlicePlugin()` above (D3).
723
- * `lib/stripe.ts` is commerce-owned (D5) and its `admitApiStripeKey` is the
724
- * sole `apps/api` reach into the Stripe SDK wrapper package, so it can no
725
- * longer be a static import (spec D1) once the module is stripped. The
726
- * widened `const specifier: string` annotation is what defeats `tsc`
727
- * resolution, exactly as it does above.
728
- *
729
- * #8450 D4: called with NO argument, so the admission covers the same
730
- * `process.env` key `getStripeClient()` later reads. `STRIPE_MODE` unset is
731
- * today's sandbox-only check; only a restricted live key beside
732
- * `STRIPE_MODE=live` on a production build comes back `"live"`.
733
- */
734
- async function admitCommerceStripeKey(): Promise<"test" | "live"> {
735
- const specifier: string = "./lib/stripe.js";
736
- const mod = (await loadSlice(specifier, () => import(specifier))) as {
737
- admitApiStripeKey?: () => "test" | "live";
738
- };
739
- const admitApiStripeKeyFn = mod.admitApiStripeKey;
740
- if (!admitApiStripeKeyFn) {
741
- throw new Error("slice export admitApiStripeKey missing from " + specifier);
742
- }
743
- return admitApiStripeKeyFn();
744
- }
745
-
746
- /**
747
- * #6889 (SEC-02 of the #6876 security review) — make the checkout route's
748
- * posture observable. POST /v1/checkout/sessions admits a caller without
749
- * X-Internal-Auth when OSS_API_INTERNAL_SECRET is unset. With FRONTEND_URL
750
- * unset in production, the trusted-origin list (#7395) holds only
751
- * AILK_TRUSTED_ORIGINS — with that unset too, CORS and cookie writes refuse
752
- * every browser origin and the redirect lock (#6877) answers 500
753
- * config-error to that caller. Both stay optional (OSS standalone compat), so
754
- * this only ever WARNS — once per unset variable, and only in production.
755
- * FRONTEND_URL is read from the raw environment because `config.FRONTEND_URL`
756
- * carries a localhost default.
757
- */
758
- function warnOnUnsetProductionProtections(): void {
759
- // #8438 D9 — AILK_CONSENT_LINK_SECRET is optional, but a value shorter than
760
- // 32 bytes is a misconfiguration worth an error at boot: it is treated as
761
- // unset, so no unsubscribe link can be minted or honored. Never the value.
762
- if (
763
- process.env["AILK_CONSENT_LINK_SECRET"] !== undefined &&
764
- process.env["AILK_CONSENT_LINK_SECRET"] !== "" &&
765
- !consentLinkSecretConfigured()
766
- ) {
767
- logger.error(
768
- "[startup] AILK_CONSENT_LINK_SECRET is set but shorter than 32 bytes — treated as unset; no unsubscribe link can be minted or honored",
769
- );
770
- }
771
- if (config.NODE_ENV !== "production") return;
772
- if (!process.env["FRONTEND_URL"]) {
773
- logger.warn(
774
- "[startup] FRONTEND_URL not set in production — CORS, cookie-authenticated writes and checkout redirects trust only AILK_TRUSTED_ORIGINS; with neither set, all three refuse every browser origin and checkout answers 500 config-error to any caller admitted without X-Internal-Auth",
775
- );
776
- }
777
- if (!process.env["OSS_API_INTERNAL_SECRET"]) {
778
- logger.warn(
779
- "[startup] OSS_API_INTERNAL_SECRET not set in production — the checkout route admits callers without X-Internal-Auth",
780
- );
781
- }
782
- }
783
-
784
- /**
785
- * Build Fastify server instance (for serverless or standalone)
122
+ * Build the Fastify server (for serverless or standalone): the six module
123
+ * objects, handed to `@working-theory/api`, which selects and claims.
786
124
  * @returns Configured Fastify instance ready for requests
787
125
  */
788
- export async function build() {
789
- // Startup-time validation: fail fast if LEAD_CAPTURE_FILE points at a missing directory.
790
- await validateLeadCaptureDir();
791
-
792
- warnOnUnsetProductionProtections();
793
-
794
- // #6155 D6 — the site-alias workspace assertion (ownership brief § Layer
795
- // 2, AC-5): resolves + asserts once at startup that AILK_SITE_ID (or its
796
- // "default" fallback) names a real workspace, throwing when it does not —
797
- // the same catch-log-exit(1) path index.ts already runs on this throws
798
- // loud rather than serving empty lists. Carve-out: with no DATABASE_URL
799
- // configured, it logs a warning and returns instead of throwing (a
800
- // database-less dev boot).
801
- await assertSiteAliasResolves();
802
-
803
- // #6677 DD-6 — the renewed licence key's durable home: with a database,
804
- // one deployment-wide SiteSetting row, so a redeploy keeps the key this
805
- // site renewed. Never throws; without DATABASE_URL the file store stays.
806
- await registerLicenseKeyStore();
807
-
808
- // Runtime key admission (#8450 D4): refuse, before any webhook or checkout
809
- // call can exercise it, every live Stripe key this process cannot vouch
810
- // for — all of them unless STRIPE_MODE=live sits beside a restricted live
811
- // key on a production build. UNCONDITIONAL since D-64 (#5796): the
812
- // commerce primitives and lib/stripe.ts are base tree now, so the key's
813
- // presence — not a module flag — is the whole question. A build with no
814
- // STRIPE_SECRET_KEY has nothing to admit.
815
- let stripeMode: "test" | "live" = "test";
816
- if (config.STRIPE_SECRET_KEY) {
817
- stripeMode = await admitCommerceStripeKey();
818
- logger.info({ stripeMode }, "[startup] stripe key admitted");
819
- }
820
-
821
- // trustProxy: true (#4730) — the Vercel serverless handler
822
- // (apps/api/api/index.ts) bridges every request through Fastify's
823
- // `.inject()` rather than a real socket, so `req.socket.remoteAddress`
824
- // (the trustProxy:false default) is a meaningless simulated value, not
825
- // the caller's address. With trustProxy enabled, req.ip instead resolves
826
- // from `X-Forwarded-For` (the header Vercel's own edge sets on every
827
- // inbound request) via Fastify's built-in proxy-addr parsing — reading
828
- // the LEFTMOST address in a multi-hop chain, i.e. the address farthest
829
- // from this process. This is what lets the per-scope rate limiter
830
- // (middleware/rate-limit.ts) key on a real visitor's IP instead of a
831
- // constant fallback or a shared intermediary's egress IP (see the flows/
832
- // leads/aeo/schedule scopes below).
833
- //
834
- // Trade-off: X-Forwarded-For is caller-suppliable to anyone hitting this
835
- // API directly (there is no fixed, allowlistable upstream proxy — this
836
- // API's actual "proxies" are arbitrary consumer web functions), so a
837
- // direct caller can still spoof a fresh identity per request. Per-IP
838
- // rate limiting on these anonymous surfaces is a best-effort abuse
839
- // deterrent, not a hard security boundary; this trades a small amount of
840
- // spoof-resistance for correctness on the overwhelmingly common path
841
- // (server-action-forwarded traffic), which was previously broken for
842
- // every caller.
843
- const server = Fastify({
844
- logger: {
845
- // The request serializer drops the QUERY STRING (#5970 security review,
846
- // finding 1). Fastify's default `req` serializer logs `req.raw.url`
847
- // verbatim, so any route whose credential rides in the query string
848
- // writes that credential into the API's own log on every single request
849
- // — including the automatic "incoming request" line, which is emitted by
850
- // Fastify's internal hook before any route-level hook can intervene.
851
- //
852
- // `GET /v1/requests/:id/confirm?token=…` is exactly such a route: the
853
- // token IS the authorization, it is live for seven days, and holding it
854
- // is enough to read a requester's name and phone and to mint a Payment
855
- // Link on the site's own Stripe account. `Referrer-Policy: no-referrer`
856
- // on that route exists to keep the token out of a THIRD PARTY's logs;
857
- // this keeps it out of ours, which is the larger hole of the two.
858
- //
859
- // The shape is otherwise Fastify's own default, minus the query: a log
860
- // consumer reading `method` + `url` + `remoteAddress` sees what it
861
- // always saw. Nothing that identifies a REQUEST is lost — `:id` is in
862
- // the path and survives; only the secret after `?` is dropped.
863
- serializers: { req: requestLogSerializer },
864
- },
865
- trustProxy: true,
866
- });
867
-
868
- // The hub's forwarding-key digests (#7394 DD-2), parsed once from
869
- // AILK_VISITOR_FORWARD_KEYS and shared by the global ceiling and the leads
870
- // scope below, so both limiters apply one identity rule. A keyed spoke's
871
- // lead write is counted per visitor (`x-ailk-visitor-ip`); everything else
872
- // keys on `req.ip`. Unset → `[]`, which believes no forwarded visitor. The
873
- // parse warns by entry position only, never an entry's value.
874
- const visitorForwardKeyDigests = buildTrustedKeyDigests(
875
- process.env.AILK_VISITOR_FORWARD_KEYS,
876
- (msg) => server.log.warn(msg),
877
- );
878
-
879
- // #8805 — the sign-up evidence read's site key, verified once per request
880
- // and shared by the global ceiling and the `signupevidence` scope below. A
881
- // registered, unrevoked spoke key (`resolveCallerSite`, the registry lookup
882
- // every spoke-aware route uses) is counted per key; a malformed, unknown or
883
- // revoked one, or none, keeps `req.ip`. It decides the bucket only — the
884
- // route still serves `resolveSiteWorkspaceId()`.
885
- const signUpEvidenceSiteKey = siteKeyReader(async (req) => {
886
- const caller = await resolveCallerSite(req);
887
- return caller.kind === "spoke" ? caller.spokeId : null;
888
- });
889
-
890
- // Collect the route table this server registers (#5314).
891
- //
892
- // Installed here — after `Fastify()` and before the first `register()` — so
893
- // no route can precede it: a root-instance `onRoute` hook fires for every
894
- // route added afterwards in ANY encapsulated child scope, which is what lets
895
- // one hook see the auth, tenant, webhooks, leads, flows, aeo and schedule
896
- // scopes without touching any of them.
897
- //
898
- // HEAD is dropped: Fastify synthesises it from every GET, so publishing it
899
- // would describe routes no handler declared.
900
- //
901
- // The table lives on THIS instance, not in module state, so two servers
902
- // built concurrently in one process cannot corrupt each other's — see the
903
- // note on `readRegisteredRoutes` in openapi/spec.ts for the concrete race.
904
- // The decorator holds the array by reference and the hook pushes into it, so
905
- // every reader sees the current contents without re-publishing.
906
- const registeredRoutes: RegisteredRoute[] = [];
907
- server.decorate(REGISTERED_ROUTES_DECORATOR, registeredRoutes);
908
- server.addHook("onRoute", (route) => {
909
- const methods = Array.isArray(route.method) ? route.method : [route.method];
910
- for (const method of methods) {
911
- if (method === "HEAD") continue;
912
- registeredRoutes.push({ method, url: route.url });
913
- }
914
- });
915
-
916
- // Register error serializer — maps internal exceptions to generic strings
917
- // so connection strings, hostnames, stack traces, and SDK error details
918
- // never reach the client. Full detail still goes to pino at error level.
919
- registerErrorSerializer(server);
920
- server.setNotFoundHandler(notFoundHandler);
921
-
922
- // Register CORS — public read API + the trusted browser origins (#7395).
923
- // `origin` is ALWAYS a copy of `config.TRUSTED_ORIGINS`, the frozen list
924
- // config/index.ts resolves once at startup from FRONTEND_URL and
925
- // AILK_TRUSTED_ORIGINS (no `*`, no wildcard host). @fastify/cors echoes a
926
- // request's Origin only on an exact match against that array, and writes no
927
- // allow-origin header otherwise; an empty list means no cross-origin caller.
928
- // Never pass `true`, a function, a RegExp or a delegator here, and never set
929
- // a per-route `cors` option: with `credentials: true`, reflecting any origin
930
- // would let any website read credentialed responses (security review M1).
931
- // Same-origin and non-browser callers are unaffected.
932
- //
933
- // `methods` is EXPLICIT because @fastify/cors defaults it to `GET,HEAD,POST`
934
- // (its own `defaultOptions`), which is narrower than the surface this server
935
- // registers (#5397). With only `origin` + `credentials` passed, the preflight
936
- // for `PATCH /v1/project-listings/drafts/:id` answered
937
- // `access-control-allow-methods: GET, HEAD, POST`, so a browser refused every
938
- // draft save on a split-origin deploy — a site could create a listing draft
939
- // and never save one. The list below is the union of the methods the route
940
- // table actually serves (GET/POST/PATCH/PUT/DELETE — `/v1/auth/tokens/:id`,
941
- // `/v1/content/:type/:slug`, `/v1/user/data/:key`, the workspace-config PUTs),
942
- // plus HEAD (Fastify synthesises it from every GET) and OPTIONS (the plugin's
943
- // own wildcard preflight route). `cors.test.ts` re-derives that union from the
944
- // live route table on every run, so a route added in a NEW method fails there
945
- // rather than silently becoming unreachable from a browser.
946
- //
947
- // This widens WHICH METHODS the allowed origins may use, never WHO is allowed:
948
- // `origin` and `credentials` are untouched.
949
- //
950
- // `allowedHeaders` is deliberately left unset: null means @fastify/cors
951
- // reflects the request's `Access-Control-Request-Headers`, so `authorization`
952
- // and `content-type` pass without this file having to maintain a list that
953
- // silently drops a header a future route needs.
954
- await server.register(cors, {
955
- origin: [...config.TRUSTED_ORIGINS],
956
- credentials: true,
957
- methods: ["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
958
- });
959
-
960
- // Register rate limiting — applied globally with the unauth ceiling. The
961
- // limiter is in-memory by default, which suits a single Vercel function /
962
- // single-process dev server; multi-instance deployments will need a
963
- // Redis store, swappable here without changing route code.
964
- //
965
- // `keyGenerator` (#7394 DD-7): a keyed spoke's lead write counts against its
966
- // visitor's own ceiling here too, or a spoke whose visitors share one
967
- // egress IP would still be capped at this limit in total. For every other
968
- // request it returns `req.ip`, the plugin's own default key.
969
- //
970
- // #8805: a sign-up evidence read with a verified site key is keyed
971
- // `site,<spokeId>` and let through here — its scope's per-key ceiling is
972
- // its one limit, so an anonymous caller spoofing the web's egress address
973
- // cannot spend it, and many web instances behind that address share no
974
- // IP bucket.
975
- await server.register(rateLimit, {
976
- max: CONTENT_RATE_LIMIT_UNAUTH,
977
- timeWindow: CONTENT_RATE_LIMIT_WINDOW,
978
- keyGenerator: async (req) => {
979
- const siteId = await signUpEvidenceSiteKey(req);
980
- return siteId === null
981
- ? resolveRateLimitIdentity(req, visitorForwardKeyDigests).identity
982
- : `${SITE_KEY_IDENTITY_PREFIX}${siteId}`;
983
- },
984
- allowList: (_req, key) =>
985
- typeof key === "string" && key.startsWith(SITE_KEY_IDENTITY_PREFIX),
986
- });
987
-
988
- // Health check
989
- server.get("/health", async () => ({
990
- status: "healthy",
991
- timestamp: new Date().toISOString(),
992
- }));
993
-
994
- // Root endpoint — introduces this deployment as the SITE it serves, not as
995
- // the kit it was built with (#5307). `_site.mdx` is read once per process;
996
- // a site without one degrades to a neutral name, never to the kit's.
997
- //
998
- // `modules` (#7501) is the optional modules whose routes this build
999
- // registers, computed once here — before any route registers — so the index
1000
- // reports what registered rather than re-reading the env per request. The
1001
- // Admin reads it to hide the surfaces whose module is off.
1002
- const modules = servedModules();
1003
- server.get("/", async () => {
1004
- const identity = resolveApiIdentity();
1005
- return {
1006
- name: apiDisplayName(identity),
1007
- version: API_VERSION,
1008
- description: apiDescription(identity),
1009
- modules,
1010
- // Derived from the routes this process actually registered (#5314),
1011
- // not a hand-maintained literal. The literal listed 11 endpoints against
1012
- // 68 registered routes, omitted `/v1/project-listings/public` and
1013
- // `/v1/billing/usage`, advertised `DELETE /v1/auth/tokens` at a path
1014
- // that 404s (the route is `/v1/auth/tokens/:id`), and named two billing
1015
- // routes on builds where the multi-tenant module registers neither.
1016
- // Same mechanism now covers commerce (D-47, #5364): checkout, the
1017
- // Stripe webhook, the flow-checkouts read and the two recommender
1018
- // routes are derived exactly like the billing routes above and
1019
- // disappear from this list on a build where `commerce` is off, rather
1020
- // than advertising a route that 404s.
1021
- //
1022
- // Read at REQUEST time, so `ready()` has fired and the set is complete
1023
- // even though this handler is registered before most routes.
1024
- endpoints: deriveRootEndpoints(registeredRoutes),
1025
- };
1026
- });
1027
-
1028
- // Register authenticated routes — the dual-mode preHandler (#587) runs in
1029
- // this sub-scope so unauthenticated callers never reach token / user data
1030
- // handlers. Cookie-first, Bearer-fallback; see middleware/auth.ts.
1031
- await server.register(async (authScope) => {
1032
- await authScope.register(authPreHandlerPlugin, {
1033
- trustedOrigins: config.TRUSTED_ORIGINS,
1034
- });
1035
- await authScope.register(tokenRoutes);
1036
- await authScope.register(userRoutes);
1037
-
1038
- // The hub signs in its spokes (#7894 D5) — `codes` and `me`. Auth only,
1039
- // NOT tenant-guarded: a person signing in to a spoke has no tenant here.
1040
- // Unconditional, like the leads inbox read below: a spoke's sign-in is not
1041
- // a module. `codes` answers a cookie session only; `me` the hub credential
1042
- // only (both enforced in the route). The credential's own admission is the
1043
- // preHandler's hub-credential branch (D6).
1044
- await authScope.register(hubSignInSessionRoutes);
1045
-
1046
- // #8765 D5 — the caller's account-level sign-up agreements. Auth only,
1047
- // NOT tenant-guarded: the agreement is about the `User` row, read and
1048
- // written in the deployment's own site workspace whatever site asks.
1049
- // Unconditional — auth and consent are never-gate surfaces — and both
1050
- // are in `HUB_SESSION_ROUTES`, so a spoke reaches them with its member's
1051
- // credential (the account layout's interstitial, D7).
1052
- await authScope.register(consentAccountRoutes);
1053
-
1054
- // Tenant-context routes (multi-tenant module) — auth only. They
1055
- // establish/inspect the active workspace, so the tenant guard does not
1056
- // apply to them; each enforces membership in the service layer.
1057
- // Module-conditional (S1, #5088): without the multi-tenant module there
1058
- // is no workspace concept to switch or inspect.
1059
- if (isModuleEnabled("multi-tenant")) {
1060
- await authScope.register(
1061
- await sliceRoutePlugin("workspaceContextRoutes"),
1062
- );
1063
- // #8438 D8 — the Admin owner's consents to Working Theory. Auth only,
1064
- // NOT tenant-guarded (the owner role is checked inside the route), and
1065
- // deliberately NOT in `HUB_SESSION_ROUTES`: a cookie session only.
1066
- await authScope.register(await sliceRoutePlugin("consentPlatformRoutes"));
1067
- }
1068
-
1069
- // The leads INBOX read (#5678) — auth only, NOT tenant-guarded, for the
1070
- // same reason as the three reads below: the tenant scope is registered
1071
- // only when the multi-tenant module is present, and lead capture is a
1072
- // never-gate surface at EVERY level, so reading a lead back cannot be
1073
- // gated on a module either. Registered unconditionally — it is the one
1074
- // route in this scope with no `isModuleEnabled()` gate. Identity is
1075
- // `request.userId`; the scope is resolved from the caller's own
1076
- // memberships by `services/leads-inbox.ts` through
1077
- // `services/owner-workspaces.ts`'s `resolveOwnerWorkspaces`, never from a
1078
- // request parameter.
1079
- await authScope.register(leadListRoute);
1080
-
1081
- // Respondent signup read (#4731) — auth only, NOT tenant-guarded: a
1082
- // respondent has no workspace membership and the tenant guard is
1083
- // default-deny. Identity is request.userId; the service re-checks
1084
- // User.emailVerified before any Lead read. Module-conditional
1085
- // (signal-collectors, D-48): the waitlist instrument's respondent half.
1086
- if (isModuleEnabled("signal-collectors")) {
1087
- await authScope.register(await sliceRoutePlugin("waitlistSignupRoutes"));
1088
- }
1089
-
1090
- // Respondent checkouts read (#4984) — the same posture as the signup
1091
- // read above: auth only, NOT tenant-guarded. Identity is request.userId;
1092
- // the only join is the lead-promotion link (never an email match).
1093
- // Module-conditional (commerce, D-47): the `/account` Purchases panel
1094
- // already degrades to its empty state with no code change when this is
1095
- // absent (`SKIPPED_STATUSES` covers 404 — spec §4.2).
1096
- if (isModuleEnabled("commerce")) {
1097
- await authScope.register(await sliceRoutePlugin("flowCheckoutRoutes"));
1098
- }
1099
-
1100
- // The update-your-card banner's read + portal handoff (#6038) — the
1101
- // same posture as flow-checkouts immediately above: auth only, NOT
1102
- // tenant-guarded (a subscriber has no workspace membership). Identity is
1103
- // request.userId; GET is cookie-or-bearer, POST refuses bearer callers
1104
- // (D3.1). Module-conditional (commerce, D8): the account page's
1105
- // subscription-status read already degrades to "no banner" with no code
1106
- // change when this is absent (SKIPPED_STATUSES covers 404 — spec §4.2).
1107
- if (isModuleEnabled("commerce")) {
1108
- await authScope.register(await sliceRoutePlugin("subscriptionRoutes"));
1109
- }
1110
-
1111
- // The founder's OWN listings read (#5295) — the same posture again: auth
1112
- // only, NOT tenant-guarded, because a founder is not a workspace member
1113
- // and the tenant guard is default-deny. Identity is request.userId; the
1114
- // only join is the owner link (ProjectListing.ownerUserId, stamped by
1115
- // services/listing-promotion.ts), never an email match. The tenant-scoped
1116
- // listing routes below keep their owner|admin floor untouched — this route
1117
- // is additive, not a relaxation of that floor. Registered here rather than
1118
- // inside the multi-tenant scope so a founder can read their own rows on a
1119
- // build without the module; the static `/me` path takes router precedence
1120
- // over the tenant scope's parametric `/v1/project-listings/:id`, exactly as
1121
- // the already-shipped static `/v1/project-listings/public` does.
1122
- // Module-conditional (signal-collectors, D-48 / D4): the listing
1123
- // instrument's founder-read half; not severable from the four anonymous
1124
- // listing routes below (same static barrel, D4).
1125
- if (isModuleEnabled("signal-collectors")) {
1126
- await authScope.register(await sliceRoutePlugin("projectListingMeRoute"));
1127
- }
1128
-
1129
- // The member reads (#7877 D11) — auth only, NOT tenant-guarded: a client
1130
- // holds no membership (and an agent, which reaches only tenant-guarded
1131
- // routes, is refused 403 by the preHandler). Each handler resolves the
1132
- // caller through `memberCaller` and the workspace from the calling
1133
- // spoke's key, never a request value. Registered under the SAME module
1134
- // predicate as every routes/workspaces/* route, at the exact URLs the
1135
- // preHandler's `HUB_SESSION_ROUTES` admits a hub credential on.
1136
- if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
1137
- await authScope.register(await sliceRoutePlugin("memberPieceRoutes"));
1138
- await authScope.register(await sliceRoutePlugin("memberMessageRoutes"));
1139
- // #8169 D9 — the Phase 1 member reads, under the same predicate.
1140
- await authScope.register(await sliceRoutePlugin("memberPurchaseRoutes"));
1141
- await authScope.register(await sliceRoutePlugin("memberAppointmentRoutes"));
1142
- await authScope.register(await sliceRoutePlugin("memberArtifactRoutes"));
1143
- await authScope.register(await sliceRoutePlugin("memberRequestFormRoutes"));
1144
- await authScope.register(await sliceRoutePlugin("memberBillingRoutes"));
1145
- // #8170 D11 — the member writes, under the same predicate.
1146
- await authScope.register(await sliceRoutePlugin("memberRequestRoutes"));
1147
- // #8515 D13 — the onboarding pair, under the same predicate.
1148
- await authScope.register(await sliceRoutePlugin("memberOnboardingRoutes"));
1149
- await authScope.register(await sliceRoutePlugin("memberArtifactReviewRoutes"));
1150
- await authScope.register(await sliceRoutePlugin("memberProfileRoutes"));
1151
- await authScope.register(await sliceRoutePlugin("memberDeletionRequestRoutes"));
1152
- // #8438 D6 — the member's own consents, under the same predicate.
1153
- await authScope.register(await sliceRoutePlugin("memberConsentRoutes"));
1154
- }
1155
- // #8169 D9 — the two member reads that reach services/scheduling/, which
1156
- // only the scheduling module delivers: a scaffold with a tenancy module
1157
- // and no scheduling carries neither file, and one with scheduling and no
1158
- // tenancy module registers neither.
1159
- if (
1160
- isModuleEnabled("scheduling") &&
1161
- (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant"))
1162
- ) {
1163
- await authScope.register(await sliceRoutePlugin("memberMeetingTypeRoutes"));
1164
- await authScope.register(await sliceRoutePlugin("memberAvailabilityRoutes"));
1165
- // #8170 D11 — the member booking, under the same predicate.
1166
- await authScope.register(await sliceRoutePlugin("memberBookingRoutes"));
1167
- }
1168
-
1169
- // Tenant-guarded domain routes (multi-tenant module) — the tenant
1170
- // preHandler (default-deny guard, see middleware/tenant.ts) runs after
1171
- // auth for every route in this inner scope. No domain handler executes
1172
- // without request.tenant, and without the multi-tenant module there is
1173
- // no tenant concept at all — the whole scope is module-conditional
1174
- // (S1, #5088), so an OSS build that excludes multi-tenant registers
1175
- // none of it.
1176
- if (isModuleEnabled("multi-tenant")) {
1177
- await authScope.register(async (tenantScope) => {
1178
- await tenantScope.register(await tenantPreHandlerSlicePlugin());
1179
- await tenantScope.register(
1180
- await sliceRoutePlugin("workspaceSessionRoutes"),
1181
- );
1182
- await tenantScope.register(await sliceRoutePlugin("sessionRoutes"));
1183
- // Org-billing routes (#3701) — org-scoped read + Stripe customer-portal.
1184
- await tenantScope.register(await sliceRoutePlugin("billingRoutes"));
1185
- // The workspace Offerings routes (#8926 D1) — the Library → Offerings
1186
- // tab's read and its selection write, beside the billing routes that
1187
- // write the Stripe connection they need.
1188
- await tenantScope.register(await sliceRoutePlugin("workspaceOfferingsRoutes"));
1189
- });
1190
- }
1191
-
1192
- // REVIEW-SURFACE routes — a sibling tenant scope, gated on EITHER module
1193
- // (#5677). The waitlist operator dashboard and the project-listings
1194
- // dashboard were registered inside the multi-tenant scope above, which was
1195
- // too narrow: `signal-collectors` (Pro) is the module that SELLS the
1196
- // instruments these routes read back — flows, deliverables, project
1197
- // listings, waitlist experiments — so a Pro customer could collect
1198
- // submissions and not read them. That was an accident of registration
1199
- // scope, not a pricing decision (#5677). Widening the gate is the
1200
- // correction.
1201
- //
1202
- // This is a REGISTRATION change, not a tenant-isolation change. The scope
1203
- // registers `tenantPreHandlerForBuild()`, which on a multi-tenant build is
1204
- // byte-for-byte the guard these two route plugins already ran behind, and
1205
- // on a build without the module is the base-build guard with the same
1206
- // default-deny posture over the seeded default Workspace. Each plugin
1207
- // registers exactly once — they live here and nowhere else, so the scopes
1208
- // cannot collide on a path — and every OTHER route in the scope above
1209
- // (workspaceSessionRoutes, sessionRoutes, billingRoutes) stays
1210
- // multi-tenant-only: those are Organization/Workspace administration, not
1211
- // a site's own submissions.
1212
- if (
1213
- isModuleEnabled("multi-tenant") ||
1214
- isModuleEnabled("signal-collectors")
1215
- ) {
1216
- await authScope.register(async (tenantScope) => {
1217
- await tenantScope.register(await tenantPreHandlerForBuild());
1218
- // Waitlist operator-dashboard read API (#4665 D7, B0) — tenant-scoped
1219
- // list/comparison/signups reads over A1's waitlist-experiment rows.
1220
- await tenantScope.register(
1221
- await sliceRoutePlugin("waitlistExperimentRoutes"),
1222
- );
1223
- // Project-listings dashboard (#4998) — tenant-scoped list/get/patch
1224
- // over the founder's-application-row lifecycle.
1225
- const { projectListingTenantRoutes } =
1226
- await import("./routes/project-listings/index.js");
1227
- await tenantScope.register(projectListingTenantRoutes);
1228
- });
1229
- }
1230
-
1231
- // The magnet Admin authoring surface (#6157 S5c) — its OWN sibling
1232
- // tenant scope (D3), not a line inside the review-surface scope above:
1233
- // its module-exclusion row stays its own, and the scope can be read,
1234
- // moved or gated without disturbing `waitlistExperimentRoutes` /
1235
- // `projectListingTenantRoutes`. Gated `multi-tenant || signal-collectors`
1236
- // (D4), VERBATIM the review-surface expression above — not narrowed to
1237
- // `(multi-tenant || signal-collectors) && <tenancy module>`, which would
1238
- // put the magnet surface out of reach of `marketing-site` (the Pro
1239
- // flagship template, which takes `signal-collectors` WITHOUT
1240
- // `multi-tenant`). The delivery is brought up to the gate instead
1241
- // (`packages/create-ailk/src/module-architecture.ts` D14/D15/D16), the
1242
- // same correction #5677 already made once for this exact scope.
1243
- if (
1244
- isModuleEnabled("multi-tenant") ||
1245
- isModuleEnabled("signal-collectors")
1246
- ) {
1247
- await authScope.register(async (tenantScope) => {
1248
- await tenantScope.register(await tenantPreHandlerForBuild());
1249
- await tenantScope.register(await sliceRoutePlugin("magnetRoutes"));
1250
- });
1251
- }
1252
-
1253
- // The appointments read (#6085, brief H7) — a sibling tenant scope,
1254
- // gated on `scheduling` alone (§ D6). NOT the anonymous schedule scope
1255
- // below (`isModuleEnabled("scheduling")`'s OTHER site, § 1.9/§ D6): a
1256
- // prospect booking a call has no account, so that scope carries no
1257
- // auth preHandler, and mounting this read there would publish every
1258
- // attendee's name and email to the internet. `tenantPreHandlerForBuild()`
1259
- // is the SAME default-deny guard the review-surface scope above uses —
1260
- // on a build with no `multi-tenant` module it fails closed unless
1261
- // `AILK_OPERATOR_EMAIL` is pinned (`middleware/default-tenant.ts`).
1262
- //
1263
- // The meetings config route (#6183 S9b) shares this exact scope and
1264
- // gate, for the same reason: its GET reaches
1265
- // apps/api/src/services/scheduling/ (D24's listEventTypes join), which
1266
- // is undeliverable to a scaffold without this module — see
1267
- // routes/workspaces/meetings.ts's own header.
1268
- if (isModuleEnabled("scheduling")) {
1269
- await authScope.register(async (tenantScope) => {
1270
- await tenantScope.register(await tenantPreHandlerForBuild());
1271
- await tenantScope.register(await sliceRoutePlugin("listAppointmentsRoute"));
1272
- await tenantScope.register(await sliceRoutePlugin("workspaceMeetingsRoutes"));
1273
- });
1274
- }
1275
-
1276
- // Workspace SETTINGS routes — a sibling tenant scope, gated on EITHER
1277
- // module (#5371 D2). The site-config routes were registered inside the
1278
- // multi-tenant scope above, which was too narrow: nothing in their logic
1279
- // is multi-tenant-specific, and since Phase 2 `resolveActiveTenant`
1280
- // resolves a single-tenant session's TenantContext from the default
1281
- // Organization/Workspace exactly like a multi-tenant one. Widening the
1282
- // gate is the correction; duplicating the route file would encode the
1283
- // original mis-scoping twice.
1284
- //
1285
- // This is a REGISTRATION change, not a tenant-isolation change: the scope
1286
- // registers its own `tenantPreHandlerForBuild()`, the same default-deny
1287
- // guard the scopes above use. The remaining routes in the multi-tenant
1288
- // scope above (workspaceSessionRoutes, sessionRoutes, billingRoutes) stay
1289
- // multi-tenant-only.
1290
- //
1291
- // #5677's selector fixes ONE of this scope's two stripped-scaffold
1292
- // problems and not the other, so do not read it as making a
1293
- // `--modules workspace` scaffold work. It removes the guard's dependency
1294
- // on `./middleware/tenant.js` (a multi-tenant slice file, which threw
1295
- // ERR_MODULE_NOT_FOUND during construction). The three route plugins
1296
- // below are still reached through slice loads, and
1297
- // `routes/workspaces/config.ts` remains a multi-tenant `ownedFiles` entry
1298
- // — `general.ts` and `members.ts` are workspace-owned, `config.ts` is not
1299
- // — so that scaffold still has no `routes/workspaces/` directory and
1300
- // still throws here. Fixing it is the same one-line class of change #5677
1301
- // made for the review surface: co-own `config.ts` under `workspace` in
1302
- // MODULE_SLICES. Out of scope for #5677, which owns the review surface.
1303
- //
1304
- // A build with BOTH modules enabled registers each of the three route
1305
- // plugins exactly once: they live here and nowhere else, so the two
1306
- // scopes cannot collide on a path.
1307
- if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
1308
- await authScope.register(async (tenantScope) => {
1309
- await tenantScope.register(await tenantPreHandlerForBuild());
1310
- await tenantScope.register(
1311
- await sliceRoutePlugin("workspaceConfigRoutes"),
1312
- );
1313
- // The Pro workspace module's own two routes (#5371): the workspace
1314
- // name, and the read-only member roster.
1315
- await tenantScope.register(
1316
- await sliceRoutePlugin("workspaceGeneralRoutes"),
1317
- );
1318
- await tenantScope.register(
1319
- await sliceRoutePlugin("workspaceMembersRoutes"),
1320
- );
1321
- // The Inbox message API (#6216 S7e) — the SAME scope, the SAME
1322
- // default-deny guard: list, item read, and the handled stamp.
1323
- await tenantScope.register(
1324
- await sliceRoutePlugin("workspaceMessageRoutes"),
1325
- );
1326
- // The Inbox submissions and subscribers reads (#7535) — the SAME
1327
- // scope and guard: every flow completion and waitlist signup (list,
1328
- // item read, handled stamp), and the read-only newsletter signups.
1329
- await tenantScope.register(
1330
- await sliceRoutePlugin("workspaceSubmissionRoutes"),
1331
- );
1332
- await tenantScope.register(
1333
- await sliceRoutePlugin("workspaceSubscriberRoutes"),
1334
- );
1335
- // The Inbox orders read (#9170) — the SAME scope and guard: the
1336
- // workspace's purchases (list, item read), behind `inbox-read`.
1337
- await tenantScope.register(
1338
- await sliceRoutePlugin("workspaceOrderRoutes"),
1339
- );
1340
- // The People reads (#852 D8) — the SAME scope and guard: the
1341
- // workspace's clients and one Person with its derived timeline,
1342
- // behind `people-read` (owner, admin, a granted agent).
1343
- await tenantScope.register(
1344
- await sliceRoutePlugin("workspacePeopleRoutes"),
1345
- );
1346
- // The member area's operator routes (#7877 D9) — the SAME scope and
1347
- // guard: a client's pieces, plan and outbound messages, and the
1348
- // workspace's declared piece stages. Writes need `people-write`
1349
- // (owner, admin; never an agent).
1350
- await tenantScope.register(
1351
- await sliceRoutePlugin("workspacePieceRoutes"),
1352
- );
1353
- // #8171 D8 — the Requests read: the inbound member rows, behind
1354
- // `inbox-read`; the content only to a role holding `people-read`.
1355
- await tenantScope.register(
1356
- await sliceRoutePlugin("workspaceRequestRoutes"),
1357
- );
1358
- // #9254 DD5 — the Callbacks read: the request-and-confirm rows the
1359
- // Inbox lists under Interested, behind `inbox-read`.
1360
- await tenantScope.register(
1361
- await sliceRoutePlugin("workspaceCallbackRoutes"),
1362
- );
1363
- // #9287 DD2 — Start, Get and Extend trial, behind `billing-manage`
1364
- // (the owner alone; never an agent).
1365
- await tenantScope.register(
1366
- await sliceRoutePlugin("workspaceTrialRoutes"),
1367
- );
1368
- await tenantScope.register(
1369
- await sliceRoutePlugin("workspacePlanRoutes"),
1370
- );
1371
- await tenantScope.register(
1372
- await sliceRoutePlugin("workspaceOutboundMessageRoutes"),
1373
- );
1374
- // The agent seat (#3591 PR-B) — the SAME scope and guard: owners and
1375
- // admins list, add, re-key and switch off agents (`agents-manage`),
1376
- // and an agent reads its own context at GET /v1/agent/context.
1377
- await tenantScope.register(
1378
- await sliceRoutePlugin("workspaceAgentRoutes"),
1379
- );
1380
- // The asset store's MANAGE routes — list, PATCH, DELETE (#6093 D16).
1381
- // Registered HERE, in the plain tenant scope, and NOT inside the
1382
- // upload's own nested strict-rate-limit scope below: a JSON
1383
- // list/patch/delete has no 20 MB body to bound, and inheriting that
1384
- // per-IP ceiling would throttle the Admin's own list against its
1385
- // siblings.
1386
- await tenantScope.register(
1387
- await sliceRoutePlugin("workspaceAssetManageRoutes"),
1388
- );
1389
- // The workspace asset store's upload half (#5854). It belongs in THIS
1390
- // scope and not beside the public routes below, because the two
1391
- // refusals its AC names — no granted tenant, and a `:workspaceId` that
1392
- // is not the session's — are `tenantPreHandlerForBuild()`'s to make,
1393
- // before any handler runs and so before any write. Its raw-body parser
1394
- // and its `onRequest` size/type hook are declared INSIDE that plugin,
1395
- // so they encapsulate to it and reach neither the public read below nor
1396
- // any sibling here.
1397
- //
1398
- // Its own rate-limit scope, at the strict per-IP ceiling the leads and
1399
- // scheduling scopes use (#5854 security review round 1, MEDIUM). Auth
1400
- // and tenant are `preHandler`s, so an UNAUTHENTICATED caller still
1401
- // reaches the parser and can make the server buffer up to 20 MB before
1402
- // the 401. `rateLimitMiddleware` is an `onRequest` hook, so scoping it
1403
- // here is what bounds how often that can be done — the cap bounds one
1404
- // body, this bounds the rate. Nested so the ceiling applies to the
1405
- // upload alone and not to the config/general/members siblings above.
1406
- await tenantScope.register(async (assetUploadScope) => {
1407
- assetUploadScope.register(rateLimitMiddleware, {
1408
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1409
- });
1410
- await assetUploadScope.register(
1411
- await sliceRoutePlugin("workspaceAssetUploadRoute"),
1412
- );
1413
- });
1414
- });
1415
- }
1416
-
1417
- // The editor's tenant-guarded content-write surface (#5853, roadmap D4).
1418
- // Its OWN sibling scope (D9) — not nested inside the workspace/multi-tenant
1419
- // scope above, so its module-exclusion row stays its own. The gate is
1420
- // `content-write` AND a tenancy module: the route file imports
1421
- // `lib/tenant-db.ts`, which only the `workspace` / `multi-tenant` slices
1422
- // deliver, so a scaffold enabling `content-write` without either would
1423
- // throw ERR_MODULE_NOT_FOUND at build() — the exact defect
1424
- // `tools/lint-slice-registration.ts` (required in CI) refuses. D9's
1425
- // "content-write alone" premise was falsified by that lint on PR #5921;
1426
- // the path itself is workspace-scoped (`/v1/workspaces/:workspaceId/…`),
1427
- // so requiring a tenancy module is coherent, not a narrowing.
1428
- // `tenantPreHandlerForBuild()` still selects the base-build
1429
- // default-workspace guard on a `workspace`-only build. The plugin is
1430
- // registered here and nowhere else, so no scope can collide on a path.
1431
- //
1432
- // The module flags are read ONCE, here, at build time (D10): on a build
1433
- // that excludes them the routes are never registered and Fastify's own 404
1434
- // answers — no auth, no tenant resolution, no handler-level
1435
- // `isModuleEnabled`.
1436
- if (
1437
- isModuleEnabled("content-write") &&
1438
- (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant"))
1439
- ) {
1440
- await authScope.register(async (tenantScope) => {
1441
- // D12: the authenticated ceiling (600/min), scope-local. The global
1442
- // limiter above is the unauth 60/min, which an autosaving editor would
1443
- // trip against its own user.
1444
- await tenantScope.register(rateLimit, {
1445
- max: CONTENT_RATE_LIMIT_AUTH,
1446
- timeWindow: CONTENT_RATE_LIMIT_WINDOW,
1447
- });
1448
- await tenantScope.register(await tenantPreHandlerForBuild());
1449
- await tenantScope.register(
1450
- await sliceRoutePlugin("workspaceContentRoutes"),
1451
- );
1452
- });
1453
- }
1454
- });
1455
-
1456
- // Public routes — no auth required.
1457
- //
1458
- // The workspace asset store's READ half (#5854), under the SAME predicate as
1459
- // its upload above rather than unconditionally: with no upload path in the
1460
- // build it would be a permanent 404 advertised on both self-description
1461
- // surfaces. It cannot join the block above — that block is nested in
1462
- // `authScope`, and an `<img src>` carries no session.
1463
- if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
1464
- await server.register(await sliceRoutePlugin("publicAssetReadRoute"));
1465
- }
1466
- await server.register(listTypesRoute);
1467
- // Before `listPagesRoute`: `search`, `offerings`, `pricing` and
1468
- // `shown-offerings` must never be read as a `:type`.
1469
- await server.register(searchContentRoute);
1470
- // The Organization's Stripe catalog source (#8922), so a stale snapshot
1471
- // refreshes on the service's bounded, never-forced path (#8924 D7, #9124;
1472
- // the bounds are `refreshWorkspaceSnapshot`'s). The two agent twins read
1473
- // the shown set through the same read with these same options, so all
1474
- // three refresh together (#8933 D5).
1475
- const shownSetRouteOptions: ShownOfferingsRouteOptions = {
1476
- sourceFor: stripeCatalogSourceFor,
1477
- };
1478
- await server.register(offeringsRoute, shownSetRouteOptions);
1479
- await server.register(pricingRoute, shownSetRouteOptions);
1480
- await server.register(shownOfferingsRoute, shownSetRouteOptions);
1481
- await server.register(listPagesRoute);
1482
- await server.register(getPageRoute);
1483
- // The hub's sign-in page names the spoke that sent a visitor (#8790 D2):
1484
- // a public read of one client's name + origin from the registry. No auth —
1485
- // the visitor is signed out by definition when the page asks.
1486
- await server.register(hubSignInPublicRoutes);
1487
- // Checkout launcher + reader — commerce PRIMITIVES (D-64, #5796; the GET
1488
- // added by #6084). Registered UNCONDITIONALLY and gated at CALL time on
1489
- // the JWT `commerce` claim, the same posture content-write has
1490
- // (requirePaidTier above), so adding commerce to a live site is a licence
1491
- // change and lands no files on its repo. Without the claim the gate
1492
- // answers the module-disabled 404 — byte-identical to the build that
1493
- // never registered the route. `checkoutRoutes` (`routes/checkout/index.ts`)
1494
- // registers BOTH `POST /v1/checkout/sessions` and
1495
- // `GET /v1/checkout/sessions/:id` — the GET in its own nested,
1496
- // rate-limited sub-scope (D16), so this preHandler applies to both while
1497
- // the GET's own limiter applies to it alone.
1498
- //
1499
- // #8450 § 9 SEC-14 — a LIVE process gives both routes their own per-IP
1500
- // `checkout` bucket on top of the global limiter, because live, the public
1501
- // GET spends one read of the shared live Stripe account per request. It
1502
- // lowers the per-IP ceiling per instance (the POST's from the global 20 to
1503
- // 10; the POST and GET now also share one count); like every limiter here
1504
- // it is in-memory, so it does not bound the total across serverless
1505
- // instances. `onRequest`, so it counts before the claim gate. Accepted
1506
- // residual: a server-side caller — apps/web's server actions, an MCP host's
1507
- // `purchase` tool — puts all its buyers on its own egress IP, so they share
1508
- // one bucket; `AILK_RATE_LIMIT_CHECKOUT_PER_MINUTE` raises the ceiling. A
1509
- // test-mode process registers nothing new.
1510
- await server.register(async (checkoutScope) => {
1511
- if (stripeMode === "live") {
1512
- await checkoutScope.register(rateLimitMiddleware, {
1513
- scope: "checkout",
1514
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1515
- });
1516
- }
1517
- checkoutScope.addHook("preHandler", requireCommerceClaim);
1518
- await checkoutScope.register(await sliceRoutePlugin("checkoutRoutes"));
1519
- });
1520
- // Request-and-confirm (#5970, D-67 / SUB-100) — the tenant's confirm form
1521
- // and the Payment Link it mints. A money path, so it sits INSIDE the
1522
- // commerce claim (DECISION-LOG D-63; D-67 assigns request-and-confirm to
1523
- // the Commerce rung rather than exempting it), and the gate is applied by
1524
- // COMPOSITION here — the checkout scope above, line for line. That is what
1525
- // lets the route files themselves read no licence claim at all (#5970 AC-4).
1526
- //
1527
- // Rate-limited at the leads scope's own strict value: the confirm link is
1528
- // unauthenticated (the token IS the credential), so it belongs on the same
1529
- // footing as the anonymous surface that mails it. Note the same caveat
1530
- // `server.ts` records elsewhere — `trustProxy` is on, so an IP limiter is
1531
- // evadable; the bound that actually holds is per ROW, one live credential
1532
- // per request, spent on use.
1533
- await server.register(async (requestsScope) => {
1534
- requestsScope.register(rateLimitMiddleware, {
1535
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1536
- });
1537
- // `requireCommerceClaim` short-circuits with `reply.send`, so a wrapper is
1538
- // the only place the unclaimed case can be OBSERVED: the preHandler ends
1539
- // the request before any handler runs, and the gate itself logs only on
1540
- // `undetermined` (`lib/require-commerce-claim.ts`).
1541
- //
1542
- // Spec D3 names this line as one of two bounds on the residual it
1543
- // discloses — a site configured for request-and-confirm but carrying no
1544
- // `commerce` claim emails a tenant a confirm link that answers 404. The
1545
- // spec puts the log in the route, where it cannot be emitted; this is the
1546
- // registration, which is where it can (#5970 acceptance review, advisory
1547
- // 4). Without it the misconfiguration is visible only in the tenant's
1548
- // inbox, which is the one place the operator is not looking.
1549
- requestsScope.addHook("preHandler", async (request, reply) => {
1550
- await requireCommerceClaim(request, reply);
1551
- if (reply.sent) {
1552
- request.log.warn(
1553
- { outcome: "confirm_link_unclaimed", path: request.routeOptions.url },
1554
- "request-confirm: a confirm link was opened on a site without the commerce claim",
1555
- );
1556
- }
1557
- });
1558
- await requestsScope.register(requestRoutes);
1559
- });
1560
- // Deliverable download — a capability-token URL (#3961 D5); the token
1561
- // itself is the sole gate, so no auth preHandler applies. Module-conditional
1562
- // (signal-collectors, D-48): gated PDFs and reports.
1563
- if (isModuleEnabled("signal-collectors")) {
1564
- await server.register(await sliceRoutePlugin("deliverableDownloadRoute"));
1565
- // #8848 D5 — the ungated file read, anonymous and keyed by slug, so it
1566
- // gets its OWN encapsulated scope: `rateLimitMiddleware` breaks
1567
- // encapsulation, so it is never installed on the grant route's
1568
- // registration above, whose budget stays as it is. With the hub's
1569
- // forward-key digests (the route is on FORWARDED_VISITOR_ROUTES), a
1570
- // site's proxied reads are counted per visitor (SEC-8848-C).
1571
- await server.register(async (deliverableOpenScope) => {
1572
- deliverableOpenScope.register(rateLimitMiddleware, {
1573
- perIpPerMinute: 60,
1574
- scope: "deliverableopen",
1575
- trustedKeyDigests: visitorForwardKeyDigests,
1576
- });
1577
- await deliverableOpenScope.register(await sliceRoutePlugin("deliverableOpenRoute"));
1578
- });
1579
- }
1580
-
1581
- // Webhooks routes — raw-body parser scoped to this sub-router only so the
1582
- // global JSON parser on all other routes is unaffected.
1583
- await server.register(async (webhooksScope) => {
1584
- webhooksScope.addContentTypeParser(
1585
- "application/json",
1586
- { parseAs: "buffer" },
1587
- (_req, body, done) => {
1588
- done(null, body);
1589
- },
1590
- );
1591
- // Slack's block_actions payload arrives as a urlencoded `payload` form
1592
- // field (#4999) — raw buffer here too, so the signature is verified over
1593
- // the EXACT bytes Slack signed, never a re-serialized parse.
1594
- webhooksScope.addContentTypeParser(
1595
- "application/x-www-form-urlencoded",
1596
- { parseAs: "buffer" },
1597
- (_req, body, done) => {
1598
- done(null, body);
1599
- },
1600
- );
1601
- // Stripe webhook receiver + customer mapping — commerce PRIMITIVES
1602
- // (D-64, #5796), registered UNCONDITIONALLY alongside checkout above.
1603
- // NO `requireCommerceClaim` preHandler here, deliberately: D3 puts
1604
- // signature verification BEFORE the claim check, so the handler itself
1605
- // consults `hasCommerceClaim()` once the signature is settled — a signed
1606
- // event on an unclaimed site is acked 200 and dropped rather than
1607
- // retried forever, while an unsigned body gets the module-disabled 404.
1608
- // See routes/webhooks/README.md § Commerce claim.
1609
- await webhooksScope.register(await sliceRoutePlugin("webhooksRoutes"));
1610
- // Project-listing operator decision (Slack Approve/Reject) — the "hand an
1611
- // operator a decision" half of D-48's grouping sentence. Module-conditional
1612
- // (signal-collectors).
1613
- if (isModuleEnabled("signal-collectors")) {
1614
- await webhooksScope.register(await sliceRoutePlugin("slackActionsRoute"));
1615
- }
1616
- });
1617
-
1618
- // Auth transactional-email relay (#5962 § D3) — its OWN scope, deliberately
1619
- // OUTSIDE `authScope`: the caller is `apps/web`'s Better Auth callbacks
1620
- // acting for an anonymous visitor who is by definition not signed in, so an
1621
- // auth preHandler in front of it would reject every legitimate call. The
1622
- // gate is the HMAC signature over the raw body plus the 300 s replay window,
1623
- // both verified inside the handler.
1624
- //
1625
- // Raw-body parser scoped to this sub-router only, for the same reason
1626
- // `webhooksScope` carries one: the signature must be checked over the EXACT
1627
- // bytes the caller signed, never a re-serialized parse.
1628
- //
1629
- // `DEFAULT_PER_IP_PER_MINUTE_STRICT` (10/min) — the constant `leadsScope`
1630
- // below already uses for its anonymous surface. It is defence in depth, not
1631
- // the control: an unsigned body never reaches a send, and per-victim volume
1632
- // is bounded by `lib/auth-email-budget.ts`.
1633
- await server.register(async (authEmailScope) => {
1634
- authEmailScope.register(rateLimitMiddleware, {
1635
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1636
- });
1637
- authEmailScope.addContentTypeParser(
1638
- "application/json",
1639
- { parseAs: "buffer" },
1640
- (_req, body, done) => {
1641
- done(null, body);
1642
- },
1643
- );
1644
- await authEmailScope.register(transactionalEmailRoute);
1645
- });
1646
-
1647
- // The hub signs in its spokes (#7894 D5) — `token` and `revoke`. Their OWN
1648
- // scope, deliberately OUTSIDE `authScope`: the caller is a spoke's server,
1649
- // authenticated by its client secret AND its spoke key inside the service,
1650
- // not by a person's session — an auth preHandler in front would reject every
1651
- // legitimate call. `scope: "hubsignin"` namespaces the buckets (the scope
1652
- // name admits no hyphen). The per-IP limit keys on `req.ip` under
1653
- // `trustProxy`, so it is a backstop only; the control is the per-client
1654
- // bucket the service keys on the VERIFIED client (SEC-7894-8).
1655
- await server.register(async (hubSignInScope) => {
1656
- hubSignInScope.register(rateLimitMiddleware, {
1657
- perIpPerMinute: 60,
1658
- scope: "hubsignin",
1659
- });
1660
- await hubSignInScope.register(hubSignInClientRoutes);
1661
- });
1662
-
1663
- // Lead-capture status (#6730 D4) — a cheap read the web page makes before
1664
- // choosing the form or the contact email. Top level, NOT in `leadsScope`:
1665
- // the web server's single egress IP already spends that scope's strict
1666
- // 10/min on form submissions. The global unauth ceiling above applies.
1667
- await server.register(leadCaptureStatusRoute);
1668
-
1669
- // The consent floor (#8438 D5, D9) — the cookie banner's log and the
1670
- // unsubscribe landing. Top level, outside every module gate: consent
1671
- // logging is part of the never-gated privacy floor. Each is in its OWN
1672
- // rate-limited scope (`consent`, `unsubscribe`) so filling one never blocks
1673
- // the other; with a forward key set, a spoke's visitors are counted one by
1674
- // one (both routes are on FORWARDED_VISITOR_ROUTES). Without one, the
1675
- // server's own IP is the bucket — the posture of the lead routes. Neither
1676
- // is behind `authScope`: the caller is a visitor, or a person in their mail.
1677
- await server.register(async (consentScope) => {
1678
- consentScope.register(rateLimitMiddleware, {
1679
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1680
- scope: "consent",
1681
- trustedKeyDigests: visitorForwardKeyDigests,
1682
- });
1683
- await consentScope.register(consentCookieRoute);
1684
- });
1685
- await server.register(async (unsubscribeScope) => {
1686
- unsubscribeScope.register(rateLimitMiddleware, {
1687
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1688
- scope: "unsubscribe",
1689
- trustedKeyDigests: visitorForwardKeyDigests,
1690
- });
1691
- await unsubscribeScope.register(consentUnsubscribeRoute);
1692
- });
1693
- // #8765 D2 — the sign-up agreements' evidence, beside the consent floor:
1694
- // anonymous, outside every module gate (sign-up and consent are never-gate
1695
- // surfaces). Its own scope, `signupevidence`, so an anonymous burst never
1696
- // shares a bucket with the cookie banner's log (SEC-8765-D). Its main
1697
- // caller is the web's own server, whose cache keeps it to one call a minute
1698
- // per enabled locale per instance. #8805: a call with a verified site key
1699
- // is counted per key (DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
1700
- // AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE), never per IP; any
1701
- // other call keeps 10 a minute per IP.
1702
- await server.register(async (signUpEvidenceScope) => {
1703
- signUpEvidenceScope.register(rateLimitMiddleware, {
1704
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1705
- perKeyPerMinute: DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
1706
- scope: "signupevidence",
1707
- siteKeyOf: signUpEvidenceSiteKey,
1708
- });
1709
- await signUpEvidenceScope.register(consentSignUpRoute);
1710
- });
1711
-
1712
- // Leads routes — rate-limit middleware scoped to this sub-router. Own
1713
- // default (10/min, pre-#4730) rather than inheriting the wider
1714
- // flows-sized default (#5111, DEF-004 — #4730's shared-default bump was
1715
- // never meant to loosen abuse protection here).
1716
- //
1717
- // `scope: "leads"` (#7394): this scope's buckets are its own, not shared
1718
- // with the other scopes' bare-IP count, and AILK_RATE_LIMIT_LEADS_PER_MINUTE
1719
- // (and ..._PER_KEY_PER_MINUTE, default 300) override the code values. With
1720
- // `trustedKeyDigests`, a keyed spoke's three lead writes are counted per
1721
- // forwarded visitor; the project-listing routes below still key on `req.ip`.
1722
- await server.register(async (leadsScope) => {
1723
- leadsScope.register(rateLimitMiddleware, {
1724
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1725
- scope: "leads",
1726
- trustedKeyDigests: visitorForwardKeyDigests,
1727
- });
1728
- await leadsScope.register(leadSubmitRoute);
1729
- await leadsScope.register(leadSubscribeRoute);
1730
- await leadsScope.register(leadCallbackRoute);
1731
- // Project-listings anonymous surface (#4998, reshaped by #5036) — the
1732
- // three-operation draft lifecycle plus the public page-render read, all
1733
- // on the same rate-limited, identity-blind posture as the leads routes
1734
- // above. Module-conditional (signal-collectors, D-48): the listing
1735
- // instrument's application-lifecycle half.
1736
- //
1737
- // Do NOT read that rate limit as a bound on anonymous draft creation. It
1738
- // keys on `req.ip`, and `trustProxy` is on (see the note at the top of this
1739
- // file), so a caller reaching the API directly can vary
1740
- // `X-Forwarded-For` and get a fresh budget per request. That is a
1741
- // pre-existing property of the limiter, not of these routes; what these
1742
- // routes do is keep the cost of an unbounded call small — the Lead write
1743
- // with its operator notification waits for `submit` (PR #5042 security
1744
- // review, finding 4).
1745
- //
1746
- // `start` DOES now send one email (#5406, the founder's resume link), so
1747
- // the older form of this note — "`start` writes one row and sends
1748
- // nothing" — no longer holds. That send is opt-in per site, and where it
1749
- // is on it is bounded in the send path rather than per IP
1750
- // (`services/project-listing-resume-email.ts`), precisely because this IP
1751
- // limiter cannot carry the weight of an outbound-mail decision. Two bounds,
1752
- // both counted in the database so they hold across instances: PER RECIPIENT
1753
- // (PR #5411 security review, finding 1) and, since #5414, PER SITE across
1754
- // every recipient — the residual the first one left, and the one that
1755
- // decides whether a flood can cost the site its sending domain.
1756
- if (isModuleEnabled("signal-collectors")) {
1757
- await leadsScope.register(
1758
- await sliceRoutePlugin("projectListingStartRoute"),
1759
- );
1760
- await leadsScope.register(
1761
- await sliceRoutePlugin("projectListingDraftPatchRoute"),
1762
- );
1763
- await leadsScope.register(
1764
- await sliceRoutePlugin("projectListingSubmitRoute"),
1765
- );
1766
- // #5407 — the resume link's verification write. Anonymous, and gated
1767
- // on the resume token AND the nonce that only the resume email
1768
- // carries, so it belongs in the same rate-limited scope as the other
1769
- // three.
1770
- await leadsScope.register(
1771
- await sliceRoutePlugin("projectListingVerifyEmailRoute"),
1772
- );
1773
- await leadsScope.register(
1774
- await sliceRoutePlugin("projectListingPublicRoute"),
1775
- );
1776
- }
1777
- });
1778
-
1779
- // The listicle directory read (#7521 D3) — anonymous, keyed by the public
1780
- // site key, and rendered by the web server on every page view of a landing
1781
- // page. Its OWN scope, NOT `leadsScope`: that scope's 10/min per IP would be
1782
- // spent by the web server's SSR reads from one egress address, and the
1783
- // directory would vanish past ~10 views a minute (SEC-7521-D1). 120/min,
1784
- // overridable by AILK_RATE_LIMIT_DIRECTORY_PER_MINUTE (#7394's named-scope
1785
- // rule); the web side memoises each landing page for 30 s per process.
1786
- // Module-conditional (signal-collectors, D-48), so a build without the
1787
- // module has no `/directory` and the web extension's read gets a 404.
1788
- await server.register(async (directoryScope) => {
1789
- directoryScope.register(rateLimitMiddleware, {
1790
- perIpPerMinute: 120,
1791
- scope: "directory",
1792
- });
1793
- if (isModuleEnabled("signal-collectors")) {
1794
- await directoryScope.register(
1795
- await sliceRoutePlugin("projectListingDirectoryRoute"),
1796
- );
1797
- }
1798
- });
1799
-
1800
- // Flows routes — anonymous surface; rate-limit middleware scoped to this
1801
- // sub-router, matching the leads scope above (#3956 D4 Clarifications:
1802
- // "follows whatever the existing anonymous /v1/leads/* routes do today").
1803
- // Keeps the wider, env-var-driven default (#4730 — see
1804
- // apps/api/src/routes/flows/README.md § Rate limit) — this is the ONE
1805
- // scope #4730's bump was actually sized for; unchanged by #5111.
1806
- await server.register(async (flowsScope) => {
1807
- flowsScope.register(rateLimitMiddleware);
1808
- // Flow engine reads (#3956) — module-conditional (signal-collectors,
1809
- // D-48). The scope itself and its rate limiter above stay unconditional
1810
- // (D2) — commerce's recommender routes below need them too.
1811
- if (isModuleEnabled("signal-collectors")) {
1812
- await flowsScope.register(await sliceRoutePlugin("getFlowRoute"));
1813
- await flowsScope.register(await sliceRoutePlugin("submitFlowStepRoute"));
1814
- await flowsScope.register(
1815
- await sliceRoutePlugin("getFlowAggregateRoute"),
1816
- );
1817
- await flowsScope.register(
1818
- await sliceRoutePlugin("putFlowDefinitionRoute"),
1819
- );
1820
- }
1821
- // The recommender's email-moment routes (#4841) — same anonymous,
1822
- // rate-limited posture as the three above. Module-conditional (commerce,
1823
- // D-47): the recommender leaves Pro flows and moves to commerce whole.
1824
- if (isModuleEnabled("commerce")) {
1825
- await flowsScope.register(
1826
- await sliceRoutePlugin("saveRecommendationRoute"),
1827
- );
1828
- await flowsScope.register(
1829
- await sliceRoutePlugin("checkoutRecommendationRoute"),
1830
- );
1831
- }
1832
- });
1833
-
1834
- // AEO score routes (#4666) — anonymous surface, same posture as the flows
1835
- // scope above: a waitlist respondent has no account. Rate-limited because
1836
- // each call fans out to the respondent's own site (an external fetch), the
1837
- // same reason the schedule scope below is rate-limited for a third party.
1838
- // Own default (10/min, pre-#4730) — see the leads scope's comment above
1839
- // (#5111, DEF-004).
1840
- await server.register(async (aeoScope) => {
1841
- aeoScope.register(rateLimitMiddleware, {
1842
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1843
- });
1844
- await aeoScope.register(scoreAeoRoute);
1845
- await aeoScope.register(exampleAeoRoute);
1846
- });
1847
-
1848
- // Schedule routes (#4416) — anonymous surface, same posture as the flows
1849
- // and leads scopes above: a prospect booking a call has no account, and the
1850
- // calLink in the request names the event, so there is no tenant scope to
1851
- // bind. Rate-limited because each call fans out to a third-party provider.
1852
- // Own default (10/min, pre-#4730) — see the leads scope's comment above
1853
- // (#5111, DEF-004). Module-conditional (S1, #5088): the scheduling module
1854
- // owns this whole route set, so an OSS build that excludes it registers
1855
- // neither the routes nor their imports.
1856
- //
1857
- // `scope: "schedule"` with `trustedKeyDigests` (#7633, the relay's security
1858
- // review): a site's same-origin relay sends every visitor's availability
1859
- // and booking call from its own server, so with the forwarding key each
1860
- // visitor is counted separately, and the key as a whole is capped at
1861
- // DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE. The scope's buckets are its own,
1862
- // and AILK_RATE_LIMIT_SCHEDULE_PER_MINUTE / ..._PER_KEY_PER_MINUTE override
1863
- // the code values, as the leads scope's do.
1864
- if (isModuleEnabled("scheduling")) {
1865
- await server.register(async (scheduleScope) => {
1866
- scheduleScope.register(rateLimitMiddleware, {
1867
- perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1868
- scope: "schedule",
1869
- trustedKeyDigests: visitorForwardKeyDigests,
1870
- perKeyPerMinute: DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE,
1871
- });
1872
- await scheduleScope.register(
1873
- await sliceRoutePlugin("listAvailabilityRoute"),
1874
- );
1875
- await scheduleScope.register(await sliceRoutePlugin("getEventMetaRoute"));
1876
- await scheduleScope.register(
1877
- await sliceRoutePlugin("createBookingRoute"),
1878
- );
1879
- // #6183 S9b D2/D12 — the enabled-meeting-slugs public read. Same scope
1880
- // as the three routes above: it inherits the module gate and the
1881
- // strict per-IP rate limit (§9 SEC-4).
1882
- await scheduleScope.register(
1883
- await sliceRoutePlugin("meetingTypesRoute"),
1884
- );
1885
- // #8843 D1 — the meetings read: the same gate and per-IP limit.
1886
- await scheduleScope.register(
1887
- await sliceRoutePlugin("listMeetingsRoute"),
1888
- );
1889
- });
1890
- }
1891
-
1892
- // Test-auth route — triple-gated (NODE_ENV !== production AND
1893
- // AILK_TEST_AUTH_ENABLED === 'true' AND AILK_TEST_AUTH_USER_ALLOWLIST
1894
- // non-empty). When any gate fails, the route is never registered and
1895
- // Fastify returns its default 404 — the surface NEVER advertises itself
1896
- // in production. See #517 / docs/internal/roadmaps/e2e-testing-roadmap.md.
1897
- if (shouldMountTestAuth()) {
1898
- await server.register(registerTestAuthRoute);
1899
- server.log.warn(
1900
- "test-auth route MOUNTED — only safe in dev/test environments",
1901
- );
126
+ export async function build(): Promise<FastifyInstance> {
127
+ const modules: ApiModule[] = [];
128
+ for (const name of OPTIONAL_MODULES) {
129
+ const { specifier, load } = MODULE_OBJECTS[name];
130
+ const mod = (await loadSlice(specifier, load)) as { module?: ApiModule };
131
+ if (!mod.module) {
132
+ throw new Error(`module export missing from ${specifier}`);
133
+ }
134
+ modules.push(mod.module);
1902
135
  }
1903
-
1904
- await server.register(openapiRoutes);
1905
-
1906
- return server;
136
+ const hub = await loadInstalledHub();
137
+ // One line at boot, never a key (#9571 D5); `artifact-boots:check` asserts
138
+ // the traced artifact reads `installed`.
139
+ logger.info({ hub: hub ? "installed" : "absent" }, "[boot] hub package");
140
+ if (hub === undefined) return buildServer({ modules, topology: "hub", config });
141
+ return buildServer({ modules, topology: "hub", config, hub });
1907
142
  }