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,202 +0,0 @@
1
- /**
2
- * Flows route group (#3956 D4).
3
- *
4
- * Routes:
5
- * GET /v1/flows/:slug — serve a validated flow config
6
- * POST /v1/flows/:slug/steps — submit one step's answers; persists
7
- * server-side on landing
8
- * GET /v1/flows/:slug/aggregate — anonymous, PII-free per-question
9
- * response counts across all completed
10
- * respondents (#4771), for the results
11
- * reveal
12
- *
13
- * `siteId` is not carried in the URL or the request body — flows follow the
14
- * same anonymous single-site default `apps/mcp/src/tools/capture_lead.ts`
15
- * already established for agent-facing anonymous surfaces
16
- * (`process.env.AILK_SITE_ID ?? "default"`), rather than the leads routes'
17
- * explicit client-supplied `siteId` (spec D4 documents no siteId field for
18
- * either flows route — this is the design decision that fills that gap).
19
- *
20
- * Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
21
- * `{ ok: false, error: { code, message } }`, never 200-with-ok:false.
22
- */
23
- import {
24
- flowSlugSchema,
25
- submitFlowStepInputSchema,
26
- } from "@working-theory/validation";
27
- import type { FastifyPluginAsync, FastifyReply } from "fastify";
28
-
29
- import { currentSiteId } from "../../lib/site.js";
30
- import { getFlowAggregate } from "../../services/flow-aggregate.js";
31
- import {
32
- serveFlow,
33
- submitStep,
34
- type FlowEngineError,
35
- type FlowEngineErrorKind,
36
- } from "../../services/flow-engine.js";
37
-
38
- const FLOW_ERROR_STATUS: Record<FlowEngineErrorKind, number> = {
39
- invalid_input: 400,
40
- flow_not_found: 404,
41
- step_conflict: 409,
42
- flows_not_configured: 503,
43
- };
44
-
45
- function sendFlowError(reply: FastifyReply, error: FlowEngineError) {
46
- return reply
47
- .status(FLOW_ERROR_STATUS[error.kind])
48
- .send({ ok: false, error: { code: error.kind, message: error.message } });
49
- }
50
-
51
- export const getFlowRoute: FastifyPluginAsync = async (fastify) => {
52
- fastify.get<{ Params: { slug: string } }>(
53
- "/v1/flows/:slug",
54
- async (request, reply) => {
55
- const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
56
- if (!parsedSlug.success) {
57
- return reply.status(400).send({
58
- ok: false,
59
- error: { code: "invalid_input", message: "Invalid flow slug." },
60
- });
61
- }
62
-
63
- const result = await serveFlow(currentSiteId(), parsedSlug.data);
64
- if (!result.ok) return sendFlowError(reply, result.error);
65
-
66
- return reply.send({ ok: true, data: result.config });
67
- },
68
- );
69
- };
70
-
71
- export const getFlowAggregateRoute: FastifyPluginAsync = async (fastify) => {
72
- fastify.get<{ Params: { slug: string } }>(
73
- "/v1/flows/:slug/aggregate",
74
- async (request, reply) => {
75
- const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
76
- if (!parsedSlug.success) {
77
- return reply.status(400).send({
78
- ok: false,
79
- error: { code: "invalid_input", message: "Invalid flow slug." },
80
- });
81
- }
82
-
83
- const result = await getFlowAggregate(currentSiteId(), parsedSlug.data);
84
- if (!result.ok) return sendFlowError(reply, result.error);
85
-
86
- return reply.send({ ok: true, data: result.data });
87
- },
88
- );
89
- };
90
-
91
- export const submitFlowStepRoute: FastifyPluginAsync = async (fastify) => {
92
- fastify.post<{ Params: { slug: string } }>(
93
- "/v1/flows/:slug/steps",
94
- async (request, reply) => {
95
- const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
96
- if (!parsedSlug.success) {
97
- return reply.status(400).send({
98
- ok: false,
99
- error: { code: "invalid_input", message: "Invalid flow slug." },
100
- });
101
- }
102
-
103
- // Validate the RAW body directly against the .strict() schema — an
104
- // unexpected top-level key (e.g. attacker-supplied `siteId`) is
105
- // rejected here rather than silently dropped by a pre-filter, so the
106
- // 400 invalid_input D4 documents for "schema validation" actually
107
- // fires on it.
108
- const body = request.body as Record<string, unknown>;
109
- const parsedBody = submitFlowStepInputSchema.safeParse(body);
110
- if (!parsedBody.success) {
111
- return reply.status(400).send({
112
- ok: false,
113
- error: {
114
- code: "invalid_input",
115
- message:
116
- parsedBody.error.issues[0]?.message ?? "Invalid step submission.",
117
- },
118
- });
119
- }
120
-
121
- const sourceMeta = {
122
- ip: request.ip,
123
- userAgent: request.headers["user-agent"],
124
- receivedAt: new Date().toISOString(),
125
- };
126
-
127
- const result = await submitStep(
128
- {
129
- siteId: currentSiteId(),
130
- slug: parsedSlug.data,
131
- ...(parsedBody.data.sessionId !== undefined && {
132
- sessionId: parsedBody.data.sessionId,
133
- }),
134
- stepKey: parsedBody.data.stepKey,
135
- values: parsedBody.data.values,
136
- ...(parsedBody.data.locale !== undefined && {
137
- locale: parsedBody.data.locale,
138
- }),
139
- },
140
- sourceMeta,
141
- );
142
-
143
- if (!result.ok) {
144
- if (result.error.kind === "invalid_input") {
145
- // Client-fixable — no server-side log needed.
146
- } else if (result.error.kind !== "flows_not_configured") {
147
- // step_conflict / flow_not_found — PII-safe operational visibility.
148
- request.log.warn(
149
- {
150
- slug: parsedSlug.data,
151
- stepKey: body["stepKey"],
152
- kind: result.error.kind,
153
- },
154
- "flow step submission rejected",
155
- );
156
- }
157
- return sendFlowError(reply, result.error);
158
- }
159
-
160
- // PII-safe structured log — mirrors leads route: no email/values/payload.
161
- request.log.info(
162
- {
163
- slug: parsedSlug.data,
164
- sessionId: result.sessionId,
165
- stepKey: result.landed,
166
- completed: result.completed,
167
- },
168
- "flow step landed",
169
- );
170
-
171
- if (result.leadRoutingFailed) {
172
- // Completion ran but routeLead's own db+file fallback both failed —
173
- // the session (not a Lead row) is the durable record for this
174
- // completion. PII-safe: no email/payload, matches the shape above.
175
- request.log.warn(
176
- {
177
- slug: parsedSlug.data,
178
- sessionId: result.sessionId,
179
- },
180
- "flow completed but lead routing failed — session is the record",
181
- );
182
- }
183
-
184
- const { sessionId, landed, nextStepKey, completed, leadId, segment, aeoScore } =
185
- result;
186
- return reply.send({
187
- ok: true,
188
- data: {
189
- sessionId,
190
- landed,
191
- nextStepKey,
192
- completed,
193
- ...(leadId !== undefined && { leadId }),
194
- ...(segment !== undefined && { segment }),
195
- // D9 (#4663) — forward the aeo_score step's own result, or the
196
- // persisted result on the completion response.
197
- ...(aeoScore !== undefined && { aeoScore }),
198
- },
199
- });
200
- },
201
- );
202
- };
@@ -1,443 +0,0 @@
1
- /**
2
- * Recommender email-moment routes — #4841 (Epic #4760 C2).
3
- *
4
- * Routes (both anonymous, registered inside server.ts's `flowsScope` beside
5
- * the three existing flow routes, so they inherit the per-IP rate limiter):
6
- * POST /v1/flows/:slug/save — "email me this plan": FlowSession + Lead
7
- * (+ the T+0 grant) for the permalink's
8
- * answers. No Stripe call.
9
- * POST /v1/flows/:slug/checkout — the purchase moment: the email moment
10
- * above, then the buyer row at a
11
- * SERVER-recomputed price, then a Stripe
12
- * TEST-mode Checkout Session whose metadata
13
- * carries the permalink + offer key.
14
- *
15
- * The body carries NO amount of any name (`flowCheckoutInputSchema` is
16
- * `.strict()`): the server recomputes `price()` from the register it served
17
- * (PRD L104) and `#4837`'s `recordCheckout` is the authority — it resolves
18
- * the register from the DB itself and verifies the session + lead ids are
19
- * this site's before writing (the PR #4851 security-review conditions).
20
- *
21
- * Gate order on checkout (spec FR5, + #4912 FR1): sandbox key → body →
22
- * redirect origin (400) → register (404) → `stripeConfigRef` +
23
- * `rateTable.currency` (409) → `offerKey` (400) → the capture → the row →
24
- * Stripe. A Stripe failure lands AFTER the session, lead and row exist, so the
25
- * buyer record survives a Stripe outage (FR6); a foreign redirect origin lands
26
- * BEFORE all of them, so a refused redirect writes nothing at all.
27
- *
28
- * `siteId` is `currentSiteId()` — the anonymous single-site default every
29
- * flows route takes (`index.ts`); `sourceMeta` is the same triple
30
- * `submitFlowStepRoute` hands `routeLead`.
31
- *
32
- * Security invariants (the `checkout/sessions.ts` posture, inherited):
33
- * - Test mode only: a key outside the `sk_test_` allow-list is refused at
34
- * request time, before any DB or Stripe call.
35
- * - No PII in logs: only `slug`, 8-char id prefixes, and an outcome.
36
- * - No user input echoed in any error body.
37
- * - The two redirect URLs must be on the site's own origin (#4912 D1) — the
38
- * shared `flowRedirectUrlSchema` pins only `http(s)` + a host, so without
39
- * this gate a direct API caller could aim a real Checkout Session's
40
- * post-payment redirect at a host they control.
41
- * - The `X-Internal-Auth` gate is deliberately NOT applied — these are the
42
- * visitor's own anonymous moment (spec Clarifications).
43
- *
44
- * Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
45
- * `{ ok: false, error: { code, message } }`, never 200-with-ok:false.
46
- *
47
- * API↔MCP parity: no MCP twin — recorded exception (apps/api/CLAUDE.md): an
48
- * agent walking a flow has no email to give and no card to pay with.
49
- */
50
- import {
51
- encodePermalink,
52
- flowCheckoutInputSchema,
53
- flowSaveInputSchema,
54
- flowSlugSchema,
55
- PERMALINK_VERSION,
56
- resolveLocalizedString,
57
- } from "@working-theory/validation";
58
- import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
59
-
60
- import { currentSiteId } from "../../lib/site.js";
61
- import {
62
- assertSandboxKey,
63
- createQuotedCheckoutSession,
64
- expireCheckoutSession,
65
- StripeConfigError,
66
- } from "../../lib/stripe.js";
67
- import {
68
- attachStripeSession,
69
- captureRecommenderChoice,
70
- expireFlowCheckout,
71
- findRegisterForSlug,
72
- recordCheckout,
73
- type RecommenderCaptureError,
74
- type RecordCheckoutErrorKind,
75
- } from "../../services/recommender-capture.js";
76
-
77
- // ─── Error mapping ───────────────────────────────────────────────────────────
78
-
79
- const CAPTURE_ERROR_STATUS: Record<RecommenderCaptureError["kind"], number> = {
80
- invalid_input: 400,
81
- flow_not_found: 404,
82
- not_recommending: 409,
83
- lead_failed: 502,
84
- flows_not_configured: 503,
85
- };
86
-
87
- /** #4837 §6's `RecordCheckoutResult.kind` vocabulary → this unit's 400/409 split (spec Clarifications — provenance). */
88
- const RECORD_CHECKOUT_STATUS: Record<RecordCheckoutErrorKind, { status: number; code: string }> = {
89
- no_register: { status: 404, code: "flow_not_found" },
90
- session_mismatch: { status: 400, code: "invalid_input" },
91
- unknown_offer: { status: 400, code: "invalid_input" },
92
- no_seat_rate: { status: 400, code: "invalid_input" },
93
- invalid_seats: { status: 400, code: "invalid_input" },
94
- unknown_credit_stop: { status: 400, code: "invalid_input" },
95
- custom_stop: { status: 400, code: "invalid_input" },
96
- no_rate_table: { status: 409, code: "checkout_not_configured" },
97
- no_currency: { status: 409, code: "checkout_not_configured" },
98
- };
99
-
100
- function sendError(reply: FastifyReply, status: number, code: string, message: string) {
101
- return reply.status(status).send({ ok: false, error: { code, message } });
102
- }
103
-
104
- function sendCaptureError(reply: FastifyReply, error: RecommenderCaptureError) {
105
- const code = error.kind === "not_recommending" ? "checkout_not_configured" : error.kind;
106
- // Never echo user input — the service's messages are fixed strings.
107
- return sendError(reply, CAPTURE_ERROR_STATUS[error.kind], code, error.message);
108
- }
109
-
110
- /** Stripe caps a metadata value at 500 characters; a long permalink is chunked, never truncated (spec D5). */
111
- export const METADATA_VALUE_MAX = 500;
112
-
113
- export function permalinkMetadata(permalink: string): Record<string, string> {
114
- if (permalink.length <= METADATA_VALUE_MAX) return { permalink };
115
- const out: Record<string, string> = {};
116
- for (let i = 0, n = 1; i < permalink.length; i += METADATA_VALUE_MAX, n++) {
117
- out[n === 1 ? "permalink" : `permalink_${n}`] = permalink.slice(i, i + METADATA_VALUE_MAX);
118
- }
119
- return out;
120
- }
121
-
122
- /**
123
- * The site's own origin — the ONLY origin a checkout redirect may resolve to
124
- * (#4912 FR1/D1).
125
- *
126
- * `FRONTEND_URL` is the single site-origin contract this API already carries:
127
- * the CORS origin (`server.ts`), the origin check in `middleware/auth.ts`, and
128
- * the billing-portal return URL (`routes/billing/portal.ts`) all read it. The
129
- * fallback is byte-identical to the one `config/index.ts` applies, and it is
130
- * read per request for the same reason `STRIPE_SECRET_KEY` is: this route's
131
- * environment-sourced security inputs are resolved at request time, not frozen
132
- * at import.
133
- *
134
- * Returns `null` when the value is set but unparseable — a misconfiguration
135
- * the route reports as a config error rather than silently widening.
136
- */
137
- function siteRedirectOrigin(): string | null {
138
- const raw = process.env["FRONTEND_URL"] || "http://localhost:3000";
139
- let parsed: URL;
140
- try {
141
- parsed = new URL(raw);
142
- } catch {
143
- return null;
144
- }
145
- // A non-http(s) value parses without throwing and yields the literal origin
146
- // `"null"` (`mailto:…`, `javascript:…`, a bare `host:port`). That would deny
147
- // every redirect — fail-closed, but undiagnosable — so it is reported as the
148
- // misconfiguration it is rather than as a 400 the operator cannot explain.
149
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
150
- return parsed.origin;
151
- }
152
-
153
- /**
154
- * Exact origin equality — scheme + host + port. A prefix or suffix comparison
155
- * would accept `https://<site-origin>.evil.example`; a scheme check keeps
156
- * `javascript:` and `data:` out even though the shared schema already refuses
157
- * them.
158
- */
159
- function isSameOriginRedirect(raw: string, siteOrigin: string): boolean {
160
- let resolved: URL;
161
- try {
162
- resolved = new URL(raw);
163
- } catch {
164
- return false;
165
- }
166
- if (resolved.protocol !== "https:" && resolved.protocol !== "http:") return false;
167
- return resolved.origin === siteOrigin;
168
- }
169
-
170
- function sourceMetaOf(request: FastifyRequest) {
171
- return {
172
- ip: request.ip,
173
- userAgent: request.headers["user-agent"],
174
- receivedAt: new Date().toISOString(),
175
- };
176
- }
177
-
178
- function prefix(id: string): string {
179
- return id.substring(0, 8);
180
- }
181
-
182
- // ─── POST /v1/flows/:slug/save ───────────────────────────────────────────────
183
-
184
- export const saveRecommendationRoute: FastifyPluginAsync = async (fastify) => {
185
- fastify.post<{ Params: { slug: string } }>("/v1/flows/:slug/save", async (request, reply) => {
186
- const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
187
- if (!parsedSlug.success) return sendError(reply, 400, "invalid_input", "Invalid flow slug.");
188
-
189
- const parsedBody = flowSaveInputSchema.safeParse(request.body);
190
- if (!parsedBody.success) {
191
- return sendError(
192
- reply,
193
- 400,
194
- "invalid_input",
195
- parsedBody.error.issues[0]?.message ?? "Invalid save request.",
196
- );
197
- }
198
- const body = parsedBody.data;
199
-
200
- const result = await captureRecommenderChoice({
201
- siteId: currentSiteId(),
202
- flowSlug: parsedSlug.data,
203
- answers: body.answers,
204
- email: body.email,
205
- ...(body.locale !== undefined && { locale: body.locale }),
206
- ...(body.sessionId !== undefined && { sessionId: body.sessionId }),
207
- choice: "save",
208
- sourceMeta: sourceMetaOf(request),
209
- });
210
- if (!result.ok) return sendCaptureError(reply, result.error);
211
-
212
- request.log.info(
213
- { slug: parsedSlug.data, sessionIdPrefix: prefix(result.sessionId), outcome: "saved" },
214
- "[recommender] plan saved",
215
- );
216
- return reply.send({ ok: true, data: { leadId: result.leadId, sessionId: result.sessionId } });
217
- });
218
- };
219
-
220
- // ─── POST /v1/flows/:slug/checkout ───────────────────────────────────────────
221
-
222
- export const checkoutRecommendationRoute: FastifyPluginAsync = async (fastify) => {
223
- fastify.post<{ Params: { slug: string } }>("/v1/flows/:slug/checkout", async (request, reply) => {
224
- // 1. Test mode only — before any DB or Stripe call (the sessions.ts posture).
225
- const secretKey = process.env["STRIPE_SECRET_KEY"];
226
- if (!secretKey) return sendError(reply, 500, "config-error", "Stripe is not configured.");
227
- try {
228
- assertSandboxKey(secretKey);
229
- } catch {
230
- return sendError(reply, 500, "live-mode-forbidden", "Stripe live mode is not permitted.");
231
- }
232
-
233
- // 2. Body — strict; an amount of any name is a 400 here, never read.
234
- const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
235
- if (!parsedSlug.success) return sendError(reply, 400, "invalid_input", "Invalid flow slug.");
236
- const slug = parsedSlug.data;
237
- const parsedBody = flowCheckoutInputSchema.safeParse(request.body);
238
- if (!parsedBody.success) {
239
- return sendError(
240
- reply,
241
- 400,
242
- "invalid_input",
243
- parsedBody.error.issues[0]?.message ?? "Invalid checkout request.",
244
- );
245
- }
246
- const body = parsedBody.data;
247
- const siteId = currentSiteId();
248
-
249
- // 2b. #4912 FR1 — both redirect URLs must land on this site's own origin.
250
- // Before every gate below, so a refused redirect creates no session,
251
- // no lead and no row, and never reaches Stripe.
252
- const siteOrigin = siteRedirectOrigin();
253
- if (siteOrigin === null) {
254
- request.log.error({ slug }, "[recommender] FRONTEND_URL is not a valid absolute URL");
255
- return sendError(reply, 500, "config-error", "The site origin is not configured.");
256
- }
257
- if (
258
- !isSameOriginRedirect(body.successUrl, siteOrigin) ||
259
- !isSameOriginRedirect(body.cancelUrl, siteOrigin)
260
- ) {
261
- // Never echo the offending URL back.
262
- request.log.warn({ slug, outcome: "redirect_origin_refused" }, "[recommender] redirect origin refused");
263
- return sendError(reply, 400, "invalid_input", "Redirect URLs must be on this site's origin.");
264
- }
265
-
266
- // 3. Register gates — 404 unknown, 409 not set up to sell, 400 unknown offer.
267
- const register = await findRegisterForSlug(siteId, slug);
268
- if (!register) return sendError(reply, 404, "flow_not_found", "Flow not found.");
269
- if (register.stripeConfigRef === null || !register.rateTable?.currency) {
270
- return sendError(
271
- reply,
272
- 409,
273
- "checkout_not_configured",
274
- "This flow is not configured for checkout.",
275
- );
276
- }
277
- const offer = register.offers?.find((o) => o.key === body.offerKey);
278
- if (!offer) return sendError(reply, 400, "invalid_input", "Unknown offer.");
279
-
280
- // 4. The email moment — session + lead (+ grant).
281
- const capture = await captureRecommenderChoice({
282
- siteId,
283
- flowSlug: slug,
284
- answers: body.answers,
285
- email: body.email,
286
- ...(body.locale !== undefined && { locale: body.locale }),
287
- ...(body.sessionId !== undefined && { sessionId: body.sessionId }),
288
- choice: "checkout",
289
- sourceMeta: sourceMetaOf(request),
290
- });
291
- if (!capture.ok) return sendCaptureError(reply, capture.error);
292
-
293
- // 5. The buyer row at the SERVER's price — the request carried no amount.
294
- const permalink = encodePermalink({ version: PERMALINK_VERSION, flowSlug: slug, answers: body.answers });
295
- const recorded = await recordCheckout({
296
- siteId,
297
- flowSlug: slug,
298
- experimentKey: register.key,
299
- sessionId: capture.sessionId,
300
- leadId: capture.leadId,
301
- email: body.email,
302
- offerKey: body.offerKey,
303
- seats: body.seats,
304
- period: body.period,
305
- creditStop: body.creditStop,
306
- permalink,
307
- });
308
- if (!recorded.ok) {
309
- const mapped = RECORD_CHECKOUT_STATUS[recorded.error.kind];
310
- return sendError(reply, mapped.status, mapped.code, recorded.error.message);
311
- }
312
-
313
- // 5b. #4912 FR5 — an identical resubmission inside the idempotency window
314
- // replays the first attempt's session instead of opening a second one.
315
- // `replayed` is the union's discriminant, so the two Stripe fields are
316
- // non-null by construction here — there is no fall-through that could
317
- // open a second session for a request the service already replayed.
318
- if (recorded.replayed) {
319
- request.log.info(
320
- {
321
- slug,
322
- flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
323
- stripeSessionIdPrefix: prefix(recorded.stripeSessionId),
324
- outcome: "checkout_session_replayed",
325
- },
326
- "[recommender] checkout session replayed",
327
- );
328
- return reply
329
- .status(201)
330
- .send({ ok: true, data: { url: recorded.checkoutUrl, sessionId: recorded.stripeSessionId } });
331
- }
332
-
333
- // 6. Stripe — after the record exists, so an outage never loses it (FR6).
334
- const locale = body.locale ?? capture.config.defaultLocale;
335
- const productName = resolveLocalizedString(offer.copy.name, locale, capture.config.defaultLocale);
336
- let session: { sessionId: string; url: string };
337
- try {
338
- session = await createQuotedCheckoutSession({
339
- currency: recorded.currency,
340
- unitAmount: recorded.amountMinor,
341
- productName,
342
- period: body.period,
343
- customerEmail: body.email,
344
- successUrl: body.successUrl,
345
- cancelUrl: body.cancelUrl,
346
- metadata: {
347
- flowCheckoutId: recorded.flowCheckoutId,
348
- flowSlug: slug,
349
- offerKey: body.offerKey,
350
- sessionId: capture.sessionId,
351
- leadId: capture.leadId,
352
- ...permalinkMetadata(permalink),
353
- },
354
- });
355
- } catch (err) {
356
- const logCtx = {
357
- slug,
358
- flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
359
- errName: err instanceof Error ? err.name : "unknown",
360
- };
361
- if (err instanceof StripeConfigError) {
362
- request.log.error(logCtx, "[recommender] stripe config error");
363
- return sendError(reply, 500, "config-error", "Stripe is not configured.");
364
- }
365
- request.log.error(logCtx, "[recommender] stripe error; checkout-unavailable");
366
- return sendError(reply, 502, "checkout-unavailable", "Checkout is temporarily unavailable.");
367
- }
368
-
369
- const attached = await attachStripeSession(
370
- siteId,
371
- recorded.flowCheckoutId,
372
- session.sessionId,
373
- session.url,
374
- );
375
- if (attached.count !== 1) {
376
- // The Stripe session exists but the row it must join on did not take
377
- // the stamp — handing the visitor that URL would produce a payment the
378
- // #4838 webhook cannot attribute. Refuse rather than 201.
379
- //
380
- // #4912 FR7 — and the session is live and payable until somebody closes
381
- // it, so it is expired and the row marked dead BEFORE the 502 returns
382
- // (#4863). Both compensations are best-effort: a failure is logged and
383
- // the 502 still returns, because the visitor's answer does not change.
384
- let sessionCompensation = "expired";
385
- try {
386
- await expireCheckoutSession(session.sessionId);
387
- } catch (err) {
388
- sessionCompensation = "expire_failed";
389
- request.log.error(
390
- {
391
- slug,
392
- flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
393
- errName: err instanceof Error ? err.name : "unknown",
394
- outcome: "attach_miss_expire_failed",
395
- },
396
- "[recommender] could not expire the orphaned stripe session",
397
- );
398
- }
399
- // The row mark uses the same `{ id, siteId }` match that just failed to
400
- // attach, so it can legitimately match nothing — a zero count is not a
401
- // throw, and reporting it as "expired" would make the audit trail lie.
402
- let rowCompensation = "marked_expired";
403
- try {
404
- const marked = await expireFlowCheckout(siteId, recorded.flowCheckoutId);
405
- if (marked.count !== 1) rowCompensation = "row_mark_missed";
406
- } catch (err) {
407
- rowCompensation = "row_mark_failed";
408
- request.log.error(
409
- {
410
- slug,
411
- flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
412
- errName: err instanceof Error ? err.name : "unknown",
413
- outcome: "attach_miss_row_mark_failed",
414
- },
415
- "[recommender] could not mark the orphaned checkout row expired",
416
- );
417
- }
418
- request.log.warn(
419
- {
420
- slug,
421
- flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
422
- outcome: "attach_miss",
423
- sessionCompensation,
424
- rowCompensation,
425
- },
426
- "[recommender] stripe session id did not attach to the checkout row",
427
- );
428
- return sendError(reply, 502, "checkout-unavailable", "Checkout is temporarily unavailable.");
429
- }
430
-
431
- request.log.info(
432
- {
433
- slug,
434
- sessionIdPrefix: prefix(capture.sessionId),
435
- flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
436
- stripeSessionIdPrefix: prefix(session.sessionId),
437
- outcome: "checkout_session_created",
438
- },
439
- "[recommender] checkout session created",
440
- );
441
- return reply.status(201).send({ ok: true, data: { url: session.url, sessionId: session.sessionId } });
442
- });
443
- };