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