create-ailk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/lib/apply-module-patches.d.ts +47 -0
  3. package/dist/lib/apply-module-patches.js +370 -0
  4. package/dist/lib/extract-module-bundle.d.ts +12 -3
  5. package/dist/lib/extract-module-bundle.js +236 -24
  6. package/dist/lib/fetch-module.d.ts +3 -38
  7. package/dist/lib/fetch-module.js +26 -8
  8. package/dist/lib/module-license-gate.d.ts +2 -0
  9. package/dist/lib/module-license-gate.js +1 -0
  10. package/dist/lib/paid-paths.d.ts +16 -0
  11. package/dist/lib/paid-paths.js +90 -0
  12. package/dist/module-architecture.d.ts +139 -23
  13. package/dist/module-architecture.js +668 -25
  14. package/dist/parse-args.d.ts +2 -2
  15. package/dist/parse-args.js +3 -1
  16. package/dist/programmatic.d.ts +2 -1
  17. package/dist/programmatic.js +15 -4
  18. package/dist/surfaces.d.ts +12 -36
  19. package/dist/surfaces.js +35 -5
  20. package/dist/sync-routes.js +31 -1
  21. package/package.json +2 -2
  22. package/templates/.claude/agents/web.md +2 -3
  23. package/templates/.claude/rules/architecture.md +5 -5
  24. package/templates/.claude/skills/README.md +2 -1
  25. package/templates/.env.example +21 -9
  26. package/templates/CONVENTIONS.md +6 -7
  27. package/templates/apps/api/.env.example +9 -0
  28. package/templates/apps/api/CLAUDE.md +33 -5
  29. package/templates/apps/api/package.json +0 -1
  30. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  31. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  32. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  33. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  34. package/templates/apps/api/src/config/index.ts +19 -0
  35. package/templates/apps/api/src/config/modules.ts +12 -14
  36. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  37. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  38. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  39. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  40. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  41. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  42. package/templates/apps/api/src/openapi/index.ts +9 -3
  43. package/templates/apps/api/src/openapi/spec.ts +505 -65
  44. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  45. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  46. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/index.ts +38 -11
  49. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  50. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  58. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  59. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  60. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  61. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  63. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  64. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  65. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  66. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  67. package/templates/apps/api/src/server.ts +459 -135
  68. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  69. package/templates/apps/api/src/services/listing-config.ts +58 -0
  70. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  71. package/templates/apps/api/src/services/project-listings.ts +229 -4
  72. package/templates/apps/api/src/vercel-handler.ts +60 -26
  73. package/templates/apps/mcp/.env.example +8 -0
  74. package/templates/apps/mcp/CLAUDE.md +2 -2
  75. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  76. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  77. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  78. package/templates/apps/mcp/src/config/modules.ts +14 -13
  79. package/templates/apps/mcp/src/server.ts +8 -4
  80. package/templates/apps/mcp/src/tools/index.ts +7 -22
  81. package/templates/apps/web/.env.example +15 -0
  82. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  83. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  84. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  91. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  92. package/templates/apps/web/jest.config.cjs +14 -13
  93. package/templates/apps/web/next.config.mjs +1 -3
  94. package/templates/apps/web/package.json +0 -6
  95. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  96. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  97. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  98. package/templates/apps/web/public/favicon-16x16.png +0 -0
  99. package/templates/apps/web/public/favicon-32x32.png +0 -0
  100. package/templates/apps/web/public/favicon.ico +0 -0
  101. package/templates/apps/web/public/favicon.svg +6 -3
  102. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  103. package/templates/apps/web/public/logomark.svg +4 -0
  104. package/templates/apps/web/public/site.webmanifest +2 -2
  105. package/templates/apps/web/public/wordmark.svg +4 -0
  106. package/templates/database/CHANGELOG.md +33 -0
  107. package/templates/database/inbox/schema.prisma +67 -0
  108. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  109. package/templates/database/package.json +1 -1
  110. package/templates/package.json +1 -1
  111. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  112. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  113. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  114. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  115. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  116. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  117. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  118. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  119. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  120. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  121. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  122. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  123. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  124. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  125. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  126. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  127. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  128. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  129. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  130. package/templates/apps/api/src/lib/stripe.ts +0 -52
  131. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  132. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  133. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  134. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  135. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  136. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  137. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  138. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  139. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  140. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  141. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  142. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  143. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  144. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  145. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  146. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  147. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  148. package/templates/apps/api/src/routes/flows/README.md +0 -147
  149. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  150. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  151. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  152. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  153. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  154. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  155. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  156. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  157. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  158. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  159. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  160. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  161. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  162. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  163. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  164. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  165. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  166. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  167. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  168. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  169. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  170. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  171. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  172. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  173. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  174. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  175. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  176. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  177. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  178. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  179. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  180. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  181. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  182. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  183. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  184. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  185. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  186. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  187. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  188. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  189. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  190. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  191. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  192. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  193. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  194. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  195. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  196. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  197. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  198. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  199. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  200. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  201. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  202. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  203. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  204. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  205. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  206. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  207. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  208. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  209. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  210. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  211. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  212. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  213. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  214. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  215. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  216. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  217. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  218. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  219. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  220. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  221. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  222. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  223. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  224. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  225. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  226. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  227. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  228. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  229. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  230. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  231. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  232. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  233. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  234. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  235. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  236. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  237. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  238. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  239. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  240. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  241. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  242. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  243. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  246. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  247. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  248. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  249. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  250. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  251. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  252. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  253. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  254. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  255. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  256. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  257. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  258. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  259. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  260. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  261. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  262. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  263. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  264. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  265. package/templates/apps/web/content/en/blog/README.txt +0 -25
  266. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  267. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  268. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  269. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  270. package/templates/apps/web/lib/source.ts +0 -34
  271. package/templates/apps/web/public/logomark-dark.svg +0 -4
  272. package/templates/apps/web/public/logomark-light.svg +0 -4
  273. 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
  /**