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
@@ -274,13 +274,13 @@ describe("ailkPurchase — valid input", () => {
274
274
  );
275
275
 
276
276
  await ailkPurchase({
277
- productCode: "price_test_123",
277
+ productCode: "growth_monthly",
278
278
  quantity: 1,
279
279
  });
280
280
 
281
281
  expect(createSession).toHaveBeenCalledTimes(1);
282
282
  const payload = createSession.mock.calls[0]![0];
283
- expect(payload.productCode).toBe("price_test_123");
283
+ expect(payload.productCode).toBe("growth_monthly");
284
284
  expect(payload.quantity).toBe(1);
285
285
  });
286
286
 
@@ -289,7 +289,7 @@ describe("ailkPurchase — valid input", () => {
289
289
  sessionCreated("https://checkout.stripe.com/pay/cs_test_promo"),
290
290
  );
291
291
 
292
- await ailkPurchase({ productCode: "price_test_123" });
292
+ await ailkPurchase({ productCode: "growth_monthly" });
293
293
 
294
294
  expect(createSession.mock.calls[0]![0]).not.toHaveProperty(
295
295
  "allowPromotionCodes",
@@ -302,7 +302,7 @@ describe("ailkPurchase — valid input", () => {
302
302
  );
303
303
 
304
304
  const output = await ailkPurchase({
305
- productCode: "price_test_xyz",
305
+ productCode: "growth_monthly",
306
306
  });
307
307
 
308
308
  expect(output).toEqual({
@@ -315,7 +315,7 @@ describe("ailkPurchase — valid input", () => {
315
315
  sessionCreated("https://checkout.stripe.com/pay/cs_test_valid"),
316
316
  );
317
317
 
318
- const output = await ailkPurchase({ productCode: "price_test_valid" });
318
+ const output = await ailkPurchase({ productCode: "growth_monthly" });
319
319
  expect(PurchaseOutputSchema.safeParse(output).success).toBe(true);
320
320
  });
321
321
  });
@@ -373,10 +373,10 @@ describe("ailkPurchase — always derives redirects from AILK_BASE_URL (#6001 AC
373
373
  sessionCreated("https://checkout.stripe.com/pay/cs_test_base"),
374
374
  );
375
375
 
376
- await ailkPurchase({ productCode: "price_test_123" });
376
+ await ailkPurchase({ productCode: "growth_monthly" });
377
377
 
378
378
  expect(createSession).toHaveBeenCalledWith({
379
- productCode: "price_test_123",
379
+ productCode: "growth_monthly",
380
380
  successUrl:
381
381
  "https://example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}",
382
382
  cancelUrl: "https://example.com/purchase/cancel",
@@ -390,10 +390,10 @@ describe("ailkPurchase — always derives redirects from AILK_BASE_URL (#6001 AC
390
390
  sessionCreated("https://checkout.stripe.com/pay/cs_test_fallback"),
391
391
  );
392
392
 
393
- await ailkPurchase({ productCode: "price_test_123" });
393
+ await ailkPurchase({ productCode: "growth_monthly" });
394
394
 
395
395
  expect(createSession).toHaveBeenCalledWith({
396
- productCode: "price_test_123",
396
+ productCode: "growth_monthly",
397
397
  successUrl:
398
398
  "http://localhost:3000/purchase/success?session_id={CHECKOUT_SESSION_ID}",
399
399
  cancelUrl: "http://localhost:3000/purchase/cancel",
@@ -408,7 +408,7 @@ describe("ailkPurchase — always derives redirects from AILK_BASE_URL (#6001 AC
408
408
  sessionCreated("https://checkout.stripe.com/pay/cs_test_braces"),
409
409
  );
410
410
 
411
- await ailkPurchase({ productCode: "price_test_123" });
411
+ await ailkPurchase({ productCode: "growth_monthly" });
412
412
 
413
413
  const payload = createSession.mock.calls[0]![0] as {
414
414
  successUrl: string;
@@ -427,7 +427,7 @@ describe("ailkPurchase — parity shape with purchaseAction", () => {
427
427
  );
428
428
 
429
429
  const output = await ailkPurchase({
430
- productCode: "price_parity",
430
+ productCode: "growth_monthly",
431
431
  });
432
432
 
433
433
  expect(output).toHaveProperty("url");
@@ -27,6 +27,7 @@
27
27
  "@working-theory/license": "workspace:*",
28
28
  "@working-theory/metadata": "workspace:*",
29
29
  "@working-theory/schema": "workspace:*",
30
+ "@working-theory/site-config": "workspace:*",
30
31
  "@working-theory/validation": "workspace:*",
31
32
  "fastify": "^5.12.5",
32
33
  "yaml": "^2.9.0",
@@ -28,14 +28,18 @@ import { buildDenyList, isUserAgentBlocked } from "./user-agent-deny.js";
28
28
 
29
29
  /**
30
30
  * Tools that require email format validation on their input, each mapped to
31
- * where the email sits in it: top level for the lead tools, `attendee.email`
32
- * for create_booking (#8071 D3). submit_flow_step has no fixed email field —
33
- * its `values` is a free record the hub's flow engine validates.
31
+ * the paths the email may sit at, read in order — the first string wins: top
32
+ * level for the lead tools, then `payload.email`, since the route lifts the
33
+ * address from there when the top level has none (#7171, #7172: `email` is
34
+ * optional at the top level of both bodies, so an agent could otherwise nest
35
+ * a malformed one past this check); `attendee.email` for create_booking
36
+ * (#8071 D3). submit_flow_step has no fixed email field — its `values` is a
37
+ * free record the hub's flow engine validates.
34
38
  */
35
- const EMAIL_VALIDATED_TOOLS = new Map<string, readonly string[]>([
36
- ["capture_lead", ["email"]],
37
- ["subscribe_newsletter", ["email"]],
38
- ["create_booking", ["attendee", "email"]],
39
+ const EMAIL_VALIDATED_TOOLS = new Map<string, readonly (readonly string[])[]>([
40
+ ["capture_lead", [["email"], ["payload", "email"]]],
41
+ ["subscribe_newsletter", [["email"], ["payload", "email"]]],
42
+ ["create_booking", [["attendee", "email"]]],
39
43
  ]);
40
44
 
41
45
  /**
@@ -274,12 +278,15 @@ export class AbuseGuard {
274
278
  };
275
279
  }
276
280
 
277
- // Rule 3: Email format validation, at the tool's own email path. A
281
+ // Rule 3: Email format validation, at the tool's own email paths — the
282
+ // first path holding a string is the address the route will read. A
278
283
  // missing or non-string email passes — the handler's Zod schema is the
279
284
  // authority on required fields.
280
- const emailPath = EMAIL_VALIDATED_TOOLS.get(toolName);
281
- if (emailPath !== undefined) {
282
- const email = valueAt(input, emailPath);
285
+ const emailPaths = EMAIL_VALIDATED_TOOLS.get(toolName);
286
+ if (emailPaths !== undefined) {
287
+ const email = emailPaths
288
+ .map((path) => valueAt(input, path))
289
+ .find((value) => typeof value === "string");
283
290
  if (typeof email === "string" && !isValidEmail(email)) {
284
291
  return {
285
292
  allowed: false,
@@ -201,6 +201,17 @@ function isOriginAllowedForWrite(
201
201
  return allowedOrigins.includes(origin);
202
202
  }
203
203
 
204
+ /**
205
+ * The "retry in …" wording of the write bucket's refusal, matching what
206
+ * @fastify/rate-limit's default error says for the same window — `format()`
207
+ * from `@lukeed/ms` over whole seconds, which for a one-minute window is
208
+ * "1 minute" or "N second(s)".
209
+ */
210
+ function retryIn(seconds: number): string {
211
+ if (seconds >= 60) return "1 minute";
212
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
213
+ }
214
+
204
215
  /**
205
216
  * An agent key's shape (#3591 D2): `ailk_agt_` plus 32 random bytes in
206
217
  * base64url. apps/mcp checks only this; apps/api is where the key is checked.
@@ -322,14 +333,25 @@ export async function registerHttpTransport(
322
333
  });
323
334
 
324
335
  // ── Rate limiting ─────────────────────────────────────────────────────────
325
- // Global ceiling — the lower write limit is enforced per-request via the
326
- // preHandler by checking the tool name after body parse.
336
+ // Global ceiling — readPerMin per IP, counted on every request (writes
337
+ // included) by the plugin's own onRequest hook.
327
338
  await app.register(rateLimit, {
328
339
  max: config.rateLimits.readPerMin,
329
340
  timeWindow: "1 minute",
330
341
  keyGenerator: (request: FastifyRequest) => request.ip,
331
342
  });
332
343
 
344
+ // The write bucket — writePerMin per IP, charged in the POST preHandler
345
+ // below once per write-tool call, after the body is parsed and classified
346
+ // (#9251). `createRateLimit()` gives it its own in-memory store with the
347
+ // ceiling's key generator and window; it is the plugin's seam for a second
348
+ // limiter on a request the ceiling has already counted (a hook from
349
+ // `app.rateLimit()` would skip such a request as already limited).
350
+ const writeLimit = app.createRateLimit({
351
+ max: config.rateLimits.writePerMin,
352
+ timeWindow: "1 minute",
353
+ });
354
+
333
355
  // ── MCP route ─────────────────────────────────────────────────────────────
334
356
 
335
357
  // GET — SSE stream for an existing session
@@ -362,13 +384,29 @@ export async function registerHttpTransport(
362
384
  return;
363
385
  }
364
386
 
365
- // Write-tool rate limit (stricter than read ceiling)
366
- // We re-use the same in-memory store but apply a tighter key.
367
- // @fastify/rate-limit tracks by keyGenerator; we check manually here.
368
- // Simple approach: use a second rate-limit store baked into the guard.
369
- // For now, we rely on the global ceiling for writes (configurable).
370
- // TODO: per-tool write rate limit when @fastify/rate-limit v11 lands
371
- // a per-route override API compatible with our stateless route design.
387
+ // Write-tool rate limit (#9251) — writePerMin per IP, tighter than
388
+ // the ceiling and counting write-tool calls only. Charged once per
389
+ // call, like the abuse guard below, so a batch of N spends N units.
390
+ // The refusal is the one the ceiling emits — the plugin's four
391
+ // headers and Fastify's 429 error body — so a caller sees one
392
+ // rate-limit shape whichever bucket ran out, never a tool-level
393
+ // error (see ABUSE_REFUSAL_STATUS on why that distinction matters).
394
+ for (const _call of writeCalls) {
395
+ const limit = await writeLimit(request);
396
+ if (limit.isAllowed || !limit.isExceeded) continue;
397
+ reply
398
+ .code(429)
399
+ .header("x-ratelimit-limit", limit.max)
400
+ .header("x-ratelimit-remaining", 0)
401
+ .header("x-ratelimit-reset", limit.ttlInSeconds)
402
+ .header("retry-after", limit.ttlInSeconds)
403
+ .send({
404
+ statusCode: 429,
405
+ error: "Too Many Requests",
406
+ message: `Rate limit exceeded, retry in ${retryIn(limit.ttlInSeconds)}`,
407
+ });
408
+ return;
409
+ }
372
410
 
373
411
  // Abuse heuristics — charged ONCE PER CALL, not once per request, so a
374
412
  // batch of N cannot spend one cap unit and execute N times. The whole
@@ -114,29 +114,3 @@ export function getMcpContentAdapter(
114
114
  // untouched and gate only the four write methods (D13).
115
115
  return readOnlyContentAdapter(adapter as MdxContentAdapter);
116
116
  }
117
-
118
- /**
119
- * The same resolution, narrowed to the MDX adapter's richer surface.
120
- *
121
- * `read_content` returns a page's raw MDX body, which only
122
- * `MdxContentAdapter.getPageSource()` provides — the base `ContentAdapter`
123
- * contract returns frontmatter only. Rather than cast, this asserts the
124
- * capability on the adapter the resolver actually built, so a server pointed
125
- * at a non-MDX `CONTENT_ADAPTER` gets a named error instead of a
126
- * `getPageSource is not a function` TypeError. On the default `mdx` path the
127
- * check always passes and behaviour is unchanged. Under `database`, both the
128
- * fail-closed empty adapter and the read-only-wrapped database adapter also
129
- * provide `getPageSource`, so the check passes there too (D5).
130
- */
131
- export function getMcpMdxContentAdapter(
132
- options: McpContentAdapterOptions = {},
133
- ): MdxContentAdapter {
134
- const adapter = getMcpContentAdapter(options);
135
- if (typeof (adapter as MdxContentAdapter).getPageSource !== "function") {
136
- throw new Error(
137
- "This tool needs the MDX adapter's getPageSource(): the adapter selected by " +
138
- "CONTENT_ADAPTER does not provide a raw MDX body. Use CONTENT_ADAPTER=mdx (the default).",
139
- );
140
- }
141
- return adapter as MdxContentAdapter;
142
- }
@@ -18,12 +18,13 @@
18
18
  *
19
19
  * 1. Every content tool's own not-found/empty mapping fires with NO tool
20
20
  * edit — `read_content` and `get_*` already treat `null` as not-found;
21
- * `list_pages` and `search_content` already treat `[]` as empty.
21
+ * `list_pages` and `search_content` already answer their empty
22
+ * `ItemList` over `[]` (#7167: the routes' own envelopes).
22
23
  * 2. "Before any content lookup" (NFR-5) is structural, not asserted —
23
24
  * there is nothing here to look anything up in.
24
- * 3. `getMcpMdxContentAdapter()`'s `typeof getPageSource === "function"`
25
- * capability check (`content-adapter.ts`) passes, so `read_content`
26
- * never sees THAT named throw on the fail-closed path.
25
+ * 3. `readContentDocument`'s `typeof getPageSource === "function"`
26
+ * capability check (`@working-theory/content-adapters`) passes, so
27
+ * `read_content` never sees THAT named throw on the fail-closed path.
27
28
  */
28
29
  import type {
29
30
  ContentPage,
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The MCP read of the site's Offerings shown set (#8933, spec D6).
3
+ *
4
+ * `get_pricing` and `get_offerings` answer a connected site's shown set —
5
+ * the persisted Stripe snapshot `GET /v1/content/shown-offerings` serves
6
+ * (#8924) — rather than its page content. apps/mcp reaches Offerings data
7
+ * only over HTTP (#8876 D9), so this file reads that route through
8
+ * `@working-theory/api-client` and hands the tools the current set, or
9
+ * `null` for the content path:
10
+ *
11
+ * 1. The deployment's own claim first: unless `mcpCommerceClaim()` reads
12
+ * `claimed`, no HTTP call is made at all. The API's claim, read inside
13
+ * the route, still decides; this one only spares an unlicensed host the
14
+ * call.
15
+ * 2. One `apiClient.offerings.shown()`, raced against
16
+ * `MCP_SHOWN_SET_BUDGET_MS`, so a hung API never holds a tool call.
17
+ * 3. A refusal, a body `shownOfferingsAnswerSchema` refuses, the budget
18
+ * passing or a rejection is `null` and one `console.error` line.
19
+ * 4. Otherwise `currentShownSet` — a `current` set with offerings, or `null`.
20
+ *
21
+ * The workspace is the API's: api-client sends the deployment's own spoke
22
+ * key, and no tool argument names one (identity-blind). No Stripe package is
23
+ * imported here or anywhere in apps/mcp (T7).
24
+ *
25
+ * T6 — every log line is `{ event, outcome }` plus an HTTP `status` or an
26
+ * error's class name as `errName`: never a body, a message, the shown set, a
27
+ * lookup key, an id, a name or a description. The log calls live here
28
+ * because the tool files may not call `console.*`.
29
+ */
30
+
31
+ import { apiClient } from "@working-theory/api-client";
32
+ import { currentShownSet } from "@working-theory/content-adapters";
33
+ import {
34
+ shownOfferingsAnswerSchema,
35
+ type WorkspaceShownSetWire,
36
+ } from "@working-theory/validation";
37
+
38
+ import { mcpCommerceClaim } from "./commerce-claim.js";
39
+
40
+ /** The longest one tool call waits on the shown set before answering content. */
41
+ export const MCP_SHOWN_SET_BUDGET_MS = 5000;
42
+
43
+ const OVER_BUDGET = Symbol("over-budget");
44
+
45
+ /** An error's class name only — never its message, which may carry Stripe text. */
46
+ function errName(err: unknown): string {
47
+ return err instanceof Error ? err.constructor.name : typeof err;
48
+ }
49
+
50
+ /** The site's current shown set, or `null` for the content path. */
51
+ export async function mcpCurrentShownSet(): Promise<WorkspaceShownSetWire | null> {
52
+ if (mcpCommerceClaim() !== "claimed") return null;
53
+
54
+ // Caught before the race, so a rejection after the budget is never unhandled.
55
+ const read = apiClient.offerings.shown().catch((err: unknown) => {
56
+ console.error({
57
+ event: "shown-offerings",
58
+ outcome: "unreachable",
59
+ errName: errName(err),
60
+ });
61
+ return null;
62
+ });
63
+ let timer: NodeJS.Timeout | undefined;
64
+ const budget = new Promise<typeof OVER_BUDGET>((resolve) => {
65
+ timer = setTimeout(() => resolve(OVER_BUDGET), MCP_SHOWN_SET_BUDGET_MS);
66
+ });
67
+ let res: Awaited<typeof read> | typeof OVER_BUDGET;
68
+ try {
69
+ res = await Promise.race([read, budget]);
70
+ } finally {
71
+ clearTimeout(timer);
72
+ }
73
+
74
+ if (res === OVER_BUDGET) {
75
+ console.error({ event: "shown-offerings", outcome: "over_budget" });
76
+ return null;
77
+ }
78
+ if (res === null) return null;
79
+ if (!res.ok) {
80
+ console.error({
81
+ event: "shown-offerings",
82
+ outcome: "refused",
83
+ status: res.status,
84
+ });
85
+ return null;
86
+ }
87
+ const parsed = shownOfferingsAnswerSchema.safeParse(res.body);
88
+ if (!parsed.success) {
89
+ console.error({ event: "shown-offerings", outcome: "unreadable" });
90
+ return null;
91
+ }
92
+ return currentShownSet(parsed.data);
93
+ }
94
+
95
+ /**
96
+ * Logs a shown-set mapping that threw (D12): the tool then answers its
97
+ * content path, so no `ZodError` message ever reaches the agent.
98
+ */
99
+ export function reportMappingFailure(err: unknown): void {
100
+ console.error({
101
+ event: "shown-offerings",
102
+ outcome: "mapping_failed",
103
+ errName: errName(err),
104
+ });
105
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The deployment's `_site.mdx`, read once per content root (#7169).
3
+ *
4
+ * `read_content` answers through `readContentDocument` in
5
+ * `@working-theory/content-adapters`, which takes the parsed site config for
6
+ * the `HomePage` graph and the `origin` read. That package may not import
7
+ * `@working-theory/site-config` (`architecture.yaml`), so the read stays with
8
+ * each app: this is the same reader `apps/api/src/routes/content/get-page.ts`
9
+ * carries — the one duplication the import rule leaves, kept identical so a
10
+ * `HomePage` read through the tool is the route's to the byte. The rejected
11
+ * alternative, `@working-theory/aeo`'s `loadSitewideIdentity`, applies its own
12
+ * acceptance rules and a parent-directory fallback, which could make the two
13
+ * differ on an edge site.
14
+ *
15
+ * A local read with the client-safe `/schema` entry:
16
+ * `@working-theory/site-config/server` imports `server-only`, which throws
17
+ * outside a React Server Components runtime.
18
+ */
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ import {
23
+ SiteConfigSchema,
24
+ type SiteConfig,
25
+ } from "@working-theory/site-config/schema";
26
+ import { parse as parseYaml } from "yaml";
27
+
28
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
29
+
30
+ /** `_site.mdx` per content root, read once; `null` when it does not load. */
31
+ const siteConfigByRoot = new Map<string, SiteConfig | null>();
32
+
33
+ /**
34
+ * The deployment's `_site.mdx`, validated against `SiteConfigSchema`, or
35
+ * `undefined` on any failure (absent, unreadable, invalid).
36
+ */
37
+ export function readSiteConfig(contentRoot: string): SiteConfig | undefined {
38
+ if (!siteConfigByRoot.has(contentRoot)) {
39
+ let config: SiteConfig | null = null;
40
+ try {
41
+ const raw = readFileSync(join(contentRoot, "_site.mdx"), "utf8");
42
+ const match = FRONTMATTER_RE.exec(raw);
43
+ if (match) {
44
+ const parsed = SiteConfigSchema.safeParse(parseYaml(match[1] ?? ""));
45
+ if (parsed.success) config = parsed.data;
46
+ }
47
+ } catch {
48
+ // Absent or unreadable — no site.
49
+ }
50
+ siteConfigByRoot.set(contentRoot, config);
51
+ }
52
+ return siteConfigByRoot.get(contentRoot) ?? undefined;
53
+ }
@@ -119,6 +119,11 @@ const MODULE_TOOL_ENTRIES: Record<
119
119
  specifier: "./tools/create_booking.js",
120
120
  exportName: "ailkCreateBooking",
121
121
  },
122
+ // #8843 D5 — the twin of GET /v1/schedule/meetings.
123
+ list_meetings: {
124
+ specifier: "./tools/list_meetings.js",
125
+ exportName: "ailkListMeetings",
126
+ },
122
127
  // D-48 signal-collectors — the flow engine's MCP twins of GET
123
128
  // /v1/flows/:slug and POST /v1/flows/:slug/steps. Anonymous, identity-blind;
124
129
  // free-anonymous status is now module-gated rather than unconditional.
@@ -1,9 +1,18 @@
1
1
  /**
2
- * capture_lead — capture a contact-form lead.
2
+ * capture_lead — capture a lead: the body `POST /v1/leads/submit` takes.
3
3
  *
4
- * Validates input via Zod, forwards to POST /v1/leads/submit via
5
- * @working-theory/api-client, and shapes the response into the MCP output schema.
6
- * No business logic — orchestration lives in apps/api B6.
4
+ * The tool adopted the API, arguments and results (#7171 — the operator's
5
+ * decision of 2026-10-19). Its input IS the route's `leadSubmitBodySchema`
6
+ * (the catalog registers the same shape, so the SDK has already parsed the
7
+ * arguments against it; the `safeParse` here keeps the stdio path and the
8
+ * `invalid_input` branch honest), the parsed body is posted as the agent
9
+ * sent it — no `siteId`, no stamped `source`, no wrapping — and the route's
10
+ * 201 body `{ id, persisted, email }` comes back unchanged. One addition
11
+ * survives, because it is deployment attribution rather than an argument:
12
+ * the spoke marker `payload.site` from `AILK_LEAD_SITE` (#7397), spread LAST
13
+ * over the agent's `payload` as the site's own writer
14
+ * (`apps/web/src/lib/lead-write.ts`) does; unset or blank, the payload goes
15
+ * as sent. No business logic — orchestration lives in apps/api.
7
16
  *
8
17
  * Paired with captureLeadAction Server Action per ADR 0013.
9
18
  */
@@ -11,21 +20,21 @@
11
20
  import { apiClient } from "@working-theory/api-client";
12
21
  import {
13
22
  LEAD_SITE_MARKER_KEY,
14
- submitLeadInputSchema,
15
- submitLeadOutputSchema,
16
- type SubmitLeadInput,
17
- type SubmitLeadOutput,
23
+ leadSubmitBodySchema,
24
+ leadWriteOutputSchema,
25
+ type LeadSubmitBody,
26
+ type LeadWriteOutput,
18
27
  } from "@working-theory/validation";
19
28
 
20
29
  import { visitorForwardHeadersForThisCall } from "../request-context.js";
21
30
 
22
- export { submitLeadInputSchema, submitLeadOutputSchema };
23
- export type { SubmitLeadInput, SubmitLeadOutput };
31
+ export { leadSubmitBodySchema, leadWriteOutputSchema };
32
+ export type { LeadSubmitBody, LeadWriteOutput };
24
33
 
25
34
  export async function ailkCaptureLead(
26
- input: SubmitLeadInput,
27
- ): Promise<SubmitLeadOutput> {
28
- const parsed = submitLeadInputSchema.safeParse(input);
35
+ input: LeadSubmitBody,
36
+ ): Promise<LeadWriteOutput> {
37
+ const parsed = leadSubmitBodySchema.safeParse(input);
29
38
  if (!parsed.success) {
30
39
  return {
31
40
  ok: false,
@@ -33,21 +42,14 @@ export async function ailkCaptureLead(
33
42
  };
34
43
  }
35
44
 
36
- const { name, email, message, phone } = parsed.data;
37
- const siteId = process.env.AILK_SITE_ID ?? "default";
38
- // The spoke marker (#7397) — unset or blank leaves the payload unmarked.
45
+ // The spoke marker (#7397) — unset or blank leaves the payload as sent.
39
46
  const site = process.env.AILK_LEAD_SITE?.trim();
40
- const body = {
41
- siteId,
42
- source: "mcp",
43
- email,
44
- payload: {
45
- name,
46
- ...(message !== undefined && { message }),
47
- ...(phone !== undefined && { phone }),
48
- ...(site && { [LEAD_SITE_MARKER_KEY]: site }),
49
- },
50
- };
47
+ const body = site
48
+ ? {
49
+ ...parsed.data,
50
+ payload: { ...parsed.data.payload, [LEAD_SITE_MARKER_KEY]: site },
51
+ }
52
+ : parsed.data;
51
53
 
52
54
  // #8073 — forward the address the transport trusted (never an identity
53
55
  // argument on this tool's own schema) so the hub counts the write in that
@@ -60,5 +62,5 @@ export async function ailkCaptureLead(
60
62
  return { ok: false, error: { kind: "api_error", status: res.status } };
61
63
  }
62
64
 
63
- return { ok: true, id: res.body.id, persisted: res.body.persisted };
65
+ return res.body;
64
66
  }
@@ -2,26 +2,45 @@
2
2
  * get_offerings — returns Services and/or Products as a unified offerings array.
3
3
  *
4
4
  * Pure read: resolves its adapter through `getMcpContentAdapter()` — the one
5
- * resolver seam (#5805, ADR 0011), MDX by default.
6
- * Lists pages of type "Service" and/or "Product" and shapes them into a
7
- * unified offerings array. Optionally filtered by type.
5
+ * resolver seam (#5805, ADR 0011), MDX by default — and answers through
6
+ * `listOfferings` (`@working-theory/content-adapters`), which lists pages of
7
+ * type "Service" and/or "Product" and shapes them into a unified offerings
8
+ * array. Optionally filtered by type.
9
+ *
10
+ * HTTP twin (#7177): `GET /v1/content/offerings` in `apps/api` takes the same
11
+ * arguments and answers through the same `listOfferings` — the shared seam
12
+ * the pair proves parity at. The tool still reads content locally, so it
13
+ * needs no API to answer.
8
14
  *
9
15
  * Phase 3 S3: optional `locale` parameter (BCP-47). Validated against the
10
16
  * project's allowlist; invalid or unknown values silently fall back to the
11
17
  * default locale (AC-i18n-07). Lists are locale-scoped.
12
18
  *
13
19
  * The offering fields (#5838) — `provider`, `categories`, `price` and
14
- * `priceCurrency` — come off the page's own frontmatter, the same shape the
15
- * listing card renders from. `price` and `priceCurrency` are `null` (never a
16
- * fabricated zero) for a listing that declares no price, and `categories` is
17
- * `[]` for one that is untagged.
20
+ * `priceCurrency` — come off the page's own frontmatter; `listOfferings`
21
+ * documents the mapping.
22
+ *
23
+ * Offerings (#8933, spec D8): when the site sells through its connected
24
+ * Stripe catalog and `type` is not `service`, the tool answers the Service
25
+ * pages followed by the workspace's Offerings shown set, in place of the
26
+ * Product pages — read over HTTP by `lib/shown-offerings.ts` (D6: only on a
27
+ * `claimed` host, at most `MCP_SHOWN_SET_BUDGET_MS`) and mapped by
28
+ * `listOfferingsWithShownSet`, the function the twin route answers through
29
+ * too, so each shown product also carries `prices` (every price with its
30
+ * interval and full lookup key). Names, descriptions and prices are
31
+ * seller-provided data returned as plain JSON values (T5). `type: "service"`,
32
+ * an unclaimed host, a workspace with no current set, a failed read or a
33
+ * mapping that throws answers the page content below, unchanged.
18
34
  *
19
35
  * Input: { type?: 'service' | 'product', locale?: string }
20
- * Output: { offerings: Array<{ name, description, category?, image?, provider, categories, price, priceCurrency }> }
36
+ * Output: { offerings: Array<{ name, description, category?, image?, provider, categories, price, priceCurrency, prices? }> }
21
37
  * Session/scope: none — read-only public content.
22
38
  */
23
39
 
24
- import { type SchemaOrgType } from "@working-theory/content-adapters";
40
+ import {
41
+ listOfferings,
42
+ listOfferingsWithShownSet,
43
+ } from "@working-theory/content-adapters";
25
44
  import {
26
45
  getOfferingsInputSchema,
27
46
  getOfferingsOutputSchema,
@@ -34,6 +53,10 @@ import {
34
53
  mcpContentRoot,
35
54
  } from "../lib/content-adapter.js";
36
55
  import { resolveLocale } from "../lib/resolve-locale.js";
56
+ import {
57
+ mcpCurrentShownSet,
58
+ reportMappingFailure,
59
+ } from "../lib/shown-offerings.js";
37
60
 
38
61
  export { getOfferingsInputSchema, getOfferingsOutputSchema };
39
62
  export type { GetOfferingsInput, GetOfferingsOutput };
@@ -54,26 +77,20 @@ export async function ailkGetOfferings(
54
77
  const locale = resolveLocale(input.locale);
55
78
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
56
79
 
57
- const typesToFetch: SchemaOrgType[] = [];
58
- if (!input.type || input.type === "service") typesToFetch.push("Service");
59
- if (!input.type || input.type === "product") typesToFetch.push("Product");
60
-
61
- const perType = await Promise.all(
62
- typesToFetch.map((type) => adapter.listPages(type, locale)),
63
- );
64
-
65
- const offerings: GetOfferingsOutput["offerings"] = perType
66
- .flat()
67
- .map((page) => ({
68
- name: page.title,
69
- description: page.description,
70
- category: page.schema?.["category"] as string | undefined,
71
- image: page.image,
72
- provider: page.provider ?? null,
73
- categories: page.categories ?? [],
74
- price: page.price ?? null,
75
- priceCurrency: page.priceCurrency ?? null,
76
- }));
80
+ if (input.type !== "service") {
81
+ const shownSet = await mcpCurrentShownSet();
82
+ if (shownSet !== null) {
83
+ try {
84
+ return await listOfferingsWithShownSet(
85
+ adapter,
86
+ { type: input.type, locale },
87
+ shownSet,
88
+ );
89
+ } catch (err) {
90
+ reportMappingFailure(err);
91
+ }
92
+ }
93
+ }
77
94
 
78
- return getOfferingsOutputSchema.parse({ offerings });
95
+ return listOfferings(adapter, { type: input.type, locale });
79
96
  }