create-ailk 0.1.1 → 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 (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. 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
- });