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
@@ -60,6 +60,17 @@ jest.mock("@working-theory/stripe-client", () => ({
60
60
  },
61
61
  }));
62
62
 
63
+ // #8931 — the route now asks the Offerings service which path a request
64
+ // takes before the env checks; every case in this suite is an env-path case,
65
+ // so the service answers `env` for both the POST and the GET.
66
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
67
+ jest.mock("../../../services/offerings/checkout-authorization.js", () => ({
68
+ resolveOfferingsCheckoutPath: jest.fn(async () => ({ kind: "env" })),
69
+ readOfferingsCheckoutSession: jest.fn(async () => ({ kind: "env" })),
70
+ startOfferingsCheckout: jest.fn(),
71
+ OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE: 60,
72
+ }));
73
+
63
74
  // Import after mock setup
64
75
  import { config } from "../../../config/index.js";
65
76
  import { checkoutRoutes } from "../index";
@@ -82,10 +82,22 @@ jest.mock("../../../lib/prisma.js", () => ({
82
82
  },
83
83
  }));
84
84
 
85
+ // #8931 — the route now asks the Offerings service which path a request
86
+ // takes before the env checks; every case in this suite is an env-path case,
87
+ // so the service answers `env` for both the POST and the GET.
88
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
89
+ jest.mock("../../../services/offerings/checkout-authorization.js", () => ({
90
+ resolveOfferingsCheckoutPath: jest.fn(async () => ({ kind: "env" })),
91
+ readOfferingsCheckoutSession: jest.fn(async () => ({ kind: "env" })),
92
+ startOfferingsCheckout: jest.fn(),
93
+ OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE: 60,
94
+ }));
95
+
85
96
  // Import after mock setup
86
97
  import { config } from "../../../config/index.js";
87
98
  import { UnknownCustomerError } from "../../../lib/stripe.js";
88
99
  import { prisma } from "../../../lib/prisma.js";
100
+ import { isOfferingsProduct } from "../../../services/offerings/catalog.js";
89
101
  import { checkoutRoutes } from "../index";
90
102
 
91
103
  // #6877 — pin the suite's site origin to the fixtures' own origin, so every
@@ -179,9 +191,23 @@ describe("POST /api/checkout/sessions", () => {
179
191
 
180
192
  expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
181
193
  expect.objectContaining({ allowPromotionCodes: true }),
194
+ { refuseProduct: isOfferingsProduct },
182
195
  );
183
196
  });
184
197
 
198
+ it("#8931 A9: an anonymous env checkout passes the Offerings marker as refuseProduct", async () => {
199
+ await app.inject({
200
+ method: "POST",
201
+ url: "/v1/checkout/sessions",
202
+ payload: VALID_BODY,
203
+ });
204
+
205
+ expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
206
+ const [, options] = mockCreateCheckoutSession.mock.calls[0];
207
+ expect(Object.keys(options)).toEqual(["refuseProduct"]);
208
+ expect(options.refuseProduct).toBe(isOfferingsProduct);
209
+ });
210
+
185
211
  it("omits allowPromotionCodes from the client call when not supplied", async () => {
186
212
  await app.inject({
187
213
  method: "POST",
@@ -836,6 +862,7 @@ describe("parity with the payload the MCP tool builds (#5971)", () => {
836
862
  cancelUrl: "https://example.com/cancelled",
837
863
  allowPromotionCodes: true,
838
864
  }),
865
+ { refuseProduct: isOfferingsProduct },
839
866
  );
840
867
  });
841
868
 
@@ -912,6 +939,7 @@ describe("CheckoutSessionRequestSchema preserves Stripe's {CHECKOUT_SESSION_ID}
912
939
  expect(res.statusCode).toBe(201);
913
940
  expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
914
941
  expect.objectContaining({ successUrl: successUrlWithToken }),
942
+ { refuseProduct: isOfferingsProduct },
915
943
  );
916
944
  });
