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
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Slice-module load boundary (#5334 D4).
3
+ *
4
+ * Every paid-module SLICE in `apps/api` is reached through an indirect dynamic
5
+ * `import()` — a specifier the call site never writes as a literal, because
6
+ * `tsc` would then resolve it and the OSS strip would break (#5182 D1). #5304
7
+ * moved those specifiers onto module-level constants, the one shape that is
8
+ * simultaneously unresolvable to `tsc` and traceable to `@vercel/nft`, so the
9
+ * files reach a Vercel deployment.
10
+ *
11
+ * WHAT THIS FILE IS FOR. That shape is correct for the deploy path the scaffold
12
+ * ships, and it cannot be made to work under a BUNDLER. esbuild bundles a
13
+ * dynamic `import()` iff its argument is a string literal at the call site —
14
+ * exactly the predicate `tsc` resolves on, and exactly the one #5182 D1
15
+ * requires be false. So in a bundled build the specifier survives verbatim and
16
+ * Node resolves it against the BUNDLE OUTPUT directory rather than the source
17
+ * module's: `"./list.js"`, written in `routes/project-listings/index.ts`,
18
+ * becomes `dist/list.js` and does not exist. `build()` throws during
19
+ * CONSTRUCTION, so every route 500s rather than one.
20
+ *
21
+ * The failure is otherwise silent on every channel: esbuild emits no warning
22
+ * for the imports it declines to bundle, and a consuming frontend degrades to
23
+ * an empty list and still answers 200. `salessmyth/samhenry` spent nine hours
24
+ * on it. This boundary is what turns that into a one-line diagnosis — it does
25
+ * not make bundling work, because nothing in this file could.
26
+ *
27
+ * WHY IT TAKES A THUNK AND NOT A SPECIFIER. `loadSlice("./list.js")` doing the
28
+ * `import()` in here would be the obvious signature and would silently undo
29
+ * #5304. `enumerateSliceSpecifiers()` treats a file as an "indirect file" only
30
+ * if it carries a dynamic `import()` with a non-literal argument; move the call
31
+ * out of the callers and they stop qualifying, their specifier constants stop
32
+ * being candidates, and `check-slice-reachability.ts` passes vacuously over an
33
+ * empty set. Worse, `@vercel/nft` constant-propagates `WORKSPACES_INDEX` into
34
+ * `import(WORKSPACES_INDEX)` only because the constant and the call sit in one
35
+ * module — hoist the call and there is nothing to propagate, so every slice
36
+ * file goes untraced and #5304's outage returns with its own gate green.
37
+ *
38
+ * So the `import()` stays at the call site, in the caller's module, and this
39
+ * function only awaits the thunk and interprets the failure.
40
+ *
41
+ * Gated by `scripts/check-bundled-build-diagnoses.ts`, which bundles `apps/api`
42
+ * and asserts that the boot fails WITH this diagnostic rather than without one.
43
+ */
44
+
45
+ /**
46
+ * Node reports an unresolvable ESM specifier as `ERR_MODULE_NOT_FOUND` and an
47
+ * unresolvable CJS one as `MODULE_NOT_FOUND`. A bundle can produce either,
48
+ * since a bundled graph mixes both.
49
+ */
50
+ const MODULE_NOT_FOUND_CODES = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]);
51
+
52
+ function isModuleNotFound(error: unknown): boolean {
53
+ const code = (error as { code?: unknown } | null | undefined)?.code;
54
+ return typeof code === "string" && MODULE_NOT_FOUND_CODES.has(code);
55
+ }
56
+
57
+ /**
58
+ * Await a slice module's loader thunk, turning an unresolvable module into a
59
+ * diagnosis instead of a bare `ERR_MODULE_NOT_FOUND` on a path that never
60
+ * existed.
61
+ *
62
+ * Anything that is not a module-resolution failure is rethrown UNTOUCHED — the
63
+ * same object, not a wrapper. A slice that throws while evaluating is a real
64
+ * bug in the slice and must keep its own stack and type.
65
+ *
66
+ * @param specifier the module specifier the thunk imports, for the message.
67
+ * Every call site already has it in scope, so nothing is enumerated here.
68
+ * @param load the caller's own `() => import(CONST)` thunk. Must close over a
69
+ * constant in the CALLER's module — see the file header.
70
+ */
71
+ export async function loadSlice(
72
+ specifier: string,
73
+ load: () => Promise<unknown>,
74
+ ): Promise<unknown> {
75
+ try {
76
+ return await load();
77
+ } catch (error) {
78
+ if (!isModuleNotFound(error)) throw error;
79
+ throw new Error(
80
+ `AILK slice module '${specifier}' could not be resolved at runtime. ` +
81
+ `This throws during server construction, so every route fails, not one. Two causes produce it:\n` +
82
+ ` 1. The module was compiled but not SHIPPED — the deploy-time file tracer did not emit it (#5304). ` +
83
+ `Check with \`pnpm slice-reachability:check\`.\n` +
84
+ ` 2. This build was BUNDLED. apps/api does not support bundling (#5334): a bundler resolves this ` +
85
+ `specifier against the bundle's own output directory rather than its source module's, and esbuild ` +
86
+ `leaves it unbundled without a warning. See apps/api/CLAUDE.md § Deploy path.`,
87
+ { cause: error },
88
+ );
89
+ }
90
+ }
@@ -28,10 +28,10 @@ describe("GET /api/openapi.json", () => {
28
28
 
29
29
  const spec = JSON.parse(res.body);
30
30
  expect(spec.openapi).toBe("3.1.0");
31
- expect(spec.info).toMatchObject({
32
- title: "AI Launch Kit — Public Content API",
33
- version: "1.0.0",
34
- });
31
+ // The title is resolved from the serving site's own `_site.mdx` (#5307),
32
+ // so assert its SHAPE rather than a literal site name — the same build
33
+ // serves a different title on every consuming site.
34
+ expect(spec.info.title).toMatch(/Public Content API$/);
35
35
  expect(spec.info.version).toBe("1.0.0");
36
36
  expect(typeof spec.paths).toBe("object");
37
37
  });
