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
@@ -5,12 +5,13 @@
5
5
  * D17). Posts `unsubscribeAction(token)` on click only — never on render,
6
6
  * so a mail scanner's GET prefetch of the page writes nothing.
7
7
  *
8
- * Reads its own strings through `useTranslations` (the island idiom
9
- * `FlowStepperClient` uses) rather than taking them as props: the done line
10
- * needs the purpose's description formatted in, and a formatting FUNCTION
11
- * handed across from the page (a Server Component) cannot be serialized
12
- * into the Flight payload — the scaffold matrix's `GET /unsubscribe -> 500`
13
- * (`unsubscribe-rsc-boundary.test.tsx` holds the regression).
8
+ * The button label and the done message are the newsletter magnet's
9
+ * (#8847 D12), passed as STRINGS: a formatting FUNCTION handed across from the
10
+ * page (a Server Component) cannot be serialized into the Flight payload —
11
+ * the scaffold matrix's `GET /unsubscribe -> 500`
12
+ * (`unsubscribe-rsc-boundary.test.tsx` holds the regression). The token
13
+ * states (invalid, already, unavailable) read their own catalog lines
14
+ * through `useTranslations`.
14
15
  */
15
16
 
16
17
  import { Button, InlineMessage } from "@working-theory/ui/primitives";
@@ -19,7 +20,21 @@ import { useState, useTransition, type ReactElement } from "react";
19
20
 
20
21
  import { unsubscribeAction, type UnsubscribeResult } from "./unsubscribe.actions";
21
22
 
22
- export function UnsubscribeButton({ token }: { token: string | null }): ReactElement {
23
+ interface UnsubscribeButtonProps {
24
+ token: string | null;
25
+ /** The button label (the magnet's `unsubscribe.cta`). */
26
+ cta: string;
27
+ /** The done message (the magnet's `unsubscribe.done`). */
28
+ doneHeadline: string;
29
+ doneBody?: string;
30
+ }
31
+
32
+ export function UnsubscribeButton({
33
+ token,
34
+ cta,
35
+ doneHeadline,
36
+ doneBody,
37
+ }: UnsubscribeButtonProps): ReactElement {
23
38
  const t = useTranslations("unsubscribe");
24
39
  const [result, setResult] = useState<UnsubscribeResult | null>(null);
25
40
  const [pending, startTransition] = useTransition();
@@ -35,7 +50,13 @@ export function UnsubscribeButton({ token }: { token: string | null }): ReactEle
35
50
  if (result?.kind === "done") {
36
51
  return (
37
52
  <InlineMessage variant="success" role="status" data-testid="unsubscribe-done">
38
- {t("done", { description: result.description })}
53
+ {/* Headline over body, as the /contact ending stacks them (#8847 D12). */}
54
+ <span className="flex flex-col">
55
+ <span className="font-semibold" data-testid="unsubscribe-done-headline">
56
+ {doneHeadline}
57
+ </span>
58
+ {doneBody !== undefined && <span className="mt-1">{doneBody}</span>}
59
+ </span>
39
60
  </InlineMessage>
40
61
  );
41
62
  }
@@ -67,7 +88,7 @@ export function UnsubscribeButton({ token }: { token: string | null }): ReactEle
67
88
  }}
68
89
  data-testid="unsubscribe-button"
69
90
  >
70
- {t("button")}
91
+ {cta}
71
92
  </Button>
72
93
  );
73
94
  }
