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,15 +2,19 @@
2
2
  * Tests for the search_content MCP tool handler.
3
3
  *
4
4
  * Acceptance criteria verified here:
5
- * - searchContentInputSchema validates correct inputs (query required, type + limit optional)
6
- * - searchContentInputSchema rejects query longer than 200 chars
7
- * - searchContentInputSchema rejects limit > 50
8
- * - searchContentInputSchema rejects empty query
9
- * - ailkSearchContent happy path returns results array with correct shape
10
- * - ailkSearchContent returns empty results for a query with no matches
5
+ * - contentSearchQuerySchema validates correct inputs (q required, type + limit optional)
6
+ * - contentSearchQuerySchema rejects q longer than 200 chars
7
+ * - contentSearchQuerySchema rejects limit > 50
8
+ * - contentSearchQuerySchema rejects empty q
9
+ * - ailkSearchContent happy path returns the route's ItemList with items of the correct shape
10
+ * - ailkSearchContent returns an empty ItemList for a query with no matches
11
11
  * - Malformed input causes Zod rejection
12
- * - #5831: searchContentInputSchema accepts a registry category slug and
12
+ * - #5831: contentSearchQuerySchema accepts a registry category slug and
13
13
  * rejects one outside the registry; the handler forwards it to the adapter
14
+ *
15
+ * #7167 (the operator's decision of 2026-10-19): the tool adopted its route's
16
+ * arguments (`q`, not `query`) and result (the `ItemList`, not `{ results }`),
17
+ * so the cases below read the new names.
14
18
  */
15
19
 
16
20
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -19,102 +23,108 @@ import { join, resolve } from "node:path";
19
23
 
20
24
  import {
21
25
  ailkSearchContent,
22
- searchContentInputSchema,
23
- searchContentOutputSchema,
26
+ contentSearchQuerySchema,
27
+ contentSearchResultSchema,
24
28
  } from "../src/tools/search_content.js";
25
29
 
26
30
  const REPO_ROOT = resolve(__dirname, "../../..");
27
31
  const CONTENT_ROOT = `${REPO_ROOT}/content`;
28
32
 
