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
@@ -0,0 +1,444 @@
1
+ /**
2
+ * @file plan-offerings.test.ts
3
+ *
4
+ * The plan offerings re-sourced from the Offerings shown set (#8934, spec
5
+ * docs/specs/8934-re-source-the-plan-offerings-spec.md D1–D6; AC-1, AC-3,
6
+ * AC-4, AC-5): a connected, claimed workspace lists one item per sellable
7
+ * Price in its shown set, keyed by the Price's full lookup key; an unconnected
8
+ * or unclaimed workspace keeps the page list; a licence or snapshot that
9
+ * cannot be read answers `{ failed }`; and the three consumers accept a
10
+ * listed key with no change to their own code.
11
+ *
12
+ * Runs over `createInMemoryPrisma()` and #8876's `seedOfferings`. External
13
+ * dependency boundaries mocked:
14
+ * - apps/api/src/lib/prisma — the in-memory, where-clause-honouring double.
15
+ * Its `lead.count` is arranged per test: the purchase email's cap reads
16
+ * with `mode: "insensitive"`, a filter the double does not model.
17
+ * - @working-theory/license — `checkLicense` per case; a keyless storage
18
+ * file (`absent`).
19
+ * - @working-theory/stripe-client — touching any export throws.
20
+ * - @working-theory/email — the operator notification and purchase email.
21
+ * - public-site-resolver's `workspaceContentAdapter` — a fixture adapter
22
+ * stands in for the workspace's content store.
23
+ */
24
+ import { inspect } from "node:util";
25
+
26
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
27
+ jest.mock("../../lib/prisma.js", () =>
28
+ jest
29
+ .requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
30
+ "../../__tests__/factories/in-memory-prisma",
31
+ )
32
+ .createInMemoryPrisma(),
33
+ );
34
+
35
+ const mockCheckLicense = jest.fn();
36
+ jest.mock("@working-theory/license", () => ({
37
+ checkLicense: (...args: unknown[]) => mockCheckLicense(...args),
38
+ readStoredJwtState: () => Promise.resolve({ state: "absent" }),
39
+ }));
40
+
41
+ jest.mock(
42
+ "@working-theory/stripe-client",
43
+ () =>
44
+ new Proxy(
45
+ {},
46
+ {
47
+ get: (_target, key) => {
48
+ if (key === "__esModule") return true;
49
+ throw new Error(`@working-theory/stripe-client.${String(key)} was touched`);
50
+ },
51
+ },
52
+ ),
53
+ );
54
+
55
+ const mockSendLeadNotification = jest.fn();
56
+ const mockSendPurchaseConfirmationEmail = jest.fn();
57
+ jest.mock("@working-theory/email", () => ({
58
+ sendLeadNotification: (...args: unknown[]) => mockSendLeadNotification(...args),
59
+ sendPurchaseConfirmationEmail: (...args: unknown[]) =>
60
+ mockSendPurchaseConfirmationEmail(...args),
61
+ }));
62
+
63
+ const mockWorkspaceContentAdapter = jest.fn();
64
+ // eslint-disable-next-line no-restricted-syntax -- the content adapter seam: a fixture adapter stands in for the workspace's content store
65
+ jest.mock("../public-site-resolver.js", () => ({
66
+ ...jest.requireActual("../public-site-resolver.js"),
67
+ workspaceContentAdapter: (...args: unknown[]) => mockWorkspaceContentAdapter(...args),
68
+ }));
69
+
70
+ import { logger } from "@working-theory/observability";
71
+ import {
72
+ parseLookupKey,
73
+ planOfferingItemSchema,
74
+ planOfferingSchema,
75
+ planOfferingsResponseSchema,
76
+ } from "@working-theory/validation";
77
+
78
+ import { prisma } from "../../lib/prisma.js";
79
+ import { routeLead } from "../lead-routing";
80
+ import { magnetCompletionFor } from "../magnet-completion";
81
+ import {
82
+ FIXTURE_ORG,
83
+ FIXTURE_ORG_2_WS,
84
+ FIXTURE_WS,
85
+ FIXTURE_WS_2,
86
+ OFFERINGS_FIXTURE_CATALOG,
87
+ seedOfferings,
88
+ } from "../offerings/__tests__/fixtures.js";
89
+ import { normalizeCatalog, type CatalogPrice, type CatalogProduct } from "../offerings/index.js";
90
+ import { resolveMemberPersonId } from "../person";
91
+ import { listPlanOfferings } from "../plan-offerings";
92
+
93
+ type Row = Record<string, unknown>;
94
+ // The in-memory double's own surface (`__db`, `__calls`) and its recorded
95
+ // delegates are not on the PrismaClient type the import carries, so it is
96
+ // named here once.
97
+ // eslint-disable-next-line no-restricted-syntax -- justified above
98
+ const store = prisma as unknown as {
99
+ __db: Record<string, Row[]>;
100
+ __calls: Array<{ model: string; op: string }>;
101
+ lead: Record<string, jest.Mock>;
102
+ workspaceOfferings: Record<string, jest.Mock>;
103
+ };
104
+
105
+ const CLAIMED = { tier: "pro", plan: null, features: ["commerce"], expiresAt: null, revoked: false };
106
+ const FREE = { tier: "free", plan: null, features: [], expiresAt: null, revoked: false };
107
+ const HOUR_MS = 60 * 60 * 1000;
108
+
109
+ /** D1's table for the seed. */
110
+ const SHOWN_SET_ITEMS = [
111
+ { key: "basic_once", name: "Basic (One-time)", type: "plan" },
112
+ { key: "growth_monthly", name: "Growth (Monthly)", type: "plan" },
113
+ { key: "growth_yearly", name: "Growth (Annual)", type: "plan" },
114
+ { key: "seo-audit_monthly", name: "SEO audit (Monthly)", type: "addon" },
115
+ ];
116
+
117
+ // The route suite's fixture pages and their keying (#8514 D9).
118
+ const page = (slug: string, title: string) => ({ slug, title });
119
+ const SERVICES = [
120
+ page("services/retainer", "Monthly retainer"),
121
+ page("services/Bad Key!", "Not a key"),
122
+ page("audit", "Brand audit"),
123
+ ];
124
+ const PRODUCTS = [page("products/starter-kit", "Starter kit"), page("retainer", "Duplicate retainer")];
125
+ const PAGE_ITEMS = [
126
+ { key: "retainer", name: "Monthly retainer", type: "service" },
127
+ { key: "audit", name: "Brand audit", type: "service" },
128
+ { key: "starter-kit", name: "Starter kit", type: "product" },
129
+ ];
130
+ const fixtureAdapter = () => ({
131
+ listPages: jest.fn(async (type: string) =>
132
+ type === "Service" ? SERVICES : type === "Product" ? PRODUCTS : [],
133
+ ),
134
+ });
135
+
136
+ const OFFERINGS_MODELS = ["stripeConnection", "organizationOfferings", "workspaceOfferings"];
137
+ const offeringsReads = () => store.__calls.filter((c) => OFFERINGS_MODELS.includes(c.model));
138
+
139
+ const enabledRow = () =>
140
+ store.__db["organizationOfferings"]!.find((r) => r["organizationId"] === FIXTURE_ORG)!;
141
+ const offeringsRow = () =>
142
+ store.__db["workspaceOfferings"]!.find((r) => r["workspaceId"] === FIXTURE_WS)!;
143
+ const interactions = () => (store.__db["interaction"] ??= []);
144
+ const people = () => (store.__db["person"] ??= []);
145
+
146
+ // ─── Logs: no line carries Stripe text (AC-5) ───────────────────────────────
147
+
148
+ const EMOJI_NAME = "\u{1F600}".repeat(200);
149
+ const FORBIDDEN_LOG_TEXT = [
150
+ ...OFFERINGS_FIXTURE_CATALOG.products.flatMap((p) => [
151
+ p.name,
152
+ ...(p.description ? [p.description] : []),
153
+ ...(p.marketing_features ?? []).map((f) => f.name),
154
+ ]),
155
+ EMOJI_NAME.slice(0, 20),
156
+ "prod_",
157
+ "price_",
158
+ ];
159
+ const LOGGER_METHODS = ["error", "warn", "info", "debug"] as const;
160
+ const CONSOLE_METHODS = ["log", "info", "warn", "error", "debug"] as const;
161
+ let spies: jest.SpyInstance[] = [];
162
+
163
+ function leakedLines(): string[] {
164
+ return spies
165
+ .flatMap((spy) => spy.mock.calls.map((args) => inspect(args, { depth: 8 })))
166
+ .filter((line) => FORBIDDEN_LOG_TEXT.some((text) => line.includes(text)));
167
+ }
168
+
169
+ // ─── The lead route (AC-3) ───────────────────────────────────────────────────
170
+
171
+ const BUYER = "buyer@x.io";
172
+ const U = "user_00000000-0000-7000-8000-000000008934";
173
+ let keySeq = 0;
174
+ const sourceMeta = { ip: "127.0.0.1", userAgent: "jest", receivedAt: new Date().toISOString(), viaSpoke: true };
175
+
176
+ /** A marked spoke checkout lead (#8793's shape) for `offering`, from slug site `wendi`. */
177
+ async function checkoutLead(offering: string): Promise<void> {
178
+ keySeq += 1;
179
+ await routeLead(
180
+ {
181
+ kind: "submit",
182
+ siteId: FIXTURE_WS,
183
+ source: "web",
184
+ email: BUYER,
185
+ payload: { site: "wendi", page: "checkout", offering, amountTotal: 900, currency: "usd" },
186
+ },
187
+ sourceMeta,
188
+ { idempotencyKey: `cs_live_h8934Plan${keySeq}` }, // gitleaks:allow -- a synthetic Checkout Session id shape, not a credential
189
+ );
190
+ }
191
+
192
+ /** The newest purchase message's body. */
193
+ const lastPurchaseBody = () => String(interactions().at(-1)?.["body"]);
194
+ const GENERIC = /^Thanks for your purchase\./;
195
+
196
+ const ENV_KEYS = [
197
+ "DATABASE_URL",
198
+ "NODE_ENV",
199
+ "AILK_LICENSE_KEY",
200
+ "AILK_LEAD_NOTIFICATION_TO",
201
+ "AILK_LEAD_NOTIFICATION_FROM",
202
+ "AILK_DELIVERABLE_EMAIL_FROM",
203
+ ] as const;
204
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
205
+
206
+ beforeEach(async () => {
207
+ for (const key of Object.keys(store.__db)) delete store.__db[key];
208
+ saved = {};
209
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
210
+ process.env.DATABASE_URL = "postgres://stub";
211
+ delete process.env.NODE_ENV;
212
+ delete process.env.AILK_LICENSE_KEY;
213
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
214
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
215
+ process.env.AILK_DELIVERABLE_EMAIL_FROM = "orders@example.com";
216
+ mockCheckLicense.mockReset().mockResolvedValue(CLAIMED);
217
+ mockWorkspaceContentAdapter.mockReset().mockImplementation(async () => fixtureAdapter());
218
+ mockSendLeadNotification.mockReset().mockResolvedValue({ ok: true, messageId: "m" });
219
+ mockSendPurchaseConfirmationEmail.mockReset().mockResolvedValue({ ok: true, messageId: "m2" });
220
+ store.lead["count"]!.mockReset().mockResolvedValue(1);
221
+ await seedOfferings(prisma);
222
+ store.__calls.length = 0;
223
+ spies = [
224
+ ...LOGGER_METHODS.map((m) => jest.spyOn(logger, m).mockImplementation(() => undefined as never)),
225
+ ...CONSOLE_METHODS.map((m) => jest.spyOn(console, m).mockImplementation(() => undefined)),
226
+ ];
227
+ });
228
+
229
+ afterEach(() => {
230
+ const leaked = leakedLines();
231
+ for (const spy of spies) spy.mockRestore();
232
+ for (const k of ENV_KEYS) {
233
+ if (saved[k] === undefined) delete process.env[k];
234
+ else process.env[k] = saved[k];
235
+ }
236
+ // AC-5: across every case, no log or console line carries a product name,
237
+ // description, feature string, or a `prod_` or `price_` id.
238
+ expect(leaked).toEqual([]);
239
+ });
240
+
241
+ describe("a connected workspace lists its shown set's lookup keys, and only those (AC-1)", () => {
242
+ it("one item per sellable Price, keyed by its full lookup key, with no page and no Stripe call", async () => {
243
+ const offerings = await listPlanOfferings(FIXTURE_WS);
244
+ expect(offerings).toEqual(SHOWN_SET_ITEMS);
245
+ if (!Array.isArray(offerings)) throw new Error("expected a list");
246
+ for (const { key } of offerings) {
247
+ expect(parseLookupKey(key)).not.toBeNull();
248
+ expect(planOfferingSchema.safeParse(key).success).toBe(true);
249
+ }
250
+ expect(() => planOfferingsResponseSchema.parse({ offerings })).not.toThrow();
251
+ expect(mockWorkspaceContentAdapter).not.toHaveBeenCalled();
252
+ // The bidi add-on is in the catalog but neither enabled nor shown.
253
+ expect(offerings.map((o) => o.key)).not.toContain("audit-plus_monthly");
254
+ });
255
+
256
+ it("a product switched off in the Organization's enabled set is absent on the next call", async () => {
257
+ const { snapshot, snapshotAt } = offeringsRow();
258
+ enabledRow()["enabledProductIds"] = (enabledRow()["enabledProductIds"] as string[]).filter(
259
+ (id) => id !== "prod_fixture_seo_audit",
260
+ );
261
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
262
+ SHOWN_SET_ITEMS.filter((item) => item.key !== "seo-audit_monthly"),
263
+ );
264
+ expect(offeringsRow()["snapshot"]).toBe(snapshot);
265
+ expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
266
+ });
267
+
268
+ it("a product switched off in the workspace's shown set is absent on the next call", async () => {
269
+ const { snapshot, snapshotAt } = offeringsRow();
270
+ offeringsRow()["shownProductIds"] = (offeringsRow()["shownProductIds"] as string[]).filter(
271
+ (id) => id !== "prod_fixture_growth",
272
+ );
273
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
274
+ SHOWN_SET_ITEMS.filter((item) => !item.key.startsWith("growth_")),
275
+ );
276
+ expect(offeringsRow()["snapshot"]).toBe(snapshot);
277
+ expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
278
+ });
279
+
280
+ it("an empty shown set lists none: a fact, not a failure", async () => {
281
+ offeringsRow()["shownProductIds"] = [];
282
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual([]);
283
+ });
284
+ });
285
+
286
+ describe("the consumers accept a listed key with no change to their own code (AC-3)", () => {
287
+ it("(a) the purchase message names a listed lookup key, and only a listed one", async () => {
288
+ await checkoutLead("growth_monthly");
289
+ expect(interactions()).toHaveLength(1);
290
+ expect(lastPurchaseBody()).toContain("Growth (Monthly)");
291
+
292
+ await checkoutLead("growth_quarterly");
293
+ expect(lastPurchaseBody()).toMatch(GENERIC);
294
+ // A page the adapter lists is not a connected workspace's offering.
295
+ await checkoutLead("retainer");
296
+ expect(lastPurchaseBody()).toMatch(GENERIC);
297
+ expect(interactions()).toHaveLength(3);
298
+ });
299
+
300
+ it("(b) the held plan released at the verified first link is a key the list names", async () => {
301
+ await checkoutLead("growth_monthly");
302
+ (store.__db["user"] ??= []).push({ id: U, email: BUYER, emailVerified: true });
303
+ const personId = await resolveMemberPersonId(FIXTURE_WS, U, BUYER);
304
+ const buyer = people().find((p) => p["id"] === personId)!;
305
+ expect(buyer["planOffering"]).toBe("growth_monthly");
306
+ const offerings = await listPlanOfferings(FIXTURE_WS);
307
+ if (!Array.isArray(offerings)) throw new Error("expected a list");
308
+ expect(offerings.find((o) => o.key === buyer["planOffering"])?.name).toBe("Growth (Monthly)");
309
+ });
310
+
311
+ it("(c) a checkout magnet completes on an unconnected workspace's page key, and degrades to lead on a connected one", async () => {
312
+ const magnet = (workspaceId: string, checkoutOffering: string): Row => ({
313
+ id: `flow_${workspaceId.slice(-4)}`,
314
+ workspaceId,
315
+ siteId: workspaceId,
316
+ slug: "buy",
317
+ status: "live",
318
+ title: "Buy",
319
+ config: {},
320
+ completion: "checkout",
321
+ checkoutOffering,
322
+ });
323
+ store.__db["flowDefinition"] = [magnet(FIXTURE_ORG_2_WS, "retainer"), magnet(FIXTURE_WS, "growth")];
324
+ const noTarget = () => Promise.resolve(null);
325
+ expect(await magnetCompletionFor(FIXTURE_ORG_2_WS, "buy", noTarget)).toMatchObject({
326
+ next: { completion: "checkout", checkoutOffering: "retainer" },
327
+ });
328
+ expect(await magnetCompletionFor(FIXTURE_WS, "buy", noTarget)).toBeNull();
329
+ });
330
+
331
+ describe("(d) a licence or snapshot that cannot be read is { failed }, never a pass", () => {
332
+ const failures: Array<[string, () => Promise<void>, string]> = [
333
+ [
334
+ "an undetermined licence",
335
+ async () => {
336
+ mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
337
+ },
338
+ "LicenseUndetermined",
339
+ ],
340
+ [
341
+ "a snapshot 25 hours old",
342
+ async () => {
343
+ for (const key of Object.keys(store.__db)) delete store.__db[key];
344
+ await seedOfferings(prisma, { now: new Date(Date.now() - 25 * HOUR_MS) });
345
+ },
346
+ "OfferingsSnapshotExpired",
347
+ ],
348
+ [
349
+ "an unreadable connection",
350
+ async () => {
351
+ store.__db["stripeConnection"]![0]!["mode"] = "prod";
352
+ },
353
+ "OfferingsSnapshotEmpty",
354
+ ],
355
+ [
356
+ "a selection read that rejects",
357
+ async () => {
358
+ store.workspaceOfferings["findUnique"]!.mockRejectedValueOnce(new Error("db down"));
359
+ },
360
+ "Error",
361
+ ],
362
+ ];
363
+
364
+ it.each(failures)("%s", async (_label, arm, failed) => {
365
+ await arm();
366
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed });
367
+ await arm();
368
+ await checkoutLead("growth_monthly");
369
+ expect(lastPurchaseBody()).toMatch(GENERIC);
370
+ });
371
+
372
+ it("an undetermined licence reads no Offerings or workspace row", async () => {
373
+ mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
374
+ store.__calls.length = 0;
375
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed: "LicenseUndetermined" });
376
+ expect(store.__calls.filter((c) => [...OFFERINGS_MODELS, "workspace"].includes(c.model))).toEqual([]);
377
+ });
378
+ });
379
+ });
380
+
381
+ describe("an unconnected or unclaimed workspace keeps the page list (AC-4)", () => {
382
+ it.each([
383
+ ["a selection with no connection", FIXTURE_ORG_2_WS],
384
+ ["a connection with no selection", FIXTURE_WS_2],
385
+ ["an unknown workspace", "wksp_01920000-8934-7000-8000-0000000000ff"],
386
+ ])("%s answers the page list", async (_label, workspaceId) => {
387
+ expect(await listPlanOfferings(workspaceId)).toEqual(PAGE_ITEMS);
388
+ expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(workspaceId);
389
+ });
390
+
391
+ it("an unclaimed, connected workspace answers the page list and reads no Offerings row", async () => {
392
+ mockCheckLicense.mockReset().mockResolvedValue(FREE);
393
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual(PAGE_ITEMS);
394
+ expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(FIXTURE_WS);
395
+ expect(offeringsReads()).toEqual([]);
396
+ });
397
+ });
398
+
399
+ describe("names and the item schema (AC-5)", () => {
400
+ it("a 200-code-point emoji name keeps its billing word, fits 200 units and splits no surrogate", async () => {
401
+ const product = {
402
+ id: "prod_fixture_emoji",
403
+ active: true,
404
+ name: EMOJI_NAME,
405
+ description: null,
406
+ marketing_features: [],
407
+ unit_label: null,
408
+ metadata: { ailk_group: "plan" },
409
+ } as CatalogProduct;
410
+ const price = {
411
+ id: "price_fixture_emoji_monthly",
412
+ active: true,
413
+ product: "prod_fixture_emoji",
414
+ lookup_key: "emoji_monthly",
415
+ type: "recurring",
416
+ recurring: { interval: "month", interval_count: 1, usage_type: "licensed" },
417
+ billing_scheme: "per_unit",
418
+ unit_amount: 100,
419
+ currency: "usd",
420
+ } as CatalogPrice;
421
+ const [emoji] = normalizeCatalog({ products: [product], prices: [price] }).offerings;
422
+ expect(emoji?.name).toBe(EMOJI_NAME);
423
+ enabledRow()["enabledProductIds"] = ["prod_fixture_emoji"];
424
+ offeringsRow()["shownProductIds"] = ["prod_fixture_emoji"];
425
+ offeringsRow()["snapshot"] = { v: 1, offerings: [emoji] };
426
+
427
+ const offerings = await listPlanOfferings(FIXTURE_WS);
428
+ if (!Array.isArray(offerings)) throw new Error(`expected a list, got ${offerings.failed}`);
429
+ const [item] = offerings;
430
+ expect(item?.key).toBe("emoji_monthly");
431
+ expect(item?.name.endsWith(" (Monthly)")).toBe(true);
432
+ expect(item!.name.length).toBeLessThanOrEqual(200);
433
+ expect(item!.name).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
434
+ expect(planOfferingItemSchema.safeParse(item).success).toBe(true);
435
+ });
436
+
437
+ it("the item schema takes the Offerings groups beside the page types", () => {
438
+ const item = (type: string) => ({ key: "growth_monthly", name: "Growth (Monthly)", type });
439
+ for (const type of ["service", "product", "plan", "addon"]) {
440
+ expect(planOfferingItemSchema.safeParse(item(type)).success).toBe(true);
441
+ }
442
+ expect(planOfferingItemSchema.safeParse(item("offering")).success).toBe(false);
443
+ });
444
+ });
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * @file checkout-buyer.ts
3
3
  * @description Resolves a signed-in user into the Checkout buyer
