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
@@ -53,7 +53,11 @@ let spokeA: SeededSpoke;
53
53
  let spokeB: SeededSpoke;
54
54
  let handlerRuns: string[];
55
55
 
56
- /** D1's 18 strings plus #8435's two contact routes and #8438's two consents routes (22), in the list's order. */
56
+ /**
57
+ * D1's 18 strings plus #8435's two contact routes, #8438's two consents
58
+ * routes, #8765's account-agreements pair and #8515's onboarding pair (26),
59
+ * in the list's order.
60
+ */
57
61
  const ADMITTED = [
58
62
  "GET /v1/hub-sign-in/me",
59
63
  "GET /v1/member/pieces",
@@ -77,6 +81,9 @@ const ADMITTED = [
77
81
  "POST /v1/member/deletion-request",
78
82
  "GET /v1/member/consents",
79
83
  "POST /v1/member/consents",
84
+ // #8765 D5 — the caller's account-level sign-up agreements.
85
+ "GET /v1/consent/account",
86
+ "POST /v1/consent/account",
80
87
  // #8515 D13 — the client's onboarding read and write.
81
88
  "GET /v1/member/onboarding",
82
89
  "POST /v1/member/onboarding",
@@ -86,6 +93,8 @@ const ADMITTED = [
86
93
  const OFF_LIST = [
87
94
  "GET /v1/consent/platform",
88
95
  "POST /v1/consent/platform",
96
+ // #8765 D2 — the anonymous sign-up evidence is never a credential's route.
97
+ "GET /v1/consent/sign-up",
89
98
  "GET /v1/member/other",
90
99
  "GET /v1/member/messages/:id",
91
100
  "GET /v1/waitlist-signups/me",
@@ -177,9 +186,9 @@ afterEach(async () => {
177
186
  });
178
187
 
179
188
  describe("HUB_SESSION_ROUTES", () => {
180
- it("is exactly the 24 entries, by method and registered URL — never a prefix (AC-1, #8515 AC-6)", () => {
189
+ it("is exactly the 26 entries, by method and registered URL — never a prefix (AC-1, #8515 AC-6, #8765 AC-6)", () => {
181
190
  expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
182
- expect(HUB_SESSION_ROUTES.length).toBe(24);
191
+ expect(HUB_SESSION_ROUTES.length).toBe(26);
183
192
  for (const route of HUB_SESSION_ROUTES) {
184
193
  expect({
185
194
  route,
@@ -222,6 +231,8 @@ describe("hub credential — admitted routes", () => {
222
231
 
223
232
  describe("hub credential — every other route is 404 with no handler run (AC-7)", () => {
224
233
  it.each([
234
+ ["GET", "/v1/consent/sign-up"],
235
+ ["GET", "/v1/consent/platform"],
225
236
  ["GET", "/v1/member/other"],
226
237
  ["GET", "/v1/member/messages/lead_1"],
227
238
  ["GET", "/v1/waitlist-signups/me"],
@@ -22,6 +22,9 @@ import {
22
22
  buildTrustedKeyDigests,
23
23
  DEFAULT_PER_IP_PER_MINUTE_STRICT,
24
24
  resolveRateLimitIdentity,
25
+ siteKeyReader,
26
+ SITE_KEYED_ROUTES,
27
+ DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
25
28
  type RateLimitOptions,
26
29
  } from "../rate-limit.js";
27
30
 
@@ -845,6 +848,7 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
845
848
  { method: "POST", url: "/v1/project-listings" },
846
849
  { method: "PATCH", url: "/v1/project-listings/drafts/:id" },
847
850
  { method: "POST", url: "/v1/project-listings/drafts/:id/submit" },
851
+ { method: "GET", url: "/v1/deliverables/open/:slug" },
848
852
  ]);
849
853
  });
850
854
  });
@@ -1189,3 +1193,217 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
1189
1193
  });
1190
1194
  });
1191
1195
  });
1196
+
1197
+ describe("the sign-up evidence read is counted per site key (#8805)", () => {
1198
+ const SIGN_UP = "/v1/consent/sign-up";
1199
+ const WEB_EGRESS = "198.51.100.20";
1200
+ const VALID = "ailk_spk_valid-site-key";
1201
+ const SPOKE_ID = "spoke_01920000-0000-7000-8000-0000000000b1";
1202
+ const ENV_KEYS = [
1203
+ "AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_MINUTE",
1204
+ "AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE",
1205
+ ] as const;
1206
+ const priorEnv = new Map<string, string | undefined>(
1207
+ ENV_KEYS.map((name) => [name, process.env[name]]),
1208
+ );
1209
+
1210
+ let app: FastifyInstance | undefined;
1211
+ let verify: jest.Mock<Promise<string | null>, [unknown]>;
1212
+ let logLines: Array<Record<string, unknown>>;
1213
+
1214
+ function signUpApp(opts: Partial<RateLimitOptions> = {}): FastifyInstance {
1215
+ const instance = Fastify({
1216
+ trustProxy: true,
1217
+ logger: {
1218
+ level: "warn",
1219
+ stream: {
1220
+ write(msg: string) {
1221
+ logLines.push(JSON.parse(msg) as Record<string, unknown>);
1222
+ },
1223
+ },
1224
+ },
1225
+ });
1226
+ instance.register(rateLimitMiddleware, {
1227
+ perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1228
+ perKeyPerMinute: 25,
1229
+ scope: "signupevidence",
1230
+ siteKeyOf: siteKeyReader(verify),
1231
+ ...opts,
1232
+ });
1233
+ instance.get(SIGN_UP, async () => ({ ok: true }));
1234
+ instance.get("/v1/other", async () => ({ ok: true }));
1235
+ return instance;
1236
+ }
1237
+
1238
+ async function statuses(
1239
+ count: number,
1240
+ headers: Record<string, string>,
1241
+ url: string = SIGN_UP,
1242
+ ): Promise<number[]> {
1243
+ if (!app) throw new Error("no app");
1244
+ const codes: number[] = [];
1245
+ for (let i = 0; i < count; i++) {
1246
+ const res = await app.inject({ method: "GET", url, headers });
1247
+ codes.push(res.statusCode);
1248
+ }
1249
+ return codes;
1250
+ }
1251
+
1252
+ const keyed = (ip: string = WEB_EGRESS, key: string = VALID) => ({
1253
+ "x-forwarded-for": ip,
1254
+ "x-ailk-spoke-key": key,
1255
+ });
1256
+
1257
+ beforeEach(() => {
1258
+ __resetBuckets();
1259
+ logLines = [];
1260
+ for (const name of ENV_KEYS) delete process.env[name];
1261
+ verify = jest.fn((req: unknown) => {
1262
+ const headers = (req as { headers: Record<string, unknown> }).headers;
1263
+ return Promise.resolve(
1264
+ headers["x-ailk-spoke-key"] === VALID ? SPOKE_ID : null,
1265
+ );
1266
+ });
1267
+ });
1268
+
1269
+ afterEach(async () => {
1270
+ if (app) await app.close();
1271
+ app = undefined;
1272
+ });
1273
+
1274
+ afterAll(() => {
1275
+ for (const [name, value] of priorEnv) {
1276
+ if (value === undefined) delete process.env[name];
1277
+ else process.env[name] = value;
1278
+ }
1279
+ });
1280
+
1281
+ it("lists GET /v1/consent/sign-up as the one site-keyed route", () => {
1282
+ expect(SITE_KEYED_ROUTES).toEqual([{ method: "GET", url: SIGN_UP }]);
1283
+ });
1284
+
1285
+ it("names a per-key default well above the per-IP 10", () => {
1286
+ expect(DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE).toBe(120);
1287
+ });
1288
+
1289
+ it("admits keyed requests from one IP past 10 a minute, up to the key ceiling, then 429", async () => {
1290
+ app = signUpApp();
1291
+ await app.ready();
1292
+ const codes = await statuses(26, keyed());
1293
+ expect(codes.slice(0, 25).every((code) => code === 200)).toBe(true);
1294
+ expect(codes[25]).toBe(429);
1295
+ });
1296
+
1297
+ it("refuses the eleventh keyless request from one IP with 429", async () => {
1298
+ app = signUpApp();
1299
+ await app.ready();
1300
+ const codes = await statuses(11, { "x-forwarded-for": WEB_EGRESS });
1301
+ expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
1302
+ expect(codes[10]).toBe(429);
1303
+ expect(verify).not.toHaveBeenCalled();
1304
+ });
1305
+
1306
+ it("keeps an invalid key on the per-IP 10 a minute", async () => {
1307
+ app = signUpApp();
1308
+ await app.ready();
1309
+ const codes = await statuses(11, keyed(WEB_EGRESS, "ailk_spk_forged"));
1310
+ expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
1311
+ expect(codes[10]).toBe(429);
1312
+ });
1313
+
1314
+ it("an anonymous caller spoofing the web's X-Forwarded-For cannot spend the keyed budget", async () => {
1315
+ app = signUpApp();
1316
+ await app.ready();
1317
+ // The spoofer fills the per-IP bucket of the web's own egress address…
1318
+ const spoofed = await statuses(30, { "x-forwarded-for": WEB_EGRESS });
1319
+ expect(spoofed[10]).toBe(429);
1320
+ // …and with a forged key too.
1321
+ await statuses(30, keyed(WEB_EGRESS, "ailk_spk_forged"));
1322
+ // The keyed caller behind that address still has its whole budget.
1323
+ const codes = await statuses(25, keyed());
1324
+ expect(codes.every((code) => code === 200)).toBe(true);
1325
+ });
1326
+
1327
+ it("many web instances behind one egress share the key bucket, not an IP bucket", async () => {
1328
+ app = signUpApp();
1329
+ await app.ready();
1330
+ const codes: number[] = [];
1331
+ for (let i = 0; i < 26; i++) {
1332
+ codes.push(...(await statuses(1, keyed(`203.0.113.${i + 1}`))));
1333
+ }
1334
+ expect(codes.slice(0, 25).every((code) => code === 200)).toBe(true);
1335
+ expect(codes[25]).toBe(429);
1336
+ });
1337
+
1338
+ it("a verification failure counts the request per IP, never unlimited", async () => {
1339
+ verify.mockRejectedValue(new Error("db down: postgres://secret"));
1340
+ app = signUpApp();
1341
+ await app.ready();
1342
+ const codes = await statuses(11, keyed());
1343
+ expect(codes[10]).toBe(429);
1344
+ const line = logLines.find(
1345
+ (l) => l["event"] === "rate_limit_site_key_unverified",
1346
+ );
1347
+ expect(line).toBeDefined();
1348
+ expect(JSON.stringify(logLines)).not.toContain("postgres://secret");
1349
+ expect(JSON.stringify(logLines)).not.toContain(VALID);
1350
+ });
1351
+
1352
+ it("a lookup that outlasts its timeout counts the request per IP", async () => {
1353
+ verify.mockImplementation(() => new Promise<string | null>(() => undefined));
1354
+ app = signUpApp({ siteKeyOf: siteKeyReader(verify, 5) });
1355
+ await app.ready();
1356
+ const codes = await statuses(11, keyed());
1357
+ expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
1358
+ expect(codes[10]).toBe(429);
1359
+ expect(
1360
+ logLines.some((l) => l["event"] === "rate_limit_site_key_unverified"),
1361
+ ).toBe(true);
1362
+ });
1363
+
1364
+ it("ignores a key on any route but the sign-up evidence read", async () => {
1365
+ app = signUpApp();
1366
+ await app.ready();
1367
+ const codes = await statuses(11, keyed(), "/v1/other");
1368
+ expect(codes[10]).toBe(429);
1369
+ expect(verify).not.toHaveBeenCalled();
1370
+ });
1371
+
1372
+ it("verifies the key once per request, however many limiters read it", async () => {
1373
+ const reader = siteKeyReader(verify);
1374
+ app = signUpApp({ siteKeyOf: reader });
1375
+ // A second reader of the same request, as the server-wide limiter is.
1376
+ app.addHook("onRequest", async (req) => {
1377
+ await reader(req);
1378
+ });
1379
+ await app.ready();
1380
+ await statuses(3, keyed());
1381
+ expect(verify).toHaveBeenCalledTimes(3);
1382
+ });
1383
+
1384
+ it("reads AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE over the code value", async () => {
1385
+ process.env.AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE = "12";
1386
+ app = signUpApp();
1387
+ await app.ready();
1388
+ const codes = await statuses(13, keyed());
1389
+ expect(codes.slice(0, 12).every((code) => code === 200)).toBe(true);
1390
+ expect(codes[12]).toBe(429);
1391
+ });
1392
+
1393
+ it("logs a keyed 429 with bucket site and the spoke id, never the key", async () => {
1394
+ app = signUpApp({ perKeyPerMinute: 1 });
1395
+ await app.ready();
1396
+ await statuses(2, keyed());
1397
+ const line = logLines.find((l) => l["msg"] === "rate_limited");
1398
+ expect(line).toMatchObject({
1399
+ ip: `site,${SPOKE_ID}`,
1400
+ bucket: "site",
1401
+ });
1402
+ expect(JSON.stringify(logLines)).not.toContain(VALID);
1403
+ });
1404
+
1405
+ it("rejects siteKeyOf without a scope", async () => {
1406
+ app = signUpApp({ scope: undefined });
1407
+ await expect(app.ready()).rejects.toThrow(/scope/);
1408
+ });
1409
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @file write-budget.test.ts
3
+ * @description The Stripe connection write budget (#8907 D9, AC-12; contract
4
+ * K10) — `chargeWriteBudget` in middleware/rate-limit.ts.
5
+ *
6
+ * It counts every write against an hourly per-Organization and per-person
7
+ * bucket in its OWN database store — never the process default — so neither
8
+ * `AILK_RATE_LIMIT_STORE=memory` nor the per-minute sweep of the shared maps
9
+ * can reset an hourly bucket (SEC-8907-2). A store that rejects falls back to
10
+ * a module-private count the sweep never touches, capped at `MAX_BUCKETS`.
11
+ */
12
+ import Fastify from "fastify";
13
+
14
+ import type { RateLimitBucket, RateLimitStore } from "../../lib/rate-limit-store.js";
15
+
16
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary; the budget's store is replaced below, so no client is ever built.
17
+ jest.mock("../../lib/prisma.js", () => ({ prisma: {} }));
18
+
19
+ const mockCreateDatabaseStore = jest.fn();
20
+ // eslint-disable-next-line no-restricted-syntax -- the database store's constructor is the seam the case under test is about: the budget must build its own database store whatever AILK_RATE_LIMIT_STORE says
21
+ jest.mock("../../lib/rate-limit-store.js", () => ({
22
+ ...jest.requireActual("../../lib/rate-limit-store.js"),
23
+ createDatabaseRateLimitStore: (...args: unknown[]) => mockCreateDatabaseStore(...args),
24
+ }));
25
+
26
+ import {
27
+ __bucketCount,
28
+ __resetBuckets,
29
+ __setWriteBudgetStoreForTests,
30
+ __writeBudgetFallbackCount,
31
+ chargeWriteBudget,
32
+ MAX_BUCKETS,
33
+ rateLimitMiddleware,
34
+ STRIPE_CONNECTION_WRITES_PER_ORG_PER_HOUR,
35
+ STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR,
36
+ } from "../rate-limit.js";
37
+
38
+ const HOUR = 60 * 60 * 1000;
39
+ const T0 = 1_800_000_000_000;
40
+
41
+ /** A `hitKey` that counts per key and window, as the database store's upsert does. */
42
+ function countingStore(): RateLimitStore & { hitKey: jest.Mock; keys: Map<string, RateLimitBucket> } {
43
+ const keys = new Map<string, RateLimitBucket>();
44
+ const hitKey = jest.fn(async (key: string, now: number, windowMs: number) => {
45
+ let bucket = keys.get(key);
46
+ if (!bucket || now - bucket.windowStartMs >= windowMs) {
47
+ bucket = { count: 0, windowStartMs: now };
48
+ keys.set(key, bucket);
49
+ }
50
+ bucket.count += 1;
51
+ return { ...bucket };
52
+ });
53
+ return {
54
+ keys,
55
+ hitKey,
56
+ hit: jest.fn(),
57
+ hasOpenWindow: jest.fn(),
58
+ sweep: jest.fn(),
59
+ };
60
+ }
61
+
62
+ function rejectingStore(): RateLimitStore {
63
+ return {
64
+ hitKey: jest.fn().mockRejectedValue(new Error("db down")),
65
+ hit: jest.fn(),
66
+ hasOpenWindow: jest.fn(),
67
+ sweep: jest.fn(),
68
+ };
69
+ }
70
+
71
+ const charge = (key: string, now: number, limit = STRIPE_CONNECTION_WRITES_PER_ORG_PER_HOUR) =>
72
+ chargeWriteBudget({ scope: "stripeconn", charges: [{ key, limit }], windowMs: HOUR, now });
73
+
74
+ beforeEach(() => {
75
+ __resetBuckets();
76
+ mockCreateDatabaseStore.mockReset();
77
+ });
78
+
79
+ afterAll(() => __setWriteBudgetStoreForTests(undefined));
80
+
81
+ describe("chargeWriteBudget (#8907 D9)", () => {
82
+ it("answers ok ten times for one Organization, refuses the eleventh, and opens again an hour later", async () => {
83
+ __setWriteBudgetStoreForTests(countingStore());
84
+ for (let i = 0; i < 10; i++) {
85
+ expect(await charge("org,org_a", T0 + i * 1000)).toEqual({ ok: true });
86
+ }
87
+ const refused = await charge("org,org_a", T0 + 10_000);
88
+ expect(refused.ok).toBe(false);
89
+ if (refused.ok) throw new Error("expected a refusal");
90
+ expect(refused.retryAfterSeconds).toBeGreaterThanOrEqual(1);
91
+ expect(refused.retryAfterSeconds).toBeLessThanOrEqual(3600);
92
+ expect(await charge("org,org_a", T0 + HOUR)).toEqual({ ok: true });
93
+ });
94
+
95
+ it("counts a person's key independently, at its own limit", async () => {
96
+ __setWriteBudgetStoreForTests(countingStore());
97
+ for (let i = 0; i < 11; i++) await charge("org,org_a", T0 + i);
98
+ for (let i = 0; i < STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR; i++) {
99
+ expect(await charge("user,user_a", T0 + i, STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR)).toEqual({
100
+ ok: true,
101
+ });
102
+ }
103
+ expect((await charge("user,user_a", T0 + 30, STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR)).ok).toBe(
104
+ false,
105
+ );
106
+ });
107
+
108
+ it("refuses when any charge is over, and counts every charge every time", async () => {
109
+ const store = countingStore();
110
+ __setWriteBudgetStoreForTests(store);
111
+ const both = (now: number) =>
112
+ chargeWriteBudget({
113
+ scope: "stripeconn",
114
+ charges: [
115
+ { key: "org,org_a", limit: 10 },
116
+ { key: "user,user_a", limit: 20 },
117
+ ],
118
+ windowMs: HOUR,
119
+ now,
120
+ });
121
+ for (let i = 0; i < 10; i++) expect((await both(T0 + i)).ok).toBe(true);
122
+ expect((await both(T0 + 10)).ok).toBe(false);
123
+ expect(store.keys.get("writebudget:stripeconn|org,org_a")?.count).toBe(11);
124
+ expect(store.keys.get("writebudget:stripeconn|user,user_a")?.count).toBe(11);
125
+ });
126
+
127
+ it("is not reset by the middleware's per-minute sweep of its shared maps", async () => {
128
+ // A rejecting store sends every count to the fallback, the one state a
129
+ // shared-map sweep could reach if the budget lived there.
130
+ __setWriteBudgetStoreForTests(rejectingStore());
131
+ const past = Date.now() - 30 * 60 * 1000;
132
+ for (let i = 0; i < 10; i++) expect((await charge("org,org_b", past)).ok).toBe(true);
133
+
134
+ const app = Fastify();
135
+ await app.register(rateLimitMiddleware, { windowMs: 60_000, perIpPerMinute: 1_000 });
136
+ app.get("/ping", async () => ({ ok: true }));
137
+ await app.ready();
138
+ // 101 requests reach the interval sweep (`evictStaleBuckets`) at least once.
139
+ for (let i = 0; i < 101; i++) await app.inject({ method: "GET", url: "/ping" });
140
+ await app.close();
141
+
142
+ expect((await charge("org,org_b", past)).ok).toBe(false);
143
+ });
144
+
145
+ it("falls back to a private count capped at MAX_BUCKETS when the store rejects", async () => {
146
+ __setWriteBudgetStoreForTests(rejectingStore());
147
+ for (let i = 0; i < MAX_BUCKETS + 5; i++) await charge(`org,org_${i}`, T0);
148
+ expect(__writeBudgetFallbackCount()).toBe(MAX_BUCKETS);
149
+ // The newest entry survived the cap, the oldest did not.
150
+ for (let i = 0; i < 10; i++) await charge(`org,org_${MAX_BUCKETS + 4}`, T0);
151
+ expect((await charge(`org,org_${MAX_BUCKETS + 4}`, T0)).ok).toBe(false);
152
+ expect((await charge("org,org_0", T0)).ok).toBe(true);
153
+ });
154
+
155
+ it("uses its own database store even with AILK_RATE_LIMIT_STORE=memory, and never the shared maps", async () => {
156
+ const before = { DATABASE_URL: process.env.DATABASE_URL, STORE: process.env.AILK_RATE_LIMIT_STORE };
157
+ process.env.DATABASE_URL = "postgres://budget.invalid/db";
158
+ process.env.AILK_RATE_LIMIT_STORE = "memory";
159
+ try {
160
+ const store = countingStore();
161
+ mockCreateDatabaseStore.mockReturnValue(store);
162
+ __setWriteBudgetStoreForTests(undefined);
163
+ expect((await charge("org,org_c", T0)).ok).toBe(true);
164
+ expect(mockCreateDatabaseStore).toHaveBeenCalledTimes(1);
165
+ expect(store.hitKey).toHaveBeenCalledWith("writebudget:stripeconn|org,org_c", T0, HOUR);
166
+ expect(__bucketCount()).toBe(0);
167
+ } finally {
168
+ process.env.DATABASE_URL = before.DATABASE_URL;
169
+ process.env.AILK_RATE_LIMIT_STORE = before.STORE;
170
+ if (before.DATABASE_URL === undefined) delete process.env.DATABASE_URL;
171
+ if (before.STORE === undefined) delete process.env.AILK_RATE_LIMIT_STORE;
172
+ }
173
+ });
174
+ });
@@ -34,9 +34,10 @@
34
34
  * spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
35
35
  * unexpired, its client enabled and its person's email verified, and only
36
36
  * on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
37
- * pairs — the credential's own `me` and the member routes, 24 in all
38
- * (#8220, #8515); any other route is 404 (never 403, so the account area's
39
- * `read()` loads "nothing for this caller"). It acts as its person
37
+ * pairs — the credential's own `me`, the member routes and the
38
+ * account-agreements pair, 26 in all (#8220, #8515, #8765); any other
39
+ * route is 404 (never 403, so the account area's `read()` loads
40
+ * "nothing for this caller"). It acts as its person
40
41
  * (`request.userId`) and carries `request.hubSession` — never
41
42
  * `request.sessionTenant` — and its request logger names the spoke
42
43
  * (`spokeId`). Each person is limited, at each spoke, to 60 requests and
@@ -153,12 +154,14 @@ export type HubSession = {
153
154
  * The only routes a hub-issued credential reaches (#7894 D6, #8220 D1): an
154
155
  * EXACT list of method + registered URL, never a prefix and never computed,
155
156
  * so a route added later joins only by a reviewed edit of this list
156
- * (SEC-7894-3). Five groups, in this order: the credential's own `me` and
157
+ * (SEC-7894-3). Six groups, in this order: the credential's own `me` and
157
158
  * #7877's three member reads; #8169's eight member reads; #8170's six member
158
159
  * writes (roadmap §7.2 row order); #8438's member consents read and write;
159
- * #8515's onboarding read and write — 24 in all.
160
+ * #8765's account-agreements read and write; #8515's onboarding read and
161
+ * write — 26 in all.
160
162
  * `GET /v1/consent/platform` is not admitted — it is the Admin owner's own
161
- * consent to Working Theory, on the hub's cookie session. The member profile write IS admitted — it
163
+ * consent to Working Theory, on the hub's cookie session. Nor is the
164
+ * anonymous `GET /v1/consent/sign-up` (#8765 D2): it takes no credential. The member profile write IS admitted — it
162
165
  * is the person's own account, which roadmap D9 saves through it. Not
163
166
  * admitted, deliberately: `GET /v1/waitlist-signups/me`,
164
167
  * `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
@@ -191,6 +194,10 @@ export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
191
194
  // admitted: those are the Admin owner's, on the hub's own cookie session.
192
195
  "GET /v1/member/consents",
193
196
  "POST /v1/member/consents",
197
+ // #8765 D5 — the caller's account-level sign-up agreements, so a spoke's
198
+ // account layout reads and accepts the one grant every site shares.
199
+ "GET /v1/consent/account",
200
+ "POST /v1/consent/account",
194
201
  // #8515 D13 — the client's onboarding read and write.
195
202
  "GET /v1/member/onboarding",
196
203
  "POST /v1/member/onboarding",