917
945
  });
@@ -1289,6 +1317,23 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
1289
1317
  });
1290
1318
  });
1291
1319
 
1320
+ it("#8931 A9: the first try and the anonymous retry both refuse an Offerings product with the marker itself", async () => {
1321
+ givenUser({ billingProvider: "stripe", billingCustomerId: "not_a_cus" });
1322
+ mockCreateCheckoutSession.mockRejectedValueOnce(
1323
+ Object.assign(new Error("invalid buyer"), { name: "ZodError" }),
1324
+ );
1325
+
1326
+ const res = await signedIn();
1327
+
1328
+ expect(res.statusCode).toBe(201);
1329
+ expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(2);
1330
+ for (const call of mockCreateCheckoutSession.mock.calls) {
1331
+ expect(call).toHaveLength(2);
1332
+ expect(Object.keys(call[1])).toEqual(["refuseProduct"]);
1333
+ expect(call[1].refuseProduct).toBe(isOfferingsProduct);
1334
+ }
1335
+ });
1336
+
1292
1337
  it("#6866 R12b: an anonymous ZodError is not retried — today's 502 mapping", async () => {
1293
1338
  mockCreateCheckoutSession.mockRejectedValue(
1294
1339
  Object.assign(new Error("invalid"), { name: "ZodError" }),
@@ -1474,6 +1519,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1474
1519
  expect(res.statusCode).toBe(201);
1475
1520
  expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
1476
1521
  expect.objectContaining({ successUrl, cancelUrl }),
1522
+ { refuseProduct: isOfferingsProduct },
1477
1523
  );
1478
1524
  });
1479
1525
 
@@ -1490,6 +1536,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1490
1536
  expect(res.statusCode).toBe(201);
1491
1537
  expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
1492
1538
  expect.objectContaining({ successUrl, cancelUrl }),
1539
+ { refuseProduct: isOfferingsProduct },
1493
1540
  );
1494
1541
  });
1495
1542
 
@@ -1604,6 +1651,7 @@ describe("POST /v1/checkout/sessions — trusted-origin allowlist (#7395)", () =
1604
1651
  expect(res.statusCode).toBe(201);
1605
1652
  expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
1606
1653
  expect.objectContaining({ successUrl, cancelUrl }),
1654
+ { refuseProduct: isOfferingsProduct },
1607
1655
  );
1608
1656
  });
1609
1657
 
@@ -1616,6 +1664,7 @@ describe("POST /v1/checkout/sessions — trusted-origin allowlist (#7395)", () =
1616
1664
  expect(res.statusCode).toBe(201);
1617
1665
  expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
1618
1666
  expect.objectContaining({ successUrl, cancelUrl }),
1667
+ { refuseProduct: isOfferingsProduct },
1619
1668
  );
1620
1669
  });
1621
1670
 
@@ -22,6 +22,11 @@
22
22
  * token-bearing response (D3, AC-9).
23
23
  * - Deliberately NOT gated by `X-Internal-Auth` (D8 layer 4 — withdrawn;
24
24
  * no `apps/web` file holds `OSS_API_INTERNAL_SECRET`).