4
- * `POST /v1/checkout/sessions` pins and stamps (#6866 D3).
4
+ * `POST /v1/checkout/sessions` pins and stamps (#6866 D3), and — the other
5
+ * way round — a completed Checkout Session into its buyer's Person, the
6
+ * subject of the terms grant the hub webhook records (#8765 D14,
7
+ * `checkoutBuyerPerson`).
5
8
  *
6
9
  * A buyer is a user whose email is VERIFIED — the customer created for them
7
10
  * carries the account email, and it must never carry an unproven one — and
@@ -11,13 +14,18 @@
11
14
  * never re-points a non-null `billingCustomerId` (#6113), so a new stamped
12
15
  * customer could only be an orphan.
13
16
  *
14
- * Base tree (N4): imports only `verified-email.ts` and `lib/prisma.ts`, never
15
- * the commerce overlay's `services/subscription.ts`. The billing read selects
16
- * no email, so `tools/lint-email-authz.ts` has nothing to flag.
17
+ * Base tree (N4): imports only `verified-email.ts`, `person.ts`,
18
+ * `lib/site.ts`, `lib/proposal-paid-store.ts` and `lib/prisma.ts` — all base
19
+ * tree — never the commerce
20
+ * overlay's `services/subscription.ts`. The billing read selects no email, so
21
+ * `tools/lint-email-authz.ts` has nothing to flag.
17
22
  */
18
23
 
19
24
  import { prisma } from "../lib/prisma.js";
25
+ import { proposalPersonId } from "../lib/proposal-paid-store.js";
26
+ import { resolveSiteWorkspaceId } from "../lib/site.js";
20
27
 
28
+ import { findOrCreatePerson } from "./person.js";
21
29
  import { findVerifiedEmail } from "./verified-email.js";
22
30
 
23
31
  export type CheckoutBuyer = {
@@ -70,3 +78,91 @@ export async function resolveCheckoutBuyer(
70
78
  },
71
79
  };
72
80
  }
81
+
82
+ // ─── The buyer's Person (#8765 D14) ──────────────────────────────────────────
83
+
84
+ /** What `checkoutBuyerPerson` reads of a signed `checkout.session.completed`. */
85
+ export type CheckoutBuyerSession = {
86
+ payment_status?: string | null;
87
+ customer_details?: { email?: string | null } | null;
88
+ metadata?: Record<string, string> | null;
89
+ };
90
+
91
+ export type CheckoutBuyerPerson = { workspaceId: string; personId: string };
92
+
93
+ function metadataString(
94
+ metadata: Record<string, string> | null | undefined,
95
+ key: string,
96
+ ): string | null {
97
+ const value: unknown = metadata?.[key];
98
+ return typeof value === "string" && value !== "" ? value : null;
99
+ }
100
+
101
+ /**
102
+ * The Person a completed Checkout Session's terms grant belongs to, or
103
+ * `null` (the webhook's `person_unresolved`). In order:
104
+ *
105
+ * - a proposal session (`metadata.ailk_proposal` + `ailk_workspace`): that
106
+ * piece's Person, when the proposal exists in that workspace;
107
+ * - a flow checkout (`metadata.flowCheckoutId`): `flowPerson`, the Person
108
+ * the webhook's Person step found or created for it — read, never
109
+ * created here.
110
+ * Neither falls through to the payer's typed email below;
111
+ * - a signed-in buyer (`metadata.ailk_user_id`, which
112
+ * `POST /v1/checkout/sessions` stamps only for a verified buyer, #6866):
113
+ * the Person of that user's VERIFIED email in the site workspace, found
114
+ * or created — in test mode too, since a verified email is the user's
115
+ * own; a user with no verified email is `null`, never the payer's typed
116
+ * email (SEC-8765-F);
117
+ * - any other paid LIVE-mode session with `customer_details.email`: that
118
+ * email's Person in the site workspace, found or created. A test-mode
119
+ * session never takes this branch, so a sandbox cannot mint a Person
120
+ * from a typed email.
121
+ *
122
+ * The site workspace is `resolveSiteWorkspaceId()`; `null` there is `null`
123
+ * here. Never throws — a store failure is `null`.
124
+ *
125
+ * @param session the signature-verified event's session object.
126
+ * @param livemode the signed event's `livemode`.
127
+ * @param flowPerson a flow checkout's Person, from `resolveCheckoutPerson`.
128
+ */
129
+ export async function checkoutBuyerPerson(
130
+ session: CheckoutBuyerSession,
131
+ livemode: boolean,
132
+ flowPerson: CheckoutBuyerPerson | null = null,
133
+ ): Promise<CheckoutBuyerPerson | null> {
134
+ const metadata = session.metadata;
135
+ try {
136
+ const pieceId = metadataString(metadata, "ailk_proposal");
137
+ const proposalWorkspaceId = metadataString(metadata, "ailk_workspace");
138
+ if (pieceId !== null && proposalWorkspaceId !== null) {
139
+ const personId = await proposalPersonId(proposalWorkspaceId, pieceId);
140
+ return personId === null
141
+ ? null
142
+ : { workspaceId: proposalWorkspaceId, personId };
143
+ }
144
+ if (metadataString(metadata, "flowCheckoutId") !== null) return flowPerson;
145
+
146
+ const buyerUserId = metadataString(metadata, "ailk_user_id");
147
+ let email: string;
148
+ if (buyerUserId !== null) {
149
+ const verified = await findVerifiedEmail(buyerUserId);
150
+ if (!verified.ok) return null;
151
+ email = verified.value.email;
152
+ } else {
153
+ const payerEmail = session.customer_details?.email;
154
+ const paid =
155
+ session.payment_status === "paid" ||
156
+ session.payment_status === "no_payment_required";
157
+ if (!livemode || !paid || typeof payerEmail !== "string") return null;
158
+ email = payerEmail;
159
+ }
160
+
161
+ const workspaceId = await resolveSiteWorkspaceId();
162
+ if (workspaceId === null) return null;
163
+ const person = await findOrCreatePerson(workspaceId, { email });
164
+ return person.ok ? { workspaceId, personId: person.value.personId } : null;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
@@ -34,6 +34,7 @@ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
34
34
  import type { Prisma } from "@working-theory/database";
35
35
  import { logger } from "@working-theory/observability";
36
36
  import {
37
+ ACCOUNT_TERMS_PURPOSE_ID,
37
38
  consentPolicyHref,
38
39
  consentPurposeById,
39
40
  consentPurposeKind,
@@ -52,6 +53,7 @@ import {
52
53
  effectiveConsent,
53
54
  latestVisitorChoice,
54
55
  ownConsentRows,
56
+ personHasCheckoutGrant,
55
57
  userPlatformRows,
56
58
  type PurposeConsentState,
57
59
  } from "../lib/consent-store.js";
@@ -507,6 +509,99 @@ export async function recordPersonConsent(
507
509
  });
508
510
  }
509
511
 
512
+ export type RecordCheckoutConsentOutcome =
513
+ "recorded" | "unchanged" | "purpose_unknown" | "version_changed";
514
+
515
+ /**
516
+ * `<purposeId>@<policyVersion>` of the account terms as the registry reads
517
+ * them now, and that version — the only stamp a checkout grant is recorded
518
+ * for (SEC-8765-E). A spoke purchase's lead names the stamp it accepted
519
+ * (#8778); derived, never written down, so a version bump retires every
520
+ * older stamp.
521
+ */
522
+ export function currentAccountTermsStamp(): {
523
+ stamp: string;
524
+ policyVersion: number;
525
+ } | null {
526
+ const purpose = consentPurposeById(
527
+ SITE_CONSENT_CONFIG_DEFAULTS,
528
+ ACCOUNT_TERMS_PURPOSE_ID,
529
+ );
530
+ return purpose
531
+ ? {
532
+ stamp: `${purpose.id}@${purpose.policyVersion}`,
533
+ policyVersion: purpose.policyVersion,
534
+ }
535
+ : null;
536
+ }
537
+
538
+ /**
539
+ * The buyer's grant from a completed Stripe Checkout whose terms box was
540
+ * accepted (#8765 D14): ONE `account_terms` row, subject `person` /
541
+ * `personId`, source `checkout`, `sourceRef` the Checkout Session id, locale
542
+ * `en`, and the registry's current statement, hash and policy version — the
543
+ * statement the box showed once its link keys were resolved (D12).
544
+ *
545
+ * One transaction whose first statement is an advisory lock on
546
+ * `ailk:consent:<workspaceId>:checkout:<sessionId>`; then, when this Person
547
+ * already holds this session's `checkout` row, nothing is written (NFR7) — so
548
+ * a webhook redelivery, even after a restart, adds no row. The row goes
549
+ * through `recordConsent` on the transaction client. A `checkout` row is
550
+ * unverified (D13): it reaches only the operator's any-source reading.
551
+ * When the site registry's `account_terms` version is not the version the
552
+ * session was stamped with, nothing is written (`version_changed`): the row
553
+ * would otherwise record terms the box never showed.
554
+ *
555
+ * @param input.workspaceId the server-resolved workspace of the buyer's Person.
556
+ * @param input.personId the buyer's Person, resolved by `checkoutBuyerPerson`.
557
+ * @param input.sessionId the signed event's Checkout Session id.
558
+ * @param input.policyVersion the version the session's `ailk_terms` stamp names.
559
+ */
560
+ export async function recordCheckoutConsent(input: {
561
+ workspaceId: string;
562
+ personId: string;
563
+ sessionId: string;
564
+ policyVersion: number;
565
+ }): Promise<RecordCheckoutConsentOutcome> {
566
+ const config = await consentConfigForSite(input.workspaceId);
567
+ const purpose = consentPurposeById(config, ACCOUNT_TERMS_PURPOSE_ID);
568
+ if (!purpose) return "purpose_unknown";
569
+ if (purpose.policyVersion !== input.policyVersion) return "version_changed";
570
+
571
+ return prisma.$transaction(async (tx) => {
572
+ await lockConsent(
573
+ tx,
574
+ `ailk:consent:${input.workspaceId}:checkout:${input.sessionId}`,
575
+ );
576
+ if (
577
+ await personHasCheckoutGrant(
578
+ input.workspaceId,
579
+ input.personId,
580
+ purpose.id,
581
+ input.sessionId,
582
+ tx,
583
+ )
584
+ ) {
585
+ return "unchanged" as const;
586
+ }
587
+ await recordConsent(
588
+ {
589
+ siteId: input.workspaceId,
590
+ subjectType: "person",
591
+ subjectId: input.personId,
592
+ personId: input.personId,
593
+ source: "checkout",
594
+ sourceRef: input.sessionId,
595
+ locale: "en",
596
+ config,
597
+ consents: { [purpose.id]: true },
598
+ },
599
+ tx,
600
+ );
601
+ return "recorded" as const;
602
+ });
603
+ }
604
+
510
605
  /**
511
606
  * One registry purpose as a settings screen reads it (D6): the wording in
512
607
  * `locale`, the policy link, and the caller's OWN state. `state` is `null`