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