create-ailk 0.4.0 → 0.6.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 (396) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +12248 -0
  3. package/component-catalog.md +193 -68
  4. package/dist/cli.js +29 -1
  5. package/dist/derive-architecture.js +5 -2
  6. package/dist/lib/api-boot-assertion.d.ts +46 -0
  7. package/dist/lib/api-boot-assertion.js +70 -0
  8. package/dist/lib/extract-module-bundle.d.ts +50 -2
  9. package/dist/lib/extract-module-bundle.js +223 -15
  10. package/dist/lib/fetch-guard.d.ts +28 -0
  11. package/dist/lib/fetch-guard.js +101 -0
  12. package/dist/lib/fetch-module.js +4 -0
  13. package/dist/lib/fetch-template.js +4 -0
  14. package/dist/lib/import-graph.d.ts +29 -0
  15. package/dist/lib/import-graph.js +140 -0
  16. package/dist/module-architecture.d.ts +60 -8
  17. package/dist/module-architecture.js +587 -242
  18. package/dist/parse-args.js +6 -2
  19. package/dist/programmatic.d.ts +10 -0
  20. package/dist/programmatic.js +134 -19
  21. package/dist/site-chrome.d.ts +80 -0
  22. package/dist/site-chrome.js +135 -0
  23. package/dist/surfaces.d.ts +21 -6
  24. package/dist/surfaces.js +209 -36
  25. package/dist/template-manifest.d.ts +88 -3
  26. package/dist/template-manifest.js +281 -29
  27. package/dist/template-names.d.ts +1 -1
  28. package/dist/template-names.js +2 -0
  29. package/package.json +10 -2
  30. package/site-templates/_base/ailk-template.json +6 -0
  31. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
  34. package/site-templates/marketing-site/ailk-template.json +11 -0
  35. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  36. package/site-templates/marketing-site/provenance.json +4 -0
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
  39. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
  41. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
  42. package/site-templates/minimalism-site/ailk-template.json +22 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  44. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  49. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  50. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  51. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  53. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  54. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  55. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  56. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  57. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
  58. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  60. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  65. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  68. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  69. package/site-templates/minimalism-site/provenance.json +4 -0
  70. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  71. package/site-templates/saas-product/ailk-template.json +16 -0
  72. package/site-templates/saas-product/provenance.json +10 -0
  73. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  74. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  75. package/site-templates/simple-service/ailk-template.json +10 -0
  76. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  77. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  78. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  79. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  80. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  81. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  82. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  83. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  84. package/site-templates/simple-service/provenance.json +4 -0
  85. package/templates/.claude/skills/README.md +16 -25
  86. package/templates/.env.example +102 -12
  87. package/templates/.github/workflows/ci.yml +48 -0
  88. package/templates/README.md +3 -1
  89. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  90. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  91. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  92. package/templates/apps/api/.env.example +13 -0
  93. package/templates/apps/api/CLAUDE.md +29 -2
  94. package/templates/apps/api/eslint.config.mjs +69 -0
  95. package/templates/apps/api/jest.config.cjs +3 -0
  96. package/templates/apps/api/package.json +7 -2
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  98. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
  99. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  100. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  101. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  102. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
  103. package/templates/apps/api/src/config/index.ts +62 -0
  104. package/templates/apps/api/src/config/modules.ts +7 -3
  105. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
  106. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  107. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  108. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  109. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  110. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  111. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  112. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  113. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  114. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  115. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  116. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  117. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  118. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  119. package/templates/apps/api/src/lib/site.ts +208 -9
  120. package/templates/apps/api/src/lib/stripe.ts +66 -0
  121. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
  122. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
  123. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  124. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  125. package/templates/apps/api/src/openapi/spec.ts +543 -2
  126. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  127. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  128. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  129. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  130. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  131. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  132. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  133. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  134. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  135. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  136. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  137. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  138. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  139. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  140. package/templates/apps/api/src/routes/content/index.ts +13 -73
  141. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  142. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  143. package/templates/apps/api/src/routes/content/search.ts +129 -0
  144. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  145. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  146. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  147. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  148. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  149. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  150. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  151. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  152. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  153. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  154. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  155. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  156. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  157. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  158. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  159. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  160. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  161. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  162. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  163. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  164. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  165. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  166. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  167. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  168. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  169. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  170. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  171. package/templates/apps/api/src/server.ts +521 -55
  172. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  173. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  174. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  175. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  176. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  177. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  178. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  179. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  180. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  181. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  182. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  183. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  184. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  185. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  186. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  187. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  188. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  189. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  190. package/templates/apps/api/src/services/audit-log.ts +668 -0
  191. package/templates/apps/api/src/services/consent.ts +18 -0
  192. package/templates/apps/api/src/services/default-tenant.ts +300 -0
  193. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  194. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  195. package/templates/apps/api/src/services/entitlements.ts +80 -0
  196. package/templates/apps/api/src/services/lead-promotion.ts +160 -0
  197. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  198. package/templates/apps/api/src/services/leads-inbox.ts +284 -0
  199. package/templates/apps/api/src/services/listing-config.ts +27 -9
  200. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  201. package/templates/apps/api/src/services/project-listings.ts +201 -6
  202. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  203. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  204. package/templates/apps/api/src/services/revalidation.ts +173 -0
  205. package/templates/apps/api/src/services/verified-email.ts +71 -0
  206. package/templates/apps/api/src/vercel-handler.ts +75 -35
  207. package/templates/apps/mcp/.env.example +17 -0
  208. package/templates/apps/mcp/CLAUDE.md +58 -25
  209. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  210. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  211. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  212. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  213. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  214. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  215. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  216. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  217. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  218. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  219. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  220. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  221. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  222. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  223. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  224. package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
  225. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  226. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  227. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  228. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  229. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  230. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  231. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  232. package/templates/apps/mcp/eslint.config.mjs +69 -0
  233. package/templates/apps/mcp/jest.config.cjs +13 -0
  234. package/templates/apps/mcp/package.json +8 -2
  235. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  236. package/templates/apps/mcp/src/config/modules.ts +10 -5
  237. package/templates/apps/mcp/src/http-transport.ts +102 -29
  238. package/templates/apps/mcp/src/index.ts +73 -11
  239. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  240. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  241. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  242. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  243. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  244. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  245. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  246. package/templates/apps/mcp/src/server.ts +61 -37
  247. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  248. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  249. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  250. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  251. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  252. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  253. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  254. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  255. package/templates/apps/mcp/src/tools/index.ts +9 -7
  256. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  257. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  258. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  259. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  260. package/templates/apps/web/.env.example +74 -8
  261. package/templates/apps/web/CLAUDE.md +46 -0
  262. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  263. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  264. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  265. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  266. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  267. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  268. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  269. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  270. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  271. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  272. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  273. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  274. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  275. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  276. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  277. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  278. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  279. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  280. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  281. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
  282. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
  283. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  284. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  285. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  286. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  287. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  288. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  289. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  290. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  291. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  292. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  293. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  294. package/templates/apps/web/app/[locale]/layout.tsx +308 -65
  295. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  296. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  297. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  298. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  299. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  300. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  301. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  302. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  303. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  304. package/templates/apps/web/auth.ts +407 -8
  305. package/templates/apps/web/components/google-analytics.tsx +18 -1
  306. package/templates/apps/web/eslint.config.mjs +69 -0
  307. package/templates/apps/web/jest.config.cjs +15 -19
  308. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  309. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  310. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  311. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
  312. package/templates/apps/web/lib/auth-email.ts +318 -0
  313. package/templates/apps/web/lib/auth-env.ts +85 -0
  314. package/templates/apps/web/lib/auth-routes.ts +55 -0
  315. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  316. package/templates/apps/web/lib/nav-config.ts +27 -7
  317. package/templates/apps/web/lib/server-api.ts +1 -1
  318. package/templates/apps/web/lib/site-chrome.ts +29 -0
  319. package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
  320. package/templates/apps/web/messages/en.json +216 -2
  321. package/templates/apps/web/next.config.mjs +20 -0
  322. package/templates/apps/web/package.json +8 -3
  323. package/templates/apps/web/sentry.client.config.ts +131 -0
  324. package/templates/apps/web/sentry.edge.config.ts +72 -0
  325. package/templates/apps/web/sentry.server.config.ts +72 -0
  326. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  327. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  328. package/templates/apps/web/src/lib/routes.ts +13 -18
  329. package/templates/brand_assets/README.md +24 -0
  330. package/templates/content/en/AboutPage/about.mdx +160 -15
  331. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  332. package/templates/content/en/ContactPage/contact.mdx +90 -13
  333. package/templates/content/en/FAQPage/faq.mdx +62 -22
  334. package/templates/content/en/HomePage/home.mdx +244 -20
  335. package/templates/content/en/LandingPage/blog.mdx +83 -0
  336. package/templates/content/en/LandingPage/features.mdx +103 -0
  337. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  338. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  339. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  340. package/templates/database/CHANGELOG.md +149 -0
  341. package/templates/database/README.md +10 -3
  342. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  343. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  344. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  345. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  346. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  347. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  348. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  349. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  350. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  351. package/templates/database/__tests__/seed.test.ts +1 -1
  352. package/templates/database/__tests__/tenancy.test.ts +141 -0
  353. package/templates/database/config/schema.prisma +79 -2
  354. package/templates/database/content/schema.prisma +87 -11
  355. package/templates/database/eslint.config.mjs +69 -0
  356. package/templates/database/inbox/schema.prisma +452 -9
  357. package/templates/database/jest.config.cjs +5 -3
  358. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  359. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  360. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  361. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  362. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  363. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  364. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  365. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  366. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  367. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  368. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  369. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  370. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  371. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  372. package/templates/database/package.json +7 -3
  373. package/templates/database/seed.ts +1 -1
  374. package/templates/database/tenancy.ts +86 -1
  375. package/templates/database/tsconfig.json +1 -0
  376. package/templates/package.json +1 -14
  377. package/versions.json +52 -0
  378. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  379. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  380. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  381. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  382. package/templates/apps/api/src/routes/content/create.ts +0 -115
  383. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  384. package/templates/apps/api/src/routes/content/update.ts +0 -112
  385. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  386. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  387. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  388. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  389. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  390. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  391. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  392. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  393. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  394. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  395. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
  396. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * @file lead-promotion.test.ts
