create-ailk 0.23.0 → 0.25.1

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 (604) hide show
  1. package/README.md +68 -33
  2. package/component-catalog.json +3 -3
  3. package/component-catalog.md +3 -3
  4. package/dist/add-module.js +10 -3
  5. package/dist/add-route.d.ts +12 -1
  6. package/dist/add-route.js +38 -4
  7. package/dist/cli.js +12 -7
  8. package/dist/derive-architecture.d.ts +3 -4
  9. package/dist/derive-architecture.js +75 -22
  10. package/dist/lib/design-checks.d.ts +9 -4
  11. package/dist/lib/design-checks.js +13 -7
  12. package/dist/lib/extract-module-bundle.js +29 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +1 -1
  14. package/dist/lib/fetch-template-snapshot.js +7 -3
  15. package/dist/lib/import-graph.d.ts +1 -1
  16. package/dist/lib/import-graph.js +4 -4
  17. package/dist/lib/module-license-gate.d.ts +12 -1
  18. package/dist/lib/module-license-gate.js +34 -12
  19. package/dist/lib/scaffold-render-sweep.d.ts +33 -0
  20. package/dist/lib/scaffold-render-sweep.js +45 -0
  21. package/dist/module-architecture.d.ts +20 -3
  22. package/dist/module-architecture.js +122 -538
  23. package/dist/paid-sections.d.ts +25 -1
  24. package/dist/paid-sections.js +88 -3
  25. package/dist/surfaces.js +10 -2
  26. package/dist/sync-routes.d.ts +30 -4
  27. package/dist/sync-routes.js +271 -18
  28. package/dist/template-record.d.ts +14 -4
  29. package/dist/template-record.js +23 -5
  30. package/dist/topology.d.ts +16 -3
  31. package/dist/topology.js +31 -19
  32. package/dist/update.d.ts +10 -1
  33. package/dist/update.js +540 -16
  34. package/package.json +3 -3
  35. package/release-scope.json +1 -1
  36. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  37. package/templates/.claude/hooks/template-guard.sh +137 -0
  38. package/templates/.claude/rules/extending.md +106 -0
  39. package/templates/.claude/rules/loop-contract.md +2 -2
  40. package/templates/.claude/settings.json +11 -0
  41. package/templates/.env.example +9 -5
  42. package/templates/.github/workflows/ci.yml +7 -4
  43. package/templates/CLAUDE.md +15 -3
  44. package/templates/CONVENTIONS.md +8 -10
  45. package/templates/README.md +14 -13
  46. package/templates/apps/api/.env.example +9 -2
  47. package/templates/apps/api/CLAUDE.md +122 -82
  48. package/templates/apps/api/jest.config.cjs +15 -5
  49. package/templates/apps/api/package.json +16 -20
  50. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  51. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  52. package/templates/apps/api/src/index.ts +1 -1
  53. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -4
  54. package/templates/apps/api/src/modules/commerce.ts +10 -0
  55. package/templates/apps/api/src/modules/content-write.ts +10 -0
  56. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  57. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  58. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  59. package/templates/apps/api/src/modules/workspace.ts +10 -0
  60. package/templates/apps/api/src/server.ts +117 -1857
  61. package/templates/apps/api/vercel.json +2 -2
  62. package/templates/apps/mcp/CLAUDE.md +77 -24
  63. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  64. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  65. package/templates/apps/mcp/jest.config.cjs +6 -1
  66. package/templates/apps/mcp/package.json +5 -12
  67. package/templates/apps/mcp/src/app.ts +8 -7
  68. package/templates/apps/mcp/src/index.ts +6 -34
  69. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  70. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  71. package/templates/apps/mcp/src/site.ts +12 -0
  72. package/templates/apps/web/.env.example +1 -1
  73. package/templates/apps/web/CLAUDE.md +21 -5
  74. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  75. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  76. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
  77. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  78. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  79. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  80. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +28 -7
  81. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  82. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  83. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  84. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  85. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  86. package/templates/apps/web/auth.ts +2 -2
  87. package/templates/apps/web/jest.config.cjs +1 -0
  88. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
  89. package/templates/apps/web/lib/auth-email.ts +1 -1
  90. package/templates/apps/web/lib/magnet-routes.ts +3 -1
  91. package/templates/apps/web/lib/newsletter.actions.ts +9 -6
  92. package/templates/apps/web/lib/relay.ts +2 -2
  93. package/templates/apps/web/lib/release-scope.ts +47 -0
  94. package/templates/apps/web/lib/server-api.ts +1 -1
  95. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  96. package/templates/customizations.yaml +37 -0
  97. package/templates/database/prisma.config.ts +10 -14
  98. package/templates/database/seed-test.ts +2 -2
  99. package/templates/database/seed.ts +32 -5
  100. package/templates/package.json +12 -6
  101. package/templates/pnpm-workspace.yaml +0 -1
  102. package/templates/project.yaml +16 -8
  103. package/templates/tools/jest/reporters.cjs +65 -0
  104. package/templates/tools/lint-architecture.ts +757 -7
  105. package/templates/tsconfig.json +1 -2
  106. package/versions.json +48 -35
  107. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  108. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  109. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  110. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  111. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  112. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  113. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  114. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  115. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  116. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  117. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  118. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  119. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  120. package/templates/apps/api/src/bin/spokes.ts +0 -198
  121. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  122. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  123. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  124. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  125. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  126. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  127. package/templates/apps/api/src/config/index.ts +0 -596
  128. package/templates/apps/api/src/config/modules.ts +0 -83
  129. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  130. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  131. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  132. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  133. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  134. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  135. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  136. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  137. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  138. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  139. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  140. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  141. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  142. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  143. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  144. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  145. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  146. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  147. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  148. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -292
  149. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  150. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  151. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  152. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  153. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  154. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  155. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  156. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  157. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  158. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  159. package/templates/apps/api/src/lib/errors.ts +0 -95
  160. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  161. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  162. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  163. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  164. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  165. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  166. package/templates/apps/api/src/lib/locales.ts +0 -201
  167. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  168. package/templates/apps/api/src/lib/prisma.ts +0 -21
  169. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  170. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  171. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  172. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  173. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  174. package/templates/apps/api/src/lib/site.ts +0 -254
  175. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  176. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  177. package/templates/apps/api/src/lib/stripe.ts +0 -318
  178. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  179. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  180. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  181. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  182. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  183. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  184. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  185. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  186. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  187. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  188. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  189. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  190. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  191. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  192. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  193. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  194. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  195. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  196. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  197. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  198. package/templates/apps/api/src/middleware/auth.ts +0 -864
  199. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  200. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  201. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -264
  202. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  203. package/templates/apps/api/src/openapi/index.ts +0 -18
  204. package/templates/apps/api/src/openapi/spec.ts +0 -1815
  205. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  206. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  207. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  208. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  209. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  210. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  211. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  212. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  213. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  214. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  215. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  216. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  217. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  218. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -325
  219. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  220. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1300
  221. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  222. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  223. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  224. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  225. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  226. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  227. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  228. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  229. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  230. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  231. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  232. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  233. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  234. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  235. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  236. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  237. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  238. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  239. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  240. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  241. package/templates/apps/api/src/routes/content/get-page.ts +0 -268
  242. package/templates/apps/api/src/routes/content/index.ts +0 -49
  243. package/templates/apps/api/src/routes/content/list-pages.ts +0 -95
  244. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  245. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  246. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  247. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  248. package/templates/apps/api/src/routes/content/search.ts +0 -129
  249. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  250. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  251. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  252. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  253. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  254. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  255. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  256. package/templates/apps/api/src/routes/leads/index.ts +0 -573
  257. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  258. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  259. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  260. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  261. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -480
  262. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -465
  263. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  264. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  265. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  266. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  267. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  268. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  269. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  270. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  271. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  272. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  273. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  274. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  275. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  276. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  277. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  278. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  279. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  280. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  281. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  282. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  283. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  284. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  285. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -448
  286. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  287. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  288. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  289. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  290. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  291. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  292. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  293. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  294. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -808
  295. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  296. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  297. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  298. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2026
  299. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -528
  300. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  301. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  302. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  303. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  304. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  305. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  306. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  307. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -867
  308. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  309. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  310. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  311. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  312. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  313. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  314. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  315. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  316. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  317. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1176
  318. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -444
  319. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -154
  320. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  321. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  322. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  323. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  324. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  325. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  326. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  327. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  328. package/templates/apps/api/src/services/abandon-email.ts +0 -313
  329. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  330. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  331. package/templates/apps/api/src/services/audit-log.ts +0 -961
  332. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  333. package/templates/apps/api/src/services/consent.ts +0 -834
  334. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  335. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -755
  336. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  337. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  338. package/templates/apps/api/src/services/entitlements.ts +0 -338
  339. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  340. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  341. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  342. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  343. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  344. package/templates/apps/api/src/services/listing-config.ts +0 -178
  345. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  346. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -295
  347. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  348. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -868
  349. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  350. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -396
  351. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -681
  352. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  353. package/templates/apps/api/src/services/offerings/catalog.ts +0 -308
  354. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -385
  355. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  356. package/templates/apps/api/src/services/offerings/connection.ts +0 -1003
  357. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  358. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  359. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  360. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  361. package/templates/apps/api/src/services/person.ts +0 -1188
  362. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  363. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  364. package/templates/apps/api/src/services/project-listings.ts +0 -2073
  365. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  366. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  367. package/templates/apps/api/src/services/revalidation.ts +0 -235
  368. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  369. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  370. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  371. package/templates/apps/api/src/services/verified-email.ts +0 -71
  372. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  373. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  374. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  375. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  376. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  377. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  378. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  379. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  380. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  381. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  382. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  383. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  384. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  385. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  386. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  387. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  388. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1151
  389. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  390. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  391. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  392. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -65
  393. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -187
  394. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  395. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  396. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -165
  397. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  398. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -228
  399. package/templates/apps/mcp/__tests__/locale.test.ts +0 -135
  400. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  401. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  402. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -474
  403. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -225
  404. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -256
  405. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  406. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  407. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -307
  408. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -223
  409. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  410. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  411. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  412. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  413. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  414. package/templates/apps/mcp/src/abuse/index.ts +0 -342
  415. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  416. package/templates/apps/mcp/src/boot.ts +0 -167
  417. package/templates/apps/mcp/src/config/modules.ts +0 -43
  418. package/templates/apps/mcp/src/http-transport.ts +0 -526
  419. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  420. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  421. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -142
  422. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -139
  423. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  424. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  425. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  426. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  427. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  428. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  429. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  430. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  431. package/templates/apps/mcp/src/request-context.ts +0 -52
  432. package/templates/apps/mcp/src/server.ts +0 -343
  433. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  434. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  435. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -64
  436. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  437. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  438. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  439. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  440. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  441. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  442. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  443. package/templates/apps/mcp/src/tools/index.ts +0 -137
  444. package/templates/apps/mcp/src/tools/list_pages.ts +0 -74
  445. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  446. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  447. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  448. package/templates/apps/mcp/src/tools/read_content.ts +0 -75
  449. package/templates/apps/mcp/src/tools/request_callback.ts +0 -59
  450. package/templates/apps/mcp/src/tools/search_content.ts +0 -67
  451. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -57
  452. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  453. package/templates/database/CHANGELOG.md +0 -961
  454. package/templates/database/LICENSE +0 -202
  455. package/templates/database/NOTICE +0 -2
  456. package/templates/database/README.md +0 -243
  457. package/templates/database/__tests__/agents.db.test.ts +0 -320
  458. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  459. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  460. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  461. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  462. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  463. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  464. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  465. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  466. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  467. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  468. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  469. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  470. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  471. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  472. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  473. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  474. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  475. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  476. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  477. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  478. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  479. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  480. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  481. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  482. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  483. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  484. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  485. package/templates/database/__tests__/person.db.test.ts +0 -586
  486. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  487. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  488. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  489. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  490. package/templates/database/__tests__/seed.test.ts +0 -240
  491. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  492. package/templates/database/__tests__/tenancy.test.ts +0 -141
  493. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  494. package/templates/database/_gitignore +0 -8
  495. package/templates/database/auth/.gitkeep +0 -0
  496. package/templates/database/auth/schema.prisma +0 -641
  497. package/templates/database/config/.gitkeep +0 -0
  498. package/templates/database/config/schema.prisma +0 -441
  499. package/templates/database/content/.gitkeep +0 -0
  500. package/templates/database/content/schema.prisma +0 -339
  501. package/templates/database/eslint.config.mjs +0 -69
  502. package/templates/database/flows/listing-diagnostic.ts +0 -182
  503. package/templates/database/flows/presets.ts +0 -315
  504. package/templates/database/inbox/.gitkeep +0 -0
  505. package/templates/database/inbox/README.md +0 -3
  506. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  507. package/templates/database/inbox/schema.prisma +0 -1447
  508. package/templates/database/jest.config.cjs +0 -36
  509. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  510. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  511. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  512. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  513. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  514. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  515. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  516. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  517. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  518. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  519. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  520. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  521. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  522. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  523. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  524. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  525. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  526. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  527. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  528. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  529. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  530. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  531. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  532. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  533. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  534. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  535. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  536. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  537. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  538. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  539. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  540. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  541. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  542. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  543. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  544. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  545. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  546. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  547. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  548. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  549. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  550. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  551. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  552. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  553. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  554. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  555. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  556. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  557. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  558. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  559. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  560. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  561. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  562. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  563. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  564. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  565. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  566. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  567. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  568. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  569. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  570. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  571. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  572. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  573. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  574. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  575. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  576. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  577. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  578. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  579. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  580. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  581. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  582. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  583. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  584. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  585. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  586. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  587. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  588. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  589. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  590. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  591. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  592. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  593. package/templates/database/migrations/migration_lock.toml +0 -3
  594. package/templates/database/ops/.gitkeep +0 -0
  595. package/templates/database/ops/schema.prisma +0 -43
  596. package/templates/database/package.json +0 -89
  597. package/templates/database/schema.prisma +0 -71
  598. package/templates/database/scripts/db-generate-locked.sh +0 -80
  599. package/templates/database/scripts/person-backfill.sql +0 -132
  600. package/templates/database/tenancy.ts +0 -183
  601. package/templates/database/tsconfig.build.json +0 -16
  602. package/templates/database/tsconfig.flows.json +0 -15
  603. package/templates/database/tsconfig.json +0 -16
  604. package/templates/database/tsconfig.tenancy.json +0 -14
