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
@@ -63,6 +63,8 @@ const UNVERIFIED_SOURCES: ReadonlySet<ConsentSource> = new Set([
63
63
  "api",
64
64
  "cookie_banner",
65
65
  "legacy",
66
+ // #8765 D13, C10: the payer at a Stripe Checkout is not a signed-in user.
67
+ "checkout",
66
68
  ]);
67
69
 
68
70
  /** Whether a row from `source` is unconfirmed by the client (D4). */
@@ -176,6 +178,32 @@ export async function anySourceConsentRows(
176
178
  ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
177
179
  }
178
180
 
181
+ /**
182
+ * Whether this Person already holds the `checkout` row of one Checkout
183
+ * Session for one purpose (#8765 D14, NFR7) — the webhook's replay check.
184
+ * Filters on `(workspace_id, person_id, purpose_id)` first, so the existing
185
+ * `(workspace_id, person_id, purpose_id, created_at)` index serves it
186
+ * (SEC-8765-N).
187
+ */
188
+ export async function personHasCheckoutGrant(
189
+ workspaceId: string,
190
+ personId: string,
191
+ purposeId: string,
192
+ sessionId: string,
193
+ db: ConsentReadClient = prisma,
194
+ ): Promise<boolean> {
195
+ const rows = await db.$queryRaw<Array<{ found: number }>>`
196
+ SELECT 1 AS "found"
197
+ FROM "consent_grants" g
198
+ WHERE g."workspace_id" = ${workspaceId}
199
+ AND g."person_id" = ${personId}
200
+ AND g."purpose_id" = ${purposeId}
201
+ AND g."source" = 'checkout'
202
+ AND g."source_ref" = ${sessionId}
203
+ LIMIT 1`;
204
+ return rows.length > 0;
205
+ }
206
+
179
207
  /**
180
208
  * A Person's OWN reading, per registry purpose (D4). The member's screens and
181
209
  * the sign-up prompt read only this.
@@ -16,6 +16,7 @@
16
16
  * - Leaves replies that handlers already committed (explicit status + body)
17
17
  * untouched
18
18
  */
19
+ import { idRegex } from "@working-theory/ids";
19
20
  import type { FastifyError, FastifyInstance } from "fastify";
20
21
 
21
22
  const GENERIC_INTERNAL_BODY = { error: "internal_error" } as const;
@@ -30,16 +31,82 @@ const GENERIC_INTERNAL_BODY = { error: "internal_error" } as const;
30
31
  * EVERY route. Logging the raw target would write that credential into the
31
32
  * API's own log the moment anything went wrong, which is precisely when logs
32
33
  * get read, copied into tickets, and shipped to third parties.
33
- *
34
- * The path itself is kept: it is what makes the line useful, and a route
35
- * parameter like `:id` is an identifier rather than a secret. The matching
36
- * strip lives on the Fastify logger's own `req` serializer in `server.ts`.
37
34
  */
38
35
  function pathWithoutQuery(url: string): string {
39
36
  const queryAt = url.indexOf("?");
40
37
  return queryAt === -1 ? url : url.slice(0, queryAt);
41
38
  }
42
39
 