3
+ * @description Unit tests for the lead-promotion service (ADR 0009 D3
4
+ * service-layer pattern; spec docs/specs/3586-lead-user-promotion-spec.md §5).
5
+ *
6
+ * External dependency boundary mocked:
7
+ * - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts`
8
+ * manual mock (`jest.mock("../../lib/prisma.js")`).
9
+ *
10
+ * `prisma.lead.findMany` is given a REALISTIC filtering implementation (over
11
+ * an in-memory fixture "table") rather than a static `mockResolvedValue`, so
12
+ * the tenant-safety test (AC-4) actually exercises what the service passes to
13
+ * the DB — a bug that widened the `siteId` match set would surface the
14
+ * cross-tenant lead here, not just in an argument-shape assertion.
15
+ *
16
+ * tenant-context.ts's `listTenancy` runs REAL — it is a pure read over the
17
+ * mocked `prisma.membership.findMany`, so exercising it here also covers the
18
+ * D3/D4 "resolve via the sole tenant-context assembler" contract rather than
19
+ * re-stubbing it.
20
+ */
21
+
22
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
23
+ jest.mock("../../lib/prisma.js");
24
+
25
+ import { prisma } from "../../lib/prisma.js";
26
+ import { promoteLeadsForVerifiedUser } from "../lead-promotion";
27
+ import type { PromoteLeadsResult } from "../lead-promotion";
28
+
29
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
30
+
31
+ const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
32
+ const OWN_ORG_ID = "org_00000000-0000-7000-8000-0000000000b1";
33
+ const OWN_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c1";
34
+ // OTHER_WORKSPACE_ID belongs to a different organization than OWN_ORG_ID —
35
+ // the tenant-safety fixture AC-4 relies on (never a workspace of OWN_ORG_ID).
36
+ const OTHER_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c2";
37
+ const USER_EMAIL = "verified-user@example.com";
38
+
39
+ type FakeLeadRow = {
40
+ id: string;
41
+ email: string;
42
+ siteId: string;
43
+ source: string;
44
+ promotedUserId: string | null;
45
+ };
46
+
47
+ /** Shapes prisma.membership.findMany's mocked return — what listTenancy reads. */
48
+ function ownMembership() {
49
+ return [
50
+ {
51
+ role: "owner",
52
+ organization: {
53
+ id: OWN_ORG_ID,
54
+ name: "Acme",
55
+ slug: "acme",
56
+ workspaces: [
57
+ { id: OWN_WORKSPACE_ID, name: "Acme HQ", slug: "acme-hq" },
58
+ ],
59
+ },
60
+ },
61
+ ];
62
+ }
63
+
64
+ function verifiedUser(overrides?: Partial<{ emailVerified: boolean }>) {
65
+ return {
66
+ id: USER_ID,
67
+ email: USER_EMAIL,
68
+ emailVerified: overrides?.emailVerified ?? true,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Wires prisma.lead.findMany / updateMany to a realistic in-memory "table"
74
+ * filtered by the (where) args the service passes — the DB-fidelity fixture
75
+ * AC-4 relies on: a lead outside `siteId.in` is genuinely never returned,
76
+ * exactly as a real WHERE clause would exclude it.
77
+ */
78
+ function seedLeadTable(rows: FakeLeadRow[]) {
79
+ const table = rows.map((r) => ({ ...r }));
80
+
81
+ (prisma.lead.findMany as jest.Mock).mockImplementation(
82
+ async (args: {
83
+ where: {
84
+ email: string;
85
+ siteId: { in: string[] };
86
+ promotedUserId: null;
87
+ };
88
+ select: { id: true; siteId: true; source: true };
89
+ }) => {
90
+ const { email, siteId } = args.where;
91
+ return table
92
+ .filter(
93
+ (row) =>
94
+ row.email === email &&
95
+ siteId.in.includes(row.siteId) &&
96
+ row.promotedUserId === null,
97
+ )
98
+ .map((row) => ({ id: row.id, siteId: row.siteId, source: row.source }));
99
+ },
100
+ );
101
+
102
+ (prisma.lead.updateMany as jest.Mock).mockImplementation(
103
+ async (args: { where: { id: { in: string[] } }; data: { promotedUserId: string; promotedAt: Date } }) => {
104
+ let count = 0;
105
+ for (const row of table) {
106
+ if (args.where.id.in.includes(row.id)) {
107
+ row.promotedUserId = args.data.promotedUserId;
108
+ count += 1;
109
+ }
110
+ }
111
+ return { count };
112
+ },
113
+ );
114
+
115
+ return table;
116
+ }
117
+
118
+ describe("promoteLeadsForVerifiedUser", () => {
119
+ // This service's workspace resolution is itself module-conditional
120
+ // (isModuleEnabled('multi-tenant') — see lead-promotion.ts); every test
121
+ // below exercises that path, so it needs the module explicitly enabled
122
+ // now that AILK_ENABLED_MODULES unset means no paid modules (D-44, #5368).
123
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
124
+
125
+ beforeEach(() => {
126
+ jest.clearAllMocks();
127
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
128
+ });
129
+
130
+ afterAll(() => {
131
+ if (priorEnabledModules === undefined) {
132
+ delete process.env.AILK_ENABLED_MODULES;
133
+ } else {
134
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
135
+ }
136
+ });
137
+
138
+ // ── AC-3: verified user + matching lead in the user's own workspace ──────
139
+ it("AC-3: matches an un-promoted lead in the user's own workspace and marks it promoted", async () => {
140
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
141
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
142
+ ownMembership(),
143
+ );
144
+ seedLeadTable([
145
+ {
146
+ id: "lead_00000000-0000-7000-8000-0000000000d1",
147
+ email: USER_EMAIL,
148
+ siteId: OWN_WORKSPACE_ID,
149
+ source: "landing-page-1",
150
+ promotedUserId: null,
151
+ },
152
+ ]);
153
+
154
+ const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
155
+
156
+ expect(result).toEqual<PromoteLeadsResult>({
157
+ ok: true,
158
+ count: 1,
159
+ promoted: [
160
+ {
161
+ leadId: "lead_00000000-0000-7000-8000-0000000000d1",
162
+ siteId: OWN_WORKSPACE_ID,
163
+ source: "landing-page-1",
164
+ },
165
+ ],
166
+ });
167
+ expect(prisma.lead.findMany).toHaveBeenCalledWith({
168
+ where: {
169
+ email: USER_EMAIL,
170
+ siteId: { in: [OWN_WORKSPACE_ID] },
171
+ promotedUserId: null,
172
+ },
173
+ select: { id: true, siteId: true, source: true },
174
+ });
175
+ expect(prisma.lead.updateMany).toHaveBeenCalledWith({
176
+ where: { id: { in: ["lead_00000000-0000-7000-8000-0000000000d1"] } },
177
+ data: {
178
+ promotedUserId: USER_ID,
179
+ promotedAt: expect.any(Date),
180
+ },
181
+ });
182
+ });
183
+
184
+ // ── AC-4 (tenant-safety — load-bearing): cross-tenant siteId not promoted ─
185
+ it("AC-4: a lead whose siteId belongs to a DIFFERENT organization's workspace is not promoted", async () => {
186
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
187
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
188
+ ownMembership(), // the user belongs ONLY to OWN_ORG_ID / OWN_WORKSPACE_ID
189
+ );
190
+ const table = seedLeadTable([
191
+ {
192
+ id: "lead_cross_tenant",
193
+ email: USER_EMAIL,
194
+ siteId: OTHER_WORKSPACE_ID, // a DIFFERENT organization's workspace
195
+ source: "contact",
196
+ promotedUserId: null,
197
+ },
198
+ ]);
199
+
200
+ const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
201
+
202
+ expect(result).toEqual<PromoteLeadsResult>({
203
+ ok: true,
204
+ count: 0,
205
+ promoted: [],
206
+ });
207
+ expect(prisma.lead.updateMany).not.toHaveBeenCalled();
208
+ // The cross-tenant lead's promotedUserId is untouched.
209
+ expect(table.find((r) => r.id === "lead_cross_tenant")?.promotedUserId).toBeNull();
210
+ // The query itself never widens siteId.in to the foreign workspace.
211
+ expect(prisma.lead.findMany).toHaveBeenCalledWith({
212
+ where: {
213
+ email: USER_EMAIL,
214
+ siteId: { in: [OWN_WORKSPACE_ID] },
215
+ promotedUserId: null,
216
+ },
217
+ select: { id: true, siteId: true, source: true },
218
+ });
219
+ });
220
+
221
+ // ── AC-5: unverified email → no-op, verified-email gate ───────────────────
222
+ it("AC-5: unverified email performs no lead update and returns count:0", async () => {
223
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(
224
+ verifiedUser({ emailVerified: false }),
225
+ );
226
+
227
+ const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
228
+
229
+ expect(result).toEqual<PromoteLeadsResult>({
230
+ ok: true,
231
+ count: 0,
232
+ promoted: [],
233
+ });
234
+ expect(prisma.membership.findMany).not.toHaveBeenCalled();
235
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
236
+ expect(prisma.lead.updateMany).not.toHaveBeenCalled();
237
+ });
238
+
239
+ // ── AC-6: idempotent — a second invocation promotes nothing ──────────────
240
+ it("AC-6: a second invocation (leads already promoted) promotes nothing", async () => {
241
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
242
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
243
+ ownMembership(),
244
+ );
245
+ seedLeadTable([
246
+ {
247
+ id: "lead_already_promoted",
248
+ email: USER_EMAIL,
249
+ siteId: OWN_WORKSPACE_ID,
250
+ source: "contact",
251
+ promotedUserId: USER_ID, // already promoted — excluded by the query
252
+ },
253
+ ]);
254
+
255
+ const first = await promoteLeadsForVerifiedUser({ userId: USER_ID });
256
+ expect(first).toEqual<PromoteLeadsResult>({
257
+ ok: true,
258
+ count: 0,
259
+ promoted: [],
260
+ });
261
+
262
+ const second = await promoteLeadsForVerifiedUser({ userId: USER_ID });
263
+ expect(second).toEqual<PromoteLeadsResult>({
264
+ ok: true,
265
+ count: 0,
266
+ promoted: [],
267
+ });
268
+ expect(prisma.lead.updateMany).not.toHaveBeenCalled();
269
+ });
270
+
271
+ // ── Idempotent end-to-end: promote once, re-run promotes nothing more ────
272
+ it("a lead promoted on the first run is excluded on the second run", async () => {
273
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
274
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
275
+ ownMembership(),
276
+ );
277
+ seedLeadTable([
278
+ {
279
+ id: "lead_first_run",
280
+ email: USER_EMAIL,
281
+ siteId: OWN_WORKSPACE_ID,
282
+ source: "contact",
283
+ promotedUserId: null,
284
+ },
285
+ ]);
286
+
287
+ const first = await promoteLeadsForVerifiedUser({ userId: USER_ID });
288
+ expect(first).toEqual<PromoteLeadsResult>({
289
+ ok: true,
290
+ count: 1,
291
+ promoted: [
292
+ { leadId: "lead_first_run", siteId: OWN_WORKSPACE_ID, source: "contact" },
293
+ ],
294
+ });
295
+
296
+ const second = await promoteLeadsForVerifiedUser({ userId: USER_ID });
297
+ expect(second).toEqual<PromoteLeadsResult>({
298
+ ok: true,
299
+ count: 0,
300
+ promoted: [],
301
+ });
302
+ });
303
+
304
+ // ── No match → count:0 ─────────────────────────────────────────────────────
305
+ it("no matching lead → returns count:0 with no update", async () => {
306
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
307
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
308
+ ownMembership(),
309
+ );
310
+ seedLeadTable([]);
311
+
312
+ const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
313
+
314
+ expect(result).toEqual<PromoteLeadsResult>({
315
+ ok: true,
316
+ count: 0,
317
+ promoted: [],
318
+ });
319
+ expect(prisma.lead.updateMany).not.toHaveBeenCalled();
320
+ });
321
+
322
+ // ── source preservation ────────────────────────────────────────────────────
323
+ it("preserves each lead's source in the result", async () => {
324
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
325
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
326
+ ownMembership(),
327
+ );
328
+ seedLeadTable([
329
+ {
330
+ id: "lead_a",
331
+ email: USER_EMAIL,
332
+ siteId: OWN_WORKSPACE_ID,
333
+ source: "contact",
334
+ promotedUserId: null,
335
+ },
336
+ {
337
+ id: "lead_b",
338
+ email: USER_EMAIL,
339
+ siteId: OWN_WORKSPACE_ID,
340
+ source: "newsletter-signup",
341
+ promotedUserId: null,
342
+ },
343
+ ]);
344
+
345
+ const result = (await promoteLeadsForVerifiedUser({
346
+ userId: USER_ID,
347
+ })) as Extract<PromoteLeadsResult, { ok: true }>;
348
+
349
+ expect(result.ok).toBe(true);
350
+ expect(result.count).toBe(2);
351
+ expect(result.promoted).toEqual([
352
+ { leadId: "lead_a", siteId: OWN_WORKSPACE_ID, source: "contact" },
353
+ {
354
+ leadId: "lead_b",
355
+ siteId: OWN_WORKSPACE_ID,
356
+ source: "newsletter-signup",
357
+ },
358
+ ]);
359
+ });
360
+
361
+ // ── No workspaces → no-op, never queries lead ─────────────────────────────
362
+ it("a user with no workspaces is a no-op — never queries inbox.Lead", async () => {
363
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
364
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
365
+
366
+ const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
367
+
368
+ expect(result).toEqual<PromoteLeadsResult>({
369
+ ok: true,
370
+ count: 0,
371
+ promoted: [],
372
+ });
373
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
374
+ });
375
+
376
+ // ── Unknown user → ok:false, user_not_found ───────────────────────────────
377
+ it("an unknown userId returns ok:false with kind user_not_found", async () => {
378
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
379
+
380
+ const result = (await promoteLeadsForVerifiedUser({
381
+ userId: "user_does-not-exist",
382
+ })) as Extract<PromoteLeadsResult, { ok: false }>;
383
+
384
+ expect(result.ok).toBe(false);
385
+ expect(result.error.kind).toBe("user_not_found");
386
+ });
387
+
388
+ // ── PII non-leak — the result never carries the user's raw email ─────────
389
+ it("PII non-leak — the result never carries the user's raw email field", async () => {
390
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
391
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
392
+ ownMembership(),
393
+ );
394
+ seedLeadTable([
395
+ {
396
+ id: "lead_00000000-0000-7000-8000-0000000000d1",
397
+ email: USER_EMAIL,
398
+ siteId: OWN_WORKSPACE_ID,
399
+ source: "contact",
400
+ promotedUserId: null,
401
+ },
402
+ ]);
403
+
404
+ const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
405
+
406
+ expect(JSON.stringify(result)).not.toContain(USER_EMAIL);
407
+ });
408
+ });
@@ -392,4 +392,164 @@ describe("routeLead", () => {
392
392
  expect(result.error.kind).toBe("invalid_input");
393
393
  expect(prisma.subscriber.create).not.toHaveBeenCalled();
394
394
  });
395
+
396
+ // ── #6203/#6254 — the workspaceId stamp is ALWAYS a copy of siteId ────────
397
+ //
398
+ // routeLead(kind: "submit") has FOUR callers in this repo, and as of
399
+ // #6254 ALL FOUR resolve `siteId` to a real `wksp_<uuidv7>` workspace id
400
+ // before calling in — the three that once passed an unresolved alias
401
+ // (flow-engine.ts, recommender-capture.ts, project-listings.ts's
402
+ // submitProjectListingDraft) now resolve it themselves
403
+ // (`resolveSiteWorkspaceId()` / `resolveWorkspaceIdForAlias()`,
404
+ // `apps/api/src/lib/site.ts`). `workspaceId` is NOT NULL as of
405
+ // database/migrations/20260922120000_tenancy_fence, so `persistToDb` can
406
+ // no longer stamp `null` for an unresolved alias the way #6203 once did —
407
+ // it copies `siteId` unconditionally, and a caller that still somehow
408
+ // passed an unresolved alias hits the pre-existing
409
+ // `leads_workspace_id_format` CHECK loudly (via the AC-11 re-raise this
410
+ // file's other describe block proves) instead of landing a
411
+ // silently-unfenced row.
412
+ describe("the workspaceId stamp copies siteId unconditionally (#6203, #6254)", () => {
413
+ it("workspaceId is always the SAME value as siteId, whatever shape siteId carries", async () => {
414
+ await routeLead(leadInput, sourceMeta); // leadInput.siteId === "site-abc"
415
+
416
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
417
+ const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
418
+ expect(data.siteId).toBe("site-abc");
419
+ expect(data.workspaceId).toBe("site-abc");
420
+ });
421
+
422
+ it("a workspace-shaped siteId (the routes/leads/index.ts path) stamps workspaceId to the SAME value", async () => {
423
+ const resolvedInput = {
424
+ ...leadInput,
425
+ siteId: "wksp_00000000-0000-7000-8000-000000000001",
426
+ };
427
+
428
+ await routeLead(resolvedInput, sourceMeta);
429
+
430
+ const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
431
+ expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
432
+ expect(data.workspaceId).toBe("wksp_00000000-0000-7000-8000-000000000001");
433
+ });
434
+ });
435
+
436
+ // #6254 AC-11 (spec D6, SEC-02) — a constraint violation (the new
437
+ // `leads_workspace_site_agree` CHECK, or the pre-existing NOT NULL) must
438
+ // never be swallowed into the file fallback and a silent `ok:true`: it
439
+ // means OUR OWN derivation produced a row Postgres itself refuses, which
440
+ // is a bug to surface, not an outage to degrade through. A GENUINE
441
+ // infrastructure failure (the existing "DB down → file fallback" test
442
+ // above, a plain `Error("connection refused")` that carries none of the
443
+ // signals below) must still take the file fallback, unchanged.
444
+ describe("a constraint violation re-raises as a deliberate error, never a silent success (#6254 AC-11)", () => {
445
+ const listTmpDirFiles = () => fs.readdir(tmpDir);
446
+
447
+ it("a Prisma-mapped class-23 code (e.g. P2011, null constraint) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
448
+ const before = await listTmpDirFiles();
449
+ const constraintErr = Object.assign(
450
+ new Error("Null constraint violation on the field: (`workspace_id`)"),
451
+ { code: "P2011" },
452
+ );
453
+ (prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
454
+
455
+ const result = (await routeLead(
456
+ leadInput,
457
+ sourceMeta,
458
+ )) as Extract<RouteLeadResult, { ok: false }>;
459
+
460
+ expect(result.ok).toBe(false);
461
+ expect(result.error.kind).toBe("persist_failed");
462
+ // No file was written — the fallback was never attempted.
463
+ const after = await listTmpDirFiles();
464
+ expect(after).toEqual(before);
465
+ });
466
+
467
+ it("a raw SQLSTATE the message carries (no Prisma-mapped code — the new CHECK constraint's own shape) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
468
+ const before = await listTmpDirFiles();
469
+ const checkErr = new Error(
470
+ 'new row for relation "leads" violates check constraint "leads_workspace_site_agree"',
471
+ );
472
+ (prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
473
+
474
+ const result = (await routeLead(
475
+ leadInput,
476
+ sourceMeta,
477
+ )) as Extract<RouteLeadResult, { ok: false }>;
478
+
479
+ expect(result.ok).toBe(false);
480
+ expect(result.error.kind).toBe("persist_failed");
481
+ const after = await listTmpDirFiles();
482
+ expect(after).toEqual(before);
483
+ });
484
+
485
+ it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
486
+ const before = await listTmpDirFiles();
487
+ const notNullErr = Object.assign(
488
+ new Error("null value in column \"workspace_id\" violates not-null constraint (23502)"),
489
+ {},
490
+ );
491
+ (prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
492
+
493
+ const result = (await routeLead(
494
+ leadInput,
495
+ sourceMeta,
496
+ )) as Extract<RouteLeadResult, { ok: false }>;
497
+
498
+ expect(result.ok).toBe(false);
499
+ expect(result.error.kind).toBe("persist_failed");
500
+ const after = await listTmpDirFiles();
501
+ expect(after).toEqual(before);
502
+ });
503
+
504
+ it("a genuine outage (no class-23 signal) still takes the file fallback — persisted:file, not persist_failed", async () => {
505
+ // Regression guard for the branch above: an ordinary connection error
506
+ // must not be misclassified as a constraint violation.
507
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
508
+ new Error("connection refused"),
509
+ );
510
+
511
+ const result = (await routeLead(
512
+ leadInput,
513
+ sourceMeta,
514
+ )) as Extract<RouteLeadResult, { ok: true }>;
515
+
516
+ expect(result.ok).toBe(true);
517
+ expect(result.persisted).toBe("file");
518
+ });
519
+ });
520
+ });
521
+
522
+ // ─── #6257 — the fence: every fenced read carries workspaceId ───────────────
523
+
524
+ /**
525
+ * Unit C2's contract for this service (#6257 AC-1/AC-2). `routeLead`'s one
526
+ * READ is the duplicate-subscriber lookup on the P2002 arm. `input.siteId` is
527
+ * the resolved workspace id at every caller (the same guarantee the three
528
+ * `create` sites above stamp on), so the lookup is scoped by it rather than
529
+ * resolving a second time.
530
+ */
531
+ describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () => {
532
+ beforeEach(() => {
533
+ jest.clearAllMocks();
534
+ process.env.DATABASE_URL = "postgres://test";
535
+ });
536
+
537
+ it("scopes the existing-subscriber read by the caller's resolved workspace id", async () => {
538
+ (prisma.subscriber.create as jest.Mock).mockRejectedValue(
539
+ Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
540
+ );
541
+ (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({ id: "sub_1" });
542
+
543
+ await routeLead(
544
+ { ...subscribeInput, email: "dupe@example.com" },
545
+ sourceMeta,
546
+ );
547
+
548
+ const { where } = (prisma.subscriber.findUnique as jest.Mock).mock.calls[0][0];
549
+ expect(where.workspaceId).toBe(subscribeInput.siteId);
550
+ expect(where.siteId_email).toEqual({
551
+ siteId: subscribeInput.siteId,
552
+ email: "dupe@example.com",
553
+ });
554
+ });
395
555
  });