create-ailk 0.21.0 → 0.23.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 (342) hide show
  1. package/component-catalog.json +186 -8
  2. package/component-catalog.md +9 -7
  3. package/dist/add-module.js +20 -14
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +12 -3
  8. package/dist/derive-architecture.js +20 -8
  9. package/dist/design-package.js +7 -2
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +5 -5
  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 +3 -2
  15. package/dist/lib/scaffold-render-sweep.js +5 -8
  16. package/dist/module-architecture.js +99 -9
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/programmatic.js +19 -5
  20. package/dist/seed-flows.d.ts +4 -1
  21. package/dist/seed-flows.js +50 -4
  22. package/dist/site-chrome.d.ts +8 -0
  23. package/dist/site-chrome.js +17 -1
  24. package/dist/spoke-checkout.d.ts +65 -41
  25. package/dist/spoke-checkout.js +208 -430
  26. package/dist/starter-grid.d.ts +0 -3
  27. package/dist/starter-grid.js +24 -14
  28. package/dist/surfaces.js +52 -26
  29. package/dist/sync-routes.d.ts +38 -1
  30. package/dist/sync-routes.js +187 -13
  31. package/dist/template-record.d.ts +4 -3
  32. package/dist/template-record.js +8 -4
  33. package/dist/topology.js +25 -1
  34. package/dist/update.js +15 -0
  35. package/package.json +3 -3
  36. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  37. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  38. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  39. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  41. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  43. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  48. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  49. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  50. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  51. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  52. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  53. package/templates/.env.example +9 -7
  54. package/templates/.github/workflows/ci.yml +13 -0
  55. package/templates/README.md +1 -0
  56. package/templates/_gitignore +4 -0
  57. package/templates/apps/api/.env.example +27 -0
  58. package/templates/apps/api/CLAUDE.md +87 -17
  59. package/templates/apps/api/package.json +1 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  61. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  62. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  63. package/templates/apps/api/src/config/index.ts +109 -0
  64. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  65. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  66. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  67. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  68. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  69. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  70. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  71. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  72. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  73. package/templates/apps/api/src/lib/stripe.ts +74 -4
  74. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  75. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  77. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  78. package/templates/apps/api/src/middleware/auth.ts +13 -6
  79. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  80. package/templates/apps/api/src/openapi/spec.ts +33 -3
  81. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  82. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  83. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  84. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  85. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  86. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  87. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  88. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  89. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  90. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  91. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  92. package/templates/apps/api/src/routes/consent/account.ts +268 -0
  93. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  94. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  95. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  96. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  97. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  98. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  99. package/templates/apps/api/src/routes/content/index.ts +16 -2
  100. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  101. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  102. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  103. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  104. package/templates/apps/api/src/routes/leads/index.ts +164 -7
  105. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  106. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  111. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  112. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  113. package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
  114. package/templates/apps/api/src/server.ts +163 -46
  115. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  116. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  117. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  118. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
  119. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  120. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  121. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  122. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  123. package/templates/apps/api/src/services/consent.ts +95 -0
  124. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  125. package/templates/apps/api/src/services/entitlements.ts +157 -1
  126. package/templates/apps/api/src/services/lead-routing.ts +431 -47
  127. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  128. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  129. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  130. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  131. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  132. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  133. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  134. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  135. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  136. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  137. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  138. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  139. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  140. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  141. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  142. package/templates/apps/api/src/services/person.ts +164 -17
  143. package/templates/apps/api/src/services/plan-offerings.ts +125 -0
  144. package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
  145. package/templates/apps/mcp/CLAUDE.md +22 -14
  146. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  147. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  148. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  149. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  150. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  151. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  152. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  153. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  154. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  155. package/templates/apps/mcp/src/server.ts +5 -0
  156. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  157. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  158. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  159. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  160. package/templates/apps/web/.env.example +24 -31
  161. package/templates/apps/web/CLAUDE.md +393 -9
  162. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  163. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  164. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  165. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  166. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  167. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  168. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  169. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  170. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  171. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  172. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  173. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  174. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  176. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  177. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  178. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  179. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  183. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  184. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  185. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  186. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  187. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  188. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  189. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  190. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  191. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  192. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  193. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  194. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  195. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  196. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
  197. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
  198. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
  199. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
  200. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  201. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  202. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
  203. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
  204. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
  205. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  206. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  207. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  208. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  209. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  210. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  211. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  212. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  213. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  214. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
  215. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
  216. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  217. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
  218. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  219. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
  220. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  221. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  222. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
  223. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  224. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  225. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  226. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  227. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  228. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  229. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  230. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  231. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  232. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  233. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  234. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  235. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  236. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  237. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  238. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  239. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  240. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  241. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  242. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  243. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  244. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  245. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  246. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  247. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  248. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  249. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  250. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  251. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  252. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  253. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  254. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  255. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  256. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  257. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  258. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  259. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  260. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  261. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  262. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  263. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  264. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  265. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  266. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  267. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  268. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  269. package/templates/apps/web/auth.ts +28 -2
  270. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  271. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  272. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  273. package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
  274. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  275. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  276. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  277. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  278. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
  279. package/templates/apps/web/lib/account-invite.ts +37 -0
  280. package/templates/apps/web/lib/auth-routes.ts +36 -2
  281. package/templates/apps/web/lib/checkout-config.ts +16 -0
  282. package/templates/apps/web/lib/consent-policies.ts +22 -0
  283. package/templates/apps/web/lib/deck-page.ts +7 -0
  284. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  285. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  286. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  287. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  288. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  289. package/templates/apps/web/lib/modules.generated.ts +9 -0
  290. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  291. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  292. package/templates/apps/web/lib/posting-title.ts +17 -0
  293. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  294. package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
  295. package/templates/apps/web/lib/site-chrome.ts +2 -0
  296. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  297. package/templates/apps/web/messages/en.json +71 -20
  298. package/templates/apps/web/middleware.ts +12 -2
  299. package/templates/apps/web/package.json +1 -0
  300. package/templates/apps/web/security-headers.config.ts +17 -0
  301. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  302. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  303. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  304. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  305. package/templates/content/en/BlogList/blog.mdx +4 -71
  306. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  307. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  308. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  309. package/templates/content/en/BookCall/start-project.mdx +1 -17
  310. package/templates/content/en/ContactPage/contact.mdx +6 -63
  311. package/templates/content/en/HomePage/home.mdx +3 -21
  312. package/templates/database/CHANGELOG.md +69 -0
  313. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  314. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  315. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  316. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  317. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  318. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  319. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  320. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  321. package/templates/database/__tests__/seed.test.ts +64 -1
  322. package/templates/database/auth/schema.prisma +4 -0
  323. package/templates/database/config/schema.prisma +91 -0
  324. package/templates/database/inbox/schema.prisma +17 -2
  325. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  326. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  327. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  328. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  329. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  330. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  331. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  332. package/templates/database/package.json +1 -1
  333. package/templates/database/seed.ts +62 -3
  334. package/versions.json +35 -35
  335. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  336. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
  337. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  338. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  339. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  340. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  341. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  342. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -1,78 +1,82 @@
