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
@@ -1,17 +1,22 @@
1
1
  /**
2
- * Tests for the submit_lead MCP tool handler.
2
+ * Tests for the capture_lead MCP tool handler.
3
3
  *
4
- * Acceptance criteria verified here:
4
+ * The tool takes the body `POST /v1/leads/submit` takes and returns the
5
+ * route's 201 body (#7171 AC-11 — the tool adopted the API). Verified here:
5
6
  * 1. Malformed input → { ok: false, error: { kind: 'invalid_input', issues } }, api-client never called
6
- * 2. Happy path → forwards to apiClient.leads.submit, returns { ok: true, id, persisted }
7
- * 3. API 400 → { ok: false, error: { kind: 'api_error', status: 400 } }
8
- * 4. API 503 → { ok: false, error: { kind: 'api_error', status: 503 } }
9
- * 5. API 429 → { ok: false, error: { kind: 'api_error', status: 429 } }
10
- * 6. #8073 — forwards the trusted caller address + AILK_VISITOR_FORWARD_KEY
7
+ * 2. Happy path → posts exactly the parsed body to apiClient.leads.submit —
8
+ * no `siteId`, whatever AILK_SITE_ID says, and no stamped `source` — and
9
+ * returns the route's body unchanged, `email` included
10
+ * 3. API 400 / 503 / 429 → { ok: false, error: { kind: 'api_error', status } }
11
+ * 4. #7397 — the spoke marker `payload.site` from AILK_LEAD_SITE, spread last
12
+ * 5. #8073 — forwards the trusted caller address + AILK_VISITOR_FORWARD_KEY
11
13
  * as the two visitor-forwarding headers only when both are present
12
14
  */
13
15
 
14
- import { LEAD_SITE_MARKER_KEY, leadSubmitSchema } from "@working-theory/validation";
16
+ import {
17
+ LEAD_SITE_MARKER_KEY,
18
+ leadSubmitBodySchema,
19
+ } from "@working-theory/validation";
15
20
  import { ailkCaptureLead } from "../src/tools/capture_lead.js";
16
21
  import { trustedCallerAddress } from "../src/request-context.js";
17
22
 
@@ -30,222 +35,141 @@ jest.mock("@working-theory/api-client", () => {
30
35
  };
31
36
  });
32
37
 
33
-
38
+
34
39
  const { apiClient } = require("@working-theory/api-client") as {
35
40
  apiClient: { leads: { submit: jest.Mock } };
36
41
  };
37
42
 
43
+ /** The route body, as an agent sends it. */
38
44
  const VALID_INPUT = {
39
- name: "Jane Smith",
45
+ source: "mcp",
40
46
  email: "jane@example.com",
41
- message: "I am interested in your services.",
47
+ payload: { name: "Jane Smith", message: "I am interested in your services." },
48
+ };
49
+
50
+ /** The route's 201 body, as the api-client hands it back. */
51
+ const WIRE = {
52
+ id: "lead_00000000-0000-7000-8000-0000000000d1",
53
+ persisted: "db",
54
+ email: { ok: true, messageId: "msg-1" },
42
55
  };
43
56
 
44
57
  beforeEach(() => {
45
58
  jest.clearAllMocks();
46
59
  // The exact-body assertions below assume no spoke marker (#7397).
47
60
  delete process.env.AILK_LEAD_SITE;
61
+ delete process.env.AILK_SITE_ID;
48
62
  });
49
63
 
