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
@@ -2,12 +2,19 @@
2
2
  * Tests for the read_content MCP tool handler.
3
3
  *
4
4
  * Acceptance criteria verified here:
5
- * - readContentInputSchema validates correct inputs (type + slug)
6
- * - readContentInputSchema rejects unknown type values and malformed slugs
7
- * - ailkReadContent returns { slug, type, frontmatter, body } for a known slug
8
- * - ailkReadContent returns null (not throws) for an unknown slug
9
- * - Output validates against readContentOutputSchema
5
+ * - contentPageParamsSchema validates correct inputs (type + slug)
6
+ * - contentPageParamsSchema rejects unknown type values and malformed slugs,
7
+ * a leading or trailing hyphen included (the route's grammar)
8
+ * - ailkReadContent returns the route's document — the page's JSON-LD at the
9
+ * root, `page`, and `body` only with `include: "body"` — for a known slug
10
+ * - ailkReadContent returns the not-found body (not throws) for an unknown slug
11
+ * - Output validates against contentPageResultSchema
10
12
  * - No console.* calls in handler (structural test)
13
+ *
14
+ * #7169 (the operator's decision of 2026-10-19): the tool adopted its route's
15
+ * arguments (`include`) and result (the document, not
16
+ * `{ slug, type, frontmatter, body } | null`), so the cases below read
17
+ * `page`/`body` and expect the not-found body where they expected `null`.
11
18
  * - #6030 AC-1: BlogPosting and DocsDetail — the two types this issue admits —
12
19
  * are readable through the adapter on both the MDX leg and the database
13
20
  * leg, and both are asserted against the shared adapter seam
@@ -26,9 +33,11 @@ import {
26
33
 
27
34
  import {
28
35
  ailkReadContent,
29
- readContentInputSchema,
30
- readContentOutputSchema,
36
+ contentPageParamsSchema,
37
+ contentPageResultSchema,
38
+ type ContentPageResult,
31
39
  } from "../src/tools/read_content.js";
40
+ import { notFoundError } from "../src/lib/errors.js";
32
41
  import {
33
42
  __setMcpPublicWorkspaceForTest,
34
43
  resetMcpPublicWorkspace,
@@ -38,6 +47,19 @@ const REPO_ROOT = resolve(__dirname, "../../..");
38
47
  const CONTENT_ROOT = join(REPO_ROOT, "content");
39
48
  const WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
40
49
 
50
+ /** The body a missing page answers — the route's 404 message, the MCP code. */
51
+ const NOT_FOUND = notFoundError("Page not found");
52
+
53
+ /** Narrows a tool answer to the document, failing on the not-found body. */
54
+ function document(
55
+ result: Awaited<ReturnType<typeof ailkReadContent>>,
56
+ ): ContentPageResult {
57
+ if (!("page" in result)) {
58
+ throw new Error(`read_content answered ${JSON.stringify(result)}`);
59
+ }
60
+ return result;
61
+ }
62
+
41
63
  /**
42
64
  * A structural `ArtifactPrismaClient` stub (#5876 spec D11) — `artifact` and
43
65
  * `artifactVersion` jest.fn()s, injected directly, no database.
@@ -86,9 +108,9 @@ function makeArtifactPrismaClientStub(overrides: {
86
108
  };
87
109
  }
88
110
 
89
- describe("readContentInputSchema", () => {
111
+ describe("contentPageParamsSchema", () => {
90
112
  it("accepts a valid type + slug", () => {
91
- const result = readContentInputSchema.safeParse({
113
+ const result = contentPageParamsSchema.safeParse({
92
114
  type: "HomePage",
93
115
  slug: "home",
94
116
  });
@@ -96,7 +118,7 @@ describe("readContentInputSchema", () => {
96
118
  });
97
119
 
98
120
  it("accepts a HomePage slug", () => {
99
- const result = readContentInputSchema.safeParse({
121
+ const result = contentPageParamsSchema.safeParse({
100
122
  type: "HomePage",
101
123
  slug: "home",
102
124
  });
@@ -104,19 +126,19 @@ describe("readContentInputSchema", () => {
104
126
  });
105
127
 
106
128
  it("rejects missing slug", () => {
107
- const result = readContentInputSchema.safeParse({ type: "HomePage" });
129
+ const result = contentPageParamsSchema.safeParse({ type: "HomePage" });
108
130
  expect(result.success).toBe(false);
109
131
  });
110
132
 
111
133
  it("rejects missing type", () => {
112
- const result = readContentInputSchema.safeParse({
134
+ const result = contentPageParamsSchema.safeParse({
113
135
  slug: "launching-ailk",
114
136
  });
115
137
  expect(result.success).toBe(false);
116
138
  });
117
139
 
118
140
  it("rejects an unknown type", () => {
119
- const result = readContentInputSchema.safeParse({
141
+ const result = contentPageParamsSchema.safeParse({
120
142
  type: "NotAType",
121
143
  slug: "some-slug",
122
144
  });
@@ -124,7 +146,7 @@ describe("readContentInputSchema", () => {
124
146
  });
125
147
 
126
148
  it("rejects empty slug", () => {
127
- const result = readContentInputSchema.safeParse({
149
+ const result = contentPageParamsSchema.safeParse({
128
150
  type: "HomePage",
129
151
  slug: "",
130
152
  });
@@ -132,7 +154,7 @@ describe("readContentInputSchema", () => {
132
154
  });
133
155
 
134
156
  it("rejects slug with path traversal characters", () => {
135
- const result = readContentInputSchema.safeParse({
157
+ const result = contentPageParamsSchema.safeParse({
136
158
  type: "HomePage",
137
159
  slug: "../etc/passwd",
138
160
  });
@@ -140,68 +162,84 @@ describe("readContentInputSchema", () => {
140
162
  });
141
163
 
142
164
  it("rejects slug with forward slashes", () => {
143
- const result = readContentInputSchema.safeParse({
165
+ const result = contentPageParamsSchema.safeParse({
144
166
  type: "HomePage",
145
167
  slug: "some/path",
146
168
  });
147
169
  expect(result.success).toBe(false);
148
170
  });
171
+
172
+ // The route's grammar (`SLUG_PATTERN`): no leading or trailing hyphen —
173
+ // refused here too since #7169 (the tool adopted the API's arguments).
174
+ it.each(["-a", "a-", "-"])("rejects slug %p (leading or trailing hyphen)", (slug) => {
175
+ const result = contentPageParamsSchema.safeParse({ type: "HomePage", slug });
176
+ expect(result.success).toBe(false);
177
+ });
178
+
179
+ it("accepts an interior hyphen", () => {
180
+ const result = contentPageParamsSchema.safeParse({
181
+ type: "HomePage",
182
+ slug: "a-b",
183
+ });
184
+ expect(result.success).toBe(true);
185
+ });
149
186
  });
150
187
 
151
188
  describe("ailkReadContent handler", () => {
152
- it("returns slug, type, frontmatter, body for a known HomePage", async () => {
153
- const result = await ailkReadContent(
154
- { type: "HomePage", slug: "home" },
155
- CONTENT_ROOT,
189
+ it("returns the HomePage document — the @graph envelope and page, no body without include", async () => {
190
+ const result = document(
191
+ await ailkReadContent({ type: "HomePage", slug: "home" }, CONTENT_ROOT),
156
192
  );
157
193
 
158
- expect(result).not.toBeNull();
159
- expect(result!.slug).toBe("home");
160
- expect(result!.type).toBe("HomePage");
161
- expect(typeof result!.frontmatter).toBe("object");
162
- expect(typeof result!.body).toBe("string");
194
+ expect(result["@context"]).toBe("https://schema.org");
195
+ expect(Array.isArray(result["@graph"])).toBe(true);
196
+ expect(result.page.slug).toBe("home");
197
+ expect(result.page.type).toBe("HomePage");
198
+ expect(result).not.toHaveProperty("body");
199
+ });
200
+
201
+ it("attaches the raw MDX body only with include: body (#5890)", async () => {
202
+ const result = document(
203
+ await ailkReadContent(
204
+ { type: "HomePage", slug: "home", include: "body" },
205
+ CONTENT_ROOT,
206
+ ),
207
+ );
208
+ expect(result.page.slug).toBe("home");
209
+ expect(typeof result.body).toBe("string");
163
210
  });
164
211
 
165
- it("returns null (not throws) for an unknown slug", async () => {
212
+ it("returns the not-found body (not throws) for an unknown slug", async () => {
166
213
  const result = await ailkReadContent(
167
214
  { type: "HomePage", slug: "does-not-exist" },
168
215
  CONTENT_ROOT,
169
216
  );
170
- expect(result).toBeNull();
217
+ expect(result).toEqual(NOT_FOUND);
171
218
  });
172
219
 
173
- it("returns null for a slug that exists in a different type", async () => {
220
+ it("returns the not-found body for a slug that exists in a different type", async () => {
174
221
  // home.mdx exists under HomePage, not AboutPage
175
222
  const result = await ailkReadContent(
176
223
  { type: "AboutPage", slug: "home" },
177
224
  CONTENT_ROOT,
178
225
  );
179
- expect(result).toBeNull();
226
+ expect(result).toEqual(NOT_FOUND);
180
227
  });
181
228
 
182
- it("returns null for a valid type with no files when given an unknown slug", async () => {
229
+ it("returns the not-found body for a valid type with no files when given an unknown slug", async () => {
183
230
  const result = await ailkReadContent(
184
231
  { type: "FAQPage", slug: "nonexistent" },
185
232
  CONTENT_ROOT,
186
233
  );
187
- expect(result).toBeNull();
188
- });
189
-
190
- it("output shape validates against readContentOutputSchema when non-null", async () => {
191
- const result = await ailkReadContent(
192
- { type: "HomePage", slug: "home" },
193
- CONTENT_ROOT,
194
- );
195
- const parsed = readContentOutputSchema.safeParse(result);
196
- expect(parsed.success).toBe(true);
234
+ expect(result).toEqual(NOT_FOUND);
197
235
  });
198
236
 
199
- it("null validates against readContentOutputSchema", async () => {
237
+ it("output shape validates against contentPageResultSchema", async () => {
200
238
  const result = await ailkReadContent(
201
- { type: "HomePage", slug: "does-not-exist" },
239
+ { type: "HomePage", slug: "home", include: "body" },
202
240
  CONTENT_ROOT,
203
241
  );
204
- const parsed = readContentOutputSchema.safeParse(result);
242
+ const parsed = contentPageResultSchema.safeParse(result);
205
243
  expect(parsed.success).toBe(true);
206
244
  });
207
245
  });
@@ -225,7 +263,7 @@ describe("ailkReadContent — CONTENT_ADAPTER=database (#5876)", () => {
225
263
  resetResolvedContentAdapter();
226
264
  });
227
265
 
228
- it("AC-1: returns { slug, type, frontmatter, body } for a published page", async () => {
266
+ it("AC-1: returns the document with page and body for a published page", async () => {
229
267
  const stub = makeArtifactPrismaClientStub({
230
268
  artifact: { publishedVersionId: "version_1" },
231
269
  version: {
@@ -244,12 +282,13 @@ describe("ailkReadContent — CONTENT_ADAPTER=database (#5876)", () => {
244
282
  prisma: stub,
245
283
  });
246
284
 
247
- const result = await ailkReadContent({ type: "HomePage", slug: "home" });
285
+ const result = document(
286
+ await ailkReadContent({ type: "HomePage", slug: "home", include: "body" }),
287
+ );
248
288
 
249
- expect(result).not.toBeNull();
250
- expect(result!.slug).toBe("home");
251
- expect(result!.type).toBe("HomePage");
252
- expect(result!.body).toBe("# Hello");
289
+ expect(result.page.slug).toBe("home");
290
+ expect(result.page.type).toBe("HomePage");
291
+ expect(result.body).toBe("# Hello");
253
292
  // The reach test (§5.2 AC-1) — the read is bound to the resolved workspace.
254
293
  expect(stub.artifact.findFirst).toHaveBeenCalledWith(
255
294
  expect.objectContaining({
@@ -258,7 +297,7 @@ describe("ailkReadContent — CONTENT_ADAPTER=database (#5876)", () => {
258
297
  );
259
298
  });
260
299
 
261
- it("AC-1: returns null for a draft-only artifact (no published version)", async () => {
300
+ it("AC-1: returns the not-found body for a draft-only artifact (no published version)", async () => {
262
301
  const stub = makeArtifactPrismaClientStub({
263
302
  artifact: { publishedVersionId: null },
264
303
  });
@@ -270,10 +309,10 @@ describe("ailkReadContent — CONTENT_ADAPTER=database (#5876)", () => {
270
309
 
271
310
  const result = await ailkReadContent({ type: "HomePage", slug: "home" });
272
311
 
273
- expect(result).toBeNull();
312
+ expect(result).toEqual(NOT_FOUND);
274
313
  });
275
314
 
276
- it("AC-1: returns null for an unknown slug", async () => {
315
+ it("AC-1: returns the not-found body for an unknown slug", async () => {
277
316
  const stub = makeArtifactPrismaClientStub({ artifact: null });
278
317
  __setMcpPublicWorkspaceForTest({
279
318
  kind: "resolved",
@@ -286,16 +325,16 @@ describe("ailkReadContent — CONTENT_ADAPTER=database (#5876)", () => {
286
325
  slug: "does-not-exist",
287
326
  });
288
327
 
289
- expect(result).toBeNull();
328
+ expect(result).toEqual(NOT_FOUND);
290
329
  });
291
330
 
292
- it("AC-2: returns null when the workspace is unresolved, with no content lookup", async () => {
331
+ it("AC-2: returns the not-found body when the workspace is unresolved, with no content lookup", async () => {
293
332
  const stub = makeArtifactPrismaClientStub({});
294
333
  __setMcpPublicWorkspaceForTest({ kind: "unresolved", reason: "ambiguous" });
295
334
 
296
335
  const result = await ailkReadContent({ type: "HomePage", slug: "home" });
297
336
 
298
- expect(result).toBeNull();
337
+ expect(result).toEqual(NOT_FOUND);
299
338
  expect(stub.artifact.findFirst).not.toHaveBeenCalled();
300
339
  });
301
340
  });
@@ -334,10 +373,12 @@ describe("ailkReadContent — BlogPosting and DocsDetail, MDX leg (#6030 AC-1)",
334
373
  });
335
374
 
336
375
  it.each(["BlogPosting", "DocsDetail"] as const)(
337
- "returns the page for %s, deep-equal to the projection over getPageSource (D3)",
376
+ "returns the page for %s, with page and body equal to what getPageSource returns (D3)",
338
377
  async (type) => {
339
378
  const slug = type === "BlogPosting" ? "launch-post" : "getting-started";
340
- const result = await ailkReadContent({ type, slug, locale: "en" }, root);
379
+ const result = document(
380
+ await ailkReadContent({ type, slug, locale: "en", include: "body" }, root),
381
+ );
341
382
 
342
383
  // The shared seam D3 asserts from both ends: read_content's payload is
343
384
  // derived from exactly what ContentAdapter.getPageSource(type, slug,
@@ -345,27 +386,22 @@ describe("ailkReadContent — BlogPosting and DocsDetail, MDX leg (#6030 AC-1)",
345
386
  const adapter = createMdxAdapter({ contentRoot: root });
346
387
  const source = await adapter.getPageSource(type, slug, "en");
347
388
  expect(source).not.toBeNull();
348
- const expected = readContentOutputSchema.parse({
349
- slug: source!.frontmatter.slug ?? slug,
350
- type: source!.frontmatter.type ?? type,
351
- frontmatter: source!.frontmatter,
352
- body: source!.body,
353
- });
354
389
 
355
- expect(result).toEqual(expected);
356
- expect(result!.slug).toBe(slug);
357
- expect(result!.type).toBe(type);
390
+ expect(result.page).toEqual(source!.frontmatter);
391
+ expect(result.body).toBe(source!.body);
392
+ expect(result.page.slug).toBe(slug);
393
+ expect(result.page.type).toBe(type);
358
394
  },
359
395
  );
360
396
 
361
397
  it.each(["BlogPosting", "DocsDetail"] as const)(
362
- "returns null for an absent slug of %s",
398
+ "returns the not-found body for an absent slug of %s",
363
399
  async (type) => {
364
400
  const result = await ailkReadContent(
365
401
  { type, slug: "does-not-exist", locale: "en" },
366
402
  root,
367
403
  );
368
- expect(result).toBeNull();
404
+ expect(result).toEqual(NOT_FOUND);
369
405
  },
370
406
  );
371
407
  });
@@ -386,7 +422,7 @@ describe("ailkReadContent — BlogPosting and DocsDetail, CONTENT_ADAPTER=databa
386
422
  });
387
423
 
388
424
  it.each(["BlogPosting", "DocsDetail"] as const)(
389
- "returns { slug, type, frontmatter, body } for a published %s",
425
+ "returns the document with page and body for a published %s",
390
426
  async (type) => {
391
427
  const slug = "probe";
392
428
  const stub = makeArtifactPrismaClientStub({
@@ -407,19 +443,20 @@ describe("ailkReadContent — BlogPosting and DocsDetail, CONTENT_ADAPTER=databa
407
443
  prisma: stub,
408
444
  });
409
445
 
410
- const result = await ailkReadContent({ type, slug });
446
+ const result = document(
447
+ await ailkReadContent({ type, slug, include: "body" }),
448
+ );
411
449
 
412
- expect(result).not.toBeNull();
413
- expect(result!.slug).toBe(slug);
414
- expect(result!.type).toBe(type);
415
- expect(result!.body).toBe("# Published body");
450
+ expect(result.page.slug).toBe(slug);
451
+ expect(result.page.type).toBe(type);
452
+ expect(result.body).toBe("# Published body");
416
453
  },
417
454
  );
418
455
 
419
456
  // D4 — the publication guard must hold for every newly-admitted type, not
420
457
  // only HomePage's existing case.
421
458
  it.each(["BlogPosting", "DocsDetail"] as const)(
422
- "returns null for a draft-only %s artifact (no published version)",
459
+ "returns the not-found body for a draft-only %s artifact (no published version)",
423
460
  async (type) => {
424
461
  const stub = makeArtifactPrismaClientStub({
425
462
  artifact: { publishedVersionId: null },
@@ -432,13 +469,13 @@ describe("ailkReadContent — BlogPosting and DocsDetail, CONTENT_ADAPTER=databa
432
469
 
433
470
  const result = await ailkReadContent({ type, slug: "probe" });
434
471
 
435
- expect(result).toBeNull();
472
+ expect(result).toEqual(NOT_FOUND);
436
473
  },
437
474
  );
438
475
 
439
476
  // D4 — type-as-address: a page stored under a different frontmatter.type
440
477
  // than requested reads as absent, never as a cross-type hit.
441
- it("returns null when the requested type does not match the stored frontmatter.type", async () => {
478
+ it("returns the not-found body when the requested type does not match the stored frontmatter.type", async () => {
442
479
  const stub = makeArtifactPrismaClientStub({
443
480
  artifact: { publishedVersionId: "version_1" },
444
481
  version: {
@@ -459,7 +496,7 @@ describe("ailkReadContent — BlogPosting and DocsDetail, CONTENT_ADAPTER=databa
459
496
 
460
497
  const result = await ailkReadContent({ type: "DocsDetail", slug: "probe" });
461
498
 
462
- expect(result).toBeNull();
499
+ expect(result).toEqual(NOT_FOUND);
463
500
  });
464
501
  });
465
502
 
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Tests for the request_callback MCP tool handler.
3
3
  *
4
- * Acceptance criteria verified here:
4
+ * The tool takes the body `POST /v1/leads/callback` takes — `{ name, phone,
5
+ * preferredTime?, payload? }` — and returns the route's 201 body (#7173
6
+ * AC-11 — the tool 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.callback, 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.callback —
9
+ * no `siteId`, whatever AILK_SITE_ID says, no stamped `payload: {}` — and
10
+ * 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,147 +31,89 @@ 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: { callback: jest.Mock } };
35
37
  };
36
38
 
37
- const VALID_INPUT = {
38
- name: "Jane Smith",
39
- phone: "+15555550100",
39
+ /** The route body, as an agent sends it — the two required fields alone. */
40
+ const VALID_INPUT = { name: "Jane Smith", phone: "+15555550100" };
41
+
42
+ /** The route's 201 body, as the api-client hands it back. */
43
+ const WIRE = {
44
+ id: "fallback-1700000000000-cb1",
45
+ persisted: "file",
46
+ email: { ok: false, error: "provider refused" },
40
47
  };
41
48
 
42
49
  beforeEach(() => {
43
50
  jest.clearAllMocks();
51
+ delete process.env.AILK_SITE_ID;
44
52
  });
45
53
 
46
54
  describe("ailkRequestCallback — input validation", () => {
47
- it("rejects malformed input (missing required fields) without calling api-client", async () => {
48
- const result = await ailkRequestCallback({} as never);
49
-
50
- expect(result.ok).toBe(false);
51
- if (!result.ok) {
52
- expect(result.error.kind).toBe("invalid_input");
53
- if (result.error.kind === "invalid_input") {
54
- expect(Array.isArray(result.error.issues)).toBe(true);
55
- expect(result.error.issues.length).toBeGreaterThan(0);
56
- }
55
+ it.each([
56
+ ["missing required fields", {}],
57
+ ["a phone under the route's bound", { name: "Jane", phone: "12" }],
58
+ ])("rejects %s without calling api-client", async (_label, input) => {
59
+ const result = await ailkRequestCallback(input as never);
60
+
61
+ expect(result).toMatchObject({ ok: false, error: { kind: "invalid_input" } });
62
+ if (!result.ok && result.error.kind === "invalid_input") {
63
+ expect(result.error.issues.length).toBeGreaterThan(0);
57
64
  }
58
65
  expect(apiClient.leads.callback).not.toHaveBeenCalled();
59
66
  });
60
67
  });
61
68
 
62
69
  describe("ailkRequestCallback — happy path", () => {
63
- it("forwards canonical body to api-client and returns ok:true with id and persisted", async () => {
64
- apiClient.leads.callback.mockResolvedValueOnce({
65
- ok: true,
66
- body: { id: "fallback-1700000000000-cb1", persisted: "file" },
67
- });
70
+ it("posts exactly the parsed body — no stamped payload — and returns the route's body unchanged, email included", async () => {
71
+ apiClient.leads.callback.mockResolvedValueOnce({ ok: true, body: WIRE });
68
72
 
69
73
  const result = await ailkRequestCallback(VALID_INPUT);
70
74
 
71
- expect(apiClient.leads.callback).toHaveBeenCalledWith({
72
- siteId: "default",
73
- name: "Jane Smith",
74
- phone: "+15555550100",
75
- payload: {},
76
- });
77
- expect(result.ok).toBe(true);
78
- if (result.ok) {
79
- expect(result.id).toBe("fallback-1700000000000-cb1");
80
- expect(result.persisted).toBe("file");
81
- }
82
- });
83
- });
84
-
85
- describe("ailkRequestCallback — API error mapping", () => {
86
- it("maps API 400 to { ok: false, error: { kind: 'api_error', status: 400 } }", async () => {
87
- apiClient.leads.callback.mockResolvedValueOnce({
88
- ok: false,
89
- status: 400,
90
- body: { error: "bad_request" },
91
- });
92
-
93
- const result = await ailkRequestCallback(VALID_INPUT);
94
-
95
- expect(result.ok).toBe(false);
96
- if (!result.ok) {
97
- expect(result.error.kind).toBe("api_error");
98
- if (result.error.kind === "api_error") {
99
- expect(result.error.status).toBe(400);
100
- }
101
- }
102
- });
103
-
104
- it("maps API 503 to { ok: false, error: { kind: 'api_error', status: 503 } }", async () => {
105
- apiClient.leads.callback.mockResolvedValueOnce({
106
- ok: false,
107
- status: 503,
108
- body: { error: "service_unavailable" },
109
- });
110
-
111
- const result = await ailkRequestCallback(VALID_INPUT);
112
-
113
- expect(result.ok).toBe(false);
114
- if (!result.ok) {
115
- expect(result.error.kind).toBe("api_error");
116
- if (result.error.kind === "api_error") {
117
- expect(result.error.status).toBe(503);
118
- }
119
- }
75
+ expect(apiClient.leads.callback).toHaveBeenCalledWith(VALID_INPUT);
76
+ expect(result).toEqual(WIRE);
120
77
  });
121
78
 
122
- it("maps API 429 to { ok: false, error: { kind: 'api_error', status: 429 } }", async () => {
123
- apiClient.leads.callback.mockResolvedValueOnce({
124
- ok: false,
125
- status: 429,
126
- body: { error: "rate_limited" },
127
- });
79
+ it("passes preferredTime and payload through as sent", async () => {
80
+ apiClient.leads.callback.mockResolvedValueOnce({ ok: true, body: WIRE });
81
+ const full = {
82
+ ...VALID_INPUT,
83
+ preferredTime: "Tuesday afternoon",
84
+ payload: { notes: "Roof estimate" },
85
+ };
128
86
 
129
- const result = await ailkRequestCallback(VALID_INPUT);
87
+ await ailkRequestCallback(full);
130
88
 
131
- expect(result.ok).toBe(false);
132
- if (!result.ok) {
133
- expect(result.error.kind).toBe("api_error");
134
- if (result.error.kind === "api_error") {
135
- expect(result.error.status).toBe(429);
136
- }
137
- }
89
+ expect(apiClient.leads.callback).toHaveBeenCalledWith(full);
138
90
  });
139
- });
140
91
 
141
- describe("ailkRequestCallback — siteId default", () => {
142
- it("uses siteId 'default' when AILK_SITE_ID is unset", async () => {
143
- delete process.env.AILK_SITE_ID;
144
- apiClient.leads.callback.mockResolvedValueOnce({
145
- ok: true,
146
- body: { id: "fallback-1700000000000-cb2", persisted: "file" },
147
- });
92
+ it("never sends a siteId, whatever AILK_SITE_ID says, and strips one the agent sent", async () => {
93
+ process.env.AILK_SITE_ID = "site-abc";
94
+ apiClient.leads.callback.mockResolvedValueOnce({ ok: true, body: WIRE });
148
95
 
149
- await ailkRequestCallback(VALID_INPUT);
96
+ await ailkRequestCallback({ ...VALID_INPUT, siteId: "site-xyz" } as never);
150
97
 
151
- const call = apiClient.leads.callback.mock.calls[0][0] as { siteId: string };
152
- expect(call.siteId).toBe("default");
98
+ expect(apiClient.leads.callback.mock.calls[0][0]).toEqual(VALID_INPUT);
153
99
  });
154
100
  });
155
101
 
156
- describe("ailkRequestCallback — siteId override", () => {
157
- afterEach(() => {
158
- delete process.env.AILK_SITE_ID;
159
- });
160
-
161
- it("uses AILK_SITE_ID env var when set", async () => {
162
- process.env.AILK_SITE_ID = "site-abc";
163
- apiClient.leads.callback.mockResolvedValueOnce({
164
- ok: true,
165
- body: { id: "fallback-1700000000000-cb3", persisted: "file" },
166
- });
167
-
168
- await ailkRequestCallback(VALID_INPUT);
169
-
170
- const call = apiClient.leads.callback.mock.calls[0][0] as { siteId: string };
171
- expect(call.siteId).toBe("site-abc");
172
- });
102
+ describe("ailkRequestCallback — API error mapping", () => {
103
+ it.each([400, 503, 429])(
104
+ "maps API %i to { ok: false, error: { kind: 'api_error', status } }",
105
+ async (status) => {
106
+ apiClient.leads.callback.mockResolvedValueOnce({
107
+ ok: false,
108
+ status,
109
+ body: { error: "refused" },
110
+ });
111
+
112
+ const result = await ailkRequestCallback(VALID_INPUT);
113
+
114
+ expect(result).toEqual({ ok: false, error: { kind: "api_error", status } });
115
+ },
116
+ );
173
117
  });
174
118
 
175
119
  describe("ailkRequestCallback — visitor-forwarding headers (#8073)", () => {
@@ -178,10 +122,7 @@ describe("ailkRequestCallback — visitor-forwarding headers (#8073)", () => {
178
122
  });
179
123
 
180
124
  it("calls api-client with exactly one argument when there is no trusted address", async () => {
181
- apiClient.leads.callback.mockResolvedValueOnce({
182
- ok: true,
183
- body: { id: "fallback-1700000000000-e1", persisted: "file" },
184
- });
125
+ apiClient.leads.callback.mockResolvedValueOnce({ ok: true, body: WIRE });
185
126
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
186
127
 
187
128
  await ailkRequestCallback(VALID_INPUT);
@@ -190,10 +131,7 @@ describe("ailkRequestCallback — visitor-forwarding headers (#8073)", () => {
190
131
  });
191
132
 
192
133
  it("forwards the trusted address and the forward key as the two visitor headers", async () => {
193
- apiClient.leads.callback.mockResolvedValueOnce({
194
- ok: true,
195
- body: { id: "fallback-1700000000000-e2", persisted: "file" },
196
- });
134
+ apiClient.leads.callback.mockResolvedValueOnce({ ok: true, body: WIRE });
197
135
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
198
136
 
199
137
  await trustedCallerAddress.run("203.0.113.9", () =>
@@ -210,10 +148,7 @@ describe("ailkRequestCallback — visitor-forwarding headers (#8073)", () => {
210
148
  });
211
149
 
212
150
  it("sends no headers when there is a trusted address but no forward key", async () => {
213
- apiClient.leads.callback.mockResolvedValueOnce({
214
- ok: true,
215
- body: { id: "fallback-1700000000000-e3", persisted: "file" },
216
- });
151
+ apiClient.leads.callback.mockResolvedValueOnce({ ok: true, body: WIRE });
217
152
  delete process.env.AILK_VISITOR_FORWARD_KEY;
218
153
 
219
154
  await trustedCallerAddress.run("203.0.113.9", () =>