create-ailk 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -9,52 +9,50 @@
9
9
  * - Vercel serverless deployment (api/index.ts)
10
10
  * - Local development (src/index.ts)
11
11
  */
12
- import cors from '@fastify/cors';
13
- import rateLimit from '@fastify/rate-limit';
14
- import Fastify from 'fastify';
15
- import type { FastifyPluginAsync, FastifyPluginCallback } from 'fastify';
12
+ import cors from "@fastify/cors";
13
+ import rateLimit from "@fastify/rate-limit";
14
+ import Fastify from "fastify";
15
+ import type { FastifyPluginAsync, FastifyPluginCallback } from "fastify";
16
16
 
17
- import { config } from './config/index.js';
18
- import { isModuleEnabled } from './config/modules.js';
19
- import { registerErrorSerializer } from './lib/error-serializer.js';
20
- import { validateLeadCaptureDir } from './lib/lead-capture-store.js';
21
- import { assertSandboxKey } from './lib/stripe.js';
22
- import { authPreHandlerPlugin } from './middleware/auth.js';
17
+ import { config } from "./config/index.js";
18
+ import { isModuleEnabled } from "./config/modules.js";
19
+ import { registerErrorSerializer } from "./lib/error-serializer.js";
20
+ import { validateLeadCaptureDir } from "./lib/lead-capture-store.js";
21
+ import { loadSlice } from "./lib/slice-load.js";
22
+ import { authPreHandlerPlugin } from "./middleware/auth.js";
23
23
  import {
24
24
  rateLimitMiddleware,
25
25
  DEFAULT_PER_IP_PER_MINUTE_STRICT,
26
- } from './middleware/rate-limit.js';
27
- import { openapiRoutes } from './openapi/index.js';
28
- import { exampleAeoRoute, scoreAeoRoute } from './routes/aeo/index.js';
29
- import { tokenRoutes } from './routes/auth/tokens.js';
30
- import { userRoutes } from './routes/auth/user.js';
31
- import { checkoutRoutes } from './routes/checkout/index.js';
32
- import { getPageRoute } from './routes/content/get-page.js';
33
- import { listPagesRoute } from './routes/content/list-pages.js';
34
- import { listTypesRoute } from './routes/content/list-types.js';
26
+ } from "./middleware/rate-limit.js";
27
+ import { openapiRoutes } from "./openapi/index.js";
28
+ import {
29
+ deriveRootEndpoints,
30
+ REGISTERED_ROUTES_DECORATOR,
31
+ API_VERSION,
32
+ apiDescription,
33
+ apiDisplayName,
34
+ resolveApiIdentity,
35
+ } from "./openapi/spec.js";
36
+ import type { RegisteredRoute } from "./openapi/surface-drift.js";
37
+ import { exampleAeoRoute, scoreAeoRoute } from "./routes/aeo/index.js";
38
+ import { tokenRoutes } from "./routes/auth/tokens.js";
39
+ import { userRoutes } from "./routes/auth/user.js";
40
+ import { getPageRoute } from "./routes/content/get-page.js";
41
+ import { listPagesRoute } from "./routes/content/list-pages.js";
42
+ import { listTypesRoute } from "./routes/content/list-types.js";
35
43
  import {
36
44
  CONTENT_RATE_LIMIT_UNAUTH,
37
45
  CONTENT_RATE_LIMIT_WINDOW,
38
- } from './routes/content/rate-limit.js';
39
- import { deliverableDownloadRoute } from './routes/deliverables/index.js';
40
- import { flowCheckoutRoutes } from './routes/flow-checkouts/index.js';
41
- import { getFlowAggregateRoute, getFlowRoute, submitFlowStepRoute } from './routes/flows/index.js';
42
- import { checkoutRecommendationRoute, saveRecommendationRoute } from './routes/flows/recommender.js';
46
+ } from "./routes/content/rate-limit.js";
43
47
  import {
44
48
  leadCallbackRoute,
45
49
  leadSubmitRoute,
46
50
  leadSubscribeRoute,
47
- } from './routes/leads/index.js';
51
+ } from "./routes/leads/index.js";
48
52
  import {
49
- projectListingDraftPatchRoute,
50
- projectListingPublicRoute,
51
- projectListingStartRoute,
52
- projectListingSubmitRoute,
53
- } from './routes/project-listings/index.js';
54
- import { slackActionsRoute } from './routes/slack/index.js';
55
- import { registerTestAuthRoute, shouldMountTestAuth } from './routes/test/auth/session.js';
56
- import { waitlistSignupRoutes } from './routes/waitlist-signups/index.js';
57
- import { webhooksRoutes } from './routes/webhooks/index.js';
53
+ registerTestAuthRoute,
54
+ shouldMountTestAuth,
55
+ } from "./routes/test/auth/session.js";
58
56
 
