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,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
- };