1
1
  /**
2
- * The spoke checkout slice (#7838) — what a spoke carrying `commerce` gets in
3
- * `apps/web`: the buy route and the Stripe webhook route (allowed to import
4
- * the checkout package, #7873), the site's own `lib/checkout-config.ts`, and
5
- * the thank-you and unavailable return pages. A spoke that signs in through
6
- * its hub also gets the billing action, the third file allowed to import the
7
- * package, and the web seam render that reaches it (#8217 D6–D8).
2
+ * The spoke checkout slice (#7838, #8748) — what a spoke carrying `commerce`
3
+ * gets in `apps/web`, copied from the template tree: the site's own
4
+ * `lib/checkout-config.ts` (its stamp) and the thank-you and unavailable
5
+ * return pages with their tests (`SPOKE_CHECKOUT_TEMPLATE_PATHS`). A spoke
6
+ * that signs in through its hub also gets the billing action, the one file
7
+ * allowed to import the package besides the thank-you page, and the web seam
8
+ * render that reaches it (#8217 D6–D8); that action is rendered here.
9
+ *
10
+ * A spoke sells through its hub's Offerings buy route
11
+ * (`apps/web/app/api/offerings/checkout/[lookupKey]/route.ts`), never its own
12
+ * key. The legacy env-key buy route, its Stripe webhook route, `lib/checkout.ts`
13
+ * (the fulfilment) and the checkout e2e workflow are retired (#9142):
14
+ * `create-ailk sync-routes --apply` removes them from an existing spoke
15
+ * (`RETIRED_SPOKE_CHECKOUT_PATHS`, sync-routes.ts).
8
16
  *
9
17
  * Every guarantee lives in `@working-theory/spoke-checkout` and arrives by a
10
- * package bump; these files are thin and, apart from the config's one stamp
11
- * line, byte-identical across spokes at one create-ailk version (roadmap D3,
12
- * AC-SPOKE-02). A hub never gets them: its checkout is `apps/api`'s.
18
+ * package bump; the copied files are `apps/web` source, tested in the
19
+ * monorepo and kept current by `create-ailk update`, and apart from the
20
+ * config's one stamp line they are byte-identical across spokes at one
21
+ * create-ailk version (roadmap D3, AC-SPOKE-02). A hub never gets them.
13
22
  *
14
- * Spec: docs/specs/7838-spoke-web-checkout-slice-spec.md (D3–D7, D9).
23
+ * Spec: docs/specs/7838-spoke-web-checkout-slice-spec.md (D3–D7, D9);
24
+ * docs/specs/8748-a-standard-spoke-checkout-route-spec.md (D2, D3).
15
25
  *
16
- * Pure module: no `import.meta.url`, so jest imports it directly. Every
17
- * package specifier in a rendered file is interpolated from a constant, so the
18
- * monorepo's own architecture lint never reads a rendered line as an import
19
- * create-ailk makes (the `CONTENT_ADAPTERS_PACKAGE` idiom in topology.ts).
26
+ * Pure module: no `import.meta.url`, so jest imports it directly — the caller
27
+ * passes `templatesDir`. Every package specifier in a rendered file is
28
+ * interpolated from a constant, so the monorepo's own architecture lint never
29
+ * reads a rendered line as an import create-ailk makes (the
30
+ * `CONTENT_ADAPTERS_PACKAGE` idiom in topology.ts).
20
31
  */
21
32
  import { createHash } from "node:crypto";
22
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
- import { dirname, join } from "node:path";
24
- import { SPOKE_CHECKOUT_ROUTE_FILES } from "./derive-architecture.js";
33
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync, } from "node:fs";
34
+ import { dirname, join, resolve, sep } from "node:path";
35
+ import { buildScaffoldMarkers, renderTemplateFile, templateDestination } from "./copy.js";
36
+ import { SPOKE_BILLING_ACTION_PATH } from "./derive-architecture.js";
25
37
  import { ENV_CATALOG } from "./lib/env-catalog.js";
26
38
  import { diffTwoStrings, } from "./lib/extract-module-bundle.js";
27
39
  import { substituteWorkspaceVersions, } from "./substitute.js";
28
40
  import { TopologyError, WEB_SEAM_PATH, renderWebSeam, } from "./topology.js";
29
- /** The checkout package a spoke's two route files import. */
41
+ /** The checkout package a spoke's billing action and thank-you page import. */
30
42
  export const SPOKE_CHECKOUT_PACKAGE = "@working-theory/spoke-checkout";
31
43
  const API_CLIENT_PACKAGE = "@working-theory/api-client";
32
- const UI_SECTIONS_PACKAGE = "@working-theory/ui/sections";
33
- const VALIDATION_PACKAGE = "@working-theory/validation";
34
- /** The site's checkout data — written once, the site's from then on. */
44
+ /** The site's checkout stamp — written once, the site's from then on (#8748 D2). */
35
45
  export const SPOKE_CHECKOUT_CONFIG_PATH = "apps/web/lib/checkout-config.ts";