@@ -1,1372 +0,0 @@
1
- /**
2
- * @file lead-routing.ts
3
- * @description Cross-namespace lead orchestration service (ADR 0009 D3).
4
- *
5
- * Pipeline: validate → delivery guard → enrich → persist (DB with file
6
- * fallback) → email. Where a lead may go is `resolveLeadDelivery()`
7
- * (`lib/lead-delivery.ts`, #6730 D1): with no database but the email vars set,
8
- * the owner email IS the delivery; in production with neither, nothing is
9
- * accepted. A lead is reported sent only for a durable row or an email the
10
- * provider accepted — never for the non-durable file fallback in production.
11
- * This is the first implementation of ADR 0009 D3's service-layer pattern.
12
- * Future cross-namespace orchestrators in apps/api/src/services/ should mirror
13
- * this shape: type-result returns, mocked-dep tests, non-transactional secondary
14
- * effects, PII-safe logging.
15
- *
16
- * PII boundary: only { siteId, kind, ip, persisted, emailKind } appear in logs.
17
- * Never log email, phone, name, or payload values.
18
- *
19
- * TENANCY (#6203). `input.siteId` is the caller's RESOLVED workspace id —
20
- * `routes/leads/index.ts` resolves it server-side before calling `routeLead`
21
- * and never a client-supplied value (see that file's header). `Lead` gets a
22
- * `workspaceId` stamp of the same value, and since #6374 so do `Subscriber`
23
- * and `Callback` — both gained a required `workspaceId` column in the fence
24
- * fan-out, stamped from the same resolved id by the three `create` sites
25
- * below. (This line previously said those two models had no such column;
26
- * that stopped being true when the keystone migration landed, and the code
27
- * under it already contradicted it.) The one READ in this file — the
28
- * duplicate-subscriber lookup on the P2002 arm — is scoped by it too
29
- * (#6257).
30
- *
31
- * IDEMPOTENCY KEY (#7869, roadmap D5). A `submit` may carry a caller's
32
- * `Idempotency-Key` (`routes/leads/index.ts` reads and validates the header;
33
- * it travels beside the input as `routeLead`'s third argument, never inside
34
- * the `.strict()` input). On `inbox` delivery only, a key already stored on a
35
- * `Lead` of THIS workspace short-circuits the write: the same `email`,
36
- * `source` and `payload` is a replay (the first lead's id, nothing written,
37
- * no capture record, no notification); anything else is an
38
- * `idempotency_conflict` (nothing written). The two reads — the lookup
39
- * before the write and the re-read after a `P2002` race — are both keyed on
40
- * `(workspaceId, idempotencyKey)`, so a key can never reach another tenant's
41
- * row. `email`, `local` and `none` deliveries ignore the key: there is no
42
- * row to dedupe against. The key itself is never logged.
43
- *
44
- * PERSON (#852 D6). Before a `submit` writes its `Lead`, `findOrCreatePerson`
45
- * (`services/person.ts`, the only Person creator) resolves the client of
46
- * this workspace for the lead's email, and its id joins the create as
47
- * `personId`. Lead capture is a never-gate surface, so the Person step never
48
- * throws, never refuses the lead and never changes `routeLead`'s result: an
49
- * unstorable address or a failed step writes the lead with `personId: null`
50
- * and logs one bare line (`person-skipped` / `person-failed`, never the
51
- * email). The `email`, `local` and `none` deliveries never reach
52
- * `persistToDb`.
53
- *
54
- * CONTACT AND LINKS (#8435 D5). A `submit` may carry `options.contact` (the
55
- * magnet's tagged answers): `findOrCreatePerson` fills the Person's BLANK
56
- * contact fields from it while the Person is uncurated, and the lead's own
57
- * `payload` is unchanged. A `subscribe` and a `callback` with a storable email
58
- * (a callback's is `payload.email`) now also resolve a Person and stamp its id
59
- * as `personId`; a callback with no email makes no Person call and writes no
60
- * `personId`. Every one of these steps is best-effort exactly like the lead's.
61
- *
62
- * CHECKOUT PLAN (#7877 D10, #8793 D1). A spoke's checkout fulfilment posts
63
- * one `submit` with the Checkout Session id as its `Idempotency-Key`. A
64
- * LIVE, paid checkout lead from a registered spoke's server — `viaSpoke`, a
65
- * `cs_live_` key, `source: "web"`, `payload.page: "checkout"`, a string
66
- * `payload.offering` and `payload.site`, and an `amountTotal` that is
67
- * absent, `null` or a positive integer — names a plan: the offering minus a
68
- * leading `<site>_` (`person.ts`'s `checkoutPlanKey`). Its email is what
69
- * the buyer typed at Stripe Checkout, which nobody verified, so the lead
70
- * never writes any Person's plan (SEC-8748-D). It HOLDS it: on the first
71
- * write of a marked lead (below) with a Person, the step logs one bare
72
- * `{ event: "checkout-plan", outcome, siteId }` line at `info` — `held` for
73
- * a plan key, `skipped` for none — and makes no Person call; the marked lead
74
- * itself is the hold. `person.ts`'s `releaseHeldCheckoutPlan` writes it
75
- * later, once, at the Person's first member-path link to the user whose
76
- * verified email is the Person's own. No spoke file changes. The step never
77
- * throws, never changes `routeLead`'s result, and never logs the offering,
78
- * the email or the key.
79
- *
80
- * THE OFFERINGS TWIN (#8932 D7). The per-Organization Stripe webhook
81
- * (`routes/webhooks/stripe-offerings.ts`) is the predicate's second source:
82
- * `options.offeringsCheckout`, which it sets only after W3–W6 (its own
83
- * signature, the hub's session record for that Organization and workspace,
84
- * the recorded Price paid) and no request can set, a key
85
- * `"stripe-offerings " + <cs_live_ session id>` (a space no `Idempotency-Key`
86
- * header can carry), `source: "web"`, `payload.page: "checkout"`, a
87
- * `payload.offering` `parseLookupKey` admits and a `payload.site` equal to
88
- * the lead's workspace. Its plan key is the FULL lookup key (no `<site>_`
89
- * prefix can match a workspace id), and its consent grant's `sourceRef` is
90
- * the bare session id. A test-mode session's key fails the twin, so its lead
91
- * is unmarked (#8932 C9).
92
- *
93
- * SPOKE CHECKOUT MARKER (#8185 D2). The same predicate — or its Offerings
94
- * twin (#8932 D7) — plus "not relayed for a visitor" (`SourceMeta.relayed`),
95
- * is evaluated once BEFORE the lead's create and stored as
96
- * `Lead.spokeCheckout` — the hub's conclusion at write
97
- * time, which `GET /v1/member/purchases` serves as a purchase
98
- * (`lib/member-store.ts`). Written only in the submit create below, never by
99
- * an update; an unmarked create carries no `spokeCheckout` key at all and
100
- * relies on the column default. The plan step above runs only for a marked
101
- * lead, after the write, and holds (#8793 D1), so a request relayed for a
102
- * visitor never reaches it.
103
- *
104
- * PURCHASE EMAIL AND INBOX MESSAGE (#8757 D14). After this request's own
105
- * write of a lead marked `spokeCheckout` with a Person — so never on a
106
- * replay, a test-mode or relayed checkout, or a lead with no Person — the
107
- * buyer gets one outbound `member` `Interaction` on that Person
108
- * (`createdBy: "system:checkout"`, subject "Your purchase", no link, payload
109
- * or attachment) and, fired with `void`, one `sendPurchaseConfirmationEmail`.
110
- * Both name the offering only when its stripped key is one
111
- * `listPlanOfferings` lists for the workspace, else "your purchase" — never
112
- * the spoke-supplied `payload.offering` (SEC-8757-G) — and both bodies are
113
- * stripped of control and bidi characters. The email's "Open your account"
114
- * link is `options.accountOrigin` (the route resolves it, as for the invite
115
- * email) plus the hub sign-in path: a marked lead is always a registered
116
- * spoke's. With no origin the email goes without the link. The email is
117
- * capped per recipient: more than `PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY`
118
- * marked leads of this workspace for the address (any case) in the last 24
119
- * hours, this one included, skips it; and per workspace: more than
120
- * `PURCHASE_EMAILS_PER_SITE_PER_HOUR` marked leads in the last hour skips
121
- * it. The message is still written. Neither
122
- * step ever throws or changes the lead's result, and their one log line,
123
- * `{ event: "checkout-purchase", step, outcome }`, names no address or
124
- * offering.
125
- *
126
- * CHECKOUT CONSENT GRANT (#8778). After the same first write of a marked
127
- * lead with a Person, a lead whose `payload.termsAccepted` equals the current
128
- * `account_terms@<version>` stamp (`currentAccountTermsStamp`) — the spoke's
129
- * fulfilment sends the stamp of the terms the buyer ticked in Stripe's box,
130
- * else `null` — records the buyer's checkout `ConsentGrant` through #8765's
131
- * `recordCheckoutConsent`: one `account_terms` row for that Person, source
132
- * `checkout`, `sourceRef` the Checkout Session id (the lead's
133
- * `Idempotency-Key`), at most once per Person and session. A `null` or absent
134
- * value writes nothing (`consent_absent`), as does any other value
135
- * (`terms_unstamped`). A replay never reaches it, and the key is left out of
136
- * the replay comparison (`comparablePayload`). Never throws and never
137
- * changes the lead's result; its one log line,
138
- * `{ event: "checkout-consent", outcome, siteId }` (plus `errName` on
139
- * `failed`), names no address or Person.
140
- *
141
- * VISITOR LINK (#8438 D11). When the request carried a valid consent visitor
142
- * id (`SourceMeta.consentVisitorId`, set by the leads and flows routes from
143
- * `x-ailk-consent-visitor` only when it is a v4 UUID) and the submit resolved
144
- * a Person, `linkConsentVisitor` joins the two — source `magnet` for a
145
- * `flow:` source, else `api`. A link never fails a lead and is never
146
- * returned: `RouteLeadResult.personId` is internal, for the flow engine's
147
- * consent write. A subscribe or callback resolves no Person, so it links
148
- * nothing. The header is never echoed and never logged.
149
- */
150
-
151
- import { randomUUID } from "node:crypto";
152
- import { isDeepStrictEqual } from "node:util";
153
-
154
- import type { Prisma } from "@working-theory/database";
155
- import { sendLeadNotification, sendPurchaseConfirmationEmail } from "@working-theory/email";
156
- import { logger } from "@working-theory/observability";
157
- import {
158
- accountInvitePath,
159
- isConsentLeadSource,
160
- LEAD_SITE_MARKER_KEY,
161
- leadInputSchema,
162
- parseLookupKey,
163
- PURCHASE_EMAIL_DEFAULTS,
164
- PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY,
165
- stripPlainText,
166
- } from "@working-theory/validation";
167
-
168
- import { writeLeadFallback } from "../lib/file-fallback.js";
169
- import { appendLeadRecord } from "../lib/lead-capture-store.js";
170
- import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
171
- import { prisma } from "../lib/prisma.js";
172
-
173
- import {
174
- currentAccountTermsStamp,
175
- linkConsentVisitor,
176
- recordCheckoutConsent,
177
- type RecordCheckoutConsentOutcome,
178
- } from "./consent.js";
179
- import { enrichLead } from "./enrichment-shim.js";
180
- import {
181
- checkoutPlanKey,
182
- findOrCreatePerson,
183
- normalizeCapturedContact,
184
- normalizeTelephone,
185
- } from "./person.js";
186
- import type { LeadContact, PersonContact } from "./person.js";
187
- import { listPlanOfferings } from "./plan-offerings.js";
188
- import { requestConfirmSupersedesNotification } from "./request-confirm.js";
189
-
190
- // ─── Types ────────────────────────────────────────────────────────────────────
191
-
192
- export type LeadKind = "submit" | "subscribe" | "callback";
193
-
194
- export type RouteLeadInput =
195
- | {
196
- kind: "submit";
197
- siteId: string;
198
- source: string;
199
- email: string;
200
- payload: Record<string, unknown>;
201
- }
202
- | { kind: "subscribe"; siteId: string; email: string; source: string }
203
- | {
204
- kind: "callback";
205
- siteId: string;
206
- name: string;
207
- phone: string;
208
- preferredTime?: string;
209
- payload: Record<string, unknown>;
210
- };
211
-
212
- export type SourceMeta = {
213
- ip: string;
214
- userAgent: string | undefined;
215
- receivedAt: string; // ISO
216
- /**
217
- * The lead came from a registered spoke of a hub (#7532 D4). Absent reads as
218
- * `false`. Never persisted — `captureRecordFor` copies fields by name.
219
- */
220
- viaSpoke?: boolean;
221
- /**
222
- * The request carried `x-ailk-relayed`, i.e. it was forwarded for a
223
- * visitor by a spoke's same-origin relay (#8185 D3). Absent reads as
224
- * `false`. Never persisted. It can only REMOVE the spoke-checkout marker,
225
- * never grant it.
226
- */
227
- relayed?: boolean;
228
- /**
229
- * The cookie banner's visitor id from the request's `x-ailk-consent-visitor`
230
- * header, set only when it is a v4 UUID (#8438 D11). Used to link the
231
- * visitor to the lead's Person; never persisted, echoed or logged.
232
- */
233
- consentVisitorId?: string;
234
- };
235
-
236
- /** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
237
- export type Persisted = "db" | "file" | "email";
238
-
239
- /** Per-call options carried beside the input (#7869 D2). */
240
- export type RouteLeadOptions = {
241
- /** The caller's validated `Idempotency-Key`; honoured for `submit` only. */
242
- idempotencyKey?: string;
243
- /**
244
- * The contact the capture offers (#8435 D5) — the magnet's tagged answers —
245
- * honoured for `submit` only and never persisted as such: the lead's
246
- * `payload` stays the raw answers.
247
- */
248
- contact?: LeadContact;
249
- /**
250
- * Where the buyer's account lives (#8757 D14): the origin the purchase
251
- * email's "Open your account" link starts sign-in on, resolved by the route
252
- * from the caller, never the body. Read for a marked checkout lead only.
253
- */
254
- accountOrigin?: string;
255
- /**
256
- * Set only by the per-Organization Stripe webhook after W5 and W6 (#8932
257
- * D7) — a signature from the Organization's own secret, the hub's own
258
- * session record for that Organization and workspace, and the recorded
259
- * Price paid; never from a request. Admits the Offerings twin of the
260
- * checkout plan predicate (`offeringsCheckoutCandidate`).
261
- */
262
- offeringsCheckout?: true;
263
- };
264
-
265
- export type RouteLeadResult =
266
- | {
267
- ok: true;
268
- /**
269
- * The answer to a repeat of an already-stored idempotency key (#7869
270
- * D4): nothing was written. Never on the wire — the route builds its
271
- * 201 body from `id`, `persisted` and `email` only.
272
- */
273
- replayed?: true;
274
- id: string;
275
- /**
276
- * The Person a `submit` resolved (#8438 D11), `null`/absent when none.
277
- * Internal only — the route builds its 201 body from `id`, `persisted`
278
- * and `email`, so this never reaches a wire.
279
- */
280
- personId?: string | null;
281
- persisted: Persisted;
282
- email:
283
- | { ok: true; messageId: string }
284
- | { ok: false; error: string }
285
- | { skipped: true; reason: string };
286
- }
287
- | {
288
- ok: false;
289
- error: {
290
- kind:
291
- | "invalid_input"
292
- | "persist_failed"
293
- | "config_missing"
294
- | "delivery_failed"
295
- | "idempotency_conflict";
296
- message: string;
297
- };
298
- };
299
-
300
- // ─── Validation ───────────────────────────────────────────────────────────────
301
-
302
- function validateInput(
303
- input: RouteLeadInput,
304
- ): { ok: true } | { ok: false; message: string } {
305
- const result = leadInputSchema.safeParse(input);
306
- if (!result.success) {
307
- return {
308
- ok: false,
309
- message: result.error.issues[0]?.message ?? "invalid input",
310
- };
311
- }
312
- return { ok: true };
313
- }
314
-
315
- // ─── Idempotency key (#7869) ─────────────────────────────────────────────────
316
-
317
- type SubmitInput = Extract<RouteLeadInput, { kind: "submit" }>;
318
- type KeyedRow = { id: string; email: string; source: string; payload: unknown };
319
- /** What a stored key means for this request (#7869 D4). */
320
- type KeyedOutcome = { kind: "replay"; id: string } | { kind: "conflict" };
321
-
322
- /**
323
- * The one read of `Lead.idempotencyKey` (#7869 D3, reused by D5's re-read).
324
- * The compound unique carries `workspaceId`, so the lookup can only ever
325
- * reach this workspace's rows.
326
- */
327
- async function findLeadByKey(
328
- workspaceId: string,
329
- idempotencyKey: string,
330
- ): Promise<KeyedRow | null> {
331
- return (await prisma.lead.findUnique({
332
- where: { workspaceId_idempotencyKey: { workspaceId, idempotencyKey } },
333
- select: { id: true, email: true, source: true, payload: true },
334
- })) as KeyedRow | null;
335
- }
336
-
337
- /**
338
- * A consent-bearing lead's `payload.consent` — a contact lead's (#8730 D3) or,
339
- * since #9130, a job application's — is written by the route on every request,
340
- * its `acceptedAt` that request's receive time, so it is left out of the replay
341
- * comparison for every `isConsentLeadSource` source; every key the client sent
342
- * still counts.
343
- *
344
- * A checkout lead's `payload.termsAccepted` (#8778) is left out too: a spoke
345
- * updated between a stored attempt and Stripe's redelivery sends the key the
346
- * stored body lacks, and a conflict there would fail every retry. A replay
347
- * writes no grant, so the key decides nothing on one.
348
- */
349
- function comparablePayload(payload: unknown, source: string): unknown {
350
- if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
351
- return payload;
352
- }
353
- const record = payload as Record<string, unknown>;
354
- if (isConsentLeadSource(source)) {
355
- const { consent: _serverWritten, ...rest } = record;
356
- return rest;
357
- }
358
- if (record["page"] === "checkout") {
359
- const { termsAccepted: _addedByUpdate, ...rest } = record;
360
- return rest;
361
- }
362
- return payload;
363
- }
364
-
365
- /**
366
- * A stored row is a replay only when it carries this request's exact `email`
367
- * and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
368
- * any other row is a conflict (#7869 D4). A consent-bearing lead's
369
- * server-written `payload.consent` is not compared (#8730, #9130), nor a
370
- * checkout lead's `payload.termsAccepted` (#8778).
371
- */
372
- function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
373
- return row.email === input.email &&
374
- row.source === input.source &&
375
- isDeepStrictEqual(
376
- comparablePayload(row.payload, row.source),
377
- comparablePayload(input.payload, input.source),
378
- )
379
- ? { kind: "replay", id: row.id }
380
- : { kind: "conflict" };
381
- }
382
-
383
- function keyedResult(outcome: KeyedOutcome): RouteLeadResult {
384
- if (outcome.kind === "conflict") {
385
- return {
386
- ok: false,
387
- error: { kind: "idempotency_conflict", message: "idempotency key reused" },
388
- };
389
- }
390
- return {
391
- ok: true,
392
- replayed: true,
393
- id: outcome.id,
394
- persisted: "db",
395
- email: { skipped: true, reason: "idempotent_replay" },
396
- };
397
- }
398
-
399
- /** The structural `P2002` test `recommender-capture.ts` already uses. */
400
- function isUniqueViolation(err: unknown): boolean {
401
- return (
402
- typeof err === "object" && err !== null && (err as { code?: unknown }).code === "P2002"
403
- );
404
- }
405
-
406
- // ─── DB persist ───────────────────────────────────────────────────────────────
407
-
408
- type DbRow = { id: string };
409
- type PersistResult =
410
- | {
411
- ok: true;
412
- id: string;
413
- source: "db";
414
- skipEmail: boolean;
415
- skipReason?: string;
416
- /** The Person a `submit` resolved (#8438 D11). */
417
- personId?: string | null;
418
- }
419
- // #7869 D5 — a keyed create lost a race to a row it now answers with.
420
- | { ok: "keyed"; outcome: KeyedOutcome }
421
- | { ok: false; error: string };
422
-
423
- /**
424
- * The Person of a submitted lead (#852 D6) — its id, or `null` when the email
425
- * is not a storable address or the step failed. Never throws: a Person step
426
- * must never refuse, lose or delay a lead. Logs the outcome kind and the
427
- * site only.
428
- */
429
- async function resolveCapturePersonId(
430
- siteId: string,
431
- capture: { email: string; name?: string | null; contact?: Partial<PersonContact> },
432
- ): Promise<string | null> {
433
- try {
434
- const person = await findOrCreatePerson(siteId, capture);
435
- if (person.ok) return person.value.personId;
436
- if (person.error.kind === "invalid_email") {
437
- logger.warn(
438
- { event: "person-skipped", kind: person.error.kind, siteId },
439
- "lead person skipped",
440
- );
441
- } else {
442
- logger.error({ event: "person-failed", siteId }, "lead person failed");
443
- }
444
- } catch {
445
- logger.error({ event: "person-failed", siteId }, "lead person failed");
446
- }
447
- return null;
448
- }
449
-
450
- async function resolveLeadPersonId(input: SubmitInput, contact?: LeadContact): Promise<string | null> {
451
- const payloadName = typeof input.payload["name"] === "string" ? input.payload["name"] : null;
452
- if (contact === undefined) {
453
- return resolveCapturePersonId(input.siteId, { email: input.email, name: payloadName });
454
- }
455
- // #8435 D5 — an explicit contact name wins over the payload's.
456
- const name = typeof contact.name === "string" ? contact.name : payloadName;
457
- return resolveCapturePersonId(input.siteId, {
458
- email: input.email,
459
- name,
460
- contact: normalizeCapturedContact(contact),
461
- });
462
- }
463
-
464
- /** A marked checkout lead's offering and site; `sessionId` is the Offerings twin's bare session id. */
465
- type CheckoutCandidate = { offering: string; site: string; sessionId?: string };
466
-
467
- /** A LIVE-mode Checkout Session id (#7877 D10, SEC-7877-C). */
468
- const LIVE_CHECKOUT_SESSION_KEY = /^cs_live_[A-Za-z0-9]{1,250}$/;
469
- const SITE_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
470
-
471
- /**
472
- * The raw offering of a lead that qualifies for the checkout plan rule, or
473
- * `null` when it does not (#7877 D10). Reads the lead's own fields only.
474
- */
475
- function checkoutPlanCandidate(
476
- input: SubmitInput,
477
- idempotencyKey: string | undefined,
478
- viaSpoke: boolean,
479
- ): CheckoutCandidate | null {
480
- if (viaSpoke !== true) return null;
481
- if (idempotencyKey === undefined || !LIVE_CHECKOUT_SESSION_KEY.test(idempotencyKey)) {
482
- return null;
483
- }
484
- const { payload } = input;
485
- const amountTotal = payload["amountTotal"];
486
- if (
487
- amountTotal !== undefined &&
488
- amountTotal !== null &&
489
- !(typeof amountTotal === "number" && Number.isInteger(amountTotal) && amountTotal > 0)
490
- ) {
491
- return null;
492
- }
493
- if (input.source !== "web" || payload["page"] !== "checkout") return null;
494
- const offering = payload["offering"];
495
- const site = payload[LEAD_SITE_MARKER_KEY];
496
- if (typeof offering !== "string" || typeof site !== "string" || !SITE_SLUG.test(site)) {
497
- return null;
498
- }
499
- return { offering, site };
500
- }
501
-
502
- /** The Offerings webhook's key: a space no `Idempotency-Key` header can carry, then a LIVE session id. */
503
- const OFFERINGS_CHECKOUT_KEY = /^stripe-offerings (cs_live_[A-Za-z0-9]{1,250})$/;
504
-
505
- /**
506
- * The Offerings twin of `checkoutPlanCandidate` (#8932 D7): a lead the
507
- * per-Organization Stripe webhook wrote after W5 and W6, for a LIVE session
508
- * only (C9, SEC-7877-C) — a test-mode session's lead is written unmarked.
509
- * Reads no amount: W6 already proved the session paid. `checkoutPlanCandidate`
510
- * is not edited, so a spoke lead is marked exactly as before.
511
- */
512
- function offeringsCheckoutCandidate(
513
- input: SubmitInput,
514
- idempotencyKey: string | undefined,
515
- offeringsCheckout: boolean,
516
- ): CheckoutCandidate | null {
517
- if (offeringsCheckout !== true || idempotencyKey === undefined) return null;
518
- const sessionId = OFFERINGS_CHECKOUT_KEY.exec(idempotencyKey)?.[1];
519
- if (sessionId === undefined) return null;
520
- const { payload } = input;
521
- if (input.source !== "web" || payload["page"] !== "checkout") return null;
522
- const offering = payload["offering"];
523
- if (typeof offering !== "string" || parseLookupKey(offering) === null) return null;
524
- const site = payload[LEAD_SITE_MARKER_KEY];
525
- if (typeof site !== "string" || site !== input.siteId) return null;
526
- return { offering, site, sessionId };
527
- }
528
-
529
- /** The candidate's offering minus a leading `<site>_` (#7877 D10). */
530
- function stripSitePrefix(candidate: CheckoutCandidate): string {
531
- const prefix = `${candidate.site}_`;
532
- return candidate.offering.startsWith(prefix)
533
- ? candidate.offering.slice(prefix.length)
534
- : candidate.offering;
535
- }
536
-
537
- /**
538
- * Hold the plan of a marked checkout lead (#7877 D10, #8793 D1): no Person
539
- * call — the marked lead itself is the hold, released only at the Person's
540
- * verified first link (`person.ts`'s `releaseHeldCheckoutPlan`). Logs
541
- * `held` for an offering that is a plan key, else `skipped`, and the site
542
- * only.
543
- */
544
- function applyCheckoutPlan(
545
- input: SubmitInput,
546
- candidate: CheckoutCandidate,
547
- ): void {
548
- const outcome = checkoutPlanKey(candidate.offering, candidate.site) === null ? "skipped" : "held";
549
- logger.info({ event: "checkout-plan", outcome, siteId: input.siteId }, "checkout plan");
550
- }
551
-
552
- // ─── Purchase email and Inbox message (#8757 D14) ────────────────────────────
553
-
554
- const DAY_MS = 24 * 60 * 60 * 1000;
555
- const HOUR_MS = 60 * 60 * 1000;
556
- /**
557
- * A workspace's purchase emails per hour, whatever the address (SEC review of
558
- * #8770, M1): a marked lead's inputs are all the spoke's own, so the
559
- * per-recipient cap alone would let a spoke key mail any number of distinct
560
- * addresses. The invite email's per-site cap
561
- * (`INVITE_EMAILS_PER_SITE_PER_HOUR`) has the same value.
562
- */
563
- const PURCHASE_EMAILS_PER_SITE_PER_HOUR = 50;
564
- const GENERIC_PURCHASE_BODY = "Thanks for your purchase.";
565
- /** How long the lead write waits on the offering's name before it says "your purchase". */
566
- const OFFERING_NAME_BUDGET_MS = 1500;
567
- /**
568
- * Stripe's special-case currencies, whose minor units differ from the ISO
569
- * decimals `Intl` formats with: the amount is left out rather than misstated.
570
- */
571
- const AMOUNT_UNSTATED_CURRENCIES = new Set(["HUF", "ISK", "TWD", "UGX"]);
572
-
573
- /**
574
- * The purchase email's sender: every completion email's rule
575
- * (`AILK_DELIVERABLE_EMAIL_FROM`, else `AILK_LEAD_NOTIFICATION_FROM`; `||`, so
576
- * "" names none), the same as `magnet-completion.ts`'s `inviteSenderAddress`,
577
- * kept here so the lead path does not load that module's graph.
578
- */
579
- function purchaseSenderAddress(): string | undefined {
580
- return (
581
- process.env.AILK_DELIVERABLE_EMAIL_FROM ||
582
- process.env.AILK_LEAD_NOTIFICATION_FROM ||
583
- undefined
584
- );
585
- }
586
-
587
- type PurchaseStep = "message" | "email";
588
-
589
- function logPurchase(siteId: string, step: PurchaseStep, outcome: string): void {
590
- const line = { event: "checkout-purchase", step, outcome, siteId };
591
- if (outcome === "failed") logger.error(line, "checkout purchase step failed");
592
- else logger.info(line, "checkout purchase step");
593
- }
594
-
595
- /**
596
- * The offering's listed name, or `null` when its stripped key is not listed,
597
- * the read fails, or it outlasts `OFFERING_NAME_BUDGET_MS`.
598
- */
599
- async function listedOfferingName(siteId: string, offering: string): Promise<string | null> {
600
- let timer: NodeJS.Timeout | undefined;
601
- const budget = new Promise<null>((resolve) => {
602
- timer = setTimeout(() => resolve(null), OFFERING_NAME_BUDGET_MS);
603
- });
604
- try {
605
- const listed = await Promise.race([listPlanOfferings(siteId), budget]);
606
- if (!Array.isArray(listed)) return null;
607
- const name = listed.find((item) => item.key === offering)?.name;
608
- const clean = name === undefined ? "" : stripPlainText(name).trim();
609
- return clean === "" ? null : clean;
610
- } catch {
611
- return null;
612
- } finally {
613
- clearTimeout(timer);
614
- }
615
- }
616
-
617
- /** `amountTotal` (minor units) in `currency`, formatted, or `null` when either is unusable. */
618
- function formattedAmount(amountTotal: unknown, currency: unknown): string | null {
619
- if (typeof amountTotal !== "number" || !Number.isInteger(amountTotal) || amountTotal <= 0) {
620
- return null;
621
- }
622
- if (typeof currency !== "string" || !/^[A-Za-z]{3}$/.test(currency)) return null;
623
- const code = currency.toUpperCase();
624
- if (AMOUNT_UNSTATED_CURRENCIES.has(code)) return null;
625
- try {
626
- const format = new Intl.NumberFormat("en", { style: "currency", currency: code });
627
- const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
628
- return format.format(amountTotal / 10 ** digits);
629
- } catch {
630
- return null;
631
- }
632
- }
633
-
634
- /** The one plain-text body the email and the message share. */
635
- function purchaseBody(name: string | null, amount: string | null): string {
636
- const sentence =
637
- name === null ? GENERIC_PURCHASE_BODY : PURCHASE_EMAIL_DEFAULTS.body.replaceAll("{offering}", name);
638
- return stripPlainText(amount === null ? sentence : `${sentence} Total: ${amount}.`);
639
- }
640
-
641
- /** The purchase email, capped per recipient (D14). Never throws; fired with `void`. */
642
- async function sendPurchaseEmail(
643
- input: SubmitInput,
644
- body: string,
645
- accountOrigin: string | undefined,
646
- ): Promise<void> {
647
- let outcome: string;
648
- try {
649
- const recent = await prisma.lead.count({
650
- where: {
651
- workspaceId: input.siteId,
652
- spokeCheckout: true,
653
- email: { equals: input.email, mode: "insensitive" },
654
- createdAt: { gte: new Date(Date.now() - DAY_MS) },
655
- },
656
- });
657
- const siteHour = await prisma.lead.count({
658
- where: {
659
- workspaceId: input.siteId,
660
- spokeCheckout: true,
661
- createdAt: { gte: new Date(Date.now() - HOUR_MS) },
662
- },
663
- });
664
- const fromAddress = purchaseSenderAddress();
665
- if (recent > PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY) {
666
- outcome = "capped_recipient";
667
- } else if (siteHour > PURCHASE_EMAILS_PER_SITE_PER_HOUR) {
668
- outcome = "capped_site";
669
- } else if (!fromAddress) {
670
- outcome = "sender_unconfigured";
671
- } else {
672
- let accountLink: string | undefined;
673
- if (accountOrigin !== undefined) {
674
- try {
675
- accountLink = accountOrigin + accountInvitePath("hub", input.email);
676
- } catch {
677
- // An address the sign-in hint refuses: the email goes without the link.
678
- }
679
- }
680
- const result = await sendPurchaseConfirmationEmail({
681
- to: input.email,
682
- fromAddress,
683
- subject: PURCHASE_EMAIL_DEFAULTS.subject,
684
- body,
685
- cta: PURCHASE_EMAIL_DEFAULTS.cta,
686
- ...(accountLink !== undefined && { accountLink }),
687
- });
688
- outcome = result.ok ? "sent" : "failed";
689
- }
690
- } catch {
691
- outcome = "failed";
692
- }
693
- logPurchase(input.siteId, "email", outcome);
694
- }
695
-
696
- /**
697
- * The buyer's Inbox message and purchase email for a marked checkout lead
698
- * just written (D14). Never throws and never changes the lead's result.
699
- */
700
- async function recordPurchase(
701
- input: SubmitInput,
702
- personId: string,
703
- candidate: CheckoutCandidate,
704
- accountOrigin: string | undefined,
705
- ): Promise<void> {
706
- // One guard around the whole step: it runs inside `routeLead`'s persist
707
- // `try`, where a throw after the row is written would be read as a failed
708
- // write and the lead captured a second time.
709
- let body: string;
710
- try {
711
- const name = await listedOfferingName(input.siteId, stripSitePrefix(candidate));
712
- body = purchaseBody(name, formattedAmount(input.payload["amountTotal"], input.payload["currency"]));
713
- } catch {
714
- logPurchase(input.siteId, "message", "failed");
715
- return;
716
- }
717
- try {
718
- await prisma.interaction.create({
719
- data: {
720
- workspaceId: input.siteId,
721
- personId,
722
- channel: "member",
723
- direction: "outbound",
724
- subject: PURCHASE_EMAIL_DEFAULTS.subject,
725
- body,
726
- createdBy: "system:checkout",
727
- },
728
- });
729
- logPurchase(input.siteId, "message", "written");
730
- } catch {
731
- logPurchase(input.siteId, "message", "failed");
732
- }
733
- void sendPurchaseEmail(input, body, accountOrigin);
734
- }
735
-
736
- /**
737
- * The buyer's checkout consent grant for a marked checkout lead just written
738
- * (#8778). Never throws and never changes the lead's result.
739
- */
740
- async function recordSpokeCheckoutConsent(
741
- input: SubmitInput,
742
- personId: string,
743
- sessionId: string,
744
- ): Promise<void> {
745
- const accepted = input.payload["termsAccepted"];
746
- let outcome:
747
- | RecordCheckoutConsentOutcome
748
- | "consent_absent"
749
- | "terms_unstamped"
750
- | "failed";
751
- let errName: string | undefined;
752
- try {
753
- const expected = currentAccountTermsStamp();
754
- if (accepted === undefined || accepted === null) {
755
- outcome = "consent_absent";
756
- } else if (expected === null || accepted !== expected.stamp) {
757
- outcome = "terms_unstamped";
758
- } else {
759
- outcome = await recordCheckoutConsent({
760
- workspaceId: input.siteId,
761
- personId,
762
- sessionId,
763
- policyVersion: expected.policyVersion,
764
- });
765
- }
766
- } catch (err) {
767
- // The error's class only (a Prisma error's message can carry values).
768
- outcome = "failed";
769
- errName = err instanceof Error ? err.name : "UnknownError";
770
- }
771
- const line = {
772
- event: "checkout-consent",
773
- outcome,
774
- siteId: input.siteId,
775
- ...(errName !== undefined && { errName }),
776
- };
777
- if (outcome === "failed") logger.error(line, "checkout terms grant failed");
778
- else logger.info(line, "checkout terms grant");
779
- }
780
-
781
- async function persistToDb(
782
- input: RouteLeadInput,
783
- idempotencyKey?: string,
784
- viaSpoke = false,
785
- relayed = false,
786
- contact?: LeadContact,
787
- accountOrigin?: string,
788
- offeringsCheckout = false,
789
- ): Promise<PersistResult> {
790
- if (input.kind === "submit") {
791
- const personId = await resolveLeadPersonId(input, contact);
792
- // #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
793
- // evaluated before the create so its result can be stored. A relayed
794
- // request is never a spoke's server call (D3). #8932 D7 — or its
795
- // Offerings twin, set only by the per-Organization Stripe webhook.
796
- const candidate =
797
- checkoutPlanCandidate(input, idempotencyKey, viaSpoke) ??
798
- offeringsCheckoutCandidate(input, idempotencyKey, offeringsCheckout);
799
- const spokeCheckout = candidate !== null && relayed !== true;
800
- let row: DbRow;
801
- try {
802
- row = (await prisma.lead.create({
803
- data: {
804
- siteId: input.siteId,
805
- // #6203/#6254 — the isolation key, equal to `siteId` by
806
- // construction. `input.siteId` is the RESOLVED workspace id at
807
- // EVERY `routeLead(kind: "submit")` caller now — the primary route
808
- // (`routes/leads/index.ts`, never reads it from the body — see that
809
- // file's header) and the three service-layer callers
810
- // (`flow-engine.ts`'s `completeSubmission`,
811
- // `recommender-capture.ts`'s `captureRecommenderChoice`,
812
- // `project-listings.ts`'s `submitProjectListingDraft`) all resolve
813
- // the site alias before calling in (#6254 AC-6/AC-10). `workspaceId`
814
- // is NOT NULL as of
815
- // database/migrations/20260922120000_tenancy_fence, so this can no
816
- // longer stamp `null` for an unresolved alias the way it once did
817
- // (spec D2's old rule) — a caller that somehow still passed one
818
- // would instead hit the pre-existing `<table>_workspace_id_format`
819
- // CHECK loudly, via the AC-11 re-raise below, rather than landing a
820
- // silently-unfenced row.
821
- workspaceId: input.siteId,
822
- source: input.source,
823
- email: input.email,
824
- payload: input.payload as Prisma.InputJsonValue,
825
- // #852 D6 — the Person `resolveLeadPersonId` found or created.
826
- personId,
827
- ...(idempotencyKey !== undefined && { idempotencyKey }),
828
- // #8185 D4 — only a marked create names the column.
829
- ...(spokeCheckout && { spokeCheckout: true }),
830
- },
831
- select: { id: true },
832
- })) as DbRow;
833
- } catch (err) {
834
- // #7869 D5 — two keyed requests both missed the lookup and both
835
- // created; the unique index refused the second. Answer with the
836
- // winner. Anything else — no key, another error, a winner that cannot
837
- // be read back — is rethrown untouched, so `routeLead`'s existing
838
- // constraint and outage handling decide it exactly as before.
839
- if (idempotencyKey === undefined || !isUniqueViolation(err)) throw err;
840
- const winner = await findLeadByKey(input.siteId, idempotencyKey).catch(() => null);
841
- if (!winner) throw err;
842
- return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
843
- }
844
- // #7877 D10 — only after THIS request's own write, so a replay holds nothing.
845
- if (candidate !== null && personId !== null) {
846
- // #8757 D14 — the marked lead only: live, unrelayed, this write.
847
- if (spokeCheckout) {
848
- // #8793 D1 — the plan is held, never written here.
849
- applyCheckoutPlan(input, candidate);
850
- await recordPurchase(input, personId, candidate, accountOrigin);
851
- // #8778 — a marked lead's key is its live Checkout Session id;
852
- // #8932 D7 — the Offerings twin's key is prefixed, so its bare id.
853
- const sessionId = candidate.sessionId ?? idempotencyKey;
854
- if (sessionId !== undefined) {
855
- await recordSpokeCheckoutConsent(input, personId, sessionId);
856
- }
857
- }
858
- }
859
- return { ok: true, id: row.id, source: "db", skipEmail: false, personId };
860
- }
861
-
862
- if (input.kind === "subscribe") {
863
- // #8435 D5 — the subscriber's Person; `null` when the step skips or fails.
864
- const personId = await resolveCapturePersonId(input.siteId, { email: input.email });
865
- try {
866
- const row = (await prisma.subscriber.create({
867
- data: {
868
- siteId: input.siteId,
869
- // #6374 — the isolation key, equal to `siteId` by construction,
870
- // exactly as `lead.create` above stamps it. `input.siteId` is the
871
- // RESOLVED workspace id at every `routeLead` caller (see the
872
- // `submit` branch's own note); a caller that somehow passed a raw
873
- // alias would hit `subscribers_workspace_id_format` loudly rather
874
- // than landing a silently-unfenced row. Never from a request body.
875
- workspaceId: input.siteId,
876
- email: input.email,
877
- source: input.source,
878
- personId,
879
- },
880
- select: { id: true },
881
- })) as DbRow;
882
- return { ok: true, id: row.id, source: "db", skipEmail: false };
883
- } catch (err) {
884
- // Handle unique constraint violation (duplicate subscriber)
885
- if (
886
- err != null &&
887
- typeof err === "object" &&
888
- "code" in err &&
889
- (err as { code: string }).code === "P2002"
890
- ) {
891
- // #6257 — the duplicate lookup is scoped by the same resolved
892
- // workspace id the `create` above stamps (`input.siteId` is the
893
- // RESOLVED id at every caller — see the `submit` branch's note), so a
894
- // P2002 on another tenant's row can never resolve to its id.
895
- const existing = (await prisma.subscriber.findUnique({
896
- where: {
897
- siteId_email: { siteId: input.siteId, email: input.email },
898
- workspaceId: input.siteId,
899
- },
900
- select: { id: true },
901
- })) as DbRow | null;
902
- const existingId = existing?.id ?? "duplicate-no-id";
903
- return {
904
- ok: true,
905
- id: existingId,
906
- source: "db",
907
- skipEmail: true,
908
- skipReason: "duplicate",
909
- };
910
- }
911
- throw err; // re-throw non-P2002 errors to trigger fallback
912
- }
913
- }
914
-
915
- // kind === "callback"
916
- // #8435 D5 — a callback with a string `payload.email` resolves a Person; one
917
- // without makes no call and writes no `personId`.
918
- const callbackEmail = input.payload["email"];
919
- const telephone = normalizeTelephone(input.phone);
920
- const callbackPersonId =
921
- typeof callbackEmail === "string"
922
- ? await resolveCapturePersonId(input.siteId, {
923
- email: callbackEmail,
924
- name: input.name,
925
- contact: telephone === null ? {} : { telephone },
926
- })
927
- : undefined;
928
- const row = (await prisma.callback.create({
929
- data: {
930
- siteId: input.siteId,
931
- // #6374 — the isolation key, equal to `siteId` by construction; same
932
- // resolution guarantee and same loud-failure posture as the two
933
- // branches above. Never from a request body (AC-6).
934
- workspaceId: input.siteId,
935
- name: input.name,
936
- phone: input.phone,
937
- ...(input.preferredTime !== undefined && {
938
- preferredTime: input.preferredTime,
939
- }),
940
- payload: input.payload as Prisma.InputJsonValue,
941
- ...(callbackPersonId !== undefined && { personId: callbackPersonId }),
942
- },
943
- select: { id: true },
944
- })) as DbRow;
945
- return { ok: true, id: row.id, source: "db", skipEmail: false };
946
- }
947
-
948
- // ─── Constraint-violation detection (#6254 AC-11) ──────────────────────────────
949
-
950
- /**
951
- * SQLSTATE class 23 — "integrity constraint violation" — covers a NOT NULL
952
- * (23502), a CHECK (23514), a unique (23505) or a foreign-key (23503)
953
- * violation. Two of Prisma's own mapped codes correspond to a class-23
954
- * violation on a `.create()` call: P2002 (unique) and P2011 (null
955
- * constraint) — both `PrismaClientKnownRequestError`s Prisma classifies
956
- * from its OWN schema knowledge. The new `<table>_workspace_site_agree`
957
- * CHECK (database/migrations/20260922120000_tenancy_fence) has no such
958
- * schema-level representation — Prisma cannot classify a raw CHECK it does
959
- * not know about, so the raw Postgres SQLSTATE is read defensively from
960
- * wherever the error actually carries it: `error.meta.code` (some engine/
961
- * adapter combinations thread it through there) or the error message text
962
- * (every Postgres client library's message includes the SQLSTATE and/or the
963
- * words "constraint"/"null value" verbatim).
964
- */
965
- // P2000 (value too long) is deliberately excluded — a data-length validation
966
- // error, not a SQLSTATE class-23 integrity-constraint violation (security
967
- // review finding, PR #6360): it doesn't correspond to 23502/23503/23505/
968
- // 23514, so treating it as one would take this path for a case AC-11 does
969
- // not target.
970
- const PRISMA_CLASS_23_CODES = new Set(["P2002", "P2003", "P2004", "P2011"]);
971
-
972
- function isConstraintViolation(error: unknown): boolean {
973
- if (error == null || typeof error !== "object") return false;
974
-
975
- const code = (error as { code?: unknown }).code;
976
- if (typeof code === "string" && PRISMA_CLASS_23_CODES.has(code)) return true;
977
-
978
- const metaCode = (error as { meta?: { code?: unknown } }).meta?.code;
979
- if (typeof metaCode === "string" && /^23\d{3}$/.test(metaCode)) return true;
980
-
981
- const message = (error as { message?: unknown }).message;
982
- if (typeof message === "string") {
983
- // Labeled SQLSTATE only — "code: '23514'", "(SQLSTATE 23502)", etc. A
984
- // bare `\b23\d{3}\b` anywhere in the message (security review finding,
985
- // PR #6360) would also match a coincidental 5-digit number in a genuine
986
- // outage's own text (a port, a byte count, a timeout in ms), stealing
987
- // that case away from the file fallback it should still take.
988
- if (/\b(?:sqlstate|code)[:\s]*['"]?23\d{3}['"]?/i.test(message)) return true;
989
- if (/violates (check|not-null|unique|foreign key) constraint/i.test(message)) {
990
- return true;
991
- }
992
- if (/null value in column/i.test(message)) return true;
993
- }
994
-
995
- return false;
996
- }
997
-
998
- // ─── Email ────────────────────────────────────────────────────────────────────
999
-
1000
- type EmailResult =
1001
- | { ok: true; messageId: string }
1002
- | { ok: false; error: string }
1003
- | { skipped: true; reason: string };
1004
-
1005
- async function sendEmail(
1006
- input: RouteLeadInput,
1007
- id: string,
1008
- persisted: Persisted,
1009
- viaSpoke = false,
1010
- ): Promise<EmailResult> {
1011
- // Request-and-confirm SUPERSEDES this notification for a callback (#5970
1012
- // D6). AC-1 requires the tenant to receive exactly one email, and the
1013
- // confirm message carries strictly more than this one does — the same
1014
- // request details, plus the link that confirms a time and a price. Sending
1015
- // both would be two; sending this one and not that one would lose the
1016
- // feature. `submit` and `subscribe` are untouched.
1017
- //
1018
- // The predicate lives in request-confirm.ts and is read by both sides, so
1019
- // the two cannot both fire. It does NOT by itself rule out zero — see that
1020
- // function's header for what the coupling does and does not guarantee, and
1021
- // for the `persisted` term below, which is what closed the one zero case
1022
- // that was reachable in production. With the feature unconfigured — the
1023
- // default, and what every existing leads suite runs under — this is false
1024
- // and every byte below is reached as before.
1025
- // `persisted === "db"` is load-bearing, not decoration (#5970 security
1026
- // review round 3, M1). The confirm message can only supersede this one if it
1027
- // is actually going to be sent, and it can only be sent against a ROW: the
1028
- // credential is minted by an `updateMany` on `callbacks`. On the FILE
1029
- // FALLBACK path — a DB outage, an unset DATABASE_URL, an exhausted pool —
1030
- // there is no row, so suppressing here would have produced ZERO operator
1031
- // emails for every callback captured during the outage, on the one surface
1032
- // whose whole purpose is that an outage must not lose a lead.
1033
- //
1034
- // `!viaSpoke` (#7532 D4, SEC-7532-2): request-and-confirm mints a Payment
1035
- // Link on the deployment's ONE Stripe account and mails the deployment's ONE
1036
- // address, so a spoke's callback never takes it — the route does not send
1037
- // the confirm link for a spoke, and this keeps the generic notification.
1038
- if (
1039
- input.kind === "callback" &&
1040
- !viaSpoke &&
1041
- persisted === "db" &&
1042
- requestConfirmSupersedesNotification()
1043
- ) {
1044
- return { skipped: true, reason: "request_confirm" };
1045
- }
1046
-
1047
- const to = process.env.AILK_LEAD_NOTIFICATION_TO;
1048
- const from = process.env.AILK_LEAD_NOTIFICATION_FROM;
1049
-
1050
- if (!to || !from) {
1051
- return { skipped: true, reason: "env_unset" };
1052
- }
1053
-
1054
- const leadForEmail = {
1055
- id,
1056
- siteId: input.siteId,
1057
- source: input.kind === "callback" ? "callback" : input.source,
1058
- email: input.kind === "callback" ? "" : input.email,
1059
- // A callback's contact details are top-level fields, not payload keys
1060
- // (#6730): with no database the email is the only record of the
1061
- // request, so it must carry the name and number to call back.
1062
- payload:
1063
- input.kind === "subscribe"
1064
- ? {}
1065
- : input.kind === "callback"
1066
- ? {
1067
- ...input.payload,
1068
- name: input.name,
1069
- phone: input.phone,
1070
- ...(input.preferredTime !== undefined && {
1071
- preferredTime: input.preferredTime,
1072
- }),
1073
- }
1074
- : input.payload,
1075
- createdAt: new Date().toISOString(),
1076
- };
1077
-
1078
- const emailResult = await sendLeadNotification({
1079
- lead: leadForEmail,
1080
- recipientEmail: to,
1081
- fromAddress: from,
1082
- // A callback has no visitor address to reply to; an empty Reply-To is
1083
- // not a valid header, so reply to the owner instead.
1084
- ...(input.kind === "callback" && { replyToOverride: to }),
1085
- });
1086
-
1087
- if (emailResult.ok) {
1088
- return { ok: true, messageId: emailResult.messageId };
1089
- }
1090
-
1091
- const { kind, message } = emailResult.error;
1092
- if (kind === "config_missing") {
1093
- return { skipped: true, reason: "resend_key_unset" };
1094
- }
1095
- return { ok: false, error: `${kind}: ${message}` };
1096
- }
1097
-
1098
- // ─── Email delivery (#6730 D2) ────────────────────────────────────────────────
1099
-
1100
- type EmailDelivery =
1101
- | { ok: true; id: string; email: { ok: true; messageId: string } }
1102
- | { ok: false; message: string };
1103
-
1104
- /**
1105
- * Deliver a lead as the owner email alone — success only when the provider
1106
- * accepted it. The id matches the file-fallback id format
1107
- * (`/^[a-z0-9_-]{8,64}$/i`).
1108
- */
1109
- async function deliverByEmail(input: RouteLeadInput): Promise<EmailDelivery> {
1110
- const id = `email-${Date.now()}-${randomUUID().slice(0, 8)}`;
1111
- const result = await sendEmail(input, id, "email");
1112
- if ("ok" in result && result.ok) return { ok: true, id, email: result };
1113
- return {
1114
- ok: false,
1115
- message: "error" in result ? result.error : `email skipped: ${result.reason}`,
1116
- };
1117
- }
1118
-
1119
- // ─── Lead-capture record ─────────────────────────────────────────────────────
1120
-
1121
- function captureRecordFor(input: RouteLeadInput, id: string, sourceMeta: SourceMeta) {
1122
- return {
1123
- id,
1124
- createdAt: sourceMeta.receivedAt,
1125
- source: input.kind === "callback" ? "callback" : input.source,
1126
- data:
1127
- input.kind === "callback"
1128
- ? { name: input.name, phone: input.phone, ...(input.payload ?? {}) }
1129
- : input.kind === "subscribe"
1130
- ? { email: input.email }
1131
- : { email: input.email, ...(input.payload ?? {}) },
1132
- metadata: {
1133
- ip: sourceMeta.ip,
1134
- userAgent: sourceMeta.userAgent,
1135
- },
1136
- };
1137
- }
1138
-
1139
- // ─── Main export ──────────────────────────────────────────────────────────────
1140
-
1141
- export async function routeLead(
1142
- input: RouteLeadInput,
1143
- sourceMeta: SourceMeta,
1144
- options: RouteLeadOptions = {},
1145
- ): Promise<RouteLeadResult> {
1146
- // 1. Validate
1147
- const validation = validateInput(input);
1148
- if (!validation.ok) {
1149
- return {
1150
- ok: false,
1151
- error: { kind: "invalid_input", message: validation.message },
1152
- };
1153
- }
1154
-
1155
- // 2. Guard: where can this lead go (#6730 D1)? A kind the configured
1156
- // delivery cannot take — anything in production with neither a database
1157
- // nor the email vars, a newsletter signup with no database — is refused
1158
- // before any write, so no visitor is told a message was sent when it
1159
- // reached nobody.
1160
- const delivery = resolveLeadDelivery();
1161
- if (!acceptsKind(delivery, input.kind)) {
1162
- return {
1163
- ok: false,
1164
- error: {
1165
- kind: "config_missing",
1166
- message: "Lead capture is not configured.",
1167
- },
1168
- };
1169
- }
1170
-
1171
- // 2a. Idempotency key (#7869 D3/D7) — submit, a key, and a database to
1172
- // dedupe against. A stored key answers before any enrichment or write. A
1173
- // failed read is not an answer: the create below meets the same database
1174
- // and takes the existing outage path.
1175
- const idempotencyKey =
1176
- input.kind === "submit" && delivery === "inbox" ? options.idempotencyKey : undefined;
1177
- if (idempotencyKey !== undefined && input.kind === "submit") {
1178
- const stored = await findLeadByKey(input.siteId, idempotencyKey).catch(() => null);
1179
- if (stored) return keyedResult(classifyKeyedRow(stored, input));
1180
- }
1181
-
1182
- // 3. Enrich (reserved for Phase 3 — today returns lead unchanged).
1183
- // Cast via unknown: enrichLead accepts CreateLead (B3 shim type) but the
1184
- // pass-through contract means RouteLeadInput is safe here until Phase 3 wires
1185
- // the real enrichment call with proper type alignment.
1186
- await enrichLead(input as unknown as Parameters<typeof enrichLead>[0]);
1187
-
1188
- // Email delivery — no database: the owner email is the delivery, so skip
1189
- // the DB and the (non-durable) file fallback entirely.
1190
- if (delivery === "email") {
1191
- const emailed = await deliverByEmail(input);
1192
- if (!emailed.ok) {
1193
- return {
1194
- ok: false,
1195
- error: { kind: "delivery_failed", message: emailed.message },
1196
- };
1197
- }
1198
- await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
1199
- return {
1200
- ok: true,
1201
- id: emailed.id,
1202
- persisted: "email",
1203
- email: emailed.email,
1204
- };
1205
- }
1206
-
1207
- // 4. Persist primary (DB), fallback to file on error
1208
- let id: string;
1209
- let persisted: Persisted;
1210
- let skipEmail = false;
1211
- let skipReason: string | undefined;
1212
- let personId: string | null = null;
1213
-
1214
- try {
1215
- const dbResult = await persistToDb(
1216
- input,
1217
- idempotencyKey,
1218
- sourceMeta.viaSpoke === true,
1219
- sourceMeta.relayed === true,
1220
- input.kind === "submit" ? options.contact : undefined,
1221
- input.kind === "submit" ? options.accountOrigin : undefined,
1222
- input.kind === "submit" && options.offeringsCheckout === true,
1223
- );
1224
- // #7869 D5 — a lost race answers like the lookup above: no capture
1225
- // record, no notification.
1226
- if (dbResult.ok === "keyed") return keyedResult(dbResult.outcome);
1227
- if (!dbResult.ok) {
1228
- // persistToDb returned ok:false — defensive guard; current impl doesn't reach this
1229
- return {
1230
- ok: false,
1231
- error: { kind: "persist_failed", message: dbResult.error },
1232
- };
1233
- }
1234
- id = dbResult.id;
1235
- persisted = "db";
1236
- skipEmail = dbResult.skipEmail;
1237
- skipReason = dbResult.skipReason;
1238
- personId = dbResult.personId ?? null;
1239
- // #8438 D11 — only after THIS request's own write (a keyed replay
1240
- // returned above), and only when a Person was resolved.
1241
- if (sourceMeta.consentVisitorId !== undefined && personId !== null) {
1242
- await linkConsentVisitor({
1243
- workspaceId: input.siteId,
1244
- visitorId: sourceMeta.consentVisitorId,
1245
- personId,
1246
- source:
1247
- input.kind === "submit" && input.source.startsWith("flow:")
1248
- ? "magnet"
1249
- : "api",
1250
- });
1251
- }
1252
- } catch (dbErr) {
1253
- // #6254 AC-11 (spec D6, SEC-02) — a constraint violation is OUR OWN
1254
- // derivation producing a row Postgres itself refuses (an unresolved
1255
- // workspaceId hitting NOT NULL, or a site<->workspace disagreement
1256
- // hitting the new equality CHECK) — a bug to surface, never an outage
1257
- // to degrade through. Falling back here would return a silent 201 while
1258
- // the Lead never lands in Postgres, invisible to the Inbox forever —
1259
- // the exact silent data loss AC-7 exists to close, merely relocated
1260
- // onto this never-gate surface.
1261
- //
1262
- // A DELIBERATE error response, not a bare re-raise (spec § 7's
1263
- // api-versioning waiver is conditioned on exactly this: "a documented
1264
- // error body, never a bare re-raise surfacing as an unhandled throw").
1265
- // `persist_failed` already maps to a 503 with a documented body
1266
- // (`routes/leads/index.ts`) — the SAME shape a genuine DB-outage
1267
- // failure (both DB and file fail) already returns, so this adds no new
1268
- // response shape, only a new path that reaches the existing one
1269
- // WITHOUT first trying — and losing time to — a file write for an
1270
- // error the file fallback can never fix.
1271
- //
1272
- // A GENUINE infrastructure failure (an unreachable DB, an exhausted
1273
- // pool, an unset DATABASE_URL) is NOT a constraint violation and still
1274
- // takes the file fallback below, unchanged.
1275
- if (isConstraintViolation(dbErr)) {
1276
- const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
1277
- return {
1278
- ok: false,
1279
- error: { kind: "persist_failed", message: `db: ${dbMsg}` },
1280
- };
1281
- }
1282
-
1283
- // In production the file fallback is not durable (a serverless instance
1284
- // does not outlive the request), so reporting it as success would lose
1285
- // the lead silently. Deliver by email when that is configured; otherwise
1286
- // fail loudly (#6730 D2). A newsletter signup is never emailed — one
1287
- // owner email per address is not a list (`acceptsKind`).
1288
- if (process.env.NODE_ENV === "production") {
1289
- const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
1290
- if (input.kind === "subscribe") {
1291
- return {
1292
- ok: false,
1293
- error: { kind: "persist_failed", message: `db: ${dbMsg}` },
1294
- };
1295
- }
1296
- const emailed = await deliverByEmail(input);
1297
- if (!emailed.ok) {
1298
- return {
1299
- ok: false,
1300
- error: {
1301
- kind: "persist_failed",
1302
- // Not the provider's text: it can echo an address, and this
1303
- // message is logged (spec D5).
1304
- message: `db: ${dbMsg}; email delivery failed`,
1305
- },
1306
- };
1307
- }
1308
- await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
1309
- return {
1310
- ok: true,
1311
- id: emailed.id,
1312
- persisted: "email",
1313
- email: emailed.email,
1314
- };
1315
- }
1316
-
1317
- // 5. File fallback (outside production only)
1318
- // Include a UUID suffix so concurrent fallbacks produce unique IDs even
1319
- // within the same millisecond. Format still matches /^[a-z0-9_-]{8,64}$/i.
1320
- const fallbackLead = {
1321
- id: `fallback-${Date.now()}-${randomUUID().slice(0, 8)}`,
1322
- siteId: input.siteId,
1323
- source: input.kind === "callback" ? "callback" : input.source,
1324
- email: input.kind === "callback" ? "" : input.email,
1325
- payload: input.kind === "subscribe" ? {} : input.payload,
1326
- createdAt: sourceMeta.receivedAt,
1327
- };
1328
-
1329
- let fallbackResult;
1330
- try {
1331
- fallbackResult = await writeLeadFallback(fallbackLead);
1332
- } catch {
1333
- const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
1334
- return {
1335
- ok: false,
1336
- error: {
1337
- kind: "persist_failed",
1338
- message: `db: ${dbMsg}; fallback threw`,
1339
- },
1340
- };
1341
- }
1342
-
1343
- if (!fallbackResult.ok) {
1344
- const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
1345
- return {
1346
- ok: false,
1347
- error: {
1348
- kind: "persist_failed",
1349
- message: `db: ${dbMsg}; fallback: ${fallbackResult.error.message}`,
1350
- },
1351
- };
1352
- }
1353
-
1354
- id = fallbackLead.id;
1355
- persisted = "file";
1356
- }
1357
-
1358
- // 6. Write to lead-capture JSONL (always-on canonical AX surface).
1359
- // Best-effort — a write failure does NOT roll back persistence or block email.
1360
- // The file is the agent-readable surface; DB is the transactional record.
1361
- await appendLeadRecord(captureRecordFor(input, id!, sourceMeta));
1362
-
1363
- // 7. Email notification (best-effort — never rolls back persistence)
1364
- let emailResult: EmailResult;
1365
- if (skipEmail) {
1366
- emailResult = { skipped: true, reason: skipReason ?? "skipped" };
1367
- } else {
1368
- emailResult = await sendEmail(input, id!, persisted!, sourceMeta.viaSpoke);
1369
- }
1370
-
1371
- return { ok: true, id: id!, personId, persisted, email: emailResult };
1372
- }