create-ailk 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +12248 -0
  3. package/component-catalog.md +193 -68
  4. package/dist/cli.js +29 -1
  5. package/dist/derive-architecture.js +5 -2
  6. package/dist/lib/api-boot-assertion.d.ts +46 -0
  7. package/dist/lib/api-boot-assertion.js +70 -0
  8. package/dist/lib/extract-module-bundle.d.ts +50 -2
  9. package/dist/lib/extract-module-bundle.js +223 -15
  10. package/dist/lib/fetch-guard.d.ts +28 -0
  11. package/dist/lib/fetch-guard.js +101 -0
  12. package/dist/lib/fetch-module.js +4 -0
  13. package/dist/lib/fetch-template.js +4 -0
  14. package/dist/lib/import-graph.d.ts +29 -0
  15. package/dist/lib/import-graph.js +140 -0
  16. package/dist/module-architecture.d.ts +60 -8
  17. package/dist/module-architecture.js +587 -242
  18. package/dist/parse-args.js +6 -2
  19. package/dist/programmatic.d.ts +10 -0
  20. package/dist/programmatic.js +134 -19
  21. package/dist/site-chrome.d.ts +80 -0
  22. package/dist/site-chrome.js +135 -0
  23. package/dist/surfaces.d.ts +21 -6
  24. package/dist/surfaces.js +209 -36
  25. package/dist/template-manifest.d.ts +88 -3
  26. package/dist/template-manifest.js +281 -29
  27. package/dist/template-names.d.ts +1 -1
  28. package/dist/template-names.js +2 -0
  29. package/package.json +10 -2
  30. package/site-templates/_base/ailk-template.json +6 -0
  31. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
  34. package/site-templates/marketing-site/ailk-template.json +11 -0
  35. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  36. package/site-templates/marketing-site/provenance.json +4 -0
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
  39. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
  41. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
  42. package/site-templates/minimalism-site/ailk-template.json +22 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  44. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  49. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  50. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  51. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  53. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  54. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  55. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  56. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  57. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
  58. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  60. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  65. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  68. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  69. package/site-templates/minimalism-site/provenance.json +4 -0
  70. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  71. package/site-templates/saas-product/ailk-template.json +16 -0
  72. package/site-templates/saas-product/provenance.json +10 -0
  73. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  74. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  75. package/site-templates/simple-service/ailk-template.json +10 -0
  76. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  77. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  78. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  79. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  80. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  81. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  82. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  83. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  84. package/site-templates/simple-service/provenance.json +4 -0
  85. package/templates/.claude/skills/README.md +16 -25
  86. package/templates/.env.example +102 -12
  87. package/templates/.github/workflows/ci.yml +48 -0
  88. package/templates/README.md +3 -1
  89. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  90. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  91. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  92. package/templates/apps/api/.env.example +13 -0
  93. package/templates/apps/api/CLAUDE.md +29 -2
  94. package/templates/apps/api/eslint.config.mjs +69 -0
  95. package/templates/apps/api/jest.config.cjs +3 -0
  96. package/templates/apps/api/package.json +7 -2
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  98. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
  99. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  100. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  101. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  102. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
  103. package/templates/apps/api/src/config/index.ts +62 -0
  104. package/templates/apps/api/src/config/modules.ts +7 -3
  105. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
  106. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  107. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  108. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  109. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  110. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  111. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  112. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  113. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  114. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  115. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  116. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  117. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  118. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  119. package/templates/apps/api/src/lib/site.ts +208 -9
  120. package/templates/apps/api/src/lib/stripe.ts +66 -0
  121. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
  122. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
  123. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  124. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  125. package/templates/apps/api/src/openapi/spec.ts +543 -2
  126. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  127. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  128. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  129. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  130. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  131. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  132. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  133. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  134. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  135. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  136. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  137. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  138. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  139. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  140. package/templates/apps/api/src/routes/content/index.ts +13 -73
  141. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  142. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  143. package/templates/apps/api/src/routes/content/search.ts +129 -0
  144. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  145. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  146. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  147. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  148. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  149. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  150. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  151. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  152. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  153. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  154. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  155. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  156. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  157. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  158. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  159. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  160. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  161. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  162. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  163. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  164. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  165. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  166. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  167. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  168. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  169. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  170. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  171. package/templates/apps/api/src/server.ts +521 -55
  172. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  173. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  174. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  175. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  176. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  177. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  178. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  179. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  180. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  181. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  182. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  183. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  184. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  185. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  186. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  187. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  188. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  189. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  190. package/templates/apps/api/src/services/audit-log.ts +668 -0
  191. package/templates/apps/api/src/services/consent.ts +18 -0
  192. package/templates/apps/api/src/services/default-tenant.ts +300 -0
  193. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  194. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  195. package/templates/apps/api/src/services/entitlements.ts +80 -0
  196. package/templates/apps/api/src/services/lead-promotion.ts +160 -0
  197. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  198. package/templates/apps/api/src/services/leads-inbox.ts +284 -0
  199. package/templates/apps/api/src/services/listing-config.ts +27 -9
  200. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  201. package/templates/apps/api/src/services/project-listings.ts +201 -6
  202. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  203. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  204. package/templates/apps/api/src/services/revalidation.ts +173 -0
  205. package/templates/apps/api/src/services/verified-email.ts +71 -0
  206. package/templates/apps/api/src/vercel-handler.ts +75 -35
  207. package/templates/apps/mcp/.env.example +17 -0
  208. package/templates/apps/mcp/CLAUDE.md +58 -25
  209. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  210. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  211. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  212. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  213. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  214. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  215. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  216. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  217. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  218. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  219. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  220. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  221. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  222. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  223. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  224. package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
  225. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  226. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  227. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  228. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  229. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  230. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  231. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  232. package/templates/apps/mcp/eslint.config.mjs +69 -0
  233. package/templates/apps/mcp/jest.config.cjs +13 -0
  234. package/templates/apps/mcp/package.json +8 -2
  235. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  236. package/templates/apps/mcp/src/config/modules.ts +10 -5
  237. package/templates/apps/mcp/src/http-transport.ts +102 -29
  238. package/templates/apps/mcp/src/index.ts +73 -11
  239. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  240. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  241. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  242. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  243. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  244. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  245. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  246. package/templates/apps/mcp/src/server.ts +61 -37
  247. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  248. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  249. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  250. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  251. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  252. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  253. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  254. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  255. package/templates/apps/mcp/src/tools/index.ts +9 -7
  256. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  257. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  258. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  259. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  260. package/templates/apps/web/.env.example +74 -8
  261. package/templates/apps/web/CLAUDE.md +46 -0
  262. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  263. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  264. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  265. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  266. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  267. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  268. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  269. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  270. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  271. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  272. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  273. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  274. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  275. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  276. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  277. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  278. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  279. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  280. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  281. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
  282. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
  283. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  284. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  285. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  286. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  287. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  288. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  289. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  290. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  291. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  292. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  293. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  294. package/templates/apps/web/app/[locale]/layout.tsx +308 -65
  295. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  296. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  297. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  298. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  299. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  300. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  301. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  302. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  303. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  304. package/templates/apps/web/auth.ts +407 -8
  305. package/templates/apps/web/components/google-analytics.tsx +18 -1
  306. package/templates/apps/web/eslint.config.mjs +69 -0
  307. package/templates/apps/web/jest.config.cjs +15 -19
  308. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  309. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  310. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  311. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
  312. package/templates/apps/web/lib/auth-email.ts +318 -0
  313. package/templates/apps/web/lib/auth-env.ts +85 -0
  314. package/templates/apps/web/lib/auth-routes.ts +55 -0
  315. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  316. package/templates/apps/web/lib/nav-config.ts +27 -7
  317. package/templates/apps/web/lib/server-api.ts +1 -1
  318. package/templates/apps/web/lib/site-chrome.ts +29 -0
  319. package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
  320. package/templates/apps/web/messages/en.json +216 -2
  321. package/templates/apps/web/next.config.mjs +20 -0
  322. package/templates/apps/web/package.json +8 -3
  323. package/templates/apps/web/sentry.client.config.ts +131 -0
  324. package/templates/apps/web/sentry.edge.config.ts +72 -0
  325. package/templates/apps/web/sentry.server.config.ts +72 -0
  326. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  327. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  328. package/templates/apps/web/src/lib/routes.ts +13 -18
  329. package/templates/brand_assets/README.md +24 -0
  330. package/templates/content/en/AboutPage/about.mdx +160 -15
  331. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  332. package/templates/content/en/ContactPage/contact.mdx +90 -13
  333. package/templates/content/en/FAQPage/faq.mdx +62 -22
  334. package/templates/content/en/HomePage/home.mdx +244 -20
  335. package/templates/content/en/LandingPage/blog.mdx +83 -0
  336. package/templates/content/en/LandingPage/features.mdx +103 -0
  337. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  338. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  339. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  340. package/templates/database/CHANGELOG.md +149 -0
  341. package/templates/database/README.md +10 -3
  342. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  343. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  344. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  345. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  346. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  347. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  348. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  349. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  350. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  351. package/templates/database/__tests__/seed.test.ts +1 -1
  352. package/templates/database/__tests__/tenancy.test.ts +141 -0
  353. package/templates/database/config/schema.prisma +79 -2
  354. package/templates/database/content/schema.prisma +87 -11
  355. package/templates/database/eslint.config.mjs +69 -0
  356. package/templates/database/inbox/schema.prisma +452 -9
  357. package/templates/database/jest.config.cjs +5 -3
  358. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  359. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  360. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  361. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  362. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  363. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  364. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  365. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  366. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  367. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  368. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  369. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  370. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  371. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  372. package/templates/database/package.json +7 -3
  373. package/templates/database/seed.ts +1 -1
  374. package/templates/database/tenancy.ts +86 -1
  375. package/templates/database/tsconfig.json +1 -0
  376. package/templates/package.json +1 -14
  377. package/versions.json +52 -0
  378. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  379. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  380. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  381. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  382. package/templates/apps/api/src/routes/content/create.ts +0 -115
  383. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  384. package/templates/apps/api/src/routes/content/update.ts +0 -112
  385. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  386. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  387. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  388. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  389. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  390. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  391. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  392. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  393. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  394. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  395. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
  396. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @file list.test.ts
