create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  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 +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -144,7 +144,10 @@ export async function resolveWorkspaceIdForAlias(
144
144
  const row = await resolveSentinelWorkspaceRow(prisma);
145
145
  return row?.id ?? null;
146
146
  }
147
- const row = await prisma.workspace.findUnique({ where: { id: alias } });
147
+ const row = await prisma.workspace.findUnique({
148
+ where: { id: alias },
149
+ select: { id: true },
150
+ });
148
151
  return row ? alias : null;
149
152
  }
150
153
 
@@ -8,11 +8,14 @@
8
8
  * '@working-theory/stripe-client' — single chokepoint for AILK-internal ergonomics
9
9
  * (logger context, request-id propagation) without touching every consumer.
10
10
  */
11
+ import { logger } from "@working-theory/observability";
11
12
  import {
12
13
  admitLiveRestrictedKey,
13
14
  assertSandboxKey,
15
+ createStripeClientForKey,
14
16
  getStripeClient,
15
17
  StripeConfigError,
18
+ type StripeConnectionMode,
16
19
  } from "@working-theory/stripe-client";
17
20
 
18
21
  export {
@@ -26,24 +29,39 @@ export {
26
29
  // Both are on main (#6850, #6862); #8450 is the narrowing this guarded —
27
30
  // `assertAdmittedStripeKey` below. This export itself stays sandbox-only.
28
31
  assertSandboxKey,
32
+ // The plan checkout's mode assertion and terms-box failure (#9067 D3 step 9).
33
+ CheckoutModeMismatchError,
34
+ // The Organization connection's key classifier (#8906, #8907 D4).
35
+ classifyStripeKey,
29
36
  createAdHocPrice,
30
37
  createBillingPortalSession,
31
38
  createCheckoutSession,
39
+ // The Offerings checkout's per-client calls (#8931 D6), on the connection's client.
40
+ createCheckoutSessionForPrice,
32
41
  createPaymentLink,
33
42
  createQuotedCheckoutSession,
43
+ // Start trial's hosted trial session (#9342 DD2), on the Organization's client.
44
+ createTrialCheckoutSession,
45
+ expireCheckoutSessionWith,
46
+ findActivePriceByLookupKey,
34
47
  getStripeClient,
48
+ // Working Theory's own absolute policies for the plan checkout's terms box (#9067 D3 step 9).
49
+ PLATFORM_TERMS_OF_SERVICE,
35
50
  // Checkout session read (#6084 D9) — re-exported through this sole
36
51
  // Stripe-touching chokepoint, same as every other Stripe call here.
37
52
  retrieveCheckoutSession,
38
53
  STRIPE_API_VERSION,
39
54
  StripeConfigError,
40
55
  StripeWebhookSignatureError,
56
+ TermsOfServiceUrlMissingError,
41
57
  toSubscriptionLifecycleStatus,
42
58
  // Signed-in Checkout (#6866 D8) — a pinned customer Stripe rejects.
43
59
  UnknownCustomerError,
44
60
  UnknownProductCodeError,
45
61
  UnknownSessionError,
46
62
  verifyWebhookSignature,
63
+ // The per-Organization webhook's verify (#8932 D3): the row's secret, no env key.
64
+ verifyWebhookSignatureKeyless,
47
65
  WEBHOOK_TOLERANCE_SECONDS,
48
66
  } from "@working-theory/stripe-client";
49
67
 
@@ -55,15 +73,19 @@ export type {
55
73
  CreateBillingPortalSessionOutput,
56
74
  CreatePaymentLinkInput,
57
75
  CreatePaymentLinkOutput,
76
+ CreateCheckoutSessionForPriceInput,
58
77
  CreateCheckoutSessionInput,
59
78
  CreateCheckoutSessionOutput,
60
79
  CreateCheckoutSessionParams,
61
80
  CreateQuotedCheckoutSessionInput,
62
81
  CreateQuotedCheckoutSessionOutput,
82
+ CreateTrialCheckoutSessionInput,
63
83
  RetrieveCheckoutSessionInput,
64
84
  RetrieveCheckoutSessionOutput,
65
85
  Stripe,
66
86
  StripeCheckoutSession,
87
+ StripeConnectionMode,
88
+ StripeKeyClass,
67
89
  StripeWebhookEvent,
68
90
  } from "@working-theory/stripe-client";
69
91
 
@@ -81,9 +103,10 @@ let liveKeyAdmitted = false;
81
103
  * rule, with one fail-closed delta (PR #8470 security review R-1): on Vercel
82
104
  * (`VERCEL` set) a missing `VERCEL_ENV` is not production, because
83
105
  * `vercel.json` sets `NODE_ENV=production` in every environment, Preview
84
- * included.
106
+ * included. Exported for the Organization connection's go-live check and
107
+ * its live-row guard (#8907 D3, D8 step 6); the body is unchanged.
85
108
  */
86
- function isProductionBuild(): boolean {
109
+ export function isProductionBuild(): boolean {
87
110
  if (process.env.VERCEL_ENV !== undefined) {
88
111
  return process.env.VERCEL_ENV === "production";
89
112
  }
@@ -145,6 +168,35 @@ export function assertAdmittedStripeKey(key: string): void {
145
168
  assertSandboxKey(key);
146
169
  }
147
170
 
171
+ /**
172
+ * A fresh Stripe client from ONE Organization's own key (#8907 D12, #8906
173
+ * F-T1 to F-T3), or `null` when the factory refuses it: the factory's only
174
+ * caller, so `productionBuild` is always the strict predicate above and never
175
+ * a caller's boolean. It reads and sets no process flag — `stripeKeyMode()`
176
+ * is untouched — so one process holds a live Organization and a test one
177
+ * (contract K6, K7). A refusal logs `{ organizationId, reason }` only.
178
+ * `architecture.yaml` confines its callers to exact paths.
179
+ */
180
+ export function stripeClientForConnection(input: {
181
+ organizationId: string;
182
+ key: string;
183
+ mode: StripeConnectionMode;
184
+ liveAt: Date | null;
185
+ }): ReturnType<typeof createStripeClientForKey> {
186
+ return createStripeClientForKey({
187
+ organizationId: input.organizationId,
188
+ key: input.key,
189
+ mode: input.mode,
190
+ liveAt: input.liveAt,
191
+ productionBuild: isProductionBuild(),
192
+ log: (refusal) =>
193
+ logger.warn(
194
+ { organizationId: refusal.organizationId, reason: refusal.reason },
195
+ "[stripe] connection key refused for its mode",
196
+ ),
197
+ });
198
+ }
199
+
148
200
  /** The key class this process admitted at boot: `"live"` only after `admitApiStripeKey()` did. */
149
201
  export function stripeKeyMode(): "test" | "live" {
150
202
  return liveKeyAdmitted ? "live" : "test";
@@ -165,14 +217,22 @@ export async function expireCheckoutSession(stripeSessionId: string): Promise<vo
165
217
  await getStripeClient().checkout.sessions.expire(stripeSessionId);
166
218
  }
167
219
 
168
- /** #8171 D7 — what the Billing page shows: the plan's name and the card on file. */
220
+ /**
221
+ * #8171 D7 — what the Billing page shows: the plan's name and the card on
222
+ * file. #9067 D6 adds the renewal date and the site count, read from the
223
+ * same subscription.
224
+ */
169
225
  export type CustomerBillingSummary = {
170
226
  planName: string | null;
171
227
  paymentMethod: { brand: string; last4: string; expMonth: number; expYear: number } | null;
228
+ renewsAt: string | null;
229
+ sites: number | null;
172
230
  };
173
231
 
174
232
  /** A subscription status that still names the customer's plan (#8171 C10). */
175
233
  const CURRENT_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due", "unpaid"]);
234
+ /** A subscription status that renews at its period end (#9067 C3). */
235
+ const RENEWING_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due"]);
176
236
  const PLAN_NAME_MAX = 200;
177
237
 
178
238
  /**
@@ -224,7 +284,20 @@ export async function readCustomerBillingSummary(
224
284
  ?.default_payment_method ?? null;
225
285
  }
226
286
 
227
- return { planName, paymentMethod: cardOf(method) };
287
+ // #9067 C3, D6 — under `dahlia` the period end lives on the item, and a
288
+ // subscription set to cancel names no renewal.
289
+ const item = current?.items.data[0];
290
+ const renews =
291
+ current !== undefined &&
292
+ RENEWING_SUBSCRIPTION_STATUSES.has(current.status) &&
293
+ current.cancel_at_period_end !== true &&
294
+ current.cancel_at == null &&
295
+ typeof item?.current_period_end === "number";
296
+ const renewsAt = renews ? new Date(item.current_period_end * 1000).toISOString() : null;
297
+ const quantity = item?.quantity;
298
+ const sites = typeof quantity === "number" && Number.isInteger(quantity) && quantity >= 1 ? quantity : null;
299
+
300
+ return { planName, paymentMethod: cardOf(method), renewsAt, sites };
228
301
  }
229
302
 
230
303
  /** A `card` payment method's presentation fields, or `null` for anything else. */
@@ -82,11 +82,75 @@ export function markSeen(eventId: string, nowMs: number = Date.now()): void {
82
82
  }
83
83
  }
84
84
 
85
+ // ─── Per-Organization store (#8932 D8, contract W10) ─────────────────────────
86
+ //
87
+ // The per-Organization endpoint (`POST /v1/webhooks/stripe/:connectionToken`)
88
+ // dedupes in its OWN map, keyed `<organizationId>:<eventId>`, so neither
89
+ // endpoint's dedup can swallow the other's event (SEC-8879-1) and two
90
+ // Organizations sharing one Stripe account never collide (an Organization id
91
+ // holds no `:`). Same TTL, soft cap and sweep rules as the legacy pair above;
92
+ // the legacy `hasSeen`/`markSeen` never read it. Correctness rests on the
93
+ // lead's database key, not on this map.
94
+
95
+ const orgStore = new Map<string, Entry>();
96
+ let orgInsertsSinceLastSweep = 0;
97
+
98
+ function orgKey(organizationId: string, eventId: string): string {
99
+ return `${organizationId}:${eventId}`;
100
+ }
101
+
102
+ function sweepOrgExpired(nowMs: number): void {
103
+ for (const [key, entry] of orgStore) {
104
+ if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
105
+ orgStore.delete(key);
106
+ }
107
+ }
108
+ orgInsertsSinceLastSweep = 0;
109
+ }
110
+
111
+ /** `hasSeen`'s twin for one Organization's endpoint. */
112
+ export function hasSeenForOrganization(
113
+ organizationId: string,
114
+ eventId: string,
115
+ nowMs: number = Date.now(),
116
+ ): boolean {
117
+ const key = orgKey(organizationId, eventId);
118
+ const entry = orgStore.get(key);
119
+ if (!entry) return false;
120
+ if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
121
+ orgStore.delete(key);
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+
127
+ /** `markSeen`'s twin for one Organization's endpoint. */
128
+ export function markSeenForOrganization(
129
+ organizationId: string,
130
+ eventId: string,
131
+ nowMs: number = Date.now(),
132
+ ): void {
133
+ const key = orgKey(organizationId, eventId);
134
+ if (!orgStore.has(key)) {
135
+ orgStore.set(key, { insertedAtMs: nowMs });
136
+ }
137
+
138
+ orgInsertsSinceLastSweep += 1;
139
+ if (
140
+ orgStore.size > SOFT_CAP ||
141
+ orgInsertsSinceLastSweep >= SWEEP_INTERVAL_INSERTS
142
+ ) {
143
+ sweepOrgExpired(nowMs);
144
+ }
145
+ }
146
+
85
147
  /**
86
- * Test-only reset. Clears all dedup state between test cases so they do not
87
- * interfere with each other.
148
+ * Test-only reset. Clears all dedup state — both stores — between test cases
149
+ * so they do not interfere with each other.
88
150
  */
89
151
  export function __resetIdempotencyStore(): void {
90
152
  store.clear();
91
153
  insertsSinceLastSweep = 0;
154
+ orgStore.clear();
155
+ orgInsertsSinceLastSweep = 0;
92
156
  }
@@ -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
+ });