create-ailk 0.2.0 → 0.3.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 (273) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/lib/apply-module-patches.d.ts +47 -0
  3. package/dist/lib/apply-module-patches.js +370 -0
  4. package/dist/lib/extract-module-bundle.d.ts +12 -3
  5. package/dist/lib/extract-module-bundle.js +236 -24
  6. package/dist/lib/fetch-module.d.ts +3 -38
  7. package/dist/lib/fetch-module.js +26 -8
  8. package/dist/lib/module-license-gate.d.ts +2 -0
  9. package/dist/lib/module-license-gate.js +1 -0
  10. package/dist/lib/paid-paths.d.ts +16 -0
  11. package/dist/lib/paid-paths.js +90 -0
  12. package/dist/module-architecture.d.ts +139 -23
  13. package/dist/module-architecture.js +668 -25
  14. package/dist/parse-args.d.ts +2 -2
  15. package/dist/parse-args.js +3 -1
  16. package/dist/programmatic.d.ts +2 -1
  17. package/dist/programmatic.js +15 -4
  18. package/dist/surfaces.d.ts +12 -36
  19. package/dist/surfaces.js +35 -5
  20. package/dist/sync-routes.js +31 -1
  21. package/package.json +2 -2
  22. package/templates/.claude/agents/web.md +2 -3
  23. package/templates/.claude/rules/architecture.md +5 -5
  24. package/templates/.claude/skills/README.md +2 -1
  25. package/templates/.env.example +21 -9
  26. package/templates/CONVENTIONS.md +6 -7
  27. package/templates/apps/api/.env.example +9 -0
  28. package/templates/apps/api/CLAUDE.md +33 -5
  29. package/templates/apps/api/package.json +0 -1
  30. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  31. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  32. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  33. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  34. package/templates/apps/api/src/config/index.ts +19 -0
  35. package/templates/apps/api/src/config/modules.ts +12 -14
  36. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  37. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  38. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  39. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  40. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  41. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  42. package/templates/apps/api/src/openapi/index.ts +9 -3
  43. package/templates/apps/api/src/openapi/spec.ts +505 -65
  44. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  45. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  46. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/index.ts +38 -11
  49. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  50. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  58. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  59. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  60. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  61. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  63. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  64. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  65. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  66. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  67. package/templates/apps/api/src/server.ts +459 -135
  68. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  69. package/templates/apps/api/src/services/listing-config.ts +58 -0
  70. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  71. package/templates/apps/api/src/services/project-listings.ts +229 -4
  72. package/templates/apps/api/src/vercel-handler.ts +60 -26
  73. package/templates/apps/mcp/.env.example +8 -0
  74. package/templates/apps/mcp/CLAUDE.md +2 -2
  75. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  76. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  77. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  78. package/templates/apps/mcp/src/config/modules.ts +14 -13
  79. package/templates/apps/mcp/src/server.ts +8 -4
  80. package/templates/apps/mcp/src/tools/index.ts +7 -22
  81. package/templates/apps/web/.env.example +15 -0
  82. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  83. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  84. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  91. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  92. package/templates/apps/web/jest.config.cjs +14 -13
  93. package/templates/apps/web/next.config.mjs +1 -3
  94. package/templates/apps/web/package.json +0 -6
  95. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  96. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  97. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  98. package/templates/apps/web/public/favicon-16x16.png +0 -0
  99. package/templates/apps/web/public/favicon-32x32.png +0 -0
  100. package/templates/apps/web/public/favicon.ico +0 -0
  101. package/templates/apps/web/public/favicon.svg +6 -3
  102. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  103. package/templates/apps/web/public/logomark.svg +4 -0
  104. package/templates/apps/web/public/site.webmanifest +2 -2
  105. package/templates/apps/web/public/wordmark.svg +4 -0
  106. package/templates/database/CHANGELOG.md +33 -0
  107. package/templates/database/inbox/schema.prisma +67 -0
  108. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  109. package/templates/database/package.json +1 -1
  110. package/templates/package.json +1 -1
  111. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  112. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  113. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  114. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  115. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  116. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  117. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  118. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  119. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  120. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  121. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  122. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  123. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  124. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  125. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  126. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  127. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  128. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  129. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  130. package/templates/apps/api/src/lib/stripe.ts +0 -52
  131. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  132. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  133. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  134. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  135. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  136. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  137. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  138. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  139. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  140. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  141. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  142. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  143. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  144. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  145. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  146. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  147. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  148. package/templates/apps/api/src/routes/flows/README.md +0 -147
  149. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  150. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  151. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  152. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  153. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  154. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  155. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  156. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  157. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  158. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  159. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  160. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  161. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  162. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  163. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  164. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  165. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  166. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  167. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  168. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  169. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  170. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  171. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  172. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  173. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  174. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  175. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  176. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  177. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  178. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  179. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  180. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  181. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  182. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  183. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  184. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  185. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  186. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  187. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  188. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  189. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  190. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  191. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  192. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  193. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  194. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  195. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  196. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  197. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  198. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  199. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  200. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  201. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  202. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  203. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  204. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  205. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  206. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  207. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  208. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  209. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  210. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  211. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  212. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  213. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  214. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  215. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  216. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  217. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  218. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  219. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  220. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  221. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  222. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  223. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  224. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  225. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  226. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  227. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  228. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  229. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  230. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  231. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  232. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  233. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  234. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  235. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  236. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  237. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  238. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  239. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  240. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  241. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  242. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  243. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  246. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  247. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  248. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  249. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  250. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  251. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  252. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  253. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  254. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  255. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  256. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  257. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  258. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  259. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  260. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  261. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  262. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  263. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  264. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  265. package/templates/apps/web/content/en/blog/README.txt +0 -25
  266. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  267. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  268. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  269. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  270. package/templates/apps/web/lib/source.ts +0 -34
  271. package/templates/apps/web/public/logomark-dark.svg +0 -4
  272. package/templates/apps/web/public/logomark-light.svg +0 -4
  273. package/templates/apps/web/source.config.ts +0 -78