50
64
  describe("ailkCaptureLead — input validation", () => {
51
- it("rejects malformed input (missing required fields) without calling api-client", async () => {
65
+ it("rejects malformed input (no source) without calling api-client", async () => {
52
66
  const result = await ailkCaptureLead({ email: "not-an-email" } as never);
53
67
 
54
- expect(result.ok).toBe(false);
55
- if (!result.ok) {
56
- expect(result.error.kind).toBe("invalid_input");
57
- if (result.error.kind === "invalid_input") {
58
- expect(Array.isArray(result.error.issues)).toBe(true);
59
- expect(result.error.issues.length).toBeGreaterThan(0);
60
- }
68
+ expect(result).toMatchObject({ ok: false, error: { kind: "invalid_input" } });
69
+ if (!result.ok && result.error.kind === "invalid_input") {
70
+ expect(result.error.issues.length).toBeGreaterThan(0);
61
71
  }
62
72
  expect(apiClient.leads.submit).not.toHaveBeenCalled();
63
73
  });
64
- });
65
74
 
66
- describe("ailkCaptureLead — happy path", () => {
67
- it("forwards canonical body to api-client and returns ok:true with id and persisted", async () => {
68
- apiClient.leads.submit.mockResolvedValueOnce({
69
- ok: true,
70
- body: { id: "lead_00000000-0000-7000-8000-0000000000d1", persisted: "db" },
71
- });
75
+ it("admits the StepFlow envelope — email inside payload, none at the top level — as the route does", async () => {
76
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
77
+ const envelope = { source: "step-flow", payload: { email: "env@example.com" } };
72
78
 
73
- const result = await ailkCaptureLead(VALID_INPUT);
79
+ await ailkCaptureLead(envelope);
74
80
 
75
- expect(apiClient.leads.submit).toHaveBeenCalledWith({
76
- siteId: "default",
77
- source: "mcp",
78
- email: "jane@example.com",
79
- payload: { name: "Jane Smith", message: "I am interested in your services." },
80
- });
81
- expect(result.ok).toBe(true);
82
- if (result.ok) {
83
- expect(result.id).toBe("lead_00000000-0000-7000-8000-0000000000d1");
84
- expect(result.persisted).toBe("db");
85
- }
81
+ expect(apiClient.leads.submit).toHaveBeenCalledWith(envelope);
86
82
  });
87
83
  });
88
84
 
89
- describe("ailkCaptureLead — API error mapping", () => {
90
- it("maps API 400 to { ok: false, error: { kind: 'api_error', status: 400 } }", async () => {
91
- apiClient.leads.submit.mockResolvedValueOnce({
92
- ok: false,
93
- status: 400,
94
- body: { error: "bad_request" },
95
- });
96
-
97
- const result = await ailkCaptureLead(VALID_INPUT);
98
-
99
- expect(result.ok).toBe(false);
100
- if (!result.ok) {
101
- expect(result.error.kind).toBe("api_error");
102
- if (result.error.kind === "api_error") {
103
- expect(result.error.status).toBe(400);
104
- }
105
- }
106
- });
107
-
108
- it("maps API 503 to { ok: false, error: { kind: 'api_error', status: 503 } }", async () => {
109
- apiClient.leads.submit.mockResolvedValueOnce({
110
- ok: false,
111
- status: 503,
112
- body: { error: "service_unavailable" },
113
- });
114
-
115
- const result = await ailkCaptureLead(VALID_INPUT);
116
-
117
- expect(result.ok).toBe(false);
118
- if (!result.ok) {
119
- expect(result.error.kind).toBe("api_error");
120
- if (result.error.kind === "api_error") {
121
- expect(result.error.status).toBe(503);
122
- }
123
- }
124
- });
125
-
126
- it("maps API 429 to { ok: false, error: { kind: 'api_error', status: 429 } }", async () => {
127
- apiClient.leads.submit.mockResolvedValueOnce({
128
- ok: false,
129
- status: 429,
130
- body: { error: "rate_limited" },
131
- });
85
+ describe("ailkCaptureLead — happy path", () => {
86
+ it("posts exactly the parsed body and returns the route's body unchanged, email included", async () => {
87
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
132
88
 
133
89
  const result = await ailkCaptureLead(VALID_INPUT);
134
90
 
135
- expect(result.ok).toBe(false);
136
- if (!result.ok) {
137
- expect(result.error.kind).toBe("api_error");
138
- if (result.error.kind === "api_error") {
139
- expect(result.error.status).toBe(429);
140
- }
141
- }
91
+ expect(apiClient.leads.submit).toHaveBeenCalledWith(VALID_INPUT);
92
+ expect(result).toEqual(WIRE);
142
93
  });
143
- });
144
94
 
145
- describe("ailkCaptureLead — siteId default", () => {
146
- it("uses siteId 'default' when AILK_SITE_ID is unset", async () => {
147
- delete process.env.AILK_SITE_ID;
148
- apiClient.leads.submit.mockResolvedValueOnce({
149
- ok: true,
150
- body: { id: "lead_00000000-0000-7000-8000-0000000000d2", persisted: "db" },
151
- });
95
+ it("never sends a siteId, whatever AILK_SITE_ID says, and strips one the agent sent", async () => {
96
+ process.env.AILK_SITE_ID = "site-abc";
97
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
152
98
 
153
- await ailkCaptureLead(VALID_INPUT);
99
+ await ailkCaptureLead({ ...VALID_INPUT, siteId: "site-xyz" } as never);
154
100
 
155
- const call = apiClient.leads.submit.mock.calls[0][0] as { siteId: string };
156
- expect(call.siteId).toBe("default");
101
+ expect(apiClient.leads.submit.mock.calls[0][0]).toEqual(VALID_INPUT);
157
102
  });
158
103
  });
159
104
 
160
- describe("ailkCaptureLead — siteId override", () => {
161
- afterEach(() => {
162
- delete process.env.AILK_SITE_ID;
163
- });
164
-
165
- it("uses AILK_SITE_ID env var when set", async () => {
166
- process.env.AILK_SITE_ID = "site-abc";
167
- apiClient.leads.submit.mockResolvedValueOnce({
168
- ok: true,
169
- body: { id: "lead_00000000-0000-7000-8000-0000000000d3", persisted: "db" },
170
- });
171
-
172
- await ailkCaptureLead(VALID_INPUT);
173
-
174
- const call = apiClient.leads.submit.mock.calls[0][0] as { siteId: string };
175
- expect(call.siteId).toBe("site-abc");
176
- });
105
+ describe("ailkCaptureLead — API error mapping", () => {
106
+ it.each([400, 503, 429])(
107
+ "maps API %i to { ok: false, error: { kind: 'api_error', status } }",
108
+ async (status) => {
109
+ apiClient.leads.submit.mockResolvedValueOnce({
110
+ ok: false,
111
+ status,
112
+ body: { error: "refused" },
113
+ });
114
+
115
+ const result = await ailkCaptureLead(VALID_INPUT);
116
+
117
+ expect(result).toEqual({ ok: false, error: { kind: "api_error", status } });
118
+ },
119
+ );
177
120
  });
178
121
 
179
122
  // #7397 — a spoke of a hub-and-spokes deployment is marked in the lead
180
- // PAYLOAD (`payload.site`); `source` stays "mcp" because
181
- // MESSAGE_LEAD_SOURCE_LIST is frozen. The marker comes from the MCP server's
182
- // own env, so an MCP-origin lead reaches the hub inbox attributed.
123
+ // PAYLOAD (`payload.site`). The marker comes from the MCP server's own env,
124
+ // so an MCP-origin lead reaches the hub inbox attributed; it is the one key
125
+ // the tool adds to the body the agent sent.
183
126
  describe("ailkCaptureLead — payload site marker (#7397)", () => {
184
- afterEach(() => {
185
- delete process.env.AILK_LEAD_SITE;
186
- });
187
-
188
127
  it("names the marker key 'site', the key the issue's hub-and-spokes design names", () => {
189
128
  expect(LEAD_SITE_MARKER_KEY).toBe("site");
190
129
  });
191
130
 
192
- it("carries payload.site from AILK_LEAD_SITE, leaving source 'mcp'", async () => {
131
+ it("spreads payload.site from AILK_LEAD_SITE LAST, over an agent's own value", async () => {
193
132
  process.env.AILK_LEAD_SITE = "spoke-a.example";
194
- apiClient.leads.submit.mockResolvedValueOnce({
195
- ok: true,
196
- body: { id: "lead_00000000-0000-7000-8000-0000000000d4", persisted: "db" },
197
- });
133
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
198
134
 
199
- await ailkCaptureLead(VALID_INPUT);
135
+ await ailkCaptureLead({
136
+ ...VALID_INPUT,
137
+ payload: { ...VALID_INPUT.payload, site: "agent-said" },
138
+ });
200
139
 
201
140
  expect(apiClient.leads.submit).toHaveBeenCalledWith({
202
- siteId: "default",
203
- source: "mcp",
204
- email: "jane@example.com",
205
- payload: {
206
- name: "Jane Smith",
207
- message: "I am interested in your services.",
208
- site: "spoke-a.example",
209
- },
141
+ ...VALID_INPUT,
142
+ payload: { ...VALID_INPUT.payload, site: "spoke-a.example" },
210
143
  });
211
144
  });
212
145
 
213
- it("sends a marked body the API's submit schema accepts", async () => {
146
+ it("marks a body the agent sent without a payload", async () => {
214
147
  process.env.AILK_LEAD_SITE = "spoke-a.example";
215
- apiClient.leads.submit.mockResolvedValueOnce({
216
- ok: true,
217
- body: { id: "lead_00000000-0000-7000-8000-0000000000d5", persisted: "db" },
218
- });
148
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
219
149
 
220
- await ailkCaptureLead(VALID_INPUT);
150
+ await ailkCaptureLead({ source: "mcp", email: "jane@example.com" });
221
151
 
222
152
  const body = apiClient.leads.submit.mock.calls[0][0] as Record<string, unknown>;
223
- expect(leadSubmitSchema.safeParse({ kind: "submit", ...body }).success).toBe(true);
153
+ expect(body["payload"]).toEqual({ site: "spoke-a.example" });
154
+ expect(leadSubmitBodySchema.safeParse(body).success).toBe(true);
224
155
  });
225
156
 
226
157
  it.each([
227
158
  ["unset", undefined],
228
159
  ["empty", ""],
229
160
  ["whitespace only", " "],
230
- ])("omits payload.site when AILK_LEAD_SITE is %s", async (_label, value) => {
161
+ ])("sends the payload as it came when AILK_LEAD_SITE is %s", async (_label, value) => {
231
162
  if (value !== undefined) process.env.AILK_LEAD_SITE = value;
232
- apiClient.leads.submit.mockResolvedValueOnce({
233
- ok: true,
234
- body: { id: "lead_00000000-0000-7000-8000-0000000000d6", persisted: "db" },
235
- });
163
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
236
164
 
237
165
  await ailkCaptureLead(VALID_INPUT);
238
166
 
239
- const call = apiClient.leads.submit.mock.calls[0][0] as { payload: Record<string, unknown> };
240
- expect(call.payload).not.toHaveProperty("site");
167
+ expect(apiClient.leads.submit.mock.calls[0][0]).toEqual(VALID_INPUT);
241
168
  });
242
169
 
243
170
  it("trims surrounding whitespace from AILK_LEAD_SITE", async () => {
244
171
  process.env.AILK_LEAD_SITE = " spoke-a.example\n";
245
- apiClient.leads.submit.mockResolvedValueOnce({
246
- ok: true,
247
- body: { id: "lead_00000000-0000-7000-8000-0000000000d7", persisted: "db" },
248
- });
172
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
249
173
 
250
174
  await ailkCaptureLead(VALID_INPUT);
251
175
 
@@ -260,10 +184,7 @@ describe("ailkCaptureLead — visitor-forwarding headers (#8073)", () => {
260
184
  });
261
185
 
262
186
  it("calls api-client with exactly one argument when there is no trusted address", async () => {
263
- apiClient.leads.submit.mockResolvedValueOnce({
264
- ok: true,
265
- body: { id: "lead_00000000-0000-7000-8000-0000000000e1", persisted: "db" },
266
- });
187
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
267
188
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
268
189
 
269
190
  await ailkCaptureLead(VALID_INPUT);
@@ -272,10 +193,7 @@ describe("ailkCaptureLead — visitor-forwarding headers (#8073)", () => {
272
193
  });
273
194
 
274
195
  it("forwards the trusted address and the forward key as the two visitor headers", async () => {
275
- apiClient.leads.submit.mockResolvedValueOnce({
276
- ok: true,
277
- body: { id: "lead_00000000-0000-7000-8000-0000000000e2", persisted: "db" },
278
- });
196
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
279
197
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
280
198
 
281
199
  await trustedCallerAddress.run("203.0.113.9", () =>
@@ -292,10 +210,7 @@ describe("ailkCaptureLead — visitor-forwarding headers (#8073)", () => {
292
210
  });
293
211
 
294
212
  it("sends no headers when there is a trusted address but no forward key", async () => {
295
- apiClient.leads.submit.mockResolvedValueOnce({
296
- ok: true,
297
- body: { id: "lead_00000000-0000-7000-8000-0000000000e3", persisted: "db" },
298
- });
213
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
299
214
  delete process.env.AILK_VISITOR_FORWARD_KEY;
300
215
 
301
216
  await trustedCallerAddress.run("203.0.113.9", () =>
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Tests for the subscribe_newsletter MCP tool handler.
3
3
  *
4
- * Acceptance criteria verified here:
4
+ * The tool takes the body `POST /v1/leads/subscribe` takes — `{ email,
5
+ * source }` — and returns the route's 201 body (#7172 AC-11 — the tool
6
+ * adopted the API). Verified here:
5
7
  * 1. Malformed input → { ok: false, error: { kind: 'invalid_input', issues } }, api-client never called
6
- * 2. Happy path → forwards to apiClient.leads.subscribe, returns { ok: true, id, persisted }
7
- * 3. API 400 → { ok: false, error: { kind: 'api_error', status: 400 } }
8
- * 4. API 503 → { ok: false, error: { kind: 'api_error', status: 503 } }
9
- * 5. API 429 → { ok: false, error: { kind: 'api_error', status: 429 } }
10
- * 6. #8073 — forwards the trusted caller address + AILK_VISITOR_FORWARD_KEY
8
+ * 2. Happy path → posts exactly the parsed body to apiClient.leads.subscribe —
9
+ * no `siteId`, whatever AILK_SITE_ID says, no stamped `source`, no
10
+ * `firstName` — and returns the route's body unchanged, `email` included
11
+ * 3. API 400 / 503 / 429 → { ok: false, error: { kind: 'api_error', status } }
12
+ * 4. #8073 — forwards the trusted caller address + AILK_VISITOR_FORWARD_KEY
11
13
  * as the two visitor-forwarding headers only when both are present
12
14
  */
13
15
 
@@ -29,145 +31,81 @@ jest.mock("@working-theory/api-client", () => {
29
31
  };
30
32
  });
31
33
 
32
-
34
+
33
35
  const { apiClient } = require("@working-theory/api-client") as {
34
36
  apiClient: { leads: { subscribe: jest.Mock } };
35
37
  };
36
38
 
37
- const VALID_INPUT = {
38
- email: "jane@example.com",
39
+ /** The route body, as an agent sends it. */
40
+ const VALID_INPUT = { email: "jane@example.com", source: "mcp" };
41
+
42
+ /** The route's 201 body, as the api-client hands it back. */
43
+ const WIRE = {
44
+ id: "subscr_00000000-0000-7000-8000-0000000000d1",
45
+ persisted: "db",
46
+ email: { skipped: true, reason: "no sender configured" },
39
47
  };
40
48
 
41
49
  beforeEach(() => {
42
50
  jest.clearAllMocks();
51
+ delete process.env.AILK_SITE_ID;
43
52
  });
44
53
 
45
54
  describe("ailkSubscribeNewsletter — input validation", () => {
46
- it("rejects malformed input (invalid email) without calling api-client", async () => {
47
- const result = await ailkSubscribeNewsletter({ email: "not-an-email" });
48
-
49
- expect(result.ok).toBe(false);
50
- if (!result.ok) {
51
- expect(result.error.kind).toBe("invalid_input");
52
- if (result.error.kind === "invalid_input") {
53
- expect(Array.isArray(result.error.issues)).toBe(true);
54
- expect(result.error.issues.length).toBeGreaterThan(0);
55
- }
55
+ it.each([
56
+ ["an invalid email", { email: "not-an-email", source: "mcp" }],
57
+ ["no source", { email: "jane@example.com" }],
58
+ ["a source with a control character", { email: "jane@example.com", source: "a\nb" }],
59
+ ])("rejects %s without calling api-client", async (_label, input) => {
60
+ const result = await ailkSubscribeNewsletter(input as never);
61
+
62
+ expect(result).toMatchObject({ ok: false, error: { kind: "invalid_input" } });
63
+ if (!result.ok && result.error.kind === "invalid_input") {
64
+ expect(result.error.issues.length).toBeGreaterThan(0);
56
65
  }
57
66
  expect(apiClient.leads.subscribe).not.toHaveBeenCalled();
58
67
  });
59
68
  });
60
69
 
61
70
  describe("ailkSubscribeNewsletter — happy path", () => {
62
- it("forwards canonical body to api-client and returns ok:true with id and persisted", async () => {
63
- apiClient.leads.subscribe.mockResolvedValueOnce({
64
- ok: true,
65
- body: { id: "subscr_00000000-0000-7000-8000-0000000000d1", persisted: "db" },
66
- });
67
-
68
- const result = await ailkSubscribeNewsletter(VALID_INPUT);
69
-
70
- expect(apiClient.leads.subscribe).toHaveBeenCalledWith({
71
- siteId: "default",
72
- email: "jane@example.com",
73
- source: "mcp",
74
- });
75
- expect(result.ok).toBe(true);
76
- if (result.ok) {
77
- expect(result.id).toBe("subscr_00000000-0000-7000-8000-0000000000d1");
78
- expect(result.persisted).toBe("db");
79
- }
80
- });
81
- });
82
-
83
- describe("ailkSubscribeNewsletter — API error mapping", () => {
84
- it("maps API 400 to { ok: false, error: { kind: 'api_error', status: 400 } }", async () => {
85
- apiClient.leads.subscribe.mockResolvedValueOnce({
86
- ok: false,
87
- status: 400,
88
- body: { error: "bad_request" },
89
- });
90
-
91
- const result = await ailkSubscribeNewsletter(VALID_INPUT);
92
-
93
- expect(result.ok).toBe(false);
94
- if (!result.ok) {
95
- expect(result.error.kind).toBe("api_error");
96
- if (result.error.kind === "api_error") {
97
- expect(result.error.status).toBe(400);
98
- }
99
- }
100
- });
101
-
102
- it("maps API 503 to { ok: false, error: { kind: 'api_error', status: 503 } }", async () => {
103
- apiClient.leads.subscribe.mockResolvedValueOnce({
104
- ok: false,
105
- status: 503,
106
- body: { error: "service_unavailable" },
107
- });
108
-
109
- const result = await ailkSubscribeNewsletter(VALID_INPUT);
110
-
111
- expect(result.ok).toBe(false);
112
- if (!result.ok) {
113
- expect(result.error.kind).toBe("api_error");
114
- if (result.error.kind === "api_error") {
115
- expect(result.error.status).toBe(503);
116
- }
117
- }
118
- });
119
-
120
- it("maps API 429 to { ok: false, error: { kind: 'api_error', status: 429 } }", async () => {
121
- apiClient.leads.subscribe.mockResolvedValueOnce({
122
- ok: false,
123
- status: 429,
124
- body: { error: "rate_limited" },
125
- });
71
+ it("posts exactly the parsed body and returns the route's body unchanged, email included", async () => {
72
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
126
73
 
127
74
  const result = await ailkSubscribeNewsletter(VALID_INPUT);
128
75
 
129
- expect(result.ok).toBe(false);
130
- if (!result.ok) {
131
- expect(result.error.kind).toBe("api_error");
132
- if (result.error.kind === "api_error") {
133
- expect(result.error.status).toBe(429);
134
- }
135
- }
76
+ expect(apiClient.leads.subscribe).toHaveBeenCalledWith(VALID_INPUT);
77
+ expect(result).toEqual(WIRE);
136
78
  });
137
- });
138
79
 
139
- describe("ailkSubscribeNewsletter — siteId default", () => {
140
- it("uses siteId 'default' when AILK_SITE_ID is unset", async () => {
141
- delete process.env.AILK_SITE_ID;
142
- apiClient.leads.subscribe.mockResolvedValueOnce({
143
- ok: true,
144
- body: { id: "subscr_00000000-0000-7000-8000-0000000000d2", persisted: "db" },
145
- });
80
+ it("never sends a siteId or a firstName, whatever AILK_SITE_ID says or the agent sent", async () => {
81
+ process.env.AILK_SITE_ID = "site-abc";
82
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
146
83
 
147
- await ailkSubscribeNewsletter(VALID_INPUT);
84
+ await ailkSubscribeNewsletter({
85
+ ...VALID_INPUT,
86
+ siteId: "site-xyz",
87
+ firstName: "Jane",
88
+ } as never);
148
89
 
149
- const call = apiClient.leads.subscribe.mock.calls[0][0] as { siteId: string };
150
- expect(call.siteId).toBe("default");
90
+ expect(apiClient.leads.subscribe.mock.calls[0][0]).toEqual(VALID_INPUT);
151
91
  });
152
92
  });
153
93
 
154
- describe("ailkSubscribeNewsletter — siteId override", () => {
155
- afterEach(() => {
156
- delete process.env.AILK_SITE_ID;
157
- });
158
-
159
- it("uses AILK_SITE_ID env var when set", async () => {
160
- process.env.AILK_SITE_ID = "site-abc";
161
- apiClient.leads.subscribe.mockResolvedValueOnce({
162
- ok: true,
163
- body: { id: "subscr_00000000-0000-7000-8000-0000000000d3", persisted: "db" },
164
- });
165
-
166
- await ailkSubscribeNewsletter(VALID_INPUT);
167
-
168
- const call = apiClient.leads.subscribe.mock.calls[0][0] as { siteId: string };
169
- expect(call.siteId).toBe("site-abc");
170
- });
94
+ describe("ailkSubscribeNewsletter — API error mapping", () => {
95
+ it.each([400, 503, 429])(
96
+ "maps API %i to { ok: false, error: { kind: 'api_error', status } }",
97
+ async (status) => {
98
+ apiClient.leads.subscribe.mockResolvedValueOnce({
99
+ ok: false,
100
+ status,
101
+ body: { error: "refused" },
102
+ });
103
+
104
+ const result = await ailkSubscribeNewsletter(VALID_INPUT);
105
+
106
+ expect(result).toEqual({ ok: false, error: { kind: "api_error", status } });
107
+ },
108
+ );
171
109
  });
172
110
 
173
111
  describe("ailkSubscribeNewsletter — visitor-forwarding headers (#8073)", () => {
@@ -176,10 +114,7 @@ describe("ailkSubscribeNewsletter — visitor-forwarding headers (#8073)", () =>
176
114
  });
177
115
 
178
116
  it("calls api-client with exactly one argument when there is no trusted address", async () => {
179
- apiClient.leads.subscribe.mockResolvedValueOnce({
180
- ok: true,
181
- body: { id: "subscr_00000000-0000-7000-8000-0000000000e1", persisted: "db" },
182
- });
117
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
183
118
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
184
119
 
185
120
  await ailkSubscribeNewsletter(VALID_INPUT);
@@ -188,10 +123,7 @@ describe("ailkSubscribeNewsletter — visitor-forwarding headers (#8073)", () =>
188
123
  });
189
124
 
190
125
  it("forwards the trusted address and the forward key as the two visitor headers", async () => {
191
- apiClient.leads.subscribe.mockResolvedValueOnce({
192
- ok: true,
193
- body: { id: "subscr_00000000-0000-7000-8000-0000000000e2", persisted: "db" },
194
- });
126
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
195
127
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
196
128
 
197
129
  await trustedCallerAddress.run("203.0.113.9", () =>
@@ -208,10 +140,7 @@ describe("ailkSubscribeNewsletter — visitor-forwarding headers (#8073)", () =>
208
140
  });
209
141
 
210
142
  it("sends no headers when there is a trusted address but no forward key", async () => {
211
- apiClient.leads.subscribe.mockResolvedValueOnce({
212
- ok: true,
213
- body: { id: "subscr_00000000-0000-7000-8000-0000000000e3", persisted: "db" },
214
- });
143
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
215
144
  delete process.env.AILK_VISITOR_FORWARD_KEY;
216
145
 
217
146
  await trustedCallerAddress.run("203.0.113.9", () =>