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
@@ -65,7 +65,8 @@
65
65
  * (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book` — and
66
66
  * since #7479 the three listing-draft calls a spoke makes for its applicant
67
67
  * (`POST /v1/project-listings`, `PATCH /v1/project-listings/drafts/:id`,
68
- * `POST /v1/project-listings/drafts/:id/submit`). The global limiter (`server.ts`) passes the hub's digests, so a listed call is
68
+ * `POST /v1/project-listings/drafts/:id/submit`), and since #8848 the open
69
+ * file read (`GET /v1/deliverables/open/:slug`). The global limiter (`server.ts`) passes the hub's digests, so a listed call is
69
70
  * counted per visitor there; a scope's own limiter does so only when its
70
71
  * registration passes `trustedKeyDigests` too, as the leads and schedule
71
72
  * scopes do. A believed visitor is
@@ -76,6 +77,21 @@
76
77
  * request keys on `req.ip`, as before. Spec:
77
78
  * docs/specs/7394-the-leads-rate-limit-scope-spec.md.
78
79
  *
80
+ * Per-site-key sign-up evidence (#8805): the web's server reads
81
+ * `GET /v1/consent/sign-up` on every sign-up, and every web instance behind
82
+ * one egress address shared one per-IP bucket, which an anonymous caller
83
+ * spoofing that address's `X-Forwarded-For` could fill. A registration that
84
+ * passes `siteKeyOf` (built by `siteKeyReader`) counts a request on one of
85
+ * the `SITE_KEYED_ROUTES` that presents a VERIFIED site key
86
+ * (`x-ailk-spoke-key`, the key the web's api-client already sends) against
87
+ * that key's own `site,<spokeId>` bucket and the scope's per-key ceiling
88
+ * only — never an IP bucket. No other identity starts with `site,` (`req.ip`
89
+ * holds no comma, a forwarded visitor is `v<n>,<ip>`), so no unkeyed caller
90
+ * can reach it. An invalid or absent key, or a verification that fails or
91
+ * outlasts `SITE_KEY_TIMEOUT_MS`, is counted on `req.ip` as before. The key changes
92
+ * the request's rate-limit identity and nothing else: the route still serves
93
+ * the deployment's own site workspace.
94
+ *
79
95
  * Scope namespace and env limit (#7394): a registration with a `scope` keys
80
96
  * its buckets as `<scope>|<identity>`, so its count is not shared with any
81
97
  * other registration, and reads its limits from
@@ -146,6 +162,13 @@ export type RateLimitOptions = {
146
162
  * visitors (#7394). Default: 300.
147
163
  */
148
164
  perKeyPerMinute?: number;
165
+ /**
166
+ * The verified site key a request presents, as its spoke id, or `null`
167
+ * (#8805). Build it with `siteKeyReader`. A request it names is counted
168
+ * against `<scope>|site,<spokeId>` and `perKeyPerMinute` only, never an IP
169
+ * bucket. Needs a `scope`.
170
+ */
171
+ siteKeyOf?: (req: FastifyRequest) => Promise<string | null>;
149
172
  /**
150
173
  * Override the process-default store (#5040 D4). Test-only, like
151
174
  * `maxBuckets` — no production caller overrides this.
@@ -181,6 +204,14 @@ export const MAX_BUCKETS = 10_000;
181
204
  */
182
205
  export const DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE = 60;
183
206
 
207
+ /**
208
+ * The sign-up evidence read's per-site-key ceiling (#8805). The web's cache
209
+ * keeps each instance to about one read a minute per enabled locale, so this
210
+ * covers many instances of one site at once.
211
+ * `AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE` overrides it.
212
+ */
213
+ export const DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE = 120;
214
+
184
215
  /**
185
216
  * How long a database-store call may run before the middleware falls back to
186
217
  * the memory store for that request (#5040 D5). `RateLimitOptions.storeTimeoutMs`
@@ -218,8 +249,75 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
218
249
  { method: "POST", url: "/v1/project-listings" },
219
250
  { method: "PATCH", url: "/v1/project-listings/drafts/:id" },
220
251
  { method: "POST", url: "/v1/project-listings/drafts/:id/submit" },
252
+ // #8848 D5 — an ungated file's bytes, which a site's own route handler
253
+ // reads for its visitor (`/reports/<slug>/file`), in `deliverableopen`.
254
+ { method: "GET", url: "/v1/deliverables/open/:slug" },
221
255
  ];
222
256
 
257
+ /**
258
+ * The only (method, route) pairs on which a verified site key is the
259
+ * caller's rate-limit identity (#8805): the sign-up agreements' evidence read.
260
+ */
261
+ export const SITE_KEYED_ROUTES: readonly Readonly<{
262
+ method: "GET";
263
+ url: string;
264
+ }>[] = [{ method: "GET", url: "/v1/consent/sign-up" }];
265
+
266
+ /**
267
+ * A verified site key's identity prefix. No other identity starts with it:
268
+ * `req.ip` never holds a comma, and a forwarded visitor is `v<n>,<ip>`.
269
+ */
270
+ export const SITE_KEY_IDENTITY_PREFIX = "site,";
271
+
272
+ /**
273
+ * How long a site-key lookup may run before the request is counted per IP
274
+ * instead (#8805) — the store's own bound (#5040 D5).
275
+ */
276
+ export const SITE_KEY_TIMEOUT_MS = 1_000;
277
+
278
+ const SITE_KEY_HEADER = "x-ailk-spoke-key";
279
+
280
+ /**
281
+ * The `siteKeyOf` reader (#8805). `verify` turns a request's site key into
282
+ * its spoke id, or `null` for a key that is not a registered, unrevoked one;
283
+ * it is called only for a request on one of the `SITE_KEYED_ROUTES` that
284
+ * carries a non-empty `x-ailk-spoke-key`, and at most once per request, so
285
+ * the global limiter and the scope's own share one lookup. A `verify` that
286
+ * throws, or has not answered within `timeoutMs`, reads as `null` — the
287
+ * request is counted per IP, never unlimited — and logs the error's name only.
288
+ */
289
+ export function siteKeyReader(
290
+ verify: (req: FastifyRequest) => Promise<string | null>,
291
+ timeoutMs: number = SITE_KEY_TIMEOUT_MS,
292
+ ): (req: FastifyRequest) => Promise<string | null> {
293
+ const verified = new WeakMap<FastifyRequest, Promise<string | null>>();
294
+ return (req) => {
295
+ const url = req.routeOptions.url ?? "";
296
+ const listed = SITE_KEYED_ROUTES.some(
297
+ (route) => route.method === req.method && route.url === url,
298
+ );
299
+ const presented = req.headers[SITE_KEY_HEADER];
300
+ if (!listed || typeof presented !== "string" || presented.trim() === "") {
301
+ return Promise.resolve(null);
302
+ }
303
+ let pending = verified.get(req);
304
+ if (pending === undefined) {
305
+ pending = withTimeout(verify(req), timeoutMs).catch((err: unknown) => {
306
+ req.log.warn(
307
+ {
308
+ event: "rate_limit_site_key_unverified",
309
+ err: err instanceof Error ? err.name : "unknown",
310
+ },
311
+ "rate_limit_site_key_unverified",
312
+ );
313
+ return null;
314
+ });
315
+ verified.set(req, pending);
316
+ }
317
+ return pending;
318
+ };
319
+ }
320
+
223
321
  const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
224
322
  const FORWARD_KEY_HEADER = "x-ailk-visitor-forward-key";
225
323
  const MIN_FORWARD_KEY_LENGTH = 32;
@@ -593,6 +691,10 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
593
691
  done(new Error("rate-limit trustedKeyDigests needs a scope"));
594
692
  return;
595
693
  }
694
+ if (opts.siteKeyOf !== undefined && scope === undefined) {
695
+ done(new Error("rate-limit siteKeyOf needs a scope"));
696
+ return;
697
+ }
596
698
 
597
699
  const warn = (msg: string) => fastify.log.warn(msg);
598
700
  const perIpPerMinute = resolveScopedLimit(
@@ -608,7 +710,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
608
710
  opts.perKeyPerMinute ?? DEFAULT_PER_KEY_PER_MINUTE,
609
711
  warn,
610
712
  );
611
- const { ipFromRequest } = opts;
713
+ const { ipFromRequest, siteKeyOf } = opts;
612
714
  const identityOf: (req: FastifyRequest) => {
613
715
  identity: string;
614
716
  keyIndex: number | null;
@@ -631,6 +733,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
631
733
  activeStore: RateLimitStore,
632
734
  ip: string,
633
735
  keyIndex: number | null,
736
+ siteId: string | null,
634
737
  now: number,
635
738
  sweepOnInterval: boolean,
636
739
  ): Promise<{
@@ -642,6 +745,23 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
642
745
  await activeStore.sweep(now, windowMs, "interval");
643
746
  }
644
747
 
748
+ // #8805: a verified site key is counted against its own bucket and the
749
+ // per-key ceiling only — no IP bucket is read or opened.
750
+ if (siteId !== null) {
751
+ const siteBucket = await activeStore.hitKey(
752
+ bucketKey(`${SITE_KEY_IDENTITY_PREFIX}${siteId}`),
753
+ now,
754
+ windowMs,
755
+ );
756
+ const siteOver = siteBucket.count > perKeyPerMinute;
757
+ if (siteOver) await activeStore.sweep(now, windowMs, "limited");
758
+ return {
759
+ tripped: siteOver ? siteBucket : undefined,
760
+ visitorOver: false,
761
+ keyOver: siteOver,
762
+ };
763
+ }
764
+
645
765
  // #7394 DD-8: a keyed request also counts against its key's own
646
766
  // bucket, even when the visitor bucket is already over its limit.
647
767
  const keyBucket =
@@ -675,7 +795,11 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
675
795
  "onRequest",
676
796
  async (req: FastifyRequest, reply: FastifyReply) => {
677
797
  const now = getClock().now();
678
- const { identity: ip, keyIndex } = identityOf(req);
798
+ const siteId = siteKeyOf ? await siteKeyOf(req) : null;
799
+ const { identity: ip, keyIndex } =
800
+ siteId === null
801
+ ? identityOf(req)
802
+ : { identity: `${SITE_KEY_IDENTITY_PREFIX}${siteId}`, keyIndex: null };
679
803
 
680
804
  requestsSinceLastClean += 1;
681
805
  const sweepOnInterval = requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS;
@@ -697,7 +821,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
697
821
  };
698
822
  try {
699
823
  outcome = await withTimeout(
700
- countRequest(primaryStore, ip, keyIndex, now, sweepOnInterval),
824
+ countRequest(primaryStore, ip, keyIndex, siteId, now, sweepOnInterval),
701
825
  storeTimeoutMs,
702
826
  );
703
827
  } catch (err) {
@@ -710,6 +834,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
710
834
  fallbackStore,
711
835
  ip,
712
836
  keyIndex,
837
+ siteId,
713
838
  now,
714
839
  sweepOnInterval,
715
840
  );
@@ -730,6 +855,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
730
855
  const pathOnly = req.url.split("?")[0] ?? req.url;
731
856
  // A keyed 429 names the bucket that tripped and the key's position,
732
857
  // so a misused key can be traced to its spoke; never the key itself.
858
+ // A site-keyed 429 (#8805) names `bucket: "site"` and the spoke id.
733
859
  req.log.warn(
734
860
  {
735
861
  ip,
@@ -739,6 +865,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
739
865
  bucket: visitorOver ? "visitor" : "key",
740
866
  keyIndex,
741
867
  }),
868
+ ...(siteId !== null && { bucket: "site" }),
742
869
  },
743
870
  RATE_LIMITED,
744
871
  );
@@ -759,3 +886,98 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
759
886
  (rateLimitMiddleware as FastifyPluginCallback & { [key: symbol]: boolean })[
760
887
  Symbol.for("skip-override")
761
888
  ] = true;
889
+
890
+ // ─── The Stripe connection write budget (#8907 D9, contract K10) ────────────
891
+
892
+ /** One Organization's connection writes in an hour (K10). */
893
+ export const STRIPE_CONNECTION_WRITES_PER_ORG_PER_HOUR = 10;
894
+ /** One person's connection writes in an hour, across Organizations (K10). */
895
+ export const STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR = 20;
896
+
897
+ /** One bucket a write is counted against: `org,<id>` or `user,<id>`. */
898
+ export type WriteBudgetCharge = { key: string; limit: number };
899
+
900
+ export type WriteBudgetResult = { ok: true } | { ok: false; retryAfterSeconds: number };
901
+
902
+ /**
903
+ * The budget's own database store (D9, SEC-8907-2) — never the process
904
+ * default, so `AILK_RATE_LIMIT_STORE=memory` can never send an hourly bucket
905
+ * into the shared `keyBuckets` map a per-minute sweep clears. Each database
906
+ * row keeps its own `window_ms`, so no sweep shortens it.
907
+ */
908
+ let writeBudgetStore: RateLimitStore | undefined;
909
+
910
+ /**
911
+ * Where a request is counted while the database store rejects or times out.
912
+ * Module-private and never touched by `evictStaleBuckets`; capped at
913
+ * `MAX_BUCKETS`, the oldest entry dropped first, and an entry replaced only
914
+ * once its own window has ended. Per instance while the database is
915
+ * degraded — the residual SEC-8907-15 accepts.
916
+ */
917
+ const writeBudgetFallback = new Map<string, RateLimitBucket>();
918
+
919
+ function fallbackHit(key: string, now: number, windowMs: number): RateLimitBucket {
920
+ let bucket = writeBudgetFallback.get(key);
921
+ if (!bucket || now - bucket.windowStartMs >= windowMs) {
922
+ writeBudgetFallback.delete(key);
923
+ if (writeBudgetFallback.size >= MAX_BUCKETS) {
924
+ const oldestKey = writeBudgetFallback.keys().next().value;
925
+ if (oldestKey !== undefined) writeBudgetFallback.delete(oldestKey);
926
+ }
927
+ bucket = { count: 0, windowStartMs: now };
928
+ writeBudgetFallback.set(key, bucket);
929
+ }
930
+ bucket.count += 1;
931
+ return bucket;
932
+ }
933
+
934
+ /**
935
+ * Count one write against every charge's bucket and answer whether all of
936
+ * them are still within their limit (D9). Every call counts — a refused key
937
+ * costs a write too, so the probes are never a key-validity oracle. On a
938
+ * refusal, `retryAfterSeconds` is the latest window end among the refusing
939
+ * buckets, at least 1.
940
+ */
941
+ export async function chargeWriteBudget(input: {
942
+ scope: string;
943
+ charges: readonly WriteBudgetCharge[];
944
+ windowMs: number;
945
+ now: number;
946
+ warn?: (obj: Record<string, unknown>, msg: string) => void;
947
+ }): Promise<WriteBudgetResult> {
948
+ const { scope, charges, windowMs, now } = input;
949
+ writeBudgetStore ??= createDatabaseRateLimitStore(prisma);
950
+ const activeStore = writeBudgetStore;
951
+
952
+ let retryAfterSeconds = 0;
953
+ for (const charge of charges) {
954
+ const key = `writebudget:${scope}|${charge.key}`;
955
+ let bucket: RateLimitBucket;
956
+ try {
957
+ bucket = await withTimeout(activeStore.hitKey(key, now, windowMs), STORE_TIMEOUT_MS);
958
+ } catch (err) {
959
+ warnFallback(
960
+ input.warn ?? (() => undefined),
961
+ now,
962
+ err instanceof RateLimitStoreTimeoutError ? "timeout" : "error",
963
+ );
964
+ bucket = fallbackHit(key, now, windowMs);
965
+ }
966
+ if (bucket.count > charge.limit) {
967
+ const endsInMs = bucket.windowStartMs + windowMs - now;
968
+ retryAfterSeconds = Math.max(retryAfterSeconds, Math.ceil(endsInMs / 1000), 1);
969
+ }
970
+ }
971
+ return retryAfterSeconds > 0 ? { ok: false, retryAfterSeconds } : { ok: true };
972
+ }
973
+
974
+ /** Test-only: replace the budget's store, as the middleware's `store` option does for a registration. */
975
+ export function __setWriteBudgetStoreForTests(store: RateLimitStore | undefined): void {
976
+ writeBudgetStore = store;
977
+ writeBudgetFallback.clear();
978
+ }
979
+
980
+ /** Test-only: the fallback map's size. */
981
+ export function __writeBudgetFallbackCount(): number {
982
+ return writeBudgetFallback.size;
983
+ }
@@ -3,6 +3,7 @@
3
3
  * GET /api/openapi.json — OpenAPI 3.x spec describing the public content API.
4
4
  */
5
5
  import { openapiRoutes } from "../index";
6
+ import { buildOpenApiSpec } from "../spec";
6
7
 
7
8
  describe("GET /api/openapi.json", () => {
8
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #506/#507: pre-existing test-hygiene violation
@@ -54,6 +55,132 @@ describe("GET /api/openapi.json", () => {
54
55
  );
55
56
  });
56
57
 
58
+ // #9285 AC-3 — the Stripe connection read's three answers are documented, and
59
+ // only on a build that registers the billing routes (multi-tenant).
60
+ describe("Inbox message and Person record reads (#9349 DD8, AC-10)", () => {
61
+ const PRIOR_ENABLED_MODULES = process.env.AILK_ENABLED_MODULES;
62
+ const LIST_PATH = "/v1/workspaces/{workspaceId}/messages";
63
+ const ITEM_PATH = "/v1/workspaces/{workspaceId}/messages/{leadId}";
64
+ const PERSON_PATH = "/v1/workspaces/{workspaceId}/people/{personId}";
65
+
66
+ afterEach(() => {
67
+ if (PRIOR_ENABLED_MODULES === undefined) {
68
+ delete process.env.AILK_ENABLED_MODULES;
69
+ } else {
70
+ process.env.AILK_ENABLED_MODULES = PRIOR_ENABLED_MODULES;
71
+ }
72
+ });
73
+
74
+ const dataRef = (op: { responses: Record<string, unknown> }) =>
75
+ (op.responses["200"] as {
76
+ content: {
77
+ "application/json": { schema: { properties: { data: { $ref: string } } } };
78
+ };
79
+ }).content["application/json"].schema.properties.data.$ref;
80
+
81
+ it("documents the two message GETs and the Person record on a multi-tenant build", async () => {
82
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
83
+ const spec = JSON.parse(
84
+ (await app.inject({ method: "GET", url: "/v1/openapi.json" })).body,
85
+ );
86
+
87
+ expect(spec.paths[LIST_PATH].get.operationId).toBe("listInboxMessages");
88
+ expect(dataRef(spec.paths[LIST_PATH].get)).toBe("#/components/schemas/InboxMessageList");
89
+ expect(spec.paths[ITEM_PATH].get.operationId).toBe("getInboxMessage");
90
+ expect(dataRef(spec.paths[ITEM_PATH].get)).toBe("#/components/schemas/InboxMessage");
91
+ expect(spec.paths[PERSON_PATH].get.operationId).toBe("getPersonRecord");
92
+ expect(dataRef(spec.paths[PERSON_PATH].get)).toBe("#/components/schemas/PersonRecord");
93
+
94
+ const message = spec.components.schemas.InboxMessage;
95
+ expect(Object.keys(message.properties).sort()).toEqual([
96
+ "body",
97
+ "createdAt",
98
+ "email",
99
+ "excerpt",
100
+ "handledAt",
101
+ "id",
102
+ "name",
103
+ "personId",
104
+ "source",
105
+ ]);
106
+ expect(message.additionalProperties).toBe(false);
107
+ expect(
108
+ Object.keys(spec.components.schemas.PersonRecord.properties.person.properties),
109
+ ).toContain("stripeCustomerUrl");
110
+ });
111
+
112
+ it("the PATCH on the item path stays the derived operation beside the hand-authored GET", () => {
113
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
114
+ const url = "/v1/workspaces/:workspaceId/messages/:leadId";
115
+ const spec = buildOpenApiSpec({}, [
116
+ { method: "GET", url },
117
+ { method: "PATCH", url },
118
+ ]);
119
+ const item = spec.paths[ITEM_PATH] as Record<
120
+ string,
121
+ { operationId: string; description: string }
122
+ >;
123
+ expect(item.get!.operationId).toBe("getInboxMessage");
124
+ expect(item.patch!.description).toContain(
125
+ "Derived from this deployment's route table",
126
+ );
127
+ });
128
+
129
+ it("advertises none of the three where the routes do not register", async () => {
130
+ process.env.AILK_ENABLED_MODULES = "commerce";
131
+ const spec = JSON.parse(
132
+ (await app.inject({ method: "GET", url: "/v1/openapi.json" })).body,
133
+ );
134
+ for (const path of [LIST_PATH, ITEM_PATH, PERSON_PATH]) {
135
+ expect(spec.paths[path]).toBeUndefined();
136
+ }
137
+ });
138
+ });
139
+
140
+ describe("stripe-connection read (#9285)", () => {
141
+ const PRIOR_ENABLED_MODULES = process.env.AILK_ENABLED_MODULES;
142
+
143
+ afterEach(() => {
144
+ if (PRIOR_ENABLED_MODULES === undefined) {
145
+ delete process.env.AILK_ENABLED_MODULES;
146
+ } else {
147
+ process.env.AILK_ENABLED_MODULES = PRIOR_ENABLED_MODULES;
148
+ }
149
+ });
150
+
151
+ it("documents the stored view, the environment source and null on a multi-tenant build", async () => {
152
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
153
+ const res = await app.inject({ method: "GET", url: "/v1/openapi.json" });
154
+ const spec = JSON.parse(res.body);
155
+
156
+ const get = spec.paths["/v1/billing/stripe-connection"].get;
157
+ expect(get.operationId).toBe("getStripeConnection");
158
+ expect(get.responses["200"].content["application/json"].schema.$ref).toBe(
159
+ "#/components/schemas/StripeConnectionReadResponse",
160
+ );
161
+
162
+ const { data } = spec.components.schemas.StripeConnectionReadResponse.properties;
163
+ expect(data.nullable).toBe(true);
164
+ expect(data.oneOf).toEqual([
165
+ { $ref: "#/components/schemas/StripeConnectionView" },
166
+ { $ref: "#/components/schemas/StripeEnvironmentSource" },
167
+ ]);
168
+
169
+ // The environment source carries the mode and nothing else.
170
+ const env = spec.components.schemas.StripeEnvironmentSource;
171
+ expect(Object.keys(env.properties).sort()).toEqual(["mode", "source"]);
172
+ expect(env.properties.source.enum).toEqual(["environment"]);
173
+ expect(env.additionalProperties).toBe(false);
174
+ });
175
+
176
+ it("does not advertise it where the billing routes do not register", async () => {
177
+ process.env.AILK_ENABLED_MODULES = "commerce";
178
+ const res = await app.inject({ method: "GET", url: "/v1/openapi.json" });
179
+ const spec = JSON.parse(res.body);
180
+ expect(spec.paths["/v1/billing/stripe-connection"]).toBeUndefined();
181
+ });
182
+ });
183
+
57
184
  // The checkout path's OpenAPI documentation is module-conditional (commerce,
58
185
  // D-47 / #5364) — mirroring the route's own registration gate in server.ts —
59
186
  // so these two assertions need the module enabled, exactly as a real