59
57
  /**
60
58
  * Paid-module SLICE entry points, keyed by the export each branch needs
@@ -69,9 +67,15 @@ import { webhooksRoutes } from './routes/webhooks/index.js';
69
67
  * `./middleware/tenant.js` import was an eleventh — measured on a stripped
70
68
  * tree, not argued.
71
69
  *
72
- * Reading the specifier from this table is what makes it non-literal at the
73
- * call site, and that INDIRECTION — not the type of the value — is what defeats
74
- * resolution. S1's `isModuleEnabled()` gate is unchanged and still decides
70
+ * Keeping the specifier off the `import()` call site is what defeats that
71
+ * resolution, and the INDIRECTION — not the type of the value — is what does
72
+ * the work. WHICH FORM of indirection is not free, and #5304 is the correction:
73
+ * reading the specifier out of a TABLE, as this file did until then, hid it
74
+ * from the deploy-time file tracer as well as from `tsc`, so the files were
75
+ * compiled and never shipped. The form below — a module-level constant
76
+ * dereferenced into `import()` — is unresolvable to `tsc` and traceable to
77
+ * `@vercel/nft`, the only pair of properties that satisfies both the strip and
78
+ * the deployment. S1's `isModuleEnabled()` gate is unchanged and still decides
75
79
  * whether the branch runs; `AILK_ENABLED_MODULES` unset still means every
76
80
  * module on, so a default build registers exactly what it registered before.
77
81
  *
@@ -81,27 +85,200 @@ import { webhooksRoutes } from './routes/webhooks/index.js';
81
85
  * with no members, so the module silently fails to load on the tree that HAS
82
86
  * it. See the note above the imports in `packages/ui/src/sections/SectionRenderer.tsx`.
83
87
  *
84
- * `./routes/project-listings/index.js` is deliberately absent: that barrel is a
85
- * MIXED directory that ships (the free anonymous listing routes are registered
86
- * from it statically at the top of this file), so its dynamic import at the
87
- * tenant scope is a literal on purpose and resolves fine on both trees.
88
+ * BUNDLING `apps/api` IS NOT A SUPPORTED DEPLOY PATH, and that is a property of
89
+ * the analysers rather than a posture (#5334). esbuild bundles a dynamic
90
+ * `import()` only when its argument is a string literal at the call site
91
+ * exactly the predicate `tsc` resolves on, and exactly the one the strip
92
+ * forbids. So no expression shape can satisfy both, and in a bundled build
93
+ * these specifiers survive verbatim and resolve against the BUNDLE OUTPUT
94
+ * directory instead of their source module's: `"./list.js"` in
95
+ * `routes/project-listings/index.ts` becomes `dist/list.js` and is not there.
96
+ * esbuild does not warn. `build()` then throws during CONSTRUCTION, so every
97
+ * route 500s rather than one — measured in production, nine hours of total API
98
+ * outage on a consuming site. The supported path is what the scaffold ships:
99
+ * plain `tsc` output traced by `@vercel/node` (`apps/api/vercel.json`,
100
+ * `framework: null`). If you are syncing this file into a repo that bundles,
101
+ * that is the constraint you have just inherited; `apps/api/CLAUDE.md`
102
+ * § Deploy path says what to do instead. Every load site below routes through
103
+ * `loadSlice()` so a bundled build says so at boot rather than failing mute.
104
+ *
105
+ * `./routes/project-listings/index.js` carries two independent load paths on
106
+ * purpose: its five anonymous-surface exports are reached below through
107
+ * `PROJECT_LISTINGS_INDEX` / `SLICE_PLUGINS`, now that signal-collectors gates
108
+ * their registration (D4), while the tenant scope's own `import()` of the same
109
+ * specifier stays a literal — the file is a MIXED directory that always ships
110
+ * (multi-tenant's `TENANT_HANDLERS` table lives in the same file), so a literal
111
+ * there resolves fine on every tree regardless of module selection.
112
+ */
113
+ /**
114
+ * One module-level constant per slice module. The call sites below read the
115
+ * loader out of `SLICE_PLUGINS`; the specifier itself is never written at an
116
+ * `import()` call site, which is what keeps `tsc` from resolving it (#5182 D1).
117
+ *
118
+ * The constant is not a style choice — it is the mechanism (#5304 D3).
119
+ * `@vercel/nft` constant-propagates a module-level binding into `import()` and
120
+ * emits the file; it cannot evaluate a table lookup or a for-of destructure,
121
+ * which is what the previous `Record<string, string>` shape forced it to do.
122
+ * So the old shape was invisible to the DEPLOY tracer as well as to `tsc`, the
123
+ * files went untraced and unshipped, and `build()` threw ERR_MODULE_NOT_FOUND
124
+ * on the first slice it reached — measured in production, nine hours of total
125
+ * API outage on a consuming site. Both analysers are re-measured on every run
126
+ * by `scripts/__tests__/enumerate-slice-specifiers.test.ts`, and
127
+ * `scripts/check-slice-reachability.ts` fails when any specifier here stops
128
+ * being traced.
88
129
  */
89
- const SLICE_PLUGIN_SPECIFIERS = {
90
- workspaceContextRoutes: './routes/workspaces/index.js',
91
- workspaceSessionRoutes: './routes/workspaces/index.js',
92
- sessionRoutes: './routes/workspaces/sessions.js',
93
- workspaceConfigRoutes: './routes/workspaces/config.js',
94
- billingRoutes: './routes/billing/index.js',
95
- waitlistExperimentRoutes: './routes/waitlist-experiments/index.js',
96
- createPageRoute: './routes/content/create.js',
97
- updatePageRoute: './routes/content/update.js',
98
- deletePageRoute: './routes/content/delete.js',
99
- listAvailabilityRoute: './routes/schedule/index.js',
100
- getEventMetaRoute: './routes/schedule/index.js',
101
- createBookingRoute: './routes/schedule/index.js',
102
- } satisfies Record<string, string>;
130
+ const WORKSPACES_INDEX = "./routes/workspaces/index.js";
131
+ const WORKSPACES_SESSIONS = "./routes/workspaces/sessions.js";
132
+ const WORKSPACES_CONFIG = "./routes/workspaces/config.js";
133
+ const BILLING_INDEX = "./routes/billing/index.js";
134
+ const WAITLIST_EXPERIMENTS_INDEX = "./routes/waitlist-experiments/index.js";
135
+ const CONTENT_CREATE = "./routes/content/create.js";
136
+ const CONTENT_UPDATE = "./routes/content/update.js";
137
+ const CONTENT_DELETE = "./routes/content/delete.js";
138
+ const SCHEDULE_INDEX = "./routes/schedule/index.js";
139
+ const FLOWS_INDEX = "./routes/flows/index.js";
140
+ const DELIVERABLES_INDEX = "./routes/deliverables/index.js";
141
+ const PROJECT_LISTINGS_INDEX = "./routes/project-listings/index.js";
142
+ const SLACK_INDEX = "./routes/slack/index.js";
143
+ const WAITLIST_SIGNUPS_INDEX = "./routes/waitlist-signups/index.js";
144
+ const CHECKOUT_INDEX = "./routes/checkout/index.js";
145
+ const WEBHOOKS_INDEX = "./routes/webhooks/index.js";
146
+ const FLOW_CHECKOUTS_INDEX = "./routes/flow-checkouts/index.js";
147
+ const RECOMMENDER_INDEX = "./routes/flows/recommender.js";
103
148
 
