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
@@ -1,4 +1,11 @@
1
1
  /**
2
+ * NOT marked `oss: false` (D-48/#5366 escalation — see
3
+ * `routes/project-listings/me.ts`'s header comment): imported by
4
+ * `routes/project-listings/{start,patch-draft,submit,public,me}.ts`, each of
5
+ * which ships unconditionally because `routes/project-listings/index.ts`'s
6
+ * multi-tenant literal `import()` forces it. Registration of the routes that
7
+ * call into this service stays gated (`isModuleEnabled("signal-collectors")`).
8
+ *
2
9
  * @file project-listings.ts
3
10
  * @description The founder's-application-row service (#4998, reshaped by
4
11
  * #5036) — writes, reads, and transitions `ProjectListing` (ADR 0009 D3
@@ -43,6 +50,7 @@ import {
43
50
  buildProjectListingDraftPatchSchema,
44
51
  buildProjectListingSubmitSchema,
45
52
  composeAddressLine,
53
+ composeFounderName,
46
54
  flowAddressValueSchema,
47
55
  publicSiteAnswers,
48
56
  PROJECT_LISTING_CONCURRENT_LIMIT,
@@ -230,7 +238,13 @@ const PROJECT_LISTING_SELECT = {
230
238
  siteAnswers: true,
231
239
  windowDays: true,
232
240
  founderName: true,
241
+ // #5407 — the name parts beside the composed display string, the phone, and
242
+ // the resume-link verification stamp.
243
+ founderFirstName: true,
244
+ founderLastName: true,
233
245
  founderEmail: true,
246
+ founderPhone: true,
247
+ emailVerifiedAt: true,
234
248
  founderCompany: true,
235
249
  headline: true,
236
250
  logoMarkUrl: true,
@@ -357,7 +371,13 @@ function toResponseRow(
357
371
  address: row.address,
358
372
  addressParts: addressPartsFromRow(row),
359
373
  founderName: row.founderName,
374
+ founderFirstName: row.founderFirstName,
375
+ founderLastName: row.founderLastName,
360
376
  founderEmail: row.founderEmail,
377
+ founderPhone: row.founderPhone,
378
+ // #5407 — the operator's verification filter at review. Null means "no
379
+ // opening of the resume link recorded", never "verification failed".
380
+ emailVerifiedAt: row.emailVerifiedAt ? row.emailVerifiedAt.toISOString() : null,
361
381
  founderCompany: row.founderCompany,
362
382
  questions: row.questions as unknown as ProjectListingQuestions | null,
363
383
  logoMarkUrl: row.logoMarkUrl,
@@ -489,6 +509,42 @@ function hashResumeToken(token: string): string {
489
509
  return createHash("sha256").update(token).digest("hex");
490
510
  }
491
511
 
512
+ // ─── The email-verification nonce (#5407) ──────────────────────────────────
513
+
514
+ /**
515
+ * Mint the nonce that PROVES the founder read mail sent to `founderEmail`.
516
+ *
517
+ * WHY A SECOND CREDENTIAL EXISTS AT ALL, since the obvious design is to reuse
518
+ * the resume token the emailed link already carries. That design cannot work:
519
+ * `start` returns the resume token in its own 201 body, to whoever posted the
520
+ * form. The emailed link is a SECOND COPY of a credential the applicant
521
+ * already holds, so keying verification on it would let two HTTP calls — open
522
+ * a draft under a stranger's address, take the token out of the response,
523
+ * present it back — forge the stamp with no mailbox involved. Found by this
524
+ * change's own security review, before it shipped.
525
+ *
526
+ * This nonce closes that because of where it does NOT go. It is minted here,
527
+ * digested into `emailVerifyNonceHash`, and returned to exactly one caller —
528
+ * `start`, which hands it to `sendListingResumeLink` and to nothing else. It
529
+ * is in no response body, no log line, and no browser that filled the form.
530
+ * The only way to hold it is to have read the email.
531
+ *
532
+ * Same 256 bits as the resume token, for the same reason.
533
+ */
534
+ function mintVerifyNonce(): string {
535
+ return randomBytes(RESUME_TOKEN_BYTES).toString("base64url");
536
+ }
537
+
538
+ /**
539
+ * What `emailVerifyNonceHash` holds. The same plain sha-256 as
540
+ * `hashResumeToken`, for the same reason — the input is CSPRNG output, not a
541
+ * human-chosen secret — and kept as its own function rather than aliased so
542
+ * no call site has to read "resume token" while meaning the nonce.
543
+ */
544
+ function hashVerifyNonce(nonce: string): string {
545
+ return createHash("sha256").update(nonce).digest("hex");
546
+ }
547
+
492
548
  /**
493
549
  * Resolve the ONE draft a token grants access to (#5036 D1).
494
550
  *
@@ -532,6 +588,19 @@ export type StartProjectListingDraftResult =
532
588
  ok: true;
533
589
  id: string;
534
590
  resumeToken: string;
591
+ /**
592
+ * #5407 — the email-verification nonce, for the RESUME-EMAIL BUILDER
593
+ * and for nothing else.
594
+ *
595
+ * A sibling of the result rather than a field of anything the route
596
+ * hands to a response schema, deliberately (lesson 05): `start`'s 201
597
+ * body is `projectListingStartResponseSchema`, which is `.strict()` and
598
+ * carries `id` and `resumeToken` only. That route's request and
599
+ * response shapes are a declared hard floor and are unchanged — this
600
+ * value reaches `sendListingResumeLink` from the route's local
601
+ * variable and never touches the wire.
602
+ */
603
+ verifyNonce: string;
535
604
  }