25
+ * - A session the Offerings checkout recorded (#8931 D9, contract A13) is
26
+ * read with its record's own connection, in the record's mode, never the
27
+ * env key; its line-item names pass the catalog's T1 normalizer. A record
28
+ * that cannot be read, or a connection that is gone, unusable or in
29
+ * another mode, is 502 — never the env path. No record is today's read.
25
30
  *
26
31
  * Error mapping mirrors `sessions.ts`'s own vocabulary (D3):
27
32
  * 400 validation-error — `:id` fails the shape check
@@ -29,7 +34,8 @@
29
34
  * 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty
30
35
  * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
31
36
  * 500 config-error — STRIPE_SECRET_KEY invalid configuration
32
- * 502 checkout-unavailable — Stripe SDK threw an unexpected error
37
+ * 502 checkout-unavailable — Stripe SDK threw an unexpected error; a recorded
38
+ * Offerings session cannot be read on its connection (#8931)
33
39
  */
34
40
  import type { FastifyReply, FastifyRequest } from "fastify";
35
41
  import { z } from "zod";
@@ -37,9 +43,11 @@ import { z } from "zod";
37
43
  import {
38
44
  assertAdmittedStripeKey,
39
45
  retrieveCheckoutSession,
46
+ type RetrieveCheckoutSessionOutput,
40
47
  StripeConfigError,
41
48
  UnknownSessionError,
42
49
  } from "../../lib/stripe.js";
50
+ import { readOfferingsCheckoutSession } from "../../services/offerings/checkout-authorization.js";
43
51
 
44
52
  // A Stripe Checkout Session id is an unguessable bearer capability, not an
45
53
  // authorization-checked resource (D8). The shape gate is layer 2 of D8's
@@ -63,6 +71,36 @@ function setPrivateResponseHeaders(reply: FastifyReply): void {
63
71
  reply.header("Referrer-Policy", "no-referrer");
64
72
  }
65
73
 
74
+ /** The 200: D3's headers and allow-list, named field-for-field. */
75
+ function sendSessionRead(
76
+ request: FastifyRequest,
77
+ reply: FastifyReply,
78
+ id: string,
79
+ session: RetrieveCheckoutSessionOutput,
80
+ ): FastifyReply {
81
+ request.log.info(
82
+ {
83
+ method: request.method,
84
+ sessionIdPrefix: id.substring(0, 8),
85
+ status: 200,
86
+ },
87
+ "[checkout] session read",
88
+ );
89
+
90
+ setPrivateResponseHeaders(reply);
91
+ // The D3 allow-list, named field-for-field — never a spread. Nothing
92
+ // else may join this object (AC-3's set-equality test pins exactly
93
+ // these six keys).
94
+ return reply.status(200).send({
95
+ id: session.id,
96
+ status: session.status,
97
+ paymentStatus: session.paymentStatus,
98
+ currency: session.currency,
99
+ amountTotal: session.amountTotal,
100
+ lineItems: session.lineItems,
101
+ });
102
+ }
103
+
66
104
  export async function checkoutSessionReadHandler(
67
105
  request: FastifyRequest,
68
106
  reply: FastifyReply,
@@ -75,6 +113,23 @@ export async function checkoutSessionReadHandler(
75
113
  });
76
114
  }
77
115
 
116
+ const { id } = parsed.data;
117
+
118
+ // A recorded Offerings session reads on its own connection (#8931 D9). No
119
+ // record can exist without a database.
120
+ if (process.env.DATABASE_URL) {
121
+ const offerings = await readOfferingsCheckoutSession(id);
122
+ if (offerings.kind === "read") {
123
+ return sendSessionRead(request, reply, id, offerings.session);
124
+ }
125
+ if (offerings.kind === "unknown") {
126
+ return reply.status(404).send({ error: "unknown-session" });
127
+ }
128
+ if (offerings.kind === "unavailable") {
129
+ return reply.status(502).send({ error: "checkout-unavailable" });
130
+ }
131
+ }
132
+
78
133
  const stripeSecretKey = process.env["STRIPE_SECRET_KEY"];
79
134
  if (!stripeSecretKey) {
80
135
  return reply.status(500).send({ error: "stripe-not-configured" });
@@ -85,32 +140,9 @@ export async function checkoutSessionReadHandler(
85
140
  return reply.status(500).send({ error: "live-mode-forbidden" });
86
141
  }
87
142
 
88
- const { id } = parsed.data;
89
-
90
143
  try {
91
144
  const session = await retrieveCheckoutSession({ sessionId: id });
92
-
93
- request.log.info(
94
- {
95
- method: request.method,
96
- sessionIdPrefix: id.substring(0, 8),
97
- status: 200,
98
- },
99
- "[checkout] session read",
100
- );
101
-
102
- setPrivateResponseHeaders(reply);
103
- // The D3 allow-list, named field-for-field — never a spread. Nothing
104
- // else may join this object (AC-3's set-equality test pins exactly
105
- // these six keys).
106
- return reply.status(200).send({
107
- id: session.id,
108
- status: session.status,
109
- paymentStatus: session.paymentStatus,
110
- currency: session.currency,
111
- amountTotal: session.amountTotal,
112
- lineItems: session.lineItems,
113
- });
145
+ return sendSessionRead(request, reply, id, session);
114
146
  } catch (err) {
115
147
  if (err instanceof UnknownSessionError) {
116
148
  // 404 — never echo the id in the response body.
@@ -13,9 +13,23 @@
13
13
  * - No PII in logs: successUrl/cancelUrl and full session IDs are never logged.
14
14
  * Only the first 8 chars of the session ID appear in log lines.
15
15
  * - Error responses never echo user input (productCode, URLs).
16
- * - No DB write: this unit creates a Stripe session only; webhook-driven
17
- * persistence is a follow-up issue. It READS the database only to resolve
18
- * an optional signed-in buyer (below).
16
+ * - Two paths (#8931 D2, D3): for a server-resolved workspace whose
17
+ * Organization has a Stripe connection, the Offerings path —
18
+ * `services/offerings/checkout-authorization.ts` parses the key by the one
19
+ * grammar, authorizes it against org-enabled ∩ workspace-shown from the
20
+ * rows, sells the authorized Price id on the connection's own client in an
21
+ * anonymous session stamped `{ site, ailk_offering }`, and records it
22
+ * before this route answers. It never reads the env key, the snapshot or
23
+ * a caller value, charges a per-IP and a per-workspace bucket on every
24
+ * request, and never resolves a signed-in buyer. A spoke's workspace
25
+ * whose Organization has no row or no connection is the one 404 before
26
+ * any budget, env-key read or Stripe call — never the env path (#9126).
27
+ * Every other request takes the env path below, which now refuses a
28
+ * Price whose Product carries the Offerings marker (A9). The body schema
29
+ * is the same on both.
30
+ * - No DB write on the env path: it creates a Stripe session only. It READS
31
+ * the database to choose the path and to resolve an optional signed-in
32
+ * buyer (below).
19
33
  * - Optional cookie identity (#6866): a valid, same-origin Better Auth
20
34
  * session cookie whose user has a verified email makes the Checkout a
21
35
  * signed-in one — the session is pinned to the buyer's Stripe customer and
@@ -48,13 +62,20 @@
48
62
  * 401 internal-auth-required — X-Internal-Auth missing/mismatch (only when env var set)
49
63
  * 400 validation-error — Zod validation failed (missing/invalid fields), or an
50
64
  * unauthenticated redirect is off the site origin (#6877)
51
- * 404 unknown-product-code — no active Stripe price for the given productCode
65
+ * 404 unknown-product-code — no active Stripe price for the given productCode; on the
66
+ * env path, a Price whose Product is Offerings-marked; on the
67
+ * Offerings path, every key that is not sellable here (#8931);
68
+ * every key, for a spoke whose Organization is unconnected (#9126)
69
+ * 429 rate_limited — Offerings path: the per-IP or per-workspace bucket (#8931 D4)
70
+ * 503 tenant_unresolved — a presented spoke key the resolver refused (#8931 A2)
52
71
  * 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
53
72
  * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
54
73
  * 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
55
74
  * or no trusted browser origin is configured when the origin
56
75
  * lock applies (#6877, #7395)
57
- * 502 checkout-unavailable — Stripe SDK threw an unexpected error
76
+ * 502 checkout-unavailable — Stripe SDK threw an unexpected error; a database read
77
+ * choosing the path failed; Offerings path: Stripe, the rows,
78
+ * the connection or the session record failed (#8931)
58
79
  */
59
80
  import { timingSafeEqual } from "node:crypto";
60
81
 
@@ -78,10 +99,21 @@ import {
78
99
  type OptionalCookieUser,
79
100
  resolveOptionalCookieUser,
80
101
  } from "../../middleware/auth.js";
102
+ import {
103
+ chargeWriteBudget,
104
+ DEFAULT_PER_IP_PER_MINUTE_STRICT,
105
+ } from "../../middleware/rate-limit.js";
81
106
  import {
82
107
  type CheckoutBuyerResult,
83
108
  resolveCheckoutBuyer,
84
109
  } from "../../services/checkout-buyer.js";
110
+ import { isOfferingsProduct } from "../../services/offerings/catalog.js";
111
+ import {
112
+ OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE,
113
+ type OfferingsCheckoutPath,
114
+ resolveOfferingsCheckoutPath,
115
+ startOfferingsCheckout,
116
+ } from "../../services/offerings/checkout-authorization.js";
85
117
 
86
118
  type OptionalCookieUserNone = Extract<OptionalCookieUser, { kind: "none" }>;
87
119
 
@@ -245,14 +277,97 @@ export async function checkoutSessionsHandler(
245
277
  return reply.status(401).send({ error: "internal-auth-required" });
246
278
  }
247
279
 
248
- const stripeSecretKey = process.env["STRIPE_SECRET_KEY"];
249
- if (!stripeSecretKey) {
250
- return reply.status(500).send({ error: "stripe-not-configured" });
251
- }
280
+ // ── Which path (#8931 D2) — from the server-resolved workspace alone ──────
281
+ let checkoutPath: OfferingsCheckoutPath;
252
282
  try {
253
- assertAdmittedStripeKey(stripeSecretKey);
254
- } catch {
255
- return reply.status(500).send({ error: "live-mode-forbidden" });
283
+ checkoutPath = await resolveOfferingsCheckoutPath(request);
284
+ } catch (err) {
285
+ request.log.error(
286
+ {
287
+ method: request.method,
288
+ path: request.url,
289
+ outcome: "offerings_path_failed",
290
+ errName: err instanceof Error ? err.name : "unknown",
291
+ },
292
+ "[checkout] path read failed; checkout-unavailable",
293
+ );
294
+ return reply.status(502).send({ error: "checkout-unavailable" });
295
+ }
296
+ if (checkoutPath.kind === "refused") {
297
+ return reply.status(503).send({ error: "tenant_unresolved" });
298
+ }
299
+ if (checkoutPath.kind === "unavailable") {
300
+ return reply.status(502).send({ error: "checkout-unavailable" });
301
+ }
302
+ if (checkoutPath.kind === "unconnected") {
303
+ // A spoke sells nothing until its Organization connects Stripe (#9126):
304
+ // the one 404 every miss answers, before any budget, env-key read or
305
+ // Stripe call, so the hub's env key never sells for a spoke.
306
+ request.log.warn(
307
+ { outcome: "unconnected", workspaceId: checkoutPath.workspaceId },
308
+ "[checkout] spoke has no Stripe connection; unknown-product-code",
309
+ );
310
+ return reply.status(404).send({ error: "unknown-product-code" });
311
+ }
312
+
313
+ if (checkoutPath.kind === "env") {
314
+ const stripeSecretKey = process.env["STRIPE_SECRET_KEY"];
315
+ if (!stripeSecretKey) {
316
+ return reply.status(500).send({ error: "stripe-not-configured" });
317
+ }
318
+ try {
319
+ assertAdmittedStripeKey(stripeSecretKey);
320
+ } catch {
321
+ return reply.status(500).send({ error: "live-mode-forbidden" });
322
+ }
323
+ } else {
324
+ // The Offerings bucket (#8931 D4), before the body is read, whatever the
325
+ // process's mode: per caller IP, and per workspace — the bound a
326
+ // caller-set X-Forwarded-For cannot dodge. One charge at a time, the IP
327
+ // first: a request its IP bucket refuses never spends the workspace's,
328
+ // so one address cannot shut a seller's checkout for every buyer.
329
+ const charges = [
330
+ { key: `ip,${request.ip}`, limit: DEFAULT_PER_IP_PER_MINUTE_STRICT },
331
+ {
332
+ key: `ws,${checkoutPath.workspaceId}`,
333
+ limit: OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE,
334
+ },
335
+ ];
336
+ for (const charge of charges) {
337
+ let budget: Awaited<ReturnType<typeof chargeWriteBudget>>;
338
+ try {
339
+ budget = await chargeWriteBudget({
340
+ scope: "offeringscheckout",
341
+ charges: [charge],
342
+ windowMs: 60_000,
343
+ now: Date.now(),
344
+ warn: (obj, msg) => request.log.warn(obj, msg),
345
+ });
346
+ } catch (err) {
347
+ // A budget store that throws is a store failure, and the Offerings
348
+ // path's contract for one is 502 checkout-unavailable (code review on
349
+ // PR #9032) — never Fastify's 500.
350
+ request.log.error(
351
+ {
352
+ method: request.method,
353
+ path: request.url,
354
+ outcome: "offerings_budget_failed",
355
+ errName: err instanceof Error ? err.name : "unknown",
356
+ },
357
+ "[checkout] budget read failed; checkout-unavailable",
358
+ );
359
+ return reply.status(502).send({ error: "checkout-unavailable" });
360
+ }
361
+ if (!budget.ok) {
362
+ return reply
363
+ .header("Retry-After", String(budget.retryAfterSeconds))
364
+ .status(429)
365
+ .send({
366
+ error: "rate_limited",
367
+ retryAfterSeconds: budget.retryAfterSeconds,
368
+ });
369
+ }
370
+ }
256
371
  }
257
372
 
258
373
  const parsed = CheckoutSessionRequestSchema.safeParse(request.body);
@@ -306,85 +421,120 @@ export async function checkoutSessionsHandler(
306
421
  }
307
422
  }
308
423
 
309
- const sessionInput: CreateCheckoutSessionInput = {
310
- productCode,
311
- successUrl,
312
- cancelUrl,
313
- quantity: quantity ?? 1,
314
- // Omitted when the caller says nothing — Stripe's default (no promo-code
315
- // field) is unchanged for every existing caller.
316
- ...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
317
- metadata: siteStamp(),
318
- };
319
- // Built only from the session cookie — never from `parsed.data` (#6866 D5).
320
- let { buyer, identity } = await resolveBuyer(request);
424
+ let created: CreateCheckoutSessionOutput;
425
+ let buyer: Buyer | undefined;
426
+ let identity: IdentityReason | undefined;
321
427
 
322
- try {
323
- let created: CreateCheckoutSessionOutput;
324
- try {
325
- created = await createCheckoutSession({
326
- ...sessionInput,
327
- ...(buyer !== undefined && { buyer }),
328
- });
329
- } catch (err) {
330
- // The buyer itself cannot be used — a pinned customer Stripe does not
331
- // know (#6866 D8), or a stored email / customer id the library's schema
332
- // refuses (F7: no new failure mode). Retry once as anonymous; the
333
- // stored binding is never touched here. The request fields already
334
- // passed the route's schema, so an anonymous retry that still fails
335
- // maps exactly as today.
336
- const reason: IdentityReason | undefined =
337
- err instanceof UnknownCustomerError
338
- ? "stale_customer"
339
- : err instanceof Error && err.name === "ZodError"
340
- ? "buyer_invalid"
341
- : undefined;
342
- if (buyer === undefined || reason === undefined) throw err;
343
- buyer = undefined;
344
- identity = reason;
345
- created = await createCheckoutSession(sessionInput);
346
- }
347
- const { sessionId, url } = created;
348
-
349
- request.log.info(
350
- {
351
- method: request.method,
352
- path: request.url,
353
- sessionIdPrefix: sessionId.substring(0, 8),
354
- status: 201,
355
- buyer: buyer !== undefined ? "signed_in" : "anonymous",
356
- ...(identity !== undefined && { identity }),
357
- },
358
- "[checkout] session created",
359
- );
360
-
361
- return reply.status(201).send({ sessionId, url });
362
- } catch (err) {
363
- if (err instanceof UnknownProductCodeError) {
364
- // 404 — do NOT echo productCode in the response body
428
+ if (checkoutPath.kind === "offerings") {
429
+ // Anonymous by construction (#8931 C4): no buyer is resolved, and the
430
+ // session always allows promotion codes (A7, C3).
431
+ const outcome = await startOfferingsCheckout({
432
+ workspaceId: checkoutPath.workspaceId,
433
+ organizationId: checkoutPath.organizationId,
434
+ productCode,
435
+ successUrl,
436
+ cancelUrl,
437
+ quantity: quantity ?? 1,
438
+ });
439
+ if (outcome.kind === "unknown") {
440
+ // The one 404 every miss answers — never the key (A3, A5).
365
441
  return reply.status(404).send({ error: "unknown-product-code" });
366
442
  }
367
- if (err instanceof StripeConfigError) {
368
- // 500 — misconfiguration; log message (no PII) but never echo in response
443
+ if (outcome.kind === "unavailable") {
444
+ return reply.status(502).send({ error: "checkout-unavailable" });
445
+ }
446
+ created = outcome;
447
+ } else {
448
+ const sessionInput: CreateCheckoutSessionInput = {
449
+ productCode,
450
+ successUrl,
451
+ cancelUrl,
452
+ quantity: quantity ?? 1,
453
+ // Omitted when the caller says nothing — Stripe's default (no promo-code
454
+ // field) is unchanged for every existing caller.
455
+ ...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
456
+ metadata: siteStamp(),
457
+ };
458
+ // The env path never sells an Offerings product (#8931 A9, D8): the one
459
+ // marker rule decides it, on the first try and the anonymous retry.
460
+ const envOptions = { refuseProduct: isOfferingsProduct };
461
+ // Built only from the session cookie — never from `parsed.data` (#6866 D5).
462
+ ({ buyer, identity } = await resolveBuyer(request));
463
+
464
+ try {
465
+ try {
466
+ created = await createCheckoutSession(
467
+ {
468
+ ...sessionInput,
469
+ ...(buyer !== undefined && { buyer }),
470
+ },
471
+ envOptions,
472
+ );
473
+ } catch (err) {
474
+ // The buyer itself cannot be used — a pinned customer Stripe does not
475
+ // know (#6866 D8), or a stored email / customer id the library's schema
476
+ // refuses (F7: no new failure mode). Retry once as anonymous; the
477
+ // stored binding is never touched here. The request fields already
478
+ // passed the route's schema, so an anonymous retry that still fails
479
+ // maps exactly as today.
480
+ const reason: IdentityReason | undefined =
481
+ err instanceof UnknownCustomerError
482
+ ? "stale_customer"
483
+ : err instanceof Error && err.name === "ZodError"
484
+ ? "buyer_invalid"
485
+ : undefined;
486
+ if (buyer === undefined || reason === undefined) throw err;
487
+ buyer = undefined;
488
+ identity = reason;
489
+ created = await createCheckoutSession(sessionInput, envOptions);
490
+ }
491
+ } catch (err) {
492
+ if (err instanceof UnknownProductCodeError) {
493
+ // 404 — do NOT echo productCode in the response body
494
+ return reply.status(404).send({ error: "unknown-product-code" });
495
+ }
496
+ if (err instanceof StripeConfigError) {
497
+ // 500 — misconfiguration; log message (no PII) but never echo in response
498
+ request.log.error(
499
+ {
500
+ method: request.method,
501
+ path: request.url,
502
+ errName: err.name,
503
+ message: err.message,
504
+ },
505
+ "[checkout] config error",
506
+ );
507
+ return reply.status(500).send({ error: "config-error" });
508
+ }
369
509
  request.log.error(
370
510
  {
371
511
  method: request.method,
372
512
  path: request.url,
373
- errName: err.name,
374
- message: err.message,
513
+ errName: err instanceof Error ? err.name : "unknown",
375
514
  },
376
- "[checkout] config error",
515
+ "[checkout] stripe error; checkout-unavailable",
377
516
  );
378
- return reply.status(500).send({ error: "config-error" });
517
+ return reply.status(502).send({ error: "checkout-unavailable" });
379
518
  }
380
- request.log.error(
381
- {
382
- method: request.method,
383
- path: request.url,
384
- errName: err instanceof Error ? err.name : "unknown",
385
- },
386
- "[checkout] stripe error; checkout-unavailable",
387
- );
388
- return reply.status(502).send({ error: "checkout-unavailable" });
389
519
  }
520
+
521
+ const { sessionId, url } = created;
522
+
523
+ request.log.info(
524
+ {
525
+ method: request.method,
526
+ path: request.url,
527
+ sessionIdPrefix: sessionId.substring(0, 8),
528
+ status: 201,
529
+ buyer: buyer !== undefined ? "signed_in" : "anonymous",
530
+ ...(identity !== undefined && { identity }),
531
+ ...(checkoutPath.kind === "offerings" && {
532
+ checkout: "offerings",
533
+ workspaceId: checkoutPath.workspaceId,
534
+ }),
535
+ },
536
+ "[checkout] session created",
537
+ );
538
+
539
+ return reply.status(201).send({ sessionId, url });
390
540
  }
@@ -28,7 +28,7 @@ jest.mock("../../../lib/site.js", () => ({
28
28
  import { createHash } from "node:crypto";
29
29
 
30
30
  import {
31
- CONTACT_FORM_TERMS_STATEMENT,
31
+ ACCOUNT_TERMS_STATEMENT,
32
32
  SITE_CONSENT_CONFIG_DEFAULTS,
33
33
  siteConsentConfigSchema,
34
34
  } from "@working-theory/validation";
@@ -128,8 +128,8 @@ describe("GET /v1/consent/sign-up (D2)", () => {
128
128
  purposeId: "account_terms",
129
129
  policyVersion: 2,
130
130
  locale: "en",
131
- statement: CONTACT_FORM_TERMS_STATEMENT,
132
- statementHash: sha256(CONTACT_FORM_TERMS_STATEMENT),
131
+ statement: ACCOUNT_TERMS_STATEMENT,
132
+ statementHash: sha256(ACCOUNT_TERMS_STATEMENT),
133
133
  },
134
134
  ],
135
135
  },
@@ -182,7 +182,7 @@ describe("GET/POST /v1/consent/account (D5) — AC-2", () => {
182
182
  agreements: [
183
183
  {
184
184
  id: "account_terms",
185
- statement: CONTACT_FORM_TERMS_STATEMENT,
185
+ statement: ACCOUNT_TERMS_STATEMENT,
186
186
  policyVersion: 2,
187
187
  acceptedVersion: null,
188
188
  acceptedAt: null,
@@ -208,7 +208,7 @@ describe("GET/POST /v1/consent/account (D5) — AC-2", () => {
208
208
  source: "account_settings",
209
209
  sourceRef: null,
210
210
  policyVersion: 2,
211
- statement: CONTACT_FORM_TERMS_STATEMENT,
211
+ statement: ACCOUNT_TERMS_STATEMENT,
212
212
  });
213
213
  expect(double.ledger[0]?.createdAt).toBeInstanceOf(Date);
214
214
  const [entry] = res.json().data.agreements;