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
@@ -10,6 +10,18 @@
10
10
  *
11
11
  * PII boundary: only { siteId, kind, ip, persisted, emailKind } appear in logs.
12
12
  * Never log email, phone, name, or payload values.
13
+ *
14
+ * TENANCY (#6203). `input.siteId` is the caller's RESOLVED workspace id —
15
+ * `routes/leads/index.ts` resolves it server-side before calling `routeLead`
16
+ * and never a client-supplied value (see that file's header). `Lead` gets a
17
+ * `workspaceId` stamp of the same value, and since #6374 so do `Subscriber`
18
+ * and `Callback` — both gained a required `workspaceId` column in the fence
19
+ * fan-out, stamped from the same resolved id by the three `create` sites
20
+ * below. (This line previously said those two models had no such column;
21
+ * that stopped being true when the keystone migration landed, and the code
22
+ * under it already contradicted it.) The one READ in this file — the
23
+ * duplicate-subscriber lookup on the P2002 arm — is scoped by it too
24
+ * (#6257).
13
25
  */
14
26
 
15
27
  import { randomUUID } from "node:crypto";
@@ -23,6 +35,7 @@ import { appendLeadRecord } from "../lib/lead-capture-store.js";
23
35
  import { prisma } from "../lib/prisma.js";
24
36
 
25
37
  import { enrichLead } from "./enrichment-shim.js";
38
+ import { requestConfirmSupersedesNotification } from "./request-confirm.js";
26
39
 
27
40
  // ─── Types ────────────────────────────────────────────────────────────────────
28
41
 
@@ -103,6 +116,23 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
103
116
  const row = (await prisma.lead.create({
104
117
  data: {
105
118
  siteId: input.siteId,
119
+ // #6203/#6254 — the isolation key, equal to `siteId` by
120
+ // construction. `input.siteId` is the RESOLVED workspace id at
121
+ // EVERY `routeLead(kind: "submit")` caller now — the primary route
122
+ // (`routes/leads/index.ts`, never reads it from the body — see that
123
+ // file's header) and the three service-layer callers
124
+ // (`flow-engine.ts`'s `completeSubmission`,
125
+ // `recommender-capture.ts`'s `captureRecommenderChoice`,
126
+ // `project-listings.ts`'s `submitProjectListingDraft`) all resolve
127
+ // the site alias before calling in (#6254 AC-6/AC-10). `workspaceId`
128
+ // is NOT NULL as of
129
+ // database/migrations/20260922120000_tenancy_fence, so this can no
130
+ // longer stamp `null` for an unresolved alias the way it once did
131
+ // (spec D2's old rule) — a caller that somehow still passed one
132
+ // would instead hit the pre-existing `<table>_workspace_id_format`
133
+ // CHECK loudly, via the AC-11 re-raise below, rather than landing a
134
+ // silently-unfenced row.
135
+ workspaceId: input.siteId,
106
136
  source: input.source,
107
137
  email: input.email,
108
138
  payload: input.payload as Prisma.InputJsonValue,
@@ -117,6 +147,13 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
117
147
  const row = (await prisma.subscriber.create({
118
148
  data: {
119
149
  siteId: input.siteId,
150
+ // #6374 — the isolation key, equal to `siteId` by construction,
151
+ // exactly as `lead.create` above stamps it. `input.siteId` is the
152
+ // RESOLVED workspace id at every `routeLead` caller (see the
153
+ // `submit` branch's own note); a caller that somehow passed a raw
154
+ // alias would hit `subscribers_workspace_id_format` loudly rather
155
+ // than landing a silently-unfenced row. Never from a request body.
156
+ workspaceId: input.siteId,
120
157
  email: input.email,
121
158
  source: input.source,
122
159
  },
@@ -131,8 +168,15 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
131
168
  "code" in err &&
132
169
  (err as { code: string }).code === "P2002"
133
170
  ) {
171
+ // #6257 — the duplicate lookup is scoped by the same resolved
172
+ // workspace id the `create` above stamps (`input.siteId` is the
173
+ // RESOLVED id at every caller — see the `submit` branch's note), so a
174
+ // P2002 on another tenant's row can never resolve to its id.
134
175
  const existing = (await prisma.subscriber.findUnique({
135
- where: { siteId_email: { siteId: input.siteId, email: input.email } },
176
+ where: {
177
+ siteId_email: { siteId: input.siteId, email: input.email },
178
+ workspaceId: input.siteId,
179
+ },
136
180
  select: { id: true },
137
181
  })) as DbRow | null;
138
182
  const existingId = existing?.id ?? "duplicate-no-id";
@@ -152,6 +196,10 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
152
196
  const row = (await prisma.callback.create({
153
197
  data: {
154
198
  siteId: input.siteId,
199
+ // #6374 — the isolation key, equal to `siteId` by construction; same
200
+ // resolution guarantee and same loud-failure posture as the two
201
+ // branches above. Never from a request body (AC-6).
202
+ workspaceId: input.siteId,
155
203
  name: input.name,
156
204
  phone: input.phone,
157
205
  ...(input.preferredTime !== undefined && {
@@ -164,6 +212,56 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
164
212
  return { ok: true, id: row.id, source: "db", skipEmail: false };
165
213
  }
166
214
 
215
+ // ─── Constraint-violation detection (#6254 AC-11) ──────────────────────────────
216
+
217
+ /**
218
+ * SQLSTATE class 23 — "integrity constraint violation" — covers a NOT NULL
219
+ * (23502), a CHECK (23514), a unique (23505) or a foreign-key (23503)
220
+ * violation. Two of Prisma's own mapped codes correspond to a class-23
221
+ * violation on a `.create()` call: P2002 (unique) and P2011 (null
222
+ * constraint) — both `PrismaClientKnownRequestError`s Prisma classifies
223
+ * from its OWN schema knowledge. The new `<table>_workspace_site_agree`
224
+ * CHECK (database/migrations/20260922120000_tenancy_fence) has no such
225
+ * schema-level representation — Prisma cannot classify a raw CHECK it does
226
+ * not know about, so the raw Postgres SQLSTATE is read defensively from
227
+ * wherever the error actually carries it: `error.meta.code` (some engine/
228
+ * adapter combinations thread it through there) or the error message text
229
+ * (every Postgres client library's message includes the SQLSTATE and/or the
230
+ * words "constraint"/"null value" verbatim).
231
+ */
232
+ // P2000 (value too long) is deliberately excluded — a data-length validation
233
+ // error, not a SQLSTATE class-23 integrity-constraint violation (security
234
+ // review finding, PR #6360): it doesn't correspond to 23502/23503/23505/
235
+ // 23514, so treating it as one would take this path for a case AC-11 does
236
+ // not target.
237
+ const PRISMA_CLASS_23_CODES = new Set(["P2002", "P2003", "P2004", "P2011"]);
238
+
239
+ function isConstraintViolation(error: unknown): boolean {
240
+ if (error == null || typeof error !== "object") return false;
241
+
242
+ const code = (error as { code?: unknown }).code;
243
+ if (typeof code === "string" && PRISMA_CLASS_23_CODES.has(code)) return true;
244
+
245
+ const metaCode = (error as { meta?: { code?: unknown } }).meta?.code;
246
+ if (typeof metaCode === "string" && /^23\d{3}$/.test(metaCode)) return true;
247
+
248
+ const message = (error as { message?: unknown }).message;
249
+ if (typeof message === "string") {
250
+ // Labeled SQLSTATE only — "code: '23514'", "(SQLSTATE 23502)", etc. A
251
+ // bare `\b23\d{3}\b` anywhere in the message (security review finding,
252
+ // PR #6360) would also match a coincidental 5-digit number in a genuine
253
+ // outage's own text (a port, a byte count, a timeout in ms), stealing
254
+ // that case away from the file fallback it should still take.
255
+ if (/\b(?:sqlstate|code)[:\s]*['"]?23\d{3}['"]?/i.test(message)) return true;
256
+ if (/violates (check|not-null|unique|foreign key) constraint/i.test(message)) {
257
+ return true;
258
+ }
259
+ if (/null value in column/i.test(message)) return true;
260
+ }
261
+
262
+ return false;
263
+ }
264
+
167
265
  // ─── Email ────────────────────────────────────────────────────────────────────
168
266
 
169
267
  type EmailResult =
@@ -174,7 +272,38 @@ type EmailResult =
174
272
  async function sendEmail(
175
273
  input: RouteLeadInput,
176
274
  id: string,
275
+ persisted: "db" | "file",
177
276
  ): Promise<EmailResult> {
277
+ // Request-and-confirm SUPERSEDES this notification for a callback (#5970
278
+ // D6). AC-1 requires the tenant to receive exactly one email, and the
279
+ // confirm message carries strictly more than this one does — the same
280
+ // request details, plus the link that confirms a time and a price. Sending
281
+ // both would be two; sending this one and not that one would lose the
282
+ // feature. `submit` and `subscribe` are untouched.
283
+ //
284
+ // The predicate lives in request-confirm.ts and is read by both sides, so
285
+ // the two cannot both fire. It does NOT by itself rule out zero — see that
286
+ // function's header for what the coupling does and does not guarantee, and
287
+ // for the `persisted` term below, which is what closed the one zero case
288
+ // that was reachable in production. With the feature unconfigured — the
289
+ // default, and what every existing leads suite runs under — this is false
290
+ // and every byte below is reached as before.
291
+ // `persisted === "db"` is load-bearing, not decoration (#5970 security
292
+ // review round 3, M1). The confirm message can only supersede this one if it
293
+ // is actually going to be sent, and it can only be sent against a ROW: the
294
+ // credential is minted by an `updateMany` on `callbacks`. On the FILE
295
+ // FALLBACK path — a DB outage, an unset DATABASE_URL, an exhausted pool —
296
+ // there is no row, so suppressing here would have produced ZERO operator
297
+ // emails for every callback captured during the outage, on the one surface
298
+ // whose whole purpose is that an outage must not lose a lead.
299
+ if (
300
+ input.kind === "callback" &&
301
+ persisted === "db" &&
302
+ requestConfirmSupersedesNotification()
303
+ ) {
304
+ return { skipped: true, reason: "request_confirm" };
305
+ }
306
+
178
307
  const to = process.env.AILK_LEAD_NOTIFICATION_TO;
179
308
  const from = process.env.AILK_LEAD_NOTIFICATION_FROM;
180
309
 
@@ -265,6 +394,36 @@ export async function routeLead(
265
394
  skipEmail = dbResult.skipEmail;
266
395
  skipReason = dbResult.skipReason;
267
396
  } catch (dbErr) {
397
+ // #6254 AC-11 (spec D6, SEC-02) — a constraint violation is OUR OWN
398
+ // derivation producing a row Postgres itself refuses (an unresolved
399
+ // workspaceId hitting NOT NULL, or a site<->workspace disagreement
400
+ // hitting the new equality CHECK) — a bug to surface, never an outage
401
+ // to degrade through. Falling back here would return a silent 201 while
402
+ // the Lead never lands in Postgres, invisible to the Inbox forever —
403
+ // the exact silent data loss AC-7 exists to close, merely relocated
404
+ // onto this never-gate surface.
405
+ //
406
+ // A DELIBERATE error response, not a bare re-raise (spec § 7's
407
+ // api-versioning waiver is conditioned on exactly this: "a documented
408
+ // error body, never a bare re-raise surfacing as an unhandled throw").
409
+ // `persist_failed` already maps to a 503 with a documented body
410
+ // (`routes/leads/index.ts`) — the SAME shape a genuine DB-outage
411
+ // failure (both DB and file fail) already returns, so this adds no new
412
+ // response shape, only a new path that reaches the existing one
413
+ // WITHOUT first trying — and losing time to — a file write for an
414
+ // error the file fallback can never fix.
415
+ //
416
+ // A GENUINE infrastructure failure (an unreachable DB, an exhausted
417
+ // pool, an unset DATABASE_URL) is NOT a constraint violation and still
418
+ // takes the file fallback below, unchanged.
419
+ if (isConstraintViolation(dbErr)) {
420
+ const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
421
+ return {
422
+ ok: false,
423
+ error: { kind: "persist_failed", message: `db: ${dbMsg}` },
424
+ };
425
+ }
426
+
268
427
  // 5. File fallback
269
428
  // Include a UUID suffix so concurrent fallbacks produce unique IDs even
270
429
  // within the same millisecond. Format still matches /^[a-z0-9_-]{8,64}$/i.
@@ -331,7 +490,7 @@ export async function routeLead(
331
490
  if (skipEmail) {
332
491
  emailResult = { skipped: true, reason: skipReason ?? "skipped" };
333
492
  } else {
334
- emailResult = await sendEmail(input, id!);
493
+ emailResult = await sendEmail(input, id!, persisted!);
335
494
  }
336
495
 
337
496
  return { ok: true, id: id!, persisted, email: emailResult };
@@ -0,0 +1,284 @@
1
+ /**
2
+ * @file leads-inbox.ts
3
+ * @description The owner-side READ over captured leads (#5678) — the other
4
+ * half of `lead-routing.ts`, which only ever wrote them.
5
+ *
6
+ * Scope is resolved server-side and never supplied by the caller: the reader's
7
+ * identity is `request.userId`, their workspaces come from
8
+ * `owner-workspaces.ts`'s `resolveOwnerWorkspaces` — the ONE site→workspace
9
+ * resolution in this app, shared with `lead-promotion.ts` — and every query
10
+ * filters `siteId ∈ workspaceIds`.
11
+ * There is no site/org parameter to widen, so cross-tenant enumeration is
12
+ * structurally impossible rather than merely checked — the same posture
13
+ * `routes/waitlist-experiments/list.ts` takes.
14
+ *
15
+ * The scope is the UNION of every workspace the caller owns or administers,
16
+ * across every organization they belong to — not one active tenant, the way
17
+ * `waitlist-dashboard.ts` scopes to `tenant.workspaceId`. That difference is
18
+ * deliberate and structural: this route registers outside the tenant scope
19
+ * (it must work on a build with no multi-tenant module), so there is no
20
+ * active tenant to scope to. `Membership` is an organization-level grant, so
21
+ * the union stays inside what the caller may already see. When a workspace
22
+ * switcher reaches this surface, it is the thing that will narrow it.
23
+ *
24
+ * ## What it takes for this to return anything
25
+ *
26
+ * Two preconditions, neither of which this file can assert:
27
+ *
28
+ * 1. `resolveOwnerWorkspaces` must yield a workspace. On a build with no
29
+ * `multi-tenant` module it yields none — see its own header, and #5677,
30
+ * which made that a deliberate scope limit rather than a statement that
31
+ * there is nothing to resolve.
32
+ * 2. The `siteId` the leads were WRITTEN under must equal a workspace id,
33
+ * and there are TWO independent sources of it — both must agree with the
34
+ * workspace, and neither is `currentSiteId()` on the primary path:
35
+ *
36
+ * - The three POST capture routes take `siteId` from the REQUEST BODY
37
+ * (`routes/leads/index.ts`), which the capture sections declare in
38
+ * content and default to `"default"` (`ContactForm`, `Newsletter`,
39
+ * `StepFlow` — `ext.siteId ?? "default"`). Neither
40
+ * `routes/leads/index.ts` nor `lead-routing.ts` calls
41
+ * `currentSiteId()` at all.
42
+ * - The recommender's capture path (`routes/flows/recommender.ts`) is
43
+ * the one that DOES use `currentSiteId()` (`lib/site.ts`,
44
+ * `AILK_SITE_ID ?? "default"`).
45
+ *
46
+ * A workspace id is `wksp_<uuidv7>`, so `siteId ∈ workspaceIds` only
47
+ * matches when the content-declared value — and, for recommender leads,
48
+ * `AILK_SITE_ID` — is set to it. This is the same convention
49
+ * `waitlist-dashboard.ts` already relies on ("owner scope =
50
+ * `siteId == tenant.workspaceId`"); it is a deployment and content fact,
51
+ * not something either resolver can supply. Naming only `AILK_SITE_ID`
52
+ * here would send an operator to the wrong knob for every contact-form,
53
+ * newsletter and callback lead (PR #5705 security re-review, finding A).
54
+ *
55
+ * Both failures are EMPTY, never wide: the inbox shows no leads rather than
56
+ * someone else's. Raised by the PR #5705 security review, finding 2.
57
+ *
58
+ * PII boundary: this service returns lead PII by design, so it returns the
59
+ * NARROWEST row the inbox view renders — `id`, `name`, `email`, `source`,
60
+ * `createdAt` — and nothing else. `payload`'s remaining free-form keys
61
+ * (message, phone, and whatever else a site's form declares), `siteId`,
62
+ * `promotedUserId` and `promotedAt` are never selected out of the row that
63
+ * leaves this file, and the one value lifted out of `payload` is truncated
64
+ * (see `MAX_NAME_LENGTH`). Logs carry no lead field at all.
65
+ */
66
+
67
+ import type { Prisma } from "@working-theory/database";
68
+ import type { Id } from "@working-theory/ids";
69
+
70
+ import { prisma } from "../lib/prisma.js";
71
+
72
+ import { resolveOwnerWorkspaces } from "./owner-workspaces.js";
73
+
74
+ // ─── Types ────────────────────────────────────────────────────────────────────
75
+
76
+ /** One row of the inbox — exactly the five values the table renders. */
77
+ export type LeadInboxRow = {
78
+ /** The lead id: the row key, and the cursor for the page after this one. */
79
+ id: string;
80
+ /**
81
+ * The contact's name, lifted from `payload.name` when the site's form
82
+ * captured one. `null` otherwise — `payload` is a free-form record
83
+ * (`boundedLeadPayload`), so a site whose form declares no name field has
84
+ * none to show.
85
+ */
86
+ name: string | null;
87
+ email: string;
88
+ /** Which capture produced the lead (`"web"`, `"conversational-intake"`, …). */
89
+ source: string;
90
+ /** ISO 8601. */
91
+ createdAt: string;
92
+ };
93
+
94
+ export type LeadInboxPage = {
95
+ items: LeadInboxRow[];
96
+ /** The `cursor` for the next page, or `null` when this is the last one. */
97
+ nextCursor: string | null;
98
+ };
99
+
100
+ export type ListLeadsErrorKind = "invalid_cursor" | "internal_error";
101
+ export type ListLeadsResult =
102
+ | { ok: true; value: LeadInboxPage }
103
+ | { ok: false; error: { kind: ListLeadsErrorKind; message: string } };
104
+
105
+ /**
106
+ * Reads require `owner` or `admin` in the organization the workspace belongs
107
+ * to — the inbox exposes contact PII, so membership alone is not
108
+ * authorization. Same floor, and the same reason, as
109
+ * `WAITLIST_DASHBOARD_ROLES` (`waitlist-dashboard.ts`); declared here rather
110
+ * than imported because that file is a `signal-collectors` slice the strip
111
+ * removes and this one ships at every level.
112
+ *
113
+ * Exported so `inbox-messages.ts` (#6204's tenant-scoped read/write, moved
114
+ * off this base file's own dependency edge — see that file's header) enforces
115
+ * the SAME floor rather than a copy.
116
+ */
117
+ export const LEAD_INBOX_ROLES: ReadonlySet<string> = new Set(["owner", "admin"]);
118
+
119
+ /**
120
+ * The ONLY columns read out of the Lead table on this path.
121
+ *
122
+ * `payload` is here because Prisma cannot `select` a JSON sub-path and
123
+ * `name` lives inside it — so the whole blob crosses the DB→API boundary for
124
+ * each of the `limit + 1` rows and is narrowed in this process, not by the
125
+ * query. Nothing but `payload.name` leaves this file (see `readName`), but the
126
+ * READ is the full record: the memory cost of a page is `(limit + 1) ×
127
+ * payload size`, independent of what is returned. Bounded above by Fastify's
128
+ * 1 MiB body limit on the write side and by `MAX_LIMIT` on this one.
129
+ */
130
+ const LEAD_INBOX_SELECT = {
131
+ id: true,
132
+ email: true,
133
+ source: true,
134
+ payload: true,
135
+ createdAt: true,
136
+ } as const satisfies Prisma.LeadSelect;
137
+
138
+ // ─── Row shaping ──────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * The rendered `name` is truncated to this many characters. The write side
142
+ * bounds `payload` at 64 KEYS and nothing else (`boundedLeadPayload`,
143
+ * `packages/validation/src/schemas/leads.ts`) — no per-value length — so a
144
+ * `name` arrives with only Fastify's 1 MiB body limit above it, while every
145
+ * other rendered field is bounded at the schema (`email` 254, `source` 64).
146
+ *
147
+ * That gap is reachable by an anonymous caller: `siteId` is content-declared
148
+ * and shipped to the browser by the capture forms, `POST /v1/leads/submit`
149
+ * performs no ownership check on it, and the leads-scope rate limit keys on
150
+ * `req.ip` under instance-wide `trustProxy` (which `routes/leads/index.ts`
151
+ * documents as attacker-influenceable). Fifty-one ~900 KB names would
152
+ * otherwise be a ~45 MB response — and, because the order is `createdAt desc`
153
+ * and this PR ships no delete surface, permanently page one. Truncating here
154
+ * is what makes the inbox's size a function of the page size rather than of
155
+ * what a stranger typed. Raised by the PR #5705 security review, finding 1.
156
+ *
157
+ * 200 is the same order of magnitude as `callbackSchema`'s own declared
158
+ * `name` bound (128) with room for a long real one. Bounding `payload`'s
159
+ * string VALUES at the write boundary would fix this for every future reader,
160
+ * but that changes what the three POST routes accept, which this issue's
161
+ * scope fence forbids — so the bound lives on the read.
162
+ */
163
+ const MAX_NAME_LENGTH = 200;
164
+
165
+ /**
166
+ * `payload` is JSON of a shape the site's own form decides, so reading one
167
+ * key out of it is a boundary read: anything that is not a string is `null`,
168
+ * and a string is truncated (see `MAX_NAME_LENGTH`).
169
+ *
170
+ * Exported so `inbox-messages.ts` reuses this exact narrowing — never a copy
171
+ * (see that file's header for why the shaping stays here).
172
+ */
173
+ export function readName(payload: Prisma.JsonValue): string | null {
174
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
175
+ return null;
176
+ }
177
+ const name = (payload as Record<string, unknown>)["name"];
178
+ return typeof name === "string" ? name.slice(0, MAX_NAME_LENGTH) : null;
179
+ }
180
+
181
+ // ─── Main export ──────────────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * List the leads the caller's own sites captured, newest first.
185
+ *
186
+ * Pagination is the cursor shape `waitlist-experiments/signups` already
187
+ * established in this codebase: `(createdAt desc, id desc)`, `take: limit + 1`
188
+ * to decide `hasMore`, and a `cursor` that is the previous page's last row id.
189
+ * A supplied cursor that does not resolve WITHIN the caller's scope is a
190
+ * client input error (`invalid_cursor` → 400), never a silent fall back to
191
+ * page 1 at THIS layer — the web inbox re-reads page one when a URL-supplied
192
+ * cursor 400s, which is its own rendering choice and leaves this contract
193
+ * intact (a foreign cursor and a nonexistent one stay indistinguishable
194
+ * either way) — and never a probe that distinguishes "another site's lead" from
195
+ * "no such lead", because the scope filter runs on the cursor lookup too.
196
+ */
197
+ export async function listOwnedLeads(params: {
198
+ userId: Id<"user">;
199
+ cursor?: string | undefined;
200
+ limit: number;
201
+ }): Promise<ListLeadsResult> {
202
+ const workspaces = await resolveOwnerWorkspaces(params.userId);
203
+ if (!workspaces.ok) {
204
+ return {
205
+ ok: false,
206
+ error: { kind: "internal_error", message: workspaces.error.message },
207
+ };
208
+ }
209
+
210
+ const siteIds = workspaces.value
211
+ .filter((workspace) => LEAD_INBOX_ROLES.has(workspace.role))
212
+ .map((workspace) => workspace.id);
213
+
214
+ // No workspace the caller owns or administers means no lead can match, so
215
+ // there is nothing to query. An empty page is the honest answer — the same
216
+ // zero-workspace no-op the promotion path returns.
217
+ if (siteIds.length === 0) return { ok: true, value: { items: [], nextCursor: null } };
218
+
219
+ let cursorLead: { createdAt: Date; id: string } | undefined;
220
+ if (params.cursor) {
221
+ cursorLead =
222
+ (await prisma.lead.findFirst({
223
+ where: { id: params.cursor, siteId: { in: siteIds } },
224
+ select: { id: true, createdAt: true },
225
+ })) ?? undefined;
226
+ if (!cursorLead) {
227
+ return {
228
+ ok: false,
229
+ error: {
230
+ kind: "invalid_cursor",
231
+ message: "cursor does not resolve within this scope",
232
+ },
233
+ };
234
+ }
235
+ }
236
+
237
+ const rows = await prisma.lead.findMany({
238
+ where: {
239
+ siteId: { in: siteIds },
240
+ ...(cursorLead
241
+ ? {
242
+ OR: [
243
+ { createdAt: { lt: cursorLead.createdAt } },
244
+ { createdAt: cursorLead.createdAt, id: { lt: cursorLead.id } },
245
+ ],
246
+ }
247
+ : {}),
248
+ },
249
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
250
+ take: params.limit + 1,
251
+ select: LEAD_INBOX_SELECT,
252
+ });
253
+
254
+ const hasMore = rows.length > params.limit;
255
+ const page = hasMore ? rows.slice(0, params.limit) : rows;
256
+
257
+ return {
258
+ ok: true,
259
+ value: {
260
+ items: page.map((lead) => ({
261
+ id: lead.id,
262
+ name: readName(lead.payload),
263
+ email: lead.email,
264
+ source: lead.source,
265
+ createdAt: lead.createdAt.toISOString(),
266
+ })),
267
+ nextCursor: hasMore ? (page[page.length - 1]?.id ?? null) : null,
268
+ },
269
+ };
270
+ }
271
+
272
+ // #6204's `listUnhandledMessages` / `markMessageHandled` — the tenant-scoped
273
+ // "Needs-you" read/write pair over `tenantDb(tenant).leads` — moved OFF this
274
+ // file to `./inbox-messages.js`. This file is a BASE-tier file every scaffold
275
+ // ships (this header's own claim); `tenantDb` (`../lib/tenant-db.js`) is a
276
+ // `multi-tenant`-slice file, and the paid-module exclusion closure
277
+ // (`packages/create-ailk/src/lib/import-graph.ts`) is backward — a base file
278
+ // that imports a paid seed is ITSELF added to the exclusion set, and so is
279
+ // everything that imports IT. Importing `tenantDb` from here once already
280
+ // pulled `server.ts`/`cli.ts`/`index.ts`/`vercel-handler.ts` out of a keyless
281
+ // scaffold (caught by `__tests__/paid-module-exclusions.test.ts`, #5678's own
282
+ // cascade recurring a third time). `readName()` and `LEAD_INBOX_ROLES` above
283
+ // stay here (both exported) and `inbox-messages.ts` imports them — the row
284
+ // shape and the role floor are the SAME constants, never a copy.
@@ -42,11 +42,18 @@ import {
42
42
  } from "@working-theory/validation";
43
43
 
44
44
  import { prisma } from "../lib/prisma.js";
45
- import { currentSiteId } from "../lib/site.js";
45
+ import { resolveSiteWorkspaceId } from "../lib/site.js";
46
46
 
47
47
  export async function listingConfigFor(siteId: string): Promise<SiteListingConfig> {
48
+ // #6257 — every caller hands this function an ALREADY-RESOLVED workspace id
49
+ // (a `ProjectListing` row's own `siteId`, an authenticated tenant's
50
+ // `workspaceId`, or `siteIdForPublicKey`'s return below), so `workspaceId`
51
+ // rides beside the unique `siteId` selector rather than being resolved a
52
+ // second time. `site_listing_configs_workspace_site_agree` makes the pair
53
+ // redundant by construction — which is the point: the tenant scope is now
54
+ // something the QUERY states, not something the caller is trusted to have.
48
55
  const row = await prisma.siteListingConfig.findUnique({
49
- where: { siteId },
56
+ where: { siteId, workspaceId: siteId },
50
57
  select: { data: true },
51
58
  });
52
59
  if (!row) return SITE_LISTING_CONFIG_DEFAULTS;
@@ -132,14 +139,18 @@ function warnOnDroppedField(
132
139
  * 1. A `SiteListingConfig` row whose `publicKey` matches. This is the
133
140
  * multi-tenant path and the only one that can name a site other than the
134
141
  * deployment's own.
135
- * 2. `AILK_LISTING_PUBLIC_KEY`, matched against the env, resolving to
136
- * `currentSiteId()`. This is the SINGLE-TENANT path: the default
137
- * `create-ailk` scaffold is one customer per deployment and has no
138
- * config row, so requiring one would mean a site could not accept an
139
- * application until an operator wrote a database row.
142
+ * 2. `AILK_LISTING_PUBLIC_KEY`, matched against the env, resolving to the
143
+ * RESOLVED workspace id for `currentSiteId()` (#6254 AC-6/AC-10 —
144
+ * `resolveSiteWorkspaceId()`, not the raw alias: `project_listings`
145
+ * now rejects a `site_id` that disagrees with `workspace_id`,
146
+ * database/migrations/20260922120000_tenancy_fence). This is the
147
+ * SINGLE-TENANT path: the default `create-ailk` scaffold is one
148
+ * customer per deployment and has no config row, so requiring one
149
+ * would mean a site could not accept an application until an operator
150
+ * wrote a database row.
140
151
  *
141
152
  * An unmatched key resolves to `null` and the route answers 400. It does NOT
142
- * fall through to `currentSiteId()`: a typo would otherwise silently file one
153
+ * fall through to the env path: a typo would otherwise silently file one
143
154
  * site's applications against another.
144
155
  *
145
156
  * The env comparison is a plain `===` on an opaque public identifier — there is
@@ -147,6 +158,13 @@ function warnOnDroppedField(
147
158
  * would imply a guarantee this value does not carry.
148
159
  */
149
160
  export async function siteIdForPublicKey(publicKey: string): Promise<string | null> {
161
+ // fence: global-key — `SiteListingConfig.publicKey`, a site's own public
162
+ // listing key, unique across every workspace. This is the query that
163
+ // RESOLVES the tenant: it is handed nothing but the key, and a workspace
164
+ // term here would have to come from the very answer it is computing. The
165
+ // key is not a credential (it ships in browser JavaScript by design, #5039)
166
+ // and holding it grants only the ability to open a draft; an unmatched key
167
+ // returns `null` and the route answers 400 rather than falling through.
150
168
  const row = await prisma.siteListingConfig.findUnique({
151
169
  where: { publicKey },
152
170
  select: { siteId: true },
@@ -154,7 +172,7 @@ export async function siteIdForPublicKey(publicKey: string): Promise<string | nu
154
172
  if (row) return row.siteId;
155
173
 
156
174
  const envKey = process.env["AILK_LISTING_PUBLIC_KEY"];
157
- if (envKey && envKey === publicKey) return currentSiteId();
175
+ if (envKey && envKey === publicKey) return resolveSiteWorkspaceId();
158
176
 
159
177
  return null;
160
178
  }