create-ailk 0.24.0 → 0.26.0

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