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
@@ -12,19 +12,40 @@
12
12
  * Only the first 8 chars of the session ID appear in log lines.
13
13
  * - Error responses never echo user input (productCode, URLs).
14
14
  * - No DB write: this unit creates a Stripe session only; webhook-driven
15
- * persistence is a follow-up issue.
15
+ * persistence is a follow-up issue. It READS the database only to resolve
16
+ * an optional signed-in buyer (below).
17
+ * - Optional cookie identity (#6866): a valid, same-origin Better Auth
18
+ * session cookie whose user has a verified email makes the Checkout a
19
+ * signed-in one — the session is pinned to the buyer's Stripe customer and
20
+ * stamped `metadata.ailk_user_id`, so the #6852 webhook binds it. The id
21
+ * comes ONLY from the session cookie, never the body; a bearer token never
22
+ * identifies a buyer. Any lookup failure, and a pinned customer Stripe
23
+ * does not know, degrades to today's anonymous Checkout — never an error.
24
+ * The stamped id is never echoed or logged.
16
25
  * - X-Internal-Auth gate: when OSS_API_INTERNAL_SECRET env var is set,
17
26
  * the X-Internal-Auth header must match; requests without a matching header
18
27
  * are rejected with 401. When the env var is unset, no gate is applied
19
28
  * (backward-compat for OSS standalone deployments). See #935.
29
+ * - Same-origin redirects (#6877): a request that did not pass an ENABLED
30
+ * internal-auth gate (the OSS default) must send successUrl and cancelUrl
31
+ * on the site's own origin — `FRONTEND_URL`'s, exact scheme + host + port,
32
+ * checked on the raw string as well as on the parse, because the raw
33
+ * string is what Stripe receives. Anything else is a 400 before any Stripe
34
+ * call or database read. A caller authenticated by X-Internal-Auth keeps
35
+ * its redirects; that exemption holds only while every internal caller
36
+ * builds its redirects itself — an internal caller MUST NEVER forward a
37
+ * redirect taken from its own end user.
20
38
  *
21
39
  * Error mapping:
22
40
  * 401 internal-auth-required — X-Internal-Auth missing/mismatch (only when env var set)
23
- * 400 validation-error — Zod validation failed (missing/invalid fields)
41
+ * 400 validation-error — Zod validation failed (missing/invalid fields), or an
42
+ * unauthenticated redirect is off the site origin (#6877)
24
43
  * 404 unknown-product-code — no active Stripe price for the given productCode
25
44
  * 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
26
45
  * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
27
- * 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper)
46
+ * 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
47
+ * or FRONTEND_URL is not an absolute http(s) URL / is unset in
48
+ * production when the origin lock applies (#6877)
28
49
  * 502 checkout-unavailable — Stripe SDK threw an unexpected error
29
50
  */
30
51
  import { timingSafeEqual } from "node:crypto";
@@ -32,12 +53,28 @@ import { timingSafeEqual } from "node:crypto";
32
53
  import type { FastifyReply, FastifyRequest } from "fastify";
33
54
  import { z } from "zod";
34
55
 
56
+ import { config } from "../../config/index.js";
35
57
  import {
36
58
  assertSandboxKey,
37
59
  createCheckoutSession,
38
60
  StripeConfigError,
61
+ UnknownCustomerError,
39
62
  UnknownProductCodeError,
40
63
  } from "../../lib/stripe.js";
64
+ import type {
65
+ CreateCheckoutSessionInput,
66
+ CreateCheckoutSessionOutput,
67
+ } from "../../lib/stripe.js";
68
+ import {
69
+ type OptionalCookieUser,
70
+ resolveOptionalCookieUser,
71
+ } from "../../middleware/auth.js";
72
+ import {
73
+ type CheckoutBuyerResult,
74
+ resolveCheckoutBuyer,
75
+ } from "../../services/checkout-buyer.js";
76
+
77
+ type OptionalCookieUserNone = Extract<OptionalCookieUser, { kind: "none" }>;
41
78
 
42
79
  // ── Input validation schema ───────────────────────────────────────────────────
43
80
 
@@ -81,14 +118,13 @@ const CheckoutSessionRequestSchema = z.object({
81
118
  // ── Internal-auth gate ────────────────────────────────────────────────────────
82
119
 
83
120
  /**
84
- * Enforce the X-Internal-Auth gate when OSS_API_INTERNAL_SECRET is configured.
121
+ * Classify the request against the X-Internal-Auth gate (#935, #6877 D3).
85
122
  *
86
123
  * Gate is env-var-conditional:
87
- * - env var SET → header must be present and must match exactly
88
- * - env var UNSET → no gate applied (OSS standalone backward-compat)
89
- *
90
- * Returns true if the request should be allowed through; false if it must be
91
- * rejected with 401.
124
+ * - env var SET, header missing or wrong → "refused" (401)
125
+ * - env var SET, header matches → "authenticated" (redirects kept)
126
+ * - env var UNSET → "open" (OSS standalone backward-
127
+ * compat; the request is anonymous, so the origin lock applies)
92
128
  *
93
129
  * Uses a constant-time string comparison to prevent timing attacks on the
94
130
  * secret value. Node's `crypto.timingSafeEqual` requires equal-length Buffers,
@@ -96,20 +132,116 @@ const CheckoutSessionRequestSchema = z.object({
96
132
  * no information about the secret's content beyond its length, which is
97
133
  * acceptable for a shared internal secret).
98
134
  */
99
- function passesInternalAuthGate(request: FastifyRequest): boolean {
135
+ function internalAuthVerdict(
136
+ request: FastifyRequest,
137
+ ): "refused" | "open" | "authenticated" {
100
138
  const expectedSecret = process.env["OSS_API_INTERNAL_SECRET"];
101
139
  // Gate is disabled when env var is not set — OSS standalone backward-compat.
102
- if (!expectedSecret) return true;
140
+ if (!expectedSecret) return "open";
103
141
 
104
142
  const provided = request.headers["x-internal-auth"];
105
- if (typeof provided !== "string" || !provided) return false;
143
+ if (typeof provided !== "string" || !provided) return "refused";
106
144
 
107
145
  // Constant-time comparison.
108
146
  const expectedBuf = Buffer.from(expectedSecret, "utf8");
109
147
  const providedBuf = Buffer.from(provided, "utf8");
110
- if (expectedBuf.length !== providedBuf.length) return false;
148
+ if (expectedBuf.length !== providedBuf.length) return "refused";
149
+
150
+ return timingSafeEqual(expectedBuf, providedBuf)
151
+ ? "authenticated"
152
+ : "refused";
153
+ }
154
+
155
+ // ── Same-origin redirect lock (#6877) ────────────────────────────────────────
156
+
157
+ /**
158
+ * The site's own origin, from `FRONTEND_URL` (#6877 D6). `null` when the
159
+ * setting does not parse, parses to a non-http(s) scheme (whose origin would
160
+ * be the literal `"null"`), or is unset in production — where the config
161
+ * default `http://localhost:3000` would lock a live site to localhost.
162
+ */
163
+ function siteRedirectOrigin(): string | null {
164
+ if (!process.env["FRONTEND_URL"] && config.NODE_ENV === "production") {
165
+ return null;
166
+ }
167
+ let parsed: URL;
168
+ try {
169
+ parsed = new URL(config.FRONTEND_URL);
170
+ } catch {
171
+ return null;
172
+ }
173
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
174
+ return parsed.origin;
175
+ }
176
+
177
+ /** C0 controls, space, DEL and backslash — anything a parser may normalize. */
178
+ // eslint-disable-next-line no-control-regex -- the control-character range is exactly what D2 check 1 must refuse.
179
+ const NON_CANONICAL_URL_CHAR = /[\u0000-\u0020\u007f\\]/;
180
+
181
+ /**
182
+ * True only when `raw` is on `siteOrigin` both as written and as parsed
183
+ * (#6877 D2). The raw-string checks matter because the route forwards `raw`
184
+ * itself to Stripe: WHATWG parses `https://example.com\@evil.test/x` to
185
+ * origin `https://example.com`, and another parser may not. Requiring the
186
+ * literal origin followed by `/`, `?`, `#` or the end keeps
187
+ * `https://example.com.evil.test` out, and exact origin equality keeps a
188
+ * scheme downgrade or another port out.
189
+ */
190
+ function isSameOriginRedirect(raw: string, siteOrigin: string): boolean {
191
+ if (NON_CANONICAL_URL_CHAR.test(raw)) return false;
192
+ if (!raw.startsWith(siteOrigin)) return false;
193
+ const boundary = raw.charAt(siteOrigin.length);
194
+ if (boundary !== "" && !"/?#".includes(boundary)) return false;
195
+ let resolved: URL;
196
+ try {
197
+ resolved = new URL(raw);
198
+ } catch {
199
+ return false;
200
+ }
201
+ if (resolved.protocol !== "https:" && resolved.protocol !== "http:")
202
+ return false;
203
+ return resolved.origin === siteOrigin;
204
+ }
205
+
206
+ // ── Optional signed-in buyer (#6866) ─────────────────────────────────────────
207
+
208
+ type Buyer = NonNullable<CreateCheckoutSessionInput["buyer"]>;
209
+
210
+ /** Why a present cookie produced no buyer — the closed set D6 logs. */
211
+ type IdentityReason =
212
+ | Exclude<OptionalCookieUserNone["reason"], "no_cookie">
213
+ | Extract<CheckoutBuyerResult, { ok: false }>["reason"]
214
+ | "buyer_lookup_failed"
215
+ | "buyer_invalid"
216
+ | "stale_customer";
111
217
 
112
- return timingSafeEqual(expectedBuf, providedBuf);
218
+ /**
219
+ * Resolve the signed-in buyer from the session cookie alone (#6866 D2/D3).
220
+ * Never throws: a failed lookup is anonymous (F7). `identity` is the reason a
221
+ * present cookie produced no buyer — logged, never the user's id; absent for
222
+ * a call with no cookie (D6).
223
+ */
224
+ async function resolveBuyer(
225
+ request: FastifyRequest,
226
+ ): Promise<{ buyer?: Buyer; identity?: IdentityReason }> {
227
+ const cookieUser = await resolveOptionalCookieUser(request, {
228
+ frontendOrigin: config.FRONTEND_URL,
229
+ });
230
+ if (cookieUser.kind === "none") {
231
+ return cookieUser.reason === "no_cookie"
232
+ ? {}
233
+ : { identity: cookieUser.reason };
234
+ }
235
+ try {
236
+ const result = await resolveCheckoutBuyer(cookieUser.userId);
237
+ if (!result.ok) return { identity: result.reason };
238
+ const { userId, email, customerId } = result.buyer;
239
+ return {
240
+ buyer: { userId, email, ...(customerId !== null && { customerId }) },
241
+ };
242
+ } catch {
243
+ return { identity: "buyer_lookup_failed" };
244
+ }
113
245
  }
114
246
 
115
247
  // ── Handler ───────────────────────────────────────────────────────────────────
@@ -119,7 +251,8 @@ export async function checkoutSessionsHandler(
119
251
  reply: FastifyReply,
120
252
  ): Promise<void> {
121
253
  // ── X-Internal-Auth gate (env-var-conditional) ────────────────────────────
122
- if (!passesInternalAuthGate(request)) {
254
+ const authVerdict = internalAuthVerdict(request);
255
+ if (authVerdict === "refused") {
123
256
  return reply.status(401).send({ error: "internal-auth-required" });
124
257
  }
125
258
 
@@ -144,16 +277,79 @@ export async function checkoutSessionsHandler(
144
277
  const { productCode, successUrl, cancelUrl, quantity, allowPromotionCodes } =
145
278
  parsed.data;
146
279
 
280
+ // ── Same-origin redirect lock (#6877) — before any Stripe or DB call ──────
281
+ if (authVerdict === "open") {
282
+ const siteOrigin = siteRedirectOrigin();
283
+ if (siteOrigin === null) {
284
+ request.log.error(
285
+ { method: request.method, path: request.url },
286
+ "[checkout] FRONTEND_URL is not a valid absolute URL",
287
+ );
288
+ return reply.status(500).send({ error: "config-error" });
289
+ }
290
+ const refused = (["successUrl", "cancelUrl"] as const).filter(
291
+ (field) => !isSameOriginRedirect(parsed.data[field], siteOrigin),
292
+ );
293
+ if (refused.length > 0) {
294
+ // Field names only — the refused URL is never logged or echoed.
295
+ request.log.warn(
296
+ {
297
+ method: request.method,
298
+ path: request.url,
299
+ outcome: "redirect_origin_refused",
300
+ fields: refused,
301
+ },
302
+ "[checkout] redirect origin refused",
303
+ );
304
+ return reply.status(400).send({
305
+ error: "validation-error",
306
+ details: refused.map((field) => ({
307
+ code: "custom",
308
+ path: [field],
309
+ message: `${field} must be on this site's origin`,
310
+ })),
311
+ });
312
+ }
313
+ }
314
+
315
+ const sessionInput: CreateCheckoutSessionInput = {
316
+ productCode,
317
+ successUrl,
318
+ cancelUrl,
319
+ quantity: quantity ?? 1,
320
+ // Omitted when the caller says nothing — Stripe's default (no promo-code
321
+ // field) is unchanged for every existing caller.
322
+ ...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
323
+ };
324
+ // Built only from the session cookie — never from `parsed.data` (#6866 D5).
325
+ let { buyer, identity } = await resolveBuyer(request);
326
+
147
327
  try {
148
- const { sessionId, url } = await createCheckoutSession({
149
- productCode,
150
- successUrl,
151
- cancelUrl,
152
- quantity: quantity ?? 1,
153
- // Omitted when the caller says nothing — Stripe's default (no promo-code
154
- // field) is unchanged for every existing caller.
155
- ...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
156
- });
328
+ let created: CreateCheckoutSessionOutput;
329
+ try {
330
+ created = await createCheckoutSession({
331
+ ...sessionInput,
332
+ ...(buyer !== undefined && { buyer }),
333
+ });
334
+ } catch (err) {
335
+ // The buyer itself cannot be used — a pinned customer Stripe does not
336
+ // know (#6866 D8), or a stored email / customer id the library's schema
337
+ // refuses (F7: no new failure mode). Retry once as anonymous; the
338
+ // stored binding is never touched here. The request fields already
339
+ // passed the route's schema, so an anonymous retry that still fails
340
+ // maps exactly as today.
341
+ const reason: IdentityReason | undefined =
342
+ err instanceof UnknownCustomerError
343
+ ? "stale_customer"
344
+ : err instanceof Error && err.name === "ZodError"
345
+ ? "buyer_invalid"
346
+ : undefined;
347
+ if (buyer === undefined || reason === undefined) throw err;
348
+ buyer = undefined;
349
+ identity = reason;
350
+ created = await createCheckoutSession(sessionInput);
351
+ }
352
+ const { sessionId, url } = created;
157
353
 
158
354
  request.log.info(
159
355
  {
@@ -161,6 +357,8 @@ export async function checkoutSessionsHandler(
161
357
  path: request.url,
162
358
  sessionIdPrefix: sessionId.substring(0, 8),
163
359
  status: 201,
360
+ buyer: buyer !== undefined ? "signed_in" : "anonymous",
361
+ ...(identity !== undefined && { identity }),
164
362
  },
165
363
  "[checkout] session created",
166
364
  );
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @file capture.test.ts
3
+ * `GET /v1/leads/capture` (#6730 D4) — the four D1 environments, and the
4
+ * body's exact shape: two booleans, never the mode or an env name.
5
+ */
6
+
7
+ import type { FastifyInstance } from "fastify";
8
+
9
+ // The route module also carries the write routes; their dependencies are
10
+ // external boundaries this read never reaches.
11
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts is the repo-standard double
12
+ jest.mock("../../../lib/prisma.js");
13
+ jest.mock("@working-theory/email", () => ({
14
+ sendLeadNotification: jest.fn(),
15
+ sendRequestConfirmEmail: jest.fn(),
16
+ sendRequestPaidLinkEmail: jest.fn(),
17
+ }));
18
+
19
+ import { leadCaptureStatusRoute } from "../index";
20
+
21
+ const ENV_KEYS = [
22
+ "DATABASE_URL",
23
+ "NODE_ENV",
24
+ "RESEND_API_KEY",
25
+ "AILK_LEAD_NOTIFICATION_TO",
26
+ "AILK_LEAD_NOTIFICATION_FROM",
27
+ "LEAD_CAPTURE_FILE_DISABLED",
28
+ ] as const;
29
+
30
+ const EMAIL_ENV = {
31
+ RESEND_API_KEY: "re_test",
32
+ AILK_LEAD_NOTIFICATION_TO: "owner@example.com",
33
+ AILK_LEAD_NOTIFICATION_FROM: "noreply@example.com",
34
+ };
35
+
36
+ describe("GET /v1/leads/capture", () => {
37
+ let app: FastifyInstance;
38
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
39
+
40
+ beforeEach(async () => {
41
+ saved = {};
42
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
43
+ for (const k of ENV_KEYS) delete process.env[k];
44
+ const fastify = require("fastify")();
45
+ await fastify.register(leadCaptureStatusRoute);
46
+ app = fastify;
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await app.close();
51
+ for (const k of ENV_KEYS) {
52
+ if (saved[k] === undefined) delete process.env[k];
53
+ else process.env[k] = saved[k];
54
+ }
55
+ });
56
+
57
+ const get = () => app.inject({ method: "GET", url: "/v1/leads/capture" });
58
+
59
+ it.each([
60
+ ["inbox", { DATABASE_URL: "postgres://x", NODE_ENV: "production" }, true, true],
61
+ ["email", { ...EMAIL_ENV, NODE_ENV: "production" }, true, false],
62
+ ["local", { NODE_ENV: "development" }, true, true],
63
+ ["none", { NODE_ENV: "production" }, false, false],
64
+ ] as const)("%s → contact=%s newsletter=%s", async (_mode, env, contact, newsletter) => {
65
+ Object.assign(process.env, env);
66
+
67
+ const res = await get();
68
+
69
+ expect(res.statusCode).toBe(200);
70
+ expect(res.json()).toEqual({ ok: true, data: { contact, newsletter } });
71
+ });
72
+
73
+ it("the body carries exactly ok + data{contact,newsletter} — no mode, no env names", async () => {
74
+ Object.assign(process.env, EMAIL_ENV, { NODE_ENV: "production" });
75
+
76
+ const res = await get();
77
+ const body = res.json();
78
+
79
+ expect(Object.keys(body).sort()).toEqual(["data", "ok"]);
80
+ expect(Object.keys(body.data).sort()).toEqual(["contact", "newsletter"]);
81
+ expect(res.body).not.toMatch(/email|inbox|RESEND|DATABASE|owner@example\.com/);
82
+ });
83
+
84
+ it("is cacheable for a minute", async () => {
85
+ const res = await get();
86
+ expect(res.headers["cache-control"]).toBe("public, max-age=60");
87
+ });
88
+ });
@@ -1236,3 +1236,103 @@ describe("#6335 — the shared prisma mock declares no workspace default", () =>
1236
1236
  });
1237
1237
  });
1238
1238
  });
1239
+
1240
+ // ─── #6730 D2/D5 — email delivery and the production refusal, over HTTP ─────
1241
+
1242
+ describe("POST /v1/leads/submit — #6730 delivery modes", () => {
1243
+ let app: FastifyInstance;
1244
+ const ENV_KEYS = [
1245
+ "DATABASE_URL",
1246
+ "NODE_ENV",
1247
+ "RESEND_API_KEY",
1248
+ "AILK_LEAD_NOTIFICATION_TO",
1249
+ "AILK_LEAD_NOTIFICATION_FROM",
1250
+ "LEAD_CAPTURE_FILE",
1251
+ "AILK_INBOX_FALLBACK_DIR",
1252
+ ] as const;
1253
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1254
+ const sendLeadNotification = jest.requireMock("@working-theory/email")
1255
+ .sendLeadNotification as jest.Mock;
1256
+ const writeLeadFallbackSpy = jest.spyOn(
1257
+ jest.requireActual("../../../lib/file-fallback.js"),
1258
+ "writeLeadFallback",
1259
+ );
1260
+
1261
+ const submit = () =>
1262
+ app.inject({
1263
+ method: "POST",
1264
+ url: "/v1/leads/submit",
1265
+ payload: {
1266
+ kind: "submit",
1267
+ source: "contact",
1268
+ email: "visitor@example.com",
1269
+ payload: { message: "hello" },
1270
+ },
1271
+ });
1272
+
1273
+ beforeEach(async () => {
1274
+ jest.clearAllMocks();
1275
+ saved = {};
1276
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
1277
+ for (const k of ENV_KEYS) delete process.env[k];
1278
+ process.env.NODE_ENV = "production";
1279
+ const fastify = require("fastify")();
1280
+ await fastify.register(leadSubmitRoute);
1281
+ app = fastify;
1282
+ });
1283
+
1284
+ afterEach(async () => {
1285
+ await app.close();
1286
+ for (const k of ENV_KEYS) {
1287
+ if (saved[k] === undefined) delete process.env[k];
1288
+ else process.env[k] = saved[k];
1289
+ }
1290
+ });
1291
+
1292
+ describe("email delivery (no DATABASE_URL, the three email vars)", () => {
1293
+ beforeEach(() => {
1294
+ process.env.RESEND_API_KEY = "re_test";
1295
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1296
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1297
+ });
1298
+
1299
+ it("an accepted email → 201 persisted:email; no Prisma write", async () => {
1300
+ sendLeadNotification.mockResolvedValueOnce({ ok: true, messageId: "msg-e" });
1301
+
1302
+ const res = await submit();
1303
+
1304
+ expect(res.statusCode).toBe(201);
1305
+ expect(res.json()).toMatchObject({ persisted: "email" });
1306
+ expect(res.json().id).toMatch(/^email-/);
1307
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1308
+ expect(sendLeadNotification).toHaveBeenCalledTimes(1);
1309
+ expect(sendLeadNotification.mock.calls[0][0].recipientEmail).toBe("owner@example.com");
1310
+ });
1311
+
1312
+ it("a refused email → 503 delivery_failed, never 201", async () => {
1313
+ sendLeadNotification.mockResolvedValueOnce({
1314
+ ok: false,
1315
+ error: { kind: "provider_error", message: "rejected" },
1316
+ });
1317
+
1318
+ const res = await submit();
1319
+
1320
+ expect(res.statusCode).toBe(503);
1321
+ expect(res.json()).toEqual({
1322
+ error: "delivery_failed",
1323
+ message: "The message could not be delivered.",
1324
+ });
1325
+ });
1326
+ });
1327
+
1328
+ it("production with neither → 503 lead_capture_not_configured; no Prisma, no file, no email", async () => {
1329
+ const res = await submit();
1330
+
1331
+ expect(res.statusCode).toBe(503);
1332
+ expect(res.json().error).toBe("lead_capture_not_configured");
1333
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1334
+ expect(writeLeadFallbackSpy).not.toHaveBeenCalled();
1335
+ expect(sendLeadNotification).not.toHaveBeenCalled();
1336
+ await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
1337
+ });
1338
+ });
@@ -5,6 +5,16 @@
5
5
  * POST /v1/leads/submit — lead submission (contact form / inquiry)
6
6
  * POST /v1/leads/subscribe — newsletter subscription
7
7
  * POST /v1/leads/callback — callback request (phone-back)
8
+ * GET /v1/leads/capture — whether this deployment accepts a contact
9
+ * message / a newsletter signup (#6730 D4)
10
+ *
11
+ * API↔MCP PARITY — `GET /v1/leads/capture` has NO MCP twin, a recorded
12
+ * exception on the same footing as the anonymous form-lifecycle routes. It
13
+ * exists so a web page can choose between the form and the contact email
14
+ * before rendering; an agent never needs that preflight, because the three
15
+ * write tools (`capture_lead`, `subscribe_newsletter`, `request_callback`)
16
+ * already return a typed `api_error` carrying the 503 when capture is not
17
+ * accepted — the same answer, at the moment it matters.
8
18
  *
9
19
  * TENANT RESOLUTION (#6203, spec §10.4 operator ruling — reverses this
10
20
  * file's former byte-unchanged posture). `siteId` used to be taken VERBATIM
@@ -24,6 +34,7 @@
24
34
  import { track } from "@working-theory/analytics/server";
25
35
  import type { FastifyPluginAsync } from "fastify";
26
36
 
37
+ import { acceptsKind, resolveLeadDelivery } from "../../lib/lead-delivery.js";
27
38
  import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
28
39
  import {
29
40
  type LeadKind,
@@ -175,7 +186,20 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
175
186
  );
176
187
  return reply.status(503).send({ error: "persist_failed" });
177
188
  }
178
- // config_missing — lead capture not configured (file disabled + no DB)
189
+ if (result.error.kind === "delivery_failed") {
190
+ // Email was the delivery and the provider did not accept it —
191
+ // never reported as sent (#6730 D5). Detail stays server-side.
192
+ // `{ kind, siteId }` only (spec D5): provider error text can echo
193
+ // an address.
194
+ request.log.error({ kind, siteId: workspaceId }, "lead delivery_failed");
195
+ return reply.status(503).send({
196
+ error: "delivery_failed",
197
+ message: "The message could not be delivered.",
198
+ });
199
+ }
200
+ // config_missing — this deployment has nowhere to put this kind of
201
+ // lead (#6730 D1: production with neither a database nor the email
202
+ // vars, or a newsletter signup with no database).
179
203
  return reply.status(503).send({
180
204
  error: "lead_capture_not_configured",
181
205
  message: "Lead capture is not configured.",
@@ -249,3 +273,24 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
249
273
  export const leadSubmitRoute = makeLeadsRoute("submit");
250
274
  export const leadSubscribeRoute = makeLeadsRoute("subscribe");
251
275
  export const leadCallbackRoute = makeLeadsRoute("callback");
276
+
277
+ /**
278
+ * `GET /v1/leads/capture` (#6730 D4) — two booleans and nothing else: never
279
+ * an address or which env var is missing. (The pair implies as much of the
280
+ * delivery mode as the rendered page already shows.) Registered at
281
+ * the top level in `server.ts`, outside `leadsScope`'s strict per-IP limit
282
+ * (a web server's single egress IP already spends that on form submissions),
283
+ * so only the server-wide unauthenticated ceiling applies.
284
+ */
285
+ export const leadCaptureStatusRoute: FastifyPluginAsync = async (fastify) => {
286
+ fastify.get("/v1/leads/capture", async (_request, reply) => {
287
+ const delivery = resolveLeadDelivery();
288
+ return reply.header("Cache-Control", "public, max-age=60").send({
289
+ ok: true,
290
+ data: {
291
+ contact: acceptsKind(delivery, "submit"),
292
+ newsletter: acceptsKind(delivery, "subscribe"),
293
+ },
294
+ });
295
+ });
296
+ };
@@ -170,7 +170,7 @@ describe("AC-1 — completed stamps the row once, guarded, beside the legacy tas
170
170
  expect(prisma.user.findFirst).toHaveBeenCalledWith({
171
171
  where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
172
172
  });
173
- expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { email: EMAIL } });
173
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
174
174
  });
175
175
  });
176
176
 
@@ -232,7 +232,11 @@ describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
232
232
  (prisma.user.update as jest.Mock).mockResolvedValueOnce({ id: "usr_st" });
233
233
 
234
234
  mockVerifyWebhookSignature.mockReturnValue(
235
- checkoutEvent("evt_st_004", { productId: "pro" }, customerId),
235
+ checkoutEvent(
236
+ "evt_st_004",
237
+ { productId: "pro", ailk_user_id: "usr_st" },
238
+ customerId,
239
+ ),
236
240
  );
237
241
  const r = await fire(server);
238
242
  expect(r.statusCode).toBe(200);