create-ailk 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/component-catalog.json +186 -8
  2. package/component-catalog.md +9 -7
  3. package/dist/add-module.js +20 -14
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +12 -3
  8. package/dist/derive-architecture.js +20 -8
  9. package/dist/design-package.js +7 -2
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +5 -5
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  15. package/dist/lib/scaffold-render-sweep.js +5 -8
  16. package/dist/module-architecture.js +99 -9
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/programmatic.js +19 -5
  20. package/dist/seed-flows.d.ts +4 -1
  21. package/dist/seed-flows.js +50 -4
  22. package/dist/site-chrome.d.ts +8 -0
  23. package/dist/site-chrome.js +17 -1
  24. package/dist/spoke-checkout.d.ts +65 -41
  25. package/dist/spoke-checkout.js +208 -430
  26. package/dist/starter-grid.d.ts +0 -3
  27. package/dist/starter-grid.js +24 -14
  28. package/dist/surfaces.js +52 -26
  29. package/dist/sync-routes.d.ts +38 -1
  30. package/dist/sync-routes.js +187 -13
  31. package/dist/template-record.d.ts +4 -3
  32. package/dist/template-record.js +8 -4
  33. package/dist/topology.js +25 -1
  34. package/dist/update.js +15 -0
  35. package/package.json +3 -3
  36. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  37. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  38. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  39. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  41. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  43. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  48. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  49. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  50. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  51. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  52. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  53. package/templates/.env.example +9 -7
  54. package/templates/.github/workflows/ci.yml +13 -0
  55. package/templates/README.md +1 -0
  56. package/templates/_gitignore +4 -0
  57. package/templates/apps/api/.env.example +27 -0
  58. package/templates/apps/api/CLAUDE.md +87 -17
  59. package/templates/apps/api/package.json +1 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  61. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  62. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  63. package/templates/apps/api/src/config/index.ts +109 -0
  64. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  65. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  66. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  67. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  68. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  69. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  70. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  71. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  72. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  73. package/templates/apps/api/src/lib/stripe.ts +74 -4
  74. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  75. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  77. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  78. package/templates/apps/api/src/middleware/auth.ts +13 -6
  79. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  80. package/templates/apps/api/src/openapi/spec.ts +33 -3
  81. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  82. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  83. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  84. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  85. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  86. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  87. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  88. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  89. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  90. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  91. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  92. package/templates/apps/api/src/routes/consent/account.ts +268 -0
  93. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  94. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  95. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  96. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  97. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  98. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  99. package/templates/apps/api/src/routes/content/index.ts +16 -2
  100. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  101. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  102. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  103. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  104. package/templates/apps/api/src/routes/leads/index.ts +164 -7
  105. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  106. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  111. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  112. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  113. package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
  114. package/templates/apps/api/src/server.ts +163 -46
  115. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  116. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  117. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  118. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
  119. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  120. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  121. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  122. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  123. package/templates/apps/api/src/services/consent.ts +95 -0
  124. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  125. package/templates/apps/api/src/services/entitlements.ts +157 -1
  126. package/templates/apps/api/src/services/lead-routing.ts +431 -47
  127. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  128. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  129. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  130. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  131. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  132. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  133. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  134. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  135. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  136. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  137. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  138. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  139. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  140. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  141. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  142. package/templates/apps/api/src/services/person.ts +164 -17
  143. package/templates/apps/api/src/services/plan-offerings.ts +125 -0
  144. package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
  145. package/templates/apps/mcp/CLAUDE.md +22 -14
  146. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  147. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  148. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  149. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  150. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  151. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  152. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  153. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  154. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  155. package/templates/apps/mcp/src/server.ts +5 -0
  156. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  157. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  158. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  159. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  160. package/templates/apps/web/.env.example +24 -31
  161. package/templates/apps/web/CLAUDE.md +393 -9
  162. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  163. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  164. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  165. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  166. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  167. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  168. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  169. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  170. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  171. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  172. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  173. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  174. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  176. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  177. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  178. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  179. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  183. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  184. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  185. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  186. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  187. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  188. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  189. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  190. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  191. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  192. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  193. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  194. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  195. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  196. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
  197. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
  198. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
  199. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
  200. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  201. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  202. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
  203. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
  204. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
  205. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  206. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  207. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  208. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  209. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  210. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  211. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  212. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  213. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  214. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
  215. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
  216. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  217. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
  218. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  219. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
  220. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  221. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  222. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
  223. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  224. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  225. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  226. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  227. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  228. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  229. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  230. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  231. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  232. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  233. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  234. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  235. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  236. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  237. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  238. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  239. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  240. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  241. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  242. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  243. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  244. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  245. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  246. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  247. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  248. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  249. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  250. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  251. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  252. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  253. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  254. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  255. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  256. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  257. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  258. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  259. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  260. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  261. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  262. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  263. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  264. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  265. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  266. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  267. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  268. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  269. package/templates/apps/web/auth.ts +28 -2
  270. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  271. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  272. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  273. package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
  274. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  275. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  276. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  277. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  278. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
  279. package/templates/apps/web/lib/account-invite.ts +37 -0
  280. package/templates/apps/web/lib/auth-routes.ts +36 -2
  281. package/templates/apps/web/lib/checkout-config.ts +16 -0
  282. package/templates/apps/web/lib/consent-policies.ts +22 -0
  283. package/templates/apps/web/lib/deck-page.ts +7 -0
  284. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  285. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  286. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  287. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  288. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  289. package/templates/apps/web/lib/modules.generated.ts +9 -0
  290. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  291. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  292. package/templates/apps/web/lib/posting-title.ts +17 -0
  293. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  294. package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
  295. package/templates/apps/web/lib/site-chrome.ts +2 -0
  296. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  297. package/templates/apps/web/messages/en.json +71 -20
  298. package/templates/apps/web/middleware.ts +12 -2
  299. package/templates/apps/web/package.json +1 -0
  300. package/templates/apps/web/security-headers.config.ts +17 -0
  301. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  302. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  303. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  304. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  305. package/templates/content/en/BlogList/blog.mdx +4 -71
  306. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  307. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  308. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  309. package/templates/content/en/BookCall/start-project.mdx +1 -17
  310. package/templates/content/en/ContactPage/contact.mdx +6 -63
  311. package/templates/content/en/HomePage/home.mdx +3 -21
  312. package/templates/database/CHANGELOG.md +69 -0
  313. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  314. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  315. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  316. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  317. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  318. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  319. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  320. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  321. package/templates/database/__tests__/seed.test.ts +64 -1
  322. package/templates/database/auth/schema.prisma +4 -0
  323. package/templates/database/config/schema.prisma +91 -0
  324. package/templates/database/inbox/schema.prisma +17 -2
  325. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  326. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  327. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  328. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  329. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  330. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  331. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  332. package/templates/database/package.json +1 -1
  333. package/templates/database/seed.ts +62 -3
  334. package/versions.json +35 -35
  335. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  336. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
  337. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  338. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  339. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  340. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  341. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  342. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,386 @@
