create-ailk 0.7.0 → 0.9.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 (310) hide show
  1. package/README.md +112 -10
  2. package/component-catalog.json +798 -49
  3. package/component-catalog.md +9 -6
  4. package/dist/add-module.d.ts +20 -0
  5. package/dist/add-module.js +43 -7
  6. package/dist/add-route.d.ts +56 -0
  7. package/dist/add-route.js +286 -0
  8. package/dist/apply-design-md.d.ts +7 -0
  9. package/dist/apply-design-md.js +50 -0
  10. package/dist/apply-design.d.ts +64 -0
  11. package/dist/apply-design.js +273 -0
  12. package/dist/claude-md.d.ts +111 -0
  13. package/dist/claude-md.js +334 -0
  14. package/dist/cli.js +114 -28
  15. package/dist/derive-architecture.js +41 -36
  16. package/dist/design-compile.d.ts +71 -0
  17. package/dist/design-compile.js +98 -0
  18. package/dist/design-package.d.ts +83 -0
  19. package/dist/design-package.js +468 -0
  20. package/dist/design-presets.d.ts +86 -0
  21. package/dist/design-presets.js +642 -0
  22. package/dist/design-scaffold.d.ts +76 -0
  23. package/dist/design-scaffold.js +220 -0
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +4 -1
  26. package/dist/legacy-nav-renderers.d.ts +45 -0
  27. package/dist/legacy-nav-renderers.js +545 -0
  28. package/dist/lib/auth-secret-file.d.ts +19 -0
  29. package/dist/lib/auth-secret-file.js +112 -0
  30. package/dist/lib/design-checks.d.ts +78 -0
  31. package/dist/lib/design-checks.js +174 -0
  32. package/dist/lib/extract-module-bundle.js +5 -1
  33. package/dist/lib/license-key-files.d.ts +9 -0
  34. package/dist/lib/license-key-files.js +2 -2
  35. package/dist/lib/module-license-gate.d.ts +89 -0
  36. package/dist/lib/module-license-gate.js +171 -12
  37. package/dist/lib/module-tier.d.ts +45 -16
  38. package/dist/lib/module-tier.js +76 -0
  39. package/dist/lib/pro-callout.js +10 -2
  40. package/dist/lib/render-verdict.d.ts +52 -0
  41. package/dist/lib/render-verdict.js +77 -0
  42. package/dist/lib/scaffold-render-sweep.d.ts +105 -0
  43. package/dist/lib/scaffold-render-sweep.js +236 -0
  44. package/dist/module-architecture.d.ts +19 -16
  45. package/dist/module-architecture.js +64 -21
  46. package/dist/parse-args.d.ts +88 -10
  47. package/dist/parse-args.js +272 -21
  48. package/dist/programmatic.d.ts +66 -12
  49. package/dist/programmatic.js +361 -34
  50. package/dist/site-chrome.d.ts +19 -9
  51. package/dist/site-chrome.js +23 -13
  52. package/dist/site-manifest.d.ts +25 -0
  53. package/dist/site-manifest.js +254 -0
  54. package/dist/site-roster.d.ts +41 -0
  55. package/dist/site-roster.js +149 -11
  56. package/dist/starter-grid.d.ts +232 -0
  57. package/dist/starter-grid.js +545 -0
  58. package/dist/surfaces.d.ts +41 -9
  59. package/dist/surfaces.js +268 -92
  60. package/dist/template-manifest.d.ts +65 -29
  61. package/dist/template-manifest.js +161 -382
  62. package/dist/template-names.d.ts +14 -12
  63. package/dist/template-names.js +13 -17
  64. package/package.json +5 -4
  65. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  66. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  67. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  68. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  69. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
  70. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
  71. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  72. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  73. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  74. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  75. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  76. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
  77. package/site-templates/_base/content/en/UseCase/persona-1.mdx +70 -0
  78. package/site-templates/_base/content/en/UseCase/persona-2.mdx +70 -0
  79. package/site-templates/_base/content/en/UseCase/persona-3.mdx +70 -0
  80. package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
  81. package/site-templates/designs/minimalism/DESIGN.md +98 -6
  82. package/site-templates/designs/minimalism/sample/README.md +48 -0
  83. package/site-templates/designs/standard/DESIGN.md +103 -6
  84. package/site-templates/marketing-site/DESIGN.md +103 -6
  85. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +144 -47
  86. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +82 -41
  87. package/site-templates/marketing-site-free/DESIGN.md +103 -6
  88. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +150 -48
  89. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +114 -67
  90. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  91. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  92. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  93. package/site-templates/saas-product/DESIGN.md +103 -6
  94. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  95. package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +210 -81
  96. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +356 -0
  97. package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
  98. package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
  99. package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
  100. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  101. package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
  102. package/site-templates/{minimalism-site → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
  103. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
  104. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
  105. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
  106. package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
  107. package/site-templates/shapes/local-business-service/provenance.json +4 -0
  108. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +471 -0
  109. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +354 -0
  110. package/site-templates/shapes/organization-service/ailk-template.json +10 -0
  111. package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
  112. package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
  113. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  114. package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
  115. package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  116. package/site-templates/{minimalism-site → shapes/organization-service}/content/en/ServicesList/services.mdx +3 -4
  117. package/site-templates/shapes/organization-service/provenance.json +4 -0
  118. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +478 -0
  119. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +353 -0
  120. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  121. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  122. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  123. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  124. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  125. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +171 -0
  126. package/site-templates/{simple-service → shapes/person-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +10 -11
  127. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  128. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  129. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  130. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  131. package/site-templates/shapes/person-service/provenance.json +4 -0
  132. package/templates/.claude/rules/architecture.md +7 -0
  133. package/templates/.claude/skills/README.md +5 -3
  134. package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
  135. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
  136. package/templates/.claude/skills/add-page/SKILL.md +241 -238
  137. package/templates/.claude/skills/add-page/references/page-types.md +85 -72
  138. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
  139. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
  140. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
  141. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
  142. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
  143. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
  144. package/templates/.claude/skills/check-site/SKILL.md +23 -0
  145. package/templates/.claude/skills/fill-page/SKILL.md +26 -7
  146. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
  147. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  148. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  149. package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
  150. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +44 -36
  151. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
  152. package/templates/.claude/skills/update-page/SKILL.md +117 -0
  153. package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
  154. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
  155. package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
  156. package/templates/.github/workflows/ci.yml +4 -0
  157. package/templates/.prettierignore +1 -0
  158. package/templates/CLAUDE.md +146 -103
  159. package/templates/DESIGN.md +103 -6
  160. package/templates/README.md +63 -43
  161. package/templates/apps/api/.env.example +2 -1
  162. package/templates/apps/api/CLAUDE.md +3 -2
  163. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
  164. package/templates/apps/api/src/__tests__/server.test.ts +116 -0
  165. package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
  166. package/templates/apps/api/src/config/index.ts +35 -16
  167. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  168. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  169. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  170. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  171. package/templates/apps/api/src/lib/stripe.ts +9 -0
  172. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
  173. package/templates/apps/api/src/middleware/auth.ts +54 -0
  174. package/templates/apps/api/src/openapi/spec.ts +63 -12
  175. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
  176. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
  177. package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
  178. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  179. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  180. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  181. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
  182. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
  183. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
  184. package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
  185. package/templates/apps/api/src/server.ts +35 -0
  186. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  187. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  188. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
  189. package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
  190. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  191. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  192. package/templates/apps/api/src/services/revalidation.ts +60 -15
  193. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  194. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
  195. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  196. package/templates/apps/mcp/src/tools/purchase.ts +4 -2
  197. package/templates/apps/web/.env.example +13 -0
  198. package/templates/apps/web/CLAUDE.md +22 -28
  199. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
  200. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
  201. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
  202. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
  203. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
  204. package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
  205. package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
  206. package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
  207. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
  208. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
  209. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  210. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  211. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
  212. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  213. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  214. package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
  215. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  216. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  217. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  218. package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +21 -5
  219. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
  220. package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
  221. package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
  222. package/templates/apps/web/app/[locale]/layout.tsx +65 -17
  223. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  224. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  225. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  226. package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
  227. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  228. package/templates/apps/web/app/[locale]/{book-a-call → portfolio}/page.tsx +12 -13
  229. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
  230. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
  232. package/templates/apps/web/app/api/revalidate/route.ts +78 -36
  233. package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
  234. package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
  235. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
  236. package/templates/apps/web/lib/locale-guard.ts +18 -0
  237. package/templates/apps/web/lib/meet-placeholder.ts +5 -0
  238. package/templates/apps/web/lib/nav-config.ts +50 -37
  239. package/templates/apps/web/lib/site-brand.tsx +13 -9
  240. package/templates/apps/web/lib/site-identity.ts +14 -0
  241. package/templates/apps/web/lib/site-logo.tsx +36 -0
  242. package/templates/apps/web/lib/site-theme.ts +46 -26
  243. package/templates/apps/web/messages/en.json +45 -5
  244. package/templates/apps/web/middleware.ts +28 -4
  245. package/templates/content/_site.mdx +4 -2
  246. package/templates/content/en/BlogList/blog.mdx +37 -16
  247. package/templates/content/en/BookCall/book-a-call.mdx +80 -0
  248. package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
  249. package/templates/content/en/BookCall/onsite-service.mdx +56 -0
  250. package/templates/content/en/BookCall/start-project.mdx +56 -0
  251. package/templates/content/en/ContactPage/contact.mdx +43 -34
  252. package/templates/content/en/HomePage/home.mdx +10 -22
  253. package/templates/content/en/LandingPage/meet.mdx +59 -0
  254. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  255. package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
  256. package/templates/database/CHANGELOG.md +14 -0
  257. package/templates/database/package.json +1 -1
  258. package/templates/package.json +2 -1
  259. package/templates/tools/check-claude-md.ts +128 -0
  260. package/versions.json +33 -33
  261. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -53
  262. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
  263. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
  264. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
  265. package/site-templates/minimalism-site/DESIGN.md +0 -162
  266. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -470
  267. package/site-templates/minimalism-site/ailk-template.json +0 -23
  268. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
  269. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -53
  270. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
  271. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
  272. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
  273. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
  274. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
  275. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
  276. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
  277. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
  278. package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
  279. package/site-templates/minimalism-site/provenance.json +0 -4
  280. package/site-templates/simple-service/DESIGN.md +0 -162
  281. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
  282. package/site-templates/simple-service/ailk-template.json +0 -11
  283. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
  284. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
  285. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  286. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  287. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  288. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  289. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  290. package/site-templates/simple-service/provenance.json +0 -4
  291. package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
  292. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
  293. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
  294. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
  295. package/templates/content/en/SoftwareProduct/product.mdx +0 -175
  296. /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
  297. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
  298. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
  299. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
  300. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
  301. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
  302. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
  303. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
  304. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
  305. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
  306. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
  307. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
  308. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/first-engagement.mdx +0 -0
  309. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/second-engagement.mdx +0 -0
  310. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/third-engagement.mdx +0 -0
@@ -4,9 +4,10 @@
4
4
  * loopback HTTP server (spec D11), not a `global.fetch` double: the contract
5
5
  * under test is the wire format, and only a real server proves that the bytes
6
6
  * on the wire are the bytes the signature covers. The server recomputes the
7
- * HMAC inside its own handler — the receiver's verification, performed for
8
- * real — and compares with `timingSafeEqual`, exactly as a Phase-3 receiver
9
- * will (spec FR7).
7
+ * HMAC inside its own handler over the D1 base string
8
+ * `v0:{timestamp}:{event}:{rawBody}` (#5902) — the receiver's verification,
9
+ * performed for real — and compares with `timingSafeEqual`, exactly as the
10
+ * receiver does (spec FR7).
10
11
  *
11
12
  * The server binds 127.0.0.1, which `ssrfBlocked` rejects; D7's
12
13
  * `allowPrivate: true` is what makes this substrate runnable at all.
@@ -23,10 +24,13 @@ import type { AddressInfo } from "node:net";
23
24
  const TEST_SECRET = "not-a-real-secret-fixture-value";
24
25
  const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
25
26
  const EVENT_HEADER = "x-ailk-revalidate-event";
27
+ const TIMESTAMP_HEADER = "x-ailk-revalidate-timestamp";
26
28
 
29
+ // `type` must be a registered page type and `slug` a SLUG_PATTERN value — the
30
+ // sender validates both before signing (#5902).
27
31
  const TARGET = {
28
32
  workspaceId: "ws_test_0001",
29
- type: "page",
33
+ type: "Service",
30
34
  slug: "pricing",
31
35
  locale: "en",
32
36
  };
@@ -63,8 +67,11 @@ jest.mock("@working-theory/observability", () => ({
63
67
  interface Recorded {
64
68
  rawBody: string;
65
69
  signatureValid: boolean;
70
+ /** Whether the signature is HMAC over the raw body ALONE (the pre-#5902 wire). */
71
+ bodyOnlySignatureValid: boolean;
66
72
  signatureHeader: string;
67
73
  eventHeader: string;
74
+ timestampHeader: string;
68
75
  contentType: string;
69
76
  receivedAt: number;
70
77
  }
@@ -81,26 +88,40 @@ beforeAll(async () => {
81
88
  req.on("data", (c: Buffer) => void chunks.push(c));
82
89
  req.on("end", () => {
83
90
  const raw = Buffer.concat(chunks);
84
-
85
- // The receiver's own verification (FR7): recompute HMAC-SHA256 over the
86
- // RAW request body bytes and compare in constant time.
87
- const expected = Buffer.from(
88
- createHmac("sha256", TEST_SECRET).update(raw).digest("hex"),
89
- "utf8",
90
- );
91
+ const timestampHeader = String(req.headers[TIMESTAMP_HEADER] ?? "");
92
+ const eventHeader = String(req.headers[EVENT_HEADER] ?? "");
91
93
  const received = Buffer.from(
92
94
  String(req.headers[SIGNATURE_HEADER] ?? "").replace(/^sha256=/, ""),
93
95
  "utf8",
94
96
  );
95
- const signatureValid =
96
- expected.length === received.length &&
97
- timingSafeEqual(expected, received);
97
+ const matches = (material: Buffer): boolean => {
98
+ const expected = Buffer.from(
99
+ createHmac("sha256", TEST_SECRET).update(material).digest("hex"),
100
+ "utf8",
101
+ );
102
+ return (
103
+ expected.length === received.length &&
104
+ timingSafeEqual(expected, received)
105
+ );
106
+ };
107
+
108
+ // The receiver's own verification (FR7, D1 as amended by #5902):
109
+ // recompute HMAC-SHA256 over `v0:{timestamp}:{event}:` followed by the
110
+ // RAW request body bytes, and compare in constant time.
111
+ const signatureValid = matches(
112
+ Buffer.concat([
113
+ Buffer.from(`v0:${timestampHeader}:${eventHeader}:`, "utf8"),
114
+ raw,
115
+ ]),
116
+ );
98
117
 
99
118
  recorded.push({
100
119
  rawBody: raw.toString("utf8"),
101
120
  signatureValid,
121
+ bodyOnlySignatureValid: matches(raw),
102
122
  signatureHeader: String(req.headers[SIGNATURE_HEADER] ?? ""),
103
- eventHeader: String(req.headers[EVENT_HEADER] ?? ""),
123
+ eventHeader,
124
+ timestampHeader,
104
125
  contentType: String(req.headers["content-type"] ?? ""),
105
126
  receivedAt: Date.now(),
106
127
  });
@@ -219,6 +240,60 @@ describe("notifyRevalidate", () => {
219
240
  });
220
241
  }, 20_000);
221
242
 
243
+ it("signs v0:{timestamp}:{event}:{body} and sends the timestamp header (#5902)", async () => {
244
+ responseStatuses = [200];
245
+ const before = Math.floor(Date.now() / 1000);
246
+
247
+ await loadService().notifyRevalidate(TARGET, { event: "unpublish" });
248
+
249
+ const after = Math.floor(Date.now() / 1000);
250
+ expect(recorded).toHaveLength(1);
251
+ const [req] = recorded;
252
+
253
+ // Unix seconds, digits only, taken at send time — what the receiver checks
254
+ // its 5-minute tolerance against.
255
+ expect(req.timestampHeader).toMatch(/^[0-9]+$/);
256
+ const ts = Number(req.timestampHeader);
257
+ expect(ts).toBeGreaterThanOrEqual(before);
258
+ expect(ts).toBeLessThanOrEqual(after);
259
+
260
+ // The base-string shape, recomputed independently here: the timestamp and
261
+ // the event are inside the MAC, so neither can be altered in flight.
262
+ const expected = createHmac("sha256", TEST_SECRET)
263
+ .update(`v0:${req.timestampHeader}:unpublish:${req.rawBody}`, "utf8")
264
+ .digest("hex");
265
+ expect(req.signatureHeader).toBe(`sha256=${expected}`);
266
+ expect(req.signatureValid).toBe(true);
267
+ // And the body-alone signature of the pre-#5902 wire no longer verifies.
268
+ expect(req.bodyOnlySignatureValid).toBe(false);
269
+ });
270
+
271
+ it.each([
272
+ ["type", "page"], // not a registered page type
273
+ ["type", "Service:some-slug:en"], // the tag-collision shape (#6005 M3)
274
+ ["type", "S".repeat(300)],
275
+ ["slug", "Bad Slug"],
276
+ ["slug", "a:b"],
277
+ ["slug", ""],
278
+ ["locale", "english"],
279
+ ["locale", "en:x"],
280
+ ["locale", ""],
281
+ ])(
282
+ "throws before any outbound call on an invalid %s (%j) (#5902)",
283
+ async (field, value) => {
284
+ responseStatuses = [200];
285
+
286
+ await expect(
287
+ loadService().notifyRevalidate(
288
+ { ...TARGET, [field]: value },
289
+ { event: "publish" },
290
+ ),
291
+ ).rejects.toThrow();
292
+
293
+ expect(recorded).toHaveLength(0);
294
+ },
295
+ );
296
+
222
297
  it("is a single-warning no-op with CONTENT_REVALIDATE_URL unset", async () => {
223
298
  delete process.env.CONTENT_REVALIDATE_URL;
224
299
  delete process.env.CONTENT_REVALIDATE_SECRET;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @file checkout-buyer.ts
3
+ * @description Resolves a signed-in user into the Checkout buyer
4
+ * `POST /v1/checkout/sessions` pins and stamps (#6866 D3).
5
+ *
6
+ * A buyer is a user whose email is VERIFIED — the customer created for them
7
+ * carries the account email, and it must never carry an unproven one — and
8
+ * whose billing binding, if any, is a Stripe customer. The binding decides
9
+ * whether the library reuses a customer (`customerId`) or creates one
10
+ * (`null`). A row bound under any other provider is not a buyer: the webhook
11
+ * never re-points a non-null `billingCustomerId` (#6113), so a new stamped
12
+ * customer could only be an orphan.
13
+ *
14
+ * Base tree (N4): imports only `verified-email.ts` and `lib/prisma.ts`, never
15
+ * the commerce overlay's `services/subscription.ts`. The billing read selects
16
+ * no email, so `tools/lint-email-authz.ts` has nothing to flag.
17
+ */
18
+
19
+ import { prisma } from "../lib/prisma.js";
20
+
21
+ import { findVerifiedEmail } from "./verified-email.js";
22
+
23
+ export type CheckoutBuyer = {
24
+ userId: string;
25
+ email: string;
26
+ /** The user's bound Stripe customer, or `null` when one is to be created. */
27
+ customerId: string | null;
28
+ };
29
+
30
+ export type CheckoutBuyerResult =
31
+ | { ok: true; buyer: CheckoutBuyer }
32
+ | {
33
+ ok: false;
34
+ reason: "user_not_found" | "email_unverified" | "foreign_provider";
35
+ };
36
+
37
+ /**
38
+ * @param userId the id resolved from the authenticated session cookie — never
39
+ * a request-supplied value.
40
+ * @throws whatever Prisma throws; the caller degrades that to anonymous (F7).
41
+ */
42
+ export async function resolveCheckoutBuyer(
43
+ userId: string,
44
+ ): Promise<CheckoutBuyerResult> {
45
+ const verified = await findVerifiedEmail(userId);
46
+ if (!verified.ok) return { ok: false, reason: verified.reason };
47
+
48
+ const billing = await prisma.user.findUnique({
49
+ where: { id: userId },
50
+ select: { billingProvider: true, billingCustomerId: true },
51
+ });
52
+ if (!billing) return { ok: false, reason: "user_not_found" };
53
+
54
+ if (billing.billingCustomerId === null) {
55
+ return {
56
+ ok: true,
57
+ buyer: { userId, email: verified.value.email, customerId: null },
58
+ };
59
+ }
60
+ // The same provider predicate `resolveBillingCustomer` applies.
61
+ if (billing.billingProvider !== "stripe") {
62
+ return { ok: false, reason: "foreign_provider" };
63
+ }
64
+ return {
65
+ ok: true,
66
+ buyer: {
67
+ userId,
68
+ email: verified.value.email,
69
+ customerId: billing.billingCustomerId,
70
+ },
71
+ };
72
+ }
@@ -2,7 +2,12 @@
2
2
  * @file lead-routing.ts
3
3
  * @description Cross-namespace lead orchestration service (ADR 0009 D3).
4
4
  *
5
- * Pipeline: validate → enrich → persist (DB with file fallback) → email.
5
+ * Pipeline: validate → delivery guard → enrich → persist (DB with file
6
+ * fallback) → email. Where a lead may go is `resolveLeadDelivery()`
7
+ * (`lib/lead-delivery.ts`, #6730 D1): with no database but the email vars set,
8
+ * the owner email IS the delivery; in production with neither, nothing is
9
+ * accepted. A lead is reported sent only for a durable row or an email the
10
+ * provider accepted — never for the non-durable file fallback in production.
6
11
  * This is the first implementation of ADR 0009 D3's service-layer pattern.
7
12
  * Future cross-namespace orchestrators in apps/api/src/services/ should mirror
8
13
  * this shape: type-result returns, mocked-dep tests, non-transactional secondary
@@ -32,6 +37,7 @@ import { leadInputSchema } from "@working-theory/validation";
32
37
 
33
38
  import { writeLeadFallback } from "../lib/file-fallback.js";
34
39
  import { appendLeadRecord } from "../lib/lead-capture-store.js";
40
+ import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
35
41
  import { prisma } from "../lib/prisma.js";
36
42
 
37
43
  import { enrichLead } from "./enrichment-shim.js";
@@ -65,11 +71,14 @@ export type SourceMeta = {
65
71
  receivedAt: string; // ISO
66
72
  };
67
73
 
74
+ /** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
75
+ export type Persisted = "db" | "file" | "email";
76
+
68
77
  export type RouteLeadResult =
69
78
  | {
70
79
  ok: true;
71
80
  id: string;
72
- persisted: "db" | "file";
81
+ persisted: Persisted;
73
82
  email:
74
83
  | { ok: true; messageId: string }
75
84
  | { ok: false; error: string }
@@ -78,7 +87,11 @@ export type RouteLeadResult =
78
87
  | {
79
88
  ok: false;
80
89
  error: {
81
- kind: "invalid_input" | "persist_failed" | "config_missing";
90
+ kind:
91
+ | "invalid_input"
92
+ | "persist_failed"
93
+ | "config_missing"
94
+ | "delivery_failed";
82
95
  message: string;
83
96
  };
84
97
  };
@@ -272,7 +285,7 @@ type EmailResult =
272
285
  async function sendEmail(
273
286
  input: RouteLeadInput,
274
287
  id: string,
275
- persisted: "db" | "file",
288
+ persisted: Persisted,
276
289
  ): Promise<EmailResult> {
277
290
  // Request-and-confirm SUPERSEDES this notification for a callback (#5970
278
291
  // D6). AC-1 requires the tenant to receive exactly one email, and the
@@ -316,7 +329,22 @@ async function sendEmail(
316
329
  siteId: input.siteId,
317
330
  source: input.kind === "callback" ? "callback" : input.source,
318
331
  email: input.kind === "callback" ? "" : input.email,
319
- payload: input.kind === "subscribe" ? {} : input.payload,
332
+ // A callback's contact details are top-level fields, not payload keys
333
+ // (#6730): with no database the email is the only record of the
334
+ // request, so it must carry the name and number to call back.
335
+ payload:
336
+ input.kind === "subscribe"
337
+ ? {}
338
+ : input.kind === "callback"
339
+ ? {
340
+ ...input.payload,
341
+ name: input.name,
342
+ phone: input.phone,
343
+ ...(input.preferredTime !== undefined && {
344
+ preferredTime: input.preferredTime,
345
+ }),
346
+ }
347
+ : input.payload,
320
348
  createdAt: new Date().toISOString(),
321
349
  };
322
350
 
@@ -324,6 +352,9 @@ async function sendEmail(
324
352
  lead: leadForEmail,
325
353
  recipientEmail: to,
326
354
  fromAddress: from,
355
+ // A callback has no visitor address to reply to; an empty Reply-To is
356
+ // not a valid header, so reply to the owner instead.
357
+ ...(input.kind === "callback" && { replyToOverride: to }),
327
358
  });
328
359
 
329
360
  if (emailResult.ok) {
@@ -337,6 +368,47 @@ async function sendEmail(
337
368
  return { ok: false, error: `${kind}: ${message}` };
338
369
  }
339
370
 
371
+ // ─── Email delivery (#6730 D2) ────────────────────────────────────────────────
372
+
373
+ type EmailDelivery =
374
+ | { ok: true; id: string; email: { ok: true; messageId: string } }
375
+ | { ok: false; message: string };
376
+
377
+ /**
378
+ * Deliver a lead as the owner email alone — success only when the provider
379
+ * accepted it. The id matches the file-fallback id format
380
+ * (`/^[a-z0-9_-]{8,64}$/i`).
381
+ */
382
+ async function deliverByEmail(input: RouteLeadInput): Promise<EmailDelivery> {
383
+ const id = `email-${Date.now()}-${randomUUID().slice(0, 8)}`;
384
+ const result = await sendEmail(input, id, "email");
385
+ if ("ok" in result && result.ok) return { ok: true, id, email: result };
386
+ return {
387
+ ok: false,
388
+ message: "error" in result ? result.error : `email skipped: ${result.reason}`,
389
+ };
390
+ }
391
+
392
+ // ─── Lead-capture record ─────────────────────────────────────────────────────
393
+
394
+ function captureRecordFor(input: RouteLeadInput, id: string, sourceMeta: SourceMeta) {
395
+ return {
396
+ id,
397
+ createdAt: sourceMeta.receivedAt,
398
+ source: input.kind === "callback" ? "callback" : input.source,
399
+ data:
400
+ input.kind === "callback"
401
+ ? { name: input.name, phone: input.phone, ...(input.payload ?? {}) }
402
+ : input.kind === "subscribe"
403
+ ? { email: input.email }
404
+ : { email: input.email, ...(input.payload ?? {}) },
405
+ metadata: {
406
+ ip: sourceMeta.ip,
407
+ userAgent: sourceMeta.userAgent,
408
+ },
409
+ };
410
+ }
411
+
340
412
  // ─── Main export ──────────────────────────────────────────────────────────────
341
413
 
342
414
  export async function routeLead(
@@ -352,13 +424,13 @@ export async function routeLead(
352
424
  };
353
425
  }
354
426
 
355
- // 2. Guard: if file capture is disabled AND no DB is configured, bail early.
356
- // Both paths are unavailable — returning a clear error is better than a
357
- // misleading 201 or a stack trace from Prisma on a missing DATABASE_URL.
358
- if (
359
- process.env.LEAD_CAPTURE_FILE_DISABLED === "true" &&
360
- !process.env.DATABASE_URL
361
- ) {
427
+ // 2. Guard: where can this lead go (#6730 D1)? A kind the configured
428
+ // delivery cannot take — anything in production with neither a database
429
+ // nor the email vars, a newsletter signup with no database — is refused
430
+ // before any write, so no visitor is told a message was sent when it
431
+ // reached nobody.
432
+ const delivery = resolveLeadDelivery();
433
+ if (!acceptsKind(delivery, input.kind)) {
362
434
  return {
363
435
  ok: false,
364
436
  error: {
@@ -374,9 +446,28 @@ export async function routeLead(
374
446
  // the real enrichment call with proper type alignment.
375
447
  await enrichLead(input as unknown as Parameters<typeof enrichLead>[0]);
376
448
 
449
+ // Email delivery — no database: the owner email is the delivery, so skip
450
+ // the DB and the (non-durable) file fallback entirely.
451
+ if (delivery === "email") {
452
+ const emailed = await deliverByEmail(input);
453
+ if (!emailed.ok) {
454
+ return {
455
+ ok: false,
456
+ error: { kind: "delivery_failed", message: emailed.message },
457
+ };
458
+ }
459
+ await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
460
+ return {
461
+ ok: true,
462
+ id: emailed.id,
463
+ persisted: "email",
464
+ email: emailed.email,
465
+ };
466
+ }
467
+
377
468
  // 4. Persist primary (DB), fallback to file on error
378
469
  let id: string;
379
- let persisted: "db" | "file";
470
+ let persisted: Persisted;
380
471
  let skipEmail = false;
381
472
  let skipReason: string | undefined;
382
473
 
@@ -424,7 +515,41 @@ export async function routeLead(
424
515
  };
425
516
  }
426
517
 
427
- // 5. File fallback
518
+ // In production the file fallback is not durable (a serverless instance
519
+ // does not outlive the request), so reporting it as success would lose
520
+ // the lead silently. Deliver by email when that is configured; otherwise
521
+ // fail loudly (#6730 D2). A newsletter signup is never emailed — one
522
+ // owner email per address is not a list (`acceptsKind`).
523
+ if (process.env.NODE_ENV === "production") {
524
+ const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
525
+ if (input.kind === "subscribe") {
526
+ return {
527
+ ok: false,
528
+ error: { kind: "persist_failed", message: `db: ${dbMsg}` },
529
+ };
530
+ }
531
+ const emailed = await deliverByEmail(input);
532
+ if (!emailed.ok) {
533
+ return {
534
+ ok: false,
535
+ error: {
536
+ kind: "persist_failed",
537
+ // Not the provider's text: it can echo an address, and this
538
+ // message is logged (spec D5).
539
+ message: `db: ${dbMsg}; email delivery failed`,
540
+ },
541
+ };
542
+ }
543
+ await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
544
+ return {
545
+ ok: true,
546
+ id: emailed.id,
547
+ persisted: "email",
548
+ email: emailed.email,
549
+ };
550
+ }
551
+
552
+ // 5. File fallback (outside production only)
428
553
  // Include a UUID suffix so concurrent fallbacks produce unique IDs even
429
554
  // within the same millisecond. Format still matches /^[a-z0-9_-]{8,64}$/i.
430
555
  const fallbackLead = {
@@ -468,22 +593,7 @@ export async function routeLead(
468
593
  // 6. Write to lead-capture JSONL (always-on canonical AX surface).
469
594
  // Best-effort — a write failure does NOT roll back persistence or block email.
470
595
  // The file is the agent-readable surface; DB is the transactional record.
471
- const captureRecord = {
472
- id: id!,
473
- createdAt: sourceMeta.receivedAt,
474
- source: input.kind === "callback" ? "callback" : input.source,
475
- data:
476
- input.kind === "callback"
477
- ? { name: input.name, phone: input.phone, ...(input.payload ?? {}) }
478
- : input.kind === "subscribe"
479
- ? { email: input.email }
480
- : { email: input.email, ...(input.payload ?? {}) },
481
- metadata: {
482
- ip: sourceMeta.ip,
483
- userAgent: sourceMeta.userAgent,
484
- },
485
- };
486
- await appendLeadRecord(captureRecord);
596
+ await appendLeadRecord(captureRecordFor(input, id!, sourceMeta));
487
597
 
488
598
  // 7. Email notification (best-effort — never rolls back persistence)
489
599
  let emailResult: EmailResult;
@@ -25,10 +25,14 @@
25
25
  *
26
26
  * Two preconditions, neither of which this file can assert:
27
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.
28
+ * 1. The caller's scope must resolve to a workspace. With the
29
+ * `multi-tenant` module that is `resolveOwnerWorkspaces`. Without it
30
+ * (#6730 D7) — Free and Pro — that resolver yields none by design (its
31
+ * own header, #5677), so the scope is instead the default workspace,
32
+ * granted ONLY through `resolveDefaultTenantForOperator`
33
+ * (`default-tenant.ts`): `AILK_OPERATOR_EMAIL` set, the caller's
34
+ * VERIFIED email equal to it, and the deployment unambiguously
35
+ * one-workspace. Any other caller gets the empty page, never a wider one.
32
36
  * 2. The `siteId` the leads were WRITTEN under must equal a workspace id,
33
37
  * and there are TWO independent sources of it — both must agree with the
34
38
  * workspace, and neither is `currentSiteId()` on the primary path:
@@ -67,8 +71,10 @@
67
71
  import type { Prisma } from "@working-theory/database";
68
72
  import type { Id } from "@working-theory/ids";
69
73
 
74
+ import { isModuleEnabled } from "../config/modules.js";
70
75
  import { prisma } from "../lib/prisma.js";
71
76
 
77
+ import { resolveDefaultTenantForOperator } from "./default-tenant.js";
72
78
  import { resolveOwnerWorkspaces } from "./owner-workspaces.js";
73
79
 
74
80
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -178,6 +184,40 @@ export function readName(payload: Prisma.JsonValue): string | null {
178
184
  return typeof name === "string" ? name.slice(0, MAX_NAME_LENGTH) : null;
179
185
  }
180
186
 
187
+ // ─── Scope ────────────────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * The workspace ids whose leads the caller may read. With `multi-tenant`,
191
+ * every workspace they own or administer (`resolveOwnerWorkspaces`,
192
+ * unchanged). Without it (#6730 D7), the default workspace — only for the
193
+ * pinned, verified operator on a one-workspace deployment; `null` from the
194
+ * resolver means no scope at all.
195
+ */
196
+ async function resolveInboxSiteIds(
197
+ userId: Id<"user">,
198
+ ): Promise<{ ok: true; siteIds: string[] } | { ok: false; message: string }> {
199
+ if (!isModuleEnabled("multi-tenant")) {
200
+ try {
201
+ const tenant = await resolveDefaultTenantForOperator(userId);
202
+ return {
203
+ ok: true,
204
+ siteIds: tenant && LEAD_INBOX_ROLES.has(tenant.role) ? [tenant.workspaceId] : [],
205
+ };
206
+ } catch (err) {
207
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
208
+ }
209
+ }
210
+
211
+ const workspaces = await resolveOwnerWorkspaces(userId);
212
+ if (!workspaces.ok) return { ok: false, message: workspaces.error.message };
213
+ return {
214
+ ok: true,
215
+ siteIds: workspaces.value
216
+ .filter((workspace) => LEAD_INBOX_ROLES.has(workspace.role))
217
+ .map((workspace) => workspace.id),
218
+ };
219
+ }
220
+
181
221
  // ─── Main export ──────────────────────────────────────────────────────────────
182
222
 
183
223
  /**
@@ -199,17 +239,14 @@ export async function listOwnedLeads(params: {
199
239
  cursor?: string | undefined;
200
240
  limit: number;
201
241
  }): Promise<ListLeadsResult> {
202
- const workspaces = await resolveOwnerWorkspaces(params.userId);
203
- if (!workspaces.ok) {
242
+ const scope = await resolveInboxSiteIds(params.userId);
243
+ if (!scope.ok) {
204
244
  return {
205
245
  ok: false,
206
- error: { kind: "internal_error", message: workspaces.error.message },
246
+ error: { kind: "internal_error", message: scope.message },
207
247
  };
208
248
  }
209
-
210
- const siteIds = workspaces.value
211
- .filter((workspace) => LEAD_INBOX_ROLES.has(workspace.role))
212
- .map((workspace) => workspace.id);
249
+ const siteIds = scope.siteIds;
213
250
 
214
251
  // No workspace the caller owns or administers means no lead can match, so
215
252
  // there is nothing to query. An empty page is the honest answer — the same