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,633 +0,0 @@
1
- /**
2
- * POST /api/webhooks/stripe — handler unit tests
3
- *
4
- * Uses jest.useFakeTimers for the telemetry-timeout test so the 2s wall-clock
5
- * wait doesn't slow the suite.
6
- */
7
- import type { FastifyInstance } from "fastify";
8
- import Fastify from "fastify";
9
-
10
- // ── Mocks ────────────────────────────────────────────────────────────────────
11
-
12
- // Mock analytics track (external boundary: @working-theory/analytics/server)
13
- const mockTrack = jest.fn().mockResolvedValue(undefined);
14
- jest.mock("@working-theory/analytics/server", () => ({
15
- track: (...args: unknown[]) => mockTrack(...args),
16
- }));
17
-
18
- // Mock Prisma — lib/prisma.ts wraps the external DB boundary (PrismaClient
19
- // requires a live connection). This call auto-resolves to the manual mock at
20
- // src/lib/__mocks__/prisma.ts (covers user.findFirst/findUnique/update/create).
21
- // 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.
22
- jest.mock("../../../lib/prisma.js");
23
-
24
- // Mock verifyWebhookSignature at the external boundary (@working-theory/stripe-client)
25
- // rather than the internal wrapper (lib/stripe.js). lib/stripe.js is a
26
- // thin re-export with no logic of its own; the external SDK boundary is the
27
- // correct mock point for signature verification.
28
- const mockVerifyWebhookSignature = jest.fn();
29
- class MockStripeWebhookSignatureError extends Error {
30
- constructor(message: string) {
31
- super(message);
32
- this.name = "StripeWebhookSignatureError";
33
- }
34
- }
35
- jest.mock("@working-theory/stripe-client", () => ({
36
- verifyWebhookSignature: (...args: unknown[]) =>
37
- mockVerifyWebhookSignature(...args),
38
- StripeWebhookSignatureError: MockStripeWebhookSignatureError,
39
- assertSandboxKey: jest.fn(),
40
- createCheckoutSession: jest.fn(),
41
- getStripeClient: jest.fn(),
42
- STRIPE_API_VERSION: "2024-06-20",
43
- StripeConfigError: class StripeConfigError extends Error {},
44
- UnknownProductCodeError: class UnknownProductCodeError extends Error {},
45
- WEBHOOK_TOLERANCE_SECONDS: 300,
46
- }));
47
-
48
- // Import after mocks are in place
49
- import * as configModule from "../../../config/index.js";
50
- import { stripeWebhookHandler } from "../stripe.js";
51
- import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
52
- import { prisma } from "../../../lib/prisma.js";
53
-
54
- // ── Helpers ──────────────────────────────────────────────────────────────────
55
-
56
- function buildFakeEvent(overrides: Record<string, unknown> = {}) {
57
- return {
58
- id: "evt_test_12345678abcdef",
59
- type: "checkout.session.completed",
60
- data: {
61
- object: {
62
- amount_total: 4999,
63
- currency: "usd",
64
- metadata: { productId: "starter" },
65
- customer_details: { email: "alice@example.com" },
66
- id: "cs_test_session_12345678901234567890",
67
- },
68
- },
69
- ...overrides,
70
- };
71
- }
72
-
73
- async function buildTestServer(): Promise<FastifyInstance> {
74
- const server = Fastify({ logger: false });
75
-
76
- await server.register(async (webhooksScope) => {
77
- webhooksScope.addContentTypeParser(
78
- "application/json",
79
- { parseAs: "buffer" },
80
- (_req, body, done) => {
81
- done(null, body);
82
- },
83
- );
84
- webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
85
- });
86
-
87
- await server.ready();
88
- return server;
89
- }
90
-
91
- // Valid raw body payload
92
- const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
93
- const VALID_SIGNATURE = "t=1234,v1=abc";
94
-
95
- // ── Tests ────────────────────────────────────────────────────────────────────
96
-
97
- describe("POST /api/webhooks/stripe", () => {
98
- let server: FastifyInstance;
99
-
100
- beforeEach(async () => {
101
- jest.clearAllMocks();
102
- __resetIdempotencyStore();
103
- // Control STRIPE_WEBHOOK_SECRET per-test via jest.replaceProperty.
104
- // config is a module-level singleton frozen at load time; replaceProperty
105
- // restores the original value automatically in afterEach.
106
- jest.replaceProperty(configModule, "config", {
107
- ...configModule.config,
108
- STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
109
- });
110
- server = await buildTestServer();
111
- });
112
-
113
- afterEach(async () => {
114
- await server.close();
115
- jest.restoreAllMocks();
116
- });
117
-
118
- // ── Case 1: Missing config returns 503 ──────────────────────────────────
119
-
120
- it("returns 503 when STRIPE_WEBHOOK_SECRET is not set", async () => {
121
- jest.replaceProperty(configModule, "config", {
122
- ...configModule.config,
123
- STRIPE_WEBHOOK_SECRET: undefined,
124
- });
125
-
126
- const response = await server.inject({
127
- method: "POST",
128
- url: "/v1/webhooks/stripe",
129
- payload: VALID_BODY,
130
- headers: {
131
- "content-type": "application/json",
132
- "stripe-signature": VALID_SIGNATURE,
133
- },
134
- });
135
-
136
- expect(response.statusCode).toBe(503);
137
- expect(JSON.parse(response.body)).toEqual({
138
- error: "webhook-not-configured",
139
- });
140
- expect(mockVerifyWebhookSignature).not.toHaveBeenCalled();
141
- expect(mockTrack).not.toHaveBeenCalled();
142
- });
143
-
144
- // ── Case 2: Invalid signature returns 400 ──────────────────────────────
145
-
146
- it("returns 400 when signature verification fails", async () => {
147
- mockVerifyWebhookSignature.mockImplementation(() => {
148
- throw new MockStripeWebhookSignatureError(
149
- "Signature verification failed.",
150
- );
151
- });
152
-
153
- const response = await server.inject({
154
- method: "POST",
155
- url: "/v1/webhooks/stripe",
156
- payload: VALID_BODY,
157
- headers: {
158
- "content-type": "application/json",
159
- "stripe-signature": VALID_SIGNATURE,
160
- },
161
- });
162
-
163
- expect(response.statusCode).toBe(400);
164
- expect(JSON.parse(response.body)).toEqual({ error: "signature-invalid" });
165
- expect(mockTrack).not.toHaveBeenCalled();
166
- });
167
-
168
- // ── Case 3: Missing Stripe-Signature header returns 400 ─────────────────
169
-
170
- it("returns 400 when Stripe-Signature header is missing", async () => {
171
- const response = await server.inject({
172
- method: "POST",
173
- url: "/v1/webhooks/stripe",
174
- payload: VALID_BODY,
175
- headers: {
176
- "content-type": "application/json",
177
- // no stripe-signature
178
- },
179
- });
180
-
181
- expect(response.statusCode).toBe(400);
182
- expect(JSON.parse(response.body)).toEqual({ error: "signature-missing" });
183
- expect(mockTrack).not.toHaveBeenCalled();
184
- });
185
-
186
- // ── Case 4: Body not a Buffer returns 400 ──────────────────────────────
187
-
188
- it("returns 400 when body is not a Buffer (parser misconfig)", async () => {
189
- // Build a server that passes a string body instead of Buffer
190
- const stringBodyServer = Fastify({ logger: false });
191
- await stringBodyServer.register(async (scope) => {
192
- scope.addContentTypeParser(
193
- "application/json",
194
- { parseAs: "string" },
195
- (_req, body, done) => {
196
- done(null, body);
197
- },
198
- );
199
- scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
200
- });
201
- await stringBodyServer.ready();
202
-
203
- try {
204
- const response = await stringBodyServer.inject({
205
- method: "POST",
206
- url: "/v1/webhooks/stripe",
207
- payload: '{"type":"test"}',
208
- headers: {
209
- "content-type": "application/json",
210
- "stripe-signature": VALID_SIGNATURE,
211
- },
212
- });
213
-
214
- expect(response.statusCode).toBe(400);
215
- expect(JSON.parse(response.body)).toEqual({ error: "invalid-request" });
216
- expect(mockTrack).not.toHaveBeenCalled();
217
- } finally {
218
- await stringBodyServer.close();
219
- }
220
- });
221
-
222
- // ── Case 5: Valid checkout.session.completed fires telemetry and 200s ──
223
-
224
- it("returns 200 and fires track on valid checkout.session.completed", async () => {
225
- const fakeEvent = buildFakeEvent();
226
- mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
227
-
228
- const response = await server.inject({
229
- method: "POST",
230
- url: "/v1/webhooks/stripe",
231
- payload: VALID_BODY,
232
- headers: {
233
- "content-type": "application/json",
234
- "stripe-signature": VALID_SIGNATURE,
235
- },
236
- });
237
-
238
- expect(response.statusCode).toBe(200);
239
- expect(JSON.parse(response.body)).toEqual({ received: true });
240
- expect(mockTrack).toHaveBeenCalledTimes(1);
241
- expect(mockTrack).toHaveBeenCalledWith("purchase-completed", {
242
- amount: 4999,
243
- currency: "usd",
244
- productId: "starter",
245
- eventType: "checkout.session.completed",
246
- eventIdPrefix: "evt_test",
247
- });
248
- });
249
-
250
- // ── Case 6: metadata.product_id (snake_case) is accepted ───────────────
251
-
252
- it("accepts metadata.product_id (snake_case) for productId", async () => {
253
- const fakeEvent = buildFakeEvent({
254
- data: {
255
- object: {
256
- amount_total: 9900,
257
- currency: "usd",
258
- metadata: { product_id: "pro" },
259
- },
260
- },
261
- });
262
- mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
263
-
264
- const response = await server.inject({
265
- method: "POST",
266
- url: "/v1/webhooks/stripe",
267
- payload: VALID_BODY,
268
- headers: {
269
- "content-type": "application/json",
270
- "stripe-signature": VALID_SIGNATURE,
271
- },
272
- });
273
-
274
- expect(response.statusCode).toBe(200);
275
- expect(mockTrack).toHaveBeenCalledWith("purchase-completed", {
276
- amount: 9900,
277
- currency: "usd",
278
- productId: "pro",
279
- eventType: "checkout.session.completed",
280
- eventIdPrefix: "evt_test",
281
- });
282
- });
283
-
284
- // ── Case 7: no metadata produces undefined productId ───────────────────
285
-
286
- it("sends undefined productId when metadata is absent", async () => {
287
- const fakeEvent = buildFakeEvent({
288
- data: {
289
- object: {
290
- amount_total: 1999,
291
- currency: "gbp",
292
- metadata: null,
293
- },
294
- },
295
- });
296
- mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
297
-
298
- await server.inject({
299
- method: "POST",
300
- url: "/v1/webhooks/stripe",
301
- payload: VALID_BODY,
302
- headers: {
303
- "content-type": "application/json",
304
- "stripe-signature": VALID_SIGNATURE,
305
- },
306
- });
307
-
308
- const call = mockTrack.mock.calls[0];
309
- expect(call[1]).toMatchObject({ productId: undefined });
310
- });
311
-
312
- // ── Case 8: Unknown event type 200s with info log ───────────────────────
313
-
314
- it("returns 200 and does not call track for unknown event type", async () => {
315
- const fakeEvent = buildFakeEvent({ type: "payment_intent.created" });
316
- mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
317
-
318
- const response = await server.inject({
319
- method: "POST",
320
- url: "/v1/webhooks/stripe",
321
- payload: VALID_BODY,
322
- headers: {
323
- "content-type": "application/json",
324
- "stripe-signature": VALID_SIGNATURE,
325
- },
326
- });
327
-
328
- expect(response.statusCode).toBe(200);
329
- expect(JSON.parse(response.body)).toEqual({ received: true });
330
- expect(mockTrack).not.toHaveBeenCalled();
331
- });
332
-
333
- // ── Case 9: Telemetry failure does not block 200 ────────────────────────
334
-
335
- it("returns 200 even when track throws synchronously", async () => {
336
- const fakeEvent = buildFakeEvent();
337
- mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
338
- mockTrack.mockRejectedValueOnce(new Error("analytics unreachable"));
339
-
340
- const response = await server.inject({
341
- method: "POST",
342
- url: "/v1/webhooks/stripe",
343
- payload: VALID_BODY,
344
- headers: {
345
- "content-type": "application/json",
346
- "stripe-signature": VALID_SIGNATURE,
347
- },
348
- });
349
-
350
- expect(response.statusCode).toBe(200);
351
- expect(JSON.parse(response.body)).toEqual({ received: true });
352
- });
353
-
354
- // ── Case 10: Telemetry timeout does not block 200 ───────────────────────
355
-
356
- it("returns 200 within timeout window when track never resolves", async () => {
357
- const fakeEvent = buildFakeEvent();
358
- mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
359
-
360
- // track returns a promise that never resolves — simulates a hung adapter
361
- let resolveTimeout!: () => void;
362
- const neverResolves = new Promise<void>((resolve) => {
363
- resolveTimeout = resolve;
364
- });
365
- mockTrack.mockReturnValueOnce(neverResolves);
366
-
367
- // Use real timers; inject completes once the 2s timeout fires.
368
- // We set a generous test timeout so jest doesn't kill it first.
369
- const responsePromise = server.inject({
370
- method: "POST",
371
- url: "/v1/webhooks/stripe",
372
- payload: VALID_BODY,
373
- headers: {
374
- "content-type": "application/json",
375
- "stripe-signature": VALID_SIGNATURE,
376
- },
377
- });
378
-
379
- // Wait for the 2s telemetry timeout to fire naturally, then resolve the
380
- // hung track promise so the suite doesn't leak a pending microtask.
381
- const response = await responsePromise;
382
- resolveTimeout();
383
-
384
- expect(response.statusCode).toBe(200);
385
- expect(JSON.parse(response.body)).toEqual({ received: true });
386
- }, 6000); // 6s test timeout > 2s TELEMETRY_TIMEOUT_MS
387
-
388
- // ── Case 11: Logs contain no PII ────────────────────────────────────────
389
-
390
- it("does not log customer email, full event ID, or full session ID", async () => {
391
- const logLines: unknown[] = [];
392
- const logCapture = Fastify({
393
- logger: {
394
- level: "info",
395
- stream: {
396
- write(msg: string) {
397
- logLines.push(JSON.parse(msg));
398
- },
399
- },
400
- },
401
- });
402
-
403
- await logCapture.register(async (scope) => {
404
- scope.addContentTypeParser(
405
- "application/json",
406
- { parseAs: "buffer" },
407
- (_req, body, done) => {
408
- done(null, body);
409
- },
410
- );
411
- scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
412
- });
413
- await logCapture.ready();
414
-
415
- try {
416
- const fakeEvent = buildFakeEvent();
417
- mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
418
-
419
- await logCapture.inject({
420
- method: "POST",
421
- url: "/v1/webhooks/stripe",
422
- payload: VALID_BODY,
423
- headers: {
424
- "content-type": "application/json",
425
- "stripe-signature": VALID_SIGNATURE,
426
- },
427
- });
428
-
429
- const logText = JSON.stringify(logLines);
430
- // Must NOT contain full event ID
431
- expect(logText).not.toContain("evt_test_12345678abcdef");
432
- // Must NOT contain customer email
433
- expect(logText).not.toContain("alice@example.com");
434
- // Must NOT contain full session ID
435
- expect(logText).not.toContain("cs_test_session_12345678901234567890");
436
- // Should contain the 8-char prefix
437
- expect(logText).toContain("evt_test");
438
- } finally {
439
- await logCapture.close();
440
- }
441
- });
442
-
443
- // ── Case 12: Non-POST methods return 404 ────────────────────────────────
444
-
445
- it("returns 404 for GET /api/webhooks/stripe", async () => {
446
- const response = await server.inject({
447
- method: "GET",
448
- url: "/v1/webhooks/stripe",
449
- });
450
- expect(response.statusCode).toBe(404);
451
- });
452
-
453
- it("returns 404 for PUT /api/webhooks/stripe", async () => {
454
- const response = await server.inject({
455
- method: "PUT",
456
- url: "/v1/webhooks/stripe",
457
- payload: VALID_BODY,
458
- headers: { "content-type": "application/json" },
459
- });
460
- expect(response.statusCode).toBe(404);
461
- });
462
-
463
- it("returns 404 for DELETE /api/webhooks/stripe", async () => {
464
- const response = await server.inject({
465
- method: "DELETE",
466
- url: "/v1/webhooks/stripe",
467
- });
468
- expect(response.statusCode).toBe(404);
469
- });
470
-
471
- // ── Billing upsert tests (AC bullets 4-6) ──────────────────────────────────
472
-
473
- // AC bullet 4: customer.created → upsert; re-fire same event = no second write
474
- it("upserts billing fields on customer.created and is idempotent on re-fire", async () => {
475
- const customerId = "cus_test_abc123";
476
- const fakeCustomerCreated = {
477
- id: "evt_customer_created_001",
478
- type: "customer.created",
479
- data: {
480
- object: {
481
- id: customerId,
482
- email: "alice@example.com",
483
- },
484
- },
485
- };
486
- const upsertedUser = {
487
- id: "usr_1",
488
- email: "alice@example.com",
489
- billingProvider: "stripe",
490
- billingCustomerId: customerId,
491
- };
492
-
493
- // First fire: typed composite match misses (user not yet linked)
494
- (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
495
- // email fallback finds the user
496
- (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
497
- id: "usr_1",
498
- email: "alice@example.com",
499
- billingProvider: null,
500
- billingCustomerId: null,
501
- });
502
- (prisma.user.update as jest.Mock).mockResolvedValueOnce(upsertedUser);
503
-
504
- mockVerifyWebhookSignature.mockReturnValue(fakeCustomerCreated);
505
- const response1 = await server.inject({
506
- method: "POST",
507
- url: "/v1/webhooks/stripe",
508
- payload: VALID_BODY,
509
- headers: {
510
- "content-type": "application/json",
511
- "stripe-signature": VALID_SIGNATURE,
512
- },
513
- });
514
- expect(response1.statusCode).toBe(200);
515
- expect(prisma.user.update).toHaveBeenCalledTimes(1);
516
- expect(prisma.user.update).toHaveBeenCalledWith(
517
- expect.objectContaining({
518
- data: expect.objectContaining({
519
- billingProvider: "stripe",
520
- billingCustomerId: customerId,
521
- }),
522
- }),
523
- );
524
-
525
- // Re-fire same event: idempotency store short-circuits — no second write
526
- mockVerifyWebhookSignature.mockReturnValue(fakeCustomerCreated);
527
- const response2 = await server.inject({
528
- method: "POST",
529
- url: "/v1/webhooks/stripe",
530
- payload: VALID_BODY,
531
- headers: {
532
- "content-type": "application/json",
533
- "stripe-signature": VALID_SIGNATURE,
534
- },
535
- });
536
- expect(response2.statusCode).toBe(200);
537
- // update should still be 1 — idempotency store prevented second execution
538
- expect(prisma.user.update).toHaveBeenCalledTimes(1);
539
- });
540
-
541
- // AC bullet 5: checkout.session.completed + existing User matched by email → billing fields populated
542
- it("populates billingProvider/Id on checkout.session.completed for user matched by email", async () => {
543
- const customerId = "cus_test_checkout_xyz";
544
- const fakeCheckout = {
545
- id: "evt_checkout_002",
546
- type: "checkout.session.completed",
547
- data: {
548
- object: {
549
- id: "cs_test_abc",
550
- amount_total: 4999,
551
- currency: "usd",
552
- customer: customerId,
553
- customer_details: { email: "bob@example.com" },
554
- metadata: { productId: "pro" },
555
- },
556
- },
557
- };
558
- const updatedUser = {
559
- id: "usr_2",
560
- email: "bob@example.com",
561
- billingProvider: "stripe",
562
- billingCustomerId: customerId,
563
- };
564
-
565
- // Typed composite lookup misses (no prior link)
566
- (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
567
- // Email fallback finds user
568
- (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
569
- id: "usr_2",
570
- email: "bob@example.com",
571
- billingProvider: null,
572
- billingCustomerId: null,
573
- });
574
- (prisma.user.update as jest.Mock).mockResolvedValueOnce(updatedUser);
575
-
576
- mockVerifyWebhookSignature.mockReturnValue(fakeCheckout);
577
- const response = await server.inject({
578
- method: "POST",
579
- url: "/v1/webhooks/stripe",
580
- payload: VALID_BODY,
581
- headers: {
582
- "content-type": "application/json",
583
- "stripe-signature": VALID_SIGNATURE,
584
- },
585
- });
586
-
587
- expect(response.statusCode).toBe(200);
588
- expect(prisma.user.update).toHaveBeenCalledWith(
589
- expect.objectContaining({
590
- where: { id: "usr_2" },
591
- data: expect.objectContaining({
592
- billingProvider: "stripe",
593
- billingCustomerId: customerId,
594
- }),
595
- }),
596
- );
597
- });
598
-
599
- // AC bullet 6: event with neither typed match nor email match → 200 + warning, no User row created
600
- it("returns 200 and logs warning when no user match found — no row created", async () => {
601
- const fakeOrphan = {
602
- id: "evt_orphan_003",
603
- type: "customer.created",
604
- data: {
605
- object: {
606
- id: "cus_test_orphan",
607
- email: null, // no email — can't even do email fallback
608
- },
609
- },
610
- };
611
-
612
- // Typed composite lookup misses
613
- (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
614
- // No email fallback possible (email is null)
615
-
616
- mockVerifyWebhookSignature.mockReturnValue(fakeOrphan);
617
- const response = await server.inject({
618
- method: "POST",
619
- url: "/v1/webhooks/stripe",
620
- payload: VALID_BODY,
621
- headers: {
622
- "content-type": "application/json",
623
- "stripe-signature": VALID_SIGNATURE,
624
- },
625
- });
626
-
627
- expect(response.statusCode).toBe(200);
628
- expect(JSON.parse(response.body)).toEqual({ received: true });
629
- // No user row created
630
- expect(prisma.user.update).not.toHaveBeenCalled();
631
- expect(prisma.user.create).not.toHaveBeenCalled();
632
- });
633
- });
@@ -1,41 +0,0 @@
1
- /**
2
- * Webhooks route group.
3
- *
4
- * Routes:
5
- * POST /v1/webhooks/stripe — verify Stripe signature, dispatch event,
6
- * fire telemetry, respond 200.
7
- *
8
- * Rate limit: 100 req/min/IP applied at the route level. Above Stripe's
9
- * actual retry cadence (a healthy account sees a few events per hour) but
10
- * tight enough to make replay-flood DoS unattractive. Encapsulated to the
11
- * webhooks scope so the global content limit (60/min) doesn't bleed in.
12
- */
13
- import rateLimit from "@fastify/rate-limit";
14
- import type { FastifyPluginAsync } from "fastify";
15
-
16
- import { stripeWebhookHandler } from "./stripe.js";
17
-
18
- export const STRIPE_WEBHOOK_RATE_LIMIT_PER_MIN = 100;
19
-
20
- export const webhooksRoutes: FastifyPluginAsync = async (fastify) => {
21
- // Register rate-limit at this scope so the webhook gets its own bucket
22
- // distinct from the public content API's global limiter.
23
- await fastify.register(rateLimit, {
24
- global: false,
25
- max: STRIPE_WEBHOOK_RATE_LIMIT_PER_MIN,
26
- timeWindow: "1 minute",
27
- });
28
-
29
- fastify.post(
30
- "/v1/webhooks/stripe",
31
- {
32
- config: {
33
- rateLimit: {
34
- max: STRIPE_WEBHOOK_RATE_LIMIT_PER_MIN,
35
- timeWindow: "1 minute",
36
- },
37
- },
38
- },
39
- stripeWebhookHandler,
40
- );
41
- };