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,389 @@
1
+ /**
2
+ * @file magnet-completion.test.ts
3
+ *
4
+ * #8757 D6 (spec docs/specs/8757-a-per-magnet-completion-setting-spec.md,
5
+ * AC-5's origin, link and copy): where a submitter's account link points,
6
+ * what the invite email says, and when a completion degrades to `lead`.
7
+ *
8
+ * Mocked at their own boundaries: the completion store, the sign-in client
9
+ * read, the commerce claim, the offerings lister and `@working-theory/email`.
10
+ */
11
+ const mockLiveCompletionBySlug = jest.fn();
12
+ const mockInviteSendCounts = jest.fn();
13
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
14
+ jest.mock("../../lib/flow-completion-store.js", () => ({
15
+ liveCompletionBySlug: (...args: unknown[]) => mockLiveCompletionBySlug(...args),
16
+ inviteSendCounts: (...args: unknown[]) => mockInviteSendCounts(...args),
17
+ }));
18
+
19
+ const mockFindClientByClientId = jest.fn();
20
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
21
+ jest.mock("../../lib/hub-sign-in-store.js", () => ({
22
+ findClientByClientId: (...args: unknown[]) => mockFindClientByClientId(...args),
23
+ }));
24
+
25
+ const mockResolveCommerceClaim = jest.fn();
26
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
27
+ jest.mock("../../lib/require-commerce-claim.js", () => ({
28
+ resolveCommerceClaim: () => mockResolveCommerceClaim(),
29
+ }));
30
+
31
+ const mockListPlanOfferings = jest.fn();
32
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
33
+ jest.mock("../plan-offerings.js", () => ({
34
+ listPlanOfferings: (...args: unknown[]) => mockListPlanOfferings(...args),
35
+ }));
36
+
37
+ const mockSendDeliverableEmail = jest.fn();
38
+ jest.mock("@working-theory/email", () => ({
39
+ sendDeliverableEmail: (...args: unknown[]) => mockSendDeliverableEmail(...args),
40
+ }));
41
+
42
+ import { deliverableSenderAddress } from "../deliverable-fulfillment.js";
43
+ import {
44
+ accountTargetFor,
45
+ inviteSenderAddress,
46
+ MAGNET_COMPLETION_BUDGET_MS,
47
+ magnetCompletionFor,
48
+ magnetCompletionWithinBudget,
49
+ sendInviteConfirmation,
50
+ type MagnetCompletion,
51
+ } from "../magnet-completion.js";
52
+
53
+ const WS = "wksp_00000000-0000-7000-8000-0000000000c1";
54
+ const S1 = "spoke_00000000-0000-7000-8000-0000000000d1";
55
+ const NOW = new Date("2026-10-15T12:00:00.000Z");
56
+ const originalEnv = process.env;
57
+
58
+ const client = (redirectUri: string, enabled = true) => ({
59
+ id: "oauthc_1",
60
+ clientId: S1,
61
+ clientSecret: "x",
62
+ redirectUris: [redirectUri],
63
+ enabled,
64
+ });
65
+
66
+ const invite = (config: unknown = { defaultLocale: "en" }): MagnetCompletion => ({
67
+ next: { completion: "invite" },
68
+ title: "Request a quote",
69
+ config,
70
+ target: { origin: "https://site.example", provider: "site" },
71
+ });
72
+
73
+ beforeEach(() => {
74
+ jest.clearAllMocks();
75
+ process.env = {
76
+ ...originalEnv,
77
+ FRONTEND_URL: "https://site.example/app",
78
+ AILK_ENABLED_MODULES: "multi-tenant",
79
+ AILK_DELIVERABLE_EMAIL_FROM: "hello@site.example",
80
+ };
81
+ mockInviteSendCounts.mockResolvedValue({ recipientDay: 1, siteHour: 1 });
82
+ mockSendDeliverableEmail.mockResolvedValue({ ok: true, messageId: "m1" });
83
+ mockResolveCommerceClaim.mockResolvedValue("claimed");
84
+ mockListPlanOfferings.mockResolvedValue([{ key: "retainer", name: "Retainer", type: "service" }]);
85
+ });
86
+
87
+ afterAll(() => {
88
+ process.env = originalEnv;
89
+ });
90
+
91
+ describe("accountTargetFor — the link's origin is never the request's", () => {
92
+ it("a keyless caller on a build with an account area gets FRONTEND_URL's origin and the site path", async () => {
93
+ expect(await accountTargetFor({ kind: "anonymous" })).toEqual({
94
+ origin: "https://site.example",
95
+ provider: "site",
96
+ });
97
+ });
98
+
99
+ it("a keyless caller on a build with no account area gets nothing", async () => {
100
+ process.env.AILK_ENABLED_MODULES = "signal-collectors";
101
+ expect(await accountTargetFor({ kind: "anonymous" })).toBeNull();
102
+ });
103
+
104
+ it("a spoke gets its enabled sign-in client's callback origin and the hub path", async () => {
105
+ mockFindClientByClientId.mockResolvedValue(client("https://spoke.example/api/hub-session/callback"));
106
+ expect(await accountTargetFor({ kind: "spoke", spokeId: S1, workspaceId: WS })).toEqual({
107
+ origin: "https://spoke.example",
108
+ provider: "hub",
109
+ });
110
+ });
111
+
112
+ it("a spoke with no client, a disabled one, or an http callback in production gets nothing", async () => {
113
+ const spoke = { kind: "spoke", spokeId: S1, workspaceId: WS } as const;
114
+ mockFindClientByClientId.mockResolvedValueOnce(null);
115
+ expect(await accountTargetFor(spoke)).toBeNull();
116
+ mockFindClientByClientId.mockResolvedValueOnce(client("https://spoke.example/cb", false));
117
+ expect(await accountTargetFor(spoke)).toBeNull();
118
+ process.env.NODE_ENV = "production";
119
+ mockFindClientByClientId.mockResolvedValueOnce(client("http://localhost:3000/api/hub-session/callback"));
120
+ expect(await accountTargetFor(spoke)).toBeNull();
121
+ });
122
+
123
+ it("an http loopback callback outside production is allowed (local development)", async () => {
124
+ process.env.NODE_ENV = "development";
125
+ mockFindClientByClientId.mockResolvedValue(client("http://localhost:3000/api/hub-session/callback"));
126
+ expect(await accountTargetFor({ kind: "spoke", spokeId: S1, workspaceId: WS })).toEqual({
127
+ origin: "http://localhost:3000",
128
+ provider: "hub",
129
+ });
130
+ });
131
+ });
132
+
133
+ describe("inviteSenderAddress — every completion email's sender", () => {
134
+ it.each([
135
+ [{ AILK_DELIVERABLE_EMAIL_FROM: "a@x.example", AILK_LEAD_NOTIFICATION_FROM: "b@x.example" }],
136
+ [{ AILK_DELIVERABLE_EMAIL_FROM: "", AILK_LEAD_NOTIFICATION_FROM: "b@x.example" }],
137
+ [{}],
138
+ ])("answers what deliverableSenderAddress answers for %j", (env) => {
139
+ delete process.env.AILK_DELIVERABLE_EMAIL_FROM;
140
+ delete process.env.AILK_LEAD_NOTIFICATION_FROM;
141
+ Object.assign(process.env, env);
142
+ expect(inviteSenderAddress()).toBe(deliverableSenderAddress());
143
+ });
144
+ });
145
+
146
+ describe("magnetCompletionWithinBudget — a slow read never holds the 201", () => {
147
+ it("answers null once the budget passes", async () => {
148
+ jest.useFakeTimers();
149
+ try {
150
+ mockLiveCompletionBySlug.mockReturnValue(new Promise(() => undefined));
151
+ const pending = magnetCompletionWithinBudget(WS, "contact", async () => null);
152
+ jest.advanceTimersByTime(MAGNET_COMPLETION_BUDGET_MS);
153
+ expect(await pending).toBeNull();
154
+ } finally {
155
+ jest.useRealTimers();
156
+ }
157
+ });
158
+ });
159
+
160
+ describe("magnetCompletionFor", () => {
161
+ const target = { origin: "https://site.example", provider: "site" as const };
162
+ const live = (completion: string, checkoutOffering: string | null = null) =>
163
+ mockLiveCompletionBySlug.mockResolvedValue({
164
+ completion,
165
+ checkoutOffering,
166
+ title: "Contact form",
167
+ config: {},
168
+ });
169
+
170
+ it("lead, or no live magnet, is null and never resolves a target", async () => {
171
+ const resolveTarget = jest.fn().mockResolvedValue(target);
172
+ live("lead");
173
+ expect(await magnetCompletionFor(WS, "contact", resolveTarget)).toBeNull();
174
+ mockLiveCompletionBySlug.mockResolvedValue(null);
175
+ expect(await magnetCompletionFor(WS, "contact", resolveTarget)).toBeNull();
176
+ expect(resolveTarget).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it("invite with a target answers next: invite; with none degrades to lead", async () => {
180
+ live("invite");
181
+ expect(await magnetCompletionFor(WS, "contact", async () => target)).toMatchObject({
182
+ next: { completion: "invite" },
183
+ title: "Contact form",
184
+ target,
185
+ });
186
+ expect(await magnetCompletionFor(WS, "contact", async () => null)).toBeNull();
187
+ });
188
+
189
+ it("checkout re-checks the claim and the listed offering, and never resolves a target", async () => {
190
+ const resolveTarget = jest.fn();
191
+ live("checkout", "retainer");
192
+ expect(await magnetCompletionFor(WS, "contact", resolveTarget)).toMatchObject({
193
+ next: { completion: "checkout", checkoutOffering: "retainer" },
194
+ });
195
+ expect(mockListPlanOfferings).toHaveBeenCalledWith(WS);
196
+ expect(resolveTarget).not.toHaveBeenCalled();
197
+ });
198
+ });
199
+
200
+ describe("sendInviteConfirmation — copy and link", () => {
201
+ it("sends the defaults with the title filled in and the prefilled sign-in link", async () => {
202
+ expect(
203
+ await sendInviteConfirmation({
204
+ workspaceId: WS,
205
+ slug: "request-quote",
206
+ email: "a+b@x.example",
207
+ locale: undefined,
208
+ completion: invite(),
209
+ now: NOW,
210
+ }),
211
+ ).toBe("sent");
212
+ expect(mockInviteSendCounts).toHaveBeenCalledWith(WS, "request-quote", "a+b@x.example", NOW);
213
+ expect(mockSendDeliverableEmail).toHaveBeenCalledWith({
214
+ to: "a+b@x.example",
215
+ fromAddress: "hello@site.example",
216
+ subject: "We received your Request a quote",
217
+ body: "Thanks — we have your Request a quote. You can follow it in your account.",
218
+ cta: "Track this in your account",
219
+ downloadLink: "https://site.example/sign-in?next=%2Faccount&email=a%2Bb%40x.example",
220
+ });
221
+ });
222
+
223
+ it("uses the magnet's completionEmail in the submitter's locale, else its default locale", async () => {
224
+ const config = {
225
+ defaultLocale: "en",
226
+ completionEmail: {
227
+ subject: { en: "Got it", fr: "Bien reçu" },
228
+ body: { en: "Thanks.", fr: "Merci." },
229
+ cta: { en: "Follow it", fr: "Suivre" },
230
+ },
231
+ };
232
+ await sendInviteConfirmation({
233
+ workspaceId: WS,
234
+ slug: "contact",
235
+ email: "a@b.example",
236
+ locale: "fr",
237
+ completion: invite(config),
238
+ now: NOW,
239
+ });
240
+ expect(mockSendDeliverableEmail.mock.calls[0][0]).toMatchObject({
241
+ subject: "Bien reçu",
242
+ body: "Merci.",
243
+ cta: "Suivre",
244
+ });
245
+ await sendInviteConfirmation({
246
+ workspaceId: WS,
247
+ slug: "contact",
248
+ email: "a@b.example",
249
+ locale: "de",
250
+ completion: invite(config),
251
+ now: NOW,
252
+ });
253
+ expect(mockSendDeliverableEmail.mock.calls[1][0]).toMatchObject({ subject: "Got it" });
254
+ });
255
+
256
+ it.each(["constructor", "toString", "__proto__"])(
257
+ "a payload locale of %s reads the default locale's copy, never an inherited member",
258
+ async (locale) => {
259
+ const config = {
260
+ defaultLocale: "en",
261
+ completionEmail: { subject: { en: "Got it" }, body: { en: "Thanks." } },
262
+ };
263
+ expect(
264
+ await sendInviteConfirmation({
265
+ workspaceId: WS,
266
+ slug: "contact",
267
+ email: "a@b.example",
268
+ locale,
269
+ completion: invite(config),
270
+ now: NOW,
271
+ }),
272
+ ).toBe("sent");
273
+ expect(mockSendDeliverableEmail.mock.calls[0][0]).toMatchObject({
274
+ subject: "Got it",
275
+ body: "Thanks.",
276
+ });
277
+ },
278
+ );
279
+
280
+ it("an email the hint schema refuses gets no email (no_link)", async () => {
281
+ expect(
282
+ await sendInviteConfirmation({
283
+ workspaceId: WS,
284
+ slug: "contact",
285
+ email: "x\r\n@y",
286
+ locale: undefined,
287
+ completion: invite(),
288
+ now: NOW,
289
+ }),
290
+ ).toBe("no_link");
291
+ expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
292
+ });
293
+
294
+ it.each([
295
+ [{ recipientDay: 2, siteHour: 2 }, "capped_recipient"],
296
+ [{ recipientDay: 1, siteHour: 51 }, "capped_site"],
297
+ ])("counts %j cap the send (%s)", async (counts, outcome) => {
298
+ mockInviteSendCounts.mockResolvedValue(counts);
299
+ expect(
300
+ await sendInviteConfirmation({
301
+ workspaceId: WS,
302
+ slug: "contact",
303
+ email: "a@b.example",
304
+ locale: undefined,
305
+ completion: invite(),
306
+ now: NOW,
307
+ }),
308
+ ).toBe(outcome);
309
+ expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
310
+ });
311
+
312
+ it("a send that fails or throws is send_failed, never a throw", async () => {
313
+ mockSendDeliverableEmail.mockResolvedValueOnce({ ok: false, error: { kind: "send_failed" } });
314
+ mockSendDeliverableEmail.mockRejectedValueOnce(new Error("down"));
315
+ const input = {
316
+ workspaceId: WS,
317
+ slug: "contact",
318
+ email: "a@b.example",
319
+ locale: undefined,
320
+ completion: invite(),
321
+ now: NOW,
322
+ };
323
+ expect(await sendInviteConfirmation(input)).toBe("send_failed");
324
+ expect(await sendInviteConfirmation(input)).toBe("send_failed");
325
+ });
326
+ });
327
+
328
+ // Read as text, never imported: an app does not import a package's source.
329
+ const monorepoFs = jest.requireActual<typeof import("node:fs")>("node:fs");
330
+ const monorepoPath = jest.requireActual<typeof import("node:path")>("node:path");
331
+ const MONOREPO = monorepoPath.resolve(__dirname, "../../../../..");
332
+ const SLICES = "packages/create-ailk/src/module-architecture.ts";
333
+ // Monorepo-only: a scaffolded site carries no packages/create-ailk, and the
334
+ // scaffold matrix runs this suite inside one (#8780), so the slice assertion
335
+ // skips where the slice map does not exist rather than failing to load.
336
+ const describeInMonorepo = monorepoFs.existsSync(monorepoPath.join(MONOREPO, SLICES))
337
+ ? describe
338
+ : describe.skip;
339
+
340
+ describeInMonorepo("#8757 AC-3 / NFR6 — the lead path reaches only base-tree files", () => {
341
+ const fs = monorepoFs;
342
+ const path = monorepoPath;
343
+ const REPO = MONOREPO;
344
+ const read = (rel: string) => fs.readFileSync(path.join(REPO, rel), "utf8");
345
+ // Lazy: describe.skip still runs this body at collection time, so the slice
346
+ // map is read only inside a test, never while the suite loads.
347
+ let slicesCache: string | undefined;
348
+ const slicesText = () => (slicesCache ??= read(SLICES));
349
+ const sliceGlobs = () =>
350
+ [...slicesText().matchAll(/"(apps\/api\/src\/[^"]*)\/\*\*"/g)].map((m) => m[1]!);
351
+ const named = (rel: string) =>
352
+ slicesText().includes(`"${rel}"`) || sliceGlobs().some((dir) => rel.startsWith(`${dir}/`));
353
+
354
+ /** Every apps/api source file `entry` reaches through relative imports. */
355
+ function closure(entry: string): string[] {
356
+ const seen = new Set<string>();
357
+ const walk = (rel: string) => {
358
+ if (seen.has(rel)) return;
359
+ seen.add(rel);
360
+ for (const m of read(rel).matchAll(/^(?:import|export)[^;]*?from\s+["'](\.[^"']+)["']/gm)) {
361
+ const target = path.posix.normalize(
362
+ path.posix.join(path.posix.dirname(rel), m[1]!.replace(/\.js$/, ".ts")),
363
+ );
364
+ if (fs.existsSync(path.join(REPO, target))) walk(target);
365
+ }
366
+ };
367
+ walk(entry);
368
+ return [...seen];
369
+ }
370
+
371
+ const NEW_BASE_FILES = [
372
+ "apps/api/src/lib/flow-completion-store.ts",
373
+ "apps/api/src/services/plan-offerings.ts",
374
+ "apps/api/src/services/magnet-completion.ts",
375
+ ];
376
+
377
+ it("the three new files carry no oss: false marker and are named by no module slice", () => {
378
+ for (const rel of NEW_BASE_FILES) {
379
+ expect(read(rel).split("\n", 1)[0]).not.toMatch(/oss:\s*false/);
380
+ expect(named(rel)).toBe(false);
381
+ }
382
+ });
383
+
384
+ it("routes/leads/index.ts imports, directly or not, no file a module slice owns", () => {
385
+ const reached = closure("apps/api/src/routes/leads/index.ts");
386
+ for (const rel of NEW_BASE_FILES) expect(reached).toContain(rel);
387
+ expect(reached.filter(named)).toEqual([]);
388
+ });
389
+ });
@@ -7,7 +7,10 @@
7
7
  * `resolveMemberPersonId` and `setPersonPlanOffering` (#7877 D8; AC-3, AC-5),
8
8
  * and the hub's buyer read, `resolveCallerBillingCustomerId`, with the
9
9
  * stamped-Person guard on the customer-id write (#7961, spec
10
- * docs/specs/7894-the-hub-signs-in-its-spec.md D13, SEC-7894-6; AC-8).
10
+ * docs/specs/7894-the-hub-signs-in-its-spec.md D13, SEC-7894-6; AC-8),
11
+ * and the held checkout plan's release on a verified first link
12
+ * (#8793, spec docs/specs/8793-the-hub-s-checkout-plan-spec.md D2–D5;
13
+ * AC-1, AC-2, AC-6).
11
14
  *
12
15
  * Runs over `createInMemoryPrisma()`, whose `person` delegate enforces the
13
16
  * declared `(workspaceId, email)` and `(workspaceId, userId)` keys the way
@@ -36,6 +39,7 @@ import { prisma } from "../../lib/prisma.js";
36
39
  import { personContactPatchSchema } from "@working-theory/validation";
37
40
 
38
41
  import {
42
+ checkoutPlanKey,
39
43
  contactConfirmedFromRow,
40
44
  ensureMemberPerson,
41
45
  findOrCreatePerson,
@@ -45,6 +49,7 @@ import {
45
49
  normalizeTelephone,
46
50
  personContactFromRow,
47
51
  recordCheckoutPerson,
52
+ releaseHeldCheckoutPlan,
48
53
  resolveCallerBillingCustomerId,
49
54
  resolveMemberPersonId,
50
55
  setOwnPersonContact,
@@ -61,6 +66,7 @@ const store = prisma as unknown as {
61
66
  __db: Record<string, Row[]>;
62
67
  __calls: Array<{ model: string; op: string }>;
63
68
  person: Record<string, jest.Mock>;
69
+ lead: Record<string, jest.Mock>;
64
70
  };
65
71
 
66
72
  const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
@@ -943,3 +949,228 @@ describe("setPersonPlanOffering (#7877 D8, AC-4, AC-5)", () => {
943
949
  expect(await setPersonPlanOffering(WS_A1, "person_x", "retainer")).toBe("internal_error");
944
950
  });
945
951
  });
952
+
953
+ // ─── The held checkout plan (#8793) ──────────────────────────────────────────
954
+
955
+ const leads = () => (store.__db["lead"] ??= []);
956
+ const leadCalls = () => store.__calls.filter((c) => c.model === "lead");
957
+
958
+ /** A spoke checkout lead on a Person, newest last unless `createdAt` says otherwise. */
959
+ function seedMarkedLead(personId: string, row: Row = {}): Row {
960
+ const full: Row = {
961
+ id: `lead_00000000-0000-7000-8000-${String(leads().length + 700).padStart(12, "0")}`,
962
+ workspaceId: WS_A1,
963
+ personId,
964
+ email: "Ada@Example.com",
965
+ source: "web",
966
+ spokeCheckout: true,
967
+ payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" },
968
+ createdAt: new Date(Date.UTC(2026, 1, 1 + leads().length)),
969
+ ...row,
970
+ };
971
+ leads().push(full);
972
+ return full;
973
+ }
974
+
975
+ describe("checkoutPlanKey (#8793 D4)", () => {
976
+ it("strips a leading <site>_ and answers a plan key, else null", () => {
977
+ expect(checkoutPlanKey("wendi_retainer", "wendi")).toBe("retainer");
978
+ expect(checkoutPlanKey("retainer", "wendi")).toBe("retainer");
979
+ expect(checkoutPlanKey("wendi_Not A Key!", "wendi")).toBeNull();
980
+ expect(checkoutPlanKey(42, "wendi")).toBeNull();
981
+ });
982
+ });
983
+
984
+ describe("setPersonPlanOffering's link guard (#8793 D5, AC-1, AC-6)", () => {
985
+ const U = "user_00000000-0000-7000-8000-000000000031";
986
+ const V = "user_00000000-0000-7000-8000-000000000032";
987
+
988
+ it("a guarded write never overwrites a set plan", async () => {
989
+ const p = seedPerson({ email: "ada@example.com", userId: U, planOffering: "premium" });
990
+ expect(
991
+ await setPersonPlanOffering(WS_A1, p["id"] as string, "x", { linkedUserId: U }),
992
+ ).toBe("not_found");
993
+ expect(people()[0]!["planOffering"]).toBe("premium");
994
+ });
995
+
996
+ it("a guarded write never reaches a Person linked to another user", async () => {
997
+ const p = seedPerson({ email: "ada@example.com", userId: U });
998
+ expect(
999
+ await setPersonPlanOffering(WS_A1, p["id"] as string, "x", { linkedUserId: V }),
1000
+ ).toBe("not_found");
1001
+ expect(people()[0]!["planOffering"]).toBeNull();
1002
+ });
1003
+
1004
+ it("the operator's three-argument write still overwrites a set plan", async () => {
1005
+ const p = seedPerson({ email: "ada@example.com", userId: U, planOffering: "premium" });
1006
+ expect(await setPersonPlanOffering(WS_A1, p["id"] as string, "basic")).toBe("set");
1007
+ expect(people()[0]!["planOffering"]).toBe("basic");
1008
+ });
1009
+ });
1010
+
1011
+ describe("releaseHeldCheckoutPlan (#8793 D3, AC-1)", () => {
1012
+ const U = "user_00000000-0000-7000-8000-000000000041";
1013
+ const EMAIL = "ada@example.com";
1014
+ let personId: string;
1015
+
1016
+ const release = () => releaseHeldCheckoutPlan(WS_A1, personId, U, EMAIL);
1017
+ const plan = () => people()[0]!["planOffering"];
1018
+
1019
+ beforeEach(() => {
1020
+ personId = seedPerson({ email: EMAIL, userId: U })["id"] as string;
1021
+ });
1022
+
1023
+ it("one marked lead sets its key", async () => {
1024
+ seedMarkedLead(personId);
1025
+ expect(await release()).toBe("set");
1026
+ expect(plan()).toBe("retainer");
1027
+ });
1028
+
1029
+ it("two marked leads naming one key, with and without the site prefix, set it", async () => {
1030
+ seedMarkedLead(personId);
1031
+ seedMarkedLead(personId, {
1032
+ payload: { site: "wendi", page: "checkout", offering: "retainer" },
1033
+ });
1034
+ expect(await release()).toBe("set");
1035
+ expect(plan()).toBe("retainer");
1036
+ });
1037
+
1038
+ it.each([
1039
+ ["the basic purchase is newer", 1, 2],
1040
+ ["the retainer purchase is newer", 2, 1],
1041
+ ])("two marked leads naming different keys are ambiguous whatever their order (%s)", async (_label, retainerDay, basicDay) => {
1042
+ seedMarkedLead(personId, { createdAt: new Date(Date.UTC(2026, 2, retainerDay)) });
1043
+ seedMarkedLead(personId, {
1044
+ createdAt: new Date(Date.UTC(2026, 2, basicDay)),
1045
+ payload: { site: "wendi", page: "checkout", offering: "wendi_basic" },
1046
+ });
1047
+ expect(await release()).toBe("ambiguous");
1048
+ expect(plan()).toBeNull();
1049
+ });
1050
+
1051
+ it("a marked lead under another email is ignored", async () => {
1052
+ seedMarkedLead(personId);
1053
+ seedMarkedLead(personId, {
1054
+ email: "other@example.com",
1055
+ payload: { site: "wendi", page: "checkout", offering: "wendi_basic" },
1056
+ });
1057
+ expect(await release()).toBe("set");
1058
+ expect(plan()).toBe("retainer");
1059
+ });
1060
+
1061
+ it("only unmarked leads are none", async () => {
1062
+ seedMarkedLead(personId, { spokeCheckout: false });
1063
+ expect(await release()).toBe("none");
1064
+ expect(plan()).toBeNull();
1065
+ });
1066
+
1067
+ it("only an offering that is no plan key is none", async () => {
1068
+ seedMarkedLead(personId, {
1069
+ payload: { site: "wendi", page: "checkout", offering: "wendi_Not A Key!" },
1070
+ });
1071
+ expect(await release()).toBe("none");
1072
+ expect(plan()).toBeNull();
1073
+ });
1074
+
1075
+ it("51 marked leads are ambiguous, even when they all agree", async () => {
1076
+ for (let i = 0; i < 51; i += 1) seedMarkedLead(personId);
1077
+ expect(await release()).toBe("ambiguous");
1078
+ expect(plan()).toBeNull();
1079
+ });
1080
+
1081
+ it("a plan already set is kept", async () => {
1082
+ people()[0]!["planOffering"] = "premium";
1083
+ seedMarkedLead(personId);
1084
+ expect(await release()).toBe("kept");
1085
+ expect(plan()).toBe("premium");
1086
+ });
1087
+
1088
+ it("a failed lead read is internal_error, with no write", async () => {
1089
+ seedMarkedLead(personId);
1090
+ store.lead["findMany"]!.mockRejectedValueOnce(new Error("down"));
1091
+ expect(await release()).toBe("internal_error");
1092
+ expect(personCalls().filter((c) => c.op === "updateMany")).toHaveLength(0);
1093
+ expect(plan()).toBeNull();
1094
+ });
1095
+ });
1096
+
1097
+ describe("resolveMemberPersonId releases a held plan on a fresh link only (#8793 D2, AC-2)", () => {
1098
+ const users = () => (store.__db["user"] ??= []);
1099
+ const U = "user_00000000-0000-7000-8000-000000000051";
1100
+ const EMAIL = "ada@example.com";
1101
+ const log = { warn: jest.fn() };
1102
+ let personId: string;
1103
+
1104
+ const plan = () => people()[0]!["planOffering"];
1105
+ const releaseLines = () =>
1106
+ log.warn.mock.calls
1107
+ .map(([fields]) => fields as Record<string, unknown>)
1108
+ .filter((fields) => fields["event"] === "checkout-plan-release");
1109
+
1110
+ beforeEach(() => {
1111
+ log.warn.mockClear();
1112
+ personId = seedPerson({ email: EMAIL })["id"] as string;
1113
+ seedMarkedLead(personId);
1114
+ });
1115
+
1116
+ it("a verified caller's first link releases the held plan, logging nothing", async () => {
1117
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1118
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1119
+ expect(people()[0]!["userId"]).toBe(U);
1120
+ expect(plan()).toBe("retainer");
1121
+ expect(releaseLines()).toEqual([]);
1122
+ });
1123
+
1124
+ it("an unverified caller links nothing and releases nothing", async () => {
1125
+ users().push({ id: U, email: EMAIL, emailVerified: false });
1126
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBeNull();
1127
+ expect(plan()).toBeNull();
1128
+ });
1129
+
1130
+ it("a Person linked before the call is answered with no release and no lead read", async () => {
1131
+ people()[0]!["userId"] = U;
1132
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1133
+ expect(plan()).toBeNull();
1134
+ expect(leadCalls()).toHaveLength(0);
1135
+ });
1136
+
1137
+ it("a direct stamp (the inbox accept's and the consents route's path) releases nothing", async () => {
1138
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1139
+ expect(await stampPersonUser(WS_A1, personId, U)).toBe("stamped");
1140
+ expect(plan()).toBeNull();
1141
+ expect(leadCalls()).toHaveLength(0);
1142
+ });
1143
+
1144
+ it("a failed release still answers the Person and logs its kind only", async () => {
1145
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1146
+ store.lead["findMany"]!.mockRejectedValueOnce(new Error("down"));
1147
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1148
+ expect(plan()).toBeNull();
1149
+ expect(releaseLines()).toEqual([
1150
+ { event: "checkout-plan-release", outcome: "internal_error" },
1151
+ ]);
1152
+ const logged = JSON.stringify(log.warn.mock.calls);
1153
+ expect(logged).not.toContain(EMAIL);
1154
+ expect(logged).not.toContain("retainer");
1155
+ });
1156
+
1157
+ it("disagreeing purchases release nothing and log ambiguous", async () => {
1158
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1159
+ seedMarkedLead(personId, {
1160
+ payload: { site: "wendi", page: "checkout", offering: "wendi_basic" },
1161
+ });
1162
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1163
+ expect(plan()).toBeNull();
1164
+ expect(releaseLines()).toEqual([
1165
+ { event: "checkout-plan-release", outcome: "ambiguous" },
1166
+ ]);
1167
+ });
1168
+
1169
+ it("a release that finds nothing to set logs nothing", async () => {
1170
+ leads().length = 0;
1171
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1172
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1173
+ expect(plan()).toBeNull();
1174
+ expect(log.warn).not.toHaveBeenCalled();
1175
+ });
1176
+ });