@@ -0,0 +1,316 @@
1
+ // The Offerings buy route (#9051, spec
2
+ // docs/specs/9051-the-offerings-buy-route-api-spec.md). Every Stripe card on
3
+ // /pricing links `/api/offerings/checkout/<lookup_key>` (#8927); this route
4
+ // starts the hosted Checkout for that key and answers a 303 to it.
5
+ //
6
+ // Only a buyer's page load starts anything: a prefetch, a crawler, a
7
+ // cross-site load or an ungrammatical key is refused before any call. The
8
+ // route then confirms, from a shown-set read shared for 15 seconds, that the
9
+ // site is connected and shows the key — an unconnected site's POST would take
10
+ // the API's env path (#8931 A9) — and POSTs `/v1/checkout/sessions` with
11
+ // success and cancel URLs on the site's configured origin, never the
12
+ // request's. Every refusal is a 303 to `/pricing` with no body and one fixed
13
+ // log line; no response or log line carries the key.
14
+ import { checkout, offerings } from "@working-theory/api-client";
15
+ import { parseLookupKey } from "@working-theory/validation";
16
+
17
+ import { isShownLookupKey } from "../../../../../lib/offerings-pricing";
18
+ import { FALLBACK_BASE_URL } from "../../../../../lib/site-brand";
19
+ import { resolveCommerceClaim } from "../../../../../src/lib/commerce-claim";
20
+ import { resolveBaseUrl } from "../../../../../src/lib/routes";
21
+
22
+ export const runtime = "nodejs";
23
+ export const dynamic = "force-dynamic";
24
+
25
+ type RefusalReason =
26
+ | "unintended"
27
+ | "ungrammatical"
28
+ | "no_claim"
29
+ | "unconnected"
30
+ | "not_shown"
31
+ | "no_origin"
32
+ | "rate_limited"
33
+ | "unreachable"
34
+ | "bad_answer"
35
+ | `api_${number}`;
36
+
37
+ /** D6 — relative, so a misconfigured base URL can never send a buyer off-site. */
38
+ function refuse(reason: RefusalReason): Response {
39
+ console.warn(`[offerings-checkout] refused reason=${reason}`);
40
+ return new Response(null, {
41
+ status: 303,
42
+ headers: { Location: "/pricing", "Cache-Control": "no-store" },
43
+ });
44
+ }
45
+
46
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1"]);
47
+ const PLACEHOLDER_ORIGIN = new URL(FALLBACK_BASE_URL).origin;
48
+
49
+ /**
50
+ * D2 step 1 — a top-level browser navigation, and not a prefetch, an RSC
51
+ * fetch or a cross-site load: the spoke buy route's refusal set plus the
52
+ * navigation requirement, so no crawler, unfurler, `<img>` or `fetch` opens a
53
+ * Stripe session.
54
+ */
55
+ function isBuyerNavigation(request: Request): boolean {
56
+ const headers = request.headers;
57
+ if (request.method === "HEAD") return false;
58
+ if (headers.get("sec-fetch-mode") !== "navigate") return false;
59
+ if (headers.get("sec-fetch-dest") !== "document") return false;
60
+ if (headers.get("sec-fetch-site") === "cross-site") return false;
61
+ const secPurpose = (headers.get("sec-purpose") ?? "").toLowerCase();
62
+ const purpose = (headers.get("purpose") ?? "").toLowerCase();
63
+ if (secPurpose.includes("prefetch") || purpose.includes("prefetch"))
64
+ return false;
65
+ if ((headers.get("x-moz") ?? "").toLowerCase() === "prefetch") return false;
66
+ if (headers.get("next-router-prefetch") !== null) return false;
67
+ if (headers.get("rsc") !== null) return false;
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * The API origin, in the order `getApiBase()` in
73
+ * packages/api-client/src/http.ts reads it.
74
+ */
75
+ function configuredApiBase(): string | undefined {
76
+ return (
77
+ process.env.AILK_API_URL ||
78
+ process.env.NEXT_PUBLIC_AILK_API_URL ||
79
+ process.env.NEXT_PUBLIC_API_URL ||
80
+ undefined
81
+ );
82
+ }
83
+
84
+ /** `work`'s answer, or `late` once `ms` pass first. */
85
+ function settleWithin<T>(work: Promise<T>, ms: number, late: T): Promise<T> {
86
+ let timer: ReturnType<typeof setTimeout> | undefined;
87
+ const bound = new Promise<T>((resolve) => {
88
+ timer = setTimeout(() => resolve(late), ms);
89
+ });
90
+ return Promise.race([work, bound]).finally(() => clearTimeout(timer));
91
+ }
92
+
93
+ // ─── D3 — the shared shown-set read ─────────────────────────────────────────
94
+
95
+ type ShownRead =
96
+ | { kind: "unconnected" }
97
+ | { kind: "shown"; shownSet: unknown }
98
+ | { kind: "other" };
99
+
100
+ const OTHER: ShownRead = { kind: "other" };
101
+
102
+ /** `/pricing`'s window: a product switched off stops being bought within it. */
103
+ const READ_TTL_MS = 15_000;
104
+ const READ_TIMEOUT_MS = 5_000;
105
+
106
+ const reads = new Map<string, { at: number; read: Promise<ShownRead> }>();
107
+
108
+ /** The shown set as the page reads it; the body is untrusted. */
109
+ async function readShown(): Promise<ShownRead> {
110
+ try {
111
+ const res = await offerings.shown();
112
+ if (!res.ok) return OTHER;
113
+ const body: unknown = res.body;
114
+ if (body === null || typeof body !== "object") return OTHER;
115
+ const { status, shownSet } = body as {
116
+ status?: unknown;
117
+ shownSet?: unknown;
118
+ };
119
+ if (status === "unconnected") return { kind: "unconnected" };
120
+ if (status === "shown") return { kind: "shown", shownSet };
121
+ return OTHER;
122
+ } catch {
123
+ return OTHER;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * One read per instance per READ_TTL_MS: `offerings.shown()` forwards no
129
+ * visitor header, so a read per click would count every buyer against this
130
+ * server's one per-IP limit at the API. Concurrent and failed reads are
131
+ * shared too, and the shared promise settles within READ_TIMEOUT_MS.
132
+ */
133
+ function sharedShownRead(): Promise<ShownRead> {
134
+ const now = Date.now();
135
+ const hit = reads.get("shown-offerings");
136
+ if (hit && now - hit.at < READ_TTL_MS) return hit.read;
137
+ const read = settleWithin(readShown(), READ_TIMEOUT_MS, OTHER);
138
+ reads.set("shown-offerings", { at: now, read });
139
+ return read;
140
+ }
141
+
142
+ // ─── D2 steps 6 and 7 — the site origin and the per-visitor budget ──────────
143
+
144
+ /**
145
+ * The configured site origin: `https:`, or `http:` on loopback, and never
146
+ * the placeholder `resolveBaseUrl()` answers when no base URL is set.
147
+ */
148
+ function siteOrigin(): string | null {
149
+ let url: URL;
150
+ try {
151
+ url = new URL(resolveBaseUrl());
152
+ } catch {
153
+ return null;
154
+ }
155
+ const secure =
156
+ url.protocol === "https:" ||
157
+ (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
158
+ if (!secure || url.origin === PLACEHOLDER_ORIGIN) return null;
159
+ return url.origin;
160
+ }
161
+
162
+ const BUDGET_STARTS = 5;
163
+ const BUDGET_WINDOW_MS = 10 * 60_000;
164
+ const BUDGET_MAX_ADDRESSES = 10_000;
165
+ /** An address key is cut to this length, so the Map cannot grow by key size. */
166
+ const BUDGET_ADDRESS_LENGTH = 64;
167
+
168
+ const starts = new Map<string, { windowStart: number; count: number }>();
169
+
170
+ function visitorAddress(headers: Headers): string | null {
171
+ const address =
172
+ headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
173
+ headers.get("x-real-ip")?.trim();
174
+ return address ? address.slice(0, BUDGET_ADDRESS_LENGTH) : null;
175
+ }
176
+
177
+ /**
178
+ * Charges one start to `address`, or answers false when its window is spent.
179
+ * The oldest-inserted address is evicted at BUDGET_MAX_ADDRESSES.
180
+ */
181
+ function spendStart(address: string, now: number): boolean {
182
+ const entry = starts.get(address);
183
+ if (entry && now - entry.windowStart < BUDGET_WINDOW_MS) {
184
+ if (entry.count >= BUDGET_STARTS) return false;
185
+ entry.count += 1;
186
+ return true;
187
+ }
188
+ if (entry) {
189
+ starts.delete(address);
190
+ } else if (starts.size >= BUDGET_MAX_ADDRESSES) {
191
+ const oldest = starts.keys().next().value;
192
+ if (oldest !== undefined) starts.delete(oldest);
193
+ }
194
+ starts.set(address, { windowStart: now, count: 1 });
195
+ return true;
196
+ }
197
+
198
+ // ─── D2 steps 8 and 9 — the start and its answer ────────────────────────────
199
+
200
+ const START_TIMEOUT_MS = 10_000;
201
+
202
+ /**
203
+ * D5 — the visitor's forwarded address, so the API's per-IP bucket counts
204
+ * buyers, and the internal secret only from a site that is not a spoke and
205
+ * only to an `https:` or loopback API. Nothing else of the request is sent.
206
+ */
207
+ function startHeaders(incoming: Headers): Record<string, string> {
208
+ const headers: Record<string, string> = {};
209
+ const visitor = incoming.get("x-forwarded-for") || incoming.get("x-real-ip");
210
+ if (visitor) headers["x-forwarded-for"] = visitor;
211
+
212
+ const secret = process.env.OSS_API_INTERNAL_SECRET;
213
+ if (secret && !process.env.AILK_SPOKE_KEY?.trim() && isSafeApiTransport()) {
214
+ headers["x-internal-auth"] = secret;
215
+ }
216
+ return headers;
217
+ }
218
+
219
+ function isSafeApiTransport(): boolean {
220
+ try {
221
+ const url = new URL(configuredApiBase() ?? "http://localhost:3001");
222
+ return url.protocol === "https:" || LOOPBACK_HOSTS.has(url.hostname);
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * The answer's Checkout URL: `https:`, no credentials, and exactly its own
230
+ * canonical form, so `Location` never carries a string the parser would
231
+ * rewrite (a CR, LF or tab, a non-ASCII character, a missing slash).
232
+ */
233
+ function checkoutUrl(body: unknown): string | null {
234
+ if (body === null || typeof body !== "object") return null;
235
+ const { url } = body as { url?: unknown };
236
+ if (typeof url !== "string") return null;
237
+ let parsed: URL;
238
+ try {
239
+ parsed = new URL(url);
240
+ } catch {
241
+ return null;
242
+ }
243
+ if (parsed.protocol !== "https:") return null;
244
+ if (parsed.username || parsed.password) return null;
245
+ return parsed.href === url ? url : null;
246
+ }
247
+
248
+ // ─── The ladder (D2) ────────────────────────────────────────────────────────
249
+
250
+ interface BuyContext {
251
+ params: Promise<{ lookupKey: string }>;
252
+ }
253
+
254
+ async function startCheckout(
255
+ request: Request,
256
+ ctx: BuyContext,
257
+ ): Promise<Response> {
258
+ const { lookupKey } = await ctx.params;
259
+
260
+ if (!isBuyerNavigation(request)) return refuse("unintended");
261
+ if (!parseLookupKey(lookupKey)) return refuse("ungrammatical");
262
+ if (!(await resolveCommerceClaim())) return refuse("no_claim");
263
+ if (process.env.NODE_ENV === "production" && !configuredApiBase()) {
264
+ return refuse("unconnected");
265
+ }
266
+
267
+ const shown = await sharedShownRead();
268
+ if (shown.kind === "unconnected") return refuse("unconnected");
269
+ if (shown.kind !== "shown" || !isShownLookupKey(shown.shownSet, lookupKey)) {
270
+ return refuse("not_shown");
271
+ }
272
+
273
+ const origin = siteOrigin();
274
+ if (!origin) return refuse("no_origin");
275
+
276
+ const address = visitorAddress(request.headers);
277
+ if (address && !spendStart(address, Date.now())) {
278
+ return refuse("rate_limited");
279
+ }
280
+
281
+ const started = await settleWithin(
282
+ checkout
283
+ .createSession(
284
+ {
285
+ productCode: lookupKey,
286
+ successUrl: `${origin}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
287
+ cancelUrl: `${origin}/pricing`,
288
+ },
289
+ { headers: startHeaders(request.headers) },
290
+ )
291
+ .catch(() => null),
292
+ START_TIMEOUT_MS,
293
+ null,
294
+ );
295
+ if (!started) return refuse("unreachable");
296
+ if (!started.ok) return refuse(`api_${started.status}`);
297
+
298
+ const url = checkoutUrl(started.body);
299
+ if (!url) return refuse("bad_answer");
300
+ return new Response(null, {
301
+ status: 303,
302
+ headers: { Location: url, "Cache-Control": "no-store" },
303
+ });
304
+ }
305
+
306
+ // platform-decisions-waiver: api-versioning — a browser buy link that redirects to Stripe Checkout, not a versioned API contract (spec 9051 D1)
307
+ export async function GET(
308
+ request: Request,
309
+ ctx: BuyContext,
310
+ ): Promise<Response> {
311
+ try {
312
+ return await startCheckout(request, ctx);
313
+ } catch {
314
+ return refuse("unreachable");
315
+ }
316
+ }