104
- type SlicePluginName = keyof typeof SLICE_PLUGIN_SPECIFIERS;
149
+ /**
150
+ * Paid-module SLICE entry points, keyed by the export each branch needs.
151
+ *
152
+ * `specifier` is carried alongside `load` for the drift message below, and both
153
+ * read the SAME constant, so the two can never disagree. `load` is what runs:
154
+ * an arrow closing over that constant, which is the only shape both analysers
155
+ * treat as intended — unresolvable to `tsc`, traceable to `nft`.
156
+ */
157
+ const SLICE_PLUGINS = {
158
+ workspaceContextRoutes: {
159
+ specifier: WORKSPACES_INDEX,
160
+ load: () => import(WORKSPACES_INDEX),
161
+ },
162
+ workspaceSessionRoutes: {
163
+ specifier: WORKSPACES_INDEX,
164
+ load: () => import(WORKSPACES_INDEX),
165
+ },
166
+ sessionRoutes: {
167
+ specifier: WORKSPACES_SESSIONS,
168
+ load: () => import(WORKSPACES_SESSIONS),
169
+ },
170
+ workspaceConfigRoutes: {
171
+ specifier: WORKSPACES_CONFIG,
172
+ load: () => import(WORKSPACES_CONFIG),
173
+ },
174
+ billingRoutes: {
175
+ specifier: BILLING_INDEX,
176
+ load: () => import(BILLING_INDEX),
177
+ },
178
+ waitlistExperimentRoutes: {
179
+ specifier: WAITLIST_EXPERIMENTS_INDEX,
180
+ load: () => import(WAITLIST_EXPERIMENTS_INDEX),
181
+ },
182
+ createPageRoute: {
183
+ specifier: CONTENT_CREATE,
184
+ load: () => import(CONTENT_CREATE),
185
+ },
186
+ updatePageRoute: {
187
+ specifier: CONTENT_UPDATE,
188
+ load: () => import(CONTENT_UPDATE),
189
+ },
190
+ deletePageRoute: {
191
+ specifier: CONTENT_DELETE,
192
+ load: () => import(CONTENT_DELETE),
193
+ },
194
+ listAvailabilityRoute: {
195
+ specifier: SCHEDULE_INDEX,
196
+ load: () => import(SCHEDULE_INDEX),
197
+ },
198
+ getEventMetaRoute: {
199
+ specifier: SCHEDULE_INDEX,
200
+ load: () => import(SCHEDULE_INDEX),
201
+ },
202
+ createBookingRoute: {
203
+ specifier: SCHEDULE_INDEX,
204
+ load: () => import(SCHEDULE_INDEX),
205
+ },
206
+ getFlowRoute: {
207
+ specifier: FLOWS_INDEX,
208
+ load: () => import(FLOWS_INDEX),
209
+ },
210
+ submitFlowStepRoute: {
211
+ specifier: FLOWS_INDEX,
212
+ load: () => import(FLOWS_INDEX),
213
+ },
214
+ getFlowAggregateRoute: {
215
+ specifier: FLOWS_INDEX,
216
+ load: () => import(FLOWS_INDEX),
217
+ },
218
+ deliverableDownloadRoute: {
219
+ specifier: DELIVERABLES_INDEX,
220
+ load: () => import(DELIVERABLES_INDEX),
221
+ },
222
+ projectListingStartRoute: {
223
+ specifier: PROJECT_LISTINGS_INDEX,
224
+ load: () => import(PROJECT_LISTINGS_INDEX),
225
+ },
226
+ projectListingDraftPatchRoute: {
227
+ specifier: PROJECT_LISTINGS_INDEX,
228
+ load: () => import(PROJECT_LISTINGS_INDEX),
229
+ },
230
+ projectListingSubmitRoute: {
231
+ specifier: PROJECT_LISTINGS_INDEX,
232
+ load: () => import(PROJECT_LISTINGS_INDEX),
233
+ },
234
+ // #5407 — the resume link's verification write. Anonymous surface,
235
+ // module-conditional (signal-collectors, D-48) like its four siblings.
236
+ projectListingVerifyEmailRoute: {
237
+ specifier: PROJECT_LISTINGS_INDEX,
238
+ load: () => import(PROJECT_LISTINGS_INDEX),
239
+ },
240
+ projectListingPublicRoute: {
241
+ specifier: PROJECT_LISTINGS_INDEX,
242
+ load: () => import(PROJECT_LISTINGS_INDEX),
243
+ },
244
+ projectListingMeRoute: {
245
+ specifier: PROJECT_LISTINGS_INDEX,
246
+ load: () => import(PROJECT_LISTINGS_INDEX),
247
+ },
248
+ slackActionsRoute: {
249
+ specifier: SLACK_INDEX,
250
+ load: () => import(SLACK_INDEX),
251
+ },
252
+ waitlistSignupRoutes: {
253
+ specifier: WAITLIST_SIGNUPS_INDEX,
254
+ load: () => import(WAITLIST_SIGNUPS_INDEX),
255
+ },
256
+ // Commerce (D-47, #5364) — checkout, the Stripe webhook receiver, flow
257
+ // checkouts and the recommender. Module-conditional (isModuleEnabled
258
+ // "commerce") like every other paid slice above.
259
+ checkoutRoutes: {
260
+ specifier: CHECKOUT_INDEX,
261
+ load: () => import(CHECKOUT_INDEX),
262
+ },
263
+ webhooksRoutes: {
264
+ specifier: WEBHOOKS_INDEX,
265
+ load: () => import(WEBHOOKS_INDEX),
266
+ },
267
+ flowCheckoutRoutes: {
268
+ specifier: FLOW_CHECKOUTS_INDEX,
269
+ load: () => import(FLOW_CHECKOUTS_INDEX),
270
+ },
271
+ saveRecommendationRoute: {
272
+ specifier: RECOMMENDER_INDEX,
273
+ load: () => import(RECOMMENDER_INDEX),
274
+ },
275
+ checkoutRecommendationRoute: {
276
+ specifier: RECOMMENDER_INDEX,
277
+ load: () => import(RECOMMENDER_INDEX),
278
+ },
279
+ } satisfies Record<string, { specifier: string; load: () => Promise<unknown> }>;
280
+
281
+ type SlicePluginName = keyof typeof SLICE_PLUGINS;
105
282
 
