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,45 @@
1
+ #!/usr/bin/env node
2
+ /* istanbul ignore file -- CLI bootstrap; rekeyStripeConnections and
3
+ formatRekeyReport are unit-tested in
4
+ services/offerings/__tests__/connection.test.ts. */
5
+ /**
6
+ * Re-encrypt every Organization Stripe connection under the current
7
+ * STRIPE_CONNECTION_KEY (#8907 D13, contract C10).
8
+ *
9
+ * Dev: `pnpm --filter @working-theory/api stripe-connections:rekey` (tsx,
10
+ * this file). Production: `node dist/bin/stripe-connections-rekey.js`.
11
+ *
12
+ * The rotation: set STRIPE_CONNECTION_KEY_PREVIOUS to the old key and the new
13
+ * key as STRIPE_CONNECTION_KEY, redeploy, run this, and unset _PREVIOUS once
14
+ * it prints `remaining=0`. A row counted `unreadable` decrypts under neither
15
+ * key; its owner reconnects or disconnects. A second run rekeys nothing.
16
+ *
17
+ * Run it with the production environment — `VERCEL_ENV=production`, or
18
+ * `NODE_ENV=production` off Vercel — the same predicate the API goes live on;
19
+ * anywhere else it prints `not_production`, reads nothing and exits 1.
20
+ *
21
+ * Prints one line of counts only — never a key, a value or a row id — and
22
+ * exits 1 when the key is not configured, off production, or the walk fails.
23
+ */
24
+ import {
25
+ formatRekeyReport,
26
+ rekeyStripeConnections,
27
+ } from "../services/offerings/connection.js";
28
+
29
+ async function main(): Promise<void> {
30
+ const result = await rekeyStripeConnections();
31
+ if (!result.ok) {
32
+ console.error(formatRekeyReport(result));
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+ console.log(formatRekeyReport(result));
37
+ }
38
+
39
+ main().catch((err: unknown) => {
40
+ // The class name only: a store error's message is fixed, but nothing else
41
+ // a failure carries is printed.
42
+ const name = err instanceof Error ? err.name : "unknown";
43
+ console.error(`[stripe-connections-rekey] failed: ${name}`);
44
+ process.exitCode = 1;
45
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @file stripe-connection-key.test.ts
3
+ * @description STRIPE_CONNECTION_KEY and STRIPE_CONNECTION_KEY_PREVIOUS — the
4
+ * keys an Organization's Stripe connection is encrypted under (#8907 D3,
5
+ * AC-9; contract C3, C10).
6
+ *
7
+ * `resolveStripeConnectionKeys` reads both with the scheduling key's shape
8
+ * rule (canonical standard base64 of exactly 32 bytes), refuses a previous
9
+ * key with no current one or equal to it, and refuses either when it is the
10
+ * scheduling key — so one key never guards calendars and money. A refused
11
+ * key stops the API at startup; a refusal names the variable and the reason
12
+ * kind, never any part of the value. Unset is a supported state.
13
+ */
14
+
15
+ // Mock dotenv so a developer's local .env never leaks into the config module.
16
+ jest.mock("dotenv", () => ({
17
+ config: jest.fn(),
18
+ }));
19
+
20
+ const mockLoggerError = jest.fn();
21
+ jest.mock("@working-theory/observability", () => ({
22
+ logger: {
23
+ error: (...args: unknown[]) => mockLoggerError(...args),
24
+ warn: jest.fn(),
25
+ info: jest.fn(),
26
+ debug: jest.fn(),
27
+ },
28
+ }));
29
+
30
+ import { randomBytes } from "node:crypto";
31
+ import { readFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+
34
+ import { resolveStripeConnectionKeys } from "../index";
35
+
36
+ const A = randomBytes(32);
37
+ const B = randomBytes(32);
38
+ const S = randomBytes(32);
39
+ const b64 = (bytes: Buffer) => bytes.toString("base64");
40
+
41
+ /** 32 bytes whose base64 differs from its base64url, so the url form is refused. */
42
+ function urlOnlyKey(): string {
43
+ let bytes = randomBytes(32);
44
+ while (!/[+/]/.test(bytes.toString("base64"))) bytes = randomBytes(32);
45
+ return bytes.toString("base64url");
46
+ }
47
+
48
+ describe("resolveStripeConnectionKeys (#8907 D3)", () => {
49
+ it("answers both buffers for two distinct canonical 32-byte keys", () => {
50
+ const result = resolveStripeConnectionKeys({ current: b64(A), previous: b64(B), scheduling: S });
51
+ expect(result.kind).toBe("ok");
52
+ if (result.kind !== "ok") throw new Error("expected ok");
53
+ expect(result.current?.equals(A)).toBe(true);
54
+ expect(result.previous?.equals(B)).toBe(true);
55
+ });
56
+
57
+ it("answers ok with nothing when both are unset or empty", () => {
58
+ expect(resolveStripeConnectionKeys({ current: undefined, previous: "", scheduling: S })).toEqual({
59
+ kind: "ok",
60
+ });
61
+ });
62
+
63
+ it("answers the current key alone with no previous", () => {
64
+ const result = resolveStripeConnectionKeys({ current: b64(A), previous: undefined, scheduling: undefined });
65
+ expect(result).toEqual({ kind: "ok", current: A });
66
+ });
67
+
68
+ const urlKey = urlOnlyKey();
69
+ const shortKey = b64(randomBytes(16));
70
+ it.each([
71
+ ["a base64url current key", { current: urlKey }, "STRIPE_CONNECTION_KEY", "not-canonical-base64", urlKey],
72
+ ["a 16-byte current key", { current: shortKey }, "STRIPE_CONNECTION_KEY", "length", shortKey],
73
+ [
74
+ "a 16-byte previous key",
75
+ { current: b64(A), previous: shortKey },
76
+ "STRIPE_CONNECTION_KEY_PREVIOUS",
77
+ "length",
78
+ shortKey,
79
+ ],
80
+ [
81
+ "a previous key with no current",
82
+ { previous: b64(B) },
83
+ "STRIPE_CONNECTION_KEY_PREVIOUS",
84
+ "previous_without_current",
85
+ b64(B),
86
+ ],
87
+ [
88
+ "equal current and previous",
89
+ { current: b64(A), previous: b64(A) },
90
+ "STRIPE_CONNECTION_KEY_PREVIOUS",
91
+ "previous_equals_current",
92
+ b64(A),
93
+ ],
94
+ [
95
+ "a current key equal to the scheduling key",
96
+ { current: b64(S) },
97
+ "STRIPE_CONNECTION_KEY",
98
+ "shared_with_scheduling",
99
+ b64(S),
100
+ ],
101
+ [
102
+ "a previous key equal to the scheduling key",
103
+ { current: b64(A), previous: b64(S) },
104
+ "STRIPE_CONNECTION_KEY_PREVIOUS",
105
+ "shared_with_scheduling",
106
+ b64(S),
107
+ ],
108
+ ])("refuses %s, naming the variable and the reason only", (_label, raw, variable, reason, value) => {
109
+ const result = resolveStripeConnectionKeys({
110
+ current: (raw as { current?: string }).current,
111
+ previous: (raw as { previous?: string }).previous,
112
+ scheduling: S,
113
+ });
114
+ expect(result).toEqual({ kind: "refused", variable, reason });
115
+ expect(JSON.stringify(result)).not.toContain(value.slice(0, 8));
116
+ });
117
+ });
118
+
119
+ describe("config.STRIPE_CONNECTION_KEY at startup (#8907 D3)", () => {
120
+ const originalEnv = process.env;
121
+
122
+ beforeEach(() => {
123
+ jest.resetModules();
124
+ process.env = { ...originalEnv };
125
+ delete process.env.AILK_ENABLED_MODULES;
126
+ delete process.env.AILK_TRUSTED_ORIGINS;
127
+ delete process.env.SCHEDULING_CREDENTIAL_KEY;
128
+ delete process.env.STRIPE_CONNECTION_KEY;
129
+ delete process.env.STRIPE_CONNECTION_KEY_PREVIOUS;
130
+ jest.clearAllMocks();
131
+ });
132
+
133
+ afterEach(() => {
134
+ process.env = originalEnv;
135
+ jest.restoreAllMocks();
136
+ });
137
+
138
+ it("exposes the decoded keys when they are valid, and nothing when unset", () => {
139
+ process.env.STRIPE_CONNECTION_KEY = b64(A);
140
+ process.env.STRIPE_CONNECTION_KEY_PREVIOUS = b64(B);
141
+ const { config } = require("../index");
142
+ expect(config.STRIPE_CONNECTION_KEY.equals(A)).toBe(true);
143
+ expect(config.STRIPE_CONNECTION_KEY_PREVIOUS.equals(B)).toBe(true);
144
+
145
+ jest.resetModules();
146
+ delete process.env.STRIPE_CONNECTION_KEY;
147
+ delete process.env.STRIPE_CONNECTION_KEY_PREVIOUS;
148
+ const { config: unset } = require("../index");
149
+ expect(unset.STRIPE_CONNECTION_KEY).toBeUndefined();
150
+ expect(unset.STRIPE_CONNECTION_KEY_PREVIOUS).toBeUndefined();
151
+ });
152
+
153
+ it.each([
154
+ ["a 31-byte key", { STRIPE_CONNECTION_KEY: b64(randomBytes(31)) }],
155
+ ["the scheduling key", { STRIPE_CONNECTION_KEY: b64(S), SCHEDULING_CREDENTIAL_KEY: b64(S) }],
156
+ ["a previous key alone", { STRIPE_CONNECTION_KEY_PREVIOUS: b64(B) }],
157
+ ])("exits on a refused value (%s), logging the variable but never the value", (_label, env) => {
158
+ Object.assign(process.env, env);
159
+ const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {
160
+ throw new Error("Process exit called");
161
+ });
162
+ const consoleError = jest.spyOn(console, "error").mockImplementation(() => undefined);
163
+
164
+ expect(() => require("../index")).toThrow("Process exit called");
165
+ expect(exitSpy).toHaveBeenCalledWith(1);
166
+ expect(mockLoggerError).toHaveBeenCalledTimes(1);
167
+
168
+ const logged = JSON.stringify([mockLoggerError.mock.calls, consoleError.mock.calls]);
169
+ expect(logged).toContain("STRIPE_CONNECTION_KEY");
170
+ for (const value of Object.values(env)) {
171
+ expect(logged).not.toContain(value);
172
+ expect(logged).not.toContain(value.slice(0, 12));
173
+ }
174
+ });
175
+ });
176
+
177
+ describe("the .env.example files (#8907 D3)", () => {
178
+ const REPO_ROOT = join(__dirname, "..", "..", "..", "..", "..");
179
+
180
+ it.each([".env.example", "apps/api/.env.example"])("%s documents both variables, commented out", (file) => {
181
+ const text = readFileSync(join(REPO_ROOT, file), "utf8");
182
+ expect(text).toMatch(/^# STRIPE_CONNECTION_KEY=$/m);
183
+ expect(text).toMatch(/^# STRIPE_CONNECTION_KEY_PREVIOUS=$/m);
184
+ });
185
+ });
@@ -112,6 +112,15 @@ export interface Config {
112
112
  // env-configured CAL_API_KEY path works exactly as before. Held decoded,
113
113
  // never as the raw string, and never logged.
114
114
  SCHEDULING_CREDENTIAL_KEY?: Buffer;
115
+ // An Organization's Stripe connection (#8907 D3, contract C3, C10) — the
116
+ // AES-256-GCM key services/offerings/connection.ts encrypts each
117
+ // connection's restricted key and webhook secret under, and the key it
118
+ // replaced during a rotation. The SCHEDULING_CREDENTIAL_KEY shape rule,
119
+ // checked at startup by resolveStripeConnectionKeys() below; a refused
120
+ // value stops the API. Optional: unset (or empty) means a connection can be
121
+ // read and removed but not written. Held decoded, never logged.
122
+ STRIPE_CONNECTION_KEY?: Buffer;
123
+ STRIPE_CONNECTION_KEY_PREVIOUS?: Buffer;
115
124
  // What a hub does with a request to a spoke-aware public surface (meeting
116
125
  // types, the three lead writes, booking, availability, event meta, the
117
126
  // public content reads) that carries no `x-ailk-spoke-key` (#7532 D7,
@@ -258,6 +267,82 @@ export function resolveSchedulingCredentialKey(
258
267
  return { kind: 'ok', key };
259
268
  }
260
269
 
270
+ /** The two variables an Organization's Stripe connection key is read from (#8907 D3). */
271
+ export type StripeConnectionKeyVariable =
272
+ | 'STRIPE_CONNECTION_KEY'
273
+ | 'STRIPE_CONNECTION_KEY_PREVIOUS';
274
+
275
+ /** Why a Stripe connection key was refused (#8907 D3). */
276
+ export type StripeConnectionKeyRefusal =
277
+ | SchedulingCredentialKeyRefusal
278
+ | 'previous_without_current'
279
+ | 'previous_equals_current'
280
+ | 'shared_with_scheduling';
281
+
282
+ /**
283
+ * The Stripe connection keys (#8907 D3, contract C3, C10). Pure;
284
+ * `validateConfig()` calls it once.
285
+ *
286
+ * Each raw value is read with `resolveSchedulingCredentialKey`, the one shape
287
+ * rule. A previous key needs a current one, may not equal it, and neither may
288
+ * equal the scheduling key (`Buffer.equals`), so one key never guards both
289
+ * calendars and money. A refusal names the variable and the reason kind only,
290
+ * never any part of a value.
291
+ */
292
+ export function resolveStripeConnectionKeys(input: {
293
+ current: string | undefined;
294
+ previous: string | undefined;
295
+ scheduling: Buffer | undefined;
296
+ }):
297
+ | { kind: 'ok'; current?: Buffer; previous?: Buffer }
298
+ | {
299
+ kind: 'refused';
300
+ variable: StripeConnectionKeyVariable;
301
+ reason: StripeConnectionKeyRefusal;
302
+ } {
303
+ const current = resolveSchedulingCredentialKey(input.current);
304
+ if (current.kind === 'refused') {
305
+ return { kind: 'refused', variable: 'STRIPE_CONNECTION_KEY', reason: current.reason };
306
+ }
307
+ const previous = resolveSchedulingCredentialKey(input.previous);
308
+ if (previous.kind === 'refused') {
309
+ return { kind: 'refused', variable: 'STRIPE_CONNECTION_KEY_PREVIOUS', reason: previous.reason };
310
+ }
311
+ const currentKey = current.kind === 'ok' ? current.key : undefined;
312
+ const previousKey = previous.kind === 'ok' ? previous.key : undefined;
313
+ if (previousKey && !currentKey) {
314
+ return {
315
+ kind: 'refused',
316
+ variable: 'STRIPE_CONNECTION_KEY_PREVIOUS',
317
+ reason: 'previous_without_current',
318
+ };
319
+ }
320
+ if (previousKey && currentKey?.equals(previousKey)) {
321
+ return {
322
+ kind: 'refused',
323
+ variable: 'STRIPE_CONNECTION_KEY_PREVIOUS',
324
+ reason: 'previous_equals_current',
325
+ };
326
+ }
327
+ if (input.scheduling) {
328
+ if (currentKey?.equals(input.scheduling)) {
329
+ return { kind: 'refused', variable: 'STRIPE_CONNECTION_KEY', reason: 'shared_with_scheduling' };
330
+ }
331
+ if (previousKey?.equals(input.scheduling)) {
332
+ return {
333
+ kind: 'refused',
334
+ variable: 'STRIPE_CONNECTION_KEY_PREVIOUS',
335
+ reason: 'shared_with_scheduling',
336
+ };
337
+ }
338
+ }
339
+ return {
340
+ kind: 'ok',
341
+ ...(currentKey && { current: currentKey }),
342
+ ...(previousKey && { previous: previousKey }),
343
+ };
344
+ }
345
+
261
346
  /**
262
347
  * `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7, #7633 D2): unset, empty or blank →
263
348
  * `"auto"`; `true`/`1` → `"on"`; `false`/`0` → `"off"` — case-insensitive,
@@ -364,6 +449,24 @@ function validateConfig(): Config {
364
449
  process.exit(1);
365
450
  }
366
451
 
452
+ // #8907 D3 — a set-but-refused Stripe connection key stops the API in every
453
+ // environment, for the scheduling key's reason; so does a key shared with
454
+ // the scheduling key or a previous key that could never be rotated away.
455
+ // The variable and the reason kind only, never a value.
456
+ const stripeConnectionKeys = resolveStripeConnectionKeys({
457
+ current: process.env.STRIPE_CONNECTION_KEY,
458
+ previous: process.env.STRIPE_CONNECTION_KEY_PREVIOUS,
459
+ scheduling: schedulingKey.kind === 'ok' ? schedulingKey.key : undefined,
460
+ });
461
+ if (stripeConnectionKeys.kind === 'refused') {
462
+ const { variable, reason } = stripeConnectionKeys;
463
+ const message =
464
+ '❌ STRIPE_CONNECTION_KEY and STRIPE_CONNECTION_KEY_PREVIOUS must each be canonical standard base64 of exactly 32 bytes (openssl rand -base64 32), distinct from each other and from SCHEDULING_CREDENTIAL_KEY, and a previous key needs a current one';
465
+ logger.error({ variable, reason }, message);
466
+ console.error(message, JSON.stringify({ variable, reason }));
467
+ process.exit(1);
468
+ }
469
+
367
470
  // #7532 D7, #7633 D2 — the hub's spoke-key mode. An unparseable value stops
368
471
  // the API: a typo read as "off" would silently leave the hub open to
369
472
  // header-less callers. The message names the variable, never its value.
@@ -479,6 +582,12 @@ function validateConfig(): Config {
479
582
  ...(schedulingKey.kind === 'ok' && {
480
583
  SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
481
584
  }),
585
+ ...(stripeConnectionKeys.current && {
586
+ STRIPE_CONNECTION_KEY: stripeConnectionKeys.current,
587
+ }),
588
+ ...(stripeConnectionKeys.previous && {
589
+ STRIPE_CONNECTION_KEY_PREVIOUS: stripeConnectionKeys.previous,
590
+ }),
482
591
  AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey,
483
592
  AILK_SPOKE_FLOW_SEED: spokeFlowSeed,
484
593
  };
@@ -203,6 +203,9 @@ export const prisma = {
203
203
  findFirst: jest.fn(),
204
204
  findMany: jest.fn(),
205
205
  updateMany: jest.fn(),
206
+ // `count` — the flow engine's completion-email caps (#8847 security
207
+ // review). Unarranged it resolves undefined, which caps nothing.
208
+ count: jest.fn(),
206
209
  },
207
210
  subscriber: {
208
211
  create: jest.fn(),
@@ -135,6 +135,25 @@ describe("the two readings (SEC-8438-1)", () => {
135
135
  ).toBe(true);
136
136
  });
137
137
 
138
+ it("a checkout row is unverified: in the Person's any-source reading, never their own (#8765 D13, C10)", async () => {
139
+ expect(isUnverifiedSource("checkout")).toBe(true);
140
+ double.ledger.push(
141
+ row({
142
+ purposeId: "data",
143
+ granted: true,
144
+ source: "checkout",
145
+ sourceRef: "cs_live_abc123",
146
+ }),
147
+ );
148
+ const own = await ownConsentState(WS, PERSON, CONFIG);
149
+ const any = await anySourceConsentState(WS, PERSON, CONFIG);
150
+ expect(own.find((s) => s.purposeId === "data")?.row).toBeNull();
151
+ expect(any.find((s) => s.purposeId === "data")?.row).toMatchObject({
152
+ source: "checkout",
153
+ sourceRef: "cs_live_abc123",
154
+ });
155
+ });
156
+
138
157
  it("another workspace's rows and another Person's rows never show", async () => {
139
158
  double.ledger.push(
140
159
  row({ workspaceId: OTHER_WS, purposeId: "data", granted: true }),
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @file flow-completion-store.test.ts
3
+ *
4
+ * A magnet's completion setting — its data boundary (#8757, spec
5
+ * docs/specs/8757-a-per-magnet-completion-setting-spec.md D3, AC-3): every
6
+ * statement is scoped by the workspace it is given, so a write naming another
7
+ * workspace's magnet answers `not_found` and changes nothing, a draft magnet
8
+ * is never a live completion, and the invite caps count only this magnet's
9
+ * leads of this workspace in their window.
10
+ *
11
+ * The world is the in-memory Prisma double (`__tests__/factories/in-memory-prisma`),
12
+ * which refuses a filter it does not model — so scoping that rides on one
13
+ * cannot pass silently.
14
+ */
15
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
16
+ jest.mock("../prisma.js", () =>
17
+ jest
18
+ .requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
19
+ "../../__tests__/factories/in-memory-prisma",
20
+ )
21
+ .createInMemoryPrisma(),
22
+ );
23
+
24
+ import { prisma } from "../prisma.js";
25
+ import {
26
+ completionOf,
27
+ completionsFor,
28
+ inviteSendCounts,
29
+ liveCompletionBySlug,
30
+ setCompletion,
31
+ } from "../flow-completion-store.js";
32
+
33
+ type Row = Record<string, unknown>;
34
+ // eslint-disable-next-line no-restricted-syntax -- the in-memory double exposes its store on keys the real client type does not declare
35
+ const fake = prisma as unknown as { __db: Record<string, Row[]> };
36
+
37
+ const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
38
+ const WS_A2 = "wksp_00000000-0000-7000-8000-0000000000a2";
39
+ const M1 = "flow_00000000-0000-7000-8000-000000000001";
40
+ const M2 = "flow_00000000-0000-7000-8000-000000000002";
41
+ const DRAFT = "flow_00000000-0000-7000-8000-000000000003";
42
+ const NOW = new Date("2026-10-15T12:00:00.000Z");
43
+
44
+ function magnet(id: string, ws: string, slug: string, status: string, extra: Row = {}): Row {
45
+ return { id, siteId: ws, workspaceId: ws, slug, title: `Magnet ${slug}`, status, ...extra };
46
+ }
47
+
48
+ function lead(ws: string, n: number, source: string, email: string, ago: number): Row {
49
+ return {
50
+ id: `lead_00000000-0000-7000-8000-${String(n).padStart(12, "0")}`,
51
+ siteId: ws,
52
+ workspaceId: ws,
53
+ source,
54
+ email,
55
+ createdAt: new Date(NOW.getTime() - ago),
56
+ };
57
+ }
58
+
59
+ beforeEach(() => {
60
+ const db = fake.__db;
61
+ for (const key of Object.keys(db)) delete db[key];
62
+ db.flowDefinition = [
63
+ magnet(M1, WS_A1, "contact", "live", { completion: "invite", checkoutOffering: null }),
64
+ magnet(M2, WS_A2, "contact", "live", { completion: "lead", checkoutOffering: null }),
65
+ magnet(DRAFT, WS_A1, "request-quote", "draft", { completion: "invite", checkoutOffering: null }),
66
+ ];
67
+ db.lead = [];
68
+ });
69
+
70
+ describe("completionsFor (D3)", () => {
71
+ it("reads only the given workspace's rows; an id absent from it reads as lead", async () => {
72
+ const settings = await completionsFor(WS_A1, [M1, M2]);
73
+ expect(completionOf(settings, M1)).toEqual({ completion: "invite", checkoutOffering: null });
74
+ expect(settings.has(M2)).toBe(false);
75
+ expect(completionOf(settings, M2)).toEqual({ completion: "lead", checkoutOffering: null });
76
+ });
77
+
78
+ it("reads a row with neither column as lead", async () => {
79
+ fake.__db.flowDefinition!.push(
80
+ magnet("flow_00000000-0000-7000-8000-000000000009", WS_A1, "bare", "live"),
81
+ );
82
+ const settings = await completionsFor(WS_A1, ["flow_00000000-0000-7000-8000-000000000009"]);
83
+ expect(completionOf(settings, "flow_00000000-0000-7000-8000-000000000009")).toEqual({
84
+ completion: "lead",
85
+ checkoutOffering: null,
86
+ });
87
+ });
88
+ });
89
+
90
+ describe("setCompletion (D3, AC-3)", () => {
91
+ it("writes exactly the pair onto the workspace's own magnet", async () => {
92
+ expect(
93
+ await setCompletion(WS_A1, M1, { completion: "checkout", checkoutOffering: "retainer" }),
94
+ ).toBe("set");
95
+ const row = fake.__db.flowDefinition!.find((r) => r.id === M1)!;
96
+ // `updatedAt` is Prisma-managed (`@updatedAt`); every other column is as it was.
97
+ expect(row).toEqual({
98
+ ...magnet(M1, WS_A1, "contact", "live", {
99
+ completion: "checkout",
100
+ checkoutOffering: "retainer",
101
+ }),
102
+ updatedAt: expect.any(Date),
103
+ });
104
+ });
105
+
106
+ it("answers not_found for another workspace's magnet and changes nothing", async () => {
107
+ const before = structuredClone(fake.__db.flowDefinition);
108
+ expect(await setCompletion(WS_A1, M2, { completion: "invite", checkoutOffering: null })).toBe(
109
+ "not_found",
110
+ );
111
+ expect(fake.__db.flowDefinition).toEqual(before);
112
+ });
113
+ });
114
+
115
+ describe("liveCompletionBySlug (D3, AC-3)", () => {
116
+ it("answers a live magnet's setting and title", async () => {
117
+ expect(await liveCompletionBySlug(WS_A1, "contact")).toEqual({
118
+ completion: "invite",
119
+ checkoutOffering: null,
120
+ title: "Magnet contact",
121
+ config: undefined,
122
+ });
123
+ });
124
+
125
+ it("answers null for a draft row, an absent slug and another workspace's magnet", async () => {
126
+ expect(await liveCompletionBySlug(WS_A1, "request-quote")).toBeNull();
127
+ expect(await liveCompletionBySlug(WS_A1, "missing")).toBeNull();
128
+ fake.__db.flowDefinition = fake.__db.flowDefinition!.filter((r) => r.id !== M1);
129
+ expect(await liveCompletionBySlug(WS_A1, "contact")).toBeNull();
130
+ });
131
+ });
132
+
133
+ describe("inviteSendCounts (D6)", () => {
134
+ it("counts this magnet's leads of this workspace: the recipient's over a day, all over an hour", async () => {
135
+ const HOUR = 60 * 60 * 1000;
136
+ fake.__db.lead = [
137
+ lead(WS_A1, 1, "flow:contact", "a@b.example", 5 * 60 * 1000),
138
+ lead(WS_A1, 2, "flow:contact", "a@b.example", 20 * HOUR),
139
+ lead(WS_A1, 3, "flow:contact", "a@b.example", 25 * HOUR),
140
+ lead(WS_A1, 4, "flow:contact", "c@d.example", 10 * 60 * 1000),
141
+ lead(WS_A1, 5, "flow:other", "a@b.example", 60 * 1000),
142
+ lead(WS_A2, 6, "flow:contact", "a@b.example", 60 * 1000),
143
+ ];
144
+ expect(await inviteSendCounts(WS_A1, "contact", "a@b.example", NOW)).toEqual({
145
+ recipientDay: 2,
146
+ siteHour: 2,
147
+ });
148
+ });
149
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @file flow-rename-race.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of #8734 AC-5 (spec
5
+ * docs/specs/8734-magnet-slug-renames-an-anonymous-spec.md D4): two renames
6
+ * racing for one free slug are serialized by `flow_definitions`'
7
+ * `(site_id, slug)` unique key. `flows.rename`'s first write is the
8
+ * definition row, so the second transaction's write waits on the first's
9
+ * uncommitted index entry and, once the first commits, fails with `23505` —
10
+ * which Prisma reports as `P2002` and `rename` answers `conflict`. The pre-
11
+ * checks run at READ COMMITTED and guarantee nothing here; the key does.
12
+ *
13
+ * Runs only when `AILK_TEST_LIVE_DATABASE === "1"` (set beside a real
14
+ * `DATABASE_URL` by `.github/workflows/api-postgres-compat.yml`), and uses the
15
+ * `pg` driver directly, the same reason `rate-limit-cross-isolate.db.test.ts`
16
+ * does: the generated Prisma client is ESM-only and cannot load under this
17
+ * package's CJS ts-jest transform.
18
+ */
19
+ import { Client } from "pg";
20
+
21
+ const describeDb =
22
+ process.env.AILK_TEST_LIVE_DATABASE === "1" ? describe : describe.skip;
23
+
24
+ describeDb("flow_definitions' (site_id, slug) key serializes two renames to one slug (live, #8734 AC-5)", () => {
25
+ let first: Client;
26
+ let second: Client;
27
+ let workspaceId = "";
28
+ let rowA = "";
29
+ let rowB = "";
30
+
31
+ async function insertRow(slug: string): Promise<string> {
32
+ const res = await first.query<{ id: string }>(
33
+ `INSERT INTO "flow_definitions"
34
+ ("site_id", "workspace_id", "slug", "config", "kind", "title", "status", "customers_enabled", "updated_at")
35
+ VALUES ($1, $1, $2, '{}'::jsonb, 'contact', $2, 'draft', false, now())
36
+ RETURNING "id"`,
37
+ [workspaceId, slug],
38
+ );
39
+ return res.rows[0]!.id;
40
+ }
41
+
42
+ /** Until `pid`'s statement is blocked on a lock — the wait the key imposes. */
43
+ async function waitUntilBlocked(pid: number): Promise<void> {
44
+ for (let attempt = 0; attempt < 100; attempt++) {
45
+ const res = await first.query<{ wait_event_type: string | null }>(
46
+ `SELECT "wait_event_type" FROM pg_stat_activity WHERE "pid" = $1`,
47
+ [pid],
48
+ );
49
+ if (res.rows[0]?.wait_event_type === "Lock") return;
50
+ await new Promise((resolve) => setTimeout(resolve, 50));
51
+ }
52
+ throw new Error("the second rename never waited on the first");
53
+ }
54
+
55
+ beforeAll(async () => {
56
+ first = new Client({ connectionString: process.env.DATABASE_URL });
57
+ second = new Client({ connectionString: process.env.DATABASE_URL });
58
+ await first.connect();
59
+ await second.connect();
60
+ const ws = await first.query<{ id: string }>(
61
+ `SELECT 'wksp_' || uuidv7()::text AS "id"`,
62
+ );
63
+ workspaceId = ws.rows[0]!.id;
64
+ rowA = await insertRow("race-a");
65
+ rowB = await insertRow("race-b");
66
+ });
67
+
68
+ afterAll(async () => {
69
+ await first.query(`DELETE FROM "flow_definitions" WHERE "workspace_id" = $1`, [
70
+ workspaceId,
71
+ ]);
72
+ await first.end();
73
+ await second.end();
74
+ });
75
+
76
+ it("the second rename to one slug waits on the first, then fails 23505 on flow_definitions_site_id_slug_key; one row holds the slug", async () => {
77
+ const rename = `UPDATE "flow_definitions" SET "slug" = 'race-x' WHERE "id" = $1`;
78
+ const secondPid = (await second.query<{ pid: number }>("SELECT pg_backend_pid() AS pid"))
79
+ .rows[0]!.pid;
80
+
81
+ let error: { code?: string; constraint?: string } | null | undefined;
82
+ try {
83
+ await first.query("BEGIN");
84
+ await first.query(rename, [rowA]);
85
+
86
+ await second.query("BEGIN");
87
+ // Not awaited: it blocks on the first transaction's uncommitted entry.
88
+ const loser = second.query(rename, [rowB]).then(
89
+ () => null,
90
+ (failure: unknown) => failure as { code?: string; constraint?: string },
91
+ );
92
+ await waitUntilBlocked(secondPid);
93
+
94
+ await first.query("COMMIT");
95
+ error = await loser;
96
+ } finally {
97
+ await first.query("ROLLBACK");
98
+ await second.query("ROLLBACK");
99
+ }
100
+
101
+ expect(error).toMatchObject({
102
+ code: "23505",
103
+ constraint: "flow_definitions_site_id_slug_key",
104
+ });
105
+ const atSlug = await first.query<{ count: string }>(
106
+ `SELECT count(*) AS "count" FROM "flow_definitions" WHERE "workspace_id" = $1 AND "slug" = 'race-x'`,
107
+ [workspaceId],
108
+ );
109
+ expect(atSlug.rows[0]!.count).toBe("1");
110
+ const b = await first.query<{ slug: string }>(
111
+ `SELECT "slug" FROM "flow_definitions" WHERE "id" = $1`,
112
+ [rowB],
113
+ );
114
+ expect(b.rows[0]!.slug).toBe("race-b");
115
+ });
116
+ });