29
- describe("searchContentInputSchema", () => {
30
- it("accepts a valid query", () => {
31
- const result = searchContentInputSchema.safeParse({ query: "hello" });
33
+ describe("contentSearchQuerySchema", () => {
34
+ it("accepts a valid q", () => {
35
+ const result = contentSearchQuerySchema.safeParse({ q: "hello" });
32
36
  expect(result.success).toBe(true);
33
37
  });
34
38
 
35
- it("accepts query with optional type filter", () => {
36
- const result = searchContentInputSchema.safeParse({
37
- query: "hello",
39
+ it("accepts q with optional type filter", () => {
40
+ const result = contentSearchQuerySchema.safeParse({
41
+ q: "hello",
38
42
  type: "HomePage",
39
43
  });
40
44
  expect(result.success).toBe(true);
41
45
  });
42
46
 
43
- it("accepts query with optional limit", () => {
44
- const result = searchContentInputSchema.safeParse({
45
- query: "hello",
47
+ it("accepts q with optional limit", () => {
48
+ const result = contentSearchQuerySchema.safeParse({
49
+ q: "hello",
46
50
  limit: 10,
47
51
  });
48
52
  expect(result.success).toBe(true);
49
53
  });
50
54
 
51
- it("rejects an empty query", () => {
52
- const result = searchContentInputSchema.safeParse({ query: "" });
55
+ it("rejects an empty q", () => {
56
+ const result = contentSearchQuerySchema.safeParse({ q: "" });
53
57
  expect(result.success).toBe(false);
54
58
  });
55
59
 
56
- it("rejects a query longer than 200 chars", () => {
57
- const result = searchContentInputSchema.safeParse({
58
- query: "a".repeat(201),
60
+ it("rejects a q longer than 200 chars", () => {
61
+ const result = contentSearchQuerySchema.safeParse({
62
+ q: "a".repeat(201),
59
63
  });
60
64
  expect(result.success).toBe(false);
61
65
  });
62
66
 
63
- it("accepts a query exactly 200 chars", () => {
64
- const result = searchContentInputSchema.safeParse({
65
- query: "a".repeat(200),
67
+ it("accepts a q exactly 200 chars", () => {
68
+ const result = contentSearchQuerySchema.safeParse({
69
+ q: "a".repeat(200),
66
70
  });
67
71
  expect(result.success).toBe(true);
68
72
  });
69
73
 
70
74
  it("rejects limit of 0", () => {
71
- const result = searchContentInputSchema.safeParse({
72
- query: "hello",
75
+ const result = contentSearchQuerySchema.safeParse({
76
+ q: "hello",
73
77
  limit: 0,
74
78
  });
75
79
  expect(result.success).toBe(false);
76
80
  });
77
81
 
78
82
  it("rejects limit > 50", () => {
79
- const result = searchContentInputSchema.safeParse({
80
- query: "hello",
83
+ const result = contentSearchQuerySchema.safeParse({
84
+ q: "hello",
81
85
  limit: 51,
82
86
  });
83
87
  expect(result.success).toBe(false);
84
88
  });
85
89
 
86
90
  it("accepts limit of 50", () => {
87
- const result = searchContentInputSchema.safeParse({
88
- query: "hello",
91
+ const result = contentSearchQuerySchema.safeParse({
92
+ q: "hello",
89
93
  limit: 50,
90
94
  });
91
95
  expect(result.success).toBe(true);
92
96
  });
93
97
 
98
+ it("coerces a string limit, as the route's query string parses (#7167 DD1)", () => {
99
+ const result = contentSearchQuerySchema.safeParse({ q: "hello", limit: "5" });
100
+ expect(result.success).toBe(true);
101
+ expect(result.data!.limit).toBe(5);
102
+ });
103
+
94
104
  it("rejects an unknown type string", () => {
95
- const result = searchContentInputSchema.safeParse({
96
- query: "hello",
105
+ const result = contentSearchQuerySchema.safeParse({
106
+ q: "hello",
97
107
  type: "NotAType",
98
108
  });
99
109
  expect(result.success).toBe(false);
100
110
  });
101
111
 
102
- it("rejects missing query (malformed input)", () => {
103
- const result = searchContentInputSchema.safeParse({});
112
+ it("rejects missing q (malformed input)", () => {
113
+ const result = contentSearchQuerySchema.safeParse({});
104
114
  expect(result.success).toBe(false);
105
115
  });
106
116
 
107
117
  it("accepts a category slug from the registry (#5831)", () => {
108
- const result = searchContentInputSchema.safeParse({
109
- query: "roof",
118
+ const result = contentSearchQuerySchema.safeParse({
119
+ q: "roof",
110
120
  category: "roofing",
111
121
  });
112
122
  expect(result.success).toBe(true);
113
123
  });
114
124
 
115
125
  it("rejects a category slug outside the registry with an enum error (#5831)", () => {
116
- const result = searchContentInputSchema.safeParse({
117
- query: "roof",
126
+ const result = contentSearchQuerySchema.safeParse({
127
+ q: "roof",
118
128
  category: "not-a-real-category",
119
129
  });
120
130
  expect(result.success).toBe(false);
@@ -125,8 +135,8 @@ describe("searchContentInputSchema", () => {
125
135
  });
126
136
 
127
137
  it("rejects a GROUP slug — groups are derived, never tagged (#5831)", () => {
128
- const result = searchContentInputSchema.safeParse({
129
- query: "roof",
138
+ const result = contentSearchQuerySchema.safeParse({
139
+ q: "roof",
130
140
  category: "professional-services",
131
141
  });
132
142
  expect(result.success).toBe(false);
@@ -182,15 +192,15 @@ describe("ailkSearchContent — category filter (#5831)", () => {
182
192
 
183
193
  it("forwards category to the adapter — only the tagged page comes back", async () => {
184
194
  const result = await ailkSearchContent(
185
- { query: "roof", category: "roofing" },
195
+ { q: "roof", category: "roofing" },
186
196
  root,
187
197
  );
188
- expect(result.results.map((r) => r.slug)).toEqual(["roof-replacement"]);
198
+ expect(result.items.map((r) => r.slug)).toEqual(["roof-replacement"]);
189
199
  });
190
200
 
191
201
  it("returns every match when no category is passed", async () => {
192
- const result = await ailkSearchContent({ query: "roof" }, root);
193
- expect(result.results.map((r) => r.slug).sort()).toEqual([
202
+ const result = await ailkSearchContent({ q: "roof" }, root);
203
+ expect(result.items.map((r) => r.slug).sort()).toEqual([
194
204
  "attic-ventilation",
195
205
  "roof-replacement",
196
206
  ]);
@@ -198,22 +208,26 @@ describe("ailkSearchContent — category filter (#5831)", () => {
198
208
 
199
209
  it("returns [] for a registry slug nothing is tagged with", async () => {
200
210
  const result = await ailkSearchContent(
201
- { query: "roof", category: "legal" },
211
+ { q: "roof", category: "legal" },
202
212
  root,
203
213
  );
204
- expect(result.results).toEqual([]);
214
+ expect(result.items).toEqual([]);
205
215
  });
206
216
  });
207
217
 
208
218
  describe("ailkSearchContent handler", () => {
209
- it("returns a results array on a known query", async () => {
210
- const result = await ailkSearchContent({ query: "launch" }, CONTENT_ROOT);
211
- expect(Array.isArray(result.results)).toBe(true);
219
+ it("returns the route's ItemList on a known query", async () => {
220
+ const result = await ailkSearchContent({ q: "launch" }, CONTENT_ROOT);
221
+ expect(result["@context"]).toBe("https://schema.org");
222
+ expect(result["@type"]).toBe("ItemList");
223
+ expect(result.query).toBe("launch");
224
+ expect(Array.isArray(result.items)).toBe(true);
225
+ expect(result.totalItems).toBe(result.items.length);
212
226
  });
213
227
 
214
228
  it("result items have required shape fields", async () => {
215
- const result = await ailkSearchContent({ query: "launch" }, CONTENT_ROOT);
216
- for (const item of result.results) {
229
+ const result = await ailkSearchContent({ q: "launch" }, CONTENT_ROOT);
230
+ for (const item of result.items) {
217
231
  expect(typeof item.slug).toBe("string");
218
232
  expect(typeof item.type).toBe("string");
219
233
  expect(typeof item.title).toBe("string");
@@ -222,25 +236,26 @@ describe("ailkSearchContent handler", () => {
222
236
  }
223
237
  });
224
238
 
225
- it("returns empty results for a query that matches nothing", async () => {
239
+ it("returns an empty ItemList for a query that matches nothing", async () => {
226
240
  const result = await ailkSearchContent(
227
- { query: "xqzxqzxqzxqz_no_match_ever" },
241
+ { q: "xqzxqzxqzxqz_no_match_ever" },
228
242
  CONTENT_ROOT,
229
243
  );
230
- expect(result.results).toEqual([]);
244
+ expect(result.items).toEqual([]);
245
+ expect(result.totalItems).toBe(0);
231
246
  });
232
247
 
233
248
  it("respects limit option", async () => {
234
249
  const result = await ailkSearchContent(
235
- { query: "a", limit: 1 },
250
+ { q: "a", limit: 1 },
236
251
  CONTENT_ROOT,
237
252
  );
238
- expect(result.results.length).toBeLessThanOrEqual(1);
253
+ expect(result.items.length).toBeLessThanOrEqual(1);
239
254
  });
240
255
 
241
- it("output validates against searchContentOutputSchema", async () => {
242
- const result = await ailkSearchContent({ query: "launch" }, CONTENT_ROOT);
243
- const parsed = searchContentOutputSchema.safeParse(result);
256
+ it("output validates against contentSearchResultSchema", async () => {
257
+ const result = await ailkSearchContent({ q: "launch" }, CONTENT_ROOT);
258
+ const parsed = contentSearchResultSchema.safeParse(result);
244
259
  expect(parsed.success).toBe(true);
245
260
  });
246
261
  });
@@ -231,19 +231,21 @@ describe("create_project on a spoke — the seam's UNSERVED_MCP_TOOLS (#8070 D7,
231
231
  });
232
232
  });
233
233
 
234
- describe("the five licence-feature tools — one-release warning cycle (#7136)", () => {
234
+ describe("the six licence-feature tools — one-release warning cycle (#7136)", () => {
235
235
  const FLOW_PAIR = ["get_flow", "submit_flow_step"];
236
- const BOOKING_TRIO = [
236
+ const BOOKING_TOOLS = [
237
237
  "list_availability",
238
238
  "get_event_meta",
239
+ "list_meetings",
239
240
  "create_booking",
240
241
  ];
241
- const FIVE = [...FLOW_PAIR, ...BOOKING_TRIO];
242
+ const SIX = [...FLOW_PAIR, ...BOOKING_TOOLS];
242
243
  const FEATURE_OF: Record<string, string> = {
243
244
  get_flow: "flows-deliverables",
244
245
  submit_flow_step: "flows-deliverables",
245
246
  list_availability: "scheduling",
246
247
  get_event_meta: "scheduling",
248
+ list_meetings: "scheduling",
247
249
  create_booking: "scheduling",
248
250
  };
249
251
 
@@ -271,12 +273,12 @@ describe("the five licence-feature tools — one-release warning cycle (#7136)",
271
273
  return Object.keys((createMcpServer() as any)._registeredTools);
272
274
  }
273
275
 
274
- it("warn mode, no feature held: all five still register and serve", () => {
276
+ it("warn mode, no feature held: all six still register and serve", () => {
275
277
  __setMcpHeldFeaturesForTest([]);
276
278
 
277
279
  const listed = listRegisteredTools().map((t) => t.name);
278
280
  const registered = registeredKeys();
279
- for (const name of FIVE) {
281
+ for (const name of SIX) {
280
282
  expect(listed).toContain(name);
281
283
  expect(registered).toContain(name);
282
284
  }
@@ -288,9 +290,9 @@ describe("the five licence-feature tools — one-release warning cycle (#7136)",
288
290
 
289
291
  const names = reportUnlicensedProTools(warn);
290
292
 
291
- expect(names.slice().sort()).toEqual(FIVE.slice().sort());
292
- expect(warn).toHaveBeenCalledTimes(5);
293
- for (const name of FIVE) {
293
+ expect(names.slice().sort()).toEqual(SIX.slice().sort());
294
+ expect(warn).toHaveBeenCalledTimes(6);
295
+ for (const name of SIX) {
294
296
  const message = warn.mock.calls
295
297
  .map(([m]) => m as string)
296
298
  .find((m) => m.includes(`"${name}"`));
@@ -326,7 +328,7 @@ describe("the five licence-feature tools — one-release warning cycle (#7136)",
326
328
  expect(warn).toHaveBeenCalledTimes(2);
327
329
  });
328
330
 
329
- it("refuse mode, no feature held: none of the five registers, and nothing is warned", () => {
331
+ it("refuse mode, no feature held: none of the six registers, and nothing is warned", () => {
330
332
  __setMcpFeatureGateForTest("refuse");
331
333
  __setMcpHeldFeaturesForTest([]);
332
334
  const warn = jest.fn();
@@ -335,7 +337,7 @@ describe("the five licence-feature tools — one-release warning cycle (#7136)",
335
337
  expect(warn).not.toHaveBeenCalled();
336
338
  const listed = listRegisteredTools().map((t) => t.name);
337
339
  const registered = registeredKeys();
338
- for (const name of FIVE) {
340
+ for (const name of SIX) {
339
341
  expect(listed).not.toContain(name);
340
342
  expect(registered).not.toContain(name);
341
343
  }
@@ -346,7 +348,7 @@ describe("the five licence-feature tools — one-release warning cycle (#7136)",
346
348
  __setMcpHeldFeaturesForTest(["scheduling"]);
347
349
 
348
350
  const registered = registeredKeys();
349
- for (const name of BOOKING_TRIO) expect(registered).toContain(name);
351
+ for (const name of BOOKING_TOOLS) expect(registered).toContain(name);
350
352
  for (const name of FLOW_PAIR) expect(registered).not.toContain(name);
351
353
  });
352
354
 
@@ -32,6 +32,8 @@
32
32
  */
33
33
 
34
34
  import { checkout } from "@working-theory/api-client";
35
+ import { getMcpToolCatalog } from "@working-theory/metadata";
36
+ import { purchaseMcpInputSchema } from "@working-theory/validation";
35
37
 
36
38
  import {
37
39
  __setMcpCommerceClaimForTest,
@@ -72,13 +74,13 @@ describe("start_purchase — parity with the payload POST /v1/checkout/sessions
72
74
  createSession.mockResolvedValueOnce(sessionCreated());
73
75
 
74
76
  await ailkPurchase({
75
- productCode: "pro-plan",
77
+ productCode: "growth_monthly",
76
78
  quantity: 2,
77
79
  });
78
80
 
79
81
  expect(createSession).toHaveBeenCalledTimes(1);
80
82
  expect(createSession).toHaveBeenCalledWith({
81
- productCode: "pro-plan",
83
+ productCode: "growth_monthly",
82
84
  quantity: 2,
83
85
  successUrl:
84
86
  "https://example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}",
@@ -89,7 +91,7 @@ describe("start_purchase — parity with the payload POST /v1/checkout/sessions
89
91
  it("omits quantity entirely when it is not supplied", async () => {
90
92
  createSession.mockResolvedValueOnce(sessionCreated());
91
93
 
92
- await ailkPurchase({ productCode: "pro-plan" });
94
+ await ailkPurchase({ productCode: "growth_monthly" });
93
95
 
94
96
  const payload = createSession.mock.calls[0]![0];
95
97
  expect(payload).not.toHaveProperty("quantity");
@@ -99,10 +101,10 @@ describe("start_purchase — parity with the payload POST /v1/checkout/sessions
99
101
  it("derives both redirect URLs from AILK_BASE_URL — always, not only when omitted (#6001)", async () => {
100
102
  createSession.mockResolvedValueOnce(sessionCreated());
101
103
 
102
- await ailkPurchase({ productCode: "pro-plan" });
104
+ await ailkPurchase({ productCode: "growth_monthly" });
103
105
 
104
106
  expect(createSession).toHaveBeenCalledWith({
105
- productCode: "pro-plan",
107
+ productCode: "growth_monthly",
106
108
  successUrl:
107
109
  "https://example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}",
108
110
  cancelUrl: "https://example.com/purchase/cancel",
@@ -114,7 +116,7 @@ describe("start_purchase — FR-1: the hosted Checkout URL", () => {
114
116
  it("returns { url } deep-equal to the seam's url, and nothing else", async () => {
115
117
  createSession.mockResolvedValueOnce(sessionCreated());
116
118
 
117
- const result = await ailkPurchase({ productCode: "pro-plan" });
119
+ const result = await ailkPurchase({ productCode: "growth_monthly" });
118
120
 
119
121
  expect(result).toEqual({ url: HOSTED_URL });
120
122
  });
@@ -122,7 +124,7 @@ describe("start_purchase — FR-1: the hosted Checkout URL", () => {
122
124
  it("does NOT surface the route's sessionId (D5 — it identifies a buyer's session)", async () => {
123
125
  createSession.mockResolvedValueOnce(sessionCreated());
124
126
 
125
- const result = await ailkPurchase({ productCode: "pro-plan" });
127
+ const result = await ailkPurchase({ productCode: "growth_monthly" });
126
128
 
127
129
  expect(result).not.toHaveProperty("sessionId");
128
130
  });
@@ -130,7 +132,7 @@ describe("start_purchase — FR-1: the hosted Checkout URL", () => {
130
132
  it("the URL it returns is Stripe-hosted — nothing is charged in-tool", async () => {
131
133
  createSession.mockResolvedValueOnce(sessionCreated());
132
134
 
133
- const result = await ailkPurchase({ productCode: "pro-plan" });
135
+ const result = await ailkPurchase({ productCode: "growth_monthly" });
134
136
 
135
137
  expect((result as { url: string }).url).toMatch(
136
138
  /^https:\/\/checkout\.stripe\.com\//,
@@ -146,7 +148,7 @@ describe("start_purchase — FR-2: an unknown offering", () => {
146
148
  body: { error: "unknown-product-code" },
147
149
  });
148
150
 
149
- const result = await ailkPurchase({ productCode: "no-such-offering" });
151
+ const result = await ailkPurchase({ productCode: "no-such-offering_monthly" });
150
152
 
151
153
  // The HTTP twin states and tests "Error responses never echo user input";
152
154
  // parity means the pair answers alike (#5971 security review, Low).
@@ -160,7 +162,7 @@ describe("start_purchase — FR-2: an unknown offering", () => {
160
162
  body: { error: "unknown-product-code" },
161
163
  });
162
164
 
163
- const result = await ailkPurchase({ productCode: "no-such-offering" });
165
+ const result = await ailkPurchase({ productCode: "no-such-offering_monthly" });
164
166
 
165
167
  expect(result).toMatchObject({ error: "not_found" });
166
168
  });
@@ -172,7 +174,7 @@ describe("start_purchase — FR-2: an unknown offering", () => {
172
174
  body: { error: "unknown-product-code" },
173
175
  });
174
176
 
175
- await ailkPurchase({ productCode: "no-such-offering" });
177
+ await ailkPurchase({ productCode: "no-such-offering_monthly" });
176
178
 
177
179
  expect(createSession).toHaveBeenCalledTimes(1);
178
180
  });
@@ -184,7 +186,7 @@ describe("start_purchase — FR-2: an unknown offering", () => {
184
186
  body: { error: "checkout-unavailable" },
185
187
  });
186
188
 
187
- const result = await ailkPurchase({ productCode: "pro-plan" });
189
+ const result = await ailkPurchase({ productCode: "growth_monthly" });
188
190
 
189
191
  expect(result).toMatchObject({ error: "validation_failed" });
190
192
  });
@@ -250,3 +252,197 @@ describe("start_purchase — the redirect fix (#6001): a caller-supplied redirec
250
252
  expect(createSession).not.toHaveBeenCalled();
251
253
  });
252
254
  });
255
+
256
+ // ── #8935 — the full Offerings lookup_key ───────────────────────────────────
257
+ //
258
+ // The tool takes a price by its full lookup_key (contract §3.7) and refuses an
259
+ // ungrammatical one before any HTTP call, with the same not-found body every
260
+ // route 404 maps to (A3, A5). The route decides the workspace and whether the
261
+ // key is shown and org-enabled (A1, #8931); the mocked seam below answers with
262
+ // the route's Offerings-path `201` body, `reply.status(201).send({ sessionId,
263
+ // url })`. Spec: `docs/specs/8935-start-purchase-sells-a-named-spec.md`.
264
+
265
+ const OFFERINGS_URL = "https://checkout.stripe.com/c/pay/cs_test_offerings";
266
+
267
+ function offeringsSessionCreated() {
268
+ return {
269
+ ok: true as const,
270
+ body: { sessionId: "cs_test_offerings", url: OFFERINGS_URL },
271
+ };
272
+ }
273
+
274
+ const NOT_FOUND = {
275
+ error: "not_found",
276
+ message: "No offering matches that product code.",
277
+ };
278
+
279
+ describe("start_purchase — a shown, org-enabled lookup_key gets the route's hosted URL (#8935 AC-1)", () => {
280
+ const keys = [
281
+ "growth_monthly",
282
+ "growth_yearly",
283
+ "basic_once",
284
+ "samhenry-coaching_monthly",
285
+ `${"a".repeat(40)}_monthly`,
286
+ ];
287
+
288
+ it.each(keys)(
289
+ "%s reaches the route byte for byte and returns { url } only",
290
+ async (key) => {
291
+ createSession.mockResolvedValueOnce(offeringsSessionCreated());
292
+
293
+ const result = await ailkPurchase({ productCode: key });
294
+
295
+ expect(result).toEqual({ url: OFFERINGS_URL });
296
+ expect(result).not.toHaveProperty("sessionId");
297
+ expect(createSession).toHaveBeenCalledTimes(1);
298
+ // One argument, the payload — no init, so no header of the tool's own.
299
+ expect(createSession.mock.calls[0]).toHaveLength(1);
300
+ expect(createSession.mock.calls[0]![0]).toEqual({
301
+ productCode: key,
302
+ successUrl:
303
+ "https://example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}",
304
+ cancelUrl: "https://example.com/purchase/cancel",
305
+ });
306
+ },
307
+ );
308
+
309
+ it("the 48-character key is the longest §3.7 allows", () => {
310
+ expect(keys[4]).toHaveLength(48);
311
+ });
312
+
313
+ it("forwards quantity alongside the key", async () => {
314
+ createSession.mockResolvedValueOnce(offeringsSessionCreated());
315
+
316
+ await ailkPurchase({ productCode: "growth_monthly", quantity: 2 });
317
+
318
+ expect(createSession.mock.calls[0]![0]).toEqual({
319
+ productCode: "growth_monthly",
320
+ quantity: 2,
321
+ successUrl:
322
+ "https://example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}",
323
+ cancelUrl: "https://example.com/purchase/cancel",
324
+ });
325
+ });
326
+ });
327
+
328
+ describe("start_purchase — an ungrammatical key is refused before any call, with the route-404 body (#8935 AC-2)", () => {
329
+ const ungrammatical = [
330
+ "pro-plan",
331
+ "Growth_Monthly",
332
+ "growth_pro_monthly",
333
+ "wendi_retainer",
334
+ "growth_quarterly",
335
+ "growth_",
336
+ "_monthly",
337
+ `${"a".repeat(41)}_monthly`,
338
+ ];
339
+
340
+ it.each(ungrammatical)(
341
+ "%s passes the shared schema but answers not_found with no call and no echo",
342
+ async (key) => {
343
+ expect(purchaseMcpInputSchema.safeParse({ productCode: key }).success).toBe(
344
+ true,
345
+ );
346
+
347
+ const result = await ailkPurchase({ productCode: key });
348
+
349
+ expect(result).toEqual(NOT_FOUND);
350
+ expect(createSession).not.toHaveBeenCalled();
351
+ expect(JSON.stringify(result)).not.toContain(key);
352
+ },
353
+ );
354
+
355
+ it("the 49-character key is one past the §3.7 bound", () => {
356
+ expect(ungrammatical[7]).toHaveLength(49);
357
+ });
358
+
359
+ it.each([
360
+ ["{ error: unknown-product-code }", { error: "unknown-product-code" }],
361
+ ["null", null],
362
+ ["{}", {}],
363
+ ])(
364
+ "every route 404 maps to the same body, whatever the route's body (%s)",
365
+ async (_label, body) => {
366
+ createSession.mockResolvedValueOnce({
367
+ ok: false as const,
368
+ status: 404,
369
+ body,
370
+ } as never);
371
+
372
+ const result = await ailkPurchase({ productCode: "growth_monthly" });
373
+
374
+ expect(result).toEqual(NOT_FOUND);
375
+ expect(JSON.stringify(result)).not.toContain("growth_monthly");
376
+ },
377
+ );
378
+
379
+ it("the pre-call refusal and the route-404 mapping are one value", async () => {
380
+ const refused = await ailkPurchase({ productCode: "pro-plan" });
381
+ createSession.mockResolvedValueOnce({
382
+ ok: false as const,
383
+ status: 404,
384
+ body: { error: "unknown-product-code" },
385
+ });
386
+ const mapped = await ailkPurchase({ productCode: "growth_monthly" });
387
+
388
+ expect(refused).toEqual(mapped);
389
+ });
390
+
391
+ it("a code the route's regex refuses still answers validation_failed, with no call", async () => {
392
+ const result = await ailkPurchase({ productCode: "has spaces" });
393
+
394
+ expect(result).toMatchObject({ error: "validation_failed" });
395
+ expect(createSession).not.toHaveBeenCalled();
396
+ });
397
+ });
398
+
399
+ describe("start_purchase — the claim and the strict schema still answer first (#8935 AC-3)", () => {
400
+ it("an unclaimed site answers the module-disabled body for an ungrammatical key, with no call", async () => {
401
+ __setMcpCommerceClaimForTest("unclaimed");
402
+
403
+ const result = await ailkPurchase({ productCode: "Growth_Monthly" });
404
+
405
+ expect(result).toEqual({ error: "not_found", message: "Not found" });
406
+ expect(createSession).not.toHaveBeenCalled();
407
+ });
408
+
409
+ it.each(["workspaceId", "organizationId", "site", "metadata"])(
410
+ "a direct call carrying %s fails validation, with no call",
411
+ async (field) => {
412
+ const result = await ailkPurchase({
413
+ productCode: "growth_monthly",
414
+ [field]: "attacker-chosen",
415
+ } as never);
416
+
417
+ expect(result).toMatchObject({ error: "validation_failed" });
418
+ expect(createSession).not.toHaveBeenCalled();
419
+ },
420
+ );
421
+
422
+ it("the input schema has exactly productCode and quantity", () => {
423
+ expect(Object.keys(purchaseMcpInputSchema.shape)).toEqual([
424
+ "productCode",
425
+ "quantity",
426
+ ]);
427
+ });
428
+ });
429
+
430
+ describe("start_purchase — the catalog description names the lookup key (#8935 AC-5)", () => {
431
+ it("tells an agent to pass the lookupKey get_pricing or get_offerings returns", () => {
432
+ const entry = getMcpToolCatalog({ addons: { commerce: true } }).find(
433
+ (tool) => tool.name === "start_purchase",
434
+ );
435
+
436
+ expect(entry?.description).toBe(
437
+ "Start a purchase for an offering on this site. Provide the price's full lookup key as productCode — the lookupKey that get_pricing or get_offerings returns, such as growth_monthly — and an optional quantity; returns a Stripe-hosted Checkout URL for the buyer to complete payment on. Nothing is charged by this tool.",
438
+ );
439
+ for (const phrase of [
440
+ "lookupKey",
441
+ "get_pricing",
442
+ "get_offerings",
443
+ "Nothing is charged by this tool",
444
+ ]) {
445
+ expect(entry?.description).toContain(phrase);
446
+ }
447
+ });
448
+ });