40
+ /**
41
+ * #6084 D14 — `GET /v1/checkout/sessions/:id` puts its credential in the
42
+ * PATH, so a `cs_…`-shaped segment is truncated to its first 8 characters
43
+ * plus `…` (a path SEGMENT rule, never a substring replace, so it cannot
44
+ * corrupt an unrelated URL). #6216 D11 adds the same arm for `lead_…`
45
+ * segments and #8438 SEC-8438-10 for `person_…` segments, which would
46
+ * otherwise put a full id beside `remoteAddress`.
47
+ */
48
+ const SESSION_ID_LOG_SEGMENT = /^cs_[A-Za-z0-9_]+$/;
49
+ const LEAD_ID_LOG_SEGMENT = idRegex("lead");
50
+ const PERSON_ID_LOG_SEGMENT = idRegex("person");
51
+
52
+ /**
53
+ * #8932 D10 (contract W9) — a webhook connection token is the per-connection
54
+ * receiver's routing credential (`/v1/webhooks/<provider>/:connectionToken`).
55
+ * Every segment after the provider is replaced by one literal
56
+ * `:connectionToken`, keeping no prefix — unlike the id arms above — and
57
+ * empty segments are dropped first, so a trailing slash, a double slash or a
58
+ * mis-pasted extra segment cannot carry the token past the rule.
59
+ */
60
+ const CONNECTION_TOKEN_PLACEHOLDER = ":connectionToken";
61
+
62
+ /** A segment as the router matches it: percent-decoded, or raw when it does not decode. */
63
+ function decodedSegment(segment: string): string {
64
+ try {
65
+ return decodeURIComponent(segment);
66
+ } catch {
67
+ return segment;
68
+ }
69
+ }
70
+
71
+ function redactConnectionToken(path: string): string | null {
72
+ // Compared DECODED: the router decodes before it matches, so
73
+ // `/v1/%77ebhooks/stripe/<token>` reaches the token route and must be
74
+ // redacted like its plain spelling.
75
+ const segments = path
76
+ .split("/")
77
+ .filter((segment) => segment.length > 0)
78
+ .map(decodedSegment);
79
+ if (segments.length < 4 || segments[0] !== "v1" || segments[1] !== "webhooks") {
80
+ return null;
81
+ }
82
+ return `/${segments.slice(0, 3).join("/")}/${CONNECTION_TOKEN_PLACEHOLDER}`;
83
+ }
84
+
85
+ /**
86
+ * The ONE path redactor for a log line (#8932 D10): the query string dropped,
87
+ * a webhook connection token replaced whole, and every `cs_…`, `lead_…` and
88
+ * `person_…` segment truncated. The Fastify request serializer (`server.ts`)
89
+ * and this handler's three `path:` fields both call it, so a 429, 413, 415 or
90
+ * 5xx on a token route logs the same redacted path its request line does.
91
+ * A route parameter that is an identifier rather than a secret — `:id` — is
92
+ * kept: it is what makes the line useful.
93
+ */
94
+ export function redactLogPath(url: string): string {
95
+ const path = pathWithoutQuery(url);
96
+ const tokenless = redactConnectionToken(path);
97
+ if (tokenless !== null) return tokenless;
98
+ return path
99
+ .split("/")
100
+ .map((segment) =>
101
+ SESSION_ID_LOG_SEGMENT.test(segment) ||
102
+ LEAD_ID_LOG_SEGMENT.test(segment) ||
103
+ PERSON_ID_LOG_SEGMENT.test(segment)
104
+ ? `${segment.slice(0, 8)}…`
105
+ : segment,
106
+ )
107
+ .join("/");
108
+ }
109
+
43
110
  export function registerErrorSerializer(server: FastifyInstance): void {
44
111
  server.setErrorHandler(async (err: FastifyError, request, reply) => {
45
112
  // Validation errors (Fastify's schema validator) — keep structured detail.
@@ -49,7 +116,7 @@ export function registerErrorSerializer(server: FastifyInstance): void {
49
116
  request.log.warn(
50
117
  {
51
118
  method: request.method,
52
- path: pathWithoutQuery(request.url),
119
+ path: redactLogPath(request.url),
53
120
  validation: err.validation,
54
121
  },
55
122
  "request validation failed",
@@ -68,7 +135,7 @@ export function registerErrorSerializer(server: FastifyInstance): void {
68
135
  request.log.info(
69
136
  {
70
137
  method: request.method,
71
- path: pathWithoutQuery(request.url),
138
+ path: redactLogPath(request.url),
72
139
  statusCode: status,
73
140
  errName: err.name,
74
141
  },
@@ -83,7 +150,7 @@ export function registerErrorSerializer(server: FastifyInstance): void {
83
150
  request.log.error(
84
151
  {
85
152
  method: request.method,
86
- path: pathWithoutQuery(request.url),
153
+ path: redactLogPath(request.url),
87
154
  errName: err.name,
88
155
  errMessage: err.message,
89
156
  errStack: err.stack,
@@ -0,0 +1,136 @@
1
+ /**
2
+ * A magnet's completion setting — its data boundary (#8757, spec
3
+ * docs/specs/8757-a-per-magnet-completion-setting-spec.md D3).
4
+ *
5
+ * `flow_definitions.completion` / `checkout_offering` are new columns, and
6
+ * `lib/tenant-db.ts` — the magnet routes' only other data path — stays closed
7
+ * under the parallel contract with #8746 (D7), so the two columns get this
8
+ * named boundary of their own. Every statement is scoped by a server-resolved
9
+ * `workspaceId` (a tenant's, or the public lead route's resolved workspace):
10
+ *
11
+ * - `completionsFor` — one `findMany` by workspace AND id list;
12
+ * - `setCompletion` — one `updateMany` by id AND workspace, writing
13
+ * exactly the two columns;
14
+ * - `liveCompletionBySlug` — one `findFirst` of a LIVE magnet by workspace
15
+ * AND slug (its pair, title and config — the
16
+ * config for a declared `completionEmail`);
17
+ * - `inviteSendCounts` — two `lead.count`s for the invite email's caps
18
+ * (D6), by workspace AND `flow:<slug>` source.
19
+ *
20
+ * No list without an id set, no other model, no read by any other key.
21
+ * Base tree (SEC-8757-D): no `oss: false` marker and no `MODULE_SLICES`
22
+ * entry, because the never-gate lead route imports it. `architecture.yaml`
23
+ * lists this file under the tenant-scope rule's `paths_scoped` with exactly
24
+ * these four call sites' counts, so a further access fails the lint.
25
+ */
26
+ import {
27
+ FLOW_COMPLETION_DEFAULT,
28
+ flowCompletionSettingSchema,
29
+ type FlowCompletionSetting,
30
+ } from "@working-theory/validation";
31
+
32
+ import { prisma } from "./prisma.js";
33
+
34
+ const DEFAULT_SETTING: FlowCompletionSetting = {
35
+ completion: FLOW_COMPLETION_DEFAULT,
36
+ checkoutOffering: null,
37
+ };
38
+
39
+ /**
40
+ * A stored pair as the vocabulary reads it. The database's CHECKs make every
41
+ * stored pair valid; a row that carries neither column (a test fixture, or a
42
+ * read before the migration) reads as the default, as `customersEnabled` does.
43
+ */
44
+ function toSetting(row: { completion?: unknown; checkoutOffering?: unknown }) {
45
+ const parsed = flowCompletionSettingSchema.safeParse({
46
+ completion: row.completion,
47
+ checkoutOffering: row.checkoutOffering ?? null,
48
+ });
49
+ return parsed.success ? parsed.data : DEFAULT_SETTING;
50
+ }
51
+
52
+ /** The setting of each of `ids` in `workspaceId`. An id absent from the map reads as `lead`. */
53
+ export async function completionsFor(
54
+ workspaceId: string,
55
+ ids: readonly string[],
56
+ ): Promise<Map<string, FlowCompletionSetting>> {
57
+ const out = new Map<string, FlowCompletionSetting>();
58
+ if (ids.length === 0) return out;
59
+ const rows = await prisma.flowDefinition.findMany({
60
+ where: { workspaceId, id: { in: [...ids] } },
61
+ select: { id: true, completion: true, checkoutOffering: true },
62
+ });
63
+ for (const row of rows) out.set(row.id, toSetting(row));
64
+ return out;
65
+ }
66
+
67
+ /** `completionsFor`'s answer for one id. */
68
+ export function completionOf(
69
+ settings: Map<string, FlowCompletionSetting>,
70
+ id: string,
71
+ ): FlowCompletionSetting {
72
+ return settings.get(id) ?? DEFAULT_SETTING;
73
+ }
74
+
75
+ /** Write the pair onto magnet `id` of `workspaceId`; nothing else on the row. */
76
+ export async function setCompletion(
77
+ workspaceId: string,
78
+ id: string,
79
+ setting: FlowCompletionSetting,
80
+ ): Promise<"set" | "not_found"> {
81
+ const result = await prisma.flowDefinition.updateMany({
82
+ where: { id, workspaceId },
83
+ data: { completion: setting.completion, checkoutOffering: setting.checkoutOffering },
84
+ });
85
+ return result.count > 0 ? "set" : "not_found";
86
+ }
87
+
88
+ export type LiveCompletion = FlowCompletionSetting & {
89
+ title: string;
90
+ /** The stored config, unparsed — the invite email reads its `completionEmail`. */
91
+ config: unknown;
92
+ };
93
+
94
+ /** The LIVE magnet at `slug` in `workspaceId`: its setting, title and config, or `null`. */
95
+ export async function liveCompletionBySlug(
96
+ workspaceId: string,
97
+ slug: string,
98
+ ): Promise<LiveCompletion | null> {
99
+ const row = await prisma.flowDefinition.findFirst({
100
+ where: { workspaceId, slug, status: "live" },
101
+ select: { title: true, config: true, completion: true, checkoutOffering: true },
102
+ });
103
+ return row === null ? null : { ...toSetting(row), title: row.title, config: row.config };
104
+ }
105
+
106
+ const DAY_MS = 24 * 60 * 60 * 1000;
107
+ const HOUR_MS = 60 * 60 * 1000;
108
+
109
+ /**
110
+ * D6's two caps, counted in the database so they hold across instances and
111
+ * against a spoofed `x-forwarded-for`: this magnet's leads for `email`
112
+ * (case-insensitively) over the last day, and all of its leads over the last
113
+ * hour. Both include the lead the caller just wrote.
114
+ */
115
+ export async function inviteSendCounts(
116
+ workspaceId: string,
117
+ slug: string,
118
+ email: string,
119
+ now: Date,
120
+ ): Promise<{ recipientDay: number; siteHour: number }> {
121
+ const source = `flow:${slug}`;
122
+ const [recipientDay, siteHour] = await Promise.all([
123
+ prisma.lead.count({
124
+ where: {
125
+ workspaceId,
126
+ source,
127
+ email: { equals: email, mode: "insensitive" },
128
+ createdAt: { gte: new Date(now.getTime() - DAY_MS) },
129
+ },
130
+ }),
131
+ prisma.lead.count({
132
+ where: { workspaceId, source, createdAt: { gte: new Date(now.getTime() - HOUR_MS) } },
133
+ }),
134
+ ]);
135
+ return { recipientDay, siteHour };
136
+ }
@@ -102,6 +102,38 @@ export async function findClientByClientId(
102
102
  });
103
103
  }
104
104
 
105
+ /** What the hub's sign-in page may show about a spoke (#8790 D2). */
106
+ export interface SignInClientInfo {
107
+ clientId: string;
108
+ /** The spoke's display name from the registry. */
109
+ name: string;
110
+ /** The origin of the client's first registered redirect URI. */
111
+ origin: string;
112
+ }
113
+
114
+ /**
115
+ * The public shape of one spoke's sign-in client, or `null` when the client
116
+ * is missing, disabled, or its spoke is revoked (#8790 D2). Only the
117
+ * registry decides what a sign-in page may name — never a query parameter.
118
+ */
119
+ export async function findSignInClientInfo(
120
+ clientId: string,
121
+ ): Promise<SignInClientInfo | null> {
122
+ const client = await findClientByClientId(clientId);
123
+ if (client === null || !client.enabled) return null;
124
+ const spoke = await findSpokeById(clientId);
125
+ if (spoke === null || spoke.revokedAt !== null) return null;
126
+ const first = client.redirectUris[0];
127
+ if (first === undefined) return null;
128
+ let origin: string;
129
+ try {
130
+ origin = new URL(first).origin;
131
+ } catch {
132
+ return null;
133
+ }
134
+ return { clientId, name: spoke.name, origin };
135
+ }
136
+
105
137
  /**
106
138
  * Create or update a spoke's sign-in client (the operator CLI). Re-running
107
139
  * rotates the secret: `clientSecret` is replaced by the new digest, so the
@@ -161,7 +193,10 @@ export function canonicalRedirectUri(raw: string): string | null {
161
193
 
162
194
  export type RegisterSignInClientResult =
163
195
  | { ok: true; spokeId: string; secret: string }
164
- | { ok: false; reason: "invalid-redirect-uri" | "spoke-not-found" | "spoke-revoked" };
196
+ | {
197
+ ok: false;
198
+ reason: "invalid-redirect-uri" | "spoke-not-found" | "spoke-revoked";
199
+ };
165
200
 
166
201
  /**
167
202
  * The operator CLI's `sign-in-client` (D3): refuse an invalid callback, an
@@ -173,7 +208,8 @@ export async function registerSignInClient(params: {
173
208
  redirectUri: string;
174
209
  }): Promise<RegisterSignInClientResult> {
175
210
  const redirectUri = canonicalRedirectUri(params.redirectUri);
176
- if (redirectUri === null) return { ok: false, reason: "invalid-redirect-uri" };
211
+ if (redirectUri === null)
212
+ return { ok: false, reason: "invalid-redirect-uri" };
177
213
 
178
214
  const spoke = await findSpokeById(params.spokeId);
179
215
  if (!spoke) return { ok: false, reason: "spoke-not-found" };
@@ -8,13 +8,15 @@
8
8
  * every scaffold without the member slice (the #5677 failure). Its one piece
9
9
  * write therefore lives here, in the base tree, importing only `prisma.ts`.
10
10
  *
11
- * Two statements and no others, both scoped by a workspace id AND a piece id
11
+ * Three statements and no others, all scoped by a workspace id AND a piece id
12
12
  * taken from a Stripe-signed `checkout.session.completed` event's metadata —
13
13
  * written by this server when it opened the session (`ailk_proposal`,
14
14
  * `ailk_workspace`; `services/proposal-payments.ts`), never settable by the
15
- * payer: a guarded `updateMany` that stamps an unpaid proposal paid, and a
16
- * `findFirst` by the same pair reading back which session paid it. No list,
17
- * no other model, no read by any other key; never called by a route.
15
+ * payer: a guarded `updateMany` that stamps an unpaid proposal paid, a
16
+ * `findFirst` by the same pair reading back which session paid it, and a
17
+ * `findFirst` by the same pair reading the proposal's Person — the subject of
18
+ * the checkout's terms grant (#8765 D14). No list, no other model, no read by
19
+ * any other key; never called by a route.
18
20
  * `architecture.yaml` names this file as a tenant-scope exclusion with that
19
21
  * reason.
20
22
  */
@@ -47,3 +49,18 @@ export async function stampProposalPaid(
47
49
  if (row === null) return "not_found";
48
50
  return row.paidSessionId === sessionId ? "replay" : "already_paid";
49
51
  }
52
+
53
+ /**
54
+ * The proposal's Person (#8765 D14), or `null` when no proposal of that id
55
+ * is in that workspace.
56
+ */
57
+ export async function proposalPersonId(
58
+ workspaceId: string,
59
+ pieceId: string,
60
+ ): Promise<string | null> {
61
+ const row = await prisma.piece.findFirst({
62
+ where: { id: pieceId, workspaceId, kind: "proposal" },
63
+ select: { personId: true },
64
+ });
65
+ return row?.personId ?? null;
66
+ }
@@ -8,11 +8,14 @@
8
8
  * '@working-theory/stripe-client' — single chokepoint for AILK-internal ergonomics
9
9
  * (logger context, request-id propagation) without touching every consumer.
10
10
  */
11
+ import { logger } from "@working-theory/observability";
11
12
  import {
12
13
  admitLiveRestrictedKey,
13
14
  assertSandboxKey,
15
+ createStripeClientForKey,
14
16
  getStripeClient,
15
17
  StripeConfigError,
18
+ type StripeConnectionMode,
16
19
  } from "@working-theory/stripe-client";
17
20
 
18
21
  export {
@@ -26,24 +29,37 @@ export {
26
29
  // Both are on main (#6850, #6862); #8450 is the narrowing this guarded —
27
30
  // `assertAdmittedStripeKey` below. This export itself stays sandbox-only.
28
31
  assertSandboxKey,
32
+ // The plan checkout's mode assertion and terms-box failure (#9067 D3 step 9).
33
+ CheckoutModeMismatchError,
34
+ // The Organization connection's key classifier (#8906, #8907 D4).
35
+ classifyStripeKey,
29
36
  createAdHocPrice,
30
37
  createBillingPortalSession,
31
38
  createCheckoutSession,
39
+ // The Offerings checkout's per-client calls (#8931 D6), on the connection's client.
40
+ createCheckoutSessionForPrice,
32
41
  createPaymentLink,
33
42
  createQuotedCheckoutSession,
43
+ expireCheckoutSessionWith,
44
+ findActivePriceByLookupKey,
34
45
  getStripeClient,
46
+ // Working Theory's own absolute policies for the plan checkout's terms box (#9067 D3 step 9).
47
+ PLATFORM_TERMS_OF_SERVICE,
35
48
  // Checkout session read (#6084 D9) — re-exported through this sole
36
49
  // Stripe-touching chokepoint, same as every other Stripe call here.
37
50
  retrieveCheckoutSession,
38
51
  STRIPE_API_VERSION,
39
52
  StripeConfigError,
40
53
  StripeWebhookSignatureError,
54
+ TermsOfServiceUrlMissingError,
41
55
  toSubscriptionLifecycleStatus,
42
56
  // Signed-in Checkout (#6866 D8) — a pinned customer Stripe rejects.
43
57
  UnknownCustomerError,
44
58
  UnknownProductCodeError,
45
59
  UnknownSessionError,
46
60
  verifyWebhookSignature,
61
+ // The per-Organization webhook's verify (#8932 D3): the row's secret, no env key.
62
+ verifyWebhookSignatureKeyless,
47
63
  WEBHOOK_TOLERANCE_SECONDS,
48
64
  } from "@working-theory/stripe-client";
49
65
 
@@ -55,6 +71,7 @@ export type {
55
71
  CreateBillingPortalSessionOutput,
56
72
  CreatePaymentLinkInput,
57
73
  CreatePaymentLinkOutput,
74
+ CreateCheckoutSessionForPriceInput,
58
75
  CreateCheckoutSessionInput,
59
76
  CreateCheckoutSessionOutput,
60
77
  CreateCheckoutSessionParams,
@@ -64,6 +81,8 @@ export type {
64
81
  RetrieveCheckoutSessionOutput,
65
82
  Stripe,
66
83
  StripeCheckoutSession,
84
+ StripeConnectionMode,
85
+ StripeKeyClass,
67
86
  StripeWebhookEvent,
68
87
  } from "@working-theory/stripe-client";
69
88
 
@@ -81,9 +100,10 @@ let liveKeyAdmitted = false;
81
100
  * rule, with one fail-closed delta (PR #8470 security review R-1): on Vercel
82
101
  * (`VERCEL` set) a missing `VERCEL_ENV` is not production, because
83
102
  * `vercel.json` sets `NODE_ENV=production` in every environment, Preview
84
- * included.
103
+ * included. Exported for the Organization connection's go-live check and
104
+ * its live-row guard (#8907 D3, D8 step 6); the body is unchanged.
85
105
  */
86
- function isProductionBuild(): boolean {
106
+ export function isProductionBuild(): boolean {
87
107
  if (process.env.VERCEL_ENV !== undefined) {
88
108
  return process.env.VERCEL_ENV === "production";
89
109
  }
@@ -145,6 +165,35 @@ export function assertAdmittedStripeKey(key: string): void {
145
165
  assertSandboxKey(key);
146
166
  }
147
167
 
168
+ /**
169
+ * A fresh Stripe client from ONE Organization's own key (#8907 D12, #8906
170
+ * F-T1 to F-T3), or `null` when the factory refuses it: the factory's only
171
+ * caller, so `productionBuild` is always the strict predicate above and never
172
+ * a caller's boolean. It reads and sets no process flag — `stripeKeyMode()`
173
+ * is untouched — so one process holds a live Organization and a test one
174
+ * (contract K6, K7). A refusal logs `{ organizationId, reason }` only.
175
+ * `architecture.yaml` confines its callers to exact paths.
176
+ */
177
+ export function stripeClientForConnection(input: {
178
+ organizationId: string;
179
+ key: string;
180
+ mode: StripeConnectionMode;
181
+ liveAt: Date | null;
182
+ }): ReturnType<typeof createStripeClientForKey> {
183
+ return createStripeClientForKey({
184
+ organizationId: input.organizationId,
185
+ key: input.key,
186
+ mode: input.mode,
187
+ liveAt: input.liveAt,
188
+ productionBuild: isProductionBuild(),
189
+ log: (refusal) =>
190
+ logger.warn(
191
+ { organizationId: refusal.organizationId, reason: refusal.reason },
192
+ "[stripe] connection key refused for its mode",
193
+ ),
194
+ });
195
+ }
196
+
148
197
  /** The key class this process admitted at boot: `"live"` only after `admitApiStripeKey()` did. */
149
198
  export function stripeKeyMode(): "test" | "live" {
150
199
  return liveKeyAdmitted ? "live" : "test";
@@ -165,14 +214,22 @@ export async function expireCheckoutSession(stripeSessionId: string): Promise<vo
165
214
  await getStripeClient().checkout.sessions.expire(stripeSessionId);
166
215
  }
167
216
 
168
- /** #8171 D7 — what the Billing page shows: the plan's name and the card on file. */
217
+ /**
218
+ * #8171 D7 — what the Billing page shows: the plan's name and the card on
219
+ * file. #9067 D6 adds the renewal date and the site count, read from the
220
+ * same subscription.
221
+ */
169
222
  export type CustomerBillingSummary = {
170
223
  planName: string | null;
171
224
  paymentMethod: { brand: string; last4: string; expMonth: number; expYear: number } | null;
225
+ renewsAt: string | null;
226
+ sites: number | null;
172
227
  };
173
228
 
174
229
  /** A subscription status that still names the customer's plan (#8171 C10). */
175
230
  const CURRENT_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due", "unpaid"]);
231
+ /** A subscription status that renews at its period end (#9067 C3). */
232
+ const RENEWING_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due"]);
176
233
  const PLAN_NAME_MAX = 200;
177
234
 
178
235
  /**
@@ -224,7 +281,20 @@ export async function readCustomerBillingSummary(
224
281
  ?.default_payment_method ?? null;
225
282
  }
226
283
 
227
- return { planName, paymentMethod: cardOf(method) };
284
+ // #9067 C3, D6 — under `dahlia` the period end lives on the item, and a
285
+ // subscription set to cancel names no renewal.
286
+ const item = current?.items.data[0];
287
+ const renews =
288
+ current !== undefined &&
289
+ RENEWING_SUBSCRIPTION_STATUSES.has(current.status) &&
290
+ current.cancel_at_period_end !== true &&
291
+ current.cancel_at == null &&
292
+ typeof item?.current_period_end === "number";
293
+ const renewsAt = renews ? new Date(item.current_period_end * 1000).toISOString() : null;
294
+ const quantity = item?.quantity;
295
+ const sites = typeof quantity === "number" && Number.isInteger(quantity) && quantity >= 1 ? quantity : null;
296
+
297
+ return { planName, paymentMethod: cardOf(method), renewsAt, sites };
228
298
  }
229
299
 
230
300
  /** A `card` payment method's presentation fields, or `null` for anything else. */
@@ -82,11 +82,75 @@ export function markSeen(eventId: string, nowMs: number = Date.now()): void {
82
82
  }
83
83
  }
84
84
 
85
+ // ─── Per-Organization store (#8932 D8, contract W10) ─────────────────────────
86
+ //
87
+ // The per-Organization endpoint (`POST /v1/webhooks/stripe/:connectionToken`)
88
+ // dedupes in its OWN map, keyed `<organizationId>:<eventId>`, so neither
89
+ // endpoint's dedup can swallow the other's event (SEC-8879-1) and two
90
+ // Organizations sharing one Stripe account never collide (an Organization id
91
+ // holds no `:`). Same TTL, soft cap and sweep rules as the legacy pair above;
92
+ // the legacy `hasSeen`/`markSeen` never read it. Correctness rests on the
93
+ // lead's database key, not on this map.
94
+
95
+ const orgStore = new Map<string, Entry>();
96
+ let orgInsertsSinceLastSweep = 0;
97
+
98
+ function orgKey(organizationId: string, eventId: string): string {
99
+ return `${organizationId}:${eventId}`;
100
+ }
101
+
102
+ function sweepOrgExpired(nowMs: number): void {
103
+ for (const [key, entry] of orgStore) {
104
+ if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
105
+ orgStore.delete(key);
106
+ }
107
+ }
108
+ orgInsertsSinceLastSweep = 0;
109
+ }
110
+
111
+ /** `hasSeen`'s twin for one Organization's endpoint. */
112
+ export function hasSeenForOrganization(
113
+ organizationId: string,
114
+ eventId: string,
115
+ nowMs: number = Date.now(),
116
+ ): boolean {
117
+ const key = orgKey(organizationId, eventId);
118
+ const entry = orgStore.get(key);
119
+ if (!entry) return false;
120
+ if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
121
+ orgStore.delete(key);
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+
127
+ /** `markSeen`'s twin for one Organization's endpoint. */
128
+ export function markSeenForOrganization(
129
+ organizationId: string,
130
+ eventId: string,
131
+ nowMs: number = Date.now(),
132
+ ): void {
133
+ const key = orgKey(organizationId, eventId);
134
+ if (!orgStore.has(key)) {
135
+ orgStore.set(key, { insertedAtMs: nowMs });
136
+ }
137
+
138
+ orgInsertsSinceLastSweep += 1;
139
+ if (
140
+ orgStore.size > SOFT_CAP ||
141
+ orgInsertsSinceLastSweep >= SWEEP_INTERVAL_INSERTS
142
+ ) {
143
+ sweepOrgExpired(nowMs);
144
+ }
145
+ }
146
+
85
147
  /**
86
- * Test-only reset. Clears all dedup state between test cases so they do not
87
- * interfere with each other.
148
+ * Test-only reset. Clears all dedup state — both stores — between test cases
149
+ * so they do not interfere with each other.
88
150
  */
89
151
  export function __resetIdempotencyStore(): void {
90
152
  store.clear();
91
153
  insertsSinceLastSweep = 0;
154
+ orgStore.clear();
155
+ orgInsertsSinceLastSweep = 0;
92
156
  }