36
- /** The two return pages: Stripe's success URL and every refused buy's target. */
46
+ /** The two return pages a commerce spoke still carries (#8748 D5). */
37
47
  export const SPOKE_CHECKOUT_PAGE_FILES = Object.freeze([
38
48
  "apps/web/app/[locale]/checkout/thank-you/page.tsx",
39
49
  "apps/web/app/[locale]/checkout/unavailable/page.tsx",
40
50
  ]);
41
51
  /**
42
- * The marker `renderPage()` stamps into both generated return pages' header
43
- * comment (D3). `apps/web`'s structured-data test cannot import create-ailk
44
- * (the import boundary), so it carries this text as a literal, kept in sync
45
- * by `packages/create-ailk/__tests__/spoke-checkout.test.ts` (#8513 AC-5).
52
+ * The spoke checkout's template paths, scaffold-relative (#8748 D3). A
53
+ * directory entry covers every file under it, so the tests that sit beside
54
+ * the pages travel with them. `copyTemplate` skips all of them for every
55
+ * scaffold; `writeSpokeCheckoutSlice` copies them into a commerce spoke.
46
56
  */
47
- export const SPOKE_CHECKOUT_PAGE_MARKER = "GENERATED by create-ailk (#7838)";
48
- /** The variables a commerce spoke depends on (D9). The package reads three of them. */
57
+ export const SPOKE_CHECKOUT_TEMPLATE_PATHS = Object.freeze([
58
+ "apps/web/app/[locale]/checkout",
59
+ SPOKE_CHECKOUT_CONFIG_PATH,
60
+ ]);
61
+ /** The variables a commerce spoke depends on (D9): the package reads both. */
49
62
  export const SPOKE_CHECKOUT_ENV_CATALOG = Object.freeze({
50
63
  STRIPE_SECRET_KEY: {
51
- description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: a test key (sk_test_ or rk_test_), or a restricted live key the operator sets with STRIPE_MODE=live.`,
52
- secret: true,
53
- },
54
- STRIPE_WEBHOOK_SECRET: {
55
- description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: the signing secret of this site's Stripe webhook endpoint.`,
64
+ description: `Read by ${SPOKE_CHECKOUT_PACKAGE} for the thank-you page's paid-session read and the billing reads, never to sell: a test key (sk_test_ or rk_test_), or a restricted live key the operator sets with STRIPE_MODE=live.`,
56
65
  secret: true,
57
66
  },
58
67
  STRIPE_MODE: {
59
68
  description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: test (the default) or live. Live is the operator's switch, per site.`,
60
69
  secret: false,
61
70
  },
62
- AILK_SPOKE_KEY: {
63
- description: "The hub-issued key this site sends as x-ailk-spoke-key; the checkout fulfilment refuses to run without it. Never committed.",
64
- secret: true,
65
- },
66
71
  });
67
72
  /** The names a commerce spoke declares although only the package reads them (D9). */
68
73
  export const SPOKE_CHECKOUT_DECLARED_ENV = Object.freeze([
69
74
  "STRIPE_SECRET_KEY",
70
- "STRIPE_WEBHOOK_SECRET",
71
75
  "STRIPE_MODE",
72
76
  ]);
73
77
  /**
74
78
  * The catalog (and declared names) a spoke's `appendEnvRows` call passes: a
75
- * commerce spoke adds the checkout's four rows (D9). `ENV_CATALOG` itself is
79
+ * commerce spoke adds the package's two rows (D9). `ENV_CATALOG` itself is
76
80
  * unchanged — its test fails on an entry the monorepo's apps never read.
77
81
  */
78
82
  export function spokeEnvRowOptions(commerce) {
@@ -83,7 +87,7 @@ export function spokeEnvRowOptions(commerce) {
83
87
  }
84
88
  : { catalog: ENV_CATALOG };
85
89
  }
86
- /** `metadata.site` / an offering slug — the package's own pattern. */
90
+ /** `metadata.site` — the package's own pattern. */
87
91
  const SITE_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
88
92
  /** A stamp a renderer may interpolate: the package's pattern, never `default` (D6). */