@@ -54,39 +54,59 @@ describe("GET /api/openapi.json", () => {
54
54
  );
55
55
  });
56
56
 
57
- it("documents the checkout sessions endpoint with operationId createCheckoutSession", async () => {
58
- const res = await app.inject({
59
- method: "GET",
60
- url: "/v1/openapi.json",
57
+ // The checkout path's OpenAPI documentation is module-conditional (commerce,
58
+ // D-47 / #5364) — mirroring the route's own registration gate in server.ts —
59
+ // so these two assertions need the module enabled, exactly as a real
60
+ // commerce-installed deployment would have it.
61
+ describe("checkout endpoint (commerce module enabled)", () => {
62
+ const PRIOR_ENABLED_MODULES = process.env.AILK_ENABLED_MODULES;
63
+
64
+ beforeEach(() => {
65
+ process.env.AILK_ENABLED_MODULES = "commerce";
61
66
  });
62
67
 
63
- const spec = JSON.parse(res.body);
64
- expect(spec.paths["/v1/checkout/sessions"].post.operationId).toBe(
65
- "createCheckoutSession",
66
- );
67
- });
68
+ afterEach(() => {
69
+ if (PRIOR_ENABLED_MODULES === undefined) {
70
+ delete process.env.AILK_ENABLED_MODULES;
71
+ } else {
72
+ process.env.AILK_ENABLED_MODULES = PRIOR_ENABLED_MODULES;
73
+ }
74
+ });
68
75
 
69
- it("documents 201, 400, 404, 500, 502 responses for the checkout endpoint", async () => {
70
- const res = await app.inject({
71
- method: "GET",
72
- url: "/v1/openapi.json",
76
+ it("documents the checkout sessions endpoint with operationId createCheckoutSession", async () => {
77
+ const res = await app.inject({
78
+ method: "GET",
79
+ url: "/v1/openapi.json",
80
+ });
81
+
82
+ const spec = JSON.parse(res.body);
83
+ expect(spec.paths["/v1/checkout/sessions"].post.operationId).toBe(
84
+ "createCheckoutSession",
85
+ );
73
86
  });
74
87
 
75
- const spec = JSON.parse(res.body);
76
- const responses = spec.paths["/v1/checkout/sessions"].post.responses;
77
- expect(responses["201"].description).toBe("Checkout session created");
78
- expect(responses["400"].description).toBe(
79
- "Validation error — invalid request body",
80
- );
81
- expect(responses["404"].description).toBe(
82
- "Unknown product code — no active Stripe price for the given productCode",
83
- );
84
- expect(responses["500"].description).toBe(
85
- "Live-mode forbidden — STRIPE_SECRET_KEY is a live key",
86
- );
87
- expect(responses["502"].description).toBe(
88
- "Checkout unavailable — Stripe returned an unexpected error",
89
- );
88
+ it("documents 201, 400, 404, 500, 502 responses for the checkout endpoint", async () => {
89
+ const res = await app.inject({
90
+ method: "GET",
91
+ url: "/v1/openapi.json",
92
+ });
93
+
94
+ const spec = JSON.parse(res.body);
95
+ const responses = spec.paths["/v1/checkout/sessions"].post.responses;
96
+ expect(responses["201"].description).toBe("Checkout session created");
97
+ expect(responses["400"].description).toBe(
98
+ "Validation error — invalid request body",
99
+ );
100
+ expect(responses["404"].description).toBe(
101
+ "Unknown product code — no active Stripe price for the given productCode",
102
+ );
103
+ expect(responses["500"].description).toBe(
104
+ "Live-mode forbidden — STRIPE_SECRET_KEY is a live key",
105
+ );
106
+ expect(responses["502"].description).toBe(
107
+ "Checkout unavailable — Stripe returned an unexpected error",
108
+ );
109
+ });
90
110
  });
91
111
 
92
112
  it("declares query and path parameters for content endpoints", async () => {
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @file spec-identity.test.ts
3
+ *
4
+ * The served API's self-description (#5307) and the `servers` block (#5306).
5
+ *
6
+ * These assert the BUILDER's output given a config rather than a deployed
7
+ * response: the defect is that a deployed site serves the kit's identity and
8
+ * the kit's host, and the builder is the only place that can be proved
9
+ * without deploying.
10
+ */
11
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import {
16
+ API_VERSION,
17
+ apiDescription,
18
+ apiDisplayName,
19
+ buildOpenApiSpec,
20
+ resetApiIdentity,
21
+ resolveApiIdentity,
22
+ } from "../spec";
23
+
24
+ /** Write a throwaway content root carrying the given `_site.mdx` frontmatter. */
25
+ function withContentRoot(frontmatter: string): string {
26
+ const root = mkdtempSync(join(tmpdir(), "ailk-site-"));
27
+ mkdirSync(root, { recursive: true });
28
+ writeFileSync(join(root, "_site.mdx"), `---\n${frontmatter}\n---\n\nbody\n`);
29
+ return root;
30
+ }
31
+
32
+ describe("buildOpenApiSpec — site identity (#5307)", () => {
33
+ it("titles the spec after the site, not after the kit", () => {
34
+ const spec = buildOpenApiSpec({ siteName: "Sam Henry" });
35
+
36
+ expect(spec.info.title).toBe("Sam Henry — Public Content API");
37
+ expect(JSON.stringify(spec.info)).not.toMatch(/AI Launch Kit|AILK/i);
38
+ });
39
+
40
+ it("degrades to a named neutral title when no site name is resolvable", () => {
41
+ const spec = buildOpenApiSpec({});
42
+
43
+ expect(spec.info.title).toBe("Public Content API");
44
+ expect(spec.info.title).not.toMatch(/AI Launch Kit|AILK/i);
45
+ });
46
+
47
+ it("asserts no licence over a deployment that has not chosen one", () => {
48
+ expect(buildOpenApiSpec({ siteName: "Sam Henry" }).info).not.toHaveProperty(
49
+ "license",
50
+ );
51
+ });
52
+
53
+ it("keeps API_VERSION as the /v1 contract version", () => {
54
+ expect(API_VERSION).toBe("1.0.0");
55
+ expect(buildOpenApiSpec({}).info.version).toBe(API_VERSION);
56
+ });
57
+ });
58
+
59
+ describe("buildOpenApiSpec — servers block (#5306)", () => {
60
+ it("names the deployment's own API origin", () => {
61
+ const spec = buildOpenApiSpec({ publicApiUrl: "https://api.samhenry.org" });
62
+
63
+ expect(spec.servers).toEqual([
64
+ { url: "https://api.samhenry.org", description: expect.any(String) },
65
+ ]);
66
+ });
67
+
68
+ it("never serves the kit's own host", () => {
69
+ const spec = buildOpenApiSpec({
70
+ siteName: "Sam Henry",
71
+ publicApiUrl: "https://api.samhenry.org",
72
+ });
73
+
74
+ expect(JSON.stringify(spec)).not.toContain("ailaunchkit.ai");
75
+ });
76
+
77
+ it("normalises a trailing slash", () => {
78
+ expect(
79
+ buildOpenApiSpec({ publicApiUrl: "https://api.samhenry.org/" }).servers[0]
80
+ ?.url,
81
+ ).toBe("https://api.samhenry.org");
82
+ });
83
+
84
+ it("degrades to a document-relative origin when the value is unset", () => {
85
+ const spec = buildOpenApiSpec({});
86
+
87
+ expect(spec.servers).toHaveLength(1);
88
+ expect(spec.servers[0]?.url).toBe("/");
89
+ // The degradation is named in the served document itself, so an agent
90
+ // reading the spec learns why rather than following a wrong absolute URL.
91
+ expect(spec.servers[0]?.description).toMatch(/AILK_PUBLIC_API_URL/);
92
+ });
93
+
94
+ it("degrades rather than serving a malformed origin", () => {
95
+ expect(buildOpenApiSpec({ publicApiUrl: "not-a-url" }).servers[0]?.url).toBe(
96
+ "/",
97
+ );
98
+ expect(
99
+ buildOpenApiSpec({ publicApiUrl: "ftp://api.example.com" }).servers[0]
100
+ ?.url,
101
+ ).toBe("/");
102
+ });
103
+ });
104
+
105
+ describe("resolveApiIdentity — reads _site.mdx", () => {
106
+ const originalContentRoot = process.env.CONTENT_ROOT;
107
+ let root: string | undefined;
108
+
109
+ afterEach(() => {
110
+ if (originalContentRoot === undefined) delete process.env.CONTENT_ROOT;
111
+ else process.env.CONTENT_ROOT = originalContentRoot;
112
+ if (root) rmSync(root, { recursive: true, force: true });
113
+ root = undefined;
114
+ resetApiIdentity();
115
+ });
116
+
117
+ it("sources the site name and description from _site.mdx", () => {
118
+ root = withContentRoot(
119
+ [
120
+ 'version: "1"',
121
+ "siteName: Sam Henry",
122
+ "baseUrl: https://samhenry.org",
123
+ "siteIdentity:",
124
+ " kind: Person",
125
+ " name: Sam Henry",
126
+ " description: Independent software consultant.",
127
+ ].join("\n"),
128
+ );
129
+ process.env.CONTENT_ROOT = root;
130
+ resetApiIdentity();
131
+
132
+ const identity = resolveApiIdentity();
133
+
134
+ expect(identity.siteName).toBe("Sam Henry");
135
+ expect(identity.siteDescription).toBe("Independent software consultant.");
136
+ expect(apiDisplayName(identity)).toBe("Sam Henry API");
137
+ expect(apiDescription(identity)).toBe("Independent software consultant.");
138
+ });
139
+
140
+ // #5306 AC-3 — the trap this card exists to refuse.
141
+ it("does NOT use _site.mdx baseUrl as the API origin", () => {
142
+ root = withContentRoot(
143
+ [
144
+ 'version: "1"',
145
+ "siteName: Sam Henry",
146
+ // The SITE origin. The API is deployed on a different host
147
+ // (api.samhenry.org), so this value must never reach `servers`.
148
+ "baseUrl: https://samhenry.org",
149
+ ].join("\n"),
150
+ );
151
+ process.env.CONTENT_ROOT = root;
152
+ resetApiIdentity();
153
+
154
+ const identity = resolveApiIdentity();
155
+ expect(identity.publicApiUrl).not.toBe("https://samhenry.org");
156
+
157
+ const spec = buildOpenApiSpec({ ...identity, publicApiUrl: undefined });
158
+ expect(JSON.stringify(spec.servers)).not.toContain("samhenry.org");
159
+ });
160
+
161
+ it("degrades to a named neutral default when _site.mdx is unreadable", () => {
162
+ root = mkdtempSync(join(tmpdir(), "ailk-empty-"));
163
+ process.env.CONTENT_ROOT = root;
164
+ resetApiIdentity();
165
+
166
+ const identity = resolveApiIdentity();
167
+
168
+ expect(identity.siteName).toBeUndefined();
169
+ expect(apiDisplayName(identity)).toBe("Content API");
170
+ expect(apiDisplayName(identity)).not.toMatch(/AI Launch Kit|AILK/i);
171
+ expect(buildOpenApiSpec(identity).info.title).toBe("Public Content API");
172
+ });
173
+
174
+ it("degrades when _site.mdx frontmatter is malformed", () => {
175
+ root = withContentRoot("siteName: [unclosed");
176
+ process.env.CONTENT_ROOT = root;
177
+ resetApiIdentity();
178
+
179
+ expect(resolveApiIdentity().siteName).toBeUndefined();
180
+ expect(apiDisplayName(resolveApiIdentity())).toBe("Content API");
181
+ });
182
+ });
@@ -0,0 +1,293 @@
1
+ /**
2
+ * @file surface-drift.test.ts
3
+ * @description The self-description drift gate (#5314).
4
+ *
5
+ * Builds the REAL server, takes the route table Fastify actually registered,
6
+ * and diffs it against both surfaces that claim to describe it — the root
7
+ * index `endpoints` object and the served OpenAPI `paths` object.
8
+ *
9
+ * Both surfaces are read over `inject`, i.e. exactly what an agent would
10
+ * fetch, never the object the builder returned.
11
+ *
12
+ * This gate was RED before the derivation landed: 61 registered routes were
13
+ * missing from `paths` and 53 from `endpoints`. Two of its five concerns
14
+ * (AC-1 coverage on each surface) are unfailable now that both surfaces
15
+ * derive from the same registry they are compared against — that is the
16
+ * inherent consequence of AC-2's "derived" branch, and the spec's D5 says so.
17
+ * What stays live is the overlay join (a hand-authored entry whose route no
18
+ * longer registers — the concern that caught `DELETE /v1/auth/tokens`, whose
19
+ * real path is `/v1/auth/tokens/:id`), the anti-vacuity floor (an empty or
20
+ * stale registry would pass a coverage check trivially, so it is asserted
21
+ * against directly), and the module-agreement case's non-empty guard, which
22
+ * fails if module gating ever collapses so both configs register identically.
23
+ *
24
+ * Mirrors `src/__tests__/module-exclusion.test.ts`'s external-boundary mocks
25
+ * (CORS, rate-limit, email) — see that file's header for why each is mocked.
26
+ */
27
+
28
+ jest.mock("@fastify/cors", () => {
29
+ return jest.fn().mockResolvedValue(undefined);
30
+ });
31
+
32
+ jest.mock("@fastify/rate-limit", () => {
33
+ return jest.fn().mockResolvedValue(undefined);
34
+ });
35
+
36
+ jest.mock("@working-theory/email", () => ({
37
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
38
+ }));
39
+
40
+ import { build } from "../../server";
41
+ import { readRegisteredRoutes } from "../spec";
42
+ import {
43
+ compareSurfaces,
44
+ formatSurfaceDrift,
45
+ hasDrift,
46
+ openApiSurfaceKeys,
47
+ rootIndexSurfaceKeys,
48
+ routeKey,
49
+ toOpenApiPath,
50
+ type RegisteredRoute,
51
+ } from "../surface-drift";
52
+
53
+ /** A route every build registers, outside every module conditional. */
54
+ const FLOOR_ROUTE = routeKey("GET", "/v1/content/types");
55
+
56
+ interface Surfaces {
57
+ registered: RegisteredRoute[];
58
+ paths: Record<string, Record<string, unknown>>;
59
+ endpoints: Record<string, string>;
60
+ }
61
+
62
+ /**
63
+ * Build the server under one module configuration and read both surfaces back
64
+ * over the wire. `undefined` means "leave AILK_ENABLED_MODULES unset", which
65
+ * is now the default deployment shape (no paid module, D-44 / #5368).
66
+ */
67
+ async function surfacesFor(enabled: string | undefined): Promise<Surfaces> {
68
+ const prior = process.env.AILK_ENABLED_MODULES;
69
+ if (enabled === undefined) {
70
+ delete process.env.AILK_ENABLED_MODULES;
71
+ } else {
72
+ process.env.AILK_ENABLED_MODULES = enabled;
73
+ }
74
+
75
+ try {
76
+ const app = await build();
77
+ await app.ready();
78
+
79
+ const registered = [...readRegisteredRoutes(app)];
80
+
81
+ const rootRes = await app.inject({ method: "GET", url: "/" });
82
+ const specRes = await app.inject({
83
+ method: "GET",
84
+ url: "/v1/openapi.json",
85
+ });
86
+
87
+ await app.close();
88
+
89
+ expect(rootRes.statusCode).toBe(200);
90
+ expect(specRes.statusCode).toBe(200);
91
+
92
+ return {
93
+ registered,
94
+ paths: JSON.parse(specRes.body).paths,
95
+ endpoints: JSON.parse(rootRes.body).endpoints,
96
+ };
97
+ } finally {
98
+ if (prior === undefined) {
99
+ delete process.env.AILK_ENABLED_MODULES;
100
+ } else {
101
+ process.env.AILK_ENABLED_MODULES = prior;
102
+ }
103
+ }
104
+ }
105
+
106
+ // The module configurations every claim below is asserted under. Unset is now
107
+ // the default deployment (no paid module, D-44 / #5368), the same as an
108
+ // explicit "" (the OSS-strip build target); a full allowlist is the
109
+ // every-module-on case. A slice route absent under "" must be absent from
110
+ // BOTH surfaces there, which is what makes its absence agreement rather than
111
+ // drift.
112
+ const ALL_PAID_MODULES =
113
+ "docs,multi-tenant,scheduling,content-write,signal-collectors";
114
+ const CONFIGS: Array<[string, string | undefined]> = [
115
+ ["AILK_ENABLED_MODULES unset (no paid module, the default)", undefined],
116
+ ['AILK_ENABLED_MODULES="" (no paid module)', ""],
117
+ [
118
+ `AILK_ENABLED_MODULES="${ALL_PAID_MODULES}" (every paid module on)`,
119
+ ALL_PAID_MODULES,
120
+ ],
121
+ ];
122
+
123
+ describe("API self-description drift (#5314)", () => {
124
+ describe.each(CONFIGS)("%s", (_label, enabled) => {
125
+ let surfaces: Surfaces;
126
+
127
+ beforeAll(async () => {
128
+ surfaces = await surfacesFor(enabled);
129
+ }, 60000);
130
+
131
+ // Anti-vacuity floor. A coverage assertion over an empty registry passes
132
+ // trivially, so an empty or stale registry is failed directly — the most
133
+ // likely way a derivation gate silently stops testing anything.
134
+ it("registers a non-empty route table including the floor route", () => {
135
+ expect(surfaces.registered.length).toBeGreaterThan(0);
136
+ const keys = surfaces.registered.map((r) => routeKey(r.method, r.url));
137
+ expect(keys).toContain(FLOOR_ROUTE);
138
+ expect(new Set(keys).size).toBe(keys.length);
139
+ });
140
+
141
+ it("publishes no surface entry for a route the server does not register", () => {
142
+ const diffs = compareSurfaces(
143
+ surfaces.registered,
144
+ surfaces.paths,
145
+ surfaces.endpoints,
146
+ );
147
+ const orphaned = diffs.filter((d) => d.orphanedInSurface.length > 0);
148
+ expect(formatSurfaceDrift(orphaned)).toBe("");
149
+ });
150
+
151
+ it("describes every registered route on both surfaces", () => {
152
+ const diffs = compareSurfaces(
153
+ surfaces.registered,
154
+ surfaces.paths,
155
+ surfaces.endpoints,
156
+ );
157
+ expect(formatSurfaceDrift(diffs)).toBe("");
158
+ expect(hasDrift(diffs)).toBe(false);
159
+ });
160
+
161
+ // AC-1 names this endpoint specifically: it returns 200 and is what the
162
+ // consuming site depends on, and before #5314 both surfaces omitted it.
163
+ // #5366 (D-48) moved it to signal-collectors: the no-drift half
164
+ // (`missingFromSurface`) holds regardless of whether the route
165
+ // registers at all, but the route itself now only registers when
166
+ // signal-collectors is one of the enabled modules for this config.
167
+ it("describes GET /v1/project-listings/public on both surfaces when registered", () => {
168
+ const diffs = compareSurfaces(
169
+ surfaces.registered,
170
+ surfaces.paths,
171
+ surfaces.endpoints,
172
+ );
173
+ for (const d of diffs) {
174
+ expect(d.missingFromSurface).not.toContain(
175
+ routeKey("GET", "/v1/project-listings/public"),
176
+ );
177
+ }
178
+ const signalCollectorsEnabled = (enabled ?? "")
179
+ .split(",")
180
+ .map((s) => s.trim())
181
+ .includes("signal-collectors");
182
+ expect(
183
+ surfaces.registered.some(
184
+ (r) => r.url === "/v1/project-listings/public" && r.method === "GET",
185
+ ),
186
+ ).toBe(signalCollectorsEnabled);
187
+ });
188
+ });
189
+
190
+ // Regression: the route table is per-instance, not module state.
191
+ //
192
+ // `vercel-handler.ts`'s `getServer()` is a check-then-set — it tests
193
+ // `!serverInstance`, then awaits twice before assigning — so two concurrent
194
+ // invocations can both call `build()`. With a module-level table, one
195
+ // build's reset would land mid-fill of the other and leave a truncated or
196
+ // doubled set, and both surfaces would silently misreport. Building two
197
+ // servers under DIFFERENT module configs and asserting each keeps its own
198
+ // table is what makes that unreachable rather than merely unlikely.
199
+ it("keeps each server's route table on its own instance", async () => {
200
+ const prior = process.env.AILK_ENABLED_MODULES;
201
+ try {
202
+ process.env.AILK_ENABLED_MODULES = ALL_PAID_MODULES;
203
+ const allOn = await build();
204
+ await allOn.ready();
205
+
206
+ process.env.AILK_ENABLED_MODULES = "";
207
+ const noneOn = await build();
208
+ await noneOn.ready();
209
+
210
+ const allKeys = readRegisteredRoutes(allOn).map((r) =>
211
+ routeKey(r.method, r.url),
212
+ );
213
+ const noneKeys = readRegisteredRoutes(noneOn).map((r) =>
214
+ routeKey(r.method, r.url),
215
+ );
216
+
217
+ // The second build must not have emptied, truncated or doubled the
218
+ // first — the exact corruption a shared table would produce.
219
+ expect(allKeys.length).toBeGreaterThan(noneKeys.length);
220
+ expect(new Set(allKeys).size).toBe(allKeys.length);
221
+ expect(new Set(noneKeys).size).toBe(noneKeys.length);
222
+ expect(allKeys).toContain(FLOOR_ROUTE);
223
+ expect(noneKeys).toContain(FLOOR_ROUTE);
224
+
225
+ // And each server's own OpenAPI document still matches its own table.
226
+ for (const app of [allOn, noneOn]) {
227
+ const paths = JSON.parse(
228
+ (await app.inject({ method: "GET", url: "/v1/openapi.json" })).body,
229
+ ).paths;
230
+ const endpoints = JSON.parse(
231
+ (await app.inject({ method: "GET", url: "/" })).body,
232
+ ).endpoints;
233
+ expect(
234
+ formatSurfaceDrift(
235
+ compareSurfaces(readRegisteredRoutes(app), paths, endpoints),
236
+ ),
237
+ ).toBe("");
238
+ }
239
+
240
+ await allOn.close();
241
+ await noneOn.close();
242
+ } finally {
243
+ if (prior === undefined) {
244
+ delete process.env.AILK_ENABLED_MODULES;
245
+ } else {
246
+ process.env.AILK_ENABLED_MODULES = prior;
247
+ }
248
+ }
249
+ }, 60000);
250
+
251
+ // AC-4. Asserted as agreement rather than as an exemption list: a disabled
252
+ // module's routes vanish from the registered set and from both surfaces at
253
+ // once, so there is nothing for the gate to forgive.
254
+ it("drops a disabled module's routes from the route table AND both surfaces together", async () => {
255
+ const allOn = await surfacesFor(ALL_PAID_MODULES);
256
+ const noneOn = await surfacesFor("");
257
+
258
+ const noneKeys = new Set(
259
+ noneOn.registered.map((r) => routeKey(r.method, r.url)),
260
+ );
261
+ const moduleOnly = allOn.registered.filter(
262
+ (r) => !noneKeys.has(routeKey(r.method, r.url)),
263
+ );
264
+
265
+ // The configurations must actually differ, or this asserts nothing.
266
+ expect(moduleOnly.length).toBeGreaterThan(0);
267
+
268
+ // Every module-only route is described when its module is on...
269
+ expect(
270
+ formatSurfaceDrift(
271
+ compareSurfaces(allOn.registered, allOn.paths, allOn.endpoints),
272
+ ),
273
+ ).toBe("");
274
+
275
+ // ...and named by neither surface when it is off, which is agreement.
276
+ //
277
+ // Each surface is parsed by the instrument's own reader, and each route is
278
+ // compared in that surface's vocabulary — OpenAPI-templated for `paths`,
279
+ // Fastify-shaped for `endpoints`. Comparing raw values instead would make
280
+ // this assertion quietly weaker than it reads: `paths` keys `{id}` where a
281
+ // registered route says `:id`, so every parametric route would "pass" on a
282
+ // vocabulary mismatch, and `endpoints` groups methods into one value
283
+ // (`"GET/PUT/DELETE /v1/user/data/:key"`), which never equals a
284
+ // single-method key. Both halves would then be vacuous for exactly the
285
+ // routes most likely to regress.
286
+ const offPathKeys = openApiSurfaceKeys(noneOn.paths);
287
+ const offEndpointKeys = rootIndexSurfaceKeys(noneOn.endpoints);
288
+ for (const { method, url } of moduleOnly) {
289
+ expect(offPathKeys).not.toContain(routeKey(method, toOpenApiPath(url)));
290
+ expect(offEndpointKeys).not.toContain(routeKey(method, url));
291
+ }
292
+ }, 60000);
293
+ });