create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -57,9 +57,10 @@
57
57
  * file leaves nothing to find, and its retry writes again. `subscribe` and
58
58
  * `callback` never read the header. The key is never logged or echoed.
59
59
  *
60
- * CONTACT TERMS ACCEPTANCE (#8730). A `submit` whose `source` is a contact
61
- * source (`isContactLeadSource` in @working-theory/validation:
62
- * `contact-form`, `conversational-intake`, `flow:contact`) must carry a
60
+ * CONTACT TERMS ACCEPTANCE (#8730). A `submit` whose `source` is a
61
+ * consent-bearing source (`isConsentLeadSource` in @working-theory/validation:
62
+ * the contact sources `contact-form`, `conversational-intake`, `flow:contact`,
63
+ * and since #9130 the Free apply page's `flow:job-application`) must carry a
63
64
  * top-level `consent: true` — the boolean, nothing else — or it is
64
65
  * `400 consent_required` before the tenant read or any write. An accepted one
65
66
  * gets `payload.consent = { statement, acceptedAt }` written HERE, replacing
@@ -70,10 +71,30 @@
70
71
  * direct caller can still send `consent: true`; the gate makes acceptance an
71
72
  * explicit, recorded assertion, not proof that a person read the terms.
72
73
  *
73
- * API↔MCP PARITY for `consent` (#8730): no guard entry, for the reason the
74
- * Idempotency-Key paragraph below gives — this route parses no validation
75
- * export, so it binds as `api-args-unbound`. `capture_lead` stamps `mcp`, a
76
- * source this check never reads, so the tool is unchanged.
74
+ * THE BODY GATE (#7171, #7172, #7173 — the operator's decision of
75
+ * 2026-10-19: the MCP tool adopts the API, arguments and results). Each of
76
+ * the three writes is its own registration, and each parses `request.body`
77
+ * with one exported `@working-theory/validation` schema —
78
+ * `leadSubmitBodySchema`, `leadSubscribeBodySchema`, `leadCallbackBodySchema`
79
+ * — answering `400 invalid_input` (the first issue's message) when the body
80
+ * is not admitted; `capture_lead`, `subscribe_newsletter` and
81
+ * `request_callback` take exactly those exports and return the 201 below
82
+ * unchanged, so the parity guard holds the three pairs as `twin`s. The
83
+ * schemas are plain objects, never `.strict()`: every body a site form sends
84
+ * today — `siteId` beside the fields, the StepFlow envelope with `email`
85
+ * inside `payload`, no `payload` at all — still passes, an unknown key is
86
+ * stripped from the parsed copy and never refused. The parsed value is a
87
+ * gate only: `captureLead` reads the RAW `request.body`, so what `routeLead`
88
+ * validates and persists is byte-identical to before (#5732 — the payload
89
+ * walk sees the object that is stored). The one new refusal beyond the
90
+ * schema's own is a malformed or `null` body, which threw before.
91
+ *
92
+ * API↔MCP PARITY for `consent` (#8730): `leadSubmitBodySchema` declares it
93
+ * as an optional boolean, so the guard sees the same argument on both sides.
94
+ * The REQUIREMENT — `true` on a consent-bearing source — is the handler's
95
+ * own check below, which the guard does not model; `capture_lead` sends the
96
+ * agent's own `source`, so an agent naming a consent source needs
97
+ * `consent: true` exactly as a form does.
77
98
  *
78
99
  * A MAGNET'S COMPLETION (#8757 D6). After a `flow:<slug>` submit is written
79
100
  * to the database, `services/magnet-completion.ts` reads that LIVE magnet's
@@ -86,9 +107,10 @@
86
107
  * `lead`, a draft or missing magnet, or a caller with nowhere to sign in
87
108
  * leaves the 201 as it was. A failed read or send never changes the 201,
88
109
  * and the completion read has a time budget (`MAGNET_COMPLETION_BUDGET_MS`).
89
- * `next` is reachable only through a `flow:` source: `capture_lead` posts
90
- * `source: "mcp"`, so the tool's arguments get the same answer as before and
91
- * no parity guard row changes (NFR5).
110
+ * `next` is reachable only through a `flow:` source, which the parity guard's
111
+ * own argument fixture for `capture_lead` never sends, so the guard's
112
+ * observation never carries it and `leadWriteResultSchema` does not declare
113
+ * it (NFR5).
92
114
  *
93
115
  * A CHECKOUT'S ACCOUNT LINK (#8757 D14). A checkout fulfilment's submit
94
116
  * (`source: "web"`, `payload.page: "checkout"`) carries the caller's account
@@ -100,20 +122,23 @@
100
122
  *
101
123
  * API↔MCP PARITY for the header (#7869 D9, roadmap §12 Q5): NO guard entry.
102
124
  * The guard binds a route's arguments to the validation export its handler
103
- * `.safeParse`s on `request.params|query|body`; the header check below is a
104
- * regex test, not such a parse, so this route still binds as
105
- * `api-args-unbound` and the `capture_lead` row is unchanged. The 201 body's
106
- * field set is unchanged too, and `capture_lead` sends no header. If the
107
- * guard ever models headers, this becomes an API-side `arg-only-in`.
125
+ * `.safeParse`s on `request.params|query|body` — here `leadSubmitBodySchema`
126
+ * — and models no header; the header check below is a regex test, not such a
127
+ * parse, so the key is outside the compared contract. The 201 body's field
128
+ * set is unchanged too, and `capture_lead` sends no header. If the guard
129
+ * ever models headers, this becomes an API-side `arg-only-in`.
108
130
  */
109
131
 
110
132
  import { track } from "@working-theory/analytics/server";
111
133
  import {
112
134
  CONTACT_FORM_TERMS_STATEMENT,
113
- isContactLeadSource,
135
+ isConsentLeadSource,
136
+ leadCallbackBodySchema,
137
+ leadSubmitBodySchema,
138
+ leadSubscribeBodySchema,
114
139
  type LeadSubmitNext,
115
140
  } from "@working-theory/validation";
116
- import type { FastifyPluginAsync } from "fastify";
141
+ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
117
142
 
118
143
  import { acceptsKind, resolveLeadDelivery } from "../../lib/lead-delivery.js";
119
144
  import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
@@ -156,17 +181,18 @@ function liftString(
156
181
  return (typeof fromPayload === "string" ? fromPayload : topLevel) as string;
157
182
  }
158
183
 
159
- /** #8730 — the refusal a contact lead without terms acceptance gets. */
184
+ /** #8730, #9130 — the refusal a consent-bearing lead without terms acceptance gets. */
160
185
  const CONSENT_REQUIRED_BODY = {
161
186
  error: "consent_required",
162
- message: "Accept the terms to send a contact message.",
187
+ message: "Accept the terms to send this form.",
163
188
  } as const;
164
189
 
165
190
  /**
166
- * #8730 D3 — the accepted terms, recorded by the server on a contact lead's
167
- * payload, replacing any `consent` the client wrote there. Only a plain
168
- * object payload is stamped: anything else is left for `routeLead`'s schema
169
- * to refuse, never spread into an object that would then pass it.
191
+ * #8730 D3, #9130 — the accepted terms, recorded by the server on a
192
+ * consent-bearing lead's payload, replacing any `consent` the client wrote
193
+ * there. Only a plain object payload is stamped: anything else is left for
194
+ * `routeLead`'s schema to refuse, never spread into an object that would then
195
+ * pass it.
170
196
  */
171
197
  function withConsentRecord(payload: unknown, acceptedAt: string): unknown {
172
198
  if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
@@ -205,7 +231,7 @@ function buildInput(
205
231
  siteId: workspaceId,
206
232
  source: body["source"] as string,
207
233
  email: liftString(body, payload, "email"),
208
- payload: isContactLeadSource(body["source"])
234
+ payload: isConsentLeadSource(body["source"])
209
235
  ? (withConsentRecord(payload, receivedAt) as Record<string, unknown>)
210
236
  : payload,
211
237
  };
@@ -241,312 +267,355 @@ function isCheckoutSubmit(body: Record<string, unknown>): boolean {
241
267
  );
242
268
  }
243
269
 
244
- function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
245
- return async (fastify) => {
246
- fastify.post(`/v1/leads/${kind}`, async (request, reply) => {
247
- const body = request.body as Record<string, unknown>;
270
+ /**
271
+ * The refusal a body the route's own export does not admit gets (#7171,
272
+ * #7172, #7173): the shape and the first-issue message `routeLead`'s
273
+ * `invalid_input` branch already answers. Logs nothing.
274
+ */
275
+ function refuseBody(
276
+ reply: FastifyReply,
277
+ issues: readonly { message: string }[],
278
+ ) {
279
+ return reply.status(400).send({
280
+ error: "invalid_input",
281
+ message: issues[0]?.message ?? "invalid input",
282
+ });
283
+ }
248
284
 
249
- // #8730 D1 — a contact lead carries the visitor's terms acceptance, or
250
- // it is refused before the tenant read and any write.
251
- if (
252
- kind === "submit" &&
253
- isContactLeadSource(body?.["source"]) &&
254
- body?.["consent"] !== true
255
- ) {
256
- return reply.status(400).send(CONSENT_REQUIRED_BODY);
257
- }
258
- // request.ip became attacker-influenceable when trustProxy: true went
259
- // instance-wide (server.ts, #4730/#4778) — leads/index.ts was never
260
- // itself touched by that PR. Traced every consumer of this value
261
- // (#5112, DEF-004): lead-routing.ts's structured log field and its
262
- // opt-in (LEAD_CAPTURE_INCLUDE_IP) persisted metadata.ip both RECORD
263
- // the value; neither branches routing, persistence, or dedup on it.
264
- // The best-effort/record-only posture is judged sufficient — the same
265
- // trade-off server.ts's own trustProxy comment already accepts. The
266
- // private @working-theory/lead-routing package (not present in this
267
- // repo, not inspectable) is the one consumer this trace could not
268
- // complete; see docs/specs/5112-leads-ip-trace-spec.md §8.
269
- const sourceMeta: SourceMeta = {
270
- ip: request.ip,
271
- userAgent: request.headers["user-agent"],
272
- receivedAt: new Date().toISOString(),
273
- };
285
+ /**
286
+ * The shared capture, after a kind's own gates: tenant resolution, the
287
+ * idempotency key, `buildInput` over the RAW body, `routeLead`, the confirm
288
+ * link, `next`, `track` and the 201. Reads `request.body` itself — never the
289
+ * body export's parsed copy — so what `routeLead` validates and persists is
290
+ * the object the client sent (the #5732 property: `boundedLeadPayload` walks
291
+ * exactly what is persisted, an own `__proto__` key included, where a
292
+ * `z.record` copy would have dropped it).
293
+ */
294
+ async function captureLead(
295
+ kind: LeadKind,
296
+ request: FastifyRequest,
297
+ reply: FastifyReply,
298
+ ) {
299
+ const body = request.body as Record<string, unknown>;
274
300
 
275
- // #7532 D4 — a registered spoke resolves first, to its own workspace; a
276
- // refused key never falls through to the alias below. A keyless lead
277
- // whose hub-status read fails on a cold start proceeds as on a non-hub,
278
- // so routeLead's outage fallback captures it (#7633 amendment).
279
- const caller = await resolveCallerSite(request, {
280
- keylessOnRegistryFailure: "anonymous",
301
+ // request.ip became attacker-influenceable when trustProxy: true went
302
+ // instance-wide (server.ts, #4730/#4778) — leads/index.ts was never
303
+ // itself touched by that PR. Traced every consumer of this value
304
+ // (#5112, DEF-004): lead-routing.ts's structured log field and its
305
+ // opt-in (LEAD_CAPTURE_INCLUDE_IP) persisted metadata.ip both RECORD
306
+ // the value; neither branches routing, persistence, or dedup on it.
307
+ // The best-effort/record-only posture is judged sufficient — the same
308
+ // trade-off server.ts's own trustProxy comment already accepts. The
309
+ // private @working-theory/lead-routing package (not present in this
310
+ // repo, not inspectable) is the one consumer this trace could not
311
+ // complete; see docs/specs/5112-leads-ip-trace-spec.md §8.
312
+ const sourceMeta: SourceMeta = {
313
+ ip: request.ip,
314
+ userAgent: request.headers["user-agent"],
315
+ receivedAt: new Date().toISOString(),
316
+ };
317
+
318
+ // #7532 D4 — a registered spoke resolves first, to its own workspace; a
319
+ // refused key never falls through to the alias below. A keyless lead
320
+ // whose hub-status read fails on a cold start proceeds as on a non-hub,
321
+ // so routeLead's outage fallback captures it (#7633 amendment).
322
+ const caller = await resolveCallerSite(request, {
323
+ keylessOnRegistryFailure: "anonymous",
324
+ });
325
+ if (caller.kind === "refused") {
326
+ return reply.status(503).send({
327
+ error: "tenant_unresolved",
328
+ message: "This site is not configured to accept submissions.",
329
+ });
330
+ }
331
+
332
+ // #6203 — resolve the tenant SERVER-SIDE before any write is
333
+ // attempted. Two branches, deliberately different:
334
+ //
335
+ // - No DATABASE_URL: the zero-config OSS file-fallback deployment.
336
+ // There is no `workspaces` table to resolve against (and
337
+ // `assertSiteAliasResolves()` already skips its own boot check for
338
+ // exactly this reason), so the "resolved" value here is the site
339
+ // ALIAS itself (`currentSiteId()`) — the same value the file
340
+ // fallback would have carried before this unit. Never a rejection:
341
+ // that would break lead capture for every zero-config scaffold.
342
+ // - DATABASE_URL set: resolve via `resolveSiteWorkspaceId()`, the
343
+ // SAME alias -> workspace resolution `currentSiteId()` already
344
+ // drives everywhere else in this app. `null` here is a genuine
345
+ // misconfiguration `build()` already refuses to boot past
346
+ // (`assertSiteAliasResolves()`) — reaching it at request time is
347
+ // the residual case that startup check cannot catch (a workspace
348
+ // deleted after boot, a transient read), and it fails closed the
349
+ // same way: reject, never guess (spec AC-4).
350
+ let workspaceId: string;
351
+ if (caller.kind === "spoke") {
352
+ workspaceId = caller.workspaceId;
353
+ sourceMeta.viaSpoke = true;
354
+ } else if (!process.env.DATABASE_URL) {
355
+ workspaceId = currentSiteId();
356
+ } else {
357
+ const resolved = await resolveSiteWorkspaceId();
358
+ if (resolved === null) {
359
+ request.log.error(
360
+ { kind },
361
+ "lead capture: DATABASE_URL is set but the site alias resolves to no workspace",
362
+ );
363
+ return reply.status(503).send({
364
+ error: "tenant_unresolved",
365
+ message: "This site is not configured to accept submissions.",
281
366
  });
282
- if (caller.kind === "refused") {
283
- return reply.status(503).send({
284
- error: "tenant_unresolved",
285
- message: "This site is not configured to accept submissions.",
367
+ }
368
+ workspaceId = resolved;
369
+ }
370
+
371
+ // #7869 D1 — after the tenant gate, before any read or write. Node
372
+ // lower-cases header names, and joins a duplicated header with ", ",
373
+ // which the pattern refuses.
374
+ let idempotencyKey: string | undefined;
375
+ if (kind === "submit") {
376
+ const rawKey = request.headers["idempotency-key"];
377
+ if (rawKey !== undefined) {
378
+ if (
379
+ typeof rawKey !== "string" ||
380
+ !IDEMPOTENCY_KEY_PATTERN.test(rawKey)
381
+ ) {
382
+ return reply.status(400).send({
383
+ error: "invalid_input",
384
+ message:
385
+ "Idempotency-Key must be 16 to 255 visible ASCII characters.",
286
386
  });
287
387
  }
388
+ idempotencyKey = rawKey;
389
+ }
390
+ }
288
391
 
289
- // #6203 — resolve the tenant SERVER-SIDE before any write is
290
- // attempted. Two branches, deliberately different:
291
- //
292
- // - No DATABASE_URL: the zero-config OSS file-fallback deployment.
293
- // There is no `workspaces` table to resolve against (and
294
- // `assertSiteAliasResolves()` already skips its own boot check for
295
- // exactly this reason), so the "resolved" value here is the site
296
- // ALIAS itself (`currentSiteId()`) — the same value the file
297
- // fallback would have carried before this unit. Never a rejection:
298
- // that would break lead capture for every zero-config scaffold.
299
- // - DATABASE_URL set: resolve via `resolveSiteWorkspaceId()`, the
300
- // SAME alias -> workspace resolution `currentSiteId()` already
301
- // drives everywhere else in this app. `null` here is a genuine
302
- // misconfiguration `build()` already refuses to boot past
303
- // (`assertSiteAliasResolves()`) — reaching it at request time is
304
- // the residual case that startup check cannot catch (a workspace
305
- // deleted after boot, a transient read), and it fails closed the
306
- // same way: reject, never guess (spec AC-4).
307
- let workspaceId: string;
308
- if (caller.kind === "spoke") {
309
- workspaceId = caller.workspaceId;
310
- sourceMeta.viaSpoke = true;
311
- } else if (!process.env.DATABASE_URL) {
312
- workspaceId = currentSiteId();
313
- } else {
314
- const resolved = await resolveSiteWorkspaceId();
315
- if (resolved === null) {
316
- request.log.error(
317
- { kind },
318
- "lead capture: DATABASE_URL is set but the site alias resolves to no workspace",
319
- );
320
- return reply.status(503).send({
321
- error: "tenant_unresolved",
322
- message: "This site is not configured to accept submissions.",
323
- });
324
- }
325
- workspaceId = resolved;
326
- }
392
+ // #8185 D3 — a spoke's same-origin relay marks every forwarded call.
393
+ // The mark can only remove the spoke-checkout marker, never grant it.
394
+ sourceMeta.relayed = request.headers["x-ailk-relayed"] !== undefined;
327
395
 
328
- // #7869 D1 — after the tenant gate, before any read or write. Node
329
- // lower-cases header names, and joins a duplicated header with ", ",
330
- // which the pattern refuses.
331
- let idempotencyKey: string | undefined;
332
- if (kind === "submit") {
333
- const rawKey = request.headers["idempotency-key"];
334
- if (rawKey !== undefined) {
335
- if (
336
- typeof rawKey !== "string" ||
337
- !IDEMPOTENCY_KEY_PATTERN.test(rawKey)
338
- ) {
339
- return reply.status(400).send({
340
- error: "invalid_input",
341
- message:
342
- "Idempotency-Key must be 16 to 255 visible ASCII characters.",
343
- });
344
- }
345
- idempotencyKey = rawKey;
346
- }
347
- }
396
+ // #8438 D11 — the cookie banner's visitor id, forwarded by the site's
397
+ // own server from its first-party cookie. Only a v4 UUID counts; the
398
+ // header is never echoed or logged. It links the visitor to the
399
+ // lead's Person after the write, and changes nothing about the answer.
400
+ const consentVisitorId = consentVisitorFromHeader(
401
+ request.headers[CONSENT_VISITOR_HEADER],
402
+ );
403
+ if (consentVisitorId !== undefined) {
404
+ sourceMeta.consentVisitorId = consentVisitorId;
405
+ }
348
406
 
349
- // #8185 D3 — a spoke's same-origin relay marks every forwarded call.
350
- // The mark can only remove the spoke-checkout marker, never grant it.
351
- sourceMeta.relayed = request.headers["x-ailk-relayed"] !== undefined;
407
+ // #8757 D14 — where a buyer's account lives, for the purchase email.
408
+ let accountOrigin: string | undefined;
409
+ if (kind === "submit" && isCheckoutSubmit(body)) {
410
+ const target = await accountTargetFor(caller).catch(() => null);
411
+ accountOrigin = target?.origin;
412
+ }
413
+
414
+ const result = await routeLead(
415
+ buildInput(kind, body, workspaceId, sourceMeta.receivedAt),
416
+ sourceMeta,
417
+ {
418
+ ...(idempotencyKey !== undefined && { idempotencyKey }),
419
+ ...(accountOrigin !== undefined && { accountOrigin }),
420
+ },
421
+ );
352
422
 
353
- // #8438 D11 — the cookie banner's visitor id, forwarded by the site's
354
- // own server from its first-party cookie. Only a v4 UUID counts; the
355
- // header is never echoed or logged. It links the visitor to the
356
- // lead's Person after the write, and changes nothing about the answer.
357
- const consentVisitorId = consentVisitorFromHeader(
358
- request.headers[CONSENT_VISITOR_HEADER],
423
+ if (!result.ok) {
424
+ if (result.error.kind === "idempotency_conflict") {
425
+ // #7869 D6 — the operator's signal that a keyed lead was refused.
426
+ // Never the key, the email or the payload.
427
+ request.log.warn(
428
+ { kind, siteId: workspaceId, outcome: "idempotency_conflict" },
429
+ "lead idempotency_conflict",
359
430
  );
360
- if (consentVisitorId !== undefined) {
361
- sourceMeta.consentVisitorId = consentVisitorId;
362
- }
431
+ return reply.status(422).send({
432
+ error: "idempotency_key_reused",
433
+ message: "This Idempotency-Key was used for a different lead.",
434
+ });
435
+ }
436
+ if (result.error.kind === "invalid_input") {
437
+ return reply
438
+ .status(400)
439
+ .send({ error: "invalid_input", message: result.error.message });
440
+ }
441
+ if (result.error.kind === "persist_failed") {
442
+ // Do not surface internal error detail — log it server-side only
443
+ request.log.error(
444
+ { kind, siteId: workspaceId, error: result.error.message },
445
+ "lead persist_failed",
446
+ );
447
+ return reply.status(503).send({ error: "persist_failed" });
448
+ }
449
+ if (result.error.kind === "delivery_failed") {
450
+ // Email was the delivery and the provider did not accept it —
451
+ // never reported as sent (#6730 D5). Detail stays server-side.
452
+ // `{ kind, siteId }` only (spec D5): provider error text can echo
453
+ // an address.
454
+ request.log.error({ kind, siteId: workspaceId }, "lead delivery_failed");
455
+ return reply.status(503).send({
456
+ error: "delivery_failed",
457
+ message: "The message could not be delivered.",
458
+ });
459
+ }
460
+ // config_missing — this deployment has nowhere to put this kind of
461
+ // lead (#6730 D1: production with neither a database nor the email
462
+ // vars, or a newsletter signup with no database).
463
+ return reply.status(503).send({
464
+ error: "lead_capture_not_configured",
465
+ message: "Lead capture is not configured.",
466
+ });
467
+ }
363
468
 
364
- // #8757 D14 — where a buyer's account lives, for the purchase email.
365
- let accountOrigin: string | undefined;
366
- if (kind === "submit" && isCheckoutSubmit(body)) {
367
- const target = await accountTargetFor(caller).catch(() => null);
368
- accountOrigin = target?.origin;
369
- }
469
+ // PII-safe structured log: only routing metadata, no email/phone/name/payload
470
+ const emailKind =
471
+ "skipped" in result.email
472
+ ? "skipped"
473
+ : result.email.ok
474
+ ? "ok"
475
+ : "error";
476
+ request.log.info(
477
+ {
478
+ siteId: workspaceId,
479
+ kind,
480
+ ip: sourceMeta.ip,
481
+ persisted: result.persisted,
482
+ emailKind,
483
+ replayed: result.replayed === true,
484
+ },
485
+ "lead routed",
486
+ );
370
487
 
371
- const result = await routeLead(
372
- buildInput(kind, body, workspaceId, sourceMeta.receivedAt),
373
- sourceMeta,
374
- {
375
- ...(idempotencyKey !== undefined && { idempotencyKey }),
376
- ...(accountOrigin !== undefined && { accountOrigin }),
377
- },
378
- );
488
+ // Request-and-confirm (#5970): mint the tenant's one-time confirm
489
+ // credential and email them the link. Fire-and-forget and `void`-
490
+ // returning, so the 201 below stays independent of both Resend and that
491
+ // write — the request is already persisted by this point, and a send
492
+ // that fails must never turn a captured lead into an error.
493
+ //
494
+ // Callback only: `submit` and `subscribe` have no confirm step. The
495
+ // service itself no-ops (with a logged reason) when the feature is not
496
+ // configured, which is the default.
497
+ // `result.persisted === "db"` for the same reason `lead-routing.ts`
498
+ // consults it (#5970 security review round 3, M1): the credential is
499
+ // minted by an update on the `callbacks` row, so a FILE-FALLBACK id
500
+ // would match nothing and the operator would be left with no email at
501
+ // all. On that path the generic notification is sent instead, which is
502
+ // what keeps "exactly one" true in both directions.
503
+ // A spoke's callback never takes this path (#7532 D4, SEC-7532-2): the
504
+ // link is minted on the deployment's one Stripe account.
505
+ if (
506
+ kind === "callback" &&
507
+ result.persisted === "db" &&
508
+ caller.kind !== "spoke"
509
+ ) {
510
+ const callbackInput = buildInput(
511
+ kind,
512
+ body,
513
+ workspaceId,
514
+ sourceMeta.receivedAt,
515
+ ) as Extract<RouteLeadInput, { kind: "callback" }>;
516
+ sendRequestConfirmLink(result.id, {
517
+ name: callbackInput.name,
518
+ phone: callbackInput.phone,
519
+ ...(callbackInput.preferredTime !== undefined && {
520
+ preferredTime: callbackInput.preferredTime,
521
+ }),
522
+ });
523
+ }
379
524
 
380
- if (!result.ok) {
381
- if (result.error.kind === "idempotency_conflict") {
382
- // #7869 D6 — the operator's signal that a keyed lead was refused.
383
- // Never the key, the email or the payload.
384
- request.log.warn(
385
- { kind, siteId: workspaceId, outcome: "idempotency_conflict" },
386
- "lead idempotency_conflict",
387
- );
388
- return reply.status(422).send({
389
- error: "idempotency_key_reused",
390
- message: "This Idempotency-Key was used for a different lead.",
391
- });
392
- }
393
- if (result.error.kind === "invalid_input") {
394
- return reply
395
- .status(400)
396
- .send({ error: "invalid_input", message: result.error.message });
397
- }
398
- if (result.error.kind === "persist_failed") {
399
- // Do not surface internal error detail — log it server-side only
400
- request.log.error(
401
- { kind, siteId: workspaceId, error: result.error.message },
402
- "lead persist_failed",
403
- );
404
- return reply.status(503).send({ error: "persist_failed" });
405
- }
406
- if (result.error.kind === "delivery_failed") {
407
- // Email was the delivery and the provider did not accept it —
408
- // never reported as sent (#6730 D5). Detail stays server-side.
409
- // `{ kind, siteId }` only (spec D5): provider error text can echo
410
- // an address.
411
- request.log.error({ kind, siteId: workspaceId }, "lead delivery_failed");
412
- return reply.status(503).send({
413
- error: "delivery_failed",
414
- message: "The message could not be delivered.",
525
+ // #8757 D6 — a written `flow:<slug>` submit answers its magnet's
526
+ // completion. A read that fails, or outlasts its budget, leaves the 201
527
+ // as it was: the lead is saved.
528
+ let next: LeadSubmitNext | undefined;
529
+ const source = body["source"];
530
+ if (
531
+ kind === "submit" &&
532
+ result.persisted === "db" &&
533
+ typeof source === "string" &&
534
+ source.startsWith("flow:")
535
+ ) {
536
+ const slug = source.slice("flow:".length);
537
+ try {
538
+ const completion = await magnetCompletionWithinBudget(workspaceId, slug, () =>
539
+ accountTargetFor(caller),
540
+ );
541
+ if (completion !== null) {
542
+ next = completion.next;
543
+ if (completion.next.completion === "invite" && !result.replayed) {
544
+ const submitInput = buildInput(
545
+ kind,
546
+ body,
547
+ workspaceId,
548
+ sourceMeta.receivedAt,
549
+ ) as Extract<RouteLeadInput, { kind: "submit" }>;
550
+ const locale = submitInput.payload["locale"];
551
+ void sendInviteConfirmation({
552
+ workspaceId,
553
+ slug,
554
+ email: submitInput.email,
555
+ locale: typeof locale === "string" ? locale : undefined,
556
+ completion,
557
+ now: new Date(),
415
558
  });
416
559
  }
417
- // config_missing — this deployment has nowhere to put this kind of
418
- // lead (#6730 D1: production with neither a database nor the email
419
- // vars, or a newsletter signup with no database).
420
- return reply.status(503).send({
421
- error: "lead_capture_not_configured",
422
- message: "Lead capture is not configured.",
423
- });
424
560
  }
425
-
426
- // PII-safe structured log: only routing metadata, no email/phone/name/payload
427
- const emailKind =
428
- "skipped" in result.email
429
- ? "skipped"
430
- : result.email.ok
431
- ? "ok"
432
- : "error";
433
- request.log.info(
434
- {
435
- siteId: workspaceId,
436
- kind,
437
- ip: sourceMeta.ip,
438
- persisted: result.persisted,
439
- emailKind,
440
- replayed: result.replayed === true,
441
- },
442
- "lead routed",
561
+ } catch (error) {
562
+ request.log.error(
563
+ { event: "magnet-completion", error: error instanceof Error ? error.name : "unknown" },
564
+ "lead magnet completion read failed",
443
565
  );
566
+ }
567
+ }
444
568
 
445
- // Request-and-confirm (#5970): mint the tenant's one-time confirm
446
- // credential and email them the link. Fire-and-forget and `void`-
447
- // returning, so the 201 below stays independent of both Resend and that
448
- // write — the request is already persisted by this point, and a send
449
- // that fails must never turn a captured lead into an error.
450
- //
451
- // Callback only: `submit` and `subscribe` have no confirm step. The
452
- // service itself no-ops (with a logged reason) when the feature is not
453
- // configured, which is the default.
454
- // `result.persisted === "db"` for the same reason `lead-routing.ts`
455
- // consults it (#5970 security review round 3, M1): the credential is
456
- // minted by an update on the `callbacks` row, so a FILE-FALLBACK id
457
- // would match nothing and the operator would be left with no email at
458
- // all. On that path the generic notification is sent instead, which is
459
- // what keeps "exactly one" true in both directions.
460
- // A spoke's callback never takes this path (#7532 D4, SEC-7532-2): the
461
- // link is minted on the deployment's one Stripe account.
462
- if (
463
- kind === "callback" &&
464
- result.persisted === "db" &&
465
- caller.kind !== "spoke"
466
- ) {
467
- const callbackInput = buildInput(
468
- kind,
469
- body,
470
- workspaceId,
471
- sourceMeta.receivedAt,
472
- ) as Extract<RouteLeadInput, { kind: "callback" }>;
473
- sendRequestConfirmLink(result.id, {
474
- name: callbackInput.name,
475
- phone: callbackInput.phone,
476
- ...(callbackInput.preferredTime !== undefined && {
477
- preferredTime: callbackInput.preferredTime,
478
- }),
479
- });
480
- }
569
+ // Fire-and-forget: track() never throws; no need to hold the response.
570
+ // #7869 D6 — a replay records nothing new: the first write counted it.
571
+ if (!result.replayed) {
572
+ void track("lead-captured", {
573
+ source: body["source"] as string | undefined,
574
+ locale: (body["payload"] as Record<string, unknown> | undefined)?.[
575
+ "locale"
576
+ ] as string | undefined,
577
+ });
578
+ }
481
579
 
482
- // #8757 D6 — a written `flow:<slug>` submit answers its magnet's
483
- // completion. A read that fails, or outlasts its budget, leaves the 201
484
- // as it was: the lead is saved.
485
- let next: LeadSubmitNext | undefined;
486
- const source = body["source"];
487
- if (
488
- kind === "submit" &&
489
- result.persisted === "db" &&
490
- typeof source === "string" &&
491
- source.startsWith("flow:")
492
- ) {
493
- const slug = source.slice("flow:".length);
494
- try {
495
- const completion = await magnetCompletionWithinBudget(workspaceId, slug, () =>
496
- accountTargetFor(caller),
497
- );
498
- if (completion !== null) {
499
- next = completion.next;
500
- if (completion.next.completion === "invite" && !result.replayed) {
501
- const submitInput = buildInput(
502
- kind,
503
- body,
504
- workspaceId,
505
- sourceMeta.receivedAt,
506
- ) as Extract<RouteLeadInput, { kind: "submit" }>;
507
- const locale = submitInput.payload["locale"];
508
- void sendInviteConfirmation({
509
- workspaceId,
510
- slug,
511
- email: submitInput.email,
512
- locale: typeof locale === "string" ? locale : undefined,
513
- completion,
514
- now: new Date(),
515
- });
516
- }
517
- }
518
- } catch (error) {
519
- request.log.error(
520
- { event: "magnet-completion", error: error instanceof Error ? error.name : "unknown" },
521
- "lead magnet completion read failed",
522
- );
523
- }
524
- }
580
+ return reply.status(201).send({
581
+ id: result.id,
582
+ persisted: result.persisted,
583
+ email: result.email,
584
+ ...(next === undefined ? {} : { next }),
585
+ });
586
+ }
525
587
 
526
- // Fire-and-forget: track() never throws; no need to hold the response.
527
- // #7869 D6 — a replay records nothing new: the first write counted it.
528
- if (!result.replayed) {
529
- void track("lead-captured", {
530
- source: body["source"] as string | undefined,
531
- locale: (body["payload"] as Record<string, unknown> | undefined)?.[
532
- "locale"
533
- ] as string | undefined,
534
- });
535
- }
588
+ export const leadSubmitRoute: FastifyPluginAsync = async (fastify) => {
589
+ fastify.post("/v1/leads/submit", async (request, reply) => {
590
+ const body = request.body as Record<string, unknown>;
536
591
 
537
- return reply.status(201).send({
538
- id: result.id,
539
- persisted: result.persisted,
540
- email: result.email,
541
- ...(next === undefined ? {} : { next }),
542
- });
543
- });
544
- };
545
- }
592
+ // #8730 D1, #9130 — a contact lead or a job application carries the
593
+ // visitor's terms acceptance, or it is refused before the body gate, the
594
+ // tenant read and any write.
595
+ if (isConsentLeadSource(body?.["source"]) && body?.["consent"] !== true) {
596
+ return reply.status(400).send(CONSENT_REQUIRED_BODY);
597
+ }
598
+ const parsed = leadSubmitBodySchema.safeParse(request.body);
599
+ if (!parsed.success) return refuseBody(reply, parsed.error.issues);
600
+ return captureLead("submit", request, reply);
601
+ });
602
+ };
546
603
 
547
- export const leadSubmitRoute = makeLeadsRoute("submit");
548
- export const leadSubscribeRoute = makeLeadsRoute("subscribe");
549
- export const leadCallbackRoute = makeLeadsRoute("callback");
604
+ export const leadSubscribeRoute: FastifyPluginAsync = async (fastify) => {
605
+ fastify.post("/v1/leads/subscribe", async (request, reply) => {
606
+ const parsed = leadSubscribeBodySchema.safeParse(request.body);
607
+ if (!parsed.success) return refuseBody(reply, parsed.error.issues);
608
+ return captureLead("subscribe", request, reply);
609
+ });
610
+ };
611
+
612
+ export const leadCallbackRoute: FastifyPluginAsync = async (fastify) => {
613
+ fastify.post("/v1/leads/callback", async (request, reply) => {
614
+ const parsed = leadCallbackBodySchema.safeParse(request.body);
615
+ if (!parsed.success) return refuseBody(reply, parsed.error.issues);
616
+ return captureLead("callback", request, reply);
617
+ });
618
+ };
550
619
 
551
620
  /**
552
621
  * `GET /v1/leads/capture` (#6730 D4) — two booleans and nothing else: never