create-ailk 0.1.0 → 0.2.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 (620) hide show
  1. package/README.md +124 -3
  2. package/component-catalog.md +456 -0
  3. package/dist/cli.js +92 -16
  4. package/dist/component-catalog.d.ts +11 -0
  5. package/dist/component-catalog.js +39 -0
  6. package/dist/copy.d.ts +7 -0
  7. package/dist/copy.js +24 -0
  8. package/dist/derive-architecture.d.ts +13 -0
  9. package/dist/derive-architecture.js +238 -0
  10. package/dist/index.d.ts +7 -2
  11. package/dist/index.js +19 -2
  12. package/dist/lib/extract-module-bundle.d.ts +16 -0
  13. package/dist/lib/extract-module-bundle.js +184 -0
  14. package/dist/lib/fetch-module.d.ts +55 -0
  15. package/dist/lib/fetch-module.js +125 -0
  16. package/dist/lib/module-license-gate.d.ts +48 -0
  17. package/dist/lib/module-license-gate.js +67 -0
  18. package/dist/lib/module-tier.d.ts +35 -0
  19. package/dist/lib/module-tier.js +37 -0
  20. package/dist/module-architecture.d.ts +175 -0
  21. package/dist/module-architecture.js +663 -0
  22. package/dist/parse-args.d.ts +47 -1
  23. package/dist/parse-args.js +143 -2
  24. package/dist/programmatic.d.ts +160 -3
  25. package/dist/programmatic.js +196 -7
  26. package/dist/surfaces.d.ts +211 -0
  27. package/dist/surfaces.js +532 -0
  28. package/dist/sync-routes.d.ts +92 -0
  29. package/dist/sync-routes.js +350 -0
  30. package/package.json +12 -5
  31. package/templates/.claude/rules/architecture.md +6 -2
  32. package/templates/.claude/skills/README.md +9 -9
  33. package/templates/.claude/skills/add-schema/SKILL.md +14 -7
  34. package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
  35. package/templates/.claude/skills/provision-config/SKILL.md +1 -1
  36. package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
  37. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
  38. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
  39. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
  40. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
  41. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
  42. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
  43. package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
  44. package/templates/.env.example +126 -2
  45. package/templates/CLAUDE.md +11 -5
  46. package/templates/README.md +1 -1
  47. package/templates/apps/api/.env.example +38 -0
  48. package/templates/apps/api/CLAUDE.md +78 -1
  49. package/templates/apps/api/LICENSE +201 -0
  50. package/templates/apps/api/api/index.ts +6 -141
  51. package/templates/apps/api/jest.config.cjs +16 -0
  52. package/templates/apps/api/package.json +22 -6
  53. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
  54. package/templates/apps/api/src/__tests__/server.test.ts +46 -0
  55. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
  56. package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
  57. package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
  58. package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
  59. package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
  60. package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
  61. package/templates/apps/api/src/bin/seed-presets.ts +58 -0
  62. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
  63. package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
  64. package/templates/apps/api/src/cli.ts +5 -11
  65. package/templates/apps/api/src/config/index.ts +35 -0
  66. package/templates/apps/api/src/config/modules.ts +43 -0
  67. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
  68. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -0
  69. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
  70. package/templates/apps/api/src/lib/site.ts +16 -0
  71. package/templates/apps/api/src/lib/stripe.ts +22 -0
  72. package/templates/apps/api/src/lib/tenant-db.ts +225 -0
  73. package/templates/apps/api/src/lib/ws-token.ts +91 -0
  74. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
  75. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
  77. package/templates/apps/api/src/middleware/auth.ts +43 -8
  78. package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
  79. package/templates/apps/api/src/middleware/tenant.ts +99 -0
  80. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
  81. package/templates/apps/api/src/openapi/spec.ts +312 -3
  82. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
  83. package/templates/apps/api/src/routes/aeo/index.ts +107 -0
  84. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
  85. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
  86. package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
  87. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
  88. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
  89. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
  90. package/templates/apps/api/src/routes/billing/index.ts +36 -0
  91. package/templates/apps/api/src/routes/billing/portal.ts +182 -0
  92. package/templates/apps/api/src/routes/billing/read.ts +75 -0
  93. package/templates/apps/api/src/routes/billing/usage.ts +153 -0
  94. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
  95. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
  96. package/templates/apps/api/src/routes/content/create.ts +1 -0
  97. package/templates/apps/api/src/routes/content/delete.ts +1 -0
  98. package/templates/apps/api/src/routes/content/index.ts +42 -6
  99. package/templates/apps/api/src/routes/content/update.ts +1 -0
  100. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
  101. package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
  102. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
  103. package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
  104. package/templates/apps/api/src/routes/flows/README.md +147 -0
  105. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
  106. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
  107. package/templates/apps/api/src/routes/flows/index.ts +202 -0
  108. package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
  109. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
  110. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
  111. package/templates/apps/api/src/routes/leads/index.ts +43 -9
  112. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +558 -0
  113. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
  114. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +869 -0
  115. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
  116. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
  118. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
  119. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +375 -0
  120. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
  121. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
  122. package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
  123. package/templates/apps/api/src/routes/project-listings/index.ts +120 -0
  124. package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
  125. package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
  126. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
  127. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
  128. package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
  129. package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
  130. package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
  131. package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
  132. package/templates/apps/api/src/routes/project-listings/start.ts +106 -0
  133. package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
  134. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
  135. package/templates/apps/api/src/routes/schedule/index.ts +249 -0
  136. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
  137. package/templates/apps/api/src/routes/slack/actions.ts +177 -0
  138. package/templates/apps/api/src/routes/slack/index.ts +38 -0
  139. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
  140. package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
  141. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
  142. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
  143. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
  144. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
  145. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
  146. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
  147. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
  148. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
  149. package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
  150. package/templates/apps/api/src/routes/webhooks/README.md +35 -12
  151. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
  152. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
  153. package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
  154. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
  155. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
  156. package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
  157. package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
  158. package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
  159. package/templates/apps/api/src/server.ts +307 -7
  160. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
  161. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
  162. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
  163. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  164. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  165. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +523 -0
  166. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
  167. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
  168. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
  169. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
  170. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
  171. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
  172. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
  173. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
  174. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
  175. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
  176. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
  177. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
  178. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
  179. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
  180. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
  181. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
  182. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
  183. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
  184. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
  185. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
  186. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
  187. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
  188. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
  189. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
  190. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
  191. package/templates/apps/api/src/services/abandon-email.ts +307 -0
  192. package/templates/apps/api/src/services/aeo-score.ts +288 -0
  193. package/templates/apps/api/src/services/answer-distributions.ts +138 -0
  194. package/templates/apps/api/src/services/consent.ts +236 -0
  195. package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
  196. package/templates/apps/api/src/services/entitlements.ts +102 -0
  197. package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
  198. package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
  199. package/templates/apps/api/src/services/flow-engine.ts +1296 -0
  200. package/templates/apps/api/src/services/lead-promotion.ts +176 -0
  201. package/templates/apps/api/src/services/lead-routing.ts +3 -3
  202. package/templates/apps/api/src/services/listing-config.ts +102 -0
  203. package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
  204. package/templates/apps/api/src/services/listing-promotion.ts +342 -0
  205. package/templates/apps/api/src/services/playbook-compile.ts +398 -0
  206. package/templates/apps/api/src/services/playbook-render.ts +263 -0
  207. package/templates/apps/api/src/services/project-listing-decision.ts +510 -0
  208. package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
  209. package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
  210. package/templates/apps/api/src/services/project-listings.ts +1472 -0
  211. package/templates/apps/api/src/services/recommender-capture.ts +835 -0
  212. package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
  213. package/templates/apps/api/src/services/scheduling/index.ts +63 -0
  214. package/templates/apps/api/src/services/scheduling/types.ts +88 -0
  215. package/templates/apps/api/src/services/slack-notify.ts +240 -0
  216. package/templates/apps/api/src/services/tenant-context.ts +451 -0
  217. package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
  218. package/templates/apps/api/src/services/usage-credits.ts +340 -0
  219. package/templates/apps/api/src/services/usage-metering.ts +699 -0
  220. package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
  221. package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
  222. package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
  223. package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
  224. package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
  225. package/templates/apps/api/src/vercel-handler.ts +165 -0
  226. package/templates/apps/api/tsconfig.json +0 -12
  227. package/templates/apps/mcp/CLAUDE.md +19 -9
  228. package/templates/apps/mcp/LICENSE +201 -0
  229. package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
  230. package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
  231. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
  232. package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
  233. package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
  234. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
  235. package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
  236. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
  237. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
  238. package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
  239. package/templates/apps/mcp/package.json +6 -4
  240. package/templates/apps/mcp/src/config/modules.ts +37 -0
  241. package/templates/apps/mcp/src/server.ts +109 -18
  242. package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
  243. package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
  244. package/templates/apps/mcp/src/tools/create_page.ts +1 -0
  245. package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
  246. package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
  247. package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
  248. package/templates/apps/mcp/src/tools/index.ts +25 -0
  249. package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
  250. package/templates/apps/mcp/src/tools/purchase.ts +3 -0
  251. package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
  252. package/templates/apps/mcp/src/tools/update_page.ts +1 -0
  253. package/templates/apps/mcp/tsconfig.json +1 -9
  254. package/templates/apps/web/.env.example +44 -0
  255. package/templates/apps/web/CLAUDE.md +28 -0
  256. package/templates/apps/web/LICENSE +201 -0
  257. package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
  258. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
  259. package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
  260. package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
  261. package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
  262. package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
  263. package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
  264. package/templates/apps/web/__tests__/setup.ts +22 -32
  265. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
  266. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
  267. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
  268. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
  269. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
  270. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
  271. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
  272. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
  273. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
  274. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
  275. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
  276. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
  277. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
  278. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
  279. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
  280. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
  281. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
  282. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
  283. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
  284. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
  285. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
  286. package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
  287. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
  288. package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
  289. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
  290. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
  291. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
  292. package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
  293. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
  294. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
  295. package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
  296. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
  297. package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
  298. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
  299. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
  300. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
  301. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
  302. package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
  303. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
  304. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
  305. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
  306. package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
  307. package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
  308. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
  309. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
  310. package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
  311. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
  312. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
  313. package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
  314. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
  315. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
  316. package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
  317. package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
  318. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
  319. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
  320. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
  321. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
  322. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
  323. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
  324. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
  325. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
  326. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
  327. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
  328. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
  329. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
  330. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
  331. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
  332. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
  333. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
  334. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  335. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
  336. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
  337. package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
  338. package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
  339. package/templates/apps/web/app/[locale]/layout.tsx +127 -16
  340. package/templates/apps/web/app/[locale]/page.tsx +10 -1
  341. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
  342. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
  343. package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
  344. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
  345. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
  346. package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
  347. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
  348. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
  349. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
  350. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
  351. package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
  352. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
  353. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
  354. package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
  355. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
  356. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
  357. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
  358. package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
  359. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
  360. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
  361. package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
  362. package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
  363. package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
  364. package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
  365. package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
  366. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
  367. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
  368. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  369. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -1
  370. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  371. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +11 -1
  372. package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
  373. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
  374. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
  375. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
  376. package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
  377. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
  378. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  379. package/templates/apps/web/app/globals.css +35 -0
  380. package/templates/apps/web/app/llms.txt/route.ts +2 -1
  381. package/templates/apps/web/app/og/route.tsx +10 -2
  382. package/templates/apps/web/auth.ts +8 -0
  383. package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
  384. package/templates/apps/web/content/en/blog/README.txt +25 -0
  385. package/templates/apps/web/content/en/docs/index.mdx +28 -0
  386. package/templates/apps/web/jest.config.cjs +90 -27
  387. package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
  388. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  389. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
  390. package/templates/apps/web/lib/account-config.ts +13 -0
  391. package/templates/apps/web/lib/server-api.ts +85 -0
  392. package/templates/apps/web/lib/site-brand.tsx +121 -0
  393. package/templates/apps/web/lib/site-theme.ts +74 -0
  394. package/templates/apps/web/messages/en.json +275 -7
  395. package/templates/apps/web/middleware.ts +11 -3
  396. package/templates/apps/web/next.config.mjs +8 -2
  397. package/templates/apps/web/package.json +17 -7
  398. package/templates/apps/web/postcss.config.mjs +23 -0
  399. package/templates/apps/web/public/.well-known/security.txt +3 -3
  400. package/templates/apps/web/src/lib/routes.ts +15 -2
  401. package/templates/apps/web/tsconfig.json +1 -16
  402. package/templates/content/_site.mdx +36 -25
  403. package/templates/content/en/AboutPage/about.mdx +24 -14
  404. package/templates/content/en/ContactPage/contact.mdx +23 -14
  405. package/templates/content/en/FAQPage/faq.mdx +10 -392
  406. package/templates/content/en/HomePage/home.mdx +14 -285
  407. package/templates/database/CHANGELOG.md +443 -0
  408. package/templates/database/LICENSE +201 -0
  409. package/templates/database/README.md +3 -1
  410. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
  411. package/templates/database/__tests__/flows-presets.test.ts +173 -0
  412. package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
  413. package/templates/database/__tests__/seed.test.ts +88 -0
  414. package/templates/database/__tests__/user-cascade.test.ts +19 -4
  415. package/templates/database/auth/schema.prisma +521 -0
  416. package/templates/database/config/schema.prisma +234 -0
  417. package/templates/database/content/schema.prisma +263 -0
  418. package/templates/database/flows/listing-diagnostic.ts +182 -0
  419. package/templates/database/flows/presets.ts +315 -0
  420. package/templates/database/inbox/__tests__/schema.test.ts +4 -0
  421. package/templates/database/inbox/schema.prisma +574 -3
  422. package/templates/database/jest.config.cjs +8 -0
  423. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
  424. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
  425. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
  426. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
  427. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
  428. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
  429. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
  430. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
  431. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
  432. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
  433. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
  434. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
  435. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
  436. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
  437. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
  438. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
  439. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
  440. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
  441. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
  442. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
  443. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
  444. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
  445. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
  446. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
  447. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
  448. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
  449. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
  450. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
  451. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
  452. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
  453. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
  454. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -0
  455. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  456. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  457. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  458. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  459. package/templates/database/ops/schema.prisma +27 -0
  460. package/templates/database/package.json +42 -4
  461. package/templates/database/prisma.config.ts +1 -1
  462. package/templates/database/schema.prisma +19 -435
  463. package/templates/database/seed-test.ts +14 -4
  464. package/templates/database/seed.ts +148 -0
  465. package/templates/database/tenancy.ts +45 -0
  466. package/templates/database/tsconfig.flows.json +15 -0
  467. package/templates/database/tsconfig.json +7 -1
  468. package/templates/database/tsconfig.tenancy.json +14 -0
  469. package/templates/package.json +30 -55
  470. package/templates/pnpm-workspace.yaml +0 -3
  471. package/templates/project.yaml +1 -1
  472. package/templates/tsconfig.json +1 -37
  473. package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
  474. package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
  475. package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
  476. package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
  477. package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
  478. package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
  479. package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
  480. package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
  481. package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
  482. package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
  483. package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
  484. package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
  485. package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
  486. package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
  487. package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
  488. package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
  489. package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
  490. package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
  491. package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
  492. package/templates/apps/web/__tests__/page.test.tsx +0 -230
  493. package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
  494. package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
  495. package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
  496. package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
  497. package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
  498. package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
  499. package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
  500. package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
  501. package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
  502. package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
  503. package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
  504. package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
  505. package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
  506. package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
  507. package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
  508. package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
  509. package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
  510. package/templates/apps/web/content/en/blog/index.mdx +0 -15
  511. package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
  512. package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
  513. package/templates/content/.gitkeep +0 -0
  514. package/templates/content/CLAUDE.md +0 -85
  515. package/templates/content/ar/HomePage/home.mdx +0 -123
  516. package/templates/content/ar/_site.mdx +0 -35
  517. package/templates/content/de/_site.mdx +0 -29
  518. package/templates/content/en/.gitkeep +0 -0
  519. package/templates/content/en/AboutPage/about.schema.json +0 -82
  520. package/templates/content/en/BookCall/book-call.mdx +0 -19
  521. package/templates/content/en/BookCall/book-call.schema.json +0 -121
  522. package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
  523. package/templates/content/en/CareersList/careers.mdx +0 -24
  524. package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
  525. package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
  526. package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
  527. package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
  528. package/templates/content/en/ClientsList/clients.mdx +0 -38
  529. package/templates/content/en/ContactPage/contact.schema.json +0 -100
  530. package/templates/content/en/ContactPoint/primary.mdx +0 -14
  531. package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
  532. package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
  533. package/templates/content/en/CoursesList/courses.mdx +0 -22
  534. package/templates/content/en/DownloadsList/downloads.mdx +0 -22
  535. package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
  536. package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
  537. package/templates/content/en/EventsList/events.mdx +0 -26
  538. package/templates/content/en/FAQPage/faq.schema.json +0 -231
  539. package/templates/content/en/GlossaryList/glossary.mdx +0 -19
  540. package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
  541. package/templates/content/en/HomePage/home.schema.json +0 -88
  542. package/templates/content/en/HomePage/launch-service.mdx +0 -475
  543. package/templates/content/en/HomePage/launch-service.schema.json +0 -138
  544. package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
  545. package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
  546. package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
  547. package/templates/content/en/Integration/github-actions.mdx +0 -31
  548. package/templates/content/en/Integration/openai-api.mdx +0 -26
  549. package/templates/content/en/Integration/vercel.mdx +0 -32
  550. package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
  551. package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
  552. package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
  553. package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
  554. package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
  555. package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
  556. package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
  557. package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
  558. package/templates/content/en/LandingPage/developers.mdx +0 -19
  559. package/templates/content/en/LandingPage/developers.schema.json +0 -109
  560. package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
  561. package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
  562. package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
  563. package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
  564. package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
  565. package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
  566. package/templates/content/en/LandingPage/platform.mdx +0 -369
  567. package/templates/content/en/LandingPage/platform.schema.json +0 -166
  568. package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
  569. package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
  570. package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
  571. package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
  572. package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
  573. package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
  574. package/templates/content/en/Organization/site-owner.mdx +0 -16
  575. package/templates/content/en/Partner/prisma.mdx +0 -31
  576. package/templates/content/en/Partner/supabase.mdx +0 -30
  577. package/templates/content/en/Partner/vercel.mdx +0 -28
  578. package/templates/content/en/PartnersList/partners.mdx +0 -22
  579. package/templates/content/en/Person/founder.mdx +0 -18
  580. package/templates/content/en/Pricing/pricing.mdx +0 -538
  581. package/templates/content/en/Pricing/pricing.schema.json +0 -209
  582. package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
  583. package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
  584. package/templates/content/en/Product/ailk.mdx +0 -19
  585. package/templates/content/en/Product/ailk.schema.json +0 -94
  586. package/templates/content/en/Product/api-launch-kit.mdx +0 -26
  587. package/templates/content/en/Product/launch-kit.mdx +0 -28
  588. package/templates/content/en/ProductsList/products.mdx +0 -26
  589. package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
  590. package/templates/content/en/ProjectsList/projects.mdx +0 -25
  591. package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
  592. package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
  593. package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
  594. package/templates/content/en/ResourcesHub/resources.mdx +0 -22
  595. package/templates/content/en/Service/consulting.mdx +0 -22
  596. package/templates/content/en/Service/implementation.mdx +0 -30
  597. package/templates/content/en/Service/launch-service.mdx +0 -232
  598. package/templates/content/en/Service/launch.mdx +0 -228
  599. package/templates/content/en/ServicesList/services.mdx +0 -84
  600. package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
  601. package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
  602. package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
  603. package/templates/content/en/TeamList/team.mdx +0 -63
  604. package/templates/content/en/TeamMember/founder.mdx +0 -72
  605. package/templates/content/en/TermsPage/terms.mdx +0 -19
  606. package/templates/content/en/TermsPage/terms.schema.json +0 -99
  607. package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
  608. package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
  609. package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
  610. package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
  611. package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
  612. package/templates/content/en-XA/AboutPage/about.mdx +0 -8
  613. package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
  614. package/templates/content/en-XA/HomePage/home.mdx +0 -8
  615. package/templates/content/es/_site.mdx +0 -30
  616. package/templates/content/fr/_site.mdx +0 -30
  617. package/templates/content/ja/_site.mdx +0 -30
  618. package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
  619. package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
  620. package/templates/content/pt-BR/HomePage/home.mdx +0 -23
