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