create-ailk 0.24.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 (599) hide show
  1. package/README.md +42 -20
  2. package/component-catalog.json +3 -3
  3. package/component-catalog.md +3 -3
  4. package/dist/add-module.js +4 -2
  5. package/dist/add-route.d.ts +12 -1
  6. package/dist/add-route.js +31 -3
  7. package/dist/cli.js +12 -7
  8. package/dist/derive-architecture.d.ts +3 -4
  9. package/dist/derive-architecture.js +65 -29
  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/module-architecture.d.ts +20 -3
  20. package/dist/module-architecture.js +117 -550
  21. package/dist/paid-sections.d.ts +25 -1
  22. package/dist/paid-sections.js +88 -3
  23. package/dist/surfaces.js +10 -2
  24. package/dist/template-record.d.ts +14 -4
  25. package/dist/template-record.js +23 -5
  26. package/dist/topology.d.ts +16 -3
  27. package/dist/topology.js +31 -19
  28. package/dist/update.d.ts +10 -1
  29. package/dist/update.js +540 -16
  30. package/package.json +3 -3
  31. package/release-scope.json +1 -1
  32. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  33. package/templates/.claude/hooks/template-guard.sh +137 -0
  34. package/templates/.claude/rules/extending.md +106 -0
  35. package/templates/.claude/rules/loop-contract.md +2 -2
  36. package/templates/.claude/settings.json +11 -0
  37. package/templates/.env.example +5 -5
  38. package/templates/.github/workflows/ci.yml +7 -4
  39. package/templates/CLAUDE.md +11 -1
  40. package/templates/CONVENTIONS.md +8 -10
  41. package/templates/README.md +14 -13
  42. package/templates/apps/api/.env.example +2 -2
  43. package/templates/apps/api/CLAUDE.md +102 -76
  44. package/templates/apps/api/jest.config.cjs +15 -5
  45. package/templates/apps/api/package.json +16 -20
  46. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  47. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  48. package/templates/apps/api/src/index.ts +1 -1
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
  50. package/templates/apps/api/src/modules/commerce.ts +10 -0
  51. package/templates/apps/api/src/modules/content-write.ts +10 -0
  52. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  53. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  54. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  55. package/templates/apps/api/src/modules/workspace.ts +10 -0
  56. package/templates/apps/api/src/server.ts +117 -1882
  57. package/templates/apps/api/vercel.json +2 -2
  58. package/templates/apps/mcp/CLAUDE.md +67 -20
  59. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  60. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  61. package/templates/apps/mcp/jest.config.cjs +6 -1
  62. package/templates/apps/mcp/package.json +5 -13
  63. package/templates/apps/mcp/src/app.ts +8 -7
  64. package/templates/apps/mcp/src/index.ts +6 -34
  65. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  66. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  67. package/templates/apps/mcp/src/site.ts +12 -0
  68. package/templates/apps/web/.env.example +1 -1
  69. package/templates/apps/web/CLAUDE.md +10 -3
  70. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  71. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  72. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  73. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
  74. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  75. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  76. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  77. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  78. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  79. package/templates/apps/web/auth.ts +2 -2
  80. package/templates/apps/web/jest.config.cjs +1 -0
  81. package/templates/apps/web/lib/auth-email.ts +1 -1
  82. package/templates/apps/web/lib/relay.ts +2 -2
  83. package/templates/apps/web/lib/release-scope.ts +47 -0
  84. package/templates/apps/web/lib/server-api.ts +1 -1
  85. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  86. package/templates/customizations.yaml +37 -0
  87. package/templates/database/prisma.config.ts +10 -14
  88. package/templates/database/seed-test.ts +2 -2
  89. package/templates/database/seed.ts +5 -5
  90. package/templates/package.json +12 -6
  91. package/templates/pnpm-workspace.yaml +0 -1
  92. package/templates/project.yaml +16 -8
  93. package/templates/tools/jest/reporters.cjs +65 -0
  94. package/templates/tools/lint-architecture.ts +553 -7
  95. package/templates/tsconfig.json +1 -2
  96. package/versions.json +48 -35
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  98. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  99. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  100. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  101. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  102. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  103. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  104. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  105. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  106. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  107. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  108. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  109. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  110. package/templates/apps/api/src/bin/spokes.ts +0 -198
  111. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  112. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  113. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  114. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  115. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  116. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  117. package/templates/apps/api/src/config/index.ts +0 -596
  118. package/templates/apps/api/src/config/modules.ts +0 -83
  119. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  120. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  121. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  122. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  123. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  124. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  125. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  126. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  127. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  128. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  129. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  130. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  131. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  132. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  133. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  134. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  135. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  136. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  137. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  138. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
  139. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  140. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  141. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  142. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  143. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  144. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  145. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  146. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  147. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  148. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  149. package/templates/apps/api/src/lib/errors.ts +0 -95
  150. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  151. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  152. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  153. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  154. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  155. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  156. package/templates/apps/api/src/lib/locales.ts +0 -201
  157. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  158. package/templates/apps/api/src/lib/prisma.ts +0 -21
  159. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  160. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  161. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  162. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  163. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  164. package/templates/apps/api/src/lib/site.ts +0 -257
  165. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  166. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  167. package/templates/apps/api/src/lib/stripe.ts +0 -321
  168. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  169. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  170. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  171. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  172. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  173. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  174. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  175. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  176. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  177. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  178. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  179. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  180. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  181. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  182. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  183. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  184. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  185. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  186. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  187. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  188. package/templates/apps/api/src/middleware/auth.ts +0 -864
  189. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  190. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  191. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
  192. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  193. package/templates/apps/api/src/openapi/index.ts +0 -18
  194. package/templates/apps/api/src/openapi/spec.ts +0 -2148
  195. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  196. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  197. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  198. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  199. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  200. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  201. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  202. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  203. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  204. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  205. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  206. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  207. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  208. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
  209. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  210. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
  211. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  212. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  213. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  214. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  215. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  216. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  217. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  218. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  219. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  220. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  221. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  222. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  223. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  224. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  225. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  226. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  227. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  228. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  229. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  230. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  231. package/templates/apps/api/src/routes/content/get-page.ts +0 -183
  232. package/templates/apps/api/src/routes/content/index.ts +0 -49
  233. package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
  234. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  235. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  236. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  237. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  238. package/templates/apps/api/src/routes/content/search.ts +0 -100
  239. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  240. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  241. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  242. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  243. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  244. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  245. package/templates/apps/api/src/routes/leads/index.ts +0 -639
  246. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  247. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  248. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  249. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  250. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
  251. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
  252. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  253. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  254. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  255. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  256. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  257. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  258. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  259. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  260. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  261. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  262. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  263. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  264. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  265. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  266. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  267. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  268. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  269. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  270. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  271. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  272. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  273. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  274. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
  275. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  276. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  277. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  278. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  279. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  280. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  281. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  282. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  283. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
  284. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  285. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  286. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  287. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
  288. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
  289. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  290. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  291. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  292. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  293. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  294. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  295. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  296. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
  297. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  298. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  299. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  300. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  301. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  302. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  303. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  304. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  305. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  306. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
  307. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
  308. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
  309. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
  310. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  311. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  312. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  313. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  314. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  315. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  316. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  317. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  318. package/templates/apps/api/src/services/abandon-email.ts +0 -336
  319. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  320. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  321. package/templates/apps/api/src/services/audit-log.ts +0 -961
  322. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  323. package/templates/apps/api/src/services/consent.ts +0 -834
  324. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  325. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
  326. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  327. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  328. package/templates/apps/api/src/services/entitlements.ts +0 -338
  329. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  330. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  331. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  332. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  333. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  334. package/templates/apps/api/src/services/listing-config.ts +0 -178
  335. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  336. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
  337. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  338. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
  339. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  340. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
  341. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
  342. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  343. package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
  344. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
  345. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  346. package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
  347. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  348. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  349. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  350. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  351. package/templates/apps/api/src/services/person.ts +0 -1211
  352. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  353. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  354. package/templates/apps/api/src/services/project-listings.ts +0 -2085
  355. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  356. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  357. package/templates/apps/api/src/services/revalidation.ts +0 -235
  358. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  359. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  360. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  361. package/templates/apps/api/src/services/verified-email.ts +0 -71
  362. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  363. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  364. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  365. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  366. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  367. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  368. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  369. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  370. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  371. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  372. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  373. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  374. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  375. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  376. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  377. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  378. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
  379. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  380. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  381. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  382. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
  383. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
  384. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  385. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  386. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
  387. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  388. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
  389. package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
  390. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  391. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  392. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
  393. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
  394. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
  395. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
  396. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  397. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  398. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
  399. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
  400. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  401. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  402. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  403. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  404. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  405. package/templates/apps/mcp/src/abuse/index.ts +0 -349
  406. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  407. package/templates/apps/mcp/src/boot.ts +0 -167
  408. package/templates/apps/mcp/src/config/modules.ts +0 -43
  409. package/templates/apps/mcp/src/http-transport.ts +0 -564
  410. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  411. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  412. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
  413. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
  414. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  415. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  416. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  417. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  418. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  419. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  420. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  421. package/templates/apps/mcp/src/lib/site-config.ts +0 -53
  422. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  423. package/templates/apps/mcp/src/request-context.ts +0 -52
  424. package/templates/apps/mcp/src/server.ts +0 -343
  425. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  426. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  427. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
  428. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  429. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  430. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  431. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  432. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  433. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  434. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  435. package/templates/apps/mcp/src/tools/index.ts +0 -138
  436. package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
  437. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  438. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  439. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  440. package/templates/apps/mcp/src/tools/read_content.ts +0 -81
  441. package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
  442. package/templates/apps/mcp/src/tools/search_content.ts +0 -63
  443. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
  444. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  445. package/templates/database/CHANGELOG.md +0 -981
  446. package/templates/database/LICENSE +0 -202
  447. package/templates/database/NOTICE +0 -2
  448. package/templates/database/README.md +0 -243
  449. package/templates/database/__tests__/agents.db.test.ts +0 -320
  450. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  451. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  452. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  453. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  454. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  455. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  456. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  457. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  458. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  459. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  460. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  461. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  462. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  463. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  464. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  465. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  466. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  467. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  468. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  469. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  470. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  471. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  472. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  473. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  474. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  475. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  476. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  477. package/templates/database/__tests__/person.db.test.ts +0 -586
  478. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  479. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  480. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  481. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  482. package/templates/database/__tests__/seed.test.ts +0 -272
  483. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  484. package/templates/database/__tests__/tenancy.test.ts +0 -141
  485. package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
  486. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  487. package/templates/database/_gitignore +0 -8
  488. package/templates/database/auth/.gitkeep +0 -0
  489. package/templates/database/auth/schema.prisma +0 -641
  490. package/templates/database/config/.gitkeep +0 -0
  491. package/templates/database/config/schema.prisma +0 -441
  492. package/templates/database/content/.gitkeep +0 -0
  493. package/templates/database/content/schema.prisma +0 -339
  494. package/templates/database/eslint.config.mjs +0 -69
  495. package/templates/database/flows/listing-diagnostic.ts +0 -182
  496. package/templates/database/flows/presets.ts +0 -315
  497. package/templates/database/inbox/.gitkeep +0 -0
  498. package/templates/database/inbox/README.md +0 -3
  499. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  500. package/templates/database/inbox/schema.prisma +0 -1447
  501. package/templates/database/jest.config.cjs +0 -36
  502. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  503. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  504. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  505. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  506. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  507. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  508. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  509. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  510. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  511. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  512. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  513. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  514. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  515. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  516. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  517. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  518. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  519. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  520. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  521. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  522. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  523. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  524. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  525. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  526. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  527. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  528. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  529. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  530. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  531. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  532. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  533. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  534. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  535. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  536. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  537. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  538. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  539. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  540. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  541. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  542. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  543. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  544. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  545. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  546. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  547. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  548. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  549. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  550. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  551. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  552. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  553. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  554. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  555. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  556. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  557. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  558. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  559. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  560. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  561. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  562. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  563. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  564. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  565. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  566. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  567. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  568. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  569. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  570. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  571. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  572. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  573. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  574. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  575. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  576. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  577. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  578. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  579. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  580. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  581. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  582. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  583. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  584. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  585. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  586. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
  587. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
  588. package/templates/database/migrations/migration_lock.toml +0 -3
  589. package/templates/database/ops/.gitkeep +0 -0
  590. package/templates/database/ops/schema.prisma +0 -43
  591. package/templates/database/package.json +0 -89
  592. package/templates/database/schema.prisma +0 -71
  593. package/templates/database/scripts/db-generate-locked.sh +0 -80
  594. package/templates/database/scripts/person-backfill.sql +0 -132
  595. package/templates/database/tenancy.ts +0 -183
  596. package/templates/database/tsconfig.build.json +0 -16
  597. package/templates/database/tsconfig.flows.json +0 -15
  598. package/templates/database/tsconfig.json +0 -16
  599. 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
- }