89
93
  function isValidSite(site) {
@@ -112,42 +116,17 @@ export function spokeCheckoutSite(projectName) {
112
116
  const digest = createHash("sha256").update(projectName).digest("hex");
113
117
  return `site-${digest.slice(0, 8)}`;
114
118
  }
115
- /** One route file (D4): three lines of wiring, every guarantee in the package. */
116
- function renderRoute(opts) {
117
- return [
118
- `// ${opts.title} — GENERATED by create-ailk (#7838). Do not edit: every`,
119
- "// guarantee lives in the spoke-checkout package and arrives by a package",
120
- "// bump. This site's data is lib/checkout-config.ts.",
121
- `import { createSpokeCheckout } from "${SPOKE_CHECKOUT_PACKAGE}";`,
122
- "",
123
- `import { checkoutConfig } from "../../../../lib/checkout-config";`,
124
- "",
125
- `export const runtime = "nodejs";`,
126
- `export const dynamic = "force-dynamic";`,
127
- "",
128
- `// platform-decisions-waiver: api-versioning — ${opts.waiver} (spec 7838 §7)`,
129
- opts.exportLine,
130
- "",
131
- ].join("\n");
132
- }
133
- const BUY_ROUTE = renderRoute({
134
- title: "The buy route",
135
- waiver: "a browser buy link that redirects to Stripe Checkout, not a versioned API contract",
136
- exportLine: "export const { GET, POST } = createSpokeCheckout(checkoutConfig).buy;",
137
- });
138
- const WEBHOOK_ROUTE = renderRoute({
139
- title: "The Stripe webhook route",
140
- waiver: "the endpoint URL registered in Stripe, not a versioned API contract",
141
- exportLine: "export const { POST } = createSpokeCheckout(checkoutConfig).webhook;",
142
- });
119
+ /** The billing action's first line, which marks the file as create-ailk's to rewrite. */
120
+ export const BILLING_ACTION_MARKER = "// The spoke billing action — GENERATED by create-ailk (#8217). Do not edit:";
143
121
  /**
144
122
  * The spoke billing action (#8217 D6): wiring only, byte-identical across
145
123
  * sites, so every guarantee arrives by a package bump. A plain server module
146
124
  * (C2): its two exports take no argument, and the hub's read is its only
147
- * source of customer ids.
125
+ * source of customer ids. It builds its config from the site's stamp and the
126
+ * site's own origin (#9142: no longer from `lib/checkout.ts`).
148
127
  */
149
- const BILLING_ACTION = [
150
- "// The spoke billing action — GENERATED by create-ailk (#8217). Do not edit:",
128
+ export const BILLING_ACTION = [
129
+ BILLING_ACTION_MARKER,
151
130
  "// every guarantee lives in the spoke-checkout package and arrives by a",
152
131
  "// package bump. A plain server module, not a Server Action file: neither",
153
132
  "// export takes an argument, and the hub's GET /v1/member/billing is the only",
@@ -158,366 +137,74 @@ const BILLING_ACTION = [
158
137
  "",
159
138
  `import { checkoutConfig } from "../../../../../lib/checkout-config";`,
160
139
  `import { forwardedCookieInit } from "../../../../../lib/server-api";`,
140
+ `import { resolveBaseUrl } from "../../../../../src/lib/routes";`,
161
141
  "",
162
- "const billing = createSpokeCheckout(checkoutConfig).billing.forMember(",
163
- " async () => member.billing(await forwardedCookieInit()),",
164
- ");",
142
+ "const billing = createSpokeCheckout({",
143
+ " site: checkoutConfig.site,",
144
+ " siteBaseUrl: resolveBaseUrl(),",
145
+ "}).billing.forMember(async () => member.billing(await forwardedCookieInit()));",
165
146
  "",
166
147
  "export const readSpokeBilling = billing.read;",
167
148
  "export const spokeBillingPortalUrl = billing.portalUrl;",
168
149
  "",
169
150
  ].join("\n");
170
- /** The site's checkout data (D5). Only the `const SITE` line varies by site. */
171
- function renderConfig(site) {
172
- return [
173
- "// checkout-config.ts — this spoke's checkout, as data (#7838).",
174
- "//",
175
- "// Written once by create-ailk and yours from then on: create-ailk never",
176
- "// rewrites it. The buy route and the Stripe webhook route build their",
177
- "// handlers from it, and so does the billing action on a spoke that signs",
178
- "// in through its hub. Only those files may import the checkout package",
179
- "// (architecture.yaml), so this file never imports it, stripe-client or the",
180
- "// stripe SDK. Keep it to data and the fulfil callback.",
181
- "//",
182
- "// site Stamped on every Checkout Session as metadata.site. This site's",
183
- "// webhook acts only on its own stamp, and the hub ignores any stamp",
184
- "// that is not its own, so the stamp must be unique on the Stripe",
185
- '// account this site shares. Never "default". Change it only before',
186
- "// any price exists: every lookup key starts with it.",
187
- "// offerings slug -> Stripe lookup_key. The buy link is /api/checkout/<slug>.",
188
- "// scaffold-commerce replaces the placeholder below.",
189
- "// fulfil Runs for each paid session, at least once. It posts one lead to",
190
- "// the hub with the session id as its Idempotency-Key, so a repeat",
191
- "// writes nothing new. The hub dedupes only an identical body: change",
192
- "// the payload's shape only when no webhook is failing.",
193
- `import { leads } from "${API_CLIENT_PACKAGE}";`,
194
- "",
195
- `import { resolveBaseUrl } from "../src/lib/routes";`,
196
- "",
197
- `const SITE = "${site}";`,
198
- "",
199
- "/** What this site's fulfil callback reads from a completed checkout. */",
200
- "interface CompletedCheckout {",
201
- " idempotencyKey: string;",
202
- " sessionId: string;",
203
- " offering: string;",
204
- ' kind: "fixed" | "quoted";',
205
- ' mode: "test" | "live";',
206
- " amountTotal: number | null;",
207
- " currency: string | null;",
208
- " email: string | null;",
209
- " customerId: string | null;",
210
- "}",
211
- "",
212
- "export const checkoutConfig = {",
213
- " site: SITE,",
214
- " siteBaseUrl: resolveBaseUrl(),",
215
- " offerings: {",
216
- " // A placeholder until scaffold-commerce creates this site's prices.",
217
- ' example: { kind: "fixed", lookupKey: `${SITE}_example`, cancelPath: "/" },',
218
- " },",
219
- " async fulfil(checkout: CompletedCheckout): Promise<void> {",
220
- " if (checkout.email === null) return;",
221
- " // No spoke key: the hub cannot place the lead in this site's workspace.",
222
- " // Fail, so Stripe retries and the dashboard shows a failed webhook.",
223
- " if (!process.env.AILK_SPOKE_KEY)",
224
- ' throw new Error("AILK_SPOKE_KEY is not set");',
225
- " const result = await leads.submit(",
226
- " {",
227
- " siteId: SITE,",
228
- ' source: "web",',
229
- " email: checkout.email,",
230
- " payload: {",
231
- " site: SITE,",
232
- ' page: "checkout",',
233
- " offering: checkout.offering,",
234
- " checkoutKind: checkout.kind,",
235
- " mode: checkout.mode,",
236
- " amountTotal: checkout.amountTotal,",
237
- " currency: checkout.currency,",
238
- " stripeCustomerId: checkout.customerId,",
239
- " checkoutSession: checkout.sessionId,",
240
- " },",
241
- " },",
242
- ' { headers: { "Idempotency-Key": checkout.idempotencyKey } },',
243
- " );",
244
- " // A replay under the same key answers 201 with the first lead; any",
245
- " // failure (422 idempotency_key_reused included) makes Stripe retry.",
246
- " if (!result.ok) throw new Error(`hub lead write answered ${result.status}`);",
247
- " },",
248
- "};",
249
- "",
250
- ].join("\n");
251
- }
252
- /** One return page (D7): a server component that reads nothing but its copy. */
253
- function renderPage(opts) {
254
- return [
255
- `// ${opts.title} — ${SPOKE_CHECKOUT_PAGE_MARKER}.`,
256
- ...opts.about.map((line) => `// ${line}`),
257
- `import { SectionRenderer } from "${UI_SECTIONS_PACKAGE}";`,
258
- `import type { PageSection } from "${VALIDATION_PACKAGE}";`,
259
- `import type { Metadata } from "next";`,
260
- `import { getTranslations } from "next-intl/server";`,
261
- `import type { ReactElement } from "react";`,
262
- "",
263
- "interface Props {",
264
- " params: Promise<{ locale: string }>;",
265
- "}",
266
- "",
267
- "export async function generateMetadata({ params }: Props): Promise<Metadata> {",
268
- " const { locale } = await params;",
269
- ` const t = await getTranslations({ locale, namespace: "${opts.namespace}" });`,
270
- ` return { title: t("title"), robots: { index: false, follow: false } };`,
271
- "}",
272
- "",
273
- "export default async function Page({ params }: Props): Promise<ReactElement> {",
274
- " const { locale } = await params;",
275
- ` const t = await getTranslations({ locale, namespace: "${opts.namespace}" });`,
276
- " const sections: PageSection[] = [",
277
- ...opts.sections.map((line) => ` ${line}`),
278
- " ];",
279
- " return (",
280
- ' <main id="main">',
281
- " <SectionRenderer sections={sections} locale={locale} leadingSectionOwnsTitle />",
282
- " </main>",
283
- " );",
284
- "}",
285
- "",
286
- ].join("\n");
287
- }
288
- const THANK_YOU_PAGE = renderPage({
289
- title: "The checkout thank-you page",
290
- about: [
291
- "Stripe returns a buyer here after paying. It reads no search parameter and",
292
- "calls neither Stripe nor the hub: the webhook is what fulfils the order.",
293
- ],
294
- namespace: "checkoutThankYou",
295
- sections: [
296
- "{",
297
- ' id: "checkout-thank-you",',
298
- ' type: "content-block",',
299
- ' data: { headline: t("title"), body: t("body") },',
300
- "},",
301
- ],
302
- });
303
- const UNAVAILABLE_PAGE = renderPage({
304
- title: "The checkout unavailable page",
305
- about: [
306
- "Every refused buy lands here. It never says why: a reason is a probe's",
307
- "answer, and the buyer needs only that nothing was charged.",
308
- ],
309
- namespace: "checkoutUnavailable",
310
- sections: [
311
- "{",
312
- ' id: "checkout-unavailable",',
313
- ' type: "content-block",',
314
- ' data: { headline: t("title"), body: t("body") },',
315
- "},",
316
- "{",
317
- ' id: "checkout-unavailable-cta",',
318
- ' type: "cta",',
319
- " data: {",
320
- ' headline: t("ctaHeadline"),',
321
- ' primaryCta: { label: t("ctaLabel"), href: "/contact" },',
322
- " },",
323
- "},",
324
- ],
325
- });
326
- // ── #7839 D8 — the checkout e2e workflow ─────────────────────────────────────
327
- /** Where a commerce spoke's end-to-end checkout workflow lives. */
328
- export const SPOKE_CHECKOUT_E2E_WORKFLOW_PATH = ".github/workflows/checkout-e2e.yml";
329
151
  /**
330
- * The Stripe CLI release the workflow installs, and the SHA-256 of its
331
- * `stripe_<v>_linux_x86_64.tar.gz` from that release's own
332
- * `stripe-linux-checksums.txt`. Never bump one without re-reading the other.
152
+ * The rendered part of the slice for `site` (#8748 D3): with `signIn: "hub"`
153
+ * the billing action (#8217 D8), else nothing. It does not depend on `site`.
154
+ * The rest of the slice is template source, copied by
155
+ * `writeSpokeCheckoutSlice`. Throws `TopologyError` for an invalid stamp
156
+ * (SEC-7838-10).
333
157
  */
334
- export const STRIPE_CLI_VERSION = "1.51.1";
335
- export const STRIPE_CLI_LINUX_X64_SHA256 = "38c8de73a8cab810a4aab8a4015cd4e3398b7b87aa42b6944d09d89ed524ac53";
336
- /** The Playwright the workflow installs — the monorepo's own pinned version. */
337
- export const E2E_PLAYWRIGHT_VERSION = "1.61.1";
338
- /**
339
- * Every action the workflow uses, pinned to the commit its `v4` tag named
340
- * when this was written (SEC-7839-2): `gh api repos/<action>/commits/v4`.
341
- */
342
- export const E2E_ACTION_PINS = Object.freeze({
343
- "actions/checkout": "11d5960a326750d5838078e36cf38b85af677262",
344
- "pnpm/action-setup": "b906affcce14559ad1aafd4ab0e942779e9f58b1",
345
- "actions/setup-node": "49933ea5288caeca8642d1e84afbd3f7d6820020",
346
- "actions/upload-artifact": "ea165f8d65b6e75b540449e92b4886f43607fa02",
347
- });
348
- function uses(action) {
349
- return `uses: ${action}@${E2E_ACTION_PINS[action]} # v4`;
158
+ export function renderSpokeCheckoutFiles(site, opts = {}) {
159
+ assertSite(site);
160
+ return opts.signIn === "hub" ? [[SPOKE_BILLING_ACTION_PATH, BILLING_ACTION]] : [];
350
161
  }
162
+ /** The one line of the template's config the writer stamps (#8748 D2). */
163
+ const SITE_LINE = /^const SITE = "[a-z0-9-]+";$/gm;
351
164
  /**
352
- * The workflow text (D8). Identical for every spoke at one create-ailk
353
- * version. Every `${{ … }}` sits in an env-style value, never in a script.
354
- */
355
- export const CHECKOUT_E2E_WORKFLOW = `# Checkout e2e — GENERATED by create-ailk (#7839). Do not edit: it changes
356
- # with the ${SPOKE_CHECKOUT_PACKAGE} command it runs.
357
- #
358
- # Pays each named offering once through Stripe's hosted Checkout in TEST
359
- # mode, then checks Stripe's webhook was answered 2xx and this site's
360
- # fulfilment ran. Start it by hand (Actions > Checkout e2e > Run workflow)
361
- # after a checkout change and before any live flip. It never runs on a push
362
- # or a pull request: it needs the network, the Stripe CLI and test-mode state.
363
- #
364
- # Setup, once: create the GitHub Environment "checkout-e2e", limit its
365
- # deployment branches to the default branch, and add to it the secrets
366
- # STRIPE_TEST_SECRET_KEY (an sk_test_ or rk_test_ key) and AILK_SPOKE_KEY,
367
- # and the variable AILK_API_URL (https). Each paid session posts one
368
- # test-mode lead to the hub. Test subscriptions it creates are cancelled.
369
- name: Checkout e2e
370
-
371
- on:
372
- workflow_dispatch:
373
- inputs:
374
- offerings:
375
- description: Comma-separated fixed offering slugs from apps/web/lib/checkout-config.ts
376
- required: true
377
- type: string
378
-
379
- permissions:
380
- contents: read
381
-
382
- concurrency:
383
- group: checkout-e2e
384
- cancel-in-progress: false
385
-
386
- jobs:
387
- checkout-e2e:
388
- runs-on: ubuntu-latest
389
- timeout-minutes: 30
390
- environment: checkout-e2e
391
- env:
392
- NEXT_PUBLIC_BASE_URL: http://localhost:3000
393
- STRIPE_MODE: test
394
- steps:
395
- - name: Refuse a live key or a missing setting
396
- env:
397
- STRIPE_SECRET_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
398
- AILK_SPOKE_KEY: \${{ secrets.AILK_SPOKE_KEY }}
399
- AILK_API_URL: \${{ vars.AILK_API_URL }}
400
- run: |
401
- case "$STRIPE_SECRET_KEY" in
402
- sk_test_*|rk_test_*) ;;
403
- *) echo "::error::STRIPE_TEST_SECRET_KEY must be an sk_test_ or rk_test_ key"; exit 2 ;;
404
- esac
405
- if [ -z "$AILK_SPOKE_KEY" ]; then
406
- echo "::error::set the AILK_SPOKE_KEY secret in the checkout-e2e environment"; exit 2
407
- fi
408
- case "$AILK_API_URL" in
409
- https://*) ;;
410
- *) echo "::error::AILK_API_URL must be the hub's https origin"; exit 2 ;;
411
- esac
412
-
413
- - ${uses("actions/checkout")}
414
-
415
- - ${uses("pnpm/action-setup")}
416
-
417
- - run: test -f pnpm-lock.yaml || touch pnpm-lock.yaml
418
-
419
- - ${uses("actions/setup-node")}
420
- with:
421
- node-version: 20
422
- cache: pnpm
423
- cache-dependency-path: pnpm-lock.yaml
424
-
425
- - name: Install
426
- run: |
427
- if [ -s pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else pnpm install; fi
428
-
429
- - name: Install the Stripe CLI (pinned, checksum-checked)
430
- run: |
431
- curl -fsSL -o "$RUNNER_TEMP/stripe.tar.gz" \\
432
- https://github.com/stripe/stripe-cli/releases/download/v${STRIPE_CLI_VERSION}/stripe_${STRIPE_CLI_VERSION}_linux_x86_64.tar.gz
433
- echo "${STRIPE_CLI_LINUX_X64_SHA256} $RUNNER_TEMP/stripe.tar.gz" | sha256sum -c -
434
- mkdir -p "$RUNNER_TEMP/stripe-cli"
435
- tar -xzf "$RUNNER_TEMP/stripe.tar.gz" -C "$RUNNER_TEMP/stripe-cli" stripe
436
-
437
- - name: Install Playwright (pinned) and Chromium
438
- run: |
439
- npm install --no-save --ignore-scripts --prefix "$RUNNER_TEMP/e2e-deps" playwright@${E2E_PLAYWRIGHT_VERSION}
440
- "$RUNNER_TEMP/e2e-deps/node_modules/.bin/playwright" install --with-deps chromium
441
-
442
- - name: Fetch the Stripe CLI's signing secret
443
- env:
444
- STRIPE_API_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
445
- run: |
446
- umask 077
447
- secret="$("$RUNNER_TEMP/stripe-cli/stripe" listen --print-secret)"
448
- echo "::add-mask::$secret"
449
- printf '%s' "$secret" > "$RUNNER_TEMP/stripe-whsec"
450
-
451
- - name: Build the site
452
- run: pnpm --filter ./apps/web build
453
-
454
- - name: Start the site
455
- env:
456
- STRIPE_SECRET_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
457
- AILK_SPOKE_KEY: \${{ secrets.AILK_SPOKE_KEY }}
458
- AILK_API_URL: \${{ vars.AILK_API_URL }}
459
- run: |
460
- STRIPE_WEBHOOK_SECRET="$(cat "$RUNNER_TEMP/stripe-whsec")"
461
- export STRIPE_WEBHOOK_SECRET
462
- cd apps/web
463
- nohup pnpm exec next start -p 3000 -H localhost > "$RUNNER_TEMP/next.log" 2>&1 &
464
- echo $! > "$RUNNER_TEMP/next.pid"
465
- for _ in $(seq 1 60); do
466
- curl -fsS -o /dev/null http://localhost:3000/checkout/thank-you && exit 0
467
- sleep 2
468
- done
469
- echo "::error::the site did not start on localhost:3000; run it locally with the same settings to see why"
470
- exit 1
471
-
472
- - name: Pay each offering
473
- working-directory: apps/web
474
- env:
475
- STRIPE_SECRET_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
476
- OFFERINGS: \${{ inputs.offerings }}
477
- SPOKE_CHECKOUT_E2E_DEPS: \${{ runner.temp }}/e2e-deps
478
- STRIPE_CLI_PATH: \${{ runner.temp }}/stripe-cli/stripe
479
- run: |
480
- STRIPE_WEBHOOK_SECRET="$(cat "$RUNNER_TEMP/stripe-whsec")"
481
- export STRIPE_WEBHOOK_SECRET
482
- pnpm exec spoke-checkout-e2e --base-url http://localhost:3000 --offerings "$OFFERINGS" --out "$RUNNER_TEMP/checkout-e2e"
483
-
484
- - name: Stop the site
485
- if: always()
486
- run: |
487
- if [ -f "$RUNNER_TEMP/next.pid" ]; then pkill -P "$(cat "$RUNNER_TEMP/next.pid")" || true; kill "$(cat "$RUNNER_TEMP/next.pid")" || true; fi
488
- rm -f "$RUNNER_TEMP/stripe-whsec"
489
-
490
- - name: Keep the report
491
- if: always()
492
- ${uses("actions/upload-artifact")}
493
- with:
494
- name: checkout-e2e
495
- path: \${{ runner.temp }}/checkout-e2e
496
- if-no-files-found: ignore
497
- retention-days: 7
498
- `;
499
- /**
500
- * The slice for `site`, in order: the two route files, the config, the two
501
- * pages (D3), then the checkout e2e workflow (#7839 D8), which does not
502
- * depend on `site`. With `signIn: "hub"` the billing action is the third
503
- * pair, so the first three paths are `SPOKE_CHECKOUT_ROUTE_FILES` (#8217 D8).
504
- * Throws `TopologyError` for an invalid stamp (SEC-7838-10).
165
+ * Every template file under `SPOKE_CHECKOUT_TEMPLATE_PATHS`, sorted, as its
166
+ * destination path and its source. A symlink is skipped, never followed, as
167
+ * `copyTemplate` skips one; a `.gitkeep` has no destination.
505
168
  */
506
- export function renderSpokeCheckoutFiles(site, opts = {}) {
507
- assertSite(site);
508
- const [buyPath, webhookPath, billingActionPath] = SPOKE_CHECKOUT_ROUTE_FILES;
509
- const [thankYouPath, unavailablePath] = SPOKE_CHECKOUT_PAGE_FILES;
510
- return [
511
- [buyPath, BUY_ROUTE],
512
- [webhookPath, WEBHOOK_ROUTE],
513
- ...(opts.signIn === "hub"
514
- ? [[billingActionPath, BILLING_ACTION]]
515
- : []),
516
- [SPOKE_CHECKOUT_CONFIG_PATH, renderConfig(site)],
517
- [thankYouPath, THANK_YOU_PAGE],
518
- [unavailablePath, UNAVAILABLE_PAGE],
519
- [SPOKE_CHECKOUT_E2E_WORKFLOW_PATH, CHECKOUT_E2E_WORKFLOW],
520
- ];
169
+ function templateCheckoutFiles(templatesDir) {
170
+ const out = [];
171
+ const visit = (rel) => {
172
+ const abs = join(templatesDir, ...rel.split("/"));
173
+ if (!existsSync(abs))
174
+ return;
175
+ const stat = lstatSync(abs);
176
+ if (stat.isSymbolicLink())
177
+ return;
178
+ if (stat.isDirectory()) {
179
+ for (const name of readdirSync(abs))
180
+ visit(`${rel}/${name}`);
181
+ return;
182
+ }
183
+ const dest = templateDestination(rel);
184
+ if (dest !== undefined)
185
+ out.push([dest, abs]);
186
+ };
187
+ for (const entry of SPOKE_CHECKOUT_TEMPLATE_PATHS)
188
+ visit(entry);
189
+ return out.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
190
+ }
191
+ /** Whether anything — a dangling symlink included — sits at `abs`. */
192
+ function lstatExists(abs) {
193
+ try {
194
+ lstatSync(abs);
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ /** Whether a path, or (when absent) its nearest existing ancestor, resolves inside `targetReal`. */
202
+ function resolvesInside(targetReal, abs) {
203
+ let probe = abs;
204
+ while (!existsSync(probe) && dirname(probe) !== probe)
205
+ probe = dirname(probe);
206
+ const real = realpathSync(probe);
207
+ return real === targetReal || real.startsWith(targetReal + sep);
521
208
  }
522
209
  const WEB_PACKAGE_JSON = "apps/web/package.json";
523
210
  /** `deps` with `name: version` added in sorted position, every other key kept in place. */
@@ -556,28 +243,79 @@ function isOlderPin(current, wanted) {
556
243
  return false;
557
244
  }
558
245
  /**
559
- * Write the slice into `targetDir` (D3). A file that already exists is left
560
- * byte-for-byte and reported `skipped` — the config is the site's once
246
+ * Write the slice into `targetDir` (#7838 D3, #8748 D3). The standard's
247
+ * template files (`SPOKE_CHECKOUT_TEMPLATE_PATHS`) are copied from
248
+ * `templatesDir`, each rendered with `copyTemplate`'s own renderer and
249
+ * markers, so a recorded hash equals what `create-ailk update` renders; the
250
+ * config's one `const SITE` line is stamped with `site`. `onWrite` is called
251
+ * once per template file written, as `copyTemplate` calls it. Then come the
252
+ * rendered files (`renderSpokeCheckoutFiles`). A file that already exists is
253
+ * left byte-for-byte and reported `skipped` — the config is the site's once
561
254
  * written. With `signIn: "hub"` (#8217 D8) the slice carries the billing
562
255
  * action, and the web seam — generated, so always rewritten — becomes the
563
- * billing render: reported `patched` when it changed, not at all when equal. Adds the package to `apps/web/package.json` when absent, and
564
- * raises a plain pin older than the one it writes (#7839): the generated
565
- * checkout e2e workflow runs a command a `^0.2.0` pin never reaches. Any
566
- * other range — newer, `workspace:`, a tag — is the site's and is kept.
256
+ * billing render: reported `patched` when it changed, not at all when equal.
257
+ * Adds the package to `apps/web/package.json` when absent, and raises a plain
258
+ * pin older than the one it writes (#7839): the rendered billing action
259
+ * calls the package's current `createSpokeCheckout` shape, which an older
260
+ * pin does not take (#9142). Any other range — newer, `workspace:`, a tag —
261
+ * is the site's and is kept.
567
262
  * Every refusal (an invalid stamp, a version map without the package, a
568
- * missing or malformed `apps/web/package.json`) throws before the first write.
263
+ * missing or malformed `apps/web/package.json`, a template missing any
264
+ * `SPOKE_CHECKOUT_TEMPLATE_PATHS` entry, a template config without exactly
265
+ * one `const SITE` line, a destination to write that resolves outside
266
+ * `targetDir`) throws before the first write.
569
267
  */
570
268
  export function writeSpokeCheckoutSlice(targetDir, opts) {
571
- const files = renderSpokeCheckoutFiles(opts.site, { signIn: opts.signIn });
269
+ const rendered = renderSpokeCheckoutFiles(opts.site, { signIn: opts.signIn });
572
270
  const range = JSON.parse(substituteWorkspaceVersions(`{"${SPOKE_CHECKOUT_PACKAGE}": "workspace:*"}`, opts.versions));
573
271
  const version = range[SPOKE_CHECKOUT_PACKAGE];
574
272
  const pkgPath = join(targetDir, WEB_PACKAGE_JSON);
575
273
  const before = readFileSync(pkgPath, "utf8");
576
274
  const pkg = JSON.parse(before);
275
+ const markers = buildScaffoldMarkers(opts.projectName);
276
+ const copied = [];
277
+ for (const [path, source] of templateCheckoutFiles(opts.templatesDir)) {
278
+ let bytes = Buffer.from(renderTemplateFile(readFileSync(source), path, opts.versions, markers));
279
+ if (path === SPOKE_CHECKOUT_CONFIG_PATH) {
280
+ const text = bytes.toString("utf8");
281
+ const lines = text.match(SITE_LINE) ?? [];
282
+ if (lines.length !== 1) {
283
+ throw new TopologyError(`spoke checkout: the template's ${SPOKE_CHECKOUT_CONFIG_PATH} must hold exactly one const SITE line, found ${lines.length}`);
284
+ }
285
+ bytes = Buffer.from(text.replace(SITE_LINE, `const SITE = "${opts.site}";`), "utf8");
286
+ }
287
+ copied.push([path, bytes]);
288
+ }
289
+ const missing = SPOKE_CHECKOUT_TEMPLATE_PATHS.filter((entry) => !copied.some(([path]) => path === entry || path.startsWith(`${entry}/`)));
290
+ if (missing.length > 0) {
291
+ throw new TopologyError(`spoke checkout: the template has no ${missing.join(", ")}`);
292
+ }
293
+ // A file already there is skipped below, never written, so only a path
294
+ // that will be written must resolve inside the site.
295
+ const targetReal = realpathSync(targetDir);
296
+ for (const [path] of [...copied, ...rendered]) {
297
+ const abs = resolve(targetDir, path);
298
+ if (lstatExists(abs))
299
+ continue;
300
+ if (!resolvesInside(targetReal, abs)) {
301
+ throw new TopologyError(`spoke checkout: refusing to write outside the site: ${path}`);
302
+ }
303
+ }
577
304
  const rows = [];
578
- for (const [path, text] of files) {
305
+ for (const [path, bytes] of copied) {
579
306
  const abs = join(targetDir, path);
580
- if (existsSync(abs)) {
307
+ if (lstatExists(abs)) {
308
+ rows.push({ path, action: "skipped", wanted: bytes.toString("utf8") });
309
+ continue;
310
+ }
311
+ mkdirSync(dirname(abs), { recursive: true });
312
+ writeFileSync(abs, bytes);
313
+ opts.onWrite?.(path, bytes);
314
+ rows.push({ path, action: "written" });
315
+ }
316
+ for (const [path, text] of rendered) {
317
+ const abs = join(targetDir, path);
318
+ if (lstatExists(abs)) {
581
319
  rows.push({ path, action: "skipped", wanted: text });
582
320
  continue;
583
321
  }
@@ -617,3 +355,43 @@ export function writeSpokeCheckoutSlice(targetDir, opts) {
617
355
  }
618
356
  return rows;
619
357
  }
358
+ /**
359
+ * Drop the checkout package from a scaffold that does not get the slice — a
360
+ * hub, or a spoke without `commerce` (#8748 D3). The template's
361
+ * `apps/web/package.json` depends on it because the monorepo's `apps/web`
362
+ * carries the standard. Every other key is kept in place; a site without the
363
+ * dependency, or without `apps/web/package.json`, is left as it is.
364
+ */
365
+ export function dropSpokeCheckoutDependency(targetDir) {
366
+ const pkgPath = join(targetDir, WEB_PACKAGE_JSON);
367
+ if (!existsSync(pkgPath))
368
+ return;
369
+ const before = readFileSync(pkgPath, "utf8");
370
+ const pkg = JSON.parse(before);
371
+ const deps = pkg.dependencies;
372
+ if (deps === undefined ||
373
+ !Object.prototype.hasOwnProperty.call(deps, SPOKE_CHECKOUT_PACKAGE)) {
374
+ return;
375
+ }
376
+ delete deps[SPOKE_CHECKOUT_PACKAGE];
377
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}${before.endsWith("\n") ? "\n" : ""}`);
378
+ }
379
+ // ── #9142 — the retired legacy env-key checkout ─────────────────────────────
380
+ /**
381
+ * What the legacy env-key checkout left in a spoke, scaffold-relative: the
382
+ * buy route and the Stripe webhook route (a directory entry covers the tests
383
+ * beside each), `lib/checkout.ts` (the fulfilment) and its test. Each was a
384
+ * template copy create-ailk recorded in `.ailk-template-files.json`;
385
+ * `create-ailk sync-routes --apply` removes the ones still byte-identical to
386
+ * that record.
387
+ */
388
+ export const RETIRED_SPOKE_CHECKOUT_PATHS = Object.freeze([
389
+ "apps/web/app/api/checkout",
390
+ "apps/web/app/api/webhooks/stripe",
391
+ "apps/web/lib/checkout.ts",
392
+ "apps/web/lib/__tests__/checkout.test.ts",
393
+ ]);
394
+ /** The retired checkout e2e workflow — rendered, never recorded (#7839 D8). */
395
+ export const RETIRED_CHECKOUT_E2E_WORKFLOW_PATH = ".github/workflows/checkout-e2e.yml";
396
+ /** The workflow's first line, which marks it as create-ailk's to remove. */
397
+ export const RETIRED_CHECKOUT_E2E_MARKER = "# Checkout e2e — GENERATED by create-ailk (#7839). Do not edit: it changes";