create-ailk 0.24.0 → 0.25.1

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 (599) hide show
  1. package/README.md +42 -20
  2. package/component-catalog.json +3 -3
  3. package/component-catalog.md +3 -3
  4. package/dist/add-module.js +4 -2
  5. package/dist/add-route.d.ts +12 -1
  6. package/dist/add-route.js +31 -3
  7. package/dist/cli.js +12 -7
  8. package/dist/derive-architecture.d.ts +3 -4
  9. package/dist/derive-architecture.js +65 -29
  10. package/dist/lib/design-checks.d.ts +9 -4
  11. package/dist/lib/design-checks.js +13 -7
  12. package/dist/lib/extract-module-bundle.js +29 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +1 -1
  14. package/dist/lib/fetch-template-snapshot.js +7 -3
  15. package/dist/lib/import-graph.d.ts +1 -1
  16. package/dist/lib/import-graph.js +4 -4
  17. package/dist/lib/module-license-gate.d.ts +12 -1
  18. package/dist/lib/module-license-gate.js +34 -12
  19. package/dist/module-architecture.d.ts +20 -3
  20. package/dist/module-architecture.js +117 -550
  21. package/dist/paid-sections.d.ts +25 -1
  22. package/dist/paid-sections.js +88 -3
  23. package/dist/surfaces.js +10 -2
  24. package/dist/template-record.d.ts +14 -4
  25. package/dist/template-record.js +23 -5
  26. package/dist/topology.d.ts +16 -3
  27. package/dist/topology.js +31 -19
  28. package/dist/update.d.ts +10 -1
  29. package/dist/update.js +540 -16
  30. package/package.json +3 -3
  31. package/release-scope.json +1 -1
  32. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  33. package/templates/.claude/hooks/template-guard.sh +137 -0
  34. package/templates/.claude/rules/extending.md +106 -0
  35. package/templates/.claude/rules/loop-contract.md +2 -2
  36. package/templates/.claude/settings.json +11 -0
  37. package/templates/.env.example +5 -5
  38. package/templates/.github/workflows/ci.yml +7 -4
  39. package/templates/CLAUDE.md +11 -1
  40. package/templates/CONVENTIONS.md +8 -10
  41. package/templates/README.md +14 -13
  42. package/templates/apps/api/.env.example +2 -2
  43. package/templates/apps/api/CLAUDE.md +102 -76
  44. package/templates/apps/api/jest.config.cjs +15 -5
  45. package/templates/apps/api/package.json +16 -20
  46. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  47. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  48. package/templates/apps/api/src/index.ts +1 -1
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
  50. package/templates/apps/api/src/modules/commerce.ts +10 -0
  51. package/templates/apps/api/src/modules/content-write.ts +10 -0
  52. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  53. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  54. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  55. package/templates/apps/api/src/modules/workspace.ts +10 -0
  56. package/templates/apps/api/src/server.ts +117 -1882
  57. package/templates/apps/api/vercel.json +2 -2
  58. package/templates/apps/mcp/CLAUDE.md +67 -20
  59. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  60. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  61. package/templates/apps/mcp/jest.config.cjs +6 -1
  62. package/templates/apps/mcp/package.json +5 -13
  63. package/templates/apps/mcp/src/app.ts +8 -7
  64. package/templates/apps/mcp/src/index.ts +6 -34
  65. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  66. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  67. package/templates/apps/mcp/src/site.ts +12 -0
  68. package/templates/apps/web/.env.example +1 -1
  69. package/templates/apps/web/CLAUDE.md +10 -3
  70. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  71. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  72. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  73. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
  74. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  75. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  76. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  77. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  78. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  79. package/templates/apps/web/auth.ts +2 -2
  80. package/templates/apps/web/jest.config.cjs +1 -0
  81. package/templates/apps/web/lib/auth-email.ts +1 -1
  82. package/templates/apps/web/lib/relay.ts +2 -2
  83. package/templates/apps/web/lib/release-scope.ts +47 -0
  84. package/templates/apps/web/lib/server-api.ts +1 -1
  85. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  86. package/templates/customizations.yaml +37 -0
  87. package/templates/database/prisma.config.ts +10 -14
  88. package/templates/database/seed-test.ts +2 -2
  89. package/templates/database/seed.ts +5 -5
  90. package/templates/package.json +12 -6
  91. package/templates/pnpm-workspace.yaml +0 -1
  92. package/templates/project.yaml +16 -8
  93. package/templates/tools/jest/reporters.cjs +65 -0
  94. package/templates/tools/lint-architecture.ts +553 -7
  95. package/templates/tsconfig.json +1 -2
  96. package/versions.json +48 -35
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  98. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  99. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  100. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  101. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  102. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  103. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  104. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  105. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  106. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  107. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  108. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  109. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  110. package/templates/apps/api/src/bin/spokes.ts +0 -198
  111. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  112. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  113. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  114. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  115. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  116. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  117. package/templates/apps/api/src/config/index.ts +0 -596
  118. package/templates/apps/api/src/config/modules.ts +0 -83
  119. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  120. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  121. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  122. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  123. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  124. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  125. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  126. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  127. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  128. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  129. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  130. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  131. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  132. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  133. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  134. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  135. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  136. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  137. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  138. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
  139. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  140. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  141. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  142. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  143. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  144. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  145. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  146. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  147. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  148. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  149. package/templates/apps/api/src/lib/errors.ts +0 -95
  150. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  151. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  152. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  153. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  154. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  155. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  156. package/templates/apps/api/src/lib/locales.ts +0 -201
  157. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  158. package/templates/apps/api/src/lib/prisma.ts +0 -21
  159. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  160. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  161. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  162. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  163. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  164. package/templates/apps/api/src/lib/site.ts +0 -257
  165. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  166. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  167. package/templates/apps/api/src/lib/stripe.ts +0 -321
  168. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  169. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  170. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  171. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  172. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  173. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  174. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  175. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  176. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  177. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  178. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  179. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  180. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  181. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  182. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  183. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  184. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  185. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  186. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  187. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  188. package/templates/apps/api/src/middleware/auth.ts +0 -864
  189. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  190. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  191. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
  192. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  193. package/templates/apps/api/src/openapi/index.ts +0 -18
  194. package/templates/apps/api/src/openapi/spec.ts +0 -2148
  195. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  196. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  197. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  198. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  199. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  200. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  201. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  202. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  203. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  204. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  205. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  206. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  207. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  208. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
  209. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  210. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
  211. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  212. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  213. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  214. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  215. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  216. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  217. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  218. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  219. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  220. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  221. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  222. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  223. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  224. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  225. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  226. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  227. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  228. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  229. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  230. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  231. package/templates/apps/api/src/routes/content/get-page.ts +0 -183
  232. package/templates/apps/api/src/routes/content/index.ts +0 -49
  233. package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
  234. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  235. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  236. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  237. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  238. package/templates/apps/api/src/routes/content/search.ts +0 -100
  239. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  240. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  241. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  242. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  243. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  244. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  245. package/templates/apps/api/src/routes/leads/index.ts +0 -639
  246. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  247. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  248. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  249. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  250. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
  251. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
  252. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  253. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  254. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  255. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  256. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  257. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  258. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  259. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  260. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  261. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  262. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  263. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  264. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  265. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  266. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  267. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  268. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  269. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  270. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  271. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  272. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  273. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  274. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
  275. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  276. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  277. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  278. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  279. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  280. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  281. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  282. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  283. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
  284. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  285. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  286. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  287. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
  288. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
  289. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  290. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  291. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  292. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  293. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  294. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  295. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  296. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
  297. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  298. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  299. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  300. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  301. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  302. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  303. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  304. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  305. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  306. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
  307. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
  308. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
  309. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
  310. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  311. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  312. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  313. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  314. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  315. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  316. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  317. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  318. package/templates/apps/api/src/services/abandon-email.ts +0 -336
  319. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  320. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  321. package/templates/apps/api/src/services/audit-log.ts +0 -961
  322. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  323. package/templates/apps/api/src/services/consent.ts +0 -834
  324. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  325. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
  326. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  327. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  328. package/templates/apps/api/src/services/entitlements.ts +0 -338
  329. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  330. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  331. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  332. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  333. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  334. package/templates/apps/api/src/services/listing-config.ts +0 -178
  335. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  336. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
  337. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  338. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
  339. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  340. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
  341. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
  342. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  343. package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
  344. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
  345. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  346. package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
  347. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  348. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  349. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  350. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  351. package/templates/apps/api/src/services/person.ts +0 -1211
  352. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  353. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  354. package/templates/apps/api/src/services/project-listings.ts +0 -2085
  355. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  356. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  357. package/templates/apps/api/src/services/revalidation.ts +0 -235
  358. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  359. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  360. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  361. package/templates/apps/api/src/services/verified-email.ts +0 -71
  362. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  363. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  364. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  365. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  366. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  367. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  368. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  369. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  370. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  371. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  372. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  373. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  374. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  375. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  376. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  377. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  378. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
  379. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  380. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  381. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  382. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
  383. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
  384. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  385. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  386. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
  387. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  388. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
  389. package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
  390. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  391. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  392. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
  393. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
  394. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
  395. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
  396. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  397. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  398. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
  399. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
  400. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  401. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  402. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  403. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  404. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  405. package/templates/apps/mcp/src/abuse/index.ts +0 -349
  406. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  407. package/templates/apps/mcp/src/boot.ts +0 -167
  408. package/templates/apps/mcp/src/config/modules.ts +0 -43
  409. package/templates/apps/mcp/src/http-transport.ts +0 -564
  410. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  411. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  412. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
  413. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
  414. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  415. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  416. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  417. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  418. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  419. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  420. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  421. package/templates/apps/mcp/src/lib/site-config.ts +0 -53
  422. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  423. package/templates/apps/mcp/src/request-context.ts +0 -52
  424. package/templates/apps/mcp/src/server.ts +0 -343
  425. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  426. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  427. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
  428. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  429. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  430. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  431. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  432. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  433. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  434. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  435. package/templates/apps/mcp/src/tools/index.ts +0 -138
  436. package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
  437. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  438. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  439. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  440. package/templates/apps/mcp/src/tools/read_content.ts +0 -81
  441. package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
  442. package/templates/apps/mcp/src/tools/search_content.ts +0 -63
  443. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
  444. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  445. package/templates/database/CHANGELOG.md +0 -981
  446. package/templates/database/LICENSE +0 -202
  447. package/templates/database/NOTICE +0 -2
  448. package/templates/database/README.md +0 -243
  449. package/templates/database/__tests__/agents.db.test.ts +0 -320
  450. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  451. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  452. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  453. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  454. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  455. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  456. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  457. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  458. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  459. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  460. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  461. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  462. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  463. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  464. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  465. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  466. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  467. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  468. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  469. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  470. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  471. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  472. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  473. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  474. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  475. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  476. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  477. package/templates/database/__tests__/person.db.test.ts +0 -586
  478. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  479. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  480. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  481. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  482. package/templates/database/__tests__/seed.test.ts +0 -272
  483. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  484. package/templates/database/__tests__/tenancy.test.ts +0 -141
  485. package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
  486. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  487. package/templates/database/_gitignore +0 -8
  488. package/templates/database/auth/.gitkeep +0 -0
  489. package/templates/database/auth/schema.prisma +0 -641
  490. package/templates/database/config/.gitkeep +0 -0
  491. package/templates/database/config/schema.prisma +0 -441
  492. package/templates/database/content/.gitkeep +0 -0
  493. package/templates/database/content/schema.prisma +0 -339
  494. package/templates/database/eslint.config.mjs +0 -69
  495. package/templates/database/flows/listing-diagnostic.ts +0 -182
  496. package/templates/database/flows/presets.ts +0 -315
  497. package/templates/database/inbox/.gitkeep +0 -0
  498. package/templates/database/inbox/README.md +0 -3
  499. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  500. package/templates/database/inbox/schema.prisma +0 -1447
  501. package/templates/database/jest.config.cjs +0 -36
  502. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  503. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  504. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  505. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  506. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  507. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  508. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  509. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  510. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  511. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  512. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  513. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  514. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  515. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  516. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  517. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  518. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  519. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  520. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  521. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  522. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  523. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  524. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  525. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  526. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  527. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  528. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  529. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  530. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  531. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  532. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  533. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  534. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  535. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  536. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  537. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  538. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  539. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  540. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  541. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  542. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  543. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  544. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  545. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  546. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  547. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  548. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  549. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  550. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  551. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  552. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  553. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  554. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  555. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  556. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  557. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  558. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  559. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  560. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  561. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  562. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  563. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  564. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  565. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  566. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  567. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  568. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  569. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  570. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  571. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  572. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  573. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  574. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  575. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  576. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  577. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  578. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  579. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  580. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  581. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  582. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  583. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  584. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  585. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  586. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
  587. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
  588. package/templates/database/migrations/migration_lock.toml +0 -3
  589. package/templates/database/ops/.gitkeep +0 -0
  590. package/templates/database/ops/schema.prisma +0 -43
  591. package/templates/database/package.json +0 -89
  592. package/templates/database/schema.prisma +0 -71
  593. package/templates/database/scripts/db-generate-locked.sh +0 -80
  594. package/templates/database/scripts/person-backfill.sql +0 -132
  595. package/templates/database/tenancy.ts +0 -183
  596. package/templates/database/tsconfig.build.json +0 -16
  597. package/templates/database/tsconfig.flows.json +0 -15
  598. package/templates/database/tsconfig.json +0 -16
  599. package/templates/database/tsconfig.tenancy.json +0 -14