@@ -0,0 +1,1296 @@
1
+ /**
2
+ * @file flow-engine.ts
3
+ * @description The linear flow engine service (#3956 D3).
4
+ *
5
+ * `serveFlow` reads a validated FlowConfig; `submitStep` is the one mutation
6
+ * path — it persists every landed step server-side IMMEDIATELY (this write is
7
+ * the partial-state capture the feature exists for, not a batched or
8
+ * end-of-flow write), and composes the existing `routeLead` service on
9
+ * completion. Mirrors `lead-routing.ts`'s house shape (ADR 0009 D3
10
+ * service-layer pattern): type-result returns, mocked-dep tests, PII-safe
11
+ * logging.
12
+ *
13
+ * `submitStep` itself is a thin orchestrator (#3974 AC1) over four isolated
14
+ * concerns, each a named helper below: `resolveSession` (session
15
+ * resolution) → `locateStep` (find the submitted step in the snapshot) →
16
+ * `extractStepValues` (per-step value validation) → `persistStepUpdate`
17
+ * (the immediate partial-state write) → `completeSubmission` (completion
18
+ * orchestration: routeLead + deliverable fulfillment). No single function
19
+ * does more than one of these jobs.
20
+ *
21
+ * `cleanupAbandonedSessions` (#3974 AC3) is the FlowSession retention
22
+ * policy — see its own doc comment below for the full policy + its
23
+ * coordination with #3962's abandon-email sweep.
24
+ *
25
+ * PII boundary: only `{ siteId, slug, stepKey, sessionId, completed }` appear
26
+ * in logs — never `values` or the assembled `payload` (mirrors
27
+ * lead-routing.ts's boundary).
28
+ */
29
+
30
+ import type { FlowDefinition } from "@working-theory/database";
31
+ import { Prisma } from "@working-theory/database";
32
+ import { logger } from "@working-theory/observability";
33
+ import {
34
+ aeoScoreResultSchema,
35
+ deriveFlags,
36
+ flowAddressValueSchema,
37
+ flowConfigSchema,
38
+ hiddenAnswers,
39
+ isStepVisible,
40
+ pruneHiddenState,
41
+ resolveVisibleSteps,
42
+ submitFlowStepInputSchema,
43
+ type AeoScoreResult,
44
+ type FlowConfig,
45
+ type FlowFlags,
46
+ type FlowState,
47
+ type FlowStep,
48
+ } from "@working-theory/validation";
49
+ import { z } from "zod";
50
+
51
+ import { prisma } from "../lib/prisma.js";
52
+
53
+ import { loadExampleScore, resolveScoreBudgetMs, scoreUrl } from "./aeo-score.js";
54
+ import { fulfillDeliverable } from "./deliverable-fulfillment.js";
55
+ import { routeLead, type SourceMeta } from "./lead-routing.js";
56
+ import { scoreCompletedSignup } from "./waitlist-scoring.js";
57
+
58
+ /**
59
+ * The exact email-format + length check `leadSubmitSchema` (leads.ts:
60
+ * `email: z.string().email().max(254)`) will re-apply at completion —
61
+ * reused here VERBATIM (both the format check and the 254 cap) so an
62
+ * invalid or over-length email is rejected AT SUBMIT TIME (400
63
+ * invalid_input) rather than silently failing `routeLead` later.
64
+ */
65
+ const EMAIL_SHAPE = z.string().email().max(254);
66
+
67
+ /**
68
+ * text/tel value cap — matches the short-free-text bound `mcp-tools.ts`
69
+ * already uses for comparable MCP inputs (`name`: max 200, `phone`: max 50);
70
+ * 256 covers either with headroom (security-review finding, Medium).
71
+ */
72
+ const TEXT_VALUE_MAX = 256;
73
+
74
+ /** textarea value cap — matches `submitLeadInputSchema.message`'s max(2000). */
75
+ const TEXTAREA_VALUE_MAX = 2000;
76
+
77
+ /**
78
+ * D5 (#4663) — the exact `url` shape check `flowConfigSchema`'s own field
79
+ * validation implies.
80
+ *
81
+ * Security-review finding (#4663, M3): under the installed zod 4.4.3,
82
+ * `z.string().url()` restricts NEITHER hostname shape NOR protocol — its
83
+ * internal `$ZodURL` applies those checks only when `def.hostname`/
84
+ * `def.protocol` are set, and bare `.url()` sets neither. A `javascript:`,
85
+ * `data:`, or `file:` value would therefore pass this check and reach
86
+ * persistence in the lead payload (SSRF itself stays blocked downstream,
87
+ * at `assertUrlSafe` — this is a separate, narrower gap: an unconstrained
88
+ * value boundary, not a network-reach one). `z.httpUrl()` is zod's own
89
+ * preset for exactly this — it fixes `protocol` to the http(s)-only regex
90
+ * and `hostname` to a domain-shaped pattern.
91
+ */
92
+ const URL_SHAPE = z.httpUrl().max(2048);
93
+
94
+ // ─── Types ────────────────────────────────────────────────────────────────────
95
+
96
+ export type FlowEngineErrorKind =
97
+ "invalid_input" | "flow_not_found" | "step_conflict" | "flows_not_configured";
98
+
99
+ export type FlowEngineError = { kind: FlowEngineErrorKind; message: string };
100
+
101
+ export type ServeFlowResult =
102
+ { ok: true; config: FlowConfig } | { ok: false; error: FlowEngineError };
103
+
104
+ export type SubmitStepInput = {
105
+ siteId: string;
106
+ slug: string;
107
+ sessionId?: string;
108
+ stepKey: string;
109
+ values: Record<string, unknown>;
110
+ locale?: string;
111
+ };
112
+
113
+ export type SubmitStepResult =
114
+ | {
115
+ ok: true;
116
+ sessionId: string;
117
+ landed: string;
118
+ nextStepKey: string | null;
119
+ completed: boolean;
120
+ leadId?: string;
121
+ /**
122
+ * Internal-only signal — completion ran but `routeLead` returned
123
+ * `ok:false` (the session is still the durable record, per D9's
124
+ * fallback). The route handler reads this to log a PII-safe warning;
125
+ * it is NOT part of `submitFlowStepOutputSchema`'s public shape and
126
+ * must be stripped before the response is sent.
127
+ */
128
+ leadRoutingFailed?: true;
129
+ /**
130
+ * Server-derived segment (#3959 D3/D6) — present from the
131
+ * segmentation-step response onward (derived this request, or
132
+ * previously persisted on the session).
133
+ */
134
+ segment?: string;
135
+ /**
136
+ * D9 (#4663) — present on the `aeo_score` step's own response AND on
137
+ * the completion response when the flow declares one (the persisted
138
+ * value, when this submission isn't the one that computed it).
139
+ */
140
+ aeoScore?: AeoScoreResult;
141
+ }
142
+ | { ok: false; error: FlowEngineError };
143
+
144
+ export type UpsertFlowDefinitionResult =
145
+ { ok: true; id: string } | { ok: false; error: FlowEngineError };
146
+
147
+ // ─── Generated Prisma Client payload types (#3974 AC2) ─────────────────────
148
+ //
149
+ // Narrowed via `select` to exactly the columns this module reads/writes —
150
+ // replacing the former hand-written `as {...}` result casts (code-review
151
+ // finding on #3956's acceptance PR: a schema/select drift is now caught at
152
+ // compile time instead of silently passing through an untyped assertion).
153
+ //
154
+ // `configSnapshot` / `state` / `config` stay `unknown` (never the generated
155
+ // `Prisma.JsonValue`) — this module never trusts a JSON column's TS shape;
156
+ // every read runs through `flowConfigSchema.safeParse` or an explicit
157
+ // `typeof` guard before use, exactly as before this change.
158
+
159
+ const FLOW_SESSION_SELECT = {
160
+ id: true,
161
+ siteId: true,
162
+ flowId: true,
163
+ flowSlug: true,
164
+ configSnapshot: true,
165
+ state: true,
166
+ email: true,
167
+ segment: true,
168
+ // D2/D6 (#4663) — the derived flag set + the mid-flow aeo_score result.
169
+ flags: true,
170
+ aeoScore: true,
171
+ leadId: true,
172
+ completedAt: true,
173
+ } as const satisfies Prisma.FlowSessionSelect;
174
+
175
+ // Session row shapes read from / written to Prisma — narrowed to what this
176
+ // module touches (the JSON columns are widened back to `unknown` — see the
177
+ // section doc comment above).
178
+ type FlowSessionRow = Omit<
179
+ Prisma.FlowSessionGetPayload<{ select: typeof FLOW_SESSION_SELECT }>,
180
+ "configSnapshot" | "state" | "flags" | "aeoScore"
181
+ > & { configSnapshot: unknown; state: unknown; flags: unknown; aeoScore: unknown };
182
+
183
+ const FLOW_DEFINITION_ID_SELECT = {
184
+ id: true,
185
+ } as const satisfies Prisma.FlowDefinitionSelect;
186
+
187
+ type FlowDefinitionIdRow = Prisma.FlowDefinitionGetPayload<{
188
+ select: typeof FLOW_DEFINITION_ID_SELECT;
189
+ }>;
190
+
191
+ const FLOW_DEFINITION_FOR_SESSION_SELECT = {
192
+ id: true,
193
+ config: true,
194
+ // #5036 D6d — copied onto the session below, so a submission is attributable
195
+ // to one listing without re-deriving it from a slug that nothing enforces.
196
+ listingId: true,
197
+ } as const satisfies Prisma.FlowDefinitionSelect;
198
+
199
+ type FlowDefinitionForSessionRow = Omit<
200
+ Prisma.FlowDefinitionGetPayload<{
201
+ select: typeof FLOW_DEFINITION_FOR_SESSION_SELECT;
202
+ }>,
203
+ "config"
204
+ > & { config: unknown };
205
+
206
+ // `serveFlow`'s own lookup passes no `select` (its call args are asserted
207
+ // exactly by flow-engine.test.ts, so no `select` clause may be added) — typed
208
+ // against the full generated row instead, with `config` widened to
209
+ // `unknown` per this section's rule.
210
+ type FlowDefinitionRow = Omit<FlowDefinition, "config"> & { config: unknown };
211
+
212
+ // ─── DB-required guard (D5) ─────────────────────────────────────────────────────
213
+
214
+ const FLOWS_NOT_CONFIGURED_ERROR: FlowEngineError = {
215
+ kind: "flows_not_configured",
216
+ message: "Flows are not configured.",
217
+ };
218
+
219
+ /**
220
+ * A flow session is read-modify-write across requests — the file fallback
221
+ * `lead-routing.ts` uses for an append-only Lead write would race and
222
+ * silently lose partial state here. So flows require a configured DB; the
223
+ * zero-config OSS path keeps its existing static capture via `/v1/leads/*`.
224
+ */
225
+ function requireDb(): FlowEngineError | null {
226
+ if (!process.env.DATABASE_URL) return FLOWS_NOT_CONFIGURED_ERROR;
227
+ return null;
228
+ }
229
+
230
+ // ─── serveFlow ────────────────────────────────────────────────────────────────
231
+
232
+ export async function serveFlow(
233
+ siteId: string,
234
+ slug: string,
235
+ ): Promise<ServeFlowResult> {
236
+ const dbError = requireDb();
237
+ if (dbError) return { ok: false, error: dbError };
238
+
239
+ const definition: FlowDefinitionRow | null =
240
+ await prisma.flowDefinition.findUnique({
241
+ where: { siteId_slug: { siteId, slug } },
242
+ });
243
+
244
+ if (!definition) {
245
+ return {
246
+ ok: false,
247
+ error: { kind: "flow_not_found", message: "Flow not found." },
248
+ };
249
+ }
250
+
251
+ const parsed = flowConfigSchema.safeParse(definition.config);
252
+ if (!parsed.success) {
253
+ // Defensive — upsertFlowDefinition already validates before write; a
254
+ // parse failure here means the stored config drifted from the schema.
255
+ return {
256
+ ok: false,
257
+ error: {
258
+ kind: "invalid_input",
259
+ message: "Stored flow config failed validation.",
260
+ },
261
+ };
262
+ }
263
+
264
+ return { ok: true, config: parsed.data };
265
+ }
266
+
267
+ // ─── upsertFlowDefinition (D6 — service-level only, no authoring route) ────────
268
+
269
+ export async function upsertFlowDefinition(
270
+ siteId: string,
271
+ config: unknown,
272
+ /**
273
+ * The `ProjectListing` this definition is projected from (#5036 D6d), when
274
+ * it is one. Stamped on the row so every session started on this flow can
275
+ * carry the same reference, instead of the link being the coincidence that
276
+ * the flow's slug equals the listing's.
277
+ */
278
+ listingId?: string,
279
+ ): Promise<UpsertFlowDefinitionResult> {
280
+ const dbError = requireDb();
281
+ if (dbError) return { ok: false, error: dbError };
282
+
283
+ const parsed = flowConfigSchema.safeParse(config);
284
+ if (!parsed.success) {
285
+ return {
286
+ ok: false,
287
+ error: {
288
+ kind: "invalid_input",
289
+ message: parsed.error.issues[0]?.message ?? "invalid flow config",
290
+ },
291
+ };
292
+ }
293
+
294
+ const row: FlowDefinitionIdRow = await prisma.flowDefinition.upsert({
295
+ where: { siteId_slug: { siteId, slug: parsed.data.slug } },
296
+ create: {
297
+ siteId,
298
+ slug: parsed.data.slug,
299
+ config: parsed.data as unknown as Prisma.InputJsonValue,
300
+ ...(listingId !== undefined && { listingId }),
301
+ },
302
+ update: {
303
+ config: parsed.data as unknown as Prisma.InputJsonValue,
304
+ ...(listingId !== undefined && { listingId }),
305
+ },
306
+ select: FLOW_DEFINITION_ID_SELECT,
307
+ });
308
+
309
+ return { ok: true, id: row.id };
310
+ }
311
+
312
+ // ─── seedFlowPresets (#3958 §3.3 — reuse, do not duplicate) ────────────────────
313
+
314
+ export type SeedFlowPresetsResult =
315
+ | { ok: true; seeded: string[] }
316
+ | { ok: false; error: FlowEngineError; seeded: string[] };
317
+
318
+ /**
319
+ * Write the given preset flow configs through the existing validated
320
+ * `upsertFlowDefinition` path, once per preset, for the given site.
321
+ *
322
+ * This is the ONLY write path into `flow_definitions` — reusing
323
+ * `upsertFlowDefinition` (rather than a second Prisma write here) is what
324
+ * keeps a malformed preset failing at seed time instead of becoming the
325
+ * engine's defensive "stored flow config failed validation" branch
326
+ * (`serveFlow` above) at a customer's first request.
327
+ *
328
+ * Stops and returns the first `invalid_input` (or `flows_not_configured`)
329
+ * failure rather than swallowing it, per §3.3 — a caller must not seed a
330
+ * partial, broken preset set. `seeded` names the slugs successfully written
331
+ * before any failure, for diagnostics.
332
+ */
333
+ export async function seedFlowPresets(
334
+ siteId: string,
335
+ presets: unknown[],
336
+ ): Promise<SeedFlowPresetsResult> {
337
+ const seeded: string[] = [];
338
+ for (const preset of presets) {
339
+ const result = await upsertFlowDefinition(siteId, preset);
340
+ if (!result.ok) {
341
+ return { ok: false, error: result.error, seeded };
342
+ }
343
+ const slug =
344
+ typeof preset === "object" && preset !== null && "slug" in preset
345
+ ? String((preset as { slug: unknown }).slug)
346
+ : "(unknown)";
347
+ seeded.push(slug);
348
+ }
349
+ return { ok: true, seeded };
350
+ }
351
+
352
+ // ─── Per-step value validation (D3 — "required fields, select/segmentation
353
+ // values ∈ declared options") ───────────────────────────────────────────
354
+
355
+ /**
356
+ * Validate + extract one step's submitted `values` against its snapshot
357
+ * definition. Returns only the declared field/question names present in
358
+ * `values` — an undeclared key is silently dropped, mirroring the leads
359
+ * route's `buildInput` explicit-destructure discipline (never let an
360
+ * unexpected key smuggle into persisted state).
361
+ */
362
+ function extractStepValues(
363
+ step: FlowStep,
364
+ values: Record<string, unknown>,
365
+ ):
366
+ { ok: true; data: Record<string, unknown> } | { ok: false; message: string } {
367
+ if (step.type === "message" || step.type === "aeo_score" || step.type === "custom") {
368
+ // Interstitial (message) / server-computed (aeo_score, D6) — nothing to
369
+ // capture from the submission; any submission just lands the step.
370
+ //
371
+ // A `custom` step (#5048) lands the same way from the ENGINE's side: its
372
+ // values are shaped by a renderer this server has no definition for, so
373
+ // there is nothing here to validate them against. A served flow does not
374
+ // carry one today — the founder application is client-configured and
375
+ // never a `FlowSession` — and this branch is what keeps that true by
376
+ // construction rather than by a fallthrough into the segmentation reader.
377
+ return { ok: true, data: {} };
378
+ }
379
+
380
+ if (step.type === "field") {
381
+ const data: Record<string, unknown> = {};
382
+ for (const field of step.fields) {
383
+ const raw = values[field.name];
384
+ const present = raw !== undefined && raw !== null && raw !== "";
385
+ if (field.required && !present) {
386
+ return {
387
+ ok: false,
388
+ message: `missing required field "${field.name}"`,
389
+ };
390
+ }
391
+ if (!present) continue;
392
+ // #5279 — an `address` field submits ONE nested parts object under its
393
+ // own name (six separable values, never a concatenated line), so it is
394
+ // checked against that shape rather than against any of the string
395
+ // branches below. `raw` is `unknown` here (boundedStepValues), so this
396
+ // is the same "type it before it reaches JSONB" discipline the text/tel
397
+ // cap below documents: an anonymous POST cannot park an arbitrary
398
+ // object in `flow_sessions.state` by naming an address field.
399
+ if (field.inputType === "address") {
400
+ if (!flowAddressValueSchema.safeParse(raw).success) {
401
+ return {
402
+ ok: false,
403
+ message: `field "${field.name}" must be a structured address`,
404
+ };
405
+ }
406
+ data[field.name] = raw;
407
+ continue;
408
+ }
409
+ if (field.inputType === "select") {
410
+ const allowed = new Set(field.options?.map((o) => o.value) ?? []);
411
+ if (typeof raw !== "string" || !allowed.has(raw)) {
412
+ return {
413
+ ok: false,
414
+ message: `field "${field.name}" is not one of the declared options`,
415
+ };
416
+ }
417
+ }
418
+ // A field that becomes session.email (mapsTo:'email') OR is declared
419
+ // inputType:'email' must actually validate as one — otherwise it
420
+ // denormalizes onto session.email and later fails routeLead's strict
421
+ // leadSubmitSchema at completion, where the failure is swallowed (a
422
+ // silent lost lead, code-review finding on #3956's acceptance verdict).
423
+ if (
424
+ (field.inputType === "email" || field.mapsTo === "email") &&
425
+ (typeof raw !== "string" || !EMAIL_SHAPE.safeParse(raw).success)
426
+ ) {
427
+ return {
428
+ ok: false,
429
+ message: `field "${field.name}" must be a valid email address`,
430
+ };
431
+ }
432
+ // text/tel/textarea carry no format constraint, but `raw` is
433
+ // `unknown` (boundedStepValues), so left unchecked an anonymous
434
+ // POST could smuggle an arbitrarily large string — or a non-string
435
+ // (nested object/array) — into `flow_sessions.state` JSONB. Bound
436
+ // by type + length here (security-review finding, Medium: unbounded
437
+ // + untyped text-field values), mirroring the length caps
438
+ // ./mcp-tools.ts already uses for comparable free-text MCP inputs
439
+ // (`name`/`phone` at 200/50, `message` at 2000).
440
+ if (
441
+ (field.inputType === "text" || field.inputType === "tel") &&
442
+ (typeof raw !== "string" || raw.length > TEXT_VALUE_MAX)
443
+ ) {
444
+ return {
445
+ ok: false,
446
+ message: `field "${field.name}" must be a string of at most ${TEXT_VALUE_MAX} characters`,
447
+ };
448
+ }
449
+ if (
450
+ field.inputType === "textarea" &&
451
+ (typeof raw !== "string" || raw.length > TEXTAREA_VALUE_MAX)
452
+ ) {
453
+ return {
454
+ ok: false,
455
+ message: `field "${field.name}" must be a string of at most ${TEXTAREA_VALUE_MAX} characters`,
456
+ };
457
+ }
458
+ // D5 (#4663) — a `url` field must satisfy the same shape check the
459
+ // config schema implies (max 2048); the client (StepFlow's
460
+ // HAS_SCHEME_RE idiom) prefixes a bare-domain value with `https://`
461
+ // before it ever reaches here, so a scheme-less value is rejected.
462
+ if (
463
+ field.inputType === "url" &&
464
+ (typeof raw !== "string" || !URL_SHAPE.safeParse(raw).success)
465
+ ) {
466
+ return {
467
+ ok: false,
468
+ message: `field "${field.name}" must be a valid URL`,
469
+ };
470
+ }
471
+ // D5 (#4663) — text/textarea minLength (declared on text/textarea only
472
+ // — flowConfigSchema's own cross-field check).
473
+ if (
474
+ field.minLength !== undefined &&
475
+ typeof raw === "string" &&
476
+ raw.length < field.minLength
477
+ ) {
478
+ return {
479
+ ok: false,
480
+ message: `field "${field.name}" must be at least ${field.minLength} characters`,
481
+ };
482
+ }
483
+ data[field.name] = raw;
484
+ }
485
+ return { ok: true, data };
486
+ }
487
+
488
+ // step.type === "segmentation"
489
+ const data: Record<string, unknown> = {};
490
+ for (const question of step.questions) {
491
+ const raw = values[question.name];
492
+ const allowed = new Set(question.options.map((o) => o.value));
493
+
494
+ if (question.multi) {
495
+ // D4 (#4663) — a non-empty array, ≤ options.length, every element ∈
496
+ // options, no duplicates, and an exclusive value only as the sole
497
+ // element (§ D4).
498
+ if (
499
+ !Array.isArray(raw) ||
500
+ raw.length === 0 ||
501
+ raw.length > question.options.length
502
+ ) {
503
+ return {
504
+ ok: false,
505
+ message: `question "${question.name}" requires a non-empty array of declared options`,
506
+ };
507
+ }
508
+ const seen = new Set<string>();
509
+ for (const v of raw) {
510
+ if (typeof v !== "string" || !allowed.has(v)) {
511
+ return {
512
+ ok: false,
513
+ message: `question "${question.name}" contains an option value that is not declared`,
514
+ };
515
+ }
516
+ if (seen.has(v)) {
517
+ return {
518
+ ok: false,
519
+ message: `question "${question.name}" contains a duplicate option value`,
520
+ };
521
+ }
522
+ seen.add(v);
523
+ }
524
+ const exclusive = question.exclusiveValues
525
+ ? raw.filter((v) => question.exclusiveValues!.includes(v as string))
526
+ : [];
527
+ if (exclusive.length > 0 && raw.length > 1) {
528
+ return {
529
+ ok: false,
530
+ message: `question "${question.name}" — an exclusive value must be selected alone`,
531
+ };
532
+ }
533
+ data[question.name] = raw;
534
+ continue;
535
+ }
536
+
537
+ // single — a deliberate, required single-select.
538
+ if (typeof raw !== "string" || !allowed.has(raw)) {
539
+ return {
540
+ ok: false,
541
+ message: `question "${question.name}" requires one of the declared options`,
542
+ };
543
+ }
544
+ data[question.name] = raw;
545
+ }
546
+ return { ok: true, data };
547
+ }
548
+
549
+ /** The `mapsTo:'email'` field value landed in this step's data, if any. */
550
+ function extractMappedEmail(
551
+ step: FlowStep,
552
+ stepData: Record<string, unknown>,
553
+ ): string | undefined {
554
+ if (step.type !== "field") return undefined;
555
+ for (const field of step.fields) {
556
+ if (field.mapsTo === "email") {
557
+ const value = stepData[field.name];
558
+ if (typeof value === "string" && value.length > 0) return value;
559
+ }
560
+ }
561
+ return undefined;
562
+ }
563
+
564
+ /**
565
+ * Derive the segment for a just-landed segmentation step declaring a band
566
+ * map (#3959 D3). Returns undefined for any non-segmentation step, a
567
+ * segmentation step with no `segments` block (band maps are opt-in), or —
568
+ * only possible against a pre-D2 snapshot — a driving-question answer that
569
+ * doesn't hit any band; D2's totality check guarantees a miss can't happen
570
+ * against a schema-valid config, and a miss here leaves the session's
571
+ * segment untouched rather than failing the submission (the snapshot, not
572
+ * the visitor, is at fault).
573
+ */
574
+ function deriveSegment(
575
+ step: FlowStep,
576
+ stepData: Record<string, unknown>,
577
+ ): string | undefined {
578
+ if (step.type !== "segmentation" || !step.segments) return undefined;
579
+ const answer = stepData[step.segments.question];
580
+ if (typeof answer !== "string") return undefined;
581
+ const band = step.segments.bands.find((b) => b.values.includes(answer));
582
+ return band?.key;
583
+ }
584
+
585
+ // ─── Session resolution (#3974 AC1) ────────────────────────────────────────────
586
+
587
+ type ResolveSessionResult =
588
+ | { ok: true; session: FlowSessionRow; configSnapshot: FlowConfig }
589
+ | { ok: false; error: FlowEngineError };
590
+
591
+ /**
592
+ * Resolve the session a step submission targets: reuse an in-progress one
593
+ * (by `sessionId`), or create the first session for a fresh walk. Isolated
594
+ * from the per-step value validation / persistence / completion-
595
+ * orchestration concerns `submitStep` composes below (#3974 AC1).
596
+ */
597
+ async function resolveSession(
598
+ input: SubmitStepInput,
599
+ ): Promise<ResolveSessionResult> {
600
+ if (input.sessionId) {
601
+ const existing: FlowSessionRow | null =
602
+ await prisma.flowSession.findUnique({
603
+ where: { id: input.sessionId },
604
+ select: FLOW_SESSION_SELECT,
605
+ });
606
+
607
+ if (
608
+ !existing ||
609
+ existing.siteId !== input.siteId ||
610
+ existing.flowSlug !== input.slug
611
+ ) {
612
+ // A sessionId minted under a different flow (flowSlug mismatch) is
613
+ // rejected here — accepting it would attribute the completing Lead's
614
+ // `source`/`flowSlug` to the WRONG flow (code-review finding on
615
+ // #3956's acceptance verdict).
616
+ return {
617
+ ok: false,
618
+ error: { kind: "step_conflict", message: "Unknown session." },
619
+ };
620
+ }
621
+ if (existing.completedAt) {
622
+ return {
623
+ ok: false,
624
+ error: { kind: "step_conflict", message: "Session already completed." },
625
+ };
626
+ }
627
+
628
+ const parsedSnapshot = flowConfigSchema.safeParse(existing.configSnapshot);
629
+ if (!parsedSnapshot.success) {
630
+ return {
631
+ ok: false,
632
+ error: {
633
+ kind: "step_conflict",
634
+ message: "Session's config snapshot failed validation.",
635
+ },
636
+ };
637
+ }
638
+ return { ok: true, session: existing, configSnapshot: parsedSnapshot.data };
639
+ }
640
+
641
+ const definition: FlowDefinitionForSessionRow | null =
642
+ await prisma.flowDefinition.findUnique({
643
+ where: { siteId_slug: { siteId: input.siteId, slug: input.slug } },
644
+ select: FLOW_DEFINITION_FOR_SESSION_SELECT,
645
+ });
646
+
647
+ if (!definition) {
648
+ return {
649
+ ok: false,
650
+ error: { kind: "flow_not_found", message: "Flow not found." },
651
+ };
652
+ }
653
+
654
+ const parsedConfig = flowConfigSchema.safeParse(definition.config);
655
+ if (!parsedConfig.success) {
656
+ return {
657
+ ok: false,
658
+ error: {
659
+ kind: "invalid_input",
660
+ message: "Stored flow config failed validation.",
661
+ },
662
+ };
663
+ }
664
+ const configSnapshot = parsedConfig.data;
665
+
666
+ const session: FlowSessionRow = await prisma.flowSession.create({
667
+ data: {
668
+ siteId: input.siteId,
669
+ flowId: definition.id,
670
+ flowSlug: input.slug,
671
+ configSnapshot: configSnapshot as unknown as Prisma.InputJsonValue,
672
+ state: {},
673
+ // #3962 D3: persist the locale of the FIRST landed step — an
674
+ // abandoned session (the only session this feature cares about)
675
+ // otherwise has no locale anywhere (submitStep only forwarded it
676
+ // into the completion payload). Set once at create; not updated on
677
+ // later steps (a mid-walk locale switch is an edge the completion
678
+ // payload already handles its own way).
679
+ locale: input.locale ?? configSnapshot.defaultLocale,
680
+ // #5036 D6d — carried down from the definition, so the session names the
681
+ // listing it belongs to rather than sharing its slug with it.
682
+ ...(definition.listingId !== null && { listingId: definition.listingId }),
683
+ },
684
+ select: FLOW_SESSION_SELECT,
685
+ });
686
+
687
+ return { ok: true, session, configSnapshot };
688
+ }
689
+
690
+ // ─── Step lookup ────────────────────────────────────────────────────────────────
691
+
692
+ type LocateStepResult =
693
+ | { ok: true; step: FlowStep; index: number }
694
+ | { ok: false; error: FlowEngineError };
695
+
696
+ /** Find the submitted `stepKey` in the session's pinned config snapshot. */
697
+ function locateStep(
698
+ configSnapshot: FlowConfig,
699
+ stepKey: string,
700
+ ): LocateStepResult {
701
+ const index = configSnapshot.steps.findIndex((s) => s.key === stepKey);
702
+ if (index === -1) {
703
+ return {
704
+ ok: false,
705
+ error: {
706
+ kind: "step_conflict",
707
+ message: `Unknown stepKey "${stepKey}" for this flow.`,
708
+ },
709
+ };
710
+ }
711
+ return { ok: true, step: configSnapshot.steps[index]!, index };
712
+ }
713
+
714
+ // ─── Flow state helpers (D1/D2/D3, #4663) ───────────────────────────────────────
715
+
716
+ /** Normalize a session's `state` JSON column into a typed, defensive value. */
717
+ function normalizeFlowState(value: unknown): FlowState {
718
+ return value && typeof value === "object" ? (value as FlowState) : {};
719
+ }
720
+
721
+ /** Merge every landed step's values into one flat name → value map (mirrors flow-visibility.ts's internal helper — engine-local, since that one isn't exported). */
722
+ function flattenFlowState(state: FlowState): Record<string, unknown> {
723
+ const flat: Record<string, unknown> = {};
724
+ for (const stepKey of Object.keys(state)) {
725
+ const stepData = state[stepKey];
726
+ if (stepData && typeof stepData === "object") Object.assign(flat, stepData);
727
+ }
728
+ return flat;
729
+ }
730
+
731
+ /** A schema-defensive read of a stored `aeoScore` JSON column value. */
732
+ function parseStoredAeoScore(value: unknown): AeoScoreResult | undefined {
733
+ const parsed = aeoScoreResultSchema.safeParse(value);
734
+ return parsed.success ? parsed.data : undefined;
735
+ }
736
+
737
+ // ─── Per-step persistence (#3974 AC1) ───────────────────────────────────────────
738
+
739
+ type PersistStepUpdateResult = {
740
+ updatedState: FlowState;
741
+ email: string | undefined;
742
+ segment: string | undefined;
743
+ flags: FlowFlags;
744
+ /** The resulting `aeoScore` JSON value AFTER this update (unchanged / reset-to-null / newly-computed). */
745
+ aeoScore: unknown;
746
+ };
747
+
748
+ /**
749
+ * Merge one landed step's extracted values into the session's accumulated
750
+ * state and persist immediately — the per-step partial-state capture this
751
+ * feature exists for. Isolated from session resolution / value validation /
752
+ * completion orchestration (#3974 AC1).
753
+ *
754
+ * D1/D2 (#4663): re-derives `flags` from the just-updated state and prunes
755
+ * every now-hidden step's stale state entry (D3) — in the SAME update. D6/D3:
756
+ * when this landing changes an EARLIER-landed `urlField` value, the
757
+ * downstream `aeo_score` step's own state entry is dropped and `aeoScore` is
758
+ * reset to `null`, so the resolver names it as the next step again.
759
+ * `aeoScoreResult`, when supplied, is this step's OWN aeo_score landing
760
+ * (D6) — persisted in this SAME update, taking priority over any reset.
761
+ */
762
+ async function persistStepUpdate(
763
+ session: FlowSessionRow,
764
+ configSnapshot: FlowConfig,
765
+ step: FlowStep,
766
+ extractedData: Record<string, unknown>,
767
+ aeoScoreResult: AeoScoreResult | undefined,
768
+ ): Promise<PersistStepUpdateResult> {
769
+ const priorState = normalizeFlowState(session.state);
770
+ let updatedState: FlowState = {
771
+ ...priorState,
772
+ [step.key]: extractedData,
773
+ };
774
+
775
+ const mappedEmail = extractMappedEmail(step, extractedData);
776
+ const email = mappedEmail ?? session.email ?? undefined;
777
+
778
+ // #3959 D3: derive the segment (if this landing step declares a band
779
+ // map) and hold it in a REQUEST-LOCAL variable — never re-read off
780
+ // `session`, which is never reassigned after this update, so on the one
781
+ // submission that lands BOTH segmentation AND completion (segmentation
782
+ // plausibly last), reading `session.segment` below would see the stale
783
+ // pre-update null.
784
+ const derivedSegment = deriveSegment(step, extractedData);
785
+ const segment = derivedSegment ?? session.segment ?? undefined;
786
+
787
+ // D6/D3 (#4663) — a changed `urlField` value resets any downstream
788
+ // aeo_score result. Detected on the FIELD step landing, before pruning.
789
+ let aeoScoreReset = false;
790
+ if (step.type === "field") {
791
+ const priorStepData = priorState[step.key];
792
+ for (const field of step.fields) {
793
+ const scoreStep = configSnapshot.steps.find(
794
+ (s): s is Extract<FlowStep, { type: "aeo_score" }> =>
795
+ s.type === "aeo_score" && s.urlField === field.name,
796
+ );
797
+ if (!scoreStep) continue;
798
+ // code-review suggestion (#4663): a step landed for the FIRST time
799
+ // (no prior state at all) is never a "change" — skip the reset so a
800
+ // fresh session's first url landing doesn't issue a wasted
801
+ // Prisma.JsonNull write against a column already null by default.
802
+ if (priorStepData === undefined) continue;
803
+ const priorValue = priorStepData[field.name];
804
+ const newValue = extractedData[field.name];
805
+ if (priorValue !== newValue) {
806
+ aeoScoreReset = true;
807
+ if (scoreStep.key in updatedState) {
808
+ const rest = { ...updatedState };
809
+ delete rest[scoreStep.key];
810
+ updatedState = rest;
811
+ }
812
+ }
813
+ }
814
+ }
815
+
816
+ // D1/D2 (#4663) — re-derive flags from the just-updated state, then prune
817
+ // any now-hidden step's stale state (persisted in the SAME update).
818
+ const flags = deriveFlags(configSnapshot, updatedState);
819
+ updatedState = pruneHiddenState(configSnapshot, updatedState, flags);
820
+
821
+ const aeoScore: unknown = aeoScoreResult ?? (aeoScoreReset ? null : undefined);
822
+
823
+ await prisma.flowSession.update({
824
+ where: { id: session.id },
825
+ data: {
826
+ state: updatedState as unknown as Prisma.InputJsonValue,
827
+ flags: flags as unknown as Prisma.InputJsonValue,
828
+ ...(mappedEmail !== undefined && { email: mappedEmail }),
829
+ ...(derivedSegment !== undefined && { segment: derivedSegment }),
830
+ // A JSON column's SQL NULL is written via the `Prisma.JsonNull`
831
+ // sentinel, never a bare `null` (which Prisma reserves for "leave the
832
+ // column untouched" on a nullable Json field).
833
+ ...(aeoScore === null && { aeoScore: Prisma.JsonNull }),
834
+ ...(aeoScoreResult !== undefined && {
835
+ aeoScore: aeoScoreResult as unknown as Prisma.InputJsonValue,
836
+ }),
837
+ },
838
+ });
839
+
840
+ return {
841
+ updatedState,
842
+ email,
843
+ segment,
844
+ flags,
845
+ aeoScore: aeoScore !== undefined ? aeoScore : session.aeoScore,
846
+ };
847
+ }
848
+
849
+ // ─── Completion orchestration (#3974 AC1) ───────────────────────────────────────
850
+
851
+ type CompleteSubmissionParams = {
852
+ session: FlowSessionRow;
853
+ configSnapshot: FlowConfig;
854
+ siteId: string;
855
+ slug: string;
856
+ locale: string | undefined;
857
+ updatedState: FlowState;
858
+ email: string | undefined;
859
+ segment: string | undefined;
860
+ flags: FlowFlags;
861
+ aeoScore: AeoScoreResult | undefined;
862
+ sourceMeta: SourceMeta;
863
+ };
864
+
865
+ type CompleteSubmissionResult = {
866
+ leadId: string | undefined;
867
+ leadRoutingFailed: true | undefined;
868
+ };
869
+
870
+ /**
871
+ * Runs once every step in the snapshot has landed: assembles the completion
872
+ * payload, routes it through `routeLead`, stamps `completedAt` (+ `leadId`
873
+ * on success), and — on success, when the snapshot declares a deliverables
874
+ * block — fires deliverable fulfillment. Isolated from session resolution /
875
+ * value validation / per-step persistence (#3974 AC1).
876
+ */
877
+ async function completeSubmission(
878
+ params: CompleteSubmissionParams,
879
+ ): Promise<CompleteSubmissionResult> {
880
+ const {
881
+ session,
882
+ configSnapshot,
883
+ siteId,
884
+ slug,
885
+ locale,
886
+ updatedState,
887
+ email,
888
+ segment,
889
+ flags,
890
+ aeoScore,
891
+ sourceMeta,
892
+ } = params;
893
+
894
+ if (!email) {
895
+ // D9: a flow with no email-mapped field completes without a Lead row —
896
+ // the session is the record. Legal but unused by the #3958 presets.
897
+ await prisma.flowSession.update({
898
+ where: { id: session.id },
899
+ data: { completedAt: new Date() },
900
+ });
901
+ return { leadId: undefined, leadRoutingFailed: undefined };
902
+ }
903
+
904
+ // Assemble the payload (D3 order, #4663): landed answers of VISIBLE steps
905
+ // only, config order → hiddenAnswers of currently-hidden steps →
906
+ // flowSlug → locale → segment → flags → aeoScore → experimentKey /
907
+ // waitlistKey (FINAL, per A1 D4 — unchanged). mapsTo-tagged values (incl.
908
+ // the email field itself) stay in payload too; email is ALSO promoted to
909
+ // routeLead's top-level required field, per D9 ("email top-level;
910
+ // everything else in payload").
911
+ const visibleSteps = resolveVisibleSteps(configSnapshot, updatedState, flags);
912
+ const payload: Record<string, unknown> = {};
913
+ for (const s of visibleSteps) {
914
+ const stepData = updatedState[s.key];
915
+ if (stepData && typeof stepData === "object") {
916
+ Object.assign(payload, stepData);
917
+ }
918
+ }
919
+ Object.assign(payload, hiddenAnswers(configSnapshot, updatedState, flags));
920
+
921
+ const resolvedLocale = locale ?? configSnapshot.defaultLocale;
922
+ payload["flowSlug"] = slug;
923
+ payload["locale"] = resolvedLocale;
924
+ // #3959 D4: server-owned, written LAST — a smuggled `segment` key in
925
+ // any submitted values is already dropped by extractStepValues's
926
+ // declared-names-only extraction (and unrepresentable as a declared
927
+ // name per D2.5), so this is defense in depth, not the only guard.
928
+ if (segment !== undefined) {
929
+ payload["segment"] = segment;
930
+ }
931
+ // D2 (#4663) — the derived flag object, written when the snapshot
932
+ // declares flags (possibly `{}` — every flag evaluated false).
933
+ if (configSnapshot.flags) {
934
+ payload["flags"] = flags;
935
+ }
936
+ // D6 (#4663) — the persisted aeo_score result, written whenever the
937
+ // score step is part of the VISIBLE set (it always lands by completion
938
+ // time in that case — completed requires every visible step in state).
939
+ if (visibleSteps.some((s) => s.type === "aeo_score") && aeoScore !== undefined) {
940
+ payload["aeoScore"] = aeoScore;
941
+ }
942
+ // #4661 D4: server-owned, written LAST (after `segment`/`flags`/
943
+ // `aeoScore`) — a smuggled `experimentKey`/`waitlistKey` key in any
944
+ // submitted values is already dropped by extractStepValues's
945
+ // declared-names-only extraction (and unrepresentable as a declared name,
946
+ // per flowConfigSchema's D4 reserved-name check), so this is defense in
947
+ // depth, not the only guard. The Signup composes the existing Lead
948
+ // record — no second signup store.
949
+ if (configSnapshot.experiment) {
950
+ payload["experimentKey"] = configSnapshot.experiment.key;
951
+ payload["waitlistKey"] = configSnapshot.experiment.waitlist;
952
+ }
953
+
954
+ const leadResult = await routeLead(
955
+ {
956
+ kind: "submit",
957
+ siteId,
958
+ source: `flow:${slug}`,
959
+ email,
960
+ payload,
961
+ },
962
+ sourceMeta,
963
+ );
964
+
965
+ if (!leadResult.ok) {
966
+ // routeLead's own DB→file fallback already exhausted itself before
967
+ // returning ok:false. The flow interaction is still complete from
968
+ // the visitor's perspective — the session itself remains the durable
969
+ // record (D9's "session is the record" fallback), so we do not fail
970
+ // this submission over a downstream lead-persistence hiccup.
971
+ // `leadRoutingFailed` surfaces the fact so the route handler can log
972
+ // it (PII-safe) — this function has no request-scoped logger of its
973
+ // own to log through directly.
974
+ await prisma.flowSession.update({
975
+ where: { id: session.id },
976
+ data: { completedAt: new Date() },
977
+ });
978
+ return { leadId: undefined, leadRoutingFailed: true };
979
+ }
980
+
981
+ const leadId = leadResult.id;
982
+ await prisma.flowSession.update({
983
+ where: { id: session.id },
984
+ data: { completedAt: new Date(), leadId },
985
+ });
986
+
987
+ // #4662 D6: fires ONLY when the snapshot declares an experiment binding
988
+ // (opt-in per flow — a flow with no `experiment` block behaves exactly as
989
+ // merged main, the AC4 regression floor: no read, no score row, unchanged
990
+ // payload). scoreCompletedSignup is the ONE write path into
991
+ // waitlist_scores; a scoring failure of ANY kind never fails this
992
+ // submission (fail-soft, the deliverables-hook posture below).
993
+ if (configSnapshot.experiment) {
994
+ try {
995
+ await scoreCompletedSignup({
996
+ siteId,
997
+ leadId,
998
+ experimentKey: configSnapshot.experiment.key,
999
+ waitlistKey: configSnapshot.experiment.waitlist,
1000
+ payload,
1001
+ flags: {}, // A3 wires the pre_launch session flag at completion
1002
+ });
1003
+ } catch (err) {
1004
+ logger.error(
1005
+ {
1006
+ siteId,
1007
+ flowSlug: slug,
1008
+ sessionId: session.id,
1009
+ errKind: err instanceof Error ? err.constructor.name : typeof err,
1010
+ },
1011
+ "[flow-engine] waitlist scoring failed — completion still succeeded",
1012
+ );
1013
+ }
1014
+ }
1015
+
1016
+ // #3961 D4: fulfillment fires ONLY on this success path, and only
1017
+ // when the snapshot declares a deliverables block (opt-in per flow
1018
+ // — a flow with no block behaves exactly as merged main, the AC4
1019
+ // regression floor). fulfillDeliverable itself never throws on its
1020
+ // own business-logic misses (no entry, no asset row, no configured
1021
+ // sender — all internal log-and-skip); this try/catch is the
1022
+ // residual safety net for a genuine DB/network error escaping the
1023
+ // service, so a fulfillment failure of ANY kind never fails this
1024
+ // submission (the owner-notification posture).
1025
+ if (configSnapshot.deliverables) {
1026
+ try {
1027
+ await fulfillDeliverable({
1028
+ siteId,
1029
+ flowSlug: slug,
1030
+ configSnapshot,
1031
+ ...(segment !== undefined && { segment }),
1032
+ leadId: leadResult.id,
1033
+ sessionId: session.id,
1034
+ email,
1035
+ locale: resolvedLocale,
1036
+ });
1037
+ } catch (err) {
1038
+ // Swallowed by design (D4) — the submission still succeeds
1039
+ // regardless. fulfillDeliverable logs its own PII-safe
1040
+ // outcomes on every path it controls; reaching HERE means an
1041
+ // exception escaped before it could log, so this is the
1042
+ // residual observability signal — the error's constructor
1043
+ // name only (never `.message`, which could carry an
1044
+ // interpolated PII value from a driver/library exception),
1045
+ // matching the `{siteId, flowSlug, sessionId}` shape the
1046
+ // service's own outcome logs already use (code-review finding
1047
+ // on this issue's own PR: a fully-silent swallow would make a
1048
+ // genuine regression here invisible in production).
1049
+ logger.error(
1050
+ {
1051
+ siteId,
1052
+ flowSlug: slug,
1053
+ sessionId: session.id,
1054
+ errKind:
1055
+ err instanceof Error ? err.constructor.name : typeof err,
1056
+ },
1057
+ "[flow-engine] deliverable fulfillment failed — completion still succeeded",
1058
+ );
1059
+ }
1060
+ }
1061
+
1062
+ return { leadId, leadRoutingFailed: undefined };
1063
+ }
1064
+
1065
+ // ─── submitStep — the orchestrator (#3974 AC1) ──────────────────────────────────
1066
+
1067
+ export async function submitStep(
1068
+ input: SubmitStepInput,
1069
+ sourceMeta: SourceMeta,
1070
+ ): Promise<SubmitStepResult> {
1071
+ const dbError = requireDb();
1072
+ if (dbError) return { ok: false, error: dbError };
1073
+
1074
+ const parsedInput = submitFlowStepInputSchema.safeParse({
1075
+ sessionId: input.sessionId,
1076
+ stepKey: input.stepKey,
1077
+ values: input.values,
1078
+ locale: input.locale,
1079
+ });
1080
+ if (!parsedInput.success) {
1081
+ return {
1082
+ ok: false,
1083
+ error: {
1084
+ kind: "invalid_input",
1085
+ message: parsedInput.error.issues[0]?.message ?? "invalid input",
1086
+ },
1087
+ };
1088
+ }
1089
+
1090
+ const resolved = await resolveSession(input);
1091
+ if (!resolved.ok) return resolved;
1092
+ const { session, configSnapshot } = resolved;
1093
+
1094
+ const located = locateStep(configSnapshot, input.stepKey);
1095
+ if (!located.ok) return located;
1096
+ const { step, index: stepIndex } = located;
1097
+
1098
+ // D1 (#4663) — a submission for a step not reachable given the CURRENT
1099
+ // (pre-landing) state/flags is a conflict, not a value error.
1100
+ const priorState = normalizeFlowState(session.state);
1101
+ const priorFlags = deriveFlags(configSnapshot, priorState);
1102
+ if (!isStepVisible(configSnapshot, step.key, priorState, priorFlags)) {
1103
+ return {
1104
+ ok: false,
1105
+ error: {
1106
+ kind: "step_conflict",
1107
+ message: "Step is not reachable on this branch.",
1108
+ },
1109
+ };
1110
+ }
1111
+
1112
+ const extracted = extractStepValues(step, input.values);
1113
+ if (!extracted.ok) {
1114
+ return {
1115
+ ok: false,
1116
+ error: { kind: "invalid_input", message: extracted.message },
1117
+ };
1118
+ }
1119
+
1120
+ // D6 (#4663) — an aeo_score landing computes the score BEFORE persisting,
1121
+ // so it lands in the SAME write as the step's own state entry. The url
1122
+ // value was landed on an EARLIER step (the aeo_score step's own extracted
1123
+ // data is always `{}`).
1124
+ let aeoScoreResult: AeoScoreResult | undefined;
1125
+ if (step.type === "aeo_score") {
1126
+ const rawUrl = flattenFlowState(priorState)[step.urlField];
1127
+ const budgetMs = resolveScoreBudgetMs(process.env["AEO_SCORE_BUDGET_MS"], logger);
1128
+ try {
1129
+ aeoScoreResult =
1130
+ typeof rawUrl === "string" && rawUrl.length > 0
1131
+ ? await scoreUrl(rawUrl, { budgetMs })
1132
+ : loadExampleScore();
1133
+ // Security-review finding (#4663, M1): scoreUrl RETURNS (never throws)
1134
+ // on an SSRF-guard rejection, with `message` built from the resolved
1135
+ // address ("Host H resolves to a blocked address: A") — the catch
1136
+ // block below only covers a THROWN exception, so this returned-value
1137
+ // path reached the anonymous caller verbatim and was persisted into
1138
+ // the lead record unsanitized. Scrub it here, in the one caller this
1139
+ // PR owns; the pre-existing POST /v1/aeo/score route (apps/api/src/
1140
+ // routes/aeo/index.ts, #4666, out of #4663's scope) shares the same
1141
+ // gap and is not touched by this fix.
1142
+ if (
1143
+ aeoScoreResult.status === "unavailable" &&
1144
+ aeoScoreResult.reason === "ssrf_blocked"
1145
+ ) {
1146
+ aeoScoreResult = {
1147
+ status: "unavailable",
1148
+ reason: "ssrf_blocked",
1149
+ message: "This URL cannot be scored.",
1150
+ };
1151
+ }
1152
+ } catch (e) {
1153
+ // code-review finding (#4663): never forward a raw caught exception
1154
+ // message to the client — scoreUrl wraps an external-URL crawler, so
1155
+ // an unexpected internal error could leak infrastructure detail (DNS
1156
+ // failure text, timeout internals, an SSRF-guard rejection message).
1157
+ // Log PII-safe server-side (no url), return a generic client message.
1158
+ logger.error(
1159
+ {
1160
+ siteId: input.siteId,
1161
+ slug: input.slug,
1162
+ sessionId: session.id,
1163
+ errKind: e instanceof Error ? e.constructor.name : typeof e,
1164
+ },
1165
+ "[flow-engine] aeo_score crawl threw — landing as unavailable",
1166
+ );
1167
+ aeoScoreResult = {
1168
+ status: "unavailable",
1169
+ reason: "crawl_failed",
1170
+ message: "Could not score this URL.",
1171
+ };
1172
+ }
1173
+ }
1174
+
1175
+ const { updatedState, email, segment, flags, aeoScore } = await persistStepUpdate(
1176
+ session,
1177
+ configSnapshot,
1178
+ step,
1179
+ extracted.data,
1180
+ aeoScoreResult,
1181
+ );
1182
+
1183
+ // D1 (#4663) — resolver-driven successor + completion, replacing the
1184
+ // former linear `steps[stepIndex + 1]` / "every step key in state".
1185
+ const visible = resolveVisibleSteps(configSnapshot, updatedState, flags);
1186
+ const nextVisible = visible.find(
1187
+ (s) => configSnapshot.steps.findIndex((cs) => cs.key === s.key) > stepIndex,
1188
+ );
1189
+ const nextStepKey = nextVisible?.key ?? null;
1190
+ const completed = visible.every((s) => s.key in updatedState);
1191
+
1192
+ let leadId: string | undefined;
1193
+ let leadRoutingFailed: true | undefined;
1194
+
1195
+ if (completed) {
1196
+ const completion = await completeSubmission({
1197
+ session,
1198
+ configSnapshot,
1199
+ siteId: input.siteId,
1200
+ slug: input.slug,
1201
+ locale: input.locale,
1202
+ updatedState,
1203
+ email,
1204
+ segment,
1205
+ flags,
1206
+ aeoScore: aeoScoreResult ?? parseStoredAeoScore(aeoScore),
1207
+ sourceMeta,
1208
+ });
1209
+ leadId = completion.leadId;
1210
+ leadRoutingFailed = completion.leadRoutingFailed;
1211
+ }
1212
+
1213
+ // D9 (#4663) — present on the aeo_score step's own response (just
1214
+ // computed), OR on the completion response (the persisted value, even
1215
+ // when this submission is a LATER step than the one that computed it).
1216
+ const responseAeoScore =
1217
+ aeoScoreResult ?? (completed ? parseStoredAeoScore(aeoScore) : undefined);
1218
+
1219
+ return {
1220
+ ok: true,
1221
+ sessionId: session.id,
1222
+ landed: step.key,
1223
+ nextStepKey,
1224
+ completed,
1225
+ ...(leadId !== undefined && { leadId }),
1226
+ ...(leadRoutingFailed !== undefined && { leadRoutingFailed }),
1227
+ ...(segment !== undefined && { segment }),
1228
+ ...(responseAeoScore !== undefined && { aeoScore: responseAeoScore }),
1229
+ };
1230
+ }
1231
+
1232
+ // ─── Session retention / cleanup (#3974 AC3) ────────────────────────────────────
1233
+
1234
+ /**
1235
+ * FlowSession retention policy. An ABANDONED session (`completedAt: null` —
1236
+ * the visitor never finished the walk) otherwise accumulates indefinitely
1237
+ * with no TTL (security-review finding, Medium, non-blocking, on #3956's
1238
+ * acceptance PR) — `cleanupAbandonedSessions` below is the fix: delete an
1239
+ * abandoned row once its `updatedAt` is older than the retention window.
1240
+ *
1241
+ * A COMPLETED session (`completedAt` set) is NEVER a retention candidate —
1242
+ * it is the durable lead-of-record (D9) and outside this policy's scope;
1243
+ * the delete's `where` clause enforces this structurally.
1244
+ *
1245
+ * Coordinated with #3962's abandon-email sweep (`runAbandonEmailSweep` in
1246
+ * ./abandon-email.ts), which is exactly the natural consumer of these same
1247
+ * abandoned rows (this issue's own "Why"). The default window
1248
+ * (`RETENTION_STALE_DAYS`, 90 days) is chosen to sit far above any realistic
1249
+ * `abandonEmail.delayMinutes` (schema floor: 5 minutes — every shipped
1250
+ * preset and documented example uses a minutes-to-hours delay), so the
1251
+ * sweep always has its full window to run before a row it might still want
1252
+ * becomes retention-eligible — and for a flow with no abandon-email policy
1253
+ * configured at all, the row still gets cleaned up eventually rather than
1254
+ * accumulating forever, instead of waiting on a stamp that will never come.
1255
+ *
1256
+ * Operational wiring (a CLI entry + external scheduler, exactly
1257
+ * `abandon-sweep.ts`'s established shape) lives at
1258
+ * `src/bin/session-retention-sweep.ts`.
1259
+ */
1260
+ const RETENTION_STALE_DAYS = 90;
1261
+
1262
+ export type CleanupAbandonedSessionsOptions = {
1263
+ /** Override the retention window, in days. Defaults to RETENTION_STALE_DAYS. */
1264
+ staleDays?: number;
1265
+ /** Override "now" — test seam. */
1266
+ now?: Date;
1267
+ };
1268
+
1269
+ export type CleanupAbandonedSessionsResult =
1270
+ | { ok: true; deleted: number }
1271
+ | { ok: false; error: FlowEngineError };
1272
+
1273
+ export async function cleanupAbandonedSessions(
1274
+ opts: CleanupAbandonedSessionsOptions = {},
1275
+ ): Promise<CleanupAbandonedSessionsResult> {
1276
+ const dbError = requireDb();
1277
+ if (dbError) return { ok: false, error: dbError };
1278
+
1279
+ const staleDays = opts.staleDays ?? RETENTION_STALE_DAYS;
1280
+ const now = opts.now ?? new Date();
1281
+ const cutoff = new Date(now.getTime() - staleDays * 24 * 60 * 60_000);
1282
+
1283
+ const result = await prisma.flowSession.deleteMany({
1284
+ where: {
1285
+ completedAt: null,
1286
+ updatedAt: { lte: cutoff },
1287
+ },
1288
+ });
1289
+
1290
+ logger.info(
1291
+ { deleted: result.count, staleDays },
1292
+ "[flow-engine] abandoned-session cleanup swept",
1293
+ );
1294
+
1295
+ return { ok: true, deleted: result.count };
1296
+ }