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
@@ -0,0 +1,810 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The Offerings buy route (#9051, spec
7
+ * docs/specs/9051-the-offerings-buy-route-api-spec.md AC-1, AC-2, AC-4–AC-6).
8
+ *
9
+ * The real api-client, mapper, `resolveBaseUrl`, `FALLBACK_BASE_URL` and
10
+ * `resolveCommerceClaim` run; only the licence read and `fetch` are stubbed.
11
+ * `fetch` routes by path: the shown-set read and the session POST each answer
12
+ * the case's own stub. The incoming request is addressed to `evil.example`
13
+ * with a buyer cookie, so a redirect built from the request or a forwarded
14
+ * cookie fails AC-1. The clock moves 16 seconds before every case, so no case
15
+ * reads another's shared answer, and each case brings its own visitor
16
+ * address, so none spends another's start budget.
17
+ */
18
+ import { readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ import { checkLicense } from "@working-theory/license";
22
+ import {
23
+ workspaceShownSetSchema,
24
+ type OfferingsPrice,
25
+ type OfferingsProduct,
26
+ } from "@working-theory/validation";
27
+
28
+ jest.mock("@working-theory/license", () => ({
29
+ checkLicense: jest.fn(),
30
+ }));
31
+
32
+ import { GET } from "../[lookupKey]/route";
33
+
34
+ const mockCheckLicense = checkLicense as jest.MockedFunction<
35
+ typeof checkLicense
36
+ >;
37
+ type LicenseShape = Awaited<ReturnType<typeof checkLicense>>;
38
+
39
+ /** Point the mocked reader at a licence shape, filling only the fields
40
+ * `resolveCommerceClaim` reads (`revoked`, `features`). */
41
+ function licenceResolves(result: Partial<LicenseShape>): void {
42
+ mockCheckLicense.mockResolvedValue(result as LicenseShape);
43
+ }
44
+
45
+ const ROUTE_SOURCE = readFileSync(
46
+ join(__dirname, "..", "[lookupKey]", "route.ts"),
47
+ "utf8",
48
+ );
49
+
50
+ const API = "https://api.example.test";
51
+ const CHECKOUT_URL = "https://checkout.stripe.com/c/pay/cs_test_a1";
52
+ const SUCCESS_URL =
53
+ "https://shop.example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}";
54
+ const CANCEL_URL = "https://shop.example.com/pricing";
55
+ const REFUSAL_LINE = /^\[offerings-checkout\] refused reason=[a-z0-9_]+$/;
56
+
57
+ // ─── The fixture: a wire shown set (no ids), parsed before use ──────────────
58
+
59
+ const PERIOD_INTERVAL = {
60
+ monthly: "month",
61
+ yearly: "year",
62
+ once: null,
63
+ } as const;
64
+
65
+ function price(
66
+ plan: string,
67
+ period: OfferingsPrice["period"],
68
+ amount: number,
69
+ ): OfferingsPrice {
70
+ return {
71
+ priceId: `price_${plan.replace(/-/g, "")}${period}`,
72
+ lookupKey: `${plan}_${period}`,
73
+ plan,
74
+ period,
75
+ interval: PERIOD_INTERVAL[period],
76
+ amount,
77
+ currency: "usd",
78
+ };
79
+ }
80
+
81
+ function product(
82
+ plan: string,
83
+ ailkGroup: OfferingsProduct["ailkGroup"],
84
+ prices: OfferingsPrice[],
85
+ ): OfferingsProduct {
86
+ return {
87
+ productId: `prod_${plan.replace(/-/g, "")}`,
88
+ plan,
89
+ name: plan,
90
+ description: null,
91
+ marketingFeatures: [],
92
+ unitLabel: null,
93
+ note: null,
94
+ termsNote: null,
95
+ ailkGroup,
96
+ ailkOrder: null,
97
+ prices,
98
+ recommended: false,
99
+ };
100
+ }
101
+
102
+ const wireShownSetSchema = workspaceShownSetSchema.partial({
103
+ workspaceId: true,
104
+ organizationId: true,
105
+ });
106
+
107
+ function wireShownSet(
108
+ options: { state?: "current" | "expired"; withGrowth?: boolean } = {},
109
+ ): unknown {
110
+ const offerings = [
111
+ ...(options.withGrowth === false
112
+ ? []
113
+ : [
114
+ product("growth", "plan", [
115
+ price("growth", "monthly", 4900),
116
+ price("growth", "yearly", 49000),
117
+ ]),
118
+ ]),
119
+ product("seo-audit", "addon", [price("seo-audit", "monthly", 1950)]),
120
+ ];
121
+ return wireShownSetSchema.parse({
122
+ productIds: offerings.map((o) => o.productId),
123
+ recommended: { plan: null, addon: null },
124
+ offerings,
125
+ state: options.state ?? "current",
126
+ snapshotAt: "2026-10-18T00:00:00.000Z",
127
+ });
128
+ }
129
+
130
+ // ─── The stubbed API ────────────────────────────────────────────────────────
131
+
132
+ type Answer =
133
+ | { status: number; json: unknown }
134
+ | { status: number; text: string }
135
+ | "reject"
136
+ | "hang";
137
+
138
+ const SHOWN: Answer = {
139
+ status: 200,
140
+ json: { ok: true, data: { status: "shown", shownSet: wireShownSet() } },
141
+ };
142
+ const STARTED: Answer = {
143
+ status: 201,
144
+ json: { sessionId: "cs_test_a1", url: CHECKOUT_URL },
145
+ };
146
+
147
+ let shownAnswer: Answer;
148
+ let postAnswer: Answer;
149
+
150
+ function respond(answer: Answer): Promise<Response> {
151
+ if (answer === "reject") return Promise.reject(new TypeError("fetch failed"));
152
+ if (answer === "hang") return new Promise<Response>(() => {});
153
+ if ("text" in answer) {
154
+ return Promise.resolve(
155
+ new Response(answer.text, { status: answer.status }),
156
+ );
157
+ }
158
+ return Promise.resolve(
159
+ new Response(JSON.stringify(answer.json), {
160
+ status: answer.status,
161
+ headers: { "content-type": "application/json" },
162
+ }),
163
+ );
164
+ }
165
+
166
+ const fetchMock = jest.fn(
167
+ (input: string | URL | Request, _init?: RequestInit) => {
168
+ const url = new URL(String(input));
169
+ if (url.pathname === "/v1/content/shown-offerings")
170
+ return respond(shownAnswer);
171
+ if (url.pathname === "/v1/checkout/sessions") return respond(postAnswer);
172
+ return Promise.reject(new Error(`unexpected fetch ${url.pathname}`));
173
+ },
174
+ );
175
+
176
+ function postCalls(): { url: string; init: RequestInit }[] {
177
+ return fetchMock.mock.calls
178
+ .filter(
179
+ ([input]) => new URL(String(input)).pathname === "/v1/checkout/sessions",
180
+ )
181
+ .map(([input, init]) => ({ url: String(input), init: init ?? {} }));
182
+ }
183
+
184
+ function shownCalls(): number {
185
+ return fetchMock.mock.calls.filter(
186
+ ([input]) =>
187
+ new URL(String(input)).pathname === "/v1/content/shown-offerings",
188
+ ).length;
189
+ }
190
+
191
+ function postHeaders(call: { init: RequestInit }): Record<string, string> {
192
+ const lower: Record<string, string> = {};
193
+ for (const [name, value] of Object.entries(
194
+ (call.init.headers ?? {}) as Record<string, string>,
195
+ )) {
196
+ lower[name.toLowerCase()] = value;
197
+ }
198
+ return lower;
199
+ }
200
+
201
+ // ─── The env, the console and the clock ─────────────────────────────────────
202
+
203
+ const env = process.env as Record<string, string | undefined>;
204
+ const ENV_NAMES = [
205
+ "AILK_API_URL",
206
+ "NEXT_PUBLIC_AILK_API_URL",
207
+ "NEXT_PUBLIC_API_URL",
208
+ "NEXT_PUBLIC_BASE_URL",
209
+ "NODE_ENV",
210
+ "OSS_API_INTERNAL_SECRET",
211
+ "AILK_SPOKE_KEY",
212
+ ] as const;
213
+ const savedEnv = Object.fromEntries(ENV_NAMES.map((name) => [name, env[name]]));
214
+
215
+ const CONSOLE_METHODS = ["log", "info", "warn", "error", "debug"] as const;
216
+ let consoleSpies: jest.SpyInstance[] = [];
217
+ let warnSpy: jest.SpyInstance;
218
+
219
+ const realFetch = global.fetch;
220
+
221
+ let addressCounter = 0;
222
+ /** A visitor address no other case uses. */
223
+ function freshAddress(): string {
224
+ addressCounter += 1;
225
+ return `10.20.${Math.floor(addressCounter / 250)}.${(addressCounter % 250) + 1}`;
226
+ }
227
+
228
+ beforeAll(() => {
229
+ jest.useFakeTimers({
230
+ doNotFake: ["nextTick", "setImmediate", "clearImmediate", "queueMicrotask"],
231
+ });
232
+ jest.setSystemTime(Date.UTC(2026, 9, 18, 12, 0, 0));
233
+ global.fetch = fetchMock as jest.Mock;
234
+ });
235
+
236
+ afterAll(() => {
237
+ jest.useRealTimers();
238
+ global.fetch = realFetch;
239
+ for (const name of ENV_NAMES) {
240
+ if (savedEnv[name] === undefined) delete env[name];
241
+ else env[name] = savedEnv[name];
242
+ }
243
+ });
244
+
245
+ beforeEach(() => {
246
+ jest.setSystemTime(Date.now() + 16_000);
247
+ for (const name of ENV_NAMES) delete env[name];
248
+ env.AILK_API_URL = API;
249
+ env.NEXT_PUBLIC_BASE_URL = "https://shop.example.com";
250
+ env.NODE_ENV = "test";
251
+ shownAnswer = SHOWN;
252
+ postAnswer = STARTED;
253
+ fetchMock.mockClear();
254
+ mockCheckLicense.mockReset();
255
+ licenceResolves({ features: ["commerce"] });
256
+ consoleSpies = CONSOLE_METHODS.map((method) =>
257
+ jest.spyOn(console, method).mockImplementation(() => {}),
258
+ );
259
+ warnSpy = consoleSpies[CONSOLE_METHODS.indexOf("warn")]!;
260
+ });
261
+
262
+ afterEach(() => {
263
+ for (const spy of consoleSpies) spy.mockRestore();
264
+ });
265
+
266
+ // ─── The request ────────────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * A buyer's card click on `key`: a top-level same-origin navigation to the
270
+ * wrong host, with a session cookie and a fresh forwarded address. A header
271
+ * set to `null` is left out.
272
+ */
273
+ function buyRequest(
274
+ key: string,
275
+ options: { method?: string; headers?: Record<string, string | null> } = {},
276
+ ): Request {
277
+ const headers: Record<string, string | null> = {
278
+ host: "evil.example",
279
+ cookie: "better-auth.session_token=abc",
280
+ "sec-fetch-mode": "navigate",
281
+ "sec-fetch-dest": "document",
282
+ "sec-fetch-site": "same-origin",
283
+ "x-forwarded-for": freshAddress(),
284
+ ...options.headers,
285
+ };
286
+ const sent = new Headers();
287
+ for (const [name, value] of Object.entries(headers)) {
288
+ if (value !== null) sent.set(name, value);
289
+ }
290
+ return new Request(`https://evil.example/api/offerings/checkout/${key}`, {
291
+ method: options.method ?? "GET",
292
+ headers: sent,
293
+ });
294
+ }
295
+
296
+ function buy(
297
+ key: string,
298
+ request: Request = buyRequest(key),
299
+ ): Promise<Response> {
300
+ return GET(request, { params: Promise.resolve({ lookupKey: key }) });
301
+ }
302
+
303
+ /** Every argument of every console call, as text. */
304
+ function consoleText(): string {
305
+ return consoleSpies
306
+ .flatMap((spy) => spy.mock.calls.flat())
307
+ .map((arg) => String(arg))
308
+ .join("\n");
309
+ }
310
+
311
+ async function expectStarted(res: Response, url = CHECKOUT_URL): Promise<void> {
312
+ expect(res.status).toBe(303);
313
+ expect(res.headers.get("location")).toBe(url);
314
+ expect(res.headers.get("cache-control")).toBe("no-store");
315
+ expect(await res.text()).toBe("");
316
+ }
317
+
318
+ /** Refused: 303 to `/pricing`, no-store, no body, one fixed warn line, no key anywhere. */
319
+ async function expectRefused(
320
+ res: Response,
321
+ key: string,
322
+ reason?: string,
323
+ ): Promise<void> {
324
+ expect(res.status).toBe(303);
325
+ expect(res.headers.get("location")).toBe("/pricing");
326
+ expect(res.headers.get("cache-control")).toBe("no-store");
327
+ const body = await res.text();
328
+ expect(body).toBe("");
329
+ expect(warnSpy).toHaveBeenCalledTimes(1);
330
+ expect(warnSpy.mock.calls[0]).toHaveLength(1);
331
+ expect(warnSpy.mock.calls[0]![0]).toMatch(REFUSAL_LINE);
332
+ if (reason) {
333
+ expect(warnSpy.mock.calls[0]![0]).toBe(
334
+ `[offerings-checkout] refused reason=${reason}`,
335
+ );
336
+ }
337
+ for (const spy of consoleSpies) {
338
+ if (spy !== warnSpy) expect(spy).not.toHaveBeenCalled();
339
+ }
340
+ expect(consoleText()).not.toContain(key);
341
+ for (const [, value] of res.headers) expect(value).not.toContain(key);
342
+ }
343
+
344
+ // ─── AC-1 ───────────────────────────────────────────────────────────────────
345
+
346
+ describe("AC-1 — a shown key on a connected site starts its Checkout", () => {
347
+ it.each(["growth_monthly", "growth_yearly"])(
348
+ "answers 303 to the API's Checkout URL for %s, posting the full key and site-origin redirects",
349
+ async (key) => {
350
+ const res = await buy(key);
351
+ await expectStarted(res);
352
+
353
+ expect(fetchMock).toHaveBeenCalledTimes(2);
354
+ expect(new URL(String(fetchMock.mock.calls[0]![0])).pathname).toBe(
355
+ "/v1/content/shown-offerings",
356
+ );
357
+ const [call] = postCalls();
358
+ expect(call!.url).toBe(`${API}/v1/checkout/sessions`);
359
+ expect(call!.init.method).toBe("POST");
360
+ expect(JSON.parse(String(call!.init.body))).toEqual({
361
+ productCode: key,
362
+ successUrl: SUCCESS_URL,
363
+ cancelUrl: CANCEL_URL,
364
+ });
365
+ expect(postHeaders(call!).cookie).toBeUndefined();
366
+ for (const spy of consoleSpies) expect(spy).not.toHaveBeenCalled();
367
+ },
368
+ );
369
+
370
+ it("forwards the visitor's x-forwarded-for, never the cookie", async () => {
371
+ await expectStarted(
372
+ await buy(
373
+ "growth_monthly",
374
+ buyRequest("growth_monthly", {
375
+ headers: { "x-forwarded-for": "203.0.113.7" },
376
+ }),
377
+ ),
378
+ );
379
+ const headers = postHeaders(postCalls()[0]!);
380
+ expect(headers["x-forwarded-for"]).toBe("203.0.113.7");
381
+ expect(headers.cookie).toBeUndefined();
382
+ expect(headers.origin).toBeUndefined();
383
+ expect(headers.authorization).toBeUndefined();
384
+ });
385
+
386
+ it("forwards x-real-ip as x-forwarded-for when no x-forwarded-for came in", async () => {
387
+ await expectStarted(
388
+ await buy(
389
+ "growth_monthly",
390
+ buyRequest("growth_monthly", {
391
+ headers: { "x-forwarded-for": null, "x-real-ip": "198.51.100.9" },
392
+ }),
393
+ ),
394
+ );
395
+ const headers = postHeaders(postCalls()[0]!);
396
+ expect(headers["x-forwarded-for"]).toBe("198.51.100.9");
397
+ expect(headers.cookie).toBeUndefined();
398
+ });
399
+
400
+ it("forwards x-real-ip when the incoming x-forwarded-for is empty", async () => {
401
+ await expectStarted(
402
+ await buy(
403
+ "growth_monthly",
404
+ buyRequest("growth_monthly", {
405
+ headers: { "x-forwarded-for": "", "x-real-ip": "198.51.100.10" },
406
+ }),
407
+ ),
408
+ );
409
+ expect(postHeaders(postCalls()[0]!)["x-forwarded-for"]).toBe(
410
+ "198.51.100.10",
411
+ );
412
+ });
413
+
414
+ it("sends x-internal-auth from OSS_API_INTERNAL_SECRET to an https API", async () => {
415
+ env.OSS_API_INTERNAL_SECRET = "s3cret";
416
+ await expectStarted(await buy("growth_monthly"));
417
+ expect(postHeaders(postCalls()[0]!)["x-internal-auth"]).toBe("s3cret");
418
+ });
419
+
420
+ it("sends no x-internal-auth when OSS_API_INTERNAL_SECRET is unset", async () => {
421
+ await expectStarted(await buy("growth_monthly"));
422
+ expect(postHeaders(postCalls()[0]!)["x-internal-auth"]).toBeUndefined();
423
+ });
424
+
425
+ it("never sends the secret from a spoke; the spoke's own key goes instead", async () => {
426
+ env.OSS_API_INTERNAL_SECRET = "s3cret";
427
+ env.AILK_SPOKE_KEY = "spk_test_1";
428
+ await expectStarted(await buy("growth_monthly"));
429
+ const headers = postHeaders(postCalls()[0]!);
430
+ expect(headers["x-internal-auth"]).toBeUndefined();
431
+ expect(headers["x-ailk-spoke-key"]).toBe("spk_test_1");
432
+ });
433
+
434
+ it("never sends the secret over clear text to a remote API", async () => {
435
+ env.OSS_API_INTERNAL_SECRET = "s3cret";
436
+ env.AILK_API_URL = "http://api.remote.example";
437
+ await expectStarted(await buy("growth_monthly"));
438
+ const [call] = postCalls();
439
+ expect(call!.url).toBe("http://api.remote.example/v1/checkout/sessions");
440
+ expect(postHeaders(call!)["x-internal-auth"]).toBeUndefined();
441
+ });
442
+ });
443
+
444
+ // ─── AC-2 ───────────────────────────────────────────────────────────────────
445
+
446
+ describe("AC-2 — a refused key, an ungrammatical key and an unconnected site return to /pricing", () => {
447
+ it.each([
448
+ [404, { error: "unknown-product-code" }],
449
+ [503, { error: "tenant_unresolved" }],
450
+ ])("refuses when the API answers %i", async (status, json) => {
451
+ postAnswer = { status, json };
452
+ await expectRefused(
453
+ await buy("growth_monthly"),
454
+ "growth_monthly",
455
+ `api_${status}`,
456
+ );
457
+ expect(fetchMock).toHaveBeenCalledTimes(2);
458
+ expect(postCalls()).toHaveLength(1);
459
+ });
460
+
461
+ it.each([
462
+ "Growth_monthly",
463
+ "growth",
464
+ "growth_weekly",
465
+ "growth_monthly_x",
466
+ `${"a".repeat(41)}_monthly`,
467
+ ])("refuses the ungrammatical key %s with no fetch", async (key) => {
468
+ await expectRefused(await buy(key), key, "ungrammatical");
469
+ expect(fetchMock).not.toHaveBeenCalled();
470
+ });
471
+
472
+ it("refuses an unconnected site after the read, with no POST", async () => {
473
+ shownAnswer = {
474
+ status: 200,
475
+ json: { ok: true, data: { status: "unconnected" } },
476
+ };
477
+ await expectRefused(
478
+ await buy("growth_monthly"),
479
+ "growth_monthly",
480
+ "unconnected",
481
+ );
482
+ expect(fetchMock).toHaveBeenCalledTimes(1);
483
+ expect(postCalls()).toHaveLength(0);
484
+ });
485
+ });
486
+
487
+ // ─── AC-4 ───────────────────────────────────────────────────────────────────
488
+
489
+ describe("AC-4 — every other outcome returns to /pricing, without throwing and within its bound", () => {
490
+ it.each([400, 401, 429, 500, 502])(
491
+ "refuses when the API answers %i",
492
+ async (status) => {
493
+ postAnswer = { status, json: { error: "x" } };
494
+ await expectRefused(
495
+ await buy("growth_monthly"),
496
+ "growth_monthly",
497
+ `api_${status}`,
498
+ );
499
+ },
500
+ );
501
+
502
+ it.each([
503
+ "http://checkout.stripe.com/c/pay/x",
504
+ "javascript:alert(1)",
505
+ "/purchase/success",
506
+ "",
507
+ "https://user:pw@checkout.stripe.com/c/pay/x",
508
+ "https://checkout.stripe.com/c/pay/x\r\nSet-Cookie: a=b",
509
+ "https://checkout.stripe.com/c/pay/ü",
510
+ "https://checkout.stripe.com",
511
+ ])("refuses a 201 whose url is %j", async (url) => {
512
+ postAnswer = { status: 201, json: { sessionId: "cs_test_a1", url } };
513
+ await expectRefused(
514
+ await buy("growth_monthly"),
515
+ "growth_monthly",
516
+ "bad_answer",
517
+ );
518
+ });
519
+
520
+ it("refuses a 201 with no url", async () => {
521
+ postAnswer = { status: 201, json: { sessionId: "cs_test_a1" } };
522
+ await expectRefused(
523
+ await buy("growth_monthly"),
524
+ "growth_monthly",
525
+ "bad_answer",
526
+ );
527
+ });
528
+
529
+ it("refuses a 201 whose body is not JSON", async () => {
530
+ postAnswer = { status: 201, text: "<html>ok</html>" };
531
+ await expectRefused(
532
+ await buy("growth_monthly"),
533
+ "growth_monthly",
534
+ "bad_answer",
535
+ );
536
+ });
537
+
538
+ it("refuses when the POST fetch rejects", async () => {
539
+ postAnswer = "reject";
540
+ await expectRefused(
541
+ await buy("growth_monthly"),
542
+ "growth_monthly",
543
+ "unreachable",
544
+ );
545
+ });
546
+
547
+ it("refuses a POST that never settles once 10 seconds pass", async () => {
548
+ postAnswer = "hang";
549
+ const pending = buy("growth_monthly");
550
+ await jest.advanceTimersByTimeAsync(10_000);
551
+ await expectRefused(await pending, "growth_monthly", "unreachable");
552
+ });
553
+
554
+ it.each<[string, Answer]>([
555
+ ["empty", { status: 200, json: { ok: true, data: { status: "empty" } } }],
556
+ [
557
+ "an expired set",
558
+ {
559
+ status: 200,
560
+ json: {
561
+ ok: true,
562
+ data: {
563
+ status: "shown",
564
+ shownSet: wireShownSet({ state: "expired" }),
565
+ },
566
+ },
567
+ },
568
+ ],
569
+ [
570
+ "a set without the key",
571
+ {
572
+ status: 200,
573
+ json: {
574
+ ok: true,
575
+ data: {
576
+ status: "shown",
577
+ shownSet: wireShownSet({ withGrowth: false }),
578
+ },
579
+ },
580
+ },
581
+ ],
582
+ ["a 500", { status: 500, json: { error: "internal" } }],
583
+ ["a rejected read", "reject"],
584
+ ])(
585
+ "refuses with no POST when the shown read is %s",
586
+ async (_label, answer) => {
587
+ shownAnswer = answer;
588
+ await expectRefused(
589
+ await buy("growth_monthly"),
590
+ "growth_monthly",
591
+ "not_shown",
592
+ );
593
+ expect(postCalls()).toHaveLength(0);
594
+ },
595
+ );
596
+
597
+ it("refuses with no POST once a hung shown read passes 5 seconds", async () => {
598
+ shownAnswer = "hang";
599
+ const pending = buy("growth_monthly");
600
+ await jest.advanceTimersByTimeAsync(5_000);
601
+ await expectRefused(await pending, "growth_monthly", "not_shown");
602
+ expect(postCalls()).toHaveLength(0);
603
+ });
604
+
605
+ it.each<[string, () => void]>([
606
+ ["no commerce feature", () => licenceResolves({ features: [] })],
607
+ [
608
+ "a revoked licence",
609
+ () => licenceResolves({ features: ["commerce"], revoked: true }),
610
+ ],
611
+ [
612
+ "a licence read that throws",
613
+ () => mockCheckLicense.mockRejectedValue(new Error("no licence")),
614
+ ],
615
+ ])("refuses with no fetch on %s", async (_label, arrange) => {
616
+ arrange();
617
+ await expectRefused(
618
+ await buy("growth_monthly"),
619
+ "growth_monthly",
620
+ "no_claim",
621
+ );
622
+ expect(fetchMock).not.toHaveBeenCalled();
623
+ });
624
+
625
+ it("refuses with no fetch in production with no API origin", async () => {
626
+ env.NODE_ENV = "production";
627
+ delete env.AILK_API_URL;
628
+ await expectRefused(
629
+ await buy("growth_monthly"),
630
+ "growth_monthly",
631
+ "unconnected",
632
+ );
633
+ expect(fetchMock).not.toHaveBeenCalled();
634
+ });
635
+
636
+ it.each(["not a url", "https://example.com"])(
637
+ "refuses with no POST when NEXT_PUBLIC_BASE_URL is %j",
638
+ async (base) => {
639
+ env.NEXT_PUBLIC_BASE_URL = base;
640
+ await expectRefused(
641
+ await buy("growth_monthly"),
642
+ "growth_monthly",
643
+ "no_origin",
644
+ );
645
+ expect(postCalls()).toHaveLength(0);
646
+ },
647
+ );
648
+ });
649
+
650
+ // ─── AC-5 ───────────────────────────────────────────────────────────────────
651
+
652
+ describe("AC-5 — only a buyer's page load starts anything", () => {
653
+ it.each<
654
+ [string, { method?: string; headers?: Record<string, string | null> }]
655
+ >([
656
+ ["HEAD", { method: "HEAD" }],
657
+ [
658
+ "no fetch metadata (a crawler)",
659
+ { headers: { "sec-fetch-mode": null, "sec-fetch-dest": null } },
660
+ ],
661
+ [
662
+ "an <img> load",
663
+ { headers: { "sec-fetch-mode": "no-cors", "sec-fetch-dest": "image" } },
664
+ ],
665
+ [
666
+ "a fetch()",
667
+ { headers: { "sec-fetch-mode": "cors", "sec-fetch-dest": "empty" } },
668
+ ],
669
+ [
670
+ "a document load that is not a navigation",
671
+ { headers: { "sec-fetch-mode": "cors" } },
672
+ ],
673
+ ["a cross-site load", { headers: { "sec-fetch-site": "cross-site" } }],
674
+ ["sec-purpose: prefetch", { headers: { "sec-purpose": "prefetch" } }],
675
+ ["purpose: prefetch", { headers: { purpose: "prefetch" } }],
676
+ ["x-moz: prefetch", { headers: { "x-moz": "prefetch" } }],
677
+ ["a Next router prefetch", { headers: { "next-router-prefetch": "1" } }],
678
+ ["an RSC fetch", { headers: { rsc: "1" } }],
679
+ ])("refuses %s with no fetch", async (_label, options) => {
680
+ const key = "growth_monthly";
681
+ await expectRefused(
682
+ await buy(key, buyRequest(key, options)),
683
+ key,
684
+ "unintended",
685
+ );
686
+ expect(fetchMock).not.toHaveBeenCalled();
687
+ });
688
+
689
+ it.each(["same-origin", "same-site", "none"])(
690
+ "starts the Checkout for a navigation with sec-fetch-site %s",
691
+ async (site) => {
692
+ const key = "growth_monthly";
693
+ await expectStarted(
694
+ await buy(
695
+ key,
696
+ buyRequest(key, { headers: { "sec-fetch-site": site } }),
697
+ ),
698
+ );
699
+ },
700
+ );
701
+ });
702
+
703
+ // ─── AC-6 ───────────────────────────────────────────────────────────────────
704
+
705
+ describe("AC-6 — one shared read, a switched-off product stops within it, a per-visitor budget", () => {
706
+ it("shares one read across concurrent clicks and re-reads after 15 seconds", async () => {
707
+ const [first, second] = await Promise.all([
708
+ buy("growth_monthly"),
709
+ buy("growth_monthly"),
710
+ ]);
711
+ await expectStarted(first!);
712
+ await expectStarted(second!);
713
+ expect(shownCalls()).toBe(1);
714
+ expect(postCalls()).toHaveLength(2);
715
+
716
+ shownAnswer = {
717
+ status: 200,
718
+ json: {
719
+ ok: true,
720
+ data: {
721
+ status: "shown",
722
+ shownSet: wireShownSet({ withGrowth: false }),
723
+ },
724
+ },
725
+ };
726
+ jest.setSystemTime(Date.now() + 16_000);
727
+ await expectRefused(
728
+ await buy("growth_monthly"),
729
+ "growth_monthly",
730
+ "not_shown",
731
+ );
732
+ expect(shownCalls()).toBe(2);
733
+ expect(postCalls()).toHaveLength(2);
734
+ });
735
+
736
+ it("starts at most 5 Checkouts per visitor per 10 minutes", async () => {
737
+ const from = (address: string) =>
738
+ buyRequest("growth_monthly", { headers: { "x-forwarded-for": address } });
739
+
740
+ for (let i = 0; i < 5; i += 1) {
741
+ await expectStarted(await buy("growth_monthly", from("192.0.2.50")));
742
+ }
743
+ expect(postCalls()).toHaveLength(5);
744
+
745
+ await expectRefused(
746
+ await buy("growth_monthly", from("192.0.2.50")),
747
+ "growth_monthly",
748
+ "rate_limited",
749
+ );
750
+ expect(postCalls()).toHaveLength(5);
751
+
752
+ await expectStarted(await buy("growth_monthly", from("192.0.2.51")));
753
+ expect(postCalls()).toHaveLength(6);
754
+
755
+ jest.setSystemTime(Date.now() + 10 * 60_000);
756
+ await expectStarted(await buy("growth_monthly", from("192.0.2.50")));
757
+ expect(postCalls()).toHaveLength(7);
758
+ });
759
+
760
+ it("keys the budget on the first 64 characters of an address", async () => {
761
+ const prefix = "f".repeat(64);
762
+ for (let i = 0; i < 5; i += 1) {
763
+ await expectStarted(
764
+ await buy(
765
+ "growth_monthly",
766
+ buyRequest("growth_monthly", {
767
+ headers: { "x-forwarded-for": `${prefix}${i}` },
768
+ }),
769
+ ),
770
+ );
771
+ }
772
+ await expectRefused(
773
+ await buy(
774
+ "growth_monthly",
775
+ buyRequest("growth_monthly", {
776
+ headers: { "x-forwarded-for": `${prefix}x` },
777
+ }),
778
+ ),
779
+ "growth_monthly",
780
+ "rate_limited",
781
+ );
782
+ expect(postCalls()).toHaveLength(5);
783
+ });
784
+
785
+ it("does not count a request with no visitor address", async () => {
786
+ for (let i = 0; i < 7; i += 1) {
787
+ await expectStarted(
788
+ await buy(
789
+ "growth_monthly",
790
+ buyRequest("growth_monthly", {
791
+ headers: { "x-forwarded-for": null },
792
+ }),
793
+ ),
794
+ );
795
+ }
796
+ expect(postCalls()).toHaveLength(7);
797
+ });
798
+
799
+ it("reaches neither Stripe nor the session cookie itself", () => {
800
+ for (const banned of [
801
+ "stripe-client",
802
+ "spoke-checkout",
803
+ "STRIPE_",
804
+ "cookies(",
805
+ "lib/checkout",
806
+ ]) {
807
+ expect(ROUTE_SOURCE).not.toContain(banned);
808
+ }
809
+ });
810
+ });