106
283
  /**
107
284
  * Load one slice route plugin. Every entry above is a `FastifyPluginAsync`
@@ -116,8 +293,8 @@ type SlicePluginName = keyof typeof SLICE_PLUGIN_SPECIFIERS;
116
293
  async function sliceRoutePlugin(
117
294
  name: SlicePluginName,
118
295
  ): Promise<FastifyPluginAsync> {
119
- const specifier: string = SLICE_PLUGIN_SPECIFIERS[name];
120
- const mod = (await import(specifier)) as Record<
296
+ const { specifier, load } = SLICE_PLUGINS[name];
297
+ const mod = (await loadSlice(specifier, load)) as Record<
121
298
  string,
122
299
  FastifyPluginAsync | undefined
123
300
  >;
@@ -134,17 +311,40 @@ async function sliceRoutePlugin(
134
311
  * (apps/api/src/middleware/tenant.ts:83).
135
312
  */
136
313
  async function tenantPreHandlerSlicePlugin(): Promise<FastifyPluginCallback> {
137
- const specifier: string = './middleware/tenant.js';
138
- const mod = (await import(specifier)) as {
314
+ const specifier: string = "./middleware/tenant.js";
315
+ const mod = (await loadSlice(specifier, () => import(specifier))) as {
139
316
  tenantPreHandlerPlugin?: FastifyPluginCallback;
140
317
  };
141
318
  const plugin = mod.tenantPreHandlerPlugin;
142
319
  if (!plugin) {
143
- throw new Error('slice export tenantPreHandlerPlugin missing from ' + specifier);
320
+ throw new Error(
321
+ "slice export tenantPreHandlerPlugin missing from " + specifier,
322
+ );
144
323
  }
145
324
  return plugin;
146
325
  }
147
326
 
327
+ /**
328
+ * The commerce boot-time sandbox guard — the one other slice entry that is
329
+ * not a route plugin, mirroring `tenantPreHandlerSlicePlugin()` above (D3).
330
+ * `lib/stripe.ts` is commerce-owned (D5) and its `assertSandboxKey` is the
331
+ * sole `apps/api` reach into the Stripe SDK wrapper package, so it can no
332
+ * longer be a static import (spec D1) once the module is stripped. The
333
+ * widened `const specifier: string` annotation is what defeats `tsc`
334
+ * resolution, exactly as it does above.
335
+ */
336
+ async function assertCommerceSandboxKey(key: string): Promise<void> {
337
+ const specifier: string = "./lib/stripe.js";
338
+ const mod = (await loadSlice(specifier, () => import(specifier))) as {
339
+ assertSandboxKey?: (key: string) => void;
340
+ };
341
+ const assertSandboxKeyFn = mod.assertSandboxKey;
342
+ if (!assertSandboxKeyFn) {
343
+ throw new Error("slice export assertSandboxKey missing from " + specifier);
344
+ }
345
+ assertSandboxKeyFn(key);
346
+ }
347
+
148
348
  /**
149
349
  * Build Fastify server instance (for serverless or standalone)
150
350
  * @returns Configured Fastify instance ready for requests
@@ -155,9 +355,11 @@ export async function build() {
155
355
 
156
356
  // Runtime sandbox-mode guard: reject live-mode Stripe keys before any
157
357
  // webhook or checkout call can exercise them. Idempotent — safe even if
158
- // assertSandboxKey is called again inside a route handler.
159
- if (config.STRIPE_SECRET_KEY) {
160
- assertSandboxKey(config.STRIPE_SECRET_KEY);
358
+ // assertSandboxKey is called again inside a route handler. Module-
359
+ // conditional (commerce, D-47/D3): a build without commerce has no
360
+ // commerce request path to guard, and lib/stripe.ts is stripped with it.
361
+ if (isModuleEnabled("commerce") && config.STRIPE_SECRET_KEY) {
362
+ await assertCommerceSandboxKey(config.STRIPE_SECRET_KEY);
161
363
  }
162
364
 
163
365
  // trustProxy: true (#4730) — the Vercel serverless handler
@@ -187,6 +389,32 @@ export async function build() {
187
389
  trustProxy: true,
188
390
  });
189
391
 
392
+ // Collect the route table this server registers (#5314).
393
+ //
394
+ // Installed here — after `Fastify()` and before the first `register()` — so
395
+ // no route can precede it: a root-instance `onRoute` hook fires for every
396
+ // route added afterwards in ANY encapsulated child scope, which is what lets
397
+ // one hook see the auth, tenant, webhooks, leads, flows, aeo and schedule
398
+ // scopes without touching any of them.
399
+ //
400
+ // HEAD is dropped: Fastify synthesises it from every GET, so publishing it
401
+ // would describe routes no handler declared.
402
+ //
403
+ // The table lives on THIS instance, not in module state, so two servers
404
+ // built concurrently in one process cannot corrupt each other's — see the
405
+ // note on `readRegisteredRoutes` in openapi/spec.ts for the concrete race.
406
+ // The decorator holds the array by reference and the hook pushes into it, so
407
+ // every reader sees the current contents without re-publishing.
408
+ const registeredRoutes: RegisteredRoute[] = [];
409
+ server.decorate(REGISTERED_ROUTES_DECORATOR, registeredRoutes);
410
+ server.addHook("onRoute", (route) => {
411
+ const methods = Array.isArray(route.method) ? route.method : [route.method];
412
+ for (const method of methods) {
413
+ if (method === "HEAD") continue;
414
+ registeredRoutes.push({ method, url: route.url });
415
+ }
416
+ });
417
+
190
418
  // Register error serializer — maps internal exceptions to generic strings
191
419
  // so connection strings, hostnames, stack traces, and SDK error details
192
420
  // never reach the client. Full detail still goes to pino at error level.
@@ -198,9 +426,32 @@ export async function build() {
198
426
  // `credentials: true` would let any website read credentialed responses
199
427
  // (security review M1). `false` disables cross-origin access entirely;
200
428
  // same-origin and non-browser callers are unaffected.
429
+ //
430
+ // `methods` is EXPLICIT because @fastify/cors defaults it to `GET,HEAD,POST`
431
+ // (its own `defaultOptions`), which is narrower than the surface this server
432
+ // registers (#5397). With only `origin` + `credentials` passed, the preflight
433
+ // for `PATCH /v1/project-listings/drafts/:id` answered
434
+ // `access-control-allow-methods: GET, HEAD, POST`, so a browser refused every
435
+ // draft save on a split-origin deploy — a site could create a listing draft
436
+ // and never save one. The list below is the union of the methods the route
437
+ // table actually serves (GET/POST/PATCH/PUT/DELETE — `/v1/auth/tokens/:id`,
438
+ // `/v1/content/:type/:slug`, `/v1/user/data/:key`, the workspace-config PUTs),
439
+ // plus HEAD (Fastify synthesises it from every GET) and OPTIONS (the plugin's
440
+ // own wildcard preflight route). `cors.test.ts` re-derives that union from the
441
+ // live route table on every run, so a route added in a NEW method fails there
442
+ // rather than silently becoming unreachable from a browser.
443
+ //
444
+ // This widens WHICH METHODS the allowed origin may use, never WHO is allowed:
445
+ // `origin` and `credentials` are untouched.
446
+ //
447
+ // `allowedHeaders` is deliberately left unset: null means @fastify/cors
448
+ // reflects the request's `Access-Control-Request-Headers`, so `authorization`
449
+ // and `content-type` pass without this file having to maintain a list that
450
+ // silently drops a header a future route needs.
201
451
  await server.register(cors, {
202
- origin: config.FRONTEND_URL || config.NODE_ENV !== 'production',
452
+ origin: config.FRONTEND_URL || config.NODE_ENV !== "production",
203
453
  credentials: true,
454
+ methods: ["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
204
455
  });
205
456
 
206
457
  // Register rate limiting — applied globally with the unauth ceiling. The
@@ -213,32 +464,35 @@ export async function build() {
213
464
  });
214
465
 
215
466
  // Health check
216
- server.get('/health', async () => ({
217
- status: 'healthy',
467
+ server.get("/health", async () => ({
468
+ status: "healthy",
218
469
  timestamp: new Date().toISOString(),
219
470
  }));
220
471
 
221
- // Root endpoint
222
- server.get('/', async () => {
472
+ // Root endpoint — introduces this deployment as the SITE it serves, not as
473
+ // the kit it was built with (#5307). `_site.mdx` is read once per process;
474
+ // a site without one degrades to a neutral name, never to the kit's.
475
+ server.get("/", async () => {
476
+ const identity = resolveApiIdentity();
223
477
  return {
224
- name: 'AI Launch Kit API',
225
- version: '1.0.0',
226
- description: 'Local auth + session backend + public content API',
227
- endpoints: {
228
- health: 'GET /health',
229
- openapi: 'GET /v1/openapi.json',
230
- // Auth (local)
231
- tokens: 'POST/GET/DELETE /v1/auth/tokens',
232
- profile: 'GET/PATCH /v1/user/profile',
233
- userData: 'GET/PUT/DELETE /v1/user/data/:key',
234
- // Public content (read-only)
235
- contentTypes: 'GET /v1/content/types',
236
- contentList: 'GET /v1/content/:type',
237
- contentPage: 'GET /v1/content/:type/:slug',
238
- checkoutSessions: 'POST /v1/checkout/sessions',
239
- billing: 'GET /v1/billing',
240
- billingPortalSession: 'POST /v1/billing/portal-session',
241
- },
478
+ name: apiDisplayName(identity),
479
+ version: API_VERSION,
480
+ description: apiDescription(identity),
481
+ // Derived from the routes this process actually registered (#5314),
482
+ // not a hand-maintained literal. The literal listed 11 endpoints against
483
+ // 68 registered routes, omitted `/v1/project-listings/public` and
484
+ // `/v1/billing/usage`, advertised `DELETE /v1/auth/tokens` at a path
485
+ // that 404s (the route is `/v1/auth/tokens/:id`), and named two billing
486
+ // routes on builds where the multi-tenant module registers neither.
487
+ // Same mechanism now covers commerce (D-47, #5364): checkout, the
488
+ // Stripe webhook, the flow-checkouts read and the two recommender
489
+ // routes are derived exactly like the billing routes above and
490
+ // disappear from this list on a build where `commerce` is off, rather
491
+ // than advertising a route that 404s.
492
+ //
493
+ // Read at REQUEST time, so `ready()` has fired and the set is complete
494
+ // even though this handler is registered before most routes.
495
+ endpoints: deriveRootEndpoints(registeredRoutes),
242
496
  };
243
497
  });
244
498
 
@@ -257,20 +511,48 @@ export async function build() {
257
511
  // apply to them; each enforces membership in the service layer.
258
512
  // Module-conditional (S1, #5088): without the multi-tenant module there
259
513
  // is no workspace concept to switch or inspect.
260
- if (isModuleEnabled('multi-tenant')) {
261
- await authScope.register(await sliceRoutePlugin('workspaceContextRoutes'));
514
+ if (isModuleEnabled("multi-tenant")) {
515
+ await authScope.register(
516
+ await sliceRoutePlugin("workspaceContextRoutes"),
517
+ );
262
518
  }
263
519
 
264
520
  // Respondent signup read (#4731) — auth only, NOT tenant-guarded: a
265
521
  // respondent has no workspace membership and the tenant guard is
266
522
  // default-deny. Identity is request.userId; the service re-checks
267
- // User.emailVerified before any Lead read.
268
- await authScope.register(waitlistSignupRoutes);
523
+ // User.emailVerified before any Lead read. Module-conditional
524
+ // (signal-collectors, D-48): the waitlist instrument's respondent half.
525
+ if (isModuleEnabled("signal-collectors")) {
526
+ await authScope.register(await sliceRoutePlugin("waitlistSignupRoutes"));
527
+ }
269
528
 
270
529
  // Respondent checkouts read (#4984) — the same posture as the signup
271
530
  // read above: auth only, NOT tenant-guarded. Identity is request.userId;
272
531
  // the only join is the lead-promotion link (never an email match).
273
- await authScope.register(flowCheckoutRoutes);
532
+ // Module-conditional (commerce, D-47): the `/account` Purchases panel
533
+ // already degrades to its empty state with no code change when this is
534
+ // absent (`SKIPPED_STATUSES` covers 404 — spec §4.2).
535
+ if (isModuleEnabled("commerce")) {
536
+ await authScope.register(await sliceRoutePlugin("flowCheckoutRoutes"));
537
+ }
538
+
539
+ // The founder's OWN listings read (#5295) — the same posture again: auth
540
+ // only, NOT tenant-guarded, because a founder is not a workspace member
541
+ // and the tenant guard is default-deny. Identity is request.userId; the
542
+ // only join is the owner link (ProjectListing.ownerUserId, stamped by
543
+ // services/listing-promotion.ts), never an email match. The tenant-scoped
544
+ // listing routes below keep their owner|admin floor untouched — this route
545
+ // is additive, not a relaxation of that floor. Registered here rather than
546
+ // inside the multi-tenant scope so a founder can read their own rows on a
547
+ // build without the module; the static `/me` path takes router precedence
548
+ // over the tenant scope's parametric `/v1/project-listings/:id`, exactly as
549
+ // the already-shipped static `/v1/project-listings/public` does.
550
+ // Module-conditional (signal-collectors, D-48 / D4): the listing
551
+ // instrument's founder-read half; not severable from the four anonymous
552
+ // listing routes below (same static barrel, D4).
553
+ if (isModuleEnabled("signal-collectors")) {
554
+ await authScope.register(await sliceRoutePlugin("projectListingMeRoute"));
555
+ }
274
556
 
275
557
  // Tenant-guarded domain routes (multi-tenant module) — the tenant
276
558
  // preHandler (default-deny guard, see middleware/tenant.ts) runs after
@@ -279,24 +561,27 @@ export async function build() {
279
561
  // no tenant concept at all — the whole scope is module-conditional
280
562
  // (S1, #5088), so an OSS build that excludes multi-tenant registers
281
563
  // none of it.
282
- if (isModuleEnabled('multi-tenant')) {
564
+ if (isModuleEnabled("multi-tenant")) {
283
565
  await authScope.register(async (tenantScope) => {
284
566
  await tenantScope.register(await tenantPreHandlerSlicePlugin());
285
- await tenantScope.register(await sliceRoutePlugin('workspaceSessionRoutes'));
286
- await tenantScope.register(await sliceRoutePlugin('sessionRoutes'));
287
- await tenantScope.register(await sliceRoutePlugin('workspaceConfigRoutes'));
567
+ await tenantScope.register(
568
+ await sliceRoutePlugin("workspaceSessionRoutes"),
569
+ );
570
+ await tenantScope.register(await sliceRoutePlugin("sessionRoutes"));
571
+ await tenantScope.register(
572
+ await sliceRoutePlugin("workspaceConfigRoutes"),
573
+ );
288
574
  // Org-billing routes (#3701) — org-scoped read + Stripe customer-portal.
289
- await tenantScope.register(await sliceRoutePlugin('billingRoutes'));
575
+ await tenantScope.register(await sliceRoutePlugin("billingRoutes"));
290
576
  // Waitlist operator-dashboard read API (#4665 D7, B0) — tenant-scoped
291
577
  // list/comparison/signups reads over A1's waitlist-experiment rows.
292
578
  await tenantScope.register(
293
- await sliceRoutePlugin('waitlistExperimentRoutes'),
579
+ await sliceRoutePlugin("waitlistExperimentRoutes"),
294
580
  );
295
581
  // Project-listings dashboard (#4998) — tenant-scoped list/get/patch
296
582
  // over the founder's-application-row lifecycle.
297
- const { projectListingTenantRoutes } = await import(
298
- './routes/project-listings/index.js'
299
- );
583
+ const { projectListingTenantRoutes } =
584
+ await import("./routes/project-listings/index.js");
300
585
  await tenantScope.register(projectListingTenantRoutes);
301
586
  });
302
587
  }
@@ -311,22 +596,28 @@ export async function build() {
311
596
  // module-conditional at REGISTRATION too (S1, #5088) so an OSS build that
312
597
  // excludes the content-write slice never wires the handler in at all. Not
313
598
  // a MODULE_SLICES entry — see config/modules.ts.
314
- if (isModuleEnabled('content-write')) {
315
- await server.register(await sliceRoutePlugin('createPageRoute'));
316
- await server.register(await sliceRoutePlugin('updatePageRoute'));
317
- await server.register(await sliceRoutePlugin('deletePageRoute'));
599
+ if (isModuleEnabled("content-write")) {
600
+ await server.register(await sliceRoutePlugin("createPageRoute"));
601
+ await server.register(await sliceRoutePlugin("updatePageRoute"));
602
+ await server.register(await sliceRoutePlugin("deletePageRoute"));
603
+ }
604
+ // Module-conditional (commerce, D-47): checkout moved to its own module.
605
+ if (isModuleEnabled("commerce")) {
606
+ await server.register(await sliceRoutePlugin("checkoutRoutes"));
318
607
  }
319
- await server.register(checkoutRoutes);
320
608
  // Deliverable download — a capability-token URL (#3961 D5); the token
321
- // itself is the sole gate, so no auth preHandler applies.
322
- await server.register(deliverableDownloadRoute);
609
+ // itself is the sole gate, so no auth preHandler applies. Module-conditional
610
+ // (signal-collectors, D-48): gated PDFs and reports.
611
+ if (isModuleEnabled("signal-collectors")) {
612
+ await server.register(await sliceRoutePlugin("deliverableDownloadRoute"));
613
+ }
323
614
 
324
615
  // Webhooks routes — raw-body parser scoped to this sub-router only so the
325
616
  // global JSON parser on all other routes is unaffected.
326
617
  await server.register(async (webhooksScope) => {
327
618
  webhooksScope.addContentTypeParser(
328
- 'application/json',
329
- { parseAs: 'buffer' },
619
+ "application/json",
620
+ { parseAs: "buffer" },
330
621
  (_req, body, done) => {
331
622
  done(null, body);
332
623
  },
@@ -335,14 +626,24 @@ export async function build() {
335
626
  // field (#4999) — raw buffer here too, so the signature is verified over
336
627
  // the EXACT bytes Slack signed, never a re-serialized parse.
337
628
  webhooksScope.addContentTypeParser(
338
- 'application/x-www-form-urlencoded',
339
- { parseAs: 'buffer' },
629
+ "application/x-www-form-urlencoded",
630
+ { parseAs: "buffer" },
340
631
  (_req, body, done) => {
341
632
  done(null, body);
342
633
  },
343
634
  );
344
- await webhooksScope.register(webhooksRoutes);
345
- await webhooksScope.register(slackActionsRoute);
635
+ // Stripe webhook receiver — module-conditional (commerce, D-47). The
636
+ // scope wrapper and both raw-body parsers above stay unconditional: the
637
+ // Slack parser below needs them too (D2).
638
+ if (isModuleEnabled("commerce")) {
639
+ await webhooksScope.register(await sliceRoutePlugin("webhooksRoutes"));
640
+ }
641
+ // Project-listing operator decision (Slack Approve/Reject) — the "hand an
642
+ // operator a decision" half of D-48's grouping sentence. Module-conditional
643
+ // (signal-collectors).
644
+ if (isModuleEnabled("signal-collectors")) {
645
+ await webhooksScope.register(await sliceRoutePlugin("slackActionsRoute"));
646
+ }
346
647
  });
347
648
 
348
649
  // Leads routes — rate-limit middleware scoped to this sub-router. Own
@@ -359,20 +660,43 @@ export async function build() {
359
660
  // Project-listings anonymous surface (#4998, reshaped by #5036) — the
360
661
  // three-operation draft lifecycle plus the public page-render read, all
361
662
  // on the same rate-limited, identity-blind posture as the leads routes
362
- // above.
663
+ // above. Module-conditional (signal-collectors, D-48): the listing
664
+ // instrument's application-lifecycle half.
363
665
  //
364
666
  // Do NOT read that rate limit as a bound on anonymous draft creation. It
365
667
  // keys on `req.ip`, and `trustProxy` is on (see the note at the top of this
366
668
  // file), so a caller reaching the API directly can vary
367
669
  // `X-Forwarded-For` and get a fresh budget per request. That is a
368
670
  // pre-existing property of the limiter, not of these routes; what these
369
- // routes do is keep the cost of an unbounded call small — `start` writes
370
- // one row and sends nothing, and the Lead write with its operator
371
- // notification waits for `submit` (PR #5042 security review, finding 4).
372
- await leadsScope.register(projectListingStartRoute);
373
- await leadsScope.register(projectListingDraftPatchRoute);
374
- await leadsScope.register(projectListingSubmitRoute);
375
- await leadsScope.register(projectListingPublicRoute);
671
+ // routes do is keep the cost of an unbounded call small — the Lead write
672
+ // with its operator notification waits for `submit` (PR #5042 security
673
+ // review, finding 4).
674
+ //
675
+ // `start` DOES now send one email (#5406, the founder's resume link), so
676
+ // the older form of this note — "`start` writes one row and sends
677
+ // nothing" — no longer holds. That send is opt-in per site, and where it
678
+ // is on it is bounded in the send path rather than per IP
679
+ // (`services/project-listing-resume-email.ts`), precisely because this IP
680
+ // limiter cannot carry the weight of an outbound-mail decision. Two bounds,
681
+ // both counted in the database so they hold across instances: PER RECIPIENT
682
+ // (PR #5411 security review, finding 1) and, since #5414, PER SITE across
683
+ // every recipient — the residual the first one left, and the one that
684
+ // decides whether a flood can cost the site its sending domain.
685
+ if (isModuleEnabled("signal-collectors")) {
686
+ await leadsScope.register(await sliceRoutePlugin("projectListingStartRoute"));
687
+ await leadsScope.register(
688
+ await sliceRoutePlugin("projectListingDraftPatchRoute"),
689
+ );
690
+ await leadsScope.register(await sliceRoutePlugin("projectListingSubmitRoute"));
691
+ // #5407 — the resume link's verification write. Anonymous, and gated
692
+ // on the resume token AND the nonce that only the resume email
693
+ // carries, so it belongs in the same rate-limited scope as the other
694
+ // three.
695
+ await leadsScope.register(
696
+ await sliceRoutePlugin("projectListingVerifyEmailRoute"),
697
+ );
698
+ await leadsScope.register(await sliceRoutePlugin("projectListingPublicRoute"));
699
+ }
376
700
  });
377
701
 
378
702
  // Flows routes — anonymous surface; rate-limit middleware scoped to this
@@ -383,13 +707,23 @@ export async function build() {
383
707
  // scope #4730's bump was actually sized for; unchanged by #5111.
384
708
  await server.register(async (flowsScope) => {
385
709
  flowsScope.register(rateLimitMiddleware);
386
- await flowsScope.register(getFlowRoute);
387
- await flowsScope.register(submitFlowStepRoute);
388
- await flowsScope.register(getFlowAggregateRoute);
710
+ // Flow engine reads (#3956) — module-conditional (signal-collectors,
711
+ // D-48). The scope itself and its rate limiter above stay unconditional
712
+ // (D2) — commerce's recommender routes below need them too.
713
+ if (isModuleEnabled("signal-collectors")) {
714
+ await flowsScope.register(await sliceRoutePlugin("getFlowRoute"));
715
+ await flowsScope.register(await sliceRoutePlugin("submitFlowStepRoute"));
716
+ await flowsScope.register(await sliceRoutePlugin("getFlowAggregateRoute"));
717
+ }
389
718
  // The recommender's email-moment routes (#4841) — same anonymous,
390
- // rate-limited posture as the three above.
391
- await flowsScope.register(saveRecommendationRoute);
392
- await flowsScope.register(checkoutRecommendationRoute);
719
+ // rate-limited posture as the three above. Module-conditional (commerce,
720
+ // D-47): the recommender leaves Pro flows and moves to commerce whole.
721
+ if (isModuleEnabled("commerce")) {
722
+ await flowsScope.register(await sliceRoutePlugin("saveRecommendationRoute"));
723
+ await flowsScope.register(
724
+ await sliceRoutePlugin("checkoutRecommendationRoute"),
725
+ );
726
+ }
393
727
  });
394
728
 
395
729
  // AEO score routes (#4666) — anonymous surface, same posture as the flows
@@ -414,14 +748,18 @@ export async function build() {
414
748
  // (#5111, DEF-004). Module-conditional (S1, #5088): the scheduling module
415
749
  // owns this whole route set, so an OSS build that excludes it registers
416
750
  // neither the routes nor their imports.
417
- if (isModuleEnabled('scheduling')) {
751
+ if (isModuleEnabled("scheduling")) {
418
752
  await server.register(async (scheduleScope) => {
419
753
  scheduleScope.register(rateLimitMiddleware, {
420
754
  perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
421
755
  });
422
- await scheduleScope.register(await sliceRoutePlugin('listAvailabilityRoute'));
423
- await scheduleScope.register(await sliceRoutePlugin('getEventMetaRoute'));
424
- await scheduleScope.register(await sliceRoutePlugin('createBookingRoute'));
756
+ await scheduleScope.register(
757
+ await sliceRoutePlugin("listAvailabilityRoute"),
758
+ );
759
+ await scheduleScope.register(await sliceRoutePlugin("getEventMetaRoute"));
760
+ await scheduleScope.register(
761
+ await sliceRoutePlugin("createBookingRoute"),
762
+ );
425
763
  });
426
764
  }
427
765
 
@@ -433,7 +771,7 @@ export async function build() {
433
771
  if (shouldMountTestAuth()) {
434
772
  await server.register(registerTestAuthRoute);
435
773
  server.log.warn(
436
- 'test-auth route MOUNTED — only safe in dev/test environments',
774
+ "test-auth route MOUNTED — only safe in dev/test environments",
437
775
  );
438
776
  }
439
777