536
605
  | { ok: false; error: { kind: "invalid_input"; message: string } };
537
606
 
@@ -557,7 +626,14 @@ export async function startProjectListingDraft(
557
626
  siteId: string,
558
627
  input: ProjectListingStartInput,
559
628
  ): Promise<StartProjectListingDraftResult> {
560
- const { founderName, requestedWindowDays } = input;
629
+ const { requestedWindowDays } = input;
630
+ // #5407 — the NOT NULL display column. `composeFounderName` builds it from
631
+ // the PARTS wherever both are written (even beside a `founderName` the body
632
+ // also sent, so the stored line and the stored parts can never disagree),
633
+ // and stores a line sent on its own verbatim. ONE WAY ONLY: nothing here or
634
+ // anywhere else derives parts back out of a line. The schema has already
635
+ // refused a body carrying neither shape, so this always produces a string.
636
+ const founderName = composeFounderName(input);
561
637
  const founderEmail = normalizeFounderEmail(input.founderEmail);
562
638
 
563
639
  const listingConfig = await listingConfigFor(siteId);
@@ -590,6 +666,13 @@ export async function startProjectListingDraft(
590
666
  }
591
667
 
592
668
  const resumeToken = mintResumeToken();
669
+ // #5407 — minted on EVERY draft, whether or not the site sends the resume
670
+ // email. `start` cannot know: the send is decided asynchronously inside
671
+ // `sendListingResumeLink`, against the site's own config. A site running no
672
+ // resume email therefore stores a nonce nobody is ever handed, and nothing
673
+ // on that row can ever verify — which is the correct outcome rather than a
674
+ // gap, because no one has read anything at that address.
675
+ const verifyNonce = mintVerifyNonce();
593
676
  // #5275 — the row and its grant record land in ONE transaction: from here
594
677
  // on `consentAt` and the ConsentGrant row are stamped by the SAME
595
678
  // affirmative act, and a grant write that fails takes the row with it
@@ -606,10 +689,21 @@ export async function startProjectListingDraft(
606
689
  data: {
607
690
  siteId,
608
691
  founderName,
692
+ // #5407 — the parts as sent, beside the composed line. Absent where a
693
+ // caller sent only a display line, which is the shape every row
694
+ // written before #5407 carries; nothing here ever splits that line.
695
+ ...(input.founderFirstName !== undefined && {
696
+ founderFirstName: input.founderFirstName,
697
+ }),
698
+ ...(input.founderLastName !== undefined && {
699
+ founderLastName: input.founderLastName,
700
+ }),
609
701
  founderEmail,
702
+ ...(input.founderPhone !== undefined && { founderPhone: input.founderPhone }),
610
703
  status: "draft",
611
704
  consentAt: new Date(),
612
705
  resumeTokenHash: hashResumeToken(resumeToken),
706
+ emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
613
707
  windowDays,
614
708
  },
615
709
  select: { id: true },
@@ -623,7 +717,120 @@ export async function startProjectListingDraft(
623
717
  return created;
624
718
  });
625
719
 
626
- return { ok: true, id: row.id, resumeToken };
720
+ return { ok: true, id: row.id, resumeToken, verifyNonce };
721
+ }
722
+
723
+ // ─── verify-email (POST, anonymous, nonce-gated) ───────────────────────────
724
+
725
+ /**
726
+ * THE NONCE HAS NO EXPIRY, and shares the resume token's lifetime exactly: it
727
+ * lives until `submit` clears both digests, and an unsubmitted draft is never
728
+ * swept (`DRAFT_HOLDS_A_SLOT_DAYS` bounds the QUOTA a draft occupies, not the
729
+ * row). A resume email sitting in a mailbox for a year still verifies that
730
+ * draft a year later.
731
+ *
732
+ * That is acceptable rather than overlooked, and for one reason: the same
733
+ * message carries the resume token, so anyone who can read it can already
734
+ * resume and submit the draft outright. A TTL would have to cover both
735
+ * credentials to mean anything, and that is a change to #5048's resume
736
+ * contract rather than to this column.
737
+ *
738
+ * It is also NOT cleared on a successful stamp — the fallback read below
739
+ * needs it to answer a repeat call idempotently.
740
+ */
741
+ export type VerifyProjectListingDraftEmailResult =
742
+ | { ok: true; value: { id: string; emailVerifiedAt: Date } }
743
+ | { ok: false; error: { kind: "not_found"; message: string } };
744
+
745
+ /**
746
+ * Record that somebody who can read mail at `founderEmail` opened this
747
+ * draft's emailed link (#5407).
748
+ *
749
+ * THE PRECISION IN THAT SENTENCE IS DELIBERATE. It is evidence about the
750
+ * ADDRESS, not about the person driving the draft. Whoever posted `start`
751
+ * chose the address and holds the resume token, so an attacker can open a
752
+ * draft under a stranger's address and the platform will mail that stranger a
753
+ * link; if they click it, the attacker's draft is stamped. Binding the stamp
754
+ * to the application's CONTENT, or to `ownerUserId`, is what would close that,
755
+ * and neither is in this issue. What the stamp rules out is the case it was
756
+ * built for: a stamp obtained with no mailbox involved at all.
757
+ *
758
+ * WHAT IT PROVES, and why it takes TWO credentials. The caller must present
759
+ * both the draft's resume token and its verification NONCE, and only the
760
+ * second one carries the proof:
761
+ *
762
+ * - the RESUME TOKEN says "you opened this draft". `start` returns it in
763
+ * its own 201 body, so anyone who posted the form has it. On its own it
764
+ * proves nothing about an address, and a design that stamped on it alone
765
+ * would be forgeable in two HTTP calls — open a draft under a stranger's
766
+ * address, take the token out of the response, present it back. That is
767
+ * the design this one replaced, caught by its own security review.
768
+ * - the NONCE says "you read mail sent to `founderEmail`". It is minted at
769
+ * `start`, digested onto the row, and handed to nothing but
770
+ * `sendListingResumeLink`. It is in no response body and no log line, and
771
+ * the only place it exists outside this server is the `?verify=`
772
+ * parameter of the link inside the email.
773
+ *
774
+ * The token stays in the guard anyway: it costs one term in a `where` and it
775
+ * means a nonce that somehow leaked alone still opens nothing.
776
+ *
777
+ * What it does NOT say is who the founder is. `ownerUserId` (#5295) is the
778
+ * column that carries an identity claim and still needs a signed-in user
779
+ * whose own address the auth system verified.
780
+ *
781
+ * WRITE-ONCE, BY COMPARE-AND-SWAP. `emailVerifiedAt: null` is in the `where`,
782
+ * so the stamp is the atomic transition and two concurrent openings of the
783
+ * same link produce one stamp between them rather than a last-writer-wins
784
+ * race. A repeat opening reads back the FIRST stamp, so the call is
785
+ * idempotent in its effect and in its answer. It is not indistinguishable —
786
+ * a repeat answers an older timestamp than "now" — and does not need to be:
787
+ * only a holder of both credentials can make the call at all, and they are
788
+ * the one person entitled to know when their own link was first opened.
789
+ *
790
+ * ONE ANSWER FOR EVERY FAILURE. A wrong token, a wrong nonce, a credential
791
+ * bound to a different listing, an unknown id and an already-submitted row
792
+ * are all `not_found` — exactly what `patch` and `submit` answer. The
793
+ * anonymous surface must not distinguish "exists but not yours" from "does
794
+ * not exist", and a verification route least of all: its whole input is a
795
+ * guessable-in-principle id and two secrets, so a distinguishable response
796
+ * would turn it into an oracle for which drafts exist and, worse, into a
797
+ * nonce-guessing feedback channel.
798
+ *
799
+ * Neither credential is ever logged, returned, or compared in application
800
+ * code — only their sha-256 digests reach the query.
801
+ */
802
+ export async function verifyProjectListingDraftEmail(
803
+ id: string,
804
+ resumeToken: string,
805
+ verifyNonce: string,
806
+ ): Promise<VerifyProjectListingDraftEmailResult> {
807
+ const guard = {
808
+ id,
809
+ status: "draft",
810
+ resumeTokenHash: hashResumeToken(resumeToken),
811
+ emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
812
+ };
813
+ const emailVerifiedAt = new Date();
814
+
815
+ const stamped = await prisma.projectListing.updateMany({
816
+ where: { ...guard, emailVerifiedAt: null },
817
+ data: { emailVerifiedAt },
818
+ });
819
+ if (stamped.count > 0) return { ok: true, value: { id, emailVerifiedAt } };
820
+
821
+ // Zero rows means one of two things, and they answer differently: the draft
822
+ // is already stamped (idempotent success, carrying the FIRST stamp), or the
823
+ // credentials do not open this draft (`not_found`). This read is what tells
824
+ // them apart, and it is gated by the same four terms the write was — so it
825
+ // cannot report a draft the caller's credentials do not open.
826
+ const row = await prisma.projectListing.findFirst({
827
+ where: guard,
828
+ select: { id: true, emailVerifiedAt: true },
829
+ });
830
+ if (row?.emailVerifiedAt) {
831
+ return { ok: true, value: { id: row.id, emailVerifiedAt: row.emailVerifiedAt } };
832
+ }
833
+ return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
627
834
  }