3
+ * Route-level inject tests for `GET /v1/leads` (#5678).
4
+ *
5
+ * The scoping proof lives one layer down, in
6
+ * `src/services/__tests__/leads-inbox.test.ts` — it seeds two sites against a
7
+ * realistically filtering prisma mock and asserts a caller for one reads none
8
+ * of the other's. This file covers what only the HTTP boundary can show: that
9
+ * an unauthenticated caller is refused before the service is ever reached,
10
+ * that the scope cannot be widened from the query string, and that the
11
+ * envelope and the 400 mapping are what `apps/api/CLAUDE.md` specifies.
12
+ */
13
+
14
+ // eslint-disable-next-line no-restricted-syntax -- the service is this route's own collaborator; mocking it keeps these tests on the HTTP boundary (the scoping proof is the service's own test).
15
+ jest.mock("../../../services/leads-inbox.js", () => ({
16
+ listOwnedLeads: jest.fn(),
17
+ }));
18
+
19
+ import type { FastifyInstance } from "fastify";
20
+
21
+ import { listOwnedLeads } from "../../../services/leads-inbox.js";
22
+ import { leadListRoute } from "../list";
23
+
24
+ const listOwnedLeadsMock = listOwnedLeads as jest.Mock;
25
+
26
+ const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
27
+ const PAGE = {
28
+ items: [
29
+ {
30
+ id: "lead_00000000-0000-7000-8000-0000000000d1",
31
+ name: "Ada Lovelace",
32
+ email: "ada@example.com",
33
+ source: "web",
34
+ createdAt: "2026-09-15T12:00:00.000Z",
35
+ },
36
+ ],
37
+ nextCursor: null,
38
+ };
39
+
40
+ /**
41
+ * Builds the route with `request.userId` pre-set, standing in for the
42
+ * `authPreHandlerPlugin` that decorates it in `server.ts`'s `authScope`.
43
+ * `userId: undefined` is the anonymous case — the same state the decorator
44
+ * leaves behind when no session resolves.
45
+ */
46
+ async function buildApp(userId: string | undefined): Promise<FastifyInstance> {
47
+ const fastify = require("fastify")();
48
+ fastify.decorateRequest("userId", undefined);
49
+ fastify.addHook("onRequest", (request: { userId?: string }, _reply: unknown, done: () => void) => {
50
+ request.userId = userId;
51
+ done();
52
+ });
53
+ await fastify.register(leadListRoute);
54
+ return fastify;
55
+ }
56
+
57
+ describe("GET /v1/leads", () => {
58
+ let app: FastifyInstance;
59
+
60
+ beforeEach(() => {
61
+ jest.clearAllMocks();
62
+ listOwnedLeadsMock.mockResolvedValue({ ok: true, value: PAGE });
63
+ });
64
+
65
+ afterEach(async () => {
66
+ await app.close();
67
+ });
68
+
69
+ it("401s an unauthenticated caller without touching the read service", async () => {
70
+ app = await buildApp(undefined);
71
+
72
+ const res = await app.inject({ method: "GET", url: "/v1/leads" });
73
+
74
+ expect(res.statusCode).toBe(401);
75
+ expect(res.json()).toEqual({
76
+ ok: false,
77
+ error: { code: "unauthorized", message: "Authentication required" },
78
+ });
79
+ expect(listOwnedLeadsMock).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("returns the page in the { ok: true, data } envelope", async () => {
83
+ app = await buildApp(USER_ID);
84
+
85
+ const res = await app.inject({ method: "GET", url: "/v1/leads" });
86
+
87
+ expect(res.statusCode).toBe(200);
88
+ expect(res.json()).toEqual({ ok: true, data: PAGE });
89
+ expect(listOwnedLeadsMock).toHaveBeenCalledWith({
90
+ userId: USER_ID,
91
+ cursor: undefined,
92
+ limit: 50,
93
+ });
94
+ });
95
+
96
+ it("ignores a caller-supplied siteId — the scope cannot be widened from the wire", async () => {
97
+ app = await buildApp(USER_ID);
98
+
99
+ const res = await app.inject({
100
+ method: "GET",
101
+ url: "/v1/leads?siteId=someone-elses-site&workspaceId=another",
102
+ });
103
+
104
+ expect(res.statusCode).toBe(200);
105
+ // The service is called with the caller's identity and the paging
106
+ // parameters only — no site, org or workspace value reaches it.
107
+ expect(listOwnedLeadsMock).toHaveBeenCalledWith({
108
+ userId: USER_ID,
109
+ cursor: undefined,
110
+ limit: 50,
111
+ });
112
+ });
113
+
114
+ it("400s a malformed cursor before any read", async () => {
115
+ app = await buildApp(USER_ID);
116
+
117
+ const res = await app.inject({ method: "GET", url: "/v1/leads?cursor=not-a-lead-id" });
118
+
119
+ expect(res.statusCode).toBe(400);
120
+ expect(res.json().error.code).toBe("validation_failed");
121
+ expect(listOwnedLeadsMock).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("400s a limit above the 200 ceiling", async () => {
125
+ app = await buildApp(USER_ID);
126
+
127
+ const res = await app.inject({ method: "GET", url: "/v1/leads?limit=1000" });
128
+
129
+ expect(res.statusCode).toBe(400);
130
+ expect(listOwnedLeadsMock).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("maps the service's invalid_cursor to a 400, not a 404 or a silent page 1", async () => {
134
+ app = await buildApp(USER_ID);
135
+ listOwnedLeadsMock.mockResolvedValue({
136
+ ok: false,
137
+ error: { kind: "invalid_cursor", message: "cursor does not resolve within this scope" },
138
+ });
139
+
140
+ const res = await app.inject({
141
+ method: "GET",
142
+ url: "/v1/leads?cursor=lead_00000000-0000-7000-8000-0000000000d9",
143
+ });
144
+
145
+ expect(res.statusCode).toBe(400);
146
+ expect(res.json()).toEqual({
147
+ ok: false,
148
+ error: {
149
+ code: "validation_failed",
150
+ message: "cursor does not resolve within this scope",
151
+ },
152
+ });
153
+ });
154
+
155
+ it("500s an internal error without surfacing its detail", async () => {
156
+ app = await buildApp(USER_ID);
157
+ listOwnedLeadsMock.mockResolvedValue({
158
+ ok: false,
159
+ error: { kind: "internal_error", message: "connect ECONNREFUSED 127.0.0.1:5432" },
160
+ });
161
+
162
+ const res = await app.inject({ method: "GET", url: "/v1/leads" });
163
+
164
+ expect(res.statusCode).toBe(500);
165
+ expect(res.json()).toEqual({
166
+ ok: false,
167
+ error: { code: "internal_error", message: "Failed to read leads" },
168
+ });
169
+ expect(res.body).not.toContain("ECONNREFUSED");
170
+ });
171
+ });
@@ -5,16 +5,32 @@
5
5
  * POST /v1/leads/submit — lead submission (contact form / inquiry)
6
6
  * POST /v1/leads/subscribe — newsletter subscription
7
7
  * POST /v1/leads/callback — callback request (phone-back)
8
+ *
9
+ * TENANT RESOLUTION (#6203, spec §10.4 operator ruling — reverses this
10
+ * file's former byte-unchanged posture). `siteId` used to be taken VERBATIM
11
+ * from the request body (`body["siteId"] as string`) — an anonymous, no-
12
+ * ownership-check value (`packages/validation/src/schemas/leads.ts`'s
13
+ * `safeSiteIdString` permits a `wksp_<uuidv7>`-shaped literal). Every
14
+ * capture kind now resolves its tenant SERVER-SIDE, via
15
+ * `resolveSiteWorkspaceId()` (`lib/site.ts`) — the SAME alias -> workspace
16
+ * resolution `currentSiteId()`/`AILK_SITE_ID` already drive throughout this
17
+ * app, unconditional on the content adapter — and a client-posted `siteId`
18
+ * is IGNORED entirely: it can no longer place a row anywhere. An unresolved
19
+ * result (no DATABASE_URL, or the alias names no workspace this database
20
+ * holds) is a 503 before any write is attempted — never a guess. See
21
+ * `lead-routing.ts`'s own header for the persistence-side half.
8
22
  */
9
23
 
10
24
  import { track } from "@working-theory/analytics/server";
11
25
  import type { FastifyPluginAsync } from "fastify";
12
26
 
27
+ import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
13
28
  import {
14
29
  type LeadKind,
15
30
  type RouteLeadInput,
16
31
  routeLead,
17
32
  } from "../../services/lead-routing.js";
33
+ import { sendRequestConfirmLink } from "../../services/request-confirm.js";
18
34
 
19
35
  /**
20
36
  * Read a named string field from the body, falling back to the same-named
@@ -42,17 +58,22 @@ function liftString(
42
58
  * `email`/`name`/`phone` out of `payload` (see liftString above) reads by
43
59
  * explicit field name only — it does not spread `payload`'s other keys, so
44
60
  * the same discipline holds. `payload` itself is passed through unchanged.
61
+ *
62
+ * `siteId` is the RESOLVED `workspaceId` (#6203) — never `body["siteId"]`.
63
+ * A body-supplied `siteId` is read nowhere in this function; a client that
64
+ * posts one has it silently ignored, never honoured.
45
65
  */
46
66
  function buildInput(
47
67
  kind: LeadKind,
48
68
  body: Record<string, unknown>,
69
+ workspaceId: string,
49
70
  ): RouteLeadInput {
50
71
  const payload = (body["payload"] ?? {}) as Record<string, unknown>;
51
72
 
52
73
  if (kind === "submit") {
53
74
  return {
54
75
  kind,
55
- siteId: body["siteId"] as string,
76
+ siteId: workspaceId,
56
77
  source: body["source"] as string,
57
78
  email: liftString(body, payload, "email"),
58
79
  payload,
@@ -61,14 +82,14 @@ function buildInput(
61
82
  if (kind === "subscribe") {
62
83
  return {
63
84
  kind,
64
- siteId: body["siteId"] as string,
85
+ siteId: workspaceId,
65
86
  email: liftString(body, payload, "email"),
66
87
  source: body["source"] as string,
67
88
  };
68
89
  }
69
90
  return {
70
91
  kind,
71
- siteId: body["siteId"] as string,
92
+ siteId: workspaceId,
72
93
  name: liftString(body, payload, "name"),
73
94
  phone: liftString(body, payload, "phone"),
74
95
  ...(body["preferredTime"] !== undefined && {
@@ -99,7 +120,46 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
99
120
  receivedAt: new Date().toISOString(),
100
121
  };
101
122
 
102
- const result = await routeLead(buildInput(kind, body), sourceMeta);
123
+ // #6203 — resolve the tenant SERVER-SIDE before any write is
124
+ // attempted. Two branches, deliberately different:
125
+ //
126
+ // - No DATABASE_URL: the zero-config OSS file-fallback deployment.
127
+ // There is no `workspaces` table to resolve against (and
128
+ // `assertSiteAliasResolves()` already skips its own boot check for
129
+ // exactly this reason), so the "resolved" value here is the site
130
+ // ALIAS itself (`currentSiteId()`) — the same value the file
131
+ // fallback would have carried before this unit. Never a rejection:
132
+ // that would break lead capture for every zero-config scaffold.
133
+ // - DATABASE_URL set: resolve via `resolveSiteWorkspaceId()`, the
134
+ // SAME alias -> workspace resolution `currentSiteId()` already
135
+ // drives everywhere else in this app. `null` here is a genuine
136
+ // misconfiguration `build()` already refuses to boot past
137
+ // (`assertSiteAliasResolves()`) — reaching it at request time is
138
+ // the residual case that startup check cannot catch (a workspace
139
+ // deleted after boot, a transient read), and it fails closed the
140
+ // same way: reject, never guess (spec AC-4).
141
+ let workspaceId: string;
142
+ if (!process.env.DATABASE_URL) {
143
+ workspaceId = currentSiteId();
144
+ } else {
145
+ const resolved = await resolveSiteWorkspaceId();
146
+ if (resolved === null) {
147
+ request.log.error(
148
+ { kind },
149
+ "lead capture: DATABASE_URL is set but the site alias resolves to no workspace",
150
+ );
151
+ return reply.status(503).send({
152
+ error: "tenant_unresolved",
153
+ message: "This site is not configured to accept submissions.",
154
+ });
155
+ }
156
+ workspaceId = resolved;
157
+ }
158
+
159
+ const result = await routeLead(
160
+ buildInput(kind, body, workspaceId),
161
+ sourceMeta,
162
+ );
103
163
 
104
164
  if (!result.ok) {
105
165
  if (result.error.kind === "invalid_input") {
@@ -110,7 +170,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
110
170
  if (result.error.kind === "persist_failed") {
111
171
  // Do not surface internal error detail — log it server-side only
112
172
  request.log.error(
113
- { kind, siteId: body["siteId"], error: result.error.message },
173
+ { kind, siteId: workspaceId, error: result.error.message },
114
174
  "lead persist_failed",
115
175
  );
116
176
  return reply.status(503).send({ error: "persist_failed" });
@@ -131,7 +191,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
131
191
  : "error";
132
192
  request.log.info(
133
193
  {
134
- siteId: body["siteId"],
194
+ siteId: workspaceId,
135
195
  kind,
136
196
  ip: sourceMeta.ip,
137
197
  persisted: result.persisted,
@@ -140,6 +200,35 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
140
200
  "lead routed",
141
201
  );
142
202
 
203
+ // Request-and-confirm (#5970): mint the tenant's one-time confirm
204
+ // credential and email them the link. Fire-and-forget and `void`-
205
+ // returning, so the 201 below stays independent of both Resend and that
206
+ // write — the request is already persisted by this point, and a send
207
+ // that fails must never turn a captured lead into an error.
208
+ //
209
+ // Callback only: `submit` and `subscribe` have no confirm step. The
210
+ // service itself no-ops (with a logged reason) when the feature is not
211
+ // configured, which is the default.
212
+ // `result.persisted === "db"` for the same reason `lead-routing.ts`
213
+ // consults it (#5970 security review round 3, M1): the credential is
214
+ // minted by an update on the `callbacks` row, so a FILE-FALLBACK id
215
+ // would match nothing and the operator would be left with no email at
216
+ // all. On that path the generic notification is sent instead, which is
217
+ // what keeps "exactly one" true in both directions.
218
+ if (kind === "callback" && result.persisted === "db") {
219
+ const callbackInput = buildInput(kind, body, workspaceId) as Extract<
220
+ RouteLeadInput,
221
+ { kind: "callback" }
222
+ >;
223
+ sendRequestConfirmLink(result.id, {
224
+ name: callbackInput.name,
225
+ phone: callbackInput.phone,
226
+ ...(callbackInput.preferredTime !== undefined && {
227
+ preferredTime: callbackInput.preferredTime,
228
+ }),
229
+ });
230
+ }
231
+
143
232
  // Fire-and-forget: track() never throws; no need to hold the response.
144
233
  void track("lead-captured", {
145
234
  source: body["source"] as string | undefined,
@@ -148,13 +237,11 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
148
237
  ] as string | undefined,
149
238
  });
150
239
 
151
- return reply
152
- .status(201)
153
- .send({
154
- id: result.id,
155
- persisted: result.persisted,
156
- email: result.email,
157
- });
240
+ return reply.status(201).send({
241
+ id: result.id,
242
+ persisted: result.persisted,
243
+ email: result.email,
244
+ });
158
245
  });
159
246
  };
160
247
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The leads INBOX read (#5678).
3
+ *
4
+ * Routes:
5
+ * GET /v1/leads?cursor=&limit= — the caller's own sites' leads, newest first
6
+ *
7
+ * Registered INSIDE `server.ts`'s `authScope` (auth only — NOT the inner
8
+ * `tenantScope`, which the multi-tenant module owns), beside
9
+ * `/v1/waitlist-signups/me` and `/v1/project-listings/me`. Lead capture is a
10
+ * never-gate surface at every level, so reading a lead back must be too: this
11
+ * route is registered unconditionally, not behind an `isModuleEnabled()` gate.
12
+ * Registering everywhere is not the same as RETURNING a row everywhere —
13
+ * `services/leads-inbox.ts` § "What it takes for this to return anything"
14
+ * names the two deployment preconditions, both of which fail empty, never
15
+ * wide.
16
+ *
17
+ * Identity is `request.userId` ONLY. There is no site, org or workspace
18
+ * parameter — the query schema declares `cursor` and `limit` and nothing else
19
+ * — so the scope cannot be widened from the wire. `services/leads-inbox.ts`
20
+ * resolves it from the caller's own memberships through
21
+ * `services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` and filters every
22
+ * read on `siteId ∈ workspaceIds`.
23
+ *
24
+ * Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
25
+ * `{ ok: false, error: { code, message } }`, real HTTP status codes:
26
+ * 400 validation_failed · 401 unauthorized · 500 internal_error.
27
+ *
28
+ * No response-contract re-parse (the `waitlist-experiments/respond.ts`
29
+ * `sendValidated` net): the five fields of a row are assembled one by one in
30
+ * `leads-inbox.ts` from five explicitly selected columns, with no
31
+ * pass-through of a stored value that could carry an unexpected shape — so
32
+ * there is no drift for that net to catch.
33
+ *
34
+ * API↔MCP parity: identity-bound (the session IS the scope; the OSS MCP
35
+ * surface is identity-blind) — no MCP twin (#3591 exception, the same shape as
36
+ * `/v1/workspaces/*`, `/v1/billing/*` and `/v1/waitlist-experiments/*`).
37
+ */
38
+ import { zLeadId } from "@working-theory/ids";
39
+ import type { FastifyPluginAsync, FastifyReply } from "fastify";
40
+ import { z } from "zod";
41
+
42
+ import { listOwnedLeads } from "../../services/leads-inbox.js";
43
+
44
+ const DEFAULT_LIMIT = 50;
45
+ const MAX_LIMIT = 200;
46
+
47
+ const emptyToUndefined = (val: unknown) => (val === "" ? undefined : val);
48
+
49
+ /** Boundary validation, mirroring `waitlist-experiments/signups.ts`'s query schema. */
50
+ const listQuerySchema = z.object({
51
+ cursor: z.preprocess(emptyToUndefined, zLeadId.optional()),
52
+ limit: z.preprocess(
53
+ emptyToUndefined,
54
+ z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
55
+ ),
56
+ });
57
+
58
+ function unauthorized(reply: FastifyReply) {
59
+ return reply.status(401).send({
60
+ ok: false,
61
+ error: { code: "unauthorized", message: "Authentication required" },
62
+ });
63
+ }
64
+
65
+ function invalidQuery(reply: FastifyReply, message: string) {
66
+ return reply.status(400).send({
67
+ ok: false,
68
+ error: { code: "validation_failed", message },
69
+ });
70
+ }
71
+
72
+ export const leadListRoute: FastifyPluginAsync = async (fastify) => {
73
+ fastify.get("/v1/leads", async (request, reply) => {
74
+ const userId = request.userId;
75
+ if (!userId) return unauthorized(reply);
76
+
77
+ const query = listQuerySchema.safeParse(request.query);
78
+ if (!query.success) return invalidQuery(reply, "Invalid query parameters");
79
+
80
+ const result = await listOwnedLeads({
81
+ userId,
82
+ cursor: query.data.cursor,
83
+ limit: query.data.limit ?? DEFAULT_LIMIT,
84
+ });
85
+
86
+ if (!result.ok) {
87
+ if (result.error.kind === "invalid_cursor") {
88
+ return invalidQuery(reply, result.error.message);
89
+ }
90
+ // Do not surface internal error detail — log it server-side only, and
91
+ // with no lead field in the log line (the leads PII boundary).
92
+ request.log.error(
93
+ { route: "leads/list", error: result.error.message },
94
+ "leads list failed",
95
+ );
96
+ return reply.status(500).send({
97
+ ok: false,
98
+ error: { code: "internal_error", message: "Failed to read leads" },
99
+ });
100
+ }
101
+
102
+ return reply.status(200).send({ ok: true, data: result.value });
103
+ });
104
+ };
@@ -74,6 +74,10 @@ import {
74
74
  } from "@working-theory/validation";
75
75
 
76
76
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
77
+ import {
78
+ mockNoWorkspace,
79
+ mockWorkspaceExists,
80
+ } from "../../../lib/__mocks__/prisma.js";
77
81
  import { prisma } from "../../../lib/prisma.js";
78
82
  import { patchProjectListingDraftRoute } from "../patch-draft.js";
79
83
  import { startProjectListingRoute } from "../start.js";
@@ -181,6 +185,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
181
185
  }
182
186
 
183
187
  function installStore(): void {
188
+ // #6335 — the shared prisma mock no longer resolves ANY requested
189
+ // workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
190
+ // by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
191
+ // services/project-listings.ts), so this suite states the ONE site it is
192
+ // built around. Keyed on the requested id — nothing else resolves.
193
+ mockWorkspaceExists(prisma, SITE_ID);
194
+
184
195
  rows = [];
185
196
  nextId = 1;
186
197
  configData = null;
@@ -239,6 +250,7 @@ function installStore(): void {
239
250
  headline: null,
240
251
  logoMarkUrl: null,
241
252
  logoWordmarkUrl: null,
253
+ solution: null,
242
254
  featured: false,
243
255
  decidedAt: null,
244
256
  decidedBy: null,
@@ -573,3 +585,42 @@ describe("the site's own application config (#5048 D5/D6)", () => {
573
585
  });
574
586
  });
575
587
  });
588
+
589
+ // ── #6335 — the explicit workspace setup above is load-bearing ────────────
590
+ //
591
+ // Every case in this file states that this suite's site has a workspace.
592
+ // This one takes that away, and proves the statement was doing work rather
593
+ // than decorating the setup: `startProjectListingDraft` resolves the site it
594
+ // is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
595
+ // and refuses BEFORE any row is written. With the shared prisma mock's old
596
+ // permissive default in place this case could not be written at all — every
597
+ // id resolved, so the fail-closed path was unreachable from a route test.
598
+ describe("#6335 — start fails closed when the site names no workspace", () => {
599
+ let app: FastifyInstance;
600
+
601
+ beforeEach(async () => {
602
+ jest.clearAllMocks();
603
+ installStore();
604
+ mockNoWorkspace(prisma);
605
+ app = await buildApp();
606
+ });
607
+
608
+ afterEach(async () => {
609
+ await app.close();
610
+ });
611
+
612
+ it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
613
+ const res = await app.inject({
614
+ method: "POST",
615
+ url: "/v1/project-listings",
616
+ payload: START_PAYLOAD,
617
+ });
618
+
619
+ expect(res.statusCode).toBe(503);
620
+ expect((res.json() as { error: { code: string } }).error.code).toBe(
621
+ "tenant_unresolved",
622
+ );
623
+ expect(prisma.projectListing.create).not.toHaveBeenCalled();
624
+ expect(rows).toHaveLength(0);
625
+ });
626
+ });