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
@@ -67,6 +67,11 @@ const mockMemberSendMessage = jest.fn();
67
67
  const mockMemberBook = jest.fn();
68
68
  jest.mock("@working-theory/api-client", () => ({
69
69
  __esModule: true,
70
+ // #8765 D7 — no agreement outstanding, so the Inbox loads.
71
+ consent: {
72
+ accountAgreements: () =>
73
+ Promise.resolve({ ok: true, body: { agreements: [], complete: true } }),
74
+ },
70
75
  waitlistSignups: { me: jest.fn() },
71
76
  subscription: { me: (...args: unknown[]) => mockNotFound(...args) },
72
77
  member: {
@@ -8,6 +8,8 @@ import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
 
10
10
  import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
11
+ import { consentStatementText } from "@working-theory/ui/blocks";
12
+ import { ACCOUNT_TERMS_STATEMENT } from "@working-theory/validation";
11
13
 
12
14
  jest.mock("next-intl/server", () => {
13
15
  const msgs: unknown = require("../../../../../messages/en.json");
@@ -121,9 +123,9 @@ function agreementPurpose(overrides: Partial<{ id: string; state: unknown }> = {
121
123
  required: true,
122
124
  signUp: true,
123
125
  description: "Account terms.",
124
- statement: "You agree to our Terms and Privacy Policy.",
125
- policyHref: "/terms",
126
- policyVersion: 1,
126
+ statement: ACCOUNT_TERMS_STATEMENT,
127
+ policyHref: "/working-agreement",
128
+ policyVersion: 2,
127
129
  state: overrides.state === undefined ? null : overrides.state,
128
130
  effective: false,
129
131
  };
@@ -274,14 +276,14 @@ describe("AC-4 — account-privacy-agreements (read-only)", () => {
274
276
  ok({
275
277
  purposes: [
276
278
  agreementPurpose({
277
- state: { granted: true, at: "2026-10-01T12:00:00.000Z", source: "sign_up", policyVersion: 1 },
279
+ state: { granted: true, at: "2026-10-01T12:00:00.000Z", source: "sign_up", policyVersion: 2 },
278
280
  }),
279
281
  ],
280
282
  }),
281
283
  );
282
284
  await renderPage();
283
285
  const row = screen.getByTestId("account-privacy-agreement-account_terms");
284
- expect(within(row).getByText("Version 1")).toBeInTheDocument();
286
+ expect(within(row).getByText("Version 2")).toBeInTheDocument();
285
287
  expect(within(row).getByText("Accepted Oct 1, 2026")).toBeInTheDocument();
286
288
  expect(within(row).queryByRole("switch")).not.toBeInTheDocument();
287
289
  expect(within(row).queryByRole("button")).not.toBeInTheDocument();
@@ -292,6 +294,43 @@ describe("AC-4 — account-privacy-agreements (read-only)", () => {
292
294
  await renderPage();
293
295
  expect(screen.getByTestId("account-privacy-agreement-account_terms-accepted")).toHaveTextContent("None yet");
294
296
  });
297
+
298
+ // #8765 D11 / AC-5 (#8761) — the statement renders with its three links.
299
+ it.each(["en", "fr"])(
300
+ "%s: the statement is the Contact sentence with no markdown, linking Terms, Privacy Policy and Working Agreement",
301
+ async (locale) => {
302
+ mockConsents.mockResolvedValue(ok({ purposes: [agreementPurpose()] }));
303
+ await renderPage(locale);
304
+ const statement = screen.getByTestId("account-privacy-agreement-account_terms-statement");
305
+ expect(statement.textContent).toBe(consentStatementText(ACCOUNT_TERMS_STATEMENT));
306
+ expect(statement.textContent).not.toContain("[");
307
+ expect(statement.textContent).not.toContain("](");
308
+ const prefix = locale === "en" ? "" : `/${locale}`;
309
+ const links = (["terms", "privacy", "working_agreement"] as const).map((key) => {
310
+ const link = within(statement).getByTestId(`consent-policy-link-${key}`);
311
+ return [link.textContent, link.getAttribute("href")];
312
+ });
313
+ expect(links).toEqual([
314
+ ["Terms", `${prefix}/terms`],
315
+ ["Privacy Policy", `${prefix}/privacy`],
316
+ ["Working Agreement", `${prefix}/working-agreement`],
317
+ ]);
318
+ },
319
+ );
320
+
321
+ it("no retired card policy link renders, and the policy link is the summary's, to /privacy", async () => {
322
+ mockConsents.mockResolvedValue(ok({ purposes: [agreementPurpose()] }));
323
+ await renderPage();
324
+ expect(
325
+ document.querySelector('[data-testid^="account-agreements-prompt-"][data-testid$="-policy"]'),
326
+ ).toBeNull();
327
+ const policyLinks = screen.getAllByText("Read our privacy policy");
328
+ expect(policyLinks).toHaveLength(1);
329
+ expect(within(screen.getByTestId("account-privacy-summary")).getByTestId("account-privacy-policy")).toHaveAttribute(
330
+ "href",
331
+ "/privacy",
332
+ );
333
+ });
295
334
  });
296
335
 
297
336
  describe("AC-4 — a failed consents read", () => {
@@ -21,7 +21,8 @@
21
21
  * mismatch, a 5xx, a network error) leaves the cookie valid, so the frame
22
22
  * stays on the page and says so, never routing away with a live session.
23
23
  * Without an endpoint (a hub or a single site) nothing changes: Better Auth,
24
- * then home.
24
+ * then home. That logic is the exported `useAccountSignOut`, so the
25
+ * agreements interstitial's Sign out ends the session the same way (#8765 D7).
25
26
  *
26
27
  * #8231 D1, as amended by #8334 — the rail reads Inbox, a Library group
27
28
  * holding Artifacts, and the footer's ?. Settings is not a rail row: it lives
@@ -120,6 +121,71 @@ export interface AccountFrameProps {
120
121
  children: ReactNode;
121
122
  }
122
123
 
124
+ export interface AccountSignOutOptions {
125
+ locale: string;
126
+ /** The seam's `SIGN_OUT_ENDPOINT`; absent or `null`: Better Auth signs out (#8465). */
127
+ signOutEndpoint?: string | null | undefined;
128
+ /** Where a hub sign-out lands; defaults to the site home. */
129
+ signInHref?: string | undefined;
130
+ }
131
+
132
+ export interface AccountSignOut {
133
+ onSignOut: () => void;
134
+ /** True after a hub sign-out was refused or failed: the session is still live. */
135
+ signOutFailed: boolean;
136
+ }
137
+
138
+ /**
139
+ * The account area's Log out — the frame's, and the agreements
140
+ * interstitial's Sign out (#8765 D7), so both end the session the same way.
141
+ * With an endpoint (a spoke that signs in through its hub) it POSTs it and
142
+ * navigates to the sign-in page only on a 2xx; a refused or failed sign-out
143
+ * sets `signOutFailed` and navigates nowhere (#8465). Without one, Better
144
+ * Auth signs out, then home.
145
+ */
146
+ export function useAccountSignOut({
147
+ locale,
148
+ signOutEndpoint,
149
+ signInHref,
150
+ }: AccountSignOutOptions): AccountSignOut {
151
+ const router = useRouter();
152
+ const [signOutFailed, setSignOutFailed] = useState(false);
153
+ const homeHref = localeHref(locale, "/");
154
+
155
+ const onSignOut = (): void => {
156
+ void (async () => {
157
+ setSignOutFailed(false);
158
+ if (signOutEndpoint) {
159
+ // A refused or failed sign-out leaves the session cookie valid, so the
160
+ // page must not pretend otherwise by navigating away (#8465).
161
+ try {
162
+ const res = await fetch(signOutEndpoint, { method: "POST", credentials: "same-origin" });
163
+ if (!res.ok) {
164
+ setSignOutFailed(true);
165
+ return;
166
+ }
167
+ } catch {
168
+ setSignOutFailed(true);
169
+ return;
170
+ }
171
+ router.push(signInHref ?? homeHref);
172
+ router.refresh();
173
+ return;
174
+ }
175
+ try {
176
+ await authClient.signOut();
177
+ } catch {
178
+ // The refresh below re-reads the session either way: a sign-out that
179
+ // failed lands the person back on a page that still knows them.
180
+ }
181
+ router.push(homeHref);
182
+ router.refresh();
183
+ })();
184
+ };
185
+
186
+ return { onSignOut, signOutFailed };
187
+ }
188
+
123
189
  export function AccountFrame({
124
190
  locale,
125
191
  user,
@@ -131,12 +197,11 @@ export function AccountFrame({
131
197
  signInHref,
132
198
  children,
133
199
  }: AccountFrameProps): ReactElement {
134
- const router = useRouter();
135
200
  const pathname = usePathname();
136
201
  const searchParams = useSearchParams();
137
202
  const [dismissed] = useForYouDismissed(ACCOUNT_PERSIST_KEY);
138
203
  const [topBarActions, setTopBarActions] = useState<HTMLElement | null>(null);
139
- const [signOutFailed, setSignOutFailed] = useState(false);
204
+ const { onSignOut, signOutFailed } = useAccountSignOut({ locale, signOutEndpoint, signInHref });
140
205
 
141
206
  const homeHref = localeHref(locale, "/");
142
207
  const inboxHref = localeHref(locale, "/account");
@@ -162,37 +227,6 @@ export function AccountFrame({
162
227
  // #8233 D4 — the Inbox route's New lives in the top bar; nowhere else.
163
228
  const onInboxRoute = pathname === inboxHref;
164
229
 
165
- const onSignOut = (): void => {
166
- void (async () => {
167
- setSignOutFailed(false);
168
- if (signOutEndpoint) {
169
- // A refused or failed sign-out leaves the session cookie valid, so the
170
- // page must not pretend otherwise by navigating away (#8465).
171
- try {
172
- const res = await fetch(signOutEndpoint, { method: "POST", credentials: "same-origin" });
173
- if (!res.ok) {
174
- setSignOutFailed(true);
175
- return;
176
- }
177
- } catch {
178
- setSignOutFailed(true);
179
- return;
180
- }
181
- router.push(signInHref ?? homeHref);
182
- router.refresh();
183
- return;
184
- }
185
- try {
186
- await authClient.signOut();
187
- } catch {
188
- // The refresh below re-reads the session either way: a sign-out that
189
- // failed lands the person back on a page that still knows them.
190
- }
191
- router.push(homeHref);
192
- router.refresh();
193
- })();
194
- };
195
-
196
230
  const {
197
231
  home,
198
232
  inbox,
@@ -33,14 +33,6 @@
33
33
  * `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE any
34
34
  * try/catch (mirrors `checkout.actions.ts`'s idiom) so a catch never
35
35
  * swallows the navigation this action exists to perform.
36
- *
37
- * This file does NOT hold the carried sign-up-agreement consume step
38
- * (`acceptCarriedAgreementsAction`, #8438 D13) — that lives in this
39
- * directory's own `agreements-actions.ts` instead, deliberately kept apart
40
- * from this file's `billing-source.ts` import: that module pulls in
41
- * `account-data.ts`, `@working-theory/ui` and `lucide-react`, a far heavier
42
- * footprint than the small, self-contained cookie codec needs. See
43
- * `agreements-actions.ts`'s own header.
44
36
  */
45
37
 
46
38
  import { redirect } from "next/navigation";
@@ -1,170 +1,58 @@
1
1
  "use server";
2
2
 
3
3
  /**
4
- * account/agreements-actions.ts — the account home's carried
5
- * sign-up-agreement CONSUME step (#8438 D13, spec
6
- * docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
4
+ * account/agreements-actions.ts — the account interstitial's one write
5
+ * (#8765 D7, spec docs/specs/8765-sign-up-refuses-without-the-spec.md).
7
6
  *
8
- * `apps/web` never touches the database (the ledger writer is `apps/api`),
9
- * and Better Auth creates the user from the sign-up card before any Person
10
- * exists to record a grant against. So the card remembers which agreements
11
- * the person ticked, in a short-lived, signed, email-bound cookie — this
12
- * file's `acceptCarriedAgreementsAction`, called once on the account home's
13
- * first signed-in render (`agreements-prompt.tsx`), decodes it and writes
14
- * each missing agreement with `via: "sign_up"`, then deletes the cookie
15
- * either way (single-use).
7
+ * `acceptAccountAgreementsAction` posts the sign-up agreements the
8
+ * interstitial (`agreements-prompt.tsx`) showed to `POST /v1/consent/account`
9
+ * (`consent.acceptAccountAgreements`), with the caller's forwarded cookie —
10
+ * or, on a spoke that signs in through its hub, the hub credential
11
+ * `forwardedCookieInit` returns — so the grant is account-level: subject
12
+ * `user`, in the deployment's site workspace, read by the hub and every
13
+ * spoke alike (C3). The API appends only when no grant exists at the current
14
+ * version, so a double click adds nothing.
16
15
  *
17
- * The MINT side, `rememberSignUpAgreementsAction`, lives in
18
- * `(auth)/sign-up/sign-up.actions.ts` instead of here, deliberately:
19
- * `(auth)/**` is one of the sign-in SEEDS a sign-in-less spoke drops
20
- * (`SPOKE_REMOVAL_SEEDS`, `packages/create-ailk/src/topology.ts` D4), and
21
- * `agreements-prompt.tsx` — this directory's own file, not a sign-in path —
22
- * must never reach into a dropped seed, or the scaffolder's own
23
- * dangling-import check (`spokeExclusions`) refuses the spoke build. So the
24
- * two sides duplicate the small cookie codec (encode there, decode here)
25
- * rather than share a module, precisely so each side's dependencies stay
26
- * native to where it lives.
16
+ * It answers `{ ok }` only; the interstitial refreshes on `ok` and the
17
+ * layout's next read decides whether the account loads. A refusal or a throw
18
+ * is `{ ok: false }`, never a throw, so the interstitial shows its error line.
27
19
  *
28
20
  * Kept as its OWN file, separate from this directory's `actions.ts`
29
21
  * (Billing's `openBillingPortal`): that file imports `billing-source.ts`,
30
- * which pulls in `account-data.ts`, `@working-theory/ui` and
31
- * `lucide-react` — a far heavier footprint than this small, self-contained
32
- * cookie codec needs. Keeping it apart means a test (or a bundler) that
33
- * reaches this file touches nothing from that tree.
34
- *
35
- * Cookie value: `<id>@<version>,…|<issuedAtMs>|<sha256(lowercased email)>|
36
- * <HMAC-SHA256 over the rest>`, keyed by `BETTER_AUTH_SECRET` — the same
37
- * dev fallback `apps/web/auth.ts`'s own `resolveSecret()` publishes when it
38
- * is unset. That secret is read directly HERE rather than importing
39
- * `resolveSecret` from `auth.ts`: that module statically imports the
40
- * ESM-only `better-auth` package, which every broad page-level test suite
41
- * that renders the account area would then have to mock. Verified with a
42
- * constant-time comparison; never trusted without it.
22
+ * which pulls in `account-data.ts`, `@working-theory/ui` and `lucide-react`.
43
23
  */
44
24
 
45
- import { createHash, createHmac, timingSafeEqual } from "node:crypto";
46
-
47
- import { member } from "@working-theory/api-client";
48
- import { cookies, headers } from "next/headers";
25
+ import { consent } from "@working-theory/api-client";
26
+ import { getLocale } from "next-intl/server";
49
27
 
50
28
  import { forwardedCookieInit } from "../../../../lib/server-api";
51
- import { readSession } from "../../../../lib/topology.generated";
52
-
53
- const SECURE_COOKIE_NAME = "__Host-ailk-signup-agreements";
54
- const DEV_COOKIE_NAME = "ailk-signup-agreements";
55
- const MAX_AGE_MS = 60 * 60 * 1000;
56
-
57
- /** Mirrors `sign-up.actions.ts`'s own copy — see that file's header. */
58
- const DEV_FALLBACK_SECRET =
59
- "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
60
-
61
- /** Mirrors `sign-up.actions.ts`'s own copy exactly — see that file's header. */
62
- function signUpAgreementsSecret(): string {
63
- const configured = process.env.BETTER_AUTH_SECRET;
64
- if (configured) return configured;
65
- const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
66
- if (process.env.NODE_ENV === "production" && !isBuildPhase) {
67
- throw new Error(
68
- "BETTER_AUTH_SECRET is required in production: it verifies the carried sign-up-agreement cookie.",
69
- );
70
- }
71
- return DEV_FALLBACK_SECRET;
72
- }
73
-
74
- function cookieName(): string {
75
- return process.env.NODE_ENV === "production" ? SECURE_COOKIE_NAME : DEV_COOKIE_NAME;
76
- }
77
-
78
- function emailHash(email: string): string {
79
- return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
80
- }
81
-
82
- function sign(payload: string): string {
83
- return createHmac("sha256", signUpAgreementsSecret()).update(payload).digest("hex");
84
- }
85
-
86
- /** Constant-time string equality — never a `===` on attacker-reachable input. */
87
- function safeEqual(a: string, b: string): boolean {
88
- const bufA = Buffer.from(a);
89
- const bufB = Buffer.from(b);
90
- return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
91
- }
92
29
 
93
- interface CarriedPurpose {
94
- id: string;
95
- version: number;
30
+ /** PII-safe: the status or the error's name, never the ids' owner. */
31
+ function logAcceptFailed(reason: string): void {
32
+ console.error("[account]", {
33
+ event: "account-agreements",
34
+ outcome: "accept_failed",
35
+ reason,
36
+ });
96
37
  }
97
38
 
98
- interface DecodedCarriedAgreements {
99
- purposes: CarriedPurpose[];
100
- emailHash: string;
39
+ export interface AcceptAccountAgreementsResult {
40
+ ok: boolean;
101
41
  }
102
42
 
103
- /** Verifies the HMAC and the one-hour window; `null` on any failure. */
104
- function decodeCarriedAgreements(value: string): DecodedCarriedAgreements | null {
105
- const parts = value.split("|");
106
- if (parts.length !== 4) return null;
107
- const [purposesPart, issuedAtPart, emailHashPart, signature] = parts as [
108
- string,
109
- string,
110
- string,
111
- string,
112
- ];
113
- if (!safeEqual(sign(`${purposesPart}|${issuedAtPart}|${emailHashPart}`), signature)) {
114
- return null;
115
- }
116
-
117
- const issuedAtMs = Number(issuedAtPart);
118
- if (!Number.isFinite(issuedAtMs) || Date.now() - issuedAtMs > MAX_AGE_MS) return null;
119
-
120
- const purposes: CarriedPurpose[] = [];
121
- for (const entry of purposesPart.split(",")) {
122
- const [id, versionStr] = entry.split("@");
123
- const version = Number(versionStr);
124
- if (!id || !Number.isFinite(version)) return null;
125
- purposes.push({ id, version });
43
+ export async function acceptAccountAgreementsAction(
44
+ purposeIds: string[],
45
+ ): Promise<AcceptAccountAgreementsResult> {
46
+ try {
47
+ const locale = await getLocale();
48
+ const res = await consent.acceptAccountAgreements(
49
+ { purposeIds, locale },
50
+ await forwardedCookieInit(),
51
+ );
52
+ if (!res.ok) logAcceptFailed(String(res.status));
53
+ return { ok: res.ok };
54
+ } catch (err) {
55
+ logAcceptFailed(err instanceof Error ? err.name : "unknown");
56
+ return { ok: false };
126
57
  }
127
- if (purposes.length === 0) return null;
128
-
129
- return { purposes, emailHash: emailHashPart };
130
- }
131
-
132
- export interface AcceptCarriedAgreementsResult {
133
- accepted: boolean;
134
- }
135
-
136
- /**
137
- * Consumes the sign-up card's carried-agreement cookie — see the file
138
- * header for the mint/consume split. Writes every id in `missingPurposeIds`
139
- * with `via: "sign_up"` only when the cookie is valid, fresh, bound to the
140
- * signed-in (verified) session's own email, and covers every one of those
141
- * ids; otherwise writes nothing. The cookie is deleted either way once
142
- * found, so this never runs twice for the same cookie.
143
- */
144
- export async function acceptCarriedAgreementsAction(
145
- missingPurposeIds: string[],
146
- ): Promise<AcceptCarriedAgreementsResult> {
147
- const store = await cookies();
148
- const name = cookieName();
149
- const raw = store.get(name)?.value;
150
- if (!raw) return { accepted: false };
151
- store.delete(name);
152
-
153
- const decoded = decodeCarriedAgreements(raw);
154
- if (!decoded) return { accepted: false };
155
-
156
- const session = await readSession(await headers());
157
- if (!session || !session.user.emailVerified) return { accepted: false };
158
- if (decoded.emailHash !== emailHash(session.user.email)) return { accepted: false };
159
-
160
- const carriedIds = new Set(decoded.purposes.map((p) => p.id));
161
- if (!missingPurposeIds.every((id) => carriedIds.has(id))) return { accepted: false };
162
-
163
- const init = await forwardedCookieInit();
164
- const results = await Promise.all(
165
- missingPurposeIds.map((purposeId) =>
166
- member.setConsent({ purposeId, granted: true, via: "sign_up" }, init),
167
- ),
168
- );
169
- return { accepted: results.every((r) => r.ok) };
170
58
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The account area's sign-up agreement gate (#8765 D7, spec
3
+ * docs/specs/8765-sign-up-refuses-without-the-spec.md).
4
+ *
5
+ * `outstandingAgreements(ctx)` is the one read the layout and the Inbox
6
+ * page share: `GET /v1/consent/account` for the caller, `cache()`d per
7
+ * request (keyed on the context `accountSession`, itself cached, hands
8
+ * both). The layout shows the interstitial in place of the account while
9
+ * it answers anything; the Inbox page awaits it before any loader and
10
+ * renders nothing meanwhile, because the App Router renders a page beside
11
+ * its layout even when the layout never renders it — and the Inbox's
12
+ * `member.messages` read stamps `readAt`.
13
+ *
14
+ * `[]` when none is outstanding (the read answered `complete: true`, or an
15
+ * older hub answered 404). It fails closed (SEC-8765-C): any other answer —
16
+ * a thrown read, a 401, 403, 429 or 5xx — is the registry's own agreements
17
+ * from the same constant the API reads (#8438 C1), logged `read_failed`.
18
+ */
19
+
20
+ import { consent } from "@working-theory/api-client";
21
+ import type { AccountContext } from "@working-theory/templates/account";
22
+ import {
23
+ resolveConsentStatement,
24
+ SITE_CONSENT_CONFIG_DEFAULTS,
25
+ signUpAgreements,
26
+ } from "@working-theory/validation";
27
+ import { cache } from "react";
28
+
29
+ import type { AgreementsInterstitialAgreement } from "./agreements-prompt";
30
+
31
+ export const outstandingAgreements = cache(
32
+ async (ctx: AccountContext): Promise<AgreementsInterstitialAgreement[]> => {
33
+ try {
34
+ const res = await consent.accountAgreements(ctx.init, {
35
+ locale: ctx.locale,
36
+ });
37
+ if (res.ok) {
38
+ if (res.body.complete) return [];
39
+ return res.body.agreements
40
+ .filter((agreement) => !agreement.current)
41
+ .map(({ id, statement, acceptedVersion }) => ({
42
+ id,
43
+ statement,
44
+ acceptedVersion,
45
+ }));
46
+ }
47
+ if (res.status === 404) return [];
48
+ } catch {
49
+ // Falls through to the registry's own agreements below.
50
+ }
51
+ console.error("[account]", {
52
+ event: "account-agreements",
53
+ outcome: "read_failed",
54
+ });
55
+ return signUpAgreements(SITE_CONSENT_CONFIG_DEFAULTS).map((purpose) => ({
56
+ id: purpose.id,
57
+ statement: resolveConsentStatement(purpose, ctx.locale).statement,
58
+ acceptedVersion: null,
59
+ }));
60
+ },
61
+ );