1
+ /**
2
+ * @file account.test.ts
3
+ * @description The sign-up agreements' evidence route and the account-level
4
+ * reading (#8765 D2, D5; AC-2, AC-3 and AC-4, route parts).
5
+ *
6
+ * External dependency boundaries mocked:
7
+ * - @working-theory/database (via moduleNameMapper -> prisma-client.js mock;
8
+ * prisma singleton via __mocks__/prisma.ts), driven by the in-memory
9
+ * ledger double
10
+ * - `resolveSiteWorkspaceId` — the seam to the deployment's site workspace
11
+ *
12
+ * The real `services/consent.ts` writer and `lib/consent-store.ts` reader run
13
+ * over the ledger double. The auth preHandler is replaced by a hook that sets
14
+ * the fields it sets (`userId`, `sessionTenant`, `hubSession`), so user `u1`'s
15
+ * cookie session, spoke `S1`'s hub credential for `u1` and a personal token
16
+ * are each one line.
17
+ */
18
+
19
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
20
+ jest.mock("../../../lib/prisma.js");
21
+
22
+ const mockResolveSiteWorkspaceId = jest.fn();
23
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
24
+ jest.mock("../../../lib/site.js", () => ({
25
+ resolveSiteWorkspaceId: () => mockResolveSiteWorkspaceId(),
26
+ }));
27
+
28
+ import { createHash } from "node:crypto";
29
+
30
+ import {
31
+ ACCOUNT_TERMS_STATEMENT,
32
+ SITE_CONSENT_CONFIG_DEFAULTS,
33
+ siteConsentConfigSchema,
34
+ } from "@working-theory/validation";
35
+ import Fastify from "fastify";
36
+ import type { FastifyInstance } from "fastify";
37
+
38
+ import {
39
+ armConsentLedger,
40
+ type ConsentLedgerDouble,
41
+ } from "../../../lib/__tests__/consent-ledger-double.js";
42
+ import { prisma } from "../../../lib/prisma.js";
43
+ import * as consentService from "../../../services/consent.js";
44
+ import { consentAccountRoutes, consentSignUpRoute } from "../index.js";
45
+
46
+ /** The deployment's own site workspace. */
47
+ const W = "wksp_01920000-0000-7000-8000-0000000000a1";
48
+ /** Spoke S1's own workspace — never where an account-level row is written. */
49
+ const S1_WS = "wksp_01920000-0000-7000-8000-0000000000b1";
50
+ const S1 = "spoke_01920000-0000-7000-8000-0000000000b1";
51
+ const U1 = "user_01920000-0000-7000-8000-0000000000a1";
52
+
53
+ const sha256 = (text: string) =>
54
+ createHash("sha256").update(text).digest("hex");
55
+
56
+ type Caller = "cookie" | "hub" | "token" | "none";
57
+
58
+ let double: ConsentLedgerDouble;
59
+ let app: FastifyInstance;
60
+
61
+ async function build(caller: Caller): Promise<FastifyInstance> {
62
+ const instance = Fastify();
63
+ instance.decorateRequest("userId", undefined);
64
+ instance.decorateRequest("sessionTenant", undefined);
65
+ instance.decorateRequest("hubSession", undefined);
66
+ instance.addHook("onRequest", async (request) => {
67
+ // eslint-disable-next-line no-restricted-syntax -- the test stands in for the auth preHandler, which assigns decorated fields the bare Fastify type does not declare
68
+ const c = request as unknown as Record<string, unknown>;
69
+ if (caller === "none") return;
70
+ c["userId"] = U1;
71
+ if (caller === "cookie") c["sessionTenant"] = { sessionRowId: "s" };
72
+ if (caller === "hub") c["hubSession"] = { spokeId: S1, workspaceId: S1_WS };
73
+ });
74
+ await instance.register(consentSignUpRoute);
75
+ await instance.register(consentAccountRoutes);
76
+ await instance.ready();
77
+ return instance;
78
+ }
79
+
80
+ /** The registry with `account_terms` moved to `version`, everything else the default. */
81
+ function registryAt(version: number) {
82
+ return siteConsentConfigSchema.parse({
83
+ ...SITE_CONSENT_CONFIG_DEFAULTS,
84
+ purposes: SITE_CONSENT_CONFIG_DEFAULTS.purposes.map((p) =>
85
+ p.id === "account_terms" ? { ...p, policyVersion: version } : p,
86
+ ),
87
+ });
88
+ }
89
+
90
+ const getAccount = (query = "") =>
91
+ app.inject({ method: "GET", url: `/v1/consent/account${query}` });
92
+ const postAccount = (payload: unknown) =>
93
+ app.inject({
94
+ method: "POST",
95
+ url: "/v1/consent/account",
96
+ payload: payload as object,
97
+ });
98
+
99
+ beforeEach(() => {
100
+ jest.restoreAllMocks();
101
+ jest.clearAllMocks();
102
+ double = armConsentLedger(prisma);
103
+ mockResolveSiteWorkspaceId.mockResolvedValue(W);
104
+ });
105
+
106
+ afterEach(async () => {
107
+ await app?.close();
108
+ });
109
+
110
+ describe("GET /v1/consent/sign-up (D2)", () => {
111
+ const get = (query = "") =>
112
+ app.inject({ method: "GET", url: `/v1/consent/sign-up${query}` });
113
+
114
+ beforeEach(async () => {
115
+ app = await build("none");
116
+ });
117
+
118
+ it("answers anonymously with W and one account_terms entry at version 2, its statement and its sha256", async () => {
119
+ const res = await get();
120
+ expect(res.statusCode).toBe(200);
121
+ expect(res.headers["cache-control"]).toBe("no-store");
122
+ expect(res.json()).toEqual({
123
+ ok: true,
124
+ data: {
125
+ workspaceId: W,
126
+ agreements: [
127
+ {
128
+ purposeId: "account_terms",
129
+ policyVersion: 2,
130
+ locale: "en",
131
+ statement: ACCOUNT_TERMS_STATEMENT,
132
+ statementHash: sha256(ACCOUNT_TERMS_STATEMENT),
133
+ },
134
+ ],
135
+ },
136
+ });
137
+ });
138
+
139
+ it("reads the default locale en, and records the locale the statement was read in", async () => {
140
+ const plain = (await get()).json().data.agreements[0];
141
+ const fr = (await get("?locale=fr")).json().data.agreements[0];
142
+ expect(plain.locale).toBe("en");
143
+ // The registry declares only English, so a French reader read English.
144
+ expect(fr).toEqual(plain);
145
+ });
146
+
147
+ it("is the evidence the writer stamps: the hash of the statement a grant records", async () => {
148
+ const evidence = (await get()).json().data.agreements[0];
149
+ await app.close();
150
+ app = await build("cookie");
151
+ await postAccount({ purposeIds: ["account_terms"] });
152
+ expect(double.ledger[0]?.statement).toBe(evidence.statement);
153
+ expect(
154
+ consentService.consentStatementHash(double.ledger[0]!.statement),
155
+ ).toBe(evidence.statementHash);
156
+ });
157
+
158
+ it("refuses a malformed query with 400 invalid_input", async () => {
159
+ for (const query of ["?locale=not_a_locale", "?workspaceId=x"]) {
160
+ const res = await get(query);
161
+ expect(res.statusCode).toBe(400);
162
+ expect(res.json().error.code).toBe("invalid_input");
163
+ }
164
+ });
165
+
166
+ it("answers 503 tenant_unresolved, uncached, when the site workspace does not resolve", async () => {
167
+ mockResolveSiteWorkspaceId.mockResolvedValue(null);
168
+ const res = await get();
169
+ expect(res.statusCode).toBe(503);
170
+ expect(res.json().error.code).toBe("tenant_unresolved");
171
+ expect(res.headers["cache-control"]).toBe("no-store");
172
+ });
173
+ });
174
+
175
+ describe("GET/POST /v1/consent/account (D5) — AC-2", () => {
176
+ it("asks a user with no user rows: complete false, one entry never accepted", async () => {
177
+ app = await build("cookie");
178
+ const res = await getAccount();
179
+ expect(res.statusCode).toBe(200);
180
+ expect(res.json().data).toEqual({
181
+ complete: false,
182
+ agreements: [
183
+ {
184
+ id: "account_terms",
185
+ statement: ACCOUNT_TERMS_STATEMENT,
186
+ policyVersion: 2,
187
+ acceptedVersion: null,
188
+ acceptedAt: null,
189
+ current: false,
190
+ },
191
+ ],
192
+ });
193
+ });
194
+
195
+ it("POST writes one dated user row in W and answers complete; a second POST adds nothing", async () => {
196
+ app = await build("cookie");
197
+ const res = await postAccount({ purposeIds: ["account_terms"] });
198
+ expect(res.statusCode).toBe(200);
199
+ expect(res.json().data.complete).toBe(true);
200
+ expect(double.ledger).toHaveLength(1);
201
+ expect(double.ledger[0]).toMatchObject({
202
+ workspaceId: W,
203
+ subjectType: "user",
204
+ subjectId: U1,
205
+ personId: null,
206
+ purposeId: "account_terms",
207
+ granted: true,
208
+ source: "account_settings",
209
+ sourceRef: null,
210
+ policyVersion: 2,
211
+ statement: ACCOUNT_TERMS_STATEMENT,
212
+ });
213
+ expect(double.ledger[0]?.createdAt).toBeInstanceOf(Date);
214
+ const [entry] = res.json().data.agreements;
215
+ expect(entry).toMatchObject({
216
+ acceptedVersion: 2,
217
+ acceptedAt: double.ledger[0]!.createdAt.toISOString(),
218
+ current: true,
219
+ });
220
+
221
+ const again = await postAccount({ purposeIds: ["account_terms"] });
222
+ expect(again.statusCode).toBe(200);
223
+ expect(again.json().data.complete).toBe(true);
224
+ expect(double.ledger).toHaveLength(1);
225
+ });
226
+
227
+ it("the same POST from S1's credential writes in W, with sourceRef spoke:S1", async () => {
228
+ app = await build("hub");
229
+ const res = await postAccount({ purposeIds: ["account_terms"] });
230
+ expect(res.statusCode).toBe(200);
231
+ expect(double.ledger).toHaveLength(1);
232
+ expect(double.ledger[0]).toMatchObject({
233
+ workspaceId: W,
234
+ subjectType: "user",
235
+ subjectId: U1,
236
+ sourceRef: `spoke:${S1}`,
237
+ });
238
+ });
239
+
240
+ it("refuses a purpose that is not a sign-up agreement with 404 purpose_unknown, writing nothing — even beside a valid one", async () => {
241
+ app = await build("cookie");
242
+ for (const purposeIds of [
243
+ ["service_terms"],
244
+ ["account_terms", "service_terms"],
245
+ ["nope"],
246
+ ]) {
247
+ const res = await postAccount({ purposeIds });
248
+ expect(res.statusCode).toBe(404);
249
+ expect(res.json().error.code).toBe("purpose_unknown");
250
+ }
251
+ expect(double.ledger).toHaveLength(0);
252
+ });
253
+
254
+ it("refuses a malformed body with 400 invalid_input", async () => {
255
+ app = await build("cookie");
256
+ for (const body of [
257
+ {},
258
+ { purposeIds: [] },
259
+ { purposeIds: ["account_terms"], userId: U1 },
260
+ { purposeIds: ["account_terms"], locale: "not_a_locale" },
261
+ ]) {
262
+ const res = await postAccount(body);
263
+ expect(res.statusCode).toBe(400);
264
+ expect(res.json().error.code).toBe("invalid_input");
265
+ }
266
+ expect(double.ledger).toHaveLength(0);
267
+ });
268
+
269
+ it("refuses a personal token and an anonymous caller with 401, before any read", async () => {
270
+ for (const caller of ["token", "none"] as const) {
271
+ app = await build(caller);
272
+ expect((await getAccount()).statusCode).toBe(401);
273
+ expect(
274
+ (await postAccount({ purposeIds: ["account_terms"] })).statusCode,
275
+ ).toBe(401);
276
+ await app.close();
277
+ }
278
+ app = await build("cookie");
279
+ expect(prisma.$queryRaw).not.toHaveBeenCalled();
280
+ expect(double.ledger).toHaveLength(0);
281
+ });
282
+
283
+ it("answers 503 tenant_unresolved when the site workspace does not resolve", async () => {
284
+ mockResolveSiteWorkspaceId.mockResolvedValue(null);
285
+ app = await build("cookie");
286
+ expect((await getAccount()).statusCode).toBe(503);
287
+ const res = await postAccount({ purposeIds: ["account_terms"] });
288
+ expect(res.statusCode).toBe(503);
289
+ expect(res.json().error.code).toBe("tenant_unresolved");
290
+ expect(double.ledger).toHaveLength(0);
291
+ });
292
+
293
+ it("reads a newest row that is not a grant as never accepted", async () => {
294
+ app = await build("cookie");
295
+ await postAccount({ purposeIds: ["account_terms"] });
296
+ double.ledger.push({
297
+ ...double.ledger[0]!,
298
+ id: "cnsnt_00000000-0000-7000-8000-0000000000f1",
299
+ granted: false,
300
+ createdAt: new Date(Date.UTC(2026, 9, 13)),
301
+ });
302
+ expect((await getAccount()).json().data.agreements[0]).toMatchObject({
303
+ acceptedVersion: null,
304
+ acceptedAt: null,
305
+ current: false,
306
+ });
307
+ });
308
+
309
+ it("logs { event, outcome } only", async () => {
310
+ app = await build("cookie");
311
+ const lines: unknown[] = [];
312
+ const capture = ((obj: unknown) => {
313
+ lines.push(obj);
314
+ }) as never;
315
+ jest.spyOn(app.log, "info").mockImplementation(capture);
316
+ jest.spyOn(app.log, "warn").mockImplementation(capture);
317
+ await postAccount({ purposeIds: ["account_terms"] });
318
+ await postAccount({ purposeIds: ["service_terms"] });
319
+ const ours = lines.filter(
320
+ (l) => typeof l === "object" && l !== null && "event" in l,
321
+ );
322
+ expect(ours).toEqual([
323
+ { event: "account-agreements", outcome: "saved" },
324
+ { event: "account-agreements", outcome: "purpose_unknown" },
325
+ ]);
326
+ });
327
+ });
328
+
329
+ describe("GET /v1/consent/account — AC-3 (route)", () => {
330
+ it("a grant at version 2 in W reads complete from S1's credential", async () => {
331
+ app = await build("cookie");
332
+ await postAccount({ purposeIds: ["account_terms"] });
333
+ await app.close();
334
+ app = await build("hub");
335
+ const res = await getAccount();
336
+ expect(res.statusCode).toBe(200);
337
+ expect(res.json().data.complete).toBe(true);
338
+ expect(res.json().data.agreements[0]).toMatchObject({
339
+ acceptedVersion: 2,
340
+ current: true,
341
+ });
342
+ });
343
+ });
344
+
345
+ describe("GET/POST /v1/consent/account — AC-4 (route)", () => {
346
+ it("a bump to version 3 asks again; accepting appends a row and keeps the old one unchanged", async () => {
347
+ app = await build("cookie");
348
+ await postAccount({ purposeIds: ["account_terms"] });
349
+ const before = structuredClone(double.ledger[0]);
350
+
351
+ jest
352
+ .spyOn(consentService, "consentConfigForSite")
353
+ .mockResolvedValue(registryAt(3));
354
+
355
+ const read = (await getAccount()).json().data;
356
+ expect(read.complete).toBe(false);
357
+ expect(read.agreements[0]).toMatchObject({
358
+ policyVersion: 3,
359
+ acceptedVersion: 2,
360
+ current: false,
361
+ });
362
+
363
+ const res = await postAccount({ purposeIds: ["account_terms"] });
364
+ expect(res.statusCode).toBe(200);
365
+ expect(res.json().data.complete).toBe(true);
366
+ expect(double.ledger).toHaveLength(2);
367
+ expect(double.ledger[0]).toEqual(before);
368
+ expect(double.ledger[1]).toMatchObject({
369
+ subjectType: "user",
370
+ subjectId: U1,
371
+ policyVersion: 3,
372
+ granted: true,
373
+ });
374
+ // The append-only double offers no way to change a stored row: the
375
+ // ledger delegate is append and read only, so an update would throw.
376
+ for (const method of [
377
+ "update",
378
+ "updateMany",
379
+ "upsert",
380
+ "delete",
381
+ "deleteMany",
382
+ ]) {
383
+ expect(method in prisma.consentGrant).toBe(false);
384
+ }
385
+ });
386
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @file sign-up-rate-limit.test.ts
3
+ * @description #8805 — the sign-up agreements' evidence read is counted per
4
+ * site key, end to end: the real `build()`, the `signupevidence` scope's own
5
+ * limiter and the REAL `@fastify/rate-limit` global ceiling.
6
+ *
7
+ * Every sign-up reads `GET /v1/consent/sign-up` from the web's server, and
8
+ * every web instance behind one egress address used to share one per-IP
9
+ * bucket of 10 a minute — which an anonymous caller spoofing that address's
10
+ * `X-Forwarded-For` could fill. A request presenting a VALID site key (the
11
+ * `x-ailk-spoke-key` the web's api-client already sends) is now counted
12
+ * against that key's own bucket instead, in both limiters; an invalid or
13
+ * absent key keeps the per-IP 10.
14
+ *
15
+ * External dependency boundary mocked: `resolveCallerSite`, the registry
16
+ * lookup a key digests to (one key valid, every other refused), and
17
+ * `resolveSiteWorkspaceId`, the route's own workspace seam. `DATABASE_URL` is
18
+ * unset for the file's duration, so the limiter counts in the memory store.
19
+ */
20
+
21
+ const mockResolveCallerSite = jest.fn();
22
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the registry lookup is the database boundary
23
+ jest.mock("../../../services/public-site-resolver.js", () => ({
24
+ ...jest.requireActual<object>("../../../services/public-site-resolver.js"),
25
+ resolveCallerSite: (...args: unknown[]) => mockResolveCallerSite(...args),
26
+ }));
27
+
28
+ const mockResolveSiteWorkspaceId = jest.fn();
29
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
30
+ jest.mock("../../../lib/site.js", () => ({
31
+ ...jest.requireActual<object>("../../../lib/site.js"),
32
+ resolveSiteWorkspaceId: () => mockResolveSiteWorkspaceId(),
33
+ }));
34
+
35
+ jest.mock("@fastify/cors", () => {
36
+ return jest.fn().mockResolvedValue(undefined);
37
+ });
38
+
39
+ jest.mock("@working-theory/email", () => ({
40
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
41
+ }));
42
+
43
+ import type { FastifyRequest } from "fastify";
44
+
45
+ import { __resetBuckets } from "../../../middleware/rate-limit";
46
+ import { build } from "../../../server";
47
+
48
+ /** The deployment's own site workspace. */
49
+ const W = "wksp_01920000-0000-7000-8000-0000000000a1";
50
+ /** The spoke's own workspace — never the evidence route's answer. */
51
+ const SPOKE_WS = "wksp_01920000-0000-7000-8000-0000000000b1";
52
+ const SPOKE_ID = "spoke_01920000-0000-7000-8000-0000000000b1";
53
+ const VALID = "ailk_spk_valid-site-key";
54
+ const WEB_EGRESS = "198.51.100.20";
55
+
56
+ describe("GET /v1/consent/sign-up against the real limiters (#8805)", () => {
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any matches server.test.ts
58
+ let app: any;
59
+ const prior = {
60
+ databaseUrl: process.env.DATABASE_URL,
61
+ perKey: process.env.AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
62
+ };
63
+
64
+ async function statuses(
65
+ count: number,
66
+ headers: Record<string, string>,
67
+ ): Promise<number[]> {
68
+ const codes: number[] = [];
69
+ for (let i = 0; i < count; i++) {
70
+ const res = await app.inject({
71
+ method: "GET",
72
+ url: "/v1/consent/sign-up",
73
+ headers,
74
+ });
75
+ codes.push(res.statusCode);
76
+ }
77
+ return codes;
78
+ }
79
+
80
+ const keyed = (key: string = VALID) => ({
81
+ "x-forwarded-for": WEB_EGRESS,
82
+ "x-ailk-spoke-key": key,
83
+ });
84
+
85
+ beforeEach(async () => {
86
+ __resetBuckets();
87
+ delete process.env.DATABASE_URL;
88
+ delete process.env.AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE;
89
+ mockResolveSiteWorkspaceId.mockResolvedValue(W);
90
+ mockResolveCallerSite.mockImplementation((req: FastifyRequest) =>
91
+ Promise.resolve(
92
+ req.headers["x-ailk-spoke-key"] === VALID
93
+ ? { kind: "spoke", spokeId: SPOKE_ID, workspaceId: SPOKE_WS }
94
+ : { kind: "refused", reason: "key-unknown" },
95
+ ),
96
+ );
97
+ app = await build();
98
+ await app.ready();
99
+ });
100
+
101
+ afterEach(async () => {
102
+ if (app) await app.close();
103
+ app = undefined;
104
+ });
105
+
106
+ afterAll(() => {
107
+ for (const [name, value] of [
108
+ ["DATABASE_URL", prior.databaseUrl],
109
+ ["AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE", prior.perKey],
110
+ ] as const) {
111
+ if (value === undefined) delete process.env[name];
112
+ else process.env[name] = value;
113
+ }
114
+ });
115
+
116
+ it("admits keyed requests from one IP past 10 a minute — and past the global 60 — up to the key ceiling of 120", async () => {
117
+ const codes = await statuses(121, keyed());
118
+ expect(codes.slice(0, 120).every((code) => code !== 429)).toBe(true);
119
+ expect(codes[120]).toBe(429);
120
+ });
121
+
122
+ it("refuses the eleventh keyless request from one IP with 429", async () => {
123
+ const codes = await statuses(11, { "x-forwarded-for": WEB_EGRESS });
124
+ expect(codes.slice(0, 10).every((code) => code !== 429)).toBe(true);
125
+ expect(codes[10]).toBe(429);
126
+ });
127
+
128
+ it("refuses the eleventh request with an invalid key from one IP with 429", async () => {
129
+ const codes = await statuses(11, keyed("ailk_spk_forged"));
130
+ expect(codes.slice(0, 10).every((code) => code !== 429)).toBe(true);
131
+ expect(codes[10]).toBe(429);
132
+ });
133
+
134
+ it("a spoofer filling the web egress address's buckets leaves the keyed caller its budget", async () => {
135
+ const spoofed = await statuses(70, { "x-forwarded-for": WEB_EGRESS });
136
+ expect(spoofed[69]).toBe(429);
137
+ const codes = await statuses(20, keyed());
138
+ expect(codes.every((code) => code !== 429)).toBe(true);
139
+ });
140
+
141
+ it("reads AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE over the default", async () => {
142
+ await app.close();
143
+ process.env.AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE = "15";
144
+ app = await build();
145
+ await app.ready();
146
+ const codes = await statuses(16, keyed());
147
+ expect(codes.slice(0, 15).every((code) => code !== 429)).toBe(true);
148
+ expect(codes[15]).toBe(429);
149
+ });
150
+
151
+ it("answers a keyed request with the site workspace, never the key's", async () => {
152
+ const res = await app.inject({
153
+ method: "GET",
154
+ url: "/v1/consent/sign-up",
155
+ headers: keyed(),
156
+ });
157
+ expect(res.statusCode).toBe(200);
158
+ expect(res.json().data.workspaceId).toBe(W);
159
+ });
160
+ });