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
@@ -31,6 +31,15 @@ jest.mock("@working-theory/email", () => ({
31
31
  ok: true,
32
32
  messageId: "msg-test",
33
33
  }),
34
+ // #5970 — the request-and-confirm tenant sender. Declared here because the
35
+ // callback route now reaches it, and because without it the feature-ON cases
36
+ // below would pass for the wrong reason: with the flag unset,
37
+ // `sendRequestConfirmLink` no-ops before it ever touches this import.
38
+ sendRequestConfirmEmail: jest.fn().mockResolvedValue({
39
+ ok: true,
40
+ messageId: "msg-confirm",
41
+ }),
42
+ sendRequestPaidLinkEmail: jest.fn(),
34
43
  }));
35
44
 
36
45
  // @working-theory/license: consumed transitively by enrichment-shim; mocked to avoid
@@ -50,7 +59,12 @@ import {
50
59
  leadSubscribeRoute,
51
60
  leadCallbackRoute,
52
61
  } from "../index";
62
+ import {
63
+ mockNoWorkspace,
64
+ mockSentinelWorkspaceExists,
65
+ } from "../../../lib/__mocks__/prisma.js";
53
66
  import { prisma } from "../../../lib/prisma.js";
67
+ import { __resetSiteWorkspaceCacheForTests } from "../../../lib/site.js";
54
68
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
55
69
  // #3979 AC1 — the single shared fixture (packages/validation, which apps/api
56
70
  // may_import_from per architecture.yaml) for the NewsletterFormInline