628
835
 
629
836
  // ─── patch (PATCH, anonymous, token-gated) ──────────────────────────────────
@@ -1010,7 +1217,15 @@ export async function submitProjectListingDraft(
1010
1217
  try {
1011
1218
  const updated = await prisma.projectListing.updateMany({
1012
1219
  where: { id, status: "draft" },
1013
- data: { status: "requested", slug: candidate, resumeTokenHash: null },
1220
+ // #5407 — the verification nonce is cleared here too, for the reason
1221
+ // the resume-token digest is: a credential that leaks after the draft
1222
+ // stops being a draft is inert twice over.
1223
+ data: {
1224
+ status: "requested",
1225
+ slug: candidate,
1226
+ resumeTokenHash: null,
1227
+ emailVerifyNonceHash: null,
1228
+ },
1014
1229
  });
1015
1230
  return updated.count;
1016
1231
  } catch (err) {
@@ -1451,7 +1666,17 @@ export async function publicProjectListings(siteId: string): Promise<ProjectList
1451
1666
 
1452
1667
  const now = new Date();
1453
1668
  return [...top3, ...rest].map(({ row, waitlistCount }) => {
1454
- const { founderEmail: _founderEmail, ...publicRow } = toResponseRow(
1669
+ // #5407 — `founderPhone` and `emailVerifiedAt` leave here with
1670
+ // `founderEmail`. `projectListingPublicSchema` omits all three and
1671
+ // `sendValidated` re-parses `.strict()`, so a key that stopped being
1672
+ // dropped here would 500 the public read rather than quietly publishing a
1673
+ // founder's phone number — which is the failure mode worth having.
1674
+ const {
1675
+ founderEmail: _founderEmail,
1676
+ founderPhone: _founderPhone,
1677
+ emailVerifiedAt: _emailVerifiedAt,
1678
+ ...publicRow
1679
+ } = toResponseRow(
1455
1680
  row,
1456
1681
  waitlistCount,
1457
1682
  now,
@@ -31,39 +31,70 @@ async function getBuildFunction(): Promise<typeof buildServer> {
31
31
 
32
32
  // Initialize build function
33
33
  const buildPromise = getBuildFunction();
34
- let serverInstance: FastifyServer | null = null;
35
34
 
36
35
  /**
37
- * Get or create the Fastify server instance
36
+ * The in-flight (or settled) build. Caching the PROMISE rather than the
37
+ * resolved instance is what makes this safe: a warm Vercel container serves
38
+ * concurrent invocations on one Node process, and the old check-then-set
39
+ * (`if (!serverInstance)` followed by two awaits before the assignment) let
40
+ * two requests arriving during a cold start both see null and both construct
41
+ * a complete Fastify server — every plugin registered, every route wired, the
42
+ * Prisma client initialised — with one of the two then discarded. Nothing
43
+ * failed; the only symptoms were cold-start latency and memory (#5332).
38
44
  */
39
- async function getServer() {
40
- if (!serverInstance) {
41
- logger.info('[Vercel Handler] Building server instance...');
42
- const startTime = Date.now();
43
-
44
- try {
45
- const buildTimeout = new Promise<never>((_, reject) => {
46
- setTimeout(() => reject(new Error('Server build timeout after 10s')), 10000);
47
- });
45
+ let serverPromise: Promise<FastifyServer> | null = null;
48
46
 
49
- const build = await Promise.race([buildPromise, buildTimeout]);
50
- logger.info('[Vercel Handler] Build function obtained, creating server...');
47
+ async function buildServerInstance(): Promise<FastifyServer> {
48
+ logger.info('[Vercel Handler] Building server instance...');
49
+ const startTime = Date.now();
51
50
 
52
- const initTimeout = new Promise<never>((_, reject) => {
53
- setTimeout(() => reject(new Error('Server initialization timeout after 10s')), 10000);
54
- });
51
+ try {
52
+ const buildTimeout = new Promise<never>((_, reject) => {
53
+ setTimeout(() => reject(new Error('Server build timeout after 10s')), 10000);
54
+ });
55
55
 
56
- serverInstance = await Promise.race([build(), initTimeout]);
56
+ const build = await Promise.race([buildPromise, buildTimeout]);
57
+ logger.info('[Vercel Handler] Build function obtained, creating server...');
57
58
 
58
- const buildTime = Date.now() - startTime;
59
- logger.info({ buildTime }, '[Vercel Handler] Server built successfully');
60
- } catch (error) {
61
- const buildTime = Date.now() - startTime;
62
- logger.error({ err: error, buildTime }, '[Vercel Handler] Failed to build server');
63
- throw new Error(`Server build failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
64
- }
59
+ const initTimeout = new Promise<never>((_, reject) => {
60
+ setTimeout(() => reject(new Error('Server initialization timeout after 10s')), 10000);
61
+ });
62
+
63
+ const serverInstance = await Promise.race([build(), initTimeout]);
64
+
65
+ const buildTime = Date.now() - startTime;
66
+ logger.info({ buildTime }, '[Vercel Handler] Server built successfully');
67
+ return serverInstance;
68
+ } catch (error) {
69
+ const buildTime = Date.now() - startTime;
70
+ logger.error({ err: error, buildTime }, '[Vercel Handler] Failed to build server');
71
+ throw new Error(`Server build failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
65
72
  }
66
- return serverInstance;
73
+ }
74
+
75
+ /**
76
+ * Get or create the Fastify server instance
77
+ */
78
+ function getServer(): Promise<FastifyServer> {
79
+ if (serverPromise) {
80
+ return serverPromise;
81
+ }
82
+
83
+ const attempt = buildServerInstance();
84
+ serverPromise = attempt;
85
+ // Evict a FAILED build from the cache so the next invocation retries —
86
+ // a rejected promise left in place would serve every later request the
87
+ // same cold-start failure for the life of the container. The guard keeps
88
+ // this from clearing a newer attempt; `attempt` is returned as-is, so the
89
+ // rejection still reaches the caller (this handler only silences the
90
+ // unhandled-rejection warning on its own bookkeeping branch).
91
+ attempt.catch(() => {
92
+ if (serverPromise === attempt) {
93
+ serverPromise = null;
94
+ }
95
+ });
96
+
97
+ return attempt;
67
98
  }
68
99
 
69
100
  /**
@@ -156,9 +187,12 @@ export default async function handler(
156
187
  logger.error({ err: error }, '[Vercel Handler] Error handling request');
157
188
 
158
189
  if (!res.headersSent) {
190
+ // Generic body only (security review on #146): this catch sits above
191
+ // Fastify's error serializer, so error.message could carry hosts,
192
+ // paths, or driver detail. The logger.error above keeps the detail.
159
193
  res.status(500).json({
160
194
  error: 'Internal Server Error',
161
- message: error instanceof Error ? error.message : String(error),
195
+ message: 'Something went wrong on our end.',
162
196
  });
163
197
  }
164
198
  }
@@ -40,6 +40,14 @@ MCP_RATE_LIMIT_WRITE_PER_MIN=10
40
40
  # Example: https://app.yourdomain.com,https://other.yourdomain.com
41
41
  MCP_CORS_WRITE_ALLOWED_ORIGINS=
42
42
 
43
+ # ── Paid modules (D-44, #5368) ──────────────────────────────────────────────────
44
+
45
+ # Comma-separated allowlist of installed paid modules, e.g.
46
+ # "multi-tenant,scheduling". Unset (the default) or "" means NO paid modules
47
+ # — installed modules enable themselves explicitly. Recognized keys: docs,
48
+ # multi-tenant, scheduling, content-write. Must match apps/api's value.
49
+ # AILK_ENABLED_MODULES=
50
+
43
51
  # ── Abuse protection ───────────────────────────────────────────────────────────
44
52
 
45
53
  # Maximum number of capture_lead submissions per IP per day.
@@ -25,8 +25,8 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
25
25
  **Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`
26
26
  **Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback`
27
27
  **Maintenance (2):** `audit`, `scaffold`
28
- **Flow (2):** `get_flow`, `submit_flow_step`
29
- **Booking (3):** `list_availability`, `get_event_meta`, `create_booking`
28
+ **Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366): identity-blind and free of any `requirePaidTier()` call like every tool in this list, but registered only when the signal-collectors module is present, the same shape Booking below already has
29
+ **Booking (3):** `list_availability`, `get_event_meta`, `create_booking` — module-gated (`module: "scheduling"`)
30
30
 
31
31
  ### Why the catalog reports 23 and the server registers 22
32
32
 
@@ -24,7 +24,28 @@ interface RegisteredTool {
24
24
  description?: string;
25
25
  }
26
26
 
27
+ // The catalog includes module-gated tools (scheduling, content-write,
28
+ // signal-collectors); an unset AILK_ENABLED_MODULES no longer registers them
29
+ // by default (D-44, #5368), so this drift check enables every paid module
30
+ // explicitly — it is comparing the full catalog, not what a default build
31
+ // registers.
32
+ const PAID_MODULES = "scheduling,content-write,signal-collectors";
33
+
27
34
  describe("MCP catalog ↔ server drift", () => {
35
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
36
+
37
+ beforeEach(() => {
38
+ process.env.AILK_ENABLED_MODULES = PAID_MODULES;
39
+ });
40
+
41
+ afterAll(() => {
42
+ if (priorEnabledModules === undefined) {
43
+ delete process.env.AILK_ENABLED_MODULES;
44
+ } else {
45
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
46
+ }
47
+ });
48
+
28
49
  it("registered tool names equal catalog tool names", () => {
29
50
  const server = createMcpServer();
30
51
  const registered = (
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `isModuleEnabled()` default-flip regression test (D-44, #5368).
3
+ *
4
+ * Mirrors `apps/api/src/config/__tests__/modules.test.ts` — apps/* may not
5
+ * import apps/*, so this reader (and its test) are duplicated rather than
6
+ * shared. Proves the mechanical claim #5368 exists to make true: an UNSET
7
+ * `AILK_ENABLED_MODULES` now gates every paid module OFF, the same as an
8
+ * explicit empty string — only an explicit allowlist turns modules on, and
9
+ * turns on exactly the modules it names.
10
+ *
11
+ * Reverting `enabledModules()`'s unset branch back to the `undefined`
12
+ * sentinel (S1, #5088's original shape) makes the "unset" cases below fail
13
+ * RED — see #5368 verification notes.
14
+ */
15
+
16
+ import { isModuleEnabled } from "../../src/config/modules.js";
17
+
18
+ const RECOGNIZED_MODULES = [
19
+ "docs",
20
+ "multi-tenant",
21
+ "scheduling",
22
+ "content-write",
23
+ ] as const;
24
+
25
+ describe("isModuleEnabled", () => {
26
+ const originalEnv = process.env.AILK_ENABLED_MODULES;
27
+
28
+ afterEach(() => {
29
+ if (originalEnv === undefined) {
30
+ delete process.env.AILK_ENABLED_MODULES;
31
+ } else {
32
+ process.env.AILK_ENABLED_MODULES = originalEnv;
33
+ }
34
+ });
35
+
36
+ describe("AILK_ENABLED_MODULES unset", () => {
37
+ it.each(RECOGNIZED_MODULES)("returns false for %s", (moduleName) => {
38
+ delete process.env.AILK_ENABLED_MODULES;
39
+ expect(isModuleEnabled(moduleName)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('AILK_ENABLED_MODULES=""', () => {
44
+ it.each(RECOGNIZED_MODULES)("returns false for %s", (moduleName) => {
45
+ process.env.AILK_ENABLED_MODULES = "";
46
+ expect(isModuleEnabled(moduleName)).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe("AILK_ENABLED_MODULES as an explicit allowlist", () => {
51
+ it.each(RECOGNIZED_MODULES)(
52
+ "enables exactly the listed modules — %s on when named",
53
+ (moduleName) => {
54
+ process.env.AILK_ENABLED_MODULES = moduleName;
55
+ expect(isModuleEnabled(moduleName)).toBe(true);
56
+ for (const other of RECOGNIZED_MODULES) {
57
+ if (other === moduleName) continue;
58
+ expect(isModuleEnabled(other)).toBe(false);
59
+ }
60
+ },
61
+ );
62
+
63
+ it("enables every module named in a comma list, and no others", () => {
64
+ process.env.AILK_ENABLED_MODULES = "multi-tenant,scheduling";
65
+ expect(isModuleEnabled("multi-tenant")).toBe(true);
66
+ expect(isModuleEnabled("scheduling")).toBe(true);
67
+ expect(isModuleEnabled("docs")).toBe(false);
68
+ expect(isModuleEnabled("content-write")).toBe(false);
69
+ });
70
+
71
+ it("trims whitespace and drops empty entries, same as before the flip", () => {
72
+ process.env.AILK_ENABLED_MODULES = " multi-tenant , , scheduling ";
73
+ expect(isModuleEnabled("multi-tenant")).toBe(true);
74
+ expect(isModuleEnabled("scheduling")).toBe(true);
75
+ expect(isModuleEnabled("docs")).toBe(false);
76
+ });
77
+ });
78
+ });
@@ -18,7 +18,7 @@ import { getMcpToolRegistrations } from "@working-theory/metadata";
18
18
 
19
19
  import { createMcpServer, listRegisteredTools } from "../src/server.js";
20
20
 
21
- const PAID_MODULES = ["scheduling", "content-write"] as const;
21
+ const PAID_MODULES = ["scheduling", "content-write", "signal-collectors"] as const;
22
22
 
23
23
  function withEnabledModules<T>(enabled: string, fn: () => T): T {
24
24
  const prior = process.env.AILK_ENABLED_MODULES;
@@ -1,26 +1,29 @@
1
1
  /**
2
2
  * Paid-module presence — the registration-grain signal used by `server.ts`
3
3
  * to decide whether a paid module's tools are wired into this build
4
- * (S1, #5088). Mirrors `apps/api/src/config/modules.ts` — apps/* may not
5
- * import apps/*, so this tiny reader is duplicated rather than shared.
4
+ * (S1, #5088; defaulted off by D-44, #5368). Mirrors
5
+ * `apps/api/src/config/modules.ts` — apps/* may not import apps/*, so this
6
+ * tiny reader is duplicated rather than shared.
6
7
  *
7
8
  * `AILK_ENABLED_MODULES` is a comma-separated allowlist (e.g.
8
- * `"multi-tenant,scheduling"`). Unset means "every module on" — today's
9
- * behavior, unchanged for every existing deployment; an explicit empty
10
- * string (`""`) means "no paid modules" (the OSS-strip build target).
9
+ * `"multi-tenant,scheduling"`). Unset means **no paid modules** — installed
10
+ * modules enable themselves explicitly; an explicit empty string (`""`)
11
+ * means the same (the OSS-strip build target); a list enables exactly the
12
+ * modules it names.
11
13
  *
12
- * Recognized module keys: `docs`, `multi-tenant`, `scheduling` (the
13
- * `ModuleName` vocabulary `packages/create-ailk/src/module-architecture.ts`
14
- * already models via `MODULE_SLICES`) plus `content-write` — the Pro
14
+ * Recognized module keys: `docs`, `multi-tenant`, `scheduling`,
15
+ * `signal-collectors` (the `ModuleName` vocabulary
16
+ * `packages/create-ailk/src/module-architecture.ts` already models via
17
+ * `MODULE_SLICES`) plus `content-write` — the Pro
15
18
  * content-write surface (`create_page`/`update_page`/`delete_page`).
16
19
  * `content-write` is intentionally NOT a `MODULE_SLICES` entry: it is not
17
20
  * (yet) a create-ailk `--modules` selector, so it stays a local flag here
18
21
  * rather than widening the create-ailk module vocabulary.
19
22
  */
20
23
 
21
- function enabledModules(): Set<string> | undefined {
24
+ function enabledModules(): Set<string> {
22
25
  const raw = process.env.AILK_ENABLED_MODULES;
23
- if (raw === undefined) return undefined; // undefined sentinel = "all enabled"
26
+ if (raw === undefined) return new Set(); // unset = no paid modules
24
27
  return new Set(
25
28
  raw
26
29
  .split(",")
@@ -31,7 +34,5 @@ function enabledModules(): Set<string> | undefined {
31
34
 
32
35
  /** Whether the named paid module's tools should register on this build/boot. */
33
36
  export function isModuleEnabled(moduleName: string): boolean {
34
- const enabled = enabledModules();
35
- if (enabled === undefined) return true;
36
- return enabled.has(moduleName);
37
+ return enabledModules().has(moduleName);
37
38
  }
@@ -20,7 +20,6 @@ import { ailkAudit } from "./tools/audit.js";
20
20
  import { ailkCaptureLead } from "./tools/capture_lead.js";
21
21
  import { ailkGetBusinessHours } from "./tools/get_business_hours.js";
22
22
  import { ailkGetFaq } from "./tools/get_faq.js";
23
- import { ailkGetFlow } from "./tools/get_flow.js";
24
23
  import { ailkGetLocations } from "./tools/get_locations.js";
25
24
  import { ailkGetOfferings } from "./tools/get_offerings.js";
26
25
  import { ailkGetPricing } from "./tools/get_pricing.js";
@@ -31,7 +30,6 @@ import { ailkReadContent } from "./tools/read_content.js";
31
30
  import { ailkRequestCallback } from "./tools/request_callback.js";
32
31
  import { ailkScaffold } from "./tools/scaffold.js";
33
32
  import { ailkSearchContent } from "./tools/search_content.js";
34
- import { ailkSubmitFlowStep } from "./tools/submit_flow_step.js";
35
33
  import { ailkSubscribeNewsletter } from "./tools/subscribe_newsletter.js";
36
34
 
37
35
  /**
@@ -65,8 +63,6 @@ const HANDLERS: Record<string, (input: unknown) => Promise<unknown>> = {
65
63
  subscribe_newsletter: (input) => ailkSubscribeNewsletter(input as never),
66
64
  request_callback: (input) => ailkRequestCallback(input as never),
67
65
  scaffold: (input) => ailkScaffold(input as never),
68
- get_flow: (input) => ailkGetFlow(input as never),
69
- submit_flow_step: (input) => ailkSubmitFlowStep(input as never),
70
66
  };
71
67
 
72
68
  type ToolHandler = (input: unknown) => Promise<unknown>;
@@ -119,6 +115,14 @@ const MODULE_TOOL_ENTRIES: Record<
119
115
  create_page: { specifier: "./tools/create_page.js", exportName: "ailkCreatePage" },
120
116
  update_page: { specifier: "./tools/update_page.js", exportName: "ailkUpdatePage" },
121
117
  delete_page: { specifier: "./tools/delete_page.js", exportName: "ailkDeletePage" },
118
+ // D-48 signal-collectors — the flow engine's MCP twins of GET
119
+ // /v1/flows/:slug and POST /v1/flows/:slug/steps. Anonymous, identity-blind;
120
+ // free-anonymous status is now module-gated rather than unconditional.
121
+ get_flow: { specifier: "./tools/get_flow.js", exportName: "ailkGetFlow" },
122
+ submit_flow_step: {
123
+ specifier: "./tools/submit_flow_step.js",
124
+ exportName: "ailkSubmitFlowStep",
125
+ },
122
126
  };
123
127
 
124
128
  /**