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
@@ -34,16 +34,20 @@
34
34
  * Billing upsert (customer.created / checkout.session.completed):
35
35
  * Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
36
36
  * carrying metadata.organizationId links the Organization (multi-tenant
37
- * module); absent → the existing User path (single-tenant), byte-for-byte.
37
+ * module); absent → the User path (single-tenant).
38
38
  *
39
39
  * Organization path (metadata.organizationId present):
40
40
  * 1. Typed composite match already pointing at this org → idempotent no-op
41
41
  * 2. Else link the target Organization (by id) billing fields
42
42
  *
43
- * User path (no organizationId — unchanged):
43
+ * User path (no organizationId):
44
44
  * 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customer)
45
- * 2. Email fallback: User.findUnique({ where: { email } }) → update billing fields
46
- * 3. No match → 200 + warn log; no User row created (pre-signup state)
45
+ * 2. Verified user signal: metadata.ailk_user_id →
46
+ * User.findUnique({ where: { id } }) → bind only when the row's
47
+ * billingCustomerId is empty; otherwise log billing_identity_conflict
48
+ * and write nothing (#6113). No signal → log billing_identity_unverified,
49
+ * write nothing (#6852) — the payer-typed Checkout email never binds.
50
+ * 3. Signal names no User → warn billing_identity_unknown_user; no row created
47
51
  *
48
52
  * Recommender purchase stamp (checkout.session.completed / .expired, #4838):
49
53
  * A session carrying metadata.flowCheckoutId (the FlowCheckout buyer row
@@ -275,7 +279,7 @@ export const stripeWebhookHandler = async (
275
279
  // Billing-entity routing (spec D2): a multi-tenant checkout stamps the
276
280
  // target Organization id in metadata.organizationId (an individual → their
277
281
  // sentinel default org). Present → link the Organization; absent → the
278
- // existing User path, byte-for-byte (single-tenant, degrade-clean).
282
+ // User path (single-tenant, degrade-clean).
279
283
  const organizationId = customer.metadata?.["organizationId"] ?? null;
280
284
  if (organizationId) {
281
285
  await upsertOrgBillingIdentity(request, {
@@ -286,7 +290,8 @@ export const stripeWebhookHandler = async (
286
290
  } else {
287
291
  await upsertBillingIdentity(request, {
288
292
  customerId: customer.id,
289
- email: customer.email ?? null,
293
+ userId: signalOf(customer.metadata),
294
+ eventType: event.type,
290
295
  eventIdPrefix,
291
296
  });
292
297
  }
@@ -306,7 +311,7 @@ export const stripeWebhookHandler = async (
306
311
  };
307
312
 
308
313
  // Billing-entity routing (spec D2): metadata.organizationId → Organization
309
- // path; absent → the existing User path, unchanged (single-tenant).
314
+ // path; absent → the User path (single-tenant).
310
315
  const organizationId = session.metadata?.["organizationId"] ?? null;
311
316
  let billingTask: Promise<void> = Promise.resolve();
312
317
  if (session.customer) {
@@ -318,7 +323,8 @@ export const stripeWebhookHandler = async (
318
323
  })
319
324
  : upsertBillingIdentity(request, {
320
325
  customerId: session.customer,
321
- email: session.customer_details?.email ?? null,
326
+ userId: signalOf(session.metadata),
327
+ eventType: event.type,
322
328
  eventIdPrefix,
323
329
  });
324
330
  }
@@ -704,14 +710,44 @@ async function stampRequestPaidByInvoice(
704
710
  }
705
711
  }
706
712
 
713
+ /**
714
+ * The verified user signal (#6852): `metadata.ailk_user_id` on the Checkout
715
+ * session or the Stripe customer, when it is a non-empty string. Never
716
+ * `client_reference_id` (a Payment Link accepts it from the payer's URL) and
717
+ * never the payer-typed email.
718
+ */
719
+ function signalOf(
720
+ metadata: Record<string, string> | null | undefined,
721
+ ): string | null {
722
+ const value: unknown = metadata?.["ailk_user_id"];
723
+ return typeof value === "string" && value !== "" ? value : null;
724
+ }
725
+
707
726
  /**
708
727
  * Link User.billingProvider + User.billingCustomerId for a Stripe customer.
709
728
  *
710
729
  * Lookup priority:
711
730
  * 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customerId)
712
731
  * → already linked; no-op (idempotent)
713
- * 2. Email fallback: User.findUnique({ where: { email } }) → update billing fields
714
- * 3. No match → warn; do NOT create a User row (pre-signup state preserved for Phase B #852)
732
+ * 2. Verified user signal (#6852): `metadata.ailk_user_id` →
733
+ * User.findUnique({ where: { id } }) → bind only when the row's
734
+ * billingCustomerId is empty; otherwise log billing_identity_conflict and
735
+ * write nothing (#6113). The write is guarded on `billingCustomerId: null`;
736
+ * a P2025 means a concurrent event bound the row first — the same customer
737
+ * is a no-op, any other is a conflict.
738
+ * No signal → log billing_identity_unverified and write nothing. The payer
739
+ * types the Checkout email and Stripe never verifies it, so an email
740
+ * never makes a binding, first or otherwise.
741
+ * Trust boundary: Checkout-session and customer `metadata` can be written
742
+ * only by a principal the seller authorised on their Stripe account (our
743
+ * server's key, a write-capable restricted key, a connected app, the
744
+ * dashboard, a Payment Link's own metadata) — never by the payer. A
745
+ * producer sets the key only from an authenticated server session, never
746
+ * from request input.
747
+ * 3. Signal names no User → warn billing_identity_unknown_user; do NOT
748
+ * create a User row (pre-signup state preserved for Phase B #852)
749
+ *
750
+ * No line logs the email, the Stripe customer id, or an unresolved signal.
715
751
  *
716
752
  * DB failures are caught and logged — a broken DB link should not cause Stripe to retry
717
753
  * the event (which would fire analytics + billing again on eventual success).
@@ -720,9 +756,15 @@ async function upsertBillingIdentity(
720
756
  request: FastifyRequest,
721
757
  {
722
758
  customerId,
723
- email,
759
+ userId,
760
+ eventType,
724
761
  eventIdPrefix,
725
- }: { customerId: string; email: string | null; eventIdPrefix: string },
762
+ }: {
763
+ customerId: string;
764
+ userId: string | null;
765
+ eventType: string;
766
+ eventIdPrefix: string;
767
+ },
726
768
  ): Promise<void> {
727
769
  try {
728
770
  const existingByBilling = await prisma.user.findFirst({
@@ -740,30 +782,80 @@ async function upsertBillingIdentity(
740
782
  return;
741
783
  }
742
784
 
743
- if (email) {
744
- const existingByEmail = await prisma.user.findUnique({
745
- where: { email },
746
- });
785
+ if (!userId) {
786
+ // A customer Checkout creates never carries session metadata, so a
787
+ // no-signal customer.created is every ordinary purchase — info, not warn.
788
+ const fields = {
789
+ eventIdPrefix,
790
+ eventType,
791
+ outcome: "billing_identity_unverified",
792
+ };
793
+ const msg =
794
+ "stripe webhook: no verified user signal — billing identity not bound";
795
+ if (eventType === "customer.created") request.log.info(fields, msg);
796
+ else request.log.warn(fields, msg);
797
+ return;
798
+ }
747
799
 
748
- if (existingByEmail) {
749
- await prisma.user.update({
750
- where: { id: existingByEmail.id },
751
- data: {
752
- billingProvider: "stripe",
753
- billingCustomerId: customerId,
754
- },
755
- });
756
- request.log.info(
757
- { eventIdPrefix },
758
- "billing identity linked via email fallback",
759
- );
760
- return;
761
- }
800
+ const row = await prisma.user.findUnique({
801
+ where: { id: userId },
802
+ select: { id: true, billingProvider: true, billingCustomerId: true },
803
+ });
804
+
805
+ if (!row) {
806
+ request.log.warn(
807
+ { eventIdPrefix, eventType, outcome: "billing_identity_unknown_user" },
808
+ "stripe webhook: user signal names no User — pre-signup state preserved",
809
+ );
810
+ return;
762
811
  }
763
812
 
764
- request.log.warn(
813
+ const isSameBinding = (r: {
814
+ billingProvider: string | null;
815
+ billingCustomerId: string | null;
816
+ }) => r.billingProvider === "stripe" && r.billingCustomerId === customerId;
817
+ const logAlreadyLinked = () =>
818
+ request.log.info(
819
+ { eventIdPrefix, outcome: "billing_identity_already_linked" },
820
+ "billing identity already linked — idempotent no-op",
821
+ );
822
+ const logConflict = () =>
823
+ request.log.warn(
824
+ {
825
+ eventIdPrefix,
826
+ outcome: "billing_identity_conflict",
827
+ userId: row.id,
828
+ },
829
+ "stripe webhook: user already bound to a different billing customer — not re-pointed",
830
+ );
831
+
832
+ if (row.billingCustomerId !== null) {
833
+ if (isSameBinding(row)) logAlreadyLinked();
834
+ else logConflict();
835
+ return;
836
+ }
837
+
838
+ try {
839
+ await prisma.user.update({
840
+ where: { id: row.id, billingCustomerId: null },
841
+ data: {
842
+ billingProvider: "stripe",
843
+ billingCustomerId: customerId,
844
+ },
845
+ });
846
+ } catch (err) {
847
+ if ((err as { code?: unknown }).code !== "P2025") throw err;
848
+ const now = await prisma.user.findUnique({
849
+ where: { id: row.id },
850
+ select: { billingProvider: true, billingCustomerId: true },
851
+ });
852
+ if (now && isSameBinding(now)) logAlreadyLinked();
853
+ else logConflict();
854
+ return;
855
+ }
856
+ request.log.info(
765
857
  { eventIdPrefix },
766
- "stripe webhook: no User match for billing identity — pre-signup state preserved",
858
+ "billing identity linked via verified user signal",
767
859
  );
768
860
  } catch (err) {
769
861
  request.log.error(
@@ -12,6 +12,7 @@
12
12
  import cors from "@fastify/cors";
13
13
  import rateLimit from "@fastify/rate-limit";
14
14
  import { idRegex } from "@working-theory/ids";
15
+ import { logger } from "@working-theory/observability";
15
16
  import Fastify from "fastify";
16
17
  import type {
17
18
  FastifyPluginAsync,
@@ -58,6 +59,7 @@ import {
58
59
  import { searchContentRoute } from "./routes/content/search.js";
59
60
  import {
60
61
  leadCallbackRoute,
62
+ leadCaptureStatusRoute,
61
63
  leadSubmitRoute,
62
64
  leadSubscribeRoute,
63
65
  } from "./routes/leads/index.js";
@@ -506,6 +508,31 @@ async function assertCommerceSandboxKey(key: string): Promise<void> {
506
508
  assertSandboxKeyFn(key);
507
509
  }
508
510
 
511
+ /**
512
+ * #6889 (SEC-02 of the #6876 security review) — make the checkout route's
513
+ * posture observable. POST /v1/checkout/sessions admits a caller without
514
+ * X-Internal-Auth when OSS_API_INTERNAL_SECRET is unset. With FRONTEND_URL
515
+ * unset, the route's same-origin redirect lock (#6877) answers 500
516
+ * config-error to that caller, and the CORS and cookie-CSRF origin checks pin
517
+ * to the `http://localhost:3000` config default. Both stay optional (OSS
518
+ * standalone compat), so this only ever WARNS — once per unset variable, and
519
+ * only in production. FRONTEND_URL is read from the raw environment because
520
+ * `config.FRONTEND_URL` carries that localhost default.
521
+ */
522
+ function warnOnUnsetProductionProtections(): void {
523
+ if (config.NODE_ENV !== "production") return;
524
+ if (!process.env["FRONTEND_URL"]) {
525
+ logger.warn(
526
+ "[startup] FRONTEND_URL not set in production — CORS/cookie origin checks are pinned to http://localhost:3000, and checkout answers 500 config-error to any caller admitted without X-Internal-Auth",
527
+ );
528
+ }
529
+ if (!process.env["OSS_API_INTERNAL_SECRET"]) {
530
+ logger.warn(
531
+ "[startup] OSS_API_INTERNAL_SECRET not set in production — the checkout route admits callers without X-Internal-Auth",
532
+ );
533
+ }
534
+ }
535
+
509
536
  /**
510
537
  * Build Fastify server instance (for serverless or standalone)
511
538
  * @returns Configured Fastify instance ready for requests
@@ -514,6 +541,8 @@ export async function build() {
514
541
  // Startup-time validation: fail fast if LEAD_CAPTURE_FILE points at a missing directory.
515
542
  await validateLeadCaptureDir();
516
543
 
544
+ warnOnUnsetProductionProtections();
545
+
517
546
  // #6155 D6 — the site-alias workspace assertion (ownership brief § Layer
518
547
  // 2, AC-5): resolves + asserts once at startup that AILK_SITE_ID (or its
519
548
  // "default" fallback) names a real workspace, throwing when it does not —
@@ -1145,6 +1174,12 @@ export async function build() {
1145
1174
  await authEmailScope.register(transactionalEmailRoute);
1146
1175
  });
1147
1176
 
1177
+ // Lead-capture status (#6730 D4) — a cheap read the web page makes before
1178
+ // choosing the form or the contact email. Top level, NOT in `leadsScope`:
1179
+ // the web server's single egress IP already spends that scope's strict
1180
+ // 10/min on form submissions. The global unauth ceiling above applies.
1181
+ await server.register(leadCaptureStatusRoute);
1182
+
1148
1183
  // Leads routes — rate-limit middleware scoped to this sub-router. Own
1149
1184
  // default (10/min, pre-#4730) rather than inheriting the wider
1150
1185
  // flows-sized default (#5111, DEF-004 — #4730's shared-default bump was
@@ -553,3 +553,197 @@ describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () =>
553
553
  });
554
554
  });
555
555
  });
556
+
557
+ // ─── #6730 D1/D2 — success only for a durable row or an accepted email ───────
558
+
559
+ describe("#6730 — routeLead delivery modes", () => {
560
+ const ENV_KEYS = [
561
+ "DATABASE_URL",
562
+ "NODE_ENV",
563
+ "RESEND_API_KEY",
564
+ "AILK_LEAD_NOTIFICATION_TO",
565
+ "AILK_LEAD_NOTIFICATION_FROM",
566
+ "AILK_INBOX_FALLBACK_DIR",
567
+ "LEAD_CAPTURE_FILE",
568
+ ] as const;
569
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
570
+ let tmpDir: string;
571
+ const listFiles = () => fs.readdir(tmpDir);
572
+
573
+ const EMAIL_ENV = {
574
+ RESEND_API_KEY: "re_test",
575
+ AILK_LEAD_NOTIFICATION_TO: "owner@example.com",
576
+ AILK_LEAD_NOTIFICATION_FROM: "noreply@example.com",
577
+ };
578
+
579
+ beforeEach(async () => {
580
+ jest.clearAllMocks();
581
+ saved = {};
582
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
583
+ for (const k of ENV_KEYS) delete process.env[k];
584
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-delivery-test-"));
585
+ process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
586
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_1" });
587
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-1" });
588
+ });
589
+
590
+ afterEach(async () => {
591
+ for (const k of ENV_KEYS) {
592
+ if (saved[k] === undefined) delete process.env[k];
593
+ else process.env[k] = saved[k];
594
+ }
595
+ await fs.rm(tmpDir, { recursive: true, force: true });
596
+ });
597
+
598
+ describe("email delivery (the three email vars, no DATABASE_URL, production)", () => {
599
+ beforeEach(() => {
600
+ Object.assign(process.env, EMAIL_ENV, { NODE_ENV: "production" });
601
+ });
602
+
603
+ it("an accepted email → ok:true persisted:email with an email- id; no Prisma, no file fallback", async () => {
604
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
605
+ RouteLeadResult,
606
+ { ok: true }
607
+ >;
608
+
609
+ expect(result).toMatchObject({
610
+ ok: true,
611
+ persisted: "email",
612
+ email: { ok: true, messageId: "msg-1" },
613
+ });
614
+ expect(result.id).toMatch(/^email-/);
615
+ expect(result.id).toMatch(/^[a-z0-9_-]{8,64}$/i);
616
+ expect(prisma.lead.create).not.toHaveBeenCalled();
617
+ expect(await listFiles()).toEqual([]);
618
+ expect(mockSendLeadNotification).toHaveBeenCalledTimes(1);
619
+ });
620
+
621
+ it("a refused email → ok:false delivery_failed (never reported as sent)", async () => {
622
+ mockSendLeadNotification.mockResolvedValue({
623
+ ok: false,
624
+ error: { kind: "provider_error", message: "rejected" },
625
+ });
626
+
627
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
628
+ RouteLeadResult,
629
+ { ok: false }
630
+ >;
631
+
632
+ expect(result.ok).toBe(false);
633
+ expect(result.error.kind).toBe("delivery_failed");
634
+ expect(prisma.lead.create).not.toHaveBeenCalled();
635
+ expect(await listFiles()).toEqual([]);
636
+ });
637
+
638
+ it("a skipped email (provider reports the key missing) → delivery_failed", async () => {
639
+ mockSendLeadNotification.mockResolvedValue({
640
+ ok: false,
641
+ error: { kind: "config_missing", message: "RESEND_API_KEY not set" },
642
+ });
643
+
644
+ const result = await routeLead(leadInput, sourceMeta);
645
+ expect(result).toMatchObject({ ok: false, error: { kind: "delivery_failed" } });
646
+ });
647
+
648
+ it("a callback is delivered by email too — carrying the name and number to call", async () => {
649
+ const result = await routeLead(
650
+ { ...callbackInput, preferredTime: "mornings" },
651
+ sourceMeta,
652
+ );
653
+ expect(result).toMatchObject({ ok: true, persisted: "email" });
654
+ expect(prisma.callback.create).not.toHaveBeenCalled();
655
+ const arg = mockSendLeadNotification.mock.calls[0][0];
656
+ expect(arg.lead.payload).toMatchObject({
657
+ name: "Alice",
658
+ phone: "+15551234567",
659
+ preferredTime: "mornings",
660
+ notes: "urgent",
661
+ });
662
+ // No visitor address to reply to — never an empty Reply-To.
663
+ expect(arg.replyToOverride).toBe("owner@example.com");
664
+ });
665
+
666
+ it("a newsletter subscribe is refused with config_missing — a signup needs a database", async () => {
667
+ const result = await routeLead(subscribeInput, sourceMeta);
668
+ expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
669
+ expect(prisma.subscriber.create).not.toHaveBeenCalled();
670
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
671
+ });
672
+ });
673
+
674
+ describe("production with neither a database nor the email vars", () => {
675
+ beforeEach(() => {
676
+ process.env.NODE_ENV = "production";
677
+ });
678
+
679
+ it("refuses with config_missing before any write", async () => {
680
+ const result = await routeLead(leadInput, sourceMeta);
681
+
682
+ expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
683
+ expect(prisma.lead.create).not.toHaveBeenCalled();
684
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
685
+ expect(await listFiles()).toEqual([]);
686
+ });
687
+ });
688
+
689
+ describe("inbox delivery in production with the database failing (not a constraint)", () => {
690
+ beforeEach(() => {
691
+ process.env.DATABASE_URL = "postgres://test";
692
+ process.env.NODE_ENV = "production";
693
+ (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
694
+ });
695
+
696
+ it("no email vars → persist_failed, and the non-durable file fallback is not attempted", async () => {
697
+ const result = await routeLead(leadInput, sourceMeta);
698
+
699
+ expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
700
+ expect(await listFiles()).toEqual([]);
701
+ });
702
+
703
+ it("email vars set and the send accepted → persisted:email", async () => {
704
+ Object.assign(process.env, EMAIL_ENV);
705
+
706
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
707
+ RouteLeadResult,
708
+ { ok: true }
709
+ >;
710
+
711
+ expect(result).toMatchObject({ ok: true, persisted: "email" });
712
+ expect(result.id).toMatch(/^email-/);
713
+ expect(await listFiles()).toEqual([]);
714
+ });
715
+
716
+ it("a newsletter signup is never emailed — persist_failed", async () => {
717
+ Object.assign(process.env, EMAIL_ENV);
718
+ (prisma.subscriber.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
719
+
720
+ const result = await routeLead(subscribeInput, sourceMeta);
721
+
722
+ expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
723
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
724
+ });
725
+
726
+ it("email vars set but the send refused → persist_failed", async () => {
727
+ Object.assign(process.env, EMAIL_ENV);
728
+ mockSendLeadNotification.mockResolvedValue({
729
+ ok: false,
730
+ error: { kind: "provider_error", message: "rejected" },
731
+ });
732
+
733
+ const result = await routeLead(leadInput, sourceMeta);
734
+ expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
735
+ });
736
+ });
737
+
738
+ describe("inbox delivery outside production keeps today's file fallback", () => {
739
+ it("DB down in test → persisted:file", async () => {
740
+ process.env.DATABASE_URL = "postgres://test";
741
+ process.env.NODE_ENV = "test";
742
+ (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
743
+
744
+ const result = await routeLead(leadInput, sourceMeta);
745
+ expect(result).toMatchObject({ ok: true, persisted: "file" });
746
+ expect((await listFiles()).length).toBeGreaterThan(0);
747
+ });
748
+ });
749
+ });
@@ -327,3 +327,127 @@ describe("listOwnedLeads", () => {
327
327
  expect(second.value.nextCursor).toBeNull();
328
328
  });
329
329
  });
330
+
331
+ // ─── #6730 D7 — the single-tenant inbox, pinned to the operator ──────────────
332
+
333
+ describe("listOwnedLeads — single-tenant build (#6730 D7)", () => {
334
+ const { DEFAULT_ORG_ID, DEFAULT_WORKSPACE_ID } = jest.requireActual(
335
+ "@working-theory/database/tenancy",
336
+ ) as { DEFAULT_ORG_ID: string; DEFAULT_WORKSPACE_ID: string };
337
+ const OPERATOR_EMAIL = "owner@example.com";
338
+ const prior = {
339
+ modules: process.env.AILK_ENABLED_MODULES,
340
+ pin: process.env.AILK_OPERATOR_EMAIL,
341
+ };
342
+
343
+ /** One workspace — the seeded default — and a caller whose verified email is `email`. */
344
+ function oneWorkspaceDeployment(opts: {
345
+ email: string;
346
+ verified?: boolean;
347
+ workspaceCount?: number;
348
+ role?: string;
349
+ }) {
350
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(opts.workspaceCount ?? 1);
351
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
352
+ id: DEFAULT_WORKSPACE_ID,
353
+ organizationId: DEFAULT_ORG_ID,
354
+ });
355
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
356
+ id: USER_ID,
357
+ email: opts.email,
358
+ emailVerified: opts.verified ?? true,
359
+ });
360
+ (prisma.membership.create as jest.Mock).mockResolvedValue({});
361
+ (prisma.membership.findUnique as jest.Mock).mockResolvedValue({
362
+ role: opts.role ?? "owner",
363
+ });
364
+ }
365
+
366
+ beforeEach(() => {
367
+ jest.clearAllMocks();
368
+ delete process.env.AILK_ENABLED_MODULES;
369
+ process.env.AILK_OPERATOR_EMAIL = OPERATOR_EMAIL;
370
+ seedLeadTable([
371
+ lead({ id: "lead_own", siteId: DEFAULT_WORKSPACE_ID }),
372
+ lead({ id: "lead_elsewhere", siteId: OTHER_SITE_ID }),
373
+ ]);
374
+ });
375
+
376
+ afterAll(() => {
377
+ for (const [key, value] of [
378
+ ["AILK_ENABLED_MODULES", prior.modules],
379
+ ["AILK_OPERATOR_EMAIL", prior.pin],
380
+ ] as const) {
381
+ if (value === undefined) delete process.env[key];
382
+ else process.env[key] = value;
383
+ }
384
+ });
385
+
386
+ it("the pinned, verified owner sees the default workspace's leads — and only those", async () => {
387
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
388
+
389
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
390
+
391
+ expect(result.ok).toBe(true);
392
+ if (!result.ok) return;
393
+ expect(result.value.items.map((r) => r.id)).toEqual(["lead_own"]);
394
+ });
395
+
396
+ it("the pin unset → empty", async () => {
397
+ delete process.env.AILK_OPERATOR_EMAIL;
398
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
399
+
400
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
401
+
402
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
403
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
404
+ });
405
+
406
+ it("a different verified email → empty", async () => {
407
+ oneWorkspaceDeployment({ email: "stranger@example.com" });
408
+
409
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
410
+
411
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
412
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it("the pinned email, unverified → empty", async () => {
416
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL, verified: false });
417
+
418
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
419
+
420
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
421
+ });
422
+
423
+ it("a second workspace → empty (not unambiguously one-workspace)", async () => {
424
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL, workspaceCount: 2 });
425
+
426
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
427
+
428
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
429
+ });
430
+
431
+ it("a `member` role → empty — the role floor still holds", async () => {
432
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL, role: "member" });
433
+
434
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
435
+
436
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
437
+ });
438
+
439
+ it("with multi-tenant on, resolveOwnerWorkspaces is the path — the default-tenant resolver is not called", async () => {
440
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
441
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
442
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
443
+ const ownerWorkspaces = jest.requireActual("../owner-workspaces.js");
444
+ const spy = jest.spyOn(ownerWorkspaces, "resolveOwnerWorkspaces");
445
+
446
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
447
+
448
+ expect(spy).toHaveBeenCalledWith(USER_ID);
449
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
450
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
451
+ spy.mockRestore();
452
+ });
453
+ });