@@ -68,6 +82,42 @@ import {
68
82
 
69
83
  const fileEnv = setupLeadFileEnv("index");
70
84
 
85
+ // #6203 — every lead-capture kind now resolves its tenant SERVER-SIDE
86
+ // (routes/leads/index.ts), never from a body-supplied `siteId`. A test's
87
+ // body may still send a `siteId` (a client-embed artifact, or a spoof
88
+ // attempt) but it is silently ignored — the SEEDED default workspace id
89
+ // (`database/tenancy.ts DEFAULT_WORKSPACE_ID`) is what actually lands, on
90
+ // both `siteId` and the new `workspaceId` column.
91
+ //
92
+ // #6335 — that resolution used to come off the shared prisma mock's
93
+ // PERMISSIVE `workspace.findUnique` default, which resolved any id it was
94
+ // handed. The default is gone, so this suite states the workspace it
95
+ // expects: `stateSentinelWorkspaceExists()` below, called wherever this
96
+ // file clears its mocks.
97
+ const RESOLVED_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
98
+
99
+ /**
100
+ * A second, real workspace the deployment is NOT serving. The three spoof
101
+ * cases below make it resolvable for their own duration, so "the row did
102
+ * not land there" is a statement about what the route READ rather than a
103
+ * side effect of the id resolving to nothing.
104
+ */
105
+ const FOREIGN_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-00000000dead";
106
+
107
+ /**
108
+ * #6335 — the ONE workspace every case in this file expects to exist: the
109
+ * seeded sentinel `currentSiteId()`'s "default" alias names. Keyed on the
110
+ * requested id inside the helper, so a body-supplied foreign `siteId`
111
+ * resolves to nothing — which is what makes the spoof cases below prove
112
+ * the route never reads it, rather than proving a permissive mock resolves
113
+ * everything. The resolver's memo is cleared alongside, so no case answers
114
+ * out of a previous one's successful resolution.
115
+ */
116
+ function stateSentinelWorkspaceExists(): void {
117
+ __resetSiteWorkspaceCacheForTests();
118
+ mockSentinelWorkspaceExists(prisma);
119
+ }
120
+
71
121
  // ─── Suite setup ──────────────────────────────────────────────────────────────
72
122
 
73
123
  describe("POST /api/leads — id isolation", () => {
@@ -75,6 +125,7 @@ describe("POST /api/leads — id isolation", () => {
75
125
 
76
126
  beforeEach(async () => {
77
127
  jest.clearAllMocks();
128
+ stateSentinelWorkspaceExists();
78
129
  process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
79
130
  process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
80
131
  process.env.DATABASE_URL = "postgres://stub";
@@ -97,7 +148,9 @@ describe("POST /api/leads — id isolation", () => {
97
148
 
98
149
  it("POST /api/leads/submit — attacker-supplied id is not returned (server uuid used)", async () => {
99
150
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a1";
100
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
151
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
152
+ id: serverGeneratedId,
153
+ });
101
154
 
102
155
  const res = await app.inject({
103
156
  method: "POST",
@@ -124,7 +177,9 @@ describe("POST /api/leads — id isolation", () => {
124
177
 
125
178
  it("POST /api/leads/subscribe — attacker-supplied id is not returned (server uuid used)", async () => {
126
179
  const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000a2";
127
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
180
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
181
+ id: serverGeneratedId,
182
+ });
128
183
 
129
184
  const res = await app.inject({
130
185
  method: "POST",
@@ -154,7 +209,9 @@ describe("POST /api/leads — id isolation", () => {
154
209
  // values), with nothing catching it.
155
210
  it("POST /v1/leads/subscribe — the block's emitted JSON shape ({ siteId, source, email }) is accepted and persisted", async () => {
156
211
  const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000b1";
157
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
212
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
213
+ id: serverGeneratedId,
214
+ });
158
215
 
159
216
  // The exact body NewsletterFormInline emits at its defaults (#3965 D1) —
160
217
  // no `kind` key: buildInput() injects `kind` server-side from the route,
@@ -185,7 +242,9 @@ describe("POST /api/leads — id isolation", () => {
185
242
  // the id-isolation walls hold regardless of validation outcome.
186
243
  it("POST /v1/leads/submit — the agency-waitlist instance's emitted JSON shape ({ siteId, source, email }) is accepted and persisted", async () => {
187
244
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000b2";
188
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
245
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
246
+ id: serverGeneratedId,
247
+ });
189
248
 
190
249
  // The exact body NewsletterFormInline emits for the agency-waitlist
191
250
  // instance — no `kind`, no `payload` key: buildInput() injects `kind`
@@ -217,7 +276,9 @@ describe("POST /api/leads — id isolation", () => {
217
276
  // this layer asserting a stale contract.
218
277
  it("POST /v1/leads/submit — the ContactForm section's emitted JSON shape ({ siteId, source, email, payload }) is accepted and persisted", async () => {
219
278
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000b3";
220
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
279
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
280
+ id: serverGeneratedId,
281
+ });
221
282
 
222
283
  // The exact body ContactForm emits at its component defaults (#4017 D1) —
223
284
  // no `kind` key: buildInput() injects `kind` server-side from the route,
@@ -236,7 +297,9 @@ describe("POST /api/leads — id isolation", () => {
236
297
  expect(prisma.lead.create).toHaveBeenCalledTimes(1);
237
298
  expect(prisma.lead.create).toHaveBeenCalledWith({
238
299
  data: {
239
- siteId: CONTACT_FORM_LEAD_BODY.siteId,
300
+ // #6203 — the RESOLVED workspace, never the body's own `siteId`.
301
+ siteId: RESOLVED_WORKSPACE_ID,
302
+ workspaceId: RESOLVED_WORKSPACE_ID,
240
303
  source: CONTACT_FORM_LEAD_BODY.source,
241
304
  email: CONTACT_FORM_LEAD_BODY.email,
242
305
  payload: CONTACT_FORM_LEAD_BODY.payload,
@@ -254,7 +317,9 @@ describe("POST /api/leads — id isolation", () => {
254
317
 
255
318
  it("POST /api/leads/callback — attacker-supplied id is not returned (server uuid used)", async () => {
256
319
  const serverGeneratedId = "callbk_00000000-0000-7000-8000-0000000000a3";
257
- (prisma.callback.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
320
+ (prisma.callback.create as jest.Mock).mockResolvedValue({
321
+ id: serverGeneratedId,
322
+ });
258
323
 
259
324
  const res = await app.inject({
260
325
  method: "POST",
@@ -281,7 +346,9 @@ describe("POST /api/leads — id isolation", () => {
281
346
 
282
347
  it("file-on-success — appendLeadRecord writes to LEAD_CAPTURE_FILE on successful DB persist", async () => {
283
348
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a4";
284
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
349
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
350
+ id: serverGeneratedId,
351
+ });
285
352
 
286
353
  const res = await app.inject({
287
354
  method: "POST",
@@ -306,7 +373,9 @@ describe("POST /api/leads — id isolation", () => {
306
373
 
307
374
  it("dual-write — real lead-capture-store AND DB are both invoked on success", async () => {
308
375
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a5";
309
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
376
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
377
+ id: serverGeneratedId,
378
+ });
310
379
 
311
380
  const res = await app.inject({
312
381
  method: "POST",
@@ -331,7 +400,9 @@ describe("POST /api/leads — id isolation", () => {
331
400
  process.env.LEAD_CAPTURE_FILE_DISABLED = "true";
332
401
 
333
402
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a6";
334
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
403
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
404
+ id: serverGeneratedId,
405
+ });
335
406
 
336
407
  const res = await app.inject({
337
408
  method: "POST",
@@ -349,9 +420,7 @@ describe("POST /api/leads — id isolation", () => {
349
420
  if (res.statusCode === 201) {
350
421
  // The real lead-capture-store short-circuits when LEAD_CAPTURE_FILE_DISABLED=true.
351
422
  // The capture file must not be written.
352
- await expect(
353
- fs.access(fileEnv.getCaptureFilePath()),
354
- ).rejects.toThrow();
423
+ await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
355
424
  }
356
425
  });
357
426
 
@@ -381,7 +450,9 @@ describe("POST /api/leads — id isolation", () => {
381
450
  it("IP-include — metadata.ip present in capture record when LEAD_CAPTURE_INCLUDE_IP=true", async () => {
382
451
  process.env.LEAD_CAPTURE_INCLUDE_IP = "true";
383
452
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a7";
384
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
453
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
454
+ id: serverGeneratedId,
455
+ });
385
456
 
386
457
  const res = await app.inject({
387
458
  method: "POST",
@@ -398,7 +469,7 @@ describe("POST /api/leads — id isolation", () => {
398
469
 
399
470
  if (res.statusCode === 201) {
400
471
  const record = await fileEnv.readLastCaptureRecord();
401
- expect((record.metadata as Record<string, unknown>)).toHaveProperty("ip");
472
+ expect(record.metadata as Record<string, unknown>).toHaveProperty("ip");
402
473
  }
403
474
  });
404
475
 
@@ -406,7 +477,9 @@ describe("POST /api/leads — id isolation", () => {
406
477
 
407
478
  it("lead — payload absent from body defaults to empty object via ?? (payload ?? {} branch)", async () => {
408
479
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a8";
409
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
480
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
481
+ id: serverGeneratedId,
482
+ });
410
483
 
411
484
  const bodyWithoutPayload: Record<string, unknown> = {
412
485
  kind: "submit",
@@ -430,7 +503,9 @@ describe("POST /api/leads — id isolation", () => {
430
503
 
431
504
  it("callback — preferredTime included when provided (preferredTime spread branch)", async () => {
432
505
  const serverGeneratedId = "callbk_00000000-0000-7000-8000-0000000000a9";
433
- (prisma.callback.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
506
+ (prisma.callback.create as jest.Mock).mockResolvedValue({
507
+ id: serverGeneratedId,
508
+ });
434
509
 
435
510
  const res = await app.inject({
436
511
  method: "POST",
@@ -453,7 +528,9 @@ describe("POST /api/leads — id isolation", () => {
453
528
 
454
529
  it("callback — payload absent from body defaults to empty object via ?? (callback payload ?? {} branch)", async () => {
455
530
  const serverGeneratedId = "callbk_00000000-0000-7000-8000-0000000000aa";
456
- (prisma.callback.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
531
+ (prisma.callback.create as jest.Mock).mockResolvedValue({
532
+ id: serverGeneratedId,
533
+ });
457
534
 
458
535
  const bodyWithoutPayload: Record<string, unknown> = {
459
536
  kind: "callback",
@@ -481,7 +558,9 @@ describe("POST /api/leads — id isolation", () => {
481
558
  delete process.env.AILK_LEAD_NOTIFICATION_TO;
482
559
  delete process.env.AILK_LEAD_NOTIFICATION_FROM;
483
560
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000ab";
484
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
561
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
562
+ id: serverGeneratedId,
563
+ });
485
564
 
486
565
  const res = await app.inject({
487
566
  method: "POST",
@@ -511,7 +590,9 @@ describe("POST /api/leads — id isolation", () => {
511
590
  it("envelope submit — { siteId, source, payload: { email, ... } } returns 201, writes one Lead with that email and the full payload, and routes the notification email", async () => {
512
591
  const { sendLeadNotification } = jest.requireMock("@working-theory/email");
513
592
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000e1";
514
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
593
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
594
+ id: serverGeneratedId,
595
+ });
515
596
 
516
597
  const envelopePayload = {
517
598
  email: "envelope@example.com",
@@ -535,7 +616,9 @@ describe("POST /api/leads — id isolation", () => {
535
616
  expect(prisma.lead.create).toHaveBeenCalledTimes(1);
536
617
  expect(prisma.lead.create).toHaveBeenCalledWith({
537
618
  data: {
538
- siteId: "site-abc",
619
+ // #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
620
+ siteId: RESOLVED_WORKSPACE_ID,
621
+ workspaceId: RESOLVED_WORKSPACE_ID,
539
622
  source: "step-flow",
540
623
  email: "envelope@example.com",
541
624
  payload: envelopePayload,
@@ -548,7 +631,9 @@ describe("POST /api/leads — id isolation", () => {
548
631
 
549
632
  it("envelope subscribe — { siteId, source, payload: { email } } returns 201 and writes one Subscriber", async () => {
550
633
  const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000e2";
551
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
634
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
635
+ id: serverGeneratedId,
636
+ });
552
637
 
553
638
  const res = await app.inject({
554
639
  method: "POST",
@@ -566,7 +651,14 @@ describe("POST /api/leads — id isolation", () => {
566
651
  expect(prisma.subscriber.create).toHaveBeenCalledTimes(1);
567
652
  expect(prisma.subscriber.create).toHaveBeenCalledWith({
568
653
  data: {
569
- siteId: "site-abc",
654
+ // #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
655
+ siteId: RESOLVED_WORKSPACE_ID,
656
+ // #6374 — `subscribers.workspace_id` is NOT NULL as of
657
+ // database/migrations/20260922120000_tenancy_fence, and
658
+ // `subscribers_workspace_site_agree` forces it to EQUAL `site_id`.
659
+ // Asserted as the same RESOLVED value, so a raw alias leaking into
660
+ // either column fails this test rather than the database.
661
+ workspaceId: RESOLVED_WORKSPACE_ID,
570
662
  email: "sub-envelope@example.com",
571
663
  source: "step-flow",
572
664
  },
@@ -610,7 +702,9 @@ describe("POST /api/leads — id isolation", () => {
610
702
 
611
703
  it("submit — a top-level email wins over a payload email", async () => {
612
704
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000e3";
613
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
705
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
706
+ id: serverGeneratedId,
707
+ });
614
708
 
615
709
  const res = await app.inject({
616
710
  method: "POST",
@@ -626,7 +720,9 @@ describe("POST /api/leads — id isolation", () => {
626
720
  expect(res.statusCode).toBe(201);
627
721
  expect(prisma.lead.create).toHaveBeenCalledWith({
628
722
  data: {
629
- siteId: "site-abc",
723
+ // #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
724
+ siteId: RESOLVED_WORKSPACE_ID,
725
+ workspaceId: RESOLVED_WORKSPACE_ID,
630
726
  source: "step-flow",
631
727
  email: "top-level@example.com",
632
728
  payload: { email: "shadowed-payload@example.com", message: "hi" },
@@ -637,7 +733,9 @@ describe("POST /api/leads — id isolation", () => {
637
733
 
638
734
  it("subscribe — a top-level email wins over a payload email", async () => {
639
735
  const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000e4";
640
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
736
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
737
+ id: serverGeneratedId,
738
+ });
641
739
 
642
740
  const res = await app.inject({
643
741
  method: "POST",
@@ -653,7 +751,11 @@ describe("POST /api/leads — id isolation", () => {
653
751
  expect(res.statusCode).toBe(201);
654
752
  expect(prisma.subscriber.create).toHaveBeenCalledWith({
655
753
  data: {
656
- siteId: "site-abc",
754
+ // #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
755
+ siteId: RESOLVED_WORKSPACE_ID,
756
+ // #6374 — equal to `siteId` by construction; see the note on the
757
+ // envelope-subscribe assertion above.
758
+ workspaceId: RESOLVED_WORKSPACE_ID,
657
759
  email: "top-level-sub@example.com",
658
760
  source: "step-flow",
659
761
  },
@@ -668,7 +770,9 @@ describe("POST /api/leads — id isolation", () => {
668
770
  error: { kind: "send_error", message: "SMTP timeout" },
669
771
  });
670
772
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000ac";
671
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
773
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
774
+ id: serverGeneratedId,
775
+ });
672
776
 
673
777
  const res = await app.inject({
674
778
  method: "POST",
@@ -702,12 +806,15 @@ describe("POST /v1/leads/submit — structured log stays PII-safe for envelope i
702
806
 
703
807
  it("the 'lead routed' log line carries no email or payload value when email is lifted from payload", async () => {
704
808
  jest.clearAllMocks();
809
+ stateSentinelWorkspaceExists();
705
810
  process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
706
811
  process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
707
812
  process.env.DATABASE_URL = "postgres://stub";
708
813
 
709
814
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000e5";
710
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
815
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
816
+ id: serverGeneratedId,
817
+ });
711
818
 
712
819
  const logLines: string[] = [];
713
820
  const logCapture = require("fastify")({
@@ -744,7 +851,8 @@ describe("POST /v1/leads/submit — structured log stays PII-safe for envelope i
744
851
  expect(logged).not.toHaveProperty("email");
745
852
  expect(logged).not.toHaveProperty("payload");
746
853
  expect(logged).toMatchObject({
747
- siteId: "site-abc",
854
+ // #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
855
+ siteId: RESOLVED_WORKSPACE_ID,
748
856
  kind: "submit",
749
857
  persisted: "db",
750
858
  });
@@ -754,6 +862,164 @@ describe("POST /v1/leads/submit — structured log stays PII-safe for envelope i
754
862
  });
755
863
  });
756
864
 
865
+ // ── #6203 AC-4 — tenant resolution: per-kind, unresolved-rejects, spoof-proof ──
866
+ //
867
+ // Every capture kind resolves its tenant SERVER-SIDE (routes/leads/index.ts's
868
+ // header). These tests assert the two halves that matter: (1) an unresolved
869
+ // resolution — DATABASE_URL set, but the alias names no workspace this
870
+ // database holds — rejects the write with a 503 BEFORE any create call, for
871
+ // each of the three kinds; (2) a client-supplied `siteId` naming a DIFFERENT,
872
+ // otherwise-resolvable workspace never places a row there — the resolved
873
+ // value always wins, proving the body value is truly ignored rather than
874
+ // merely unpreferred.
875
+
876
+ describe("POST /api/leads/:kind — tenant resolution (#6203 AC-4)", () => {
877
+ let app: FastifyInstance;
878
+
879
+ beforeEach(async () => {
880
+ jest.clearAllMocks();
881
+ stateSentinelWorkspaceExists();
882
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
883
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
884
+ process.env.DATABASE_URL = "postgres://stub";
885
+ delete process.env.AILK_SITE_ID;
886
+
887
+ const fastify = require("fastify")();
888
+ await fastify.register(leadSubmitRoute);
889
+ await fastify.register(leadSubscribeRoute);
890
+ await fastify.register(leadCallbackRoute);
891
+ app = fastify;
892
+ });
893
+
894
+ afterEach(async () => {
895
+ await app.close();
896
+ __resetSiteWorkspaceCacheForTests();
897
+ delete process.env.AILK_LEAD_NOTIFICATION_TO;
898
+ delete process.env.AILK_LEAD_NOTIFICATION_FROM;
899
+ delete process.env.DATABASE_URL;
900
+ delete process.env.AILK_SITE_ID;
901
+ });
902
+
903
+ it("submit: an unresolved workspace is a 503 tenant_unresolved and writes no Lead", async () => {
904
+ mockNoWorkspace(prisma);
905
+
906
+ const res = await app.inject({
907
+ method: "POST",
908
+ url: "/v1/leads/submit",
909
+ payload: {
910
+ siteId: "default",
911
+ source: "contact-form",
912
+ email: "nobody@example.com",
913
+ payload: {},
914
+ },
915
+ });
916
+
917
+ expect(res.statusCode).toBe(503);
918
+ expect(res.json()).toMatchObject({ error: "tenant_unresolved" });
919
+ expect(prisma.lead.create).not.toHaveBeenCalled();
920
+ });
921
+
922
+ it("subscribe: an unresolved workspace is a 503 tenant_unresolved and writes no Subscriber", async () => {
923
+ mockNoWorkspace(prisma);
924
+
925
+ const res = await app.inject({
926
+ method: "POST",
927
+ url: "/v1/leads/subscribe",
928
+ payload: { siteId: "default", source: "footer", email: "nobody@example.com" },
929
+ });
930
+
931
+ expect(res.statusCode).toBe(503);
932
+ expect(res.json()).toMatchObject({ error: "tenant_unresolved" });
933
+ expect(prisma.subscriber.create).not.toHaveBeenCalled();
934
+ });
935
+
936
+ it("callback: an unresolved workspace is a 503 tenant_unresolved and writes no Callback", async () => {
937
+ mockNoWorkspace(prisma);
938
+
939
+ const res = await app.inject({
940
+ method: "POST",
941
+ url: "/v1/leads/callback",
942
+ payload: { siteId: "default", name: "Nobody", phone: "+15550000000" },
943
+ });
944
+
945
+ expect(res.statusCode).toBe(503);
946
+ expect(res.json()).toMatchObject({ error: "tenant_unresolved" });
947
+ expect(prisma.callback.create).not.toHaveBeenCalled();
948
+ });
949
+
950
+ it("submit: a body siteId naming another workspace never places the row there — the resolved workspace always wins", async () => {
951
+ mockSentinelWorkspaceExists(prisma, FOREIGN_WORKSPACE_ID);
952
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
953
+ id: "lead_00000000-0000-7000-8000-0000000000f1",
954
+ });
955
+
956
+ const res = await app.inject({
957
+ method: "POST",
958
+ url: "/v1/leads/submit",
959
+ payload: {
960
+ // A DIFFERENT workspace id, which this test has deliberately made
961
+ // RESOLVABLE (`FOREIGN_WORKSPACE_ID` above) — so the row landing on
962
+ // the sentinel proves buildInput() never reads the body value at
963
+ // all, rather than proving the read merely failed closed.
964
+ siteId: FOREIGN_WORKSPACE_ID,
965
+ source: "contact-form",
966
+ email: "spoof@example.com",
967
+ payload: {},
968
+ },
969
+ });
970
+
971
+ expect(res.statusCode).toBe(201);
972
+ const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
973
+ expect(data.siteId).toBe(RESOLVED_WORKSPACE_ID);
974
+ expect(data.workspaceId).toBe(RESOLVED_WORKSPACE_ID);
975
+ expect(data.siteId).not.toBe(FOREIGN_WORKSPACE_ID);
976
+ });
977
+
978
+ it("subscribe: a body siteId naming another workspace never places the row there", async () => {
979
+ mockSentinelWorkspaceExists(prisma, FOREIGN_WORKSPACE_ID);
980
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
981
+ id: "subscr_00000000-0000-7000-8000-0000000000f2",
982
+ });
983
+
984
+ const res = await app.inject({
985
+ method: "POST",
986
+ url: "/v1/leads/subscribe",
987
+ payload: {
988
+ siteId: FOREIGN_WORKSPACE_ID,
989
+ source: "footer",
990
+ email: "spoof-sub@example.com",
991
+ },
992
+ });
993
+
994
+ expect(res.statusCode).toBe(201);
995
+ const { data } = (prisma.subscriber.create as jest.Mock).mock.calls[0]![0];
996
+ expect(data.siteId).toBe(RESOLVED_WORKSPACE_ID);
997
+ expect(data.siteId).not.toBe(FOREIGN_WORKSPACE_ID);
998
+ });
999
+
1000
+ it("callback: a body siteId naming another workspace never places the row there", async () => {
1001
+ mockSentinelWorkspaceExists(prisma, FOREIGN_WORKSPACE_ID);
1002
+ (prisma.callback.create as jest.Mock).mockResolvedValue({
1003
+ id: "callbk_00000000-0000-7000-8000-0000000000f3",
1004
+ });
1005
+
1006
+ const res = await app.inject({
1007
+ method: "POST",
1008
+ url: "/v1/leads/callback",
1009
+ payload: {
1010
+ siteId: FOREIGN_WORKSPACE_ID,
1011
+ name: "Spoofer",
1012
+ phone: "+15559999999",
1013
+ },
1014
+ });
1015
+
1016
+ expect(res.statusCode).toBe(201);
1017
+ const { data } = (prisma.callback.create as jest.Mock).mock.calls[0]![0];
1018
+ expect(data.siteId).toBe(RESOLVED_WORKSPACE_ID);
1019
+ expect(data.siteId).not.toBe(FOREIGN_WORKSPACE_ID);
1020
+ });
1021
+ });
1022
+
757
1023
  // ── D5 — registration binds the client's literal path (issue #3964) ───────────
758
1024
  //
759
1025
  // The drift class this issue fixed: every test above hand-supplied the same
@@ -768,6 +1034,7 @@ describe("POST /v1/leads/submit — registration binds the client's literal path
768
1034
 
769
1035
  beforeEach(async () => {
770
1036
  jest.clearAllMocks();
1037
+ stateSentinelWorkspaceExists();
771
1038
  process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
772
1039
  process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
773
1040
  process.env.DATABASE_URL = "postgres://stub";
@@ -786,7 +1053,9 @@ describe("POST /v1/leads/submit — registration binds the client's literal path
786
1053
 
787
1054
  it("resolves POST /v1/leads/submit — the exact literal packages/api-client/src/leads.ts's leads.submit() posts to", async () => {
788
1055
  const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000d1";
789
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: serverGeneratedId });
1056
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1057
+ id: serverGeneratedId,
1058
+ });
790
1059
 
791
1060
  // Cross-reference: packages/api-client/src/leads.ts —
792
1061
  // submit: (payload) => post<LeadWriteResponse>("/v1/leads/submit", payload)
@@ -820,3 +1089,150 @@ describe("POST /v1/leads/submit — registration binds the client's literal path
820
1089
  expect(res.statusCode).toBe(404);
821
1090
  });
822
1091
  });
1092
+
1093
+ // ── #5970 — the route wiring itself, at route grain ────────────────────────
1094
+ //
1095
+ // The #5970 acceptance review's one residual: the suppression half is measured
1096
+ // through the real `routeLead`, and the send half through the real
1097
+ // `sendRequestConfirmLink`, but the three lines in `routes/leads/index.ts`
1098
+ // that CONNECT them were asserted by construction only. Delete that call and
1099
+ // every other test in the branch stays green while the tenant receives zero
1100
+ // emails. These two cases drive the endpoint the acceptance criterion names,
1101
+ // so that deletion turns something red.
1102
+ //
1103
+ // NFR-1 is untouched by this block: every pre-existing case above runs with
1104
+ // the feature unset, exactly as before, and none of them is edited. What is
1105
+ // added here is the feature-ON path, which did not exist to be covered.
1106
+ describe("POST /v1/leads/callback — request-and-confirm wiring (#5970)", () => {
1107
+ // Registers its own hooks, so it must sit at describe level. It provisions
1108
+ // the file-fallback destination the second case needs; the first case never
1109
+ // reaches the fallback because its `callback.create` resolves.
1110
+ setupLeadFileEnv();
1111
+
1112
+ const CONFIRM_ENV = [
1113
+ "AILK_REQUEST_CONFIRM_ENABLED",
1114
+ "AILK_REQUEST_CONFIRM_TO",
1115
+ "AILK_REQUEST_CONFIRM_FROM",
1116
+ "AILK_PUBLIC_API_URL",
1117
+ ] as const;
1118
+
1119
+ function enableConfirm() {
1120
+ process.env.AILK_REQUEST_CONFIRM_ENABLED = "true";
1121
+ process.env.AILK_REQUEST_CONFIRM_TO = "owner@example.com";
1122
+ process.env.AILK_REQUEST_CONFIRM_FROM = "hello@example.com";
1123
+ process.env.AILK_PUBLIC_API_URL = "https://api.example.com";
1124
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1125
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1126
+ process.env.DATABASE_URL = "postgres://stub";
1127
+ }
1128
+
1129
+ afterEach(() => {
1130
+ for (const key of CONFIRM_ENV) delete process.env[key];
1131
+ delete process.env.AILK_LEAD_NOTIFICATION_TO;
1132
+ delete process.env.AILK_LEAD_NOTIFICATION_FROM;
1133
+ delete process.env.DATABASE_URL;
1134
+ });
1135
+
1136
+ /** Awaitable equivalent of the route's fire-and-forget confirm send. */
1137
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
1138
+
1139
+ async function postCallback(fastify: FastifyInstance) {
1140
+ return fastify.inject({
1141
+ method: "POST",
1142
+ url: "/v1/leads/callback",
1143
+ payload: {
1144
+ siteId: "site-abc",
1145
+ name: "Ada Lovelace",
1146
+ phone: "+44 7700 900000",
1147
+ payload: {},
1148
+ },
1149
+ });
1150
+ }
1151
+
1152
+ it("on the DB path the confirm email goes and the generic notification does not", async () => {
1153
+ jest.clearAllMocks();
1154
+ stateSentinelWorkspaceExists();
1155
+ enableConfirm();
1156
+ const email = require("@working-theory/email");
1157
+ (prisma.callback.create as jest.Mock).mockResolvedValue({
1158
+ id: "callbk_00000000-0000-7000-8000-00000000f001",
1159
+ });
1160
+ (prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
1161
+
1162
+ const fastify = require("fastify")({ logger: false }) as FastifyInstance;
1163
+ await fastify.register(leadCallbackRoute);
1164
+ await fastify.ready();
1165
+ try {
1166
+ const res = await postCallback(fastify);
1167
+ expect(res.statusCode).toBe(201);
1168
+ await flush();
1169
+
1170
+ // Exactly one email, and it is the one carrying the confirm link.
1171
+ expect(email.sendRequestConfirmEmail).toHaveBeenCalledTimes(1);
1172
+ expect(email.sendLeadNotification).not.toHaveBeenCalled();
1173
+ } finally {
1174
+ await fastify.close();
1175
+ }
1176
+ });
1177
+
1178
+ it("on the FILE-FALLBACK path the generic notification goes and the confirm email does not", async () => {
1179
+ jest.clearAllMocks();
1180
+ stateSentinelWorkspaceExists();
1181
+ enableConfirm();
1182
+ const email = require("@working-theory/email");
1183
+ (prisma.callback.create as jest.Mock).mockRejectedValue(
1184
+ new Error("connection reset"),
1185
+ );
1186
+
1187
+ const fastify = require("fastify")({ logger: false }) as FastifyInstance;
1188
+ await fastify.register(leadCallbackRoute);
1189
+ await fastify.ready();
1190
+ try {
1191
+ const res = await postCallback(fastify);
1192
+ expect(res.statusCode).toBe(201);
1193
+ await flush();
1194
+
1195
+ // No row exists to carry a credential, so the confirm send must not run
1196
+ // — and the notification it would otherwise supersede must.
1197
+ expect(email.sendRequestConfirmEmail).not.toHaveBeenCalled();
1198
+ expect(email.sendLeadNotification).toHaveBeenCalledTimes(1);
1199
+ } finally {
1200
+ await fastify.close();
1201
+ }
1202
+ });
1203
+ });
1204
+
1205
+ // ── #6335 — the fixture's own guard ───────────────────────────────────────
1206
+ //
1207
+ // Every other case in this file proves a ROUTE behaves. This one proves the
1208
+ // FIXTURE does: that the shared prisma mock ships `workspace.findUnique`
1209
+ // with no implementation at all, so a suite gets "no such workspace" until
1210
+ // it says otherwise. Re-adding a permissive default — the #6203 shape this
1211
+ // issue removed, or any catch-all under another name — fails here, and
1212
+ // fails for that reason rather than as a side effect three suites away.
1213
+ //
1214
+ // Read from a FRESH module instance so the assertion is about what the file
1215
+ // DECLARES, not about whatever the suites above have programmed onto the
1216
+ // double they share.
1217
+ describe("#6335 — the shared prisma mock declares no workspace default", () => {
1218
+ it("workspace.findUnique ships with no implementation, and answers nothing", async () => {
1219
+ await jest.isolateModulesAsync(async () => {
1220
+ const fresh = (
1221
+ require("../../../lib/__mocks__/prisma.js") as {
1222
+ prisma: { workspace: { findUnique: jest.Mock } };
1223
+ }
1224
+ ).prisma;
1225
+
1226
+ expect(fresh.workspace.findUnique.getMockImplementation()).toBeUndefined();
1227
+ // A bare `jest.fn()` returns `undefined` rather than a promise, and
1228
+ // `await undefined` is `undefined` — which every resolver in
1229
+ // `lib/site.ts` reads as "no such workspace" and rejects. That is the
1230
+ // fail-closed default this fixture is supposed to have.
1231
+ const answer: unknown = fresh.workspace.findUnique({
1232
+ where: { id: "wksp_00000000-0000-7000-8000-0000000000ff" },
1233
+ });
1234
+ expect(answer).toBeUndefined();
1235
+ await expect(Promise.resolve(answer)).resolves.toBeUndefined();
1236
+ });
1237
+ });
1238
+ });