@@ -1,983 +0,0 @@
1
- /**
2
- * Per-IP rate limit middleware for anonymous write routes.
3
- *
4
- * SINGLE-REPLICA CAVEAT: the memory store below keeps an in-process Map of
5
- * request counts. Each Fastify instance maintains its own bucket state. On a
6
- * deployment with no database (or `AILK_RATE_LIMIT_STORE=memory`), an
7
- * attacker can issue up to N × perIpPerMinute requests per minute per IP,
8
- * where N is the replica/isolate count.
9
- *
10
- * SHARED STORE (#5040): on a deployment with a database, every registration
11
- * defaults to counting in `rate_limit_counters` instead (D4), so one
12
- * visitor's limit holds across every concurrent isolate. See
13
- * `apps/api/src/lib/rate-limit-store.ts` for the store seam and
14
- * `docs/specs/5040-the-api-s-rate-limiter-spec.md` D11 for the full list of
15
- * what this protects against and what it does not — in short: it stops one
16
- * identity from exceeding its ceiling by spreading requests over isolates,
17
- * but a caller that mints a fresh identity per request (a forged
18
- * `x-forwarded-for` the edge passes through unmodified) still gets a fresh
19
- * bucket every time, in either store.
20
- *
21
- * Cleanup strategy: evict stale buckets on every 429 emission and every 100
22
- * allowed requests (bounded, O(n) over bucket count — negligible for typical
23
- * IP cardinality). No setInterval is started until the middleware is registered,
24
- * keeping the module import-side-effect-free.
25
- *
26
- * Entry-count cap (#5111/#4780 AC-3): the eviction above is time-based only —
27
- * it removes buckets whose WINDOW has expired, never bounding the TOTAL
28
- * distinct-identity count within a still-open window. Once `trustProxy: true`
29
- * made `req.ip` a caller-influenced value (see below), a caller minting a
30
- * fresh identity on every request (e.g. varying `X-Forwarded-For`) can grow
31
- * `buckets` without bound for the lifetime of one isolate. `MAX_BUCKETS`
32
- * caps total entries independent of the time-based eviction: once at
33
- * capacity, a genuinely new identity evicts the single oldest-inserted entry
34
- * (`Map` iteration order is insertion order) before inserting itself — O(1)
35
- * amortized, no behavior change for any caller under the cap. The database
36
- * store has no such cap: it relies entirely on the sweep (D6) to bound its
37
- * row count.
38
- *
39
- * Trust-proxy boundary: this middleware reads req.ip, which Fastify populates
40
- * from the socket address and any trusted proxy headers (X-Forwarded-For) per
41
- * the server's own trustProxy config. This middleware does NOT parse
42
- * X-Forwarded-For itself. `server.ts` enables `trustProxy: true`, so req.ip
43
- * resolves to the leftmost (originating) address of a multi-hop
44
- * X-Forwarded-For chain.
45
- *
46
- * Consumer double-hop caveat (#4730): when a consumer site's own Next.js
47
- * server action calls one of the rate-limited scopes below (flows/leads/aeo/
48
- * schedule) on a visitor's behalf, `req.ip` would otherwise reflect the
49
- * calling web function's shared egress IP, not the visitor's — every visitor
50
- * of that consumer site would collapse into one bucket. The fix lives on the
51
- * caller's side: `@working-theory/api-client`'s `post()`/`get()` accept an
52
- * `init.headers` override, and the flows server action
53
- * (`apps/web/app/[locale]/flows/[slug]/flow.actions.ts`) forwards the
54
- * visitor's own `x-forwarded-for` (read from ITS OWN incoming request via
55
- * Next's `headers()`) as an explicit `x-forwarded-for` header on the outbound
56
- * call — appending to, not replacing, whatever hop this API's own edge adds,
57
- * so trustProxy's leftmost-address resolution still lands on the real
58
- * visitor.
59
- *
60
- * Per-visitor lead writes from a keyed spoke (#7394): a spoke's lead writes
61
- * carry the visitor's IP in `x-ailk-visitor-ip`, which this middleware
62
- * believes only alongside a hub-issued key in `x-ailk-visitor-forward-key`,
63
- * and only on the `FORWARDED_VISITOR_ROUTES`: the three lead writes, and
64
- * since #7633 the two schedule calls a site's same-origin relay forwards
65
- * (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book` — and
66
- * since #7479 the three listing-draft calls a spoke makes for its applicant
67
- * (`POST /v1/project-listings`, `PATCH /v1/project-listings/drafts/:id`,
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
70
- * counted per visitor there; a scope's own limiter does so only when its
71
- * registration passes `trustedKeyDigests` too, as the leads and schedule
72
- * scopes do. A believed visitor is
73
- * counted as `v<n>,<ip>` (n = the key's position in
74
- * `AILK_VISITOR_FORWARD_KEYS`), and every such request also counts against
75
- * its key's own `key,<n>` bucket. Both forms contain a comma, which `req.ip`
76
- * never does, so no unkeyed caller can reach either bucket. Every other
77
- * request keys on `req.ip`, as before. Spec:
78
- * docs/specs/7394-the-leads-rate-limit-scope-spec.md.
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
- *
95
- * Scope namespace and env limit (#7394): a registration with a `scope` keys
96
- * its buckets as `<scope>|<identity>`, so its count is not shared with any
97
- * other registration, and reads its limits from
98
- * `AILK_RATE_LIMIT_<SCOPE>_PER_MINUTE` / `..._PER_KEY_PER_MINUTE`, which win
99
- * over the code values. A registration with no `scope` behaves as it did.
100
- */
101
-
102
- import { createHash, timingSafeEqual } from "node:crypto";
103
- import { isIP } from "node:net";
104
-
105
- import { getClock } from "@working-theory/common";
106
- import type {
107
- FastifyRequest,
108
- FastifyReply,
109
- FastifyPluginCallback,
110
- FastifyBaseLogger,
111
- } from "fastify";
112
-
113
- import { prisma } from "../lib/prisma.js";
114
- import {
115
- createDatabaseRateLimitStore,
116
- resolveRateLimitStoreKind,
117
- type RateLimitBucket,
118
- type RateLimitStore,
119
- } from "../lib/rate-limit-store.js";
120
-
121
- export type RateLimitOptions = {
122
- /**
123
- * Requests allowed per IP per window.
124
- * Default: AILK_RATE_LIMIT_PER_IP_PER_MINUTE env var ?? 20 (#4730 — sized
125
- * to cover a full flow: the longest shipped flow, ailk-founder-diagnostic,
126
- * is 13 steps + 1 GET, plus headroom for an invisible aeo_score
127
- * submission and one legitimate resubmit; see apps/api/src/routes/flows/
128
- * README.md).
129
- */
130
- perIpPerMinute?: number;
131
- /** Window duration in milliseconds. Default: 60_000 */
132
- windowMs?: number;
133
- /**
134
- * Extract the IP key from a request. Default: the identity
135
- * `resolveRateLimitIdentity` returns, which is `req.ip` unless
136
- * `trustedKeyDigests` is non-empty. A caller-supplied function wins.
137
- */
138
- ipFromRequest?: (req: FastifyRequest) => string;
139
- /**
140
- * Maximum distinct entries `buckets` may hold at once, independent of the
141
- * time-based eviction. Default: `MAX_BUCKETS`. Exposed for tests only —
142
- * no production caller overrides this. Has no effect on the database
143
- * store, which has no entry cap of its own (D6's sweep bounds it instead).
144
- */
145
- maxBuckets?: number;
146
- /**
147
- * Bucket namespace (#7394). With one, buckets are keyed
148
- * `<scope>|<identity>` and the limits are read from
149
- * `AILK_RATE_LIMIT_<SCOPE>_PER_MINUTE` and
150
- * `AILK_RATE_LIMIT_<SCOPE>_PER_KEY_PER_MINUTE` first. Must match
151
- * `/^[a-z][a-z0-9]*$/`. Without one, buckets are keyed on the bare identity.
152
- */
153
- scope?: string;
154
- /**
155
- * The hub's forwarding-key digests, from `buildTrustedKeyDigests` (#7394).
156
- * Default: `[]`, which believes no forwarded visitor. A non-empty list
157
- * needs a `scope`.
158
- */
159
- trustedKeyDigests?: readonly (Buffer | null)[];
160
- /**
161
- * Requests allowed per forwarding key per window, across all of that key's
162
- * visitors (#7394). Default: 300.
163
- */
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>;
172
- /**
173
- * Override the process-default store (#5040 D4). Test-only, like
174
- * `maxBuckets` — no production caller overrides this.
175
- */
176
- store?: RateLimitStore;
177
- /**
178
- * Override `STORE_TIMEOUT_MS` (#5040 D5). Test-only.
179
- */
180
- storeTimeoutMs?: number;
181
- };
182
-
183
- const RATE_LIMITED = "rate_limited" as const;
184
-
185
- /**
186
- * The pre-#4730 per-scope default (10/min) — leads/aeo/schedule's own
187
- * `perIpPerMinute` (#5111, DEF-004). `flowsScope` keeps the wider
188
- * env-var-driven default below; it is NOT built from this constant.
189
- */
190
- export const DEFAULT_PER_IP_PER_MINUTE_STRICT = 10;
191
-
192
- /**
193
- * Default cap on `buckets`' total distinct entries (#5111/#4780 AC-3). Sized
194
- * generously above any plausible legitimate per-minute visitor cardinality
195
- * for this product's anonymous scopes, while still bounding worst-case
196
- * memory growth under an unbounded-identity-churn attack.
197
- */
198
- export const MAX_BUCKETS = 10_000;
199
-
200
- /**
201
- * The schedule scope's per-forwarding-key ceiling (#7633): well below the
202
- * lead routes' 300, because a booking is a real booking at the provider.
203
- * `AILK_RATE_LIMIT_SCHEDULE_PER_KEY_PER_MINUTE` overrides it.
204
- */
205
- export const DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE = 60;
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
-
215
- /**
216
- * How long a database-store call may run before the middleware falls back to
217
- * the memory store for that request (#5040 D5). `RateLimitOptions.storeTimeoutMs`
218
- * overrides it, test-only.
219
- */
220
- export const STORE_TIMEOUT_MS = 1_000;
221
-
222
- /** How often a `rate_limit_store_fallback` warn line may be logged (D5). */
223
- const FALLBACK_WARN_THROTTLE_MS = 60_000;
224
-
225
- /**
226
- * The only (method, route) pairs on which a forwarded visitor IP is believed
227
- * (#7394 DD-2): the three lead writes, the two schedule calls a site's
228
- * relay forwards on its visitors' behalf (#7633, the relay's security
229
- * review), the two consent writes (#8438) and the three listing-draft calls
230
- * (#7479). Matched against `req.method` and `req.routeOptions.url`, the
231
- * registered route pattern.
232
- */
233
- export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
234
- method: "GET" | "POST" | "PATCH";
235
- url: string;
236
- }>[] = [
237
- { method: "POST", url: "/v1/leads/submit" },
238
- { method: "POST", url: "/v1/leads/subscribe" },
239
- { method: "POST", url: "/v1/leads/callback" },
240
- { method: "GET", url: "/v1/schedule/availability" },
241
- { method: "POST", url: "/v1/schedule/book" },
242
- // #8438 D5/D9 — the cookie banner's log and the unsubscribe landing: a
243
- // site's server forwards each visitor's IP, so one spoke's visitors are
244
- // counted one by one rather than in one bucket keyed on its egress IP.
245
- { method: "POST", url: "/v1/consent/cookie" },
246
- { method: "POST", url: "/v1/consent/unsubscribe" },
247
- // #7479 — the listing-draft lifecycle a spoke's Server Action calls on its
248
- // applicant's behalf (`start`, the draft `patch`, `submit`), in `leadsScope`.
249
- { method: "POST", url: "/v1/project-listings" },
250
- { method: "PATCH", url: "/v1/project-listings/drafts/:id" },
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" },
255
- ];
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
-
321
- const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
322
- const FORWARD_KEY_HEADER = "x-ailk-visitor-forward-key";
323
- const MIN_FORWARD_KEY_LENGTH = 32;
324
- const MAX_VISITOR_IP_LENGTH = 45;
325
- const DEFAULT_PER_KEY_PER_MINUTE = 300;
326
- const SCOPE_RE = /^[a-z][a-z0-9]*$/;
327
- const ENV_LIMIT_RE = /^[1-9][0-9]{0,5}$/;
328
-
329
- function sha256(value: string): Buffer {
330
- return createHash("sha256").update(value).digest();
331
- }
332
-
333
- /**
334
- * Parse `AILK_VISITOR_FORWARD_KEYS` (comma-separated, one entry per spoke)
335
- * into one SHA-256 digest per accepted entry. An empty entry, or one shorter
336
- * than 32 characters, becomes `null` so every other entry keeps its position.
337
- * A short entry is reported by its 1-based position, never its value.
338
- */
339
- export function buildTrustedKeyDigests(
340
- raw: string | undefined,
341
- warn: (msg: string) => void,
342
- ): readonly (Buffer | null)[] {
343
- if (raw === undefined || raw.trim() === "") return [];
344
- return raw.split(",").map((entry, index) => {
345
- const key = entry.trim();
346
- if (key === "") return null;
347
- if (key.length < MIN_FORWARD_KEY_LENGTH) {
348
- warn(
349
- `AILK_VISITOR_FORWARD_KEYS entry ${index + 1} has fewer than ${MIN_FORWARD_KEY_LENGTH} characters and is ignored`,
350
- );
351
- return null;
352
- }
353
- return sha256(key);
354
- });
355
- }
356
-
357
- /** The eight hextets of a valid IPv6 address, or `null`. */
358
- function expandIpv6(address: string): number[] | null {
359
- let text = address;
360
- const tail: number[] = [];
361
- const lastColon = text.lastIndexOf(":");
362
- const last = text.slice(lastColon + 1);
363
- if (last.includes(".")) {
364
- const octets = last.split(".").map(Number);
365
- tail.push(((octets[0] ?? 0) << 8) | (octets[1] ?? 0));
366
- tail.push(((octets[2] ?? 0) << 8) | (octets[3] ?? 0));
367
- text = `${text.slice(0, lastColon + 1)}0`;
368
- }
369
- const halves = text.split("::");
370
- const parse = (part: string | undefined): number[] =>
371
- part ? part.split(":").map((hextet) => parseInt(hextet, 16)) : [];
372
- const head = parse(halves[0]);
373
- const rest = parse(halves[1]);
374
- if (tail.length > 0) {
375
- // The `0` placeholder stood in for the dotted tail's two hextets.
376
- (halves.length > 1 ? rest : head).pop();
377
- }
378
- const fill = 8 - head.length - rest.length - tail.length;
379
- if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
380
- const hextets = [
381
- ...head,
382
- ...new Array<number>(fill).fill(0),
383
- ...rest,
384
- ...tail,
385
- ];
386
- return hextets.every((h) => Number.isInteger(h) && h >= 0 && h <= 0xffff)
387
- ? hextets
388
- : null;
389
- }
390
-
391
- /**
392
- * Validate and normalise a forwarded visitor IP (#7394 DD-1): IPv4 as is, an
393
- * IPv4-mapped IPv6 address as its IPv4 form, any other IPv6 address as its
394
- * /64 prefix. `null` for anything else.
395
- */
396
- function normaliseVisitorIp(raw: string): string | null {
397
- const value = raw.trim().toLowerCase();
398
- if (value.length > MAX_VISITOR_IP_LENGTH || value.includes("%")) return null;
399
- const family = isIP(value);
400
- if (family === 4) return value;
401
- if (family !== 6) return null;
402
- const hextets = expandIpv6(value);
403
- if (!hextets) return null;
404
- const isV4Mapped =
405
- hextets.slice(0, 5).every((h) => h === 0) && hextets[5] === 0xffff;
406
- if (isV4Mapped) {
407
- const high = hextets[6] ?? 0;
408
- const low = hextets[7] ?? 0;
409
- return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
410
- }
411
- const prefix = hextets
412
- .slice(0, 4)
413
- .map((h) => h.toString(16).padStart(4, "0"))
414
- .join(":");
415
- return `${prefix}::/64`;
416
- }
417
-
418
- /**
419
- * The rate-limit identity of a request (#7394 DD-2). A request matching one
420
- * of `FORWARDED_VISITOR_ROUTES` by method and route, presenting a key whose
421
- * digest matches a
422
- * non-null slot, with a valid `x-ailk-visitor-ip`, is `v<n>,<ip>` where `n`
423
- * is the first matching slot. Everything else is `req.ip`. Every slot is
424
- * compared, in constant time per slot, without stopping at the first match.
425
- */
426
- export function resolveRateLimitIdentity(
427
- req: FastifyRequest,
428
- trustedKeyDigests: readonly (Buffer | null)[],
429
- ): { identity: string; keyIndex: number | null } {
430
- const fallback = { identity: req.ip, keyIndex: null };
431
- if (!trustedKeyDigests.some((digest) => digest !== null)) return fallback;
432
- const url = req.routeOptions.url ?? "";
433
- const listed = FORWARDED_VISITOR_ROUTES.some(
434
- (route) => route.method === req.method && route.url === url,
435
- );
436
- if (!listed) return fallback;
437
-
438
- const presented = req.headers[FORWARD_KEY_HEADER];
439
- if (typeof presented !== "string" || presented === "") return fallback;
440
- const presentedDigest = sha256(presented);
441
- let keyIndex: number | null = null;
442
- for (const [index, digest] of trustedKeyDigests.entries()) {
443
- if (digest === null) continue;
444
- const matched = timingSafeEqual(digest, presentedDigest);
445
- if (matched && keyIndex === null) keyIndex = index;
446
- }
447
- if (keyIndex === null) return fallback;
448
-
449
- const visitorIp = req.headers[VISITOR_IP_HEADER];
450
- const normalised =
451
- typeof visitorIp === "string" ? normaliseVisitorIp(visitorIp) : null;
452
- if (normalised === null) return fallback;
453
- return { identity: `v${keyIndex},${normalised}`, keyIndex };
454
- }
455
-
456
- /**
457
- * A named scope's limit (#7394 DD-4): `AILK_RATE_LIMIT_<SCOPE>_<suffix>` when
458
- * it is a whole number from 1 to 999999, else `fallback`. A set but invalid
459
- * value is reported once and ignored. An unnamed registration always gets
460
- * `fallback`.
461
- */
462
- function resolveScopedLimit(
463
- scope: string | undefined,
464
- suffix: "PER_MINUTE" | "PER_KEY_PER_MINUTE",
465
- fallback: number,
466
- warn: (msg: string) => void,
467
- ): number {
468
- if (scope === undefined) return fallback;
469
- const name = `AILK_RATE_LIMIT_${scope.toUpperCase()}_${suffix}`;
470
- const raw = process.env[name];
471
- if (raw === undefined || raw === "") return fallback;
472
- if (ENV_LIMIT_RE.test(raw)) return Number(raw);
473
- warn(
474
- `${name} is not a whole number from 1 to 999999 and is ignored; using ${fallback}`,
475
- );
476
- return fallback;
477
- }
478
-
479
- // Module-scoped bucket store (the memory store's backing state). One entry
480
- // per unique identity, per named scope (#7394). Per-forwarding-key buckets
481
- // live in `keyBuckets` below.
482
- const buckets = new Map<string, RateLimitBucket>();
483
-
484
- // Per-forwarding-key buckets (#7394 DD-8), kept apart from `buckets` so the
485
- // `maxBuckets` FIFO eviction can never drop one: a keyed caller churning
486
- // visitor IPs would otherwise push its own key bucket out and reset its
487
- // ceiling. At most one entry per configured key per named scope, so it
488
- // needs no capacity cap of its own.
489
- const keyBuckets = new Map<string, RateLimitBucket>();
490
-
491
- // Request counter used to trigger periodic cleanup.
492
- let requestsSinceLastClean = 0;
493
- const CLEAN_INTERVAL_REQUESTS = 100;
494
-
495
- function evictStaleBuckets(now: number, windowMs: number): void {
496
- for (const [ip, bucket] of buckets) {
497
- if (now - bucket.windowStartMs >= windowMs) {
498
- buckets.delete(ip);
499
- }
500
- }
501
- for (const [key, bucket] of keyBuckets) {
502
- if (now - bucket.windowStartMs >= windowMs) {
503
- keyBuckets.delete(key);
504
- }
505
- }
506
- }
507
-
508
- /**
509
- * Test-only reset. Clears all bucket state between test cases so they do not
510
- * interfere with each other. Not intended for production use.
511
- */
512
- export function __resetBuckets(): void {
513
- buckets.clear();
514
- keyBuckets.clear();
515
- requestsSinceLastClean = 0;
516
- }
517
-
518
- /**
519
- * Test-only accessor for the current distinct-bucket count. Not intended for
520
- * production use.
521
- */
522
- export function __bucketCount(): number {
523
- return buckets.size;
524
- }
525
-
526
- /**
527
- * The memory store (#5040 D1/D7): the Maps and eviction logic above, wrapped
528
- * behind the `RateLimitStore` seam, unchanged in behavior. `maxBuckets` is
529
- * captured per call (registrations may pass their own, test-only) but every
530
- * instance still operates on the SAME shared `buckets` / `keyBuckets` Maps —
531
- * there has never been more than one memory store's worth of state in this
532
- * process.
533
- */
534
- function memoryRateLimitStore(maxBuckets: number): RateLimitStore {
535
- function hit(key: string, now: number, windowMs: number): Promise<RateLimitBucket> {
536
- let bucket = buckets.get(key);
537
-
538
- if (!bucket || now - bucket.windowStartMs >= windowMs) {
539
- // A genuinely new distinct identity (not a window roll on an
540
- // existing bucket) at capacity evicts the single oldest-inserted
541
- // entry first — independent of the time-based eviction above,
542
- // which only removes EXPIRED windows and may leave `buckets` at or
543
- // above `maxBuckets` when every entry is still within its window.
544
- if (!buckets.has(key) && buckets.size >= maxBuckets) {
545
- const oldestKey = buckets.keys().next().value;
546
- if (oldestKey !== undefined) {
547
- buckets.delete(oldestKey);
548
- }
549
- }
550
- bucket = { count: 0, windowStartMs: now };
551
- buckets.set(key, bucket);
552
- }
553
-
554
- bucket.count += 1;
555
- return Promise.resolve(bucket);
556
- }
557
-
558
- function hitKey(key: string, now: number, windowMs: number): Promise<RateLimitBucket> {
559
- let bucket = keyBuckets.get(key);
560
- if (!bucket || now - bucket.windowStartMs >= windowMs) {
561
- bucket = { count: 0, windowStartMs: now };
562
- keyBuckets.set(key, bucket);
563
- }
564
- bucket.count += 1;
565
- return Promise.resolve(bucket);
566
- }
567
-
568
- function hasOpenWindow(key: string, now: number, windowMs: number): Promise<boolean> {
569
- const bucket = buckets.get(key);
570
- return Promise.resolve(
571
- bucket !== undefined && now - bucket.windowStartMs < windowMs,
572
- );
573
- }
574
-
575
- // D6 — the memory store sweeps on both triggers, exactly as
576
- // evictStaleBuckets always has; `reason` only matters to the database
577
- // store.
578
- function sweep(now: number, windowMs: number): Promise<void> {
579
- evictStaleBuckets(now, windowMs);
580
- return Promise.resolve();
581
- }
582
-
583
- return { hit, hitKey, hasOpenWindow, sweep };
584
- }
585
-
586
- // The process-wide default store (#5040 D4), resolved once and memoized —
587
- // NOT reset by __resetBuckets(), since only a fresh module instance
588
- // (jest.isolateModules) changes which store the environment resolves to.
589
- let processStoreKind: "memory" | "database" | undefined;
590
- let processDatabaseStore: RateLimitStore | undefined;
591
- let loggedStoreKind = false;
592
-
593
- /**
594
- * A rejection or timeout fired this long ago, within `FALLBACK_WARN_THROTTLE_MS`
595
- * — do not log another `rate_limit_store_fallback` line yet.
596
- */
597
- let lastFallbackWarnAt: number | undefined;
598
-
599
- /** The process default store for a registration with no explicit `store` option. */
600
- function resolveProcessDefaultStore(
601
- warn: (msg: string) => void,
602
- log: Pick<FastifyBaseLogger, "info">,
603
- maxBuckets: number,
604
- ): RateLimitStore {
605
- if (processStoreKind === undefined) {
606
- processStoreKind = resolveRateLimitStoreKind(
607
- {
608
- DATABASE_URL: process.env.DATABASE_URL,
609
- AILK_RATE_LIMIT_STORE: process.env.AILK_RATE_LIMIT_STORE,
610
- },
611
- warn,
612
- );
613
- }
614
- if (!loggedStoreKind) {
615
- loggedStoreKind = true;
616
- log.info({ rateLimitStore: processStoreKind }, "rate_limit_store");
617
- }
618
- if (processStoreKind === "database") {
619
- processDatabaseStore ??= createDatabaseRateLimitStore(prisma);
620
- return processDatabaseStore;
621
- }
622
- return memoryRateLimitStore(maxBuckets);
623
- }
624
-
625
- class RateLimitStoreTimeoutError extends Error {}
626
-
627
- /** Reject with a `RateLimitStoreTimeoutError` if `promise` has not settled within `ms`. */
628
- function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
629
- return new Promise<T>((resolve, reject) => {
630
- const timer = setTimeout(() => {
631
- reject(new RateLimitStoreTimeoutError("rate limit store call timed out"));
632
- }, ms);
633
- promise.then(
634
- (value) => {
635
- clearTimeout(timer);
636
- resolve(value);
637
- },
638
- (err: unknown) => {
639
- clearTimeout(timer);
640
- reject(err instanceof Error ? err : new Error(String(err)));
641
- },
642
- );
643
- });
644
- }
645
-
646
- /**
647
- * Log at most one `rate_limit_store_fallback` warn per `FALLBACK_WARN_THROTTLE_MS`
648
- * per process (#5040 D5). Carries no key, IP, hash or error message.
649
- */
650
- function warnFallback(
651
- warn: (obj: Record<string, unknown>, msg: string) => void,
652
- now: number,
653
- reason: "error" | "timeout",
654
- ): void {
655
- if (
656
- lastFallbackWarnAt !== undefined &&
657
- now - lastFallbackWarnAt < FALLBACK_WARN_THROTTLE_MS
658
- ) {
659
- return;
660
- }
661
- lastFallbackWarnAt = now;
662
- warn({ event: "rate_limit_store_fallback", reason }, "rate_limit_store_fallback");
663
- }
664
-
665
- // Break Fastify's plugin encapsulation so the onRequest hook propagates to
666
- // parent and sibling scopes. This is the manual equivalent of fastify-plugin's
667
- // `skip-override` mechanism — documented in Fastify's plugin guide.
668
- // Without this, routes registered outside this plugin's scope do not see the hook.
669
- export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
670
- fastify,
671
- opts,
672
- done,
673
- ) => {
674
- const {
675
- windowMs = 60_000,
676
- maxBuckets = MAX_BUCKETS,
677
- scope,
678
- trustedKeyDigests = [],
679
- storeTimeoutMs = STORE_TIMEOUT_MS,
680
- } = opts;
681
-
682
- if (scope !== undefined && !SCOPE_RE.test(scope)) {
683
- done(
684
- new Error(
685
- `rate-limit scope ${JSON.stringify(scope)} must match ${SCOPE_RE}`,
686
- ),
687
- );
688
- return;
689
- }
690
- if (trustedKeyDigests.length > 0 && scope === undefined) {
691
- done(new Error("rate-limit trustedKeyDigests needs a scope"));
692
- return;
693
- }
694
- if (opts.siteKeyOf !== undefined && scope === undefined) {
695
- done(new Error("rate-limit siteKeyOf needs a scope"));
696
- return;
697
- }
698
-
699
- const warn = (msg: string) => fastify.log.warn(msg);
700
- const perIpPerMinute = resolveScopedLimit(
701
- scope,
702
- "PER_MINUTE",
703
- opts.perIpPerMinute ??
704
- (Number(process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE) || 20),
705
- warn,
706
- );
707
- const perKeyPerMinute = resolveScopedLimit(
708
- scope,
709
- "PER_KEY_PER_MINUTE",
710
- opts.perKeyPerMinute ?? DEFAULT_PER_KEY_PER_MINUTE,
711
- warn,
712
- );
713
- const { ipFromRequest, siteKeyOf } = opts;
714
- const identityOf: (req: FastifyRequest) => {
715
- identity: string;
716
- keyIndex: number | null;
717
- } = ipFromRequest
718
- ? (req) => ({ identity: ipFromRequest(req), keyIndex: null })
719
- : (req) => resolveRateLimitIdentity(req, trustedKeyDigests);
720
- const bucketKey = (identity: string) =>
721
- scope === undefined ? identity : `${scope}|${identity}`;
722
-
723
- const fallbackStore = memoryRateLimitStore(maxBuckets);
724
- const primaryStore: RateLimitStore =
725
- opts.store ?? resolveProcessDefaultStore(warn, fastify.log, maxBuckets);
726
-
727
- /**
728
- * Run the whole request's counting against `activeStore` (D7's unchanged
729
- * control flow: hitKey → keyOver → R-1 check (hasOpenWindow) → hit →
730
- * tripped), plus the periodic/on-429 sweep (D6).
731
- */
732
- async function countRequest(
733
- activeStore: RateLimitStore,
734
- ip: string,
735
- keyIndex: number | null,
736
- siteId: string | null,
737
- now: number,
738
- sweepOnInterval: boolean,
739
- ): Promise<{
740
- tripped: RateLimitBucket | undefined;
741
- visitorOver: boolean;
742
- keyOver: boolean;
743
- }> {
744
- if (sweepOnInterval) {
745
- await activeStore.sweep(now, windowMs, "interval");
746
- }
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
-
765
- // #7394 DD-8: a keyed request also counts against its key's own
766
- // bucket, even when the visitor bucket is already over its limit.
767
- const keyBucket =
768
- keyIndex === null
769
- ? undefined
770
- : await activeStore.hitKey(`${scope}|key,${keyIndex}`, now, windowMs);
771
- const keyOver =
772
- keyBucket !== undefined && keyBucket.count > perKeyPerMinute;
773
- // A key over its ceiling opens no NEW visitor bucket (#7394 security
774
- // review R-1). Each new entry can evict the oldest one in `buckets`,
775
- // another scope's counter included, so without this a key holder
776
- // rotating visitor IPs could wipe every strict limiter at will. A
777
- // visitor whose window is still open is counted as usual.
778
- const visitorKey = bucketKey(ip);
779
- const bucket =
780
- keyOver && !(await activeStore.hasOpenWindow(visitorKey, now, windowMs))
781
- ? undefined
782
- : await activeStore.hit(visitorKey, now, windowMs);
783
- const visitorOver = bucket !== undefined && bucket.count > perIpPerMinute;
784
- const tripped = visitorOver ? bucket : keyOver ? keyBucket : undefined;
785
-
786
- if (tripped) {
787
- // Evict stale buckets on every 429 to cap memory under abuse.
788
- await activeStore.sweep(now, windowMs, "limited");
789
- }
790
-
791
- return { tripped, visitorOver, keyOver };
792
- }
793
-
794
- fastify.addHook(
795
- "onRequest",
796
- async (req: FastifyRequest, reply: FastifyReply) => {
797
- const now = getClock().now();
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 };
803
-
804
- requestsSinceLastClean += 1;
805
- const sweepOnInterval = requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS;
806
- if (sweepOnInterval) requestsSinceLastClean = 0;
807
-
808
- // #5040 D5 — fail open to the memory store, never to "no limit": a
809
- // rejection or a timeout on ANY call of the primary store redoes the
810
- // WHOLE request's counting against the memory store, so one request
811
- // never mixes stores. withTimeout does not cancel the primary store's
812
- // in-flight call, so a write that already landed before the failure
813
- // stays counted, and a timed-out query may still land later — the
814
- // request can be counted in BOTH stores, over-counting by at most
815
- // one. That is the safe direction (D5): never under-counts, never
816
- // lets a request through uncounted.
817
- let outcome: {
818
- tripped: RateLimitBucket | undefined;
819
- visitorOver: boolean;
820
- keyOver: boolean;
821
- };
822
- try {
823
- outcome = await withTimeout(
824
- countRequest(primaryStore, ip, keyIndex, siteId, now, sweepOnInterval),
825
- storeTimeoutMs,
826
- );
827
- } catch (err) {
828
- warnFallback(
829
- (obj, msg) => fastify.log.warn(obj, msg),
830
- now,
831
- err instanceof RateLimitStoreTimeoutError ? "timeout" : "error",
832
- );
833
- outcome = await countRequest(
834
- fallbackStore,
835
- ip,
836
- keyIndex,
837
- siteId,
838
- now,
839
- sweepOnInterval,
840
- );
841
- }
842
-
843
- const { tripped, visitorOver } = outcome;
844
-
845
- if (tripped) {
846
- const retryAfterSeconds = Math.ceil(
847
- (tripped.windowStartMs + windowMs - now) / 1_000,
848
- );
849
-
850
- // The path WITHOUT its query string (#5970 security review, finding 1): a
851
- // route may carry its credential there (`/v1/requests/:id/confirm?token=…`
852
- // does), and a rate-limit warning is a line an attacker can provoke at
853
- // will — logging the raw target would let them farm credentials out of
854
- // the operator's own log by tripping the limiter.
855
- const pathOnly = req.url.split("?")[0] ?? req.url;
856
- // A keyed 429 names the bucket that tripped and the key's position,
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.
859
- req.log.warn(
860
- {
861
- ip,
862
- path: pathOnly,
863
- count: tripped.count,
864
- ...(keyIndex !== null && {
865
- bucket: visitorOver ? "visitor" : "key",
866
- keyIndex,
867
- }),
868
- ...(siteId !== null && { bucket: "site" }),
869
- },
870
- RATE_LIMITED,
871
- );
872
-
873
- return reply
874
- .status(429)
875
- .header("Retry-After", String(retryAfterSeconds))
876
- .send({ error: RATE_LIMITED, retryAfterSeconds });
877
- }
878
- },
879
- );
880
-
881
- done();
882
- };
883
-
884
- // Skip Fastify's plugin encapsulation — hooks registered inside propagate to
885
- // all scopes (parent + siblings), not just descendants of this plugin.
886
- (rateLimitMiddleware as FastifyPluginCallback & { [key: symbol]: boolean })[
887
- Symbol.for("skip-override")
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
- }