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,1003 @@
1
+ /**
2
+ * An Organization's Stripe connection (#8876, spec
3
+ * docs/specs/8876-the-offerings-data-model-typed-spec.md D6; #8907, spec
4
+ * docs/specs/8907-the-organization-stripe-connection-custody-spec.md D2–D6,
5
+ * D11–D13; contract docs/specs/8879-the-offerings-security-contract-key-spec.md
6
+ * C1–C11, K3–K9, P2) — the ONE file that reads or writes the StripeConnection
7
+ * model, and the one that encrypts or decrypts its key and webhook secret.
8
+ *
9
+ * WRITE-ONLY TO EVERY READER. No function here returns the key, the webhook
10
+ * secret, the routing token's row or a ciphertext to a route: the summary
11
+ * and the view select no ciphertext (the view reads only whether a secret is
12
+ * set), and a write answers `Saved`, which carries no `last4` and no token
13
+ * (SEC-8907-10). Only `stripeClientForOrganization` and the token lookup
14
+ * decrypt, for their server-side callers (#8054, #8861).
15
+ *
16
+ * ENCRYPTION AT REST (D2, C2, C10). `v1.<iv>.<tag>.<ciphertext>`, each part
17
+ * base64url: AES-256-GCM under `config.STRIPE_CONNECTION_KEY`, a fresh 12-byte
18
+ * IV, a 16-byte tag (enforced on decrypt), and
19
+ * `stripe-connection:v1:<organizationId>:<field>` as additional data, so a
20
+ * value copied to another Organization, into the other column or under the
21
+ * other mode fails authentication. Decryption tries the current key, then
22
+ * `STRIPE_CONNECTION_KEY_PREVIOUS`; encryption always uses the current key.
23
+ * A copy of the scheduling envelope, not an import: that file is a slice file
24
+ * and this one is base tree (D2).
25
+ *
26
+ * DATA ACCESS (D6, C11). Ten delegate calls, each keyed on an
27
+ * `organizationId` the server resolved (the tenant, or the row a token
28
+ * named), pinned by `architecture.yaml`'s `paths_scoped` count. Only the
29
+ * rekey walk lists rows, and only the bin calls it. Every call is wrapped so
30
+ * a Prisma error leaves as a `StripeConnectionStoreError` with a fixed
31
+ * message and no cause — a CHECK violation's text names the row's values
32
+ * (SEC-8907-9).
33
+ *
34
+ * `null` means no row and nothing else. A row that fails its schema is
35
+ * `"unreadable"`, so no caller mistakes a corrupt connection for an absent
36
+ * one and falls back to the deployment's env key (contract A2, NFR2).
37
+ *
38
+ * LOGGING (D15, C5). Only `organizationId`, `mode`, a fixed `outcome` or
39
+ * `reason`, and an error's class name. Never the key or any part of it,
40
+ * `last4`, the secret, the token, a ciphertext or Stripe error text.
41
+ */
42
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
43
+
44
+ import { logger } from "@working-theory/observability";
45
+ import {
46
+ stripeConnectionSummarySchema,
47
+ type StripeConnectionSummary,
48
+ } from "@working-theory/validation";
49
+ import { z } from "zod";
50
+
51
+ import { config } from "../../config/index.js";
52
+ import { prisma } from "../../lib/prisma.js";
53
+ import {
54
+ classifyStripeKey,
55
+ isProductionBuild,
56
+ stripeClientForConnection,
57
+ type StripeConnectionMode,
58
+ type StripeKeyClass,
59
+ } from "../../lib/stripe.js";
60
+
61
+ /** The connection's summary, `"unreadable"` for a corrupt row, `null` for none. */
62
+ export async function getStripeConnectionSummary(
63
+ organizationId: string,
64
+ ): Promise<StripeConnectionSummary | "unreadable" | null> {
65
+ const row = await store(() =>
66
+ prisma.stripeConnection.findUnique({
67
+ where: { organizationId },
68
+ select: { mode: true, keyLast4: true, liveAt: true, createdAt: true, updatedAt: true },
69
+ }),
70
+ );
71
+ if (row === null) return null;
72
+ const summary = stripeConnectionSummarySchema.safeParse({
73
+ mode: row.mode,
74
+ last4: row.keyLast4,
75
+ connectedAt: isoOf(row.createdAt),
76
+ liveAt: row.liveAt === null ? null : isoOf(row.liveAt),
77
+ updatedAt: isoOf(row.updatedAt),
78
+ });
79
+ if (summary.success) return summary.data;
80
+ logUnreadableRow(organizationId);
81
+ return "unreadable";
82
+ }
83
+
84
+ /** An ISO string, or `null` for a value that is not a valid Date. */
85
+ function isoOf(value: unknown): string | null {
86
+ return value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : null;
87
+ }
88
+
89
+ function logUnreadableRow(organizationId: string): void {
90
+ logger.error(
91
+ { organizationId, reason: "connection_unreadable" },
92
+ "[offerings] stripe connection row is unreadable",
93
+ );
94
+ }
95
+
96
+ function logUnreadableValue(organizationId: string): void {
97
+ logger.error(
98
+ { organizationId, reason: "credential_unreadable" },
99
+ "[offerings] stripe connection value could not be decrypted",
100
+ );
101
+ }
102
+
103
+ // ─── The store boundary ─────────────────────────────────────────────────────
104
+
105
+ /** Every store failure, with a fixed message and no cause (SEC-8907-9). */
106
+ export class StripeConnectionStoreError extends Error {
107
+ readonly uniqueViolation: boolean;
108
+ constructor(uniqueViolation: boolean) {
109
+ super("stripe connection store call failed");
110
+ this.name = "StripeConnectionStoreError";
111
+ this.uniqueViolation = uniqueViolation;
112
+ }
113
+ }
114
+
115
+ async function store<T>(call: () => Promise<T>): Promise<T> {
116
+ try {
117
+ return await call();
118
+ } catch (err) {
119
+ const code = (err as { code?: unknown } | null)?.code;
120
+ throw new StripeConnectionStoreError(code === "P2002");
121
+ }
122
+ }
123
+
124
+ // ─── The cipher (D2) ────────────────────────────────────────────────────────
125
+
126
+ /** The three encrypted values; each binds its own additional data. */
127
+ export type StripeConnectionField = "credential" | "webhook-secret:test" | "webhook-secret:live";
128
+
129
+ const FORMAT_VERSION = "v1";
130
+ const IV_BYTES = 12;
131
+ const TAG_BYTES = 16;
132
+ const KEY_BYTES = 32;
133
+
134
+ function additionalData(organizationId: string, field: StripeConnectionField): Buffer {
135
+ return Buffer.from(`stripe-connection:${FORMAT_VERSION}:${organizationId}:${field}`, "utf8");
136
+ }
137
+
138
+ function secretField(mode: StripeConnectionMode): StripeConnectionField {
139
+ return mode === "live" ? "webhook-secret:live" : "webhook-secret:test";
140
+ }
141
+
142
+ /** Encrypt one value for one Organization's field (D2). */
143
+ export function encryptStripeConnectionValue(
144
+ plaintext: string,
145
+ organizationId: string,
146
+ field: StripeConnectionField,
147
+ key: Buffer,
148
+ ): string {
149
+ const iv = randomBytes(IV_BYTES);
150
+ const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: TAG_BYTES });
151
+ cipher.setAAD(additionalData(organizationId, field));
152
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
153
+ return [
154
+ FORMAT_VERSION,
155
+ iv.toString("base64url"),
156
+ cipher.getAuthTag().toString("base64url"),
157
+ ciphertext.toString("base64url"),
158
+ ].join(".");
159
+ }
160
+
161
+ /** One key's attempt: `null` for any failure, never a throw. */
162
+ function decryptUnder(
163
+ stored: string,
164
+ organizationId: string,
165
+ field: StripeConnectionField,
166
+ key: Buffer | undefined,
167
+ ): string | null {
168
+ if (!key || key.length !== KEY_BYTES) return null;
169
+ const parts = stored.split(".");
170
+ if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) return null;
171
+ const iv = Buffer.from(parts[1] ?? "", "base64url");
172
+ const tag = Buffer.from(parts[2] ?? "", "base64url");
173
+ const ciphertext = Buffer.from(parts[3] ?? "", "base64url");
174
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) return null;
175
+ try {
176
+ const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: TAG_BYTES });
177
+ decipher.setAAD(additionalData(organizationId, field));
178
+ decipher.setAuthTag(tag);
179
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Decrypt one value under the current key, then the previous one (C10), or
187
+ * `null` when it is unreadable for ANY reason — no key, a wrong key, another
188
+ * Organization's value, the other field or mode, a part of the wrong length.
189
+ */
190
+ export function decryptStripeConnectionValue(
191
+ stored: string,
192
+ organizationId: string,
193
+ field: StripeConnectionField,
194
+ ): string | null {
195
+ return (
196
+ decryptUnder(stored, organizationId, field, config.STRIPE_CONNECTION_KEY) ??
197
+ decryptUnder(stored, organizationId, field, config.STRIPE_CONNECTION_KEY_PREVIOUS)
198
+ );
199
+ }
200
+
201
+ /** Whether the writes can run: `STRIPE_CONNECTION_KEY` is set (C3). */
202
+ export function stripeConnectionKeyConfigured(): boolean {
203
+ return config.STRIPE_CONNECTION_KEY !== undefined;
204
+ }
205
+
206
+ /** A routing token: 256 random bits, 43 base64url characters (W1). */
207
+ function mintWebhookToken(): string {
208
+ return randomBytes(32).toString("base64url");
209
+ }
210
+
211
+ const WEBHOOK_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/;
212
+ const LAST4_RE = /^[A-Za-z0-9]{4}$/;
213
+ const MODES = new Set<unknown>(["test", "live"]);
214
+
215
+ function isMode(value: unknown): value is StripeConnectionMode {
216
+ return MODES.has(value);
217
+ }
218
+
219
+ // ─── Admission by class (D4) ────────────────────────────────────────────────
220
+
221
+ export type StripeKeyRefusalReason =
222
+ | "key_not_live"
223
+ | "key_unrestricted"
224
+ | "live_key_needs_go_live"
225
+ | "key_publishable"
226
+ | "key_unrecognized"
227
+ | "key_not_usable";
228
+
229
+ export type StripeKeyColumn = "connect" | "go-live";
230
+
231
+ type Admission = "admit" | Exclude<StripeKeyRefusalReason, "key_not_usable">;
232
+
233
+ /** One row per class, so a seventh class fails the type check (K3, K4, K5). */
234
+ const ADMISSION: Record<StripeKeyColumn, Record<StripeKeyClass, Admission>> = {
235
+ connect: {
236
+ "restricted-test": "admit",
237
+ "secret-test": "key_unrestricted",
238
+ "restricted-live": "live_key_needs_go_live",
239
+ "secret-live": "key_unrestricted",
240
+ publishable: "key_publishable",
241
+ unknown: "key_unrecognized",
242
+ },
243
+ "go-live": {
244
+ "restricted-test": "key_not_live",
245
+ "secret-test": "key_unrestricted",
246
+ "restricted-live": "admit",
247
+ "secret-live": "key_unrestricted",
248
+ publishable: "key_publishable",
249
+ unknown: "key_unrecognized",
250
+ },
251
+ };
252
+
253
+ /**
254
+ * The class table's answer for one submitted key (D4). On admit, the key is
255
+ * `raw.trim()` — the string the classifier decided on — and `keyLast4` its
256
+ * last four characters; a key whose last four are not alphanumeric (a body
257
+ * under four characters, which no Stripe key has) is `key_not_usable` with no
258
+ * probe, so the database's CHECK is never what refuses.
259
+ */
260
+ export function admitConnectionKey(
261
+ raw: string,
262
+ column: StripeKeyColumn,
263
+ ):
264
+ | { ok: true; key: string; keyLast4: string }
265
+ | { ok: false; reason: StripeKeyRefusalReason } {
266
+ const admission = ADMISSION[column][classifyStripeKey(raw)];
267
+ if (admission !== "admit") return { ok: false, reason: admission };
268
+ const key = raw.trim();
269
+ const keyLast4 = key.slice(-4);
270
+ if (!LAST4_RE.test(keyLast4)) return { ok: false, reason: "key_not_usable" };
271
+ return { ok: true, key, keyLast4 };
272
+ }
273
+
274
+ // ─── The probes (D5, K9, P2) ────────────────────────────────────────────────
275
+
276
+ /** The longest the three probes may take together before the answer is `unavailable`. */
277
+ export const PROBE_TIMEOUT_MS = 10_000;
278
+
279
+ export type ProbeOutcome = "usable" | "not_usable" | "unavailable";
280
+
281
+ const NOT_USABLE_TYPES = new Set(["StripeAuthenticationError", "StripePermissionError"]);
282
+
283
+ function refusesTheKey(reason: unknown): boolean {
284
+ const { type, statusCode } = (reason ?? {}) as { type?: unknown; statusCode?: unknown };
285
+ return (
286
+ (typeof type === "string" && NOT_USABLE_TYPES.has(type)) ||
287
+ statusCode === 401 ||
288
+ statusCode === 403
289
+ );
290
+ }
291
+
292
+ function errNameOf(reason: unknown): string {
293
+ if (reason instanceof Error) return reason.name;
294
+ const type = (reason as { type?: unknown } | null)?.type;
295
+ return typeof type === "string" ? type : "unknown";
296
+ }
297
+
298
+ /**
299
+ * The three read probes (D5): `products.list`, `prices.list` and
300
+ * `checkout.sessions.list`, each `{ limit: 1 }`, started together and decided
301
+ * from all three settled results, so a refused key costs the same one round
302
+ * whichever probe refused it (SEC-8907-4). One answer covers an invalid key
303
+ * and a valid key missing a permission. The client lives only inside the
304
+ * call (C8).
305
+ */
306
+ export async function probeStripeKey(input: {
307
+ organizationId: string;
308
+ key: string;
309
+ mode: StripeConnectionMode;
310
+ liveAt: Date | null;
311
+ }): Promise<ProbeOutcome> {
312
+ const { organizationId } = input;
313
+ const client = stripeClientForConnection(input);
314
+ if (client === null) return logProbe(organizationId, "not_usable");
315
+
316
+ let timer: ReturnType<typeof setTimeout> | undefined;
317
+ const timedOut = new Promise<"timeout">((resolve) => {
318
+ timer = setTimeout(() => resolve("timeout"), PROBE_TIMEOUT_MS);
319
+ });
320
+ const settled = Promise.allSettled([
321
+ client.products.list({ limit: 1 }),
322
+ client.prices.list({ limit: 1 }),
323
+ client.checkout.sessions.list({ limit: 1 }),
324
+ ]);
325
+ const results = await Promise.race([settled, timedOut]).finally(() => clearTimeout(timer));
326
+ if (results === "timeout") return logProbe(organizationId, "unavailable", "timeout");
327
+
328
+ const rejected = results.flatMap((r) => (r.status === "rejected" ? [r.reason as unknown] : []));
329
+ if (rejected.length === 0) return logProbe(organizationId, "usable");
330
+ if (rejected.some(refusesTheKey)) return logProbe(organizationId, "not_usable");
331
+ return logProbe(organizationId, "unavailable", undefined, errNameOf(rejected[0]));
332
+ }
333
+
334
+ function logProbe(
335
+ organizationId: string,
336
+ outcome: ProbeOutcome,
337
+ reason?: "timeout",
338
+ errName?: string,
339
+ ): ProbeOutcome {
340
+ const line = {
341
+ organizationId,
342
+ outcome,
343
+ ...(reason && { reason }),
344
+ ...(errName && { errName }),
345
+ };
346
+ if (outcome === "unavailable") logger.warn(line, "[offerings] stripe key probe");
347
+ else logger.info(line, "[offerings] stripe key probe");
348
+ return outcome;
349
+ }
350
+
351
+ // ─── The reads ──────────────────────────────────────────────────────────────
352
+
353
+ /** C4's GET body: no key, no secret, no ciphertext — and no plaintext token outside the path. */
354
+ const viewSchema = z
355
+ .object({
356
+ mode: z.enum(["test", "live"]),
357
+ last4: z.string().regex(LAST4_RE),
358
+ webhookPath: z.string().regex(/^\/v1\/webhooks\/stripe\/[A-Za-z0-9_-]{43}$/),
359
+ webhookSecretSet: z.boolean(),
360
+ connectedAt: z.iso.datetime(),
361
+ liveAt: z.iso.datetime().nullable(),
362
+ updatedAt: z.iso.datetime(),
363
+ })
364
+ .strict()
365
+ .refine((view) => (view.mode === "live") === (view.liveAt !== null), { path: ["liveAt"] });
366
+ export type StripeConnectionView = z.infer<typeof viewSchema>;
367
+
368
+ /** What a write answers (D7): no `last4`, no `webhookPath` (SEC-8907-10). */
369
+ export type StripeConnectionSaved = {
370
+ mode: StripeConnectionMode;
371
+ webhookSecretSet: boolean;
372
+ liveAt: string | null;
373
+ updatedAt: string;
374
+ };
375
+
376
+ /**
377
+ * C4's GET (D7): the view, `"unreadable"` for a row that fails its schema,
378
+ * `null` for none. `webhookSecretSet` is decided from presence, never by
379
+ * decrypting.
380
+ */
381
+ export async function getStripeConnectionView(
382
+ organizationId: string,
383
+ ): Promise<StripeConnectionView | "unreadable" | null> {
384
+ const row = await store(() =>
385
+ prisma.stripeConnection.findUnique({
386
+ where: { organizationId },
387
+ select: {
388
+ mode: true,
389
+ keyLast4: true,
390
+ webhookToken: true,
391
+ webhookSecretCiphertext: true,
392
+ liveAt: true,
393
+ createdAt: true,
394
+ updatedAt: true,
395
+ },
396
+ }),
397
+ );
398
+ if (row === null) return null;
399
+ const view = viewSchema.safeParse({
400
+ mode: row.mode,
401
+ last4: row.keyLast4,
402
+ webhookPath: `/v1/webhooks/stripe/${row.webhookToken}`,
403
+ webhookSecretSet: row.webhookSecretCiphertext !== null,
404
+ connectedAt: isoOf(row.createdAt),
405
+ liveAt: row.liveAt === null ? null : isoOf(row.liveAt),
406
+ updatedAt: isoOf(row.updatedAt),
407
+ });
408
+ if (view.success) return view.data;
409
+ logUnreadableRow(organizationId);
410
+ return "unreadable";
411
+ }
412
+
413
+ /** The row every write starts from (D6 call 3, D8 step 12). */
414
+ export type StripeConnectionWriteRow = {
415
+ mode: StripeConnectionMode;
416
+ keyLast4: string;
417
+ webhookSecretSet: boolean;
418
+ liveAt: Date | null;
419
+ updatedAt: Date;
420
+ };
421
+
422
+ /**
423
+ * The read every write starts with (D6 call 3): the mode, last four and
424
+ * `updatedAt` the compare-and-swap keys on, plus whether a secret is set and
425
+ * the go-live time, so a write can answer `Saved` without a second read. The
426
+ * secret's presence is read, never its value.
427
+ */
428
+ export async function readConnectionForWrite(
429
+ organizationId: string,
430
+ ): Promise<StripeConnectionWriteRow | "unreadable" | null> {
431
+ const row = await store(() =>
432
+ prisma.stripeConnection.findUnique({
433
+ where: { organizationId },
434
+ select: {
435
+ mode: true,
436
+ keyLast4: true,
437
+ webhookSecretCiphertext: true,
438
+ liveAt: true,
439
+ updatedAt: true,
440
+ },
441
+ }),
442
+ );
443
+ if (row === null) return null;
444
+ if (!isMode(row.mode) || !LAST4_RE.test(row.keyLast4)) {
445
+ logUnreadableRow(organizationId);
446
+ return "unreadable";
447
+ }
448
+ return {
449
+ mode: row.mode,
450
+ keyLast4: row.keyLast4,
451
+ webhookSecretSet: row.webhookSecretCiphertext !== null,
452
+ liveAt: row.liveAt,
453
+ updatedAt: row.updatedAt,
454
+ };
455
+ }
456
+
457
+ function savedOf(
458
+ mode: StripeConnectionMode,
459
+ webhookSecretSet: boolean,
460
+ liveAt: Date | null,
461
+ updatedAt: Date,
462
+ ): StripeConnectionSaved {
463
+ return {
464
+ mode,
465
+ webhookSecretSet,
466
+ liveAt: liveAt === null ? null : liveAt.toISOString(),
467
+ updatedAt: updatedAt.toISOString(),
468
+ };
469
+ }
470
+
471
+ // ─── The writes (D6) ────────────────────────────────────────────────────────
472
+
473
+ type StoredFields = {
474
+ mode?: StripeConnectionMode;
475
+ keyLast4?: string;
476
+ credentialCiphertext?: string;
477
+ webhookSecretCiphertext?: string | null;
478
+ liveAt?: Date | null;
479
+ updatedAt: Date;
480
+ };
481
+
482
+ /**
483
+ * The compare-and-swap replace, go-live, secret set and rekey share (D6 call
484
+ * 5): it writes only when the row still carries the `updatedAt` its caller
485
+ * read, so a write in between is never lost or overwritten.
486
+ */
487
+ async function writeIfUnchanged(
488
+ organizationId: string,
489
+ updatedAt: Date,
490
+ data: StoredFields,
491
+ ): Promise<boolean> {
492
+ const { count } = await store(() =>
493
+ prisma.stripeConnection.updateMany({ where: { organizationId, updatedAt }, data }),
494
+ );
495
+ return count > 0;
496
+ }
497
+
498
+ /** A write that did not happen, and why — the route maps each to its answer. */
499
+ export type StripeConnectionWriteRefusal = {
500
+ kind: "connection_changed" | "connection_required" | "key_not_configured" | "live_mode_unavailable";
501
+ };
502
+
503
+ /** What the audit row and the owners' email name: the row as this write left it (C7). */
504
+ export type StripeConnectionAudit = { mode: StripeConnectionMode; last4: string };
505
+
506
+ export type StripeConnectionWrite<Kind extends string> =
507
+ | { kind: Kind; saved: StripeConnectionSaved; audit: StripeConnectionAudit }
508
+ | StripeConnectionWriteRefusal;
509
+
510
+ const CHANGED = { kind: "connection_changed" } as const;
511
+ const REQUIRED = { kind: "connection_required" } as const;
512
+ const NO_KEY = { kind: "key_not_configured" } as const;
513
+ const LIVE_UNAVAILABLE = { kind: "live_mode_unavailable" } as const;
514
+
515
+ /**
516
+ * Re-read for a retry, re-applying every precondition the route's first read
517
+ * enforced: a row that became unreadable is a change, and a row that went
518
+ * live in between is never written from a build that is not production (D3,
519
+ * SEC-8907-3) — a preview sharing production's database must not reach it
520
+ * through the retry either.
521
+ */
522
+ async function reread(
523
+ organizationId: string,
524
+ ): Promise<StripeConnectionWriteRow | null | "changed" | "live"> {
525
+ const row = await readConnectionForWrite(organizationId);
526
+ if (row === "unreadable") return "changed";
527
+ if (row?.mode === "live" && !isProductionBuild()) return "live";
528
+ return row;
529
+ }
530
+
531
+ /**
532
+ * A retry acts only on a row in the mode the request was made against: a
533
+ * replace never silently undoes a go-live that committed while its probe
534
+ * ran, and a secret entered for one mode is never bound to the other
535
+ * (C9, W2, SEC-8879-3).
536
+ */
537
+ function modeMoved(
538
+ row: StripeConnectionWriteRow,
539
+ observed: StripeConnectionWriteRow | null,
540
+ ): boolean {
541
+ return row.mode !== (observed?.mode ?? "test");
542
+ }
543
+
544
+ /**
545
+ * Connect or replace (C9). `observed` is the route's D8 step 12 read: only a
546
+ * request that found no row there may create, so a replace whose row a
547
+ * disconnect removed in between answers `connection_changed` and never brings
548
+ * the connection back (SEC-8907-6). A replace keeps the token, sets the mode
549
+ * to test and clears the secret when the row was live — the secret is bound
550
+ * to the mode. One retry from a fresh read, then `connection_changed`. Two
551
+ * first connects that race are last-writer-wins: the loser's create collides,
552
+ * its retry finds the winner's row and replaces it, and both writes are
553
+ * audited and emailed (D6, "retried once from the read").
554
+ */
555
+ export async function putStripeConnectionKey(
556
+ organizationId: string,
557
+ key: string,
558
+ keyLast4: string,
559
+ observed: StripeConnectionWriteRow | null,
560
+ ): Promise<StripeConnectionWrite<"connect" | "replace">> {
561
+ const cipherKey = config.STRIPE_CONNECTION_KEY;
562
+ if (!cipherKey) return NO_KEY;
563
+ const credentialCiphertext = encryptStripeConnectionValue(key, organizationId, "credential", cipherKey);
564
+ const mayCreate = observed === null;
565
+
566
+ let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
567
+ for (let attempt = 0; attempt < 2; attempt++) {
568
+ if (attempt > 0) row = await reread(organizationId);
569
+ if (row === "changed") return CHANGED;
570
+ if (row === "live") return LIVE_UNAVAILABLE;
571
+ const now = new Date();
572
+ if (row === null) {
573
+ if (!mayCreate) return CHANGED;
574
+ try {
575
+ await store(() =>
576
+ prisma.stripeConnection.create({
577
+ data: {
578
+ organizationId,
579
+ mode: "test",
580
+ keyLast4,
581
+ credentialCiphertext,
582
+ webhookToken: mintWebhookToken(),
583
+ webhookSecretCiphertext: null,
584
+ liveAt: null,
585
+ updatedAt: now,
586
+ },
587
+ select: { id: true },
588
+ }),
589
+ );
590
+ return {
591
+ kind: "connect",
592
+ saved: savedOf("test", false, null, now),
593
+ audit: { mode: "test", last4: keyLast4 },
594
+ };
595
+ } catch (err) {
596
+ if (err instanceof StripeConnectionStoreError && err.uniqueViolation) continue;
597
+ throw err;
598
+ }
599
+ }
600
+ if (attempt > 0 && modeMoved(row, observed)) return CHANGED;
601
+ const clearsSecret = row.mode === "live";
602
+ const written = await writeIfUnchanged(organizationId, row.updatedAt, {
603
+ mode: "test",
604
+ keyLast4,
605
+ credentialCiphertext,
606
+ liveAt: null,
607
+ ...(clearsSecret && { webhookSecretCiphertext: null }),
608
+ updatedAt: now,
609
+ });
610
+ if (written) {
611
+ return {
612
+ kind: "replace",
613
+ saved: savedOf("test", clearsSecret ? false : row.webhookSecretSet, null, now),
614
+ audit: { mode: "test", last4: keyLast4 },
615
+ };
616
+ }
617
+ }
618
+ return CHANGED;
619
+ }
620
+
621
+ /**
622
+ * Go live (K5): the restricted live key, the mode, the go-live time and the
623
+ * secret bound to `webhook-secret:live`, in one compare-and-swap. A row
624
+ * already live takes the same write, which is how a live key is rotated
625
+ * without returning to test.
626
+ */
627
+ export async function goLiveStripeConnection(
628
+ organizationId: string,
629
+ key: string,
630
+ keyLast4: string,
631
+ webhookSecret: string,
632
+ now: Date,
633
+ observed: StripeConnectionWriteRow | null,
634
+ ): Promise<StripeConnectionWrite<"go_live">> {
635
+ const cipherKey = config.STRIPE_CONNECTION_KEY;
636
+ if (!cipherKey) return NO_KEY;
637
+ if (observed === null) return REQUIRED;
638
+ const data: StoredFields = {
639
+ mode: "live",
640
+ keyLast4,
641
+ credentialCiphertext: encryptStripeConnectionValue(key, organizationId, "credential", cipherKey),
642
+ webhookSecretCiphertext: encryptStripeConnectionValue(
643
+ webhookSecret,
644
+ organizationId,
645
+ "webhook-secret:live",
646
+ cipherKey,
647
+ ),
648
+ liveAt: now,
649
+ updatedAt: now,
650
+ };
651
+
652
+ let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
653
+ for (let attempt = 0; attempt < 2; attempt++) {
654
+ if (attempt > 0) row = await reread(organizationId);
655
+ if (row === null || row === "changed") return CHANGED;
656
+ if (row === "live") return LIVE_UNAVAILABLE;
657
+ // Each attempt writes its own `updatedAt`, so the swap's version key only
658
+ // moves forward even when the probe ran long; `liveAt` is the caller's.
659
+ const updatedAt = new Date();
660
+ if (await writeIfUnchanged(organizationId, row.updatedAt, { ...data, updatedAt })) {
661
+ return {
662
+ kind: "go_live",
663
+ saved: savedOf("live", true, now, updatedAt),
664
+ audit: { mode: "live", last4: keyLast4 },
665
+ };
666
+ }
667
+ }
668
+ return CHANGED;
669
+ }
670
+
671
+ /**
672
+ * Set the webhook secret, bound to the row's own mode. The compare-and-swap
673
+ * and the retry's mode check mean a mode change in between answers
674
+ * `connection_changed` and never binds the secret to the other mode.
675
+ */
676
+ export async function setStripeWebhookSecret(
677
+ organizationId: string,
678
+ webhookSecret: string,
679
+ observed: StripeConnectionWriteRow | null,
680
+ ): Promise<StripeConnectionWrite<"webhook_secret">> {
681
+ const cipherKey = config.STRIPE_CONNECTION_KEY;
682
+ if (!cipherKey) return NO_KEY;
683
+ if (observed === null) return REQUIRED;
684
+
685
+ let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
686
+ for (let attempt = 0; attempt < 2; attempt++) {
687
+ if (attempt > 0) row = await reread(organizationId);
688
+ if (row === null || row === "changed") return CHANGED;
689
+ if (row === "live") return LIVE_UNAVAILABLE;
690
+ if (modeMoved(row, observed)) return CHANGED;
691
+ const now = new Date();
692
+ const written = await writeIfUnchanged(organizationId, row.updatedAt, {
693
+ webhookSecretCiphertext: encryptStripeConnectionValue(
694
+ webhookSecret,
695
+ organizationId,
696
+ secretField(row.mode),
697
+ cipherKey,
698
+ ),
699
+ updatedAt: now,
700
+ });
701
+ if (written) {
702
+ return {
703
+ kind: "webhook_secret",
704
+ saved: savedOf(row.mode, true, row.liveAt, now),
705
+ audit: { mode: row.mode, last4: row.keyLast4 },
706
+ };
707
+ }
708
+ }
709
+ return CHANGED;
710
+ }
711
+
712
+ /**
713
+ * Reset the webhook (D6 call 6): a new token and no secret, so the previous
714
+ * URL is `not-found` from this moment. Needs no deployment key — it only
715
+ * takes exposure away. Keyed on the `updatedAt` the caller read, so the mode
716
+ * it answers and audits is the mode it reset, and a row that went live in
717
+ * between is never reset from a build that is not production (D3); a miss is
718
+ * re-read and retried once. A token collision is retried with a new token.
719
+ */
720
+ export async function resetStripeWebhook(
721
+ organizationId: string,
722
+ observed: StripeConnectionWriteRow,
723
+ ): Promise<StripeConnectionWrite<"webhook_reset">> {
724
+ let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
725
+ for (let attempt = 0; attempt < 2; attempt++) {
726
+ if (attempt > 0) row = await reread(organizationId);
727
+ if (row === null) return REQUIRED;
728
+ if (row === "changed") return CHANGED;
729
+ if (row === "live") return LIVE_UNAVAILABLE;
730
+ const current = row;
731
+ const now = new Date();
732
+ try {
733
+ const { count } = await store(() =>
734
+ prisma.stripeConnection.updateMany({
735
+ where: { organizationId, updatedAt: current.updatedAt },
736
+ data: { webhookToken: mintWebhookToken(), webhookSecretCiphertext: null, updatedAt: now },
737
+ }),
738
+ );
739
+ if (count > 0) {
740
+ return {
741
+ kind: "webhook_reset",
742
+ saved: savedOf(current.mode, false, current.liveAt, now),
743
+ audit: { mode: current.mode, last4: current.keyLast4 },
744
+ };
745
+ }
746
+ } catch (err) {
747
+ if (!(err instanceof StripeConnectionStoreError && err.uniqueViolation)) throw err;
748
+ }
749
+ }
750
+ return CHANGED;
751
+ }
752
+
753
+ /**
754
+ * Disconnect (D6 call 7, C8): the row and its token are gone at once. The
755
+ * Organization's enabled set and its workspaces' shown sets stay. Keyed on
756
+ * the `updatedAt` the caller read, so the `remove` audit row and email name
757
+ * the mode and last four of the row actually deleted, and a row that went
758
+ * live in between is never deleted from a build that is not production (D3);
759
+ * a miss is re-read and retried once.
760
+ */
761
+ export async function deleteStripeConnection(
762
+ organizationId: string,
763
+ observed: StripeConnectionWriteRow,
764
+ ): Promise<
765
+ | { kind: "removed"; audit: StripeConnectionAudit }
766
+ | { kind: "absent" }
767
+ | { kind: "connection_changed" | "live_mode_unavailable" }
768
+ > {
769
+ let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
770
+ for (let attempt = 0; attempt < 2; attempt++) {
771
+ if (attempt > 0) row = await reread(organizationId);
772
+ if (row === null) return { kind: "absent" };
773
+ if (row === "changed") return CHANGED;
774
+ if (row === "live" || (row.mode === "live" && !isProductionBuild())) return LIVE_UNAVAILABLE;
775
+ const current = row;
776
+ const { count } = await store(() =>
777
+ prisma.stripeConnection.deleteMany({ where: { organizationId, updatedAt: current.updatedAt } }),
778
+ );
779
+ if (count > 0) return { kind: "removed", audit: { mode: current.mode, last4: current.keyLast4 } };
780
+ }
781
+ return CHANGED;
782
+ }
783
+
784
+ // ─── The token lookup (D11, W1, W2) ─────────────────────────────────────────
785
+
786
+ export type StripeWebhookConnection =
787
+ | { kind: "not-found" }
788
+ | { kind: "not-configured"; organizationId: string; mode: StripeConnectionMode }
789
+ | { kind: "unreadable"; organizationId: string }
790
+ | { kind: "ok"; organizationId: string; mode: StripeConnectionMode; webhookSecret: string };
791
+
792
+ /**
793
+ * The lookup #8861's receiver calls with the token from its path. A token
794
+ * that is not a minted shape is `not-found` with no read; a deleted row's or
795
+ * a reset row's old token is `not-found` from the moment the write commits.
796
+ * A live row on a non-production build is `unreadable` without decrypting
797
+ * (D3, SEC-8907-3).
798
+ */
799
+ export async function findStripeConnectionByWebhookToken(
800
+ token: string,
801
+ ): Promise<StripeWebhookConnection> {
802
+ if (!WEBHOOK_TOKEN_RE.test(token)) return { kind: "not-found" };
803
+ const row = await store(() =>
804
+ prisma.stripeConnection.findUnique({
805
+ where: { webhookToken: token },
806
+ select: { organizationId: true, mode: true, webhookSecretCiphertext: true },
807
+ }),
808
+ );
809
+ if (row === null) return { kind: "not-found" };
810
+ const { organizationId } = row;
811
+ if (!isMode(row.mode)) {
812
+ logUnreadableRow(organizationId);
813
+ return { kind: "unreadable", organizationId };
814
+ }
815
+ if (row.webhookSecretCiphertext === null) {
816
+ return { kind: "not-configured", organizationId, mode: row.mode };
817
+ }
818
+ if (row.mode === "live" && !isProductionBuild()) return { kind: "unreadable", organizationId };
819
+ const webhookSecret = decryptStripeConnectionValue(
820
+ row.webhookSecretCiphertext,
821
+ organizationId,
822
+ secretField(row.mode),
823
+ );
824
+ if (webhookSecret === null) {
825
+ logUnreadableValue(organizationId);
826
+ return { kind: "unreadable", organizationId };
827
+ }
828
+ return { kind: "ok", organizationId, mode: row.mode, webhookSecret };
829
+ }
830
+
831
+ // ─── The use-time client (D12, K6, K7) ──────────────────────────────────────
832
+
833
+ type ConnectionClient = NonNullable<ReturnType<typeof stripeClientForConnection>>;
834
+
835
+ export type StripeConnectionClient =
836
+ | { kind: "none" }
837
+ | { kind: "unusable" }
838
+ | { kind: "ok"; client: ConnectionClient; mode: StripeConnectionMode };
839
+
840
+ /**
841
+ * A client built from the Organization's own key, in its own mode — what
842
+ * #8054's catalog source and #8861's checkout call. A live row on a
843
+ * non-production build is `unusable` before any decrypt (D3); so is a key
844
+ * that does not decrypt and one the factory refuses for the row's mode.
845
+ * Nothing is cached beyond the call (C8), and there is never a fallback to
846
+ * the deployment's env key.
847
+ */
848
+ export async function stripeClientForOrganization(
849
+ organizationId: string,
850
+ ): Promise<StripeConnectionClient> {
851
+ const row = await store(() =>
852
+ prisma.stripeConnection.findUnique({
853
+ where: { organizationId },
854
+ select: { credentialCiphertext: true, mode: true, liveAt: true },
855
+ }),
856
+ );
857
+ if (row === null) return { kind: "none" };
858
+ if (!isMode(row.mode)) {
859
+ logUnreadableRow(organizationId);
860
+ return { kind: "unusable" };
861
+ }
862
+ if (row.mode === "live" && !isProductionBuild()) return { kind: "unusable" };
863
+ const key = decryptStripeConnectionValue(row.credentialCiphertext, organizationId, "credential");
864
+ if (key === null) {
865
+ logUnreadableValue(organizationId);
866
+ return { kind: "unusable" };
867
+ }
868
+ const client = stripeClientForConnection({ organizationId, key, mode: row.mode, liveAt: row.liveAt });
869
+ if (client === null) return { kind: "unusable" };
870
+ return { kind: "ok", client, mode: row.mode };
871
+ }
872
+
873
+ // ─── The rekey walk (D13, C10) ──────────────────────────────────────────────
874
+
875
+ export type StripeConnectionsRekeyResult =
876
+ | {
877
+ ok: true;
878
+ scanned: number;
879
+ current: number;
880
+ rekeyed: number;
881
+ changed: number;
882
+ unreadable: number;
883
+ remaining: number;
884
+ }
885
+ | { ok: false; reason: "key_not_configured" | "not_production" };
886
+
887
+ type FieldState = { ok: "current" } | { ok: "previous"; plaintext: string } | { ok: false };
888
+
889
+ function fieldState(stored: string, organizationId: string, field: StripeConnectionField): FieldState {
890
+ if (decryptUnder(stored, organizationId, field, config.STRIPE_CONNECTION_KEY) !== null) {
891
+ return { ok: "current" };
892
+ }
893
+ const plaintext = decryptUnder(stored, organizationId, field, config.STRIPE_CONNECTION_KEY_PREVIOUS);
894
+ return plaintext === null ? { ok: false } : { ok: "previous", plaintext };
895
+ }
896
+
897
+ /**
898
+ * Re-encrypt every row still under the previous key with the current one.
899
+ * A row whose every value is current is untouched; a rekeyed row keeps the
900
+ * `updatedAt` it had, so an owner's "updated" date never moves for a
901
+ * rotation; a row an owner wrote in between is left alone (`changed`); a
902
+ * value under neither key is `unreadable` and untouched. Idempotent: a second
903
+ * run rekeys nothing. `remaining` counts only what another run could rekey.
904
+ *
905
+ * It runs on a production build only (`not_production` otherwise, nothing
906
+ * read): a walk from a preview or a development machine pointed at
907
+ * production's database with another key would re-encrypt every connection
908
+ * under a key production lacks (D3), and one that skipped live rows would
909
+ * report `remaining=0` while they were still under the previous key.
910
+ */
911
+ export async function rekeyStripeConnections(
912
+ { pageSize = 100 }: { pageSize?: number } = {},
913
+ ): Promise<StripeConnectionsRekeyResult> {
914
+ const take = Math.max(1, Math.floor(pageSize));
915
+ const cipherKey = config.STRIPE_CONNECTION_KEY;
916
+ if (!cipherKey) return { ok: false, reason: "key_not_configured" };
917
+ if (!isProductionBuild()) return { ok: false, reason: "not_production" };
918
+ const counts = { scanned: 0, current: 0, rekeyed: 0, changed: 0, unreadable: 0 };
919
+
920
+ let lastId: string | undefined;
921
+ for (;;) {
922
+ const after = lastId;
923
+ const page = await store(() =>
924
+ prisma.stripeConnection.findMany({
925
+ where: after === undefined ? {} : { id: { gt: after } },
926
+ orderBy: { id: "asc" },
927
+ take,
928
+ select: {
929
+ id: true,
930
+ organizationId: true,
931
+ mode: true,
932
+ credentialCiphertext: true,
933
+ webhookSecretCiphertext: true,
934
+ updatedAt: true,
935
+ },
936
+ }),
937
+ );
938
+ for (const row of page) {
939
+ counts.scanned += 1;
940
+ const outcome = await rekeyRow(row, cipherKey);
941
+ counts[outcome] += 1;
942
+ }
943
+ if (page.length < take) break;
944
+ lastId = page[page.length - 1]!.id;
945
+ }
946
+ return { ok: true, ...counts, remaining: counts.changed };
947
+ }
948
+
949
+ async function rekeyRow(
950
+ row: {
951
+ organizationId: string;
952
+ mode: string;
953
+ credentialCiphertext: string;
954
+ webhookSecretCiphertext: string | null;
955
+ updatedAt: Date;
956
+ },
957
+ cipherKey: Buffer,
958
+ ): Promise<"current" | "rekeyed" | "changed" | "unreadable"> {
959
+ const { organizationId } = row;
960
+ if (!isMode(row.mode)) {
961
+ logUnreadableRow(organizationId);
962
+ return "unreadable";
963
+ }
964
+ const secret = secretField(row.mode);
965
+ const credential = fieldState(row.credentialCiphertext, organizationId, "credential");
966
+ const webhook =
967
+ row.webhookSecretCiphertext === null
968
+ ? ({ ok: "current" } as const)
969
+ : fieldState(row.webhookSecretCiphertext, organizationId, secret);
970
+ if (credential.ok === false || webhook.ok === false) {
971
+ logUnreadableValue(organizationId);
972
+ return "unreadable";
973
+ }
974
+ if (credential.ok === "current" && webhook.ok === "current") return "current";
975
+
976
+ const written = await writeIfUnchanged(organizationId, row.updatedAt, {
977
+ ...(credential.ok === "previous" && {
978
+ credentialCiphertext: encryptStripeConnectionValue(
979
+ credential.plaintext,
980
+ organizationId,
981
+ "credential",
982
+ cipherKey,
983
+ ),
984
+ }),
985
+ ...(webhook.ok === "previous" && {
986
+ webhookSecretCiphertext: encryptStripeConnectionValue(
987
+ webhook.plaintext,
988
+ organizationId,
989
+ secret,
990
+ cipherKey,
991
+ ),
992
+ }),
993
+ updatedAt: row.updatedAt,
994
+ });
995
+ return written ? "rekeyed" : "changed";
996
+ }
997
+
998
+ /** The bin's one output line: counts only, never a key or a row id. */
999
+ export function formatRekeyReport(result: StripeConnectionsRekeyResult): string {
1000
+ if (!result.ok) return `[stripe-connections-rekey] ${result.reason}`;
1001
+ const { scanned, current, rekeyed, changed, unreadable, remaining } = result;
1002
+ return `[stripe-connections-rekey] scanned=${scanned} current=${current} rekeyed=${rekeyed} changed=${changed} unreadable=${unreadable} remaining=${remaining}`;
1003
+ }