@@ -1,270 +0,0 @@
1
- /**
2
- * POST /api/webhooks/stripe — org-scoped billing path (#3601, spec AC2/AC4)
3
- *
4
- * Covers the Organization billing-link branch keyed on
5
- * metadata.organizationId (spec D2): a checkout with organizationId links the
6
- * Organization (real org AND individual-as-default-org), idempotently; a
7
- * checkout WITHOUT organizationId takes the User path and never touches the
8
- * Organization (single-tenant, degrade-clean regression floor).
9
- *
10
- * Companion to stripe.test.ts (the User-path tests, which stay unmodified —
11
- * AC4 regression floor).
12
- */
13
- import type { FastifyInstance } from "fastify";
14
- import Fastify from "fastify";
15
-
16
- // ── Mocks (mirror stripe.test.ts) ────────────────────────────────────────────
17
-
18
- const mockTrack = jest.fn().mockResolvedValue(undefined);
19
- jest.mock("@working-theory/analytics/server", () => ({
20
- track: (...args: unknown[]) => mockTrack(...args),
21
- }));
22
-
23
- // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
24
- jest.mock("../../../lib/prisma.js");
25
-
26
- const mockVerifyWebhookSignature = jest.fn();
27
- class MockStripeWebhookSignatureError extends Error {
28
- constructor(message: string) {
29
- super(message);
30
- this.name = "StripeWebhookSignatureError";
31
- }
32
- }
33
- jest.mock("@working-theory/stripe-client", () => ({
34
- verifyWebhookSignature: (...args: unknown[]) =>
35
- mockVerifyWebhookSignature(...args),
36
- StripeWebhookSignatureError: MockStripeWebhookSignatureError,
37
- assertSandboxKey: jest.fn(),
38
- createCheckoutSession: jest.fn(),
39
- getStripeClient: jest.fn(),
40
- STRIPE_API_VERSION: "2024-06-20",
41
- StripeConfigError: class StripeConfigError extends Error {},
42
- UnknownProductCodeError: class UnknownProductCodeError extends Error {},
43
- WEBHOOK_TOLERANCE_SECONDS: 300,
44
- }));
45
-
46
- import * as configModule from "../../../config/index.js";
47
- import { prisma } from "../../../lib/prisma.js";
48
- import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
49
- import { stripeWebhookHandler } from "../stripe.js";
50
-
51
- // The sentinel default Organization id (orgs-of-one billing anchor).
52
- const DEFAULT_ORG_ID = "org_00000000-0000-7000-8000-000000000001";
53
- const REAL_ORG_ID = "org_00000000-0000-7000-8000-0000000000ab";
54
-
55
- const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
56
- const VALID_SIGNATURE = "t=1234,v1=abc";
57
-
58
- async function buildTestServer(): Promise<FastifyInstance> {
59
- const server = Fastify({ logger: false });
60
- await server.register(async (scope) => {
61
- scope.addContentTypeParser(
62
- "application/json",
63
- { parseAs: "buffer" },
64
- (_req, body, done) => done(null, body),
65
- );
66
- scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
67
- });
68
- await server.ready();
69
- return server;
70
- }
71
-
72
- function checkoutEvent(
73
- id: string,
74
- metadata: Record<string, string> | null,
75
- customer = "cus_test_org_123",
76
- ) {
77
- return {
78
- id,
79
- type: "checkout.session.completed",
80
- data: {
81
- object: {
82
- id: "cs_test_org",
83
- amount_total: 9900,
84
- currency: "usd",
85
- customer,
86
- customer_details: { email: "org-admin@example.com" },
87
- metadata,
88
- },
89
- },
90
- };
91
- }
92
-
93
- async function fire(server: FastifyInstance) {
94
- return server.inject({
95
- method: "POST",
96
- url: "/v1/webhooks/stripe",
97
- payload: VALID_BODY,
98
- headers: {
99
- "content-type": "application/json",
100
- "stripe-signature": VALID_SIGNATURE,
101
- },
102
- });
103
- }
104
-
105
- describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
106
- let server: FastifyInstance;
107
-
108
- beforeEach(async () => {
109
- jest.clearAllMocks();
110
- __resetIdempotencyStore();
111
- jest.replaceProperty(configModule, "config", {
112
- ...configModule.config,
113
- STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
114
- });
115
- server = await buildTestServer();
116
- });
117
-
118
- afterEach(async () => {
119
- await server.close();
120
- jest.restoreAllMocks();
121
- });
122
-
123
- // AC2 — a real org checkout links the Organization by id; replay is a no-op.
124
- it("links the Organization on checkout with metadata.organizationId, idempotent on replay", async () => {
125
- const customerId = "cus_test_org_123";
126
-
127
- // First fire: no prior composite link → update the target org.
128
- (prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
129
- (prisma.organization.update as jest.Mock).mockResolvedValueOnce({
130
- id: REAL_ORG_ID,
131
- billingProvider: "stripe",
132
- billingCustomerId: customerId,
133
- });
134
-
135
- mockVerifyWebhookSignature.mockReturnValue(
136
- checkoutEvent("evt_org_001", { organizationId: REAL_ORG_ID }, customerId),
137
- );
138
- const r1 = await fire(server);
139
- expect(r1.statusCode).toBe(200);
140
- expect(prisma.organization.update).toHaveBeenCalledTimes(1);
141
- expect(prisma.organization.update).toHaveBeenCalledWith(
142
- expect.objectContaining({
143
- where: { id: REAL_ORG_ID },
144
- data: expect.objectContaining({
145
- billingProvider: "stripe",
146
- billingCustomerId: customerId,
147
- }),
148
- }),
149
- );
150
- // The User path is never taken for an org checkout.
151
- expect(prisma.user.update).not.toHaveBeenCalled();
152
-
153
- // Replay the SAME event id → idempotency store short-circuits, no 2nd write.
154
- mockVerifyWebhookSignature.mockReturnValue(
155
- checkoutEvent("evt_org_001", { organizationId: REAL_ORG_ID }, customerId),
156
- );
157
- const r2 = await fire(server);
158
- expect(r2.statusCode).toBe(200);
159
- expect(prisma.organization.update).toHaveBeenCalledTimes(1);
160
- });
161
-
162
- // AC2 — typed-composite match already pointing at this org → no-op (a fresh
163
- // event id, but the customer is already linked to the target org).
164
- it("is a no-op when the org is already linked to this customer (typed-composite match)", async () => {
165
- const customerId = "cus_already_linked";
166
- (prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce({
167
- id: REAL_ORG_ID,
168
- billingProvider: "stripe",
169
- billingCustomerId: customerId,
170
- });
171
-
172
- mockVerifyWebhookSignature.mockReturnValue(
173
- checkoutEvent("evt_org_002", { organizationId: REAL_ORG_ID }, customerId),
174
- );
175
- const r = await fire(server);
176
- expect(r.statusCode).toBe(200);
177
- expect(prisma.organization.update).not.toHaveBeenCalled();
178
- });
179
-
180
- // AC2 — an individual is billed on their sentinel default Organization
181
- // (orgs-of-one): the SAME org path, targeting DEFAULT_ORG_ID.
182
- it("bills an individual on their sentinel default Organization (orgs-of-one)", async () => {
183
- const customerId = "cus_individual_xyz";
184
- (prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
185
- (prisma.organization.update as jest.Mock).mockResolvedValueOnce({
186
- id: DEFAULT_ORG_ID,
187
- billingProvider: "stripe",
188
- billingCustomerId: customerId,
189
- });
190
-
191
- mockVerifyWebhookSignature.mockReturnValue(
192
- checkoutEvent(
193
- "evt_org_003",
194
- { organizationId: DEFAULT_ORG_ID },
195
- customerId,
196
- ),
197
- );
198
- const r = await fire(server);
199
- expect(r.statusCode).toBe(200);
200
- expect(prisma.organization.update).toHaveBeenCalledWith(
201
- expect.objectContaining({ where: { id: DEFAULT_ORG_ID } }),
202
- );
203
- expect(prisma.user.update).not.toHaveBeenCalled();
204
- });
205
-
206
- // AC4 — a checkout WITHOUT organizationId takes the User path unchanged; the
207
- // Organization is never touched (single-tenant degrade-clean regression).
208
- it("takes the User path (never touches Organization) when no organizationId is present", async () => {
209
- const customerId = "cus_single_tenant";
210
- (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
211
- (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
212
- id: "usr_st",
213
- email: "org-admin@example.com",
214
- billingProvider: null,
215
- billingCustomerId: null,
216
- });
217
- (prisma.user.update as jest.Mock).mockResolvedValueOnce({ id: "usr_st" });
218
-
219
- mockVerifyWebhookSignature.mockReturnValue(
220
- checkoutEvent("evt_st_004", { productId: "pro" }, customerId),
221
- );
222
- const r = await fire(server);
223
- expect(r.statusCode).toBe(200);
224
- expect(prisma.user.update).toHaveBeenCalledTimes(1);
225
- expect(prisma.organization.findFirst).not.toHaveBeenCalled();
226
- expect(prisma.organization.update).not.toHaveBeenCalled();
227
- });
228
-
229
- // customer.created also routes on metadata.organizationId.
230
- it("links the Organization on customer.created with metadata.organizationId", async () => {
231
- const customerId = "cus_created_org";
232
- (prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
233
- (prisma.organization.update as jest.Mock).mockResolvedValueOnce({
234
- id: REAL_ORG_ID,
235
- });
236
-
237
- mockVerifyWebhookSignature.mockReturnValue({
238
- id: "evt_cust_005",
239
- type: "customer.created",
240
- data: {
241
- object: {
242
- id: customerId,
243
- email: "org-admin@example.com",
244
- metadata: { organizationId: REAL_ORG_ID },
245
- },
246
- },
247
- });
248
- const r = await fire(server);
249
- expect(r.statusCode).toBe(200);
250
- expect(prisma.organization.update).toHaveBeenCalledWith(
251
- expect.objectContaining({ where: { id: REAL_ORG_ID } }),
252
- );
253
- expect(prisma.user.update).not.toHaveBeenCalled();
254
- });
255
-
256
- // A DB failure on the org link never throws (200 still returned).
257
- it("returns 200 even when the org billing update throws", async () => {
258
- (prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
259
- (prisma.organization.update as jest.Mock).mockRejectedValueOnce(
260
- new Error("db unreachable"),
261
- );
262
-
263
- mockVerifyWebhookSignature.mockReturnValue(
264
- checkoutEvent("evt_org_006", { organizationId: REAL_ORG_ID }),
265
- );
266
- const r = await fire(server);
267
- expect(r.statusCode).toBe(200);
268
- expect(JSON.parse(r.body)).toEqual({ received: true });
269
- });
270
- });
@@ -1,198 +0,0 @@
1
- /**
2
- * Stripe webhook rate-limit — per-IP cap on the webhook endpoint.
3
- *
4
- * The webhook is a public endpoint. Without a rate limit, an attacker can
5
- * trigger CPU-intensive signature verification at unbounded rate (DoS) or
6
- * pin our log infrastructure with garbage payloads. We allow 100 req/min
7
- * per IP — well above Stripe's actual retry cadence (~ a few per hour for
8
- * a healthy account) but tight enough to make abuse unattractive.
9
- *
10
- * The limit applies regardless of signature validity — we don't want to
11
- * permit unlimited *invalid* requests either, since those still consume CPU.
12
- */
13
- import type { FastifyInstance } from "fastify";
14
- import Fastify from "fastify";
15
-
16
- // ── Mocks ────────────────────────────────────────────────────────────────────
17
-
18
- const mockConfig: { STRIPE_WEBHOOK_SECRET: string | undefined } = {
19
- STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
20
- };
21
- // eslint-disable-next-line no-restricted-syntax -- mirrors stripe.test.ts mock pattern
22
- jest.mock("../../../config/index.js", () => ({
23
- get config() {
24
- return mockConfig;
25
- },
26
- }));
27
-
28
- jest.mock("@working-theory/analytics/server", () => ({
29
- track: jest.fn().mockResolvedValue(undefined),
30
- }));
31
-
32
- // Mock prisma so customer.created billing upsert doesn't throw
33
- // eslint-disable-next-line no-restricted-syntax -- mirrors stripe.test.ts mock pattern
34
- jest.mock("../../../lib/prisma.js");
35
-
36
- const mockVerifyWebhookSignature = jest.fn();
37
- class MockStripeWebhookSignatureError extends Error {
38
- constructor(message: string) {
39
- super(message);
40
- this.name = "StripeWebhookSignatureError";
41
- }
42
- }
43
- // eslint-disable-next-line no-restricted-syntax -- mirrors stripe.test.ts mock pattern
44
- jest.mock("../../../lib/stripe.js", () => ({
45
- verifyWebhookSignature: (...args: unknown[]) =>
46
- mockVerifyWebhookSignature(...args),
47
- StripeWebhookSignatureError: MockStripeWebhookSignatureError,
48
- }));
49
-
50
- import { webhooksRoutes } from "../index.js";
51
- import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
52
-
53
- // ── Helpers ──────────────────────────────────────────────────────────────────
54
-
55
- const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
56
- const VALID_SIGNATURE = "t=1234,v1=abc";
57
-
58
- async function buildServerWithIp(ip: string): Promise<FastifyInstance> {
59
- const server = Fastify({ logger: false });
60
-
61
- // Stub req.ip so we can simulate distinct callers reliably (light.my.request
62
- // sends localhost by default which makes per-IP testing brittle).
63
- server.addHook("onRequest", async (req) => {
64
- Object.defineProperty(req, "ip", { value: ip, configurable: true });
65
- });
66
-
67
- await server.register(async (scope) => {
68
- scope.addContentTypeParser(
69
- "application/json",
70
- { parseAs: "buffer" },
71
- (_req, body, done) => {
72
- done(null, body);
73
- },
74
- );
75
- await scope.register(webhooksRoutes);
76
- });
77
-
78
- await server.ready();
79
- return server;
80
- }
81
-
82
- // ── Tests ────────────────────────────────────────────────────────────────────
83
-
84
- describe("POST /api/webhooks/stripe — rate limit", () => {
85
- beforeEach(() => {
86
- jest.clearAllMocks();
87
- __resetIdempotencyStore();
88
- mockConfig.STRIPE_WEBHOOK_SECRET = "whsec_test_secret";
89
- // Each request gets a unique event.id so idempotency doesn't short-circuit
90
- let counter = 0;
91
- mockVerifyWebhookSignature.mockImplementation(() => ({
92
- id: `evt_rate_${(counter++).toString().padStart(12, "0")}`,
93
- type: "customer.created",
94
- data: { object: {} },
95
- }));
96
- });
97
-
98
- it("allows requests up to the per-minute cap", async () => {
99
- const server = await buildServerWithIp("10.10.10.1");
100
-
101
- try {
102
- // 100 requests within the cap should all succeed
103
- for (let i = 0; i < 100; i++) {
104
- const response = await server.inject({
105
- method: "POST",
106
- url: "/v1/webhooks/stripe",
107
- payload: VALID_BODY,
108
- headers: {
109
- "content-type": "application/json",
110
- "stripe-signature": VALID_SIGNATURE,
111
- },
112
- });
113
- expect(response.statusCode).toBe(200);
114
- }
115
- } finally {
116
- await server.close();
117
- }
118
- });
119
-
120
- it("returns 429 on the 101st request within the window", async () => {
121
- const server = await buildServerWithIp("10.10.10.2");
122
-
123
- try {
124
- for (let i = 0; i < 100; i++) {
125
- await server.inject({
126
- method: "POST",
127
- url: "/v1/webhooks/stripe",
128
- payload: VALID_BODY,
129
- headers: {
130
- "content-type": "application/json",
131
- "stripe-signature": VALID_SIGNATURE,
132
- },
133
- });
134
- }
135
-
136
- const overLimit = await server.inject({
137
- method: "POST",
138
- url: "/v1/webhooks/stripe",
139
- payload: VALID_BODY,
140
- headers: {
141
- "content-type": "application/json",
142
- "stripe-signature": VALID_SIGNATURE,
143
- },
144
- });
145
-
146
- expect(overLimit.statusCode).toBe(429);
147
- } finally {
148
- await server.close();
149
- }
150
- });
151
-
152
- it("tracks rate-limit buckets per IP independently", async () => {
153
- const serverA = await buildServerWithIp("10.10.10.3");
154
- const serverB = await buildServerWithIp("10.10.10.4");
155
-
156
- try {
157
- // Saturate server A
158
- for (let i = 0; i < 100; i++) {
159
- await serverA.inject({
160
- method: "POST",
161
- url: "/v1/webhooks/stripe",
162
- payload: VALID_BODY,
163
- headers: {
164
- "content-type": "application/json",
165
- "stripe-signature": VALID_SIGNATURE,
166
- },
167
- });
168
- }
169
-
170
- // A is over the cap
171
- const aOverLimit = await serverA.inject({
172
- method: "POST",
173
- url: "/v1/webhooks/stripe",
174
- payload: VALID_BODY,
175
- headers: {
176
- "content-type": "application/json",
177
- "stripe-signature": VALID_SIGNATURE,
178
- },
179
- });
180
- expect(aOverLimit.statusCode).toBe(429);
181
-
182
- // B still has its full budget
183
- const bFirst = await serverB.inject({
184
- method: "POST",
185
- url: "/v1/webhooks/stripe",
186
- payload: VALID_BODY,
187
- headers: {
188
- "content-type": "application/json",
189
- "stripe-signature": VALID_SIGNATURE,
190
- },
191
- });
192
- expect(bFirst.statusCode).toBe(200);
193
- } finally {
194
- await serverA.close();
195
- await serverB.close();
196
- }
197
- });
198
- });