create-ailk 0.6.1 → 0.8.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 (324) hide show
  1. package/README.md +87 -14
  2. package/component-catalog.json +735 -95
  3. package/component-catalog.md +13 -7
  4. package/dist/add-module-core.d.ts +22 -0
  5. package/dist/add-module-core.js +50 -0
  6. package/dist/add-module.d.ts +89 -0
  7. package/dist/add-module.js +334 -0
  8. package/dist/category-profile.d.ts +126 -0
  9. package/dist/category-profile.js +262 -0
  10. package/dist/cli.js +89 -29
  11. package/dist/index.d.ts +4 -2
  12. package/dist/index.js +6 -1
  13. package/dist/lib/extract-module-bundle.d.ts +56 -1
  14. package/dist/lib/extract-module-bundle.js +170 -14
  15. package/dist/lib/license-key-files.d.ts +15 -0
  16. package/dist/lib/license-key-files.js +102 -0
  17. package/dist/lib/module-license-gate.d.ts +79 -0
  18. package/dist/lib/module-license-gate.js +160 -12
  19. package/dist/lib/module-tier.d.ts +45 -16
  20. package/dist/lib/module-tier.js +76 -0
  21. package/dist/lib/pro-callout.d.ts +16 -10
  22. package/dist/lib/pro-callout.js +34 -3
  23. package/dist/license-set.d.ts +34 -0
  24. package/dist/license-set.js +194 -0
  25. package/dist/module-architecture.d.ts +20 -0
  26. package/dist/module-architecture.js +77 -19
  27. package/dist/modules/commerce/0.6.1.tar.gz +0 -0
  28. package/dist/modules/docs-blog/0.6.1.tar.gz +0 -0
  29. package/dist/modules/marketing-site/0.6.1.tar.gz +0 -0
  30. package/dist/modules/minimalism-site/0.6.1.tar.gz +0 -0
  31. package/dist/modules/multi-tenant/0.6.1.tar.gz +0 -0
  32. package/dist/modules/saas-product/0.6.1.tar.gz +0 -0
  33. package/dist/modules/scheduling/0.6.1.tar.gz +0 -0
  34. package/dist/modules/signal-collectors/0.6.1.tar.gz +0 -0
  35. package/dist/modules/simple-service/0.6.1.tar.gz +0 -0
  36. package/dist/modules/workspace/0.6.1.tar.gz +0 -0
  37. package/dist/parse-args.d.ts +85 -10
  38. package/dist/parse-args.js +227 -15
  39. package/dist/programmatic.d.ts +81 -5
  40. package/dist/programmatic.js +291 -32
  41. package/dist/site-chrome.d.ts +18 -7
  42. package/dist/site-chrome.js +32 -14
  43. package/dist/site-roster.d.ts +127 -0
  44. package/dist/site-roster.js +499 -0
  45. package/dist/starter-grid.d.ts +216 -0
  46. package/dist/starter-grid.js +449 -0
  47. package/dist/surfaces.d.ts +20 -7
  48. package/dist/surfaces.js +204 -24
  49. package/dist/sync-routes.d.ts +9 -0
  50. package/dist/sync-routes.js +2 -2
  51. package/dist/template-manifest.d.ts +94 -19
  52. package/dist/template-manifest.js +170 -289
  53. package/dist/template-names.d.ts +14 -12
  54. package/dist/template-names.js +13 -17
  55. package/package.json +6 -4
  56. package/site-templates/_base/content/en/BookCall/book-call.mdx +52 -0
  57. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  58. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  59. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  60. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  61. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +65 -0
  62. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +67 -0
  63. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  64. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  65. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  66. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  67. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  68. package/site-templates/_base/content/en/Pricing/pricing.mdx +71 -62
  69. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +17 -3
  70. package/site-templates/_base/content/en/TermsPage/terms.mdx +2 -3
  71. package/site-templates/_base/content/en/UseCase/persona-1.mdx +69 -0
  72. package/site-templates/_base/content/en/UseCase/persona-2.mdx +69 -0
  73. package/site-templates/_base/content/en/UseCase/persona-3.mdx +69 -0
  74. package/site-templates/designs/minimalism/DESIGN.md +162 -0
  75. package/site-templates/designs/standard/DESIGN.md +167 -0
  76. package/site-templates/marketing-site/DESIGN.md +59 -40
  77. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +376 -57
  78. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +230 -90
  79. package/site-templates/marketing-site/ailk-template.json +1 -0
  80. package/site-templates/marketing-site-free/DESIGN.md +59 -40
  81. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +323 -63
  82. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +323 -113
  83. package/site-templates/marketing-site-free/ailk-template.json +3 -2
  84. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  85. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  86. package/site-templates/minimalism-site/DESIGN.md +54 -40
  87. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +246 -19
  88. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +173 -51
  89. package/site-templates/minimalism-site/ailk-template.json +3 -2
  90. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +52 -0
  91. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +3 -37
  92. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  93. package/site-templates/saas-product/DESIGN.md +59 -40
  94. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  95. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +369 -0
  96. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +302 -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/shapes/local-business-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  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 +362 -0
  109. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +300 -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/shapes/organization-service/content/en/Service/first-engagement.mdx +38 -0
  117. package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +38 -0
  118. package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +38 -0
  119. package/site-templates/shapes/organization-service/content/en/ServicesList/services.mdx +72 -0
  120. package/site-templates/shapes/organization-service/provenance.json +4 -0
  121. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +369 -0
  122. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +299 -0
  123. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  124. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  125. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  126. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  127. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  128. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +169 -0
  129. package/site-templates/shapes/person-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  130. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  131. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  132. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  133. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  134. package/site-templates/shapes/person-service/provenance.json +4 -0
  135. package/templates/.claude/settings.json +15 -0
  136. package/templates/.claude/skills/README.md +10 -9
  137. package/templates/.claude/skills/{scaffold-auth → add-auth}/SKILL.md +7 -7
  138. package/templates/.claude/skills/{scaffold-auth → add-auth}/references/env-vars.md +2 -2
  139. package/templates/.claude/skills/{scaffold-auth → add-auth}/references/oauth-app-setup.md +3 -3
  140. package/templates/.claude/skills/{scaffold-page → add-page}/SKILL.md +76 -62
  141. package/templates/.claude/skills/add-page/references/page-types.md +115 -0
  142. package/templates/.claude/skills/{scaffold-page → add-page}/templates/content.mdx.tmpl +4 -4
  143. package/templates/.claude/skills/{scaffold-page → add-page}/templates/route.tsx.tmpl +1 -1
  144. package/templates/.claude/skills/{audit-site → check-site}/SKILL.md +16 -17
  145. package/templates/.claude/skills/{audit-site → check-site}/references/common-errors.md +3 -3
  146. package/templates/.claude/skills/{audit-site → check-site}/references/findings-glossary.md +16 -14
  147. package/templates/.claude/skills/{audit-site → check-site}/references/severity-levels.md +1 -1
  148. package/templates/.claude/skills/fill-page/SKILL.md +180 -0
  149. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +80 -0
  150. package/templates/.claude/skills/fill-page/references/section-sources.md +41 -0
  151. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  152. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  153. package/templates/.claude/skills/{provision-config → setup-deploy}/SKILL.md +73 -41
  154. package/templates/.claude/skills/{provision-config → setup-deploy}/references/env-vars-template.md +21 -30
  155. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +204 -0
  156. package/templates/.claude/skills/{provision-config → setup-deploy}/references/vercel-json-template.md +1 -1
  157. package/templates/.claude/skills/upgrade-plan/SKILL.md +117 -0
  158. package/templates/.claude/skills/upgrade-plan/references/options.md +24 -0
  159. package/templates/.claude/skills/upgrade-plan/scripts/upgrade-plan.ts +527 -0
  160. package/templates/.github/workflows/ci.yml +5 -0
  161. package/templates/CLAUDE.md +8 -2
  162. package/templates/DESIGN.md +56 -44
  163. package/templates/README.md +116 -46
  164. package/templates/apps/api/.env.example +2 -1
  165. package/templates/apps/api/CLAUDE.md +3 -2
  166. package/templates/apps/api/package.json +1 -1
  167. package/templates/apps/api/src/config/__tests__/index.test.ts +146 -10
  168. package/templates/apps/api/src/config/index.ts +46 -14
  169. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  170. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  171. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  172. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  173. package/templates/apps/api/src/lib/license-key-store.ts +45 -0
  174. package/templates/apps/api/src/lib/site.ts +5 -3
  175. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +97 -0
  176. package/templates/apps/api/src/middleware/auth.ts +9 -0
  177. package/templates/apps/api/src/openapi/spec.ts +63 -12
  178. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +127 -5
  179. package/templates/apps/api/src/routes/content/get-page.ts +86 -2
  180. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  181. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  182. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  183. package/templates/apps/api/src/server.ts +13 -0
  184. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  185. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  186. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  187. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  188. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +75 -1
  189. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +57 -9
  190. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  191. package/templates/apps/mcp/package.json +2 -1
  192. package/templates/apps/mcp/src/index.ts +7 -0
  193. package/templates/apps/mcp/src/lib/audit-single-page.ts +36 -10
  194. package/templates/apps/mcp/src/lib/license-key-store.ts +45 -0
  195. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  196. package/templates/apps/web/__tests__/setup.ts +18 -0
  197. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +10 -2
  198. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +2 -2
  199. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +10 -4
  200. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +22 -14
  201. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +26 -18
  202. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +12 -4
  203. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +35 -4
  204. package/templates/apps/web/app/[locale]/[slug]/page.tsx +49 -9
  205. package/templates/apps/web/app/[locale]/book-a-call/page.tsx +50 -0
  206. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
  207. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
  208. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +3 -4
  209. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +67 -29
  210. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +94 -11
  211. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  212. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  213. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +228 -86
  214. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  215. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  216. package/templates/apps/web/app/[locale]/contact/page.tsx +115 -7
  217. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  218. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
  219. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +3 -4
  220. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +4 -5
  221. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  222. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  223. package/templates/apps/web/app/[locale]/glossary/page.tsx +50 -0
  224. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  225. package/templates/apps/web/app/[locale]/how-to/page.tsx +25 -15
  226. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
  227. package/templates/apps/web/app/[locale]/layout.tsx +146 -52
  228. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  229. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  230. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  231. package/templates/apps/web/app/[locale]/newsletter/page.tsx +142 -0
  232. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
  233. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  234. package/templates/apps/web/app/[locale]/portfolio/page.tsx +49 -0
  235. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
  236. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -5
  237. package/templates/apps/web/app/[locale]/{cancellation → purchase-policy}/page.tsx +15 -10
  238. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +4 -5
  239. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
  240. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +14 -1
  241. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  242. package/templates/apps/web/app/[locale]/software/page.tsx +25 -15
  243. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  244. package/templates/apps/web/app/[locale]/tech-articles/page.tsx +25 -15
  245. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +3 -4
  246. package/templates/apps/web/app/[locale]/use-cases/[slug]/page.tsx +63 -0
  247. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +3 -4
  248. package/templates/apps/web/app/[locale]/use-cases/page.tsx +15 -2
  249. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  250. package/templates/apps/web/app/[locale]/whitepapers/page.tsx +25 -15
  251. package/templates/apps/web/app/error-fallback.tsx +17 -45
  252. package/templates/apps/web/app/not-found.tsx +14 -38
  253. package/templates/apps/web/app/og/route.tsx +6 -0
  254. package/templates/apps/web/eslint.config.mjs +3 -0
  255. package/templates/apps/web/instrumentation.ts +14 -0
  256. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -0
  257. package/templates/apps/web/lib/license-key-store.ts +52 -0
  258. package/templates/apps/web/lib/nav-config.ts +82 -18
  259. package/templates/apps/web/lib/prisma-for-auth.ts +9 -6
  260. package/templates/apps/web/lib/site-brand.tsx +1 -1
  261. package/templates/apps/web/lib/site-chrome.ts +3 -3
  262. package/templates/apps/web/lib/site-identity.ts +148 -0
  263. package/templates/apps/web/lib/site-theme.ts +11 -0
  264. package/templates/apps/web/messages/en.json +78 -37
  265. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  266. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  267. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  268. package/templates/apps/web/public/favicon-16x16.png +0 -0
  269. package/templates/apps/web/public/favicon-32x32.png +0 -0
  270. package/templates/apps/web/public/favicon.ico +0 -0
  271. package/templates/apps/web/public/favicon.svg +3 -3
  272. package/templates/apps/web/public/logomark.svg +6 -3
  273. package/templates/apps/web/public/logos/ai-launch-kit-light.svg +4 -0
  274. package/templates/apps/web/public/logos/ai-launch-kit.svg +4 -0
  275. package/templates/apps/web/public/logos/atomic-agent-light.svg +4 -0
  276. package/templates/apps/web/public/logos/atomic-agent.svg +4 -0
  277. package/templates/apps/web/public/logos/salessmyth-light.svg +4 -0
  278. package/templates/apps/web/public/logos/salessmyth.svg +4 -0
  279. package/templates/apps/web/public/site.webmanifest +4 -4
  280. package/templates/apps/web/public/wordmark-light.svg +4 -0
  281. package/templates/apps/web/public/wordmark.svg +3 -3
  282. package/templates/apps/web/security-headers.config.ts +5 -1
  283. package/templates/content/_site.mdx +25 -0
  284. package/templates/content/en/AboutPage/about.mdx +150 -144
  285. package/templates/content/en/BlogList/blog.mdx +126 -0
  286. package/templates/content/en/ContactPage/contact.mdx +21 -92
  287. package/templates/content/en/FAQPage/faq.mdx +86 -57
  288. package/templates/content/en/HomePage/home.mdx +268 -217
  289. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +181 -0
  290. package/templates/content/en/UseCasesList/use-cases.mdx +170 -0
  291. package/templates/database/CHANGELOG.md +14 -0
  292. package/templates/database/package.json +1 -1
  293. package/templates/package.json +3 -1
  294. package/templates/tools/lint-architecture.ts +315 -0
  295. package/templates/tools/tsconfig.json +14 -0
  296. package/versions.json +33 -33
  297. package/site-templates/simple-service/DESIGN.md +0 -148
  298. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +0 -240
  299. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -278
  300. package/site-templates/simple-service/ailk-template.json +0 -10
  301. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -67
  302. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  303. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  304. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  305. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  306. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  307. package/site-templates/simple-service/provenance.json +0 -4
  308. package/templates/.claude/skills/add-schema/SKILL.md +0 -277
  309. package/templates/.claude/skills/add-schema/references/conflict-handling.md +0 -200
  310. package/templates/.claude/skills/add-schema/references/file-finding.md +0 -123
  311. package/templates/.claude/skills/add-schema/references/schema-types.md +0 -234
  312. package/templates/.claude/skills/provision-config/references/runbook-template.md +0 -248
  313. package/templates/.claude/skills/scaffold-page/references/page-types.md +0 -169
  314. package/templates/.claude/skills/suggest-site-pages/SKILL.md +0 -242
  315. package/templates/.claude/skills/suggest-site-pages/references/categories.md +0 -287
  316. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -150
  317. package/templates/apps/web/app/[locale]/returns/page.tsx +0 -44
  318. package/templates/apps/web/app/[locale]/shipping/page.tsx +0 -44
  319. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +0 -108
  320. package/templates/content/en/LandingPage/blog.mdx +0 -83
  321. package/templates/content/en/LandingPage/features.mdx +0 -103
  322. package/templates/content/en/LandingPage/how-it-works.mdx +0 -143
  323. package/templates/content/en/ReturnsPolicyPage/returns.mdx +0 -108
  324. package/templates/content/en/ShippingPolicyPage/shipping.mdx +0 -108
@@ -747,9 +747,11 @@ export function buildOpenApiSpec(
747
747
  "form fields — for the given siteId. `source` is a free-form tag the caller " +
748
748
  "sets to identify the channel (for example `agent`); it is stored as-is and " +
749
749
  "not checked against a fixed vocabulary. A 201 response means the lead " +
750
- "landed, in the database or (if the database is unavailable) a file " +
751
- "fallback; the response's `email` field reports, best-effort, whether the " +
752
- "site operator was notified.",
750
+ "landed — in the database, a local file fallback (outside production " +
751
+ "only), or, on a deployment with no database, the site operator's inbox " +
752
+ "by email (`persisted: \"email\"`); with a database, the response's " +
753
+ "`email` field reports, best-effort, whether the operator was notified. " +
754
+ "A lead is never reported captured when nothing stored or delivered it.",
753
755
  requestBody: {
754
756
  required: true,
755
757
  content: {
@@ -777,8 +779,8 @@ export function buildOpenApiSpec(
777
779
  },
778
780
  "503": {
779
781
  description:
780
- "Lead capture unavailable — persistence failed, or neither file " +
781
- "capture nor a database is configured",
782
+ "Lead capture unavailable — persistence failed, delivery failed, or " +
783
+ "this deployment has nowhere to put the lead",
782
784
  content: {
783
785
  "application/json": {
784
786
  schema: {
@@ -787,6 +789,7 @@ export function buildOpenApiSpec(
787
789
  {
788
790
  $ref: "#/components/schemas/LeadCaptureNotConfiguredResponse",
789
791
  },
792
+ { $ref: "#/components/schemas/DeliveryFailedResponse" },
790
793
  ],
791
794
  },
792
795
  },
@@ -795,6 +798,36 @@ export function buildOpenApiSpec(
795
798
  },
796
799
  },
797
800
  },
801
+ "/v1/leads/capture": {
802
+ get: {
803
+ operationId: "getLeadCaptureStatus",
804
+ summary: "Whether this site accepts a contact message or a signup",
805
+ description:
806
+ "Two booleans: `contact` — a `POST /v1/leads/submit` would be accepted; " +
807
+ "`newsletter` — a `POST /v1/leads/subscribe` would be. A web page reads " +
808
+ "it to show the contact form or the site's contact email. It never says " +
809
+ "how a lead is delivered. No MCP twin (recorded parity exception): the " +
810
+ "three lead-write tools already answer the same question with a 503.",
811
+ responses: {
812
+ "200": {
813
+ description: "Lead-capture status",
814
+ content: {
815
+ "application/json": {
816
+ schema: {
817
+ type: "object",
818
+ required: ["ok", "data"],
819
+ properties: {
820
+ ok: { type: "boolean", enum: [true] },
821
+ data: { $ref: "#/components/schemas/LeadCaptureStatus" },
822
+ },
823
+ },
824
+ },
825
+ },
826
+ },
827
+ "429": { $ref: "#/components/responses/RateLimited" },
828
+ },
829
+ },
830
+ },
798
831
  "/v1/leads/subscribe": {
799
832
  post: {
800
833
  operationId: "subscribeLead",
@@ -833,8 +866,8 @@ export function buildOpenApiSpec(
833
866
  },
834
867
  "503": {
835
868
  description:
836
- "Lead capture unavailable — persistence failed, or neither file " +
837
- "capture nor a database is configured",
869
+ "Lead capture unavailable — persistence failed, delivery failed, or " +
870
+ "this deployment has nowhere to put the lead",
838
871
  content: {
839
872
  "application/json": {
840
873
  schema: {
@@ -1033,8 +1066,8 @@ export function buildOpenApiSpec(
1033
1066
  },
1034
1067
  "503": {
1035
1068
  description:
1036
- "Lead capture unavailable — persistence failed, or neither file " +
1037
- "capture nor a database is configured",
1069
+ "Lead capture unavailable — persistence failed, delivery failed, or " +
1070
+ "this deployment has nowhere to put the lead",
1038
1071
  content: {
1039
1072
  "application/json": {
1040
1073
  schema: {
@@ -1043,6 +1076,7 @@ export function buildOpenApiSpec(
1043
1076
  {
1044
1077
  $ref: "#/components/schemas/LeadCaptureNotConfiguredResponse",
1045
1078
  },
1079
+ { $ref: "#/components/schemas/DeliveryFailedResponse" },
1046
1080
  ],
1047
1081
  },
1048
1082
  },
@@ -1489,10 +1523,11 @@ export function buildOpenApiSpec(
1489
1523
  type: "string",
1490
1524
  description:
1491
1525
  "Server-assigned record id — a DB-prefixed entity id when " +
1492
- 'persisted is "db", or a `fallback-<ts>-<rand>` file id when ' +
1493
- 'persisted is "file"',
1526
+ 'persisted is "db", a `fallback-<ts>-<rand>` file id when ' +
1527
+ 'persisted is "file", or an `email-<ts>-<rand>` id when persisted ' +
1528
+ 'is "email" (no database; the operator email was the delivery)',
1494
1529
  },
1495
- persisted: { type: "string", enum: ["db", "file"] },
1530
+ persisted: { type: "string", enum: ["db", "file", "email"] },
1496
1531
  email: { $ref: "#/components/schemas/LeadEmailResult" },
1497
1532
  },
1498
1533
  },
@@ -1511,6 +1546,22 @@ export function buildOpenApiSpec(
1511
1546
  error: { type: "string", enum: ["persist_failed"] },
1512
1547
  },
1513
1548
  },
1549
+ DeliveryFailedResponse: {
1550
+ type: "object",
1551
+ required: ["error", "message"],
1552
+ properties: {
1553
+ error: { type: "string", enum: ["delivery_failed"] },
1554
+ message: { type: "string" },
1555
+ },
1556
+ },
1557
+ LeadCaptureStatus: {
1558
+ type: "object",
1559
+ required: ["contact", "newsletter"],
1560
+ properties: {
1561
+ contact: { type: "boolean" },
1562
+ newsletter: { type: "boolean" },
1563
+ },
1564
+ },
1514
1565
  LeadCaptureNotConfiguredResponse: {
1515
1566
  type: "object",
1516
1567
  required: ["error", "message"],
@@ -6,6 +6,9 @@
6
6
  * No internal module mocks — the route exercises the real adapter stack.
7
7
  */
8
8
 
9
+ import { writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
9
12
  import type { FastifyInstance } from "fastify";
10
13
 
11
14
  import { setupContentFixture } from "../../../__tests__/factories/content-fixture";
@@ -17,6 +20,30 @@ import { contentRoutes } from "../index";
17
20
 
18
21
  const fixture = setupContentFixture();
19
22
 
23
+ /**
24
+ * A `_site.mdx` in the fixture's content root — an Organization identity at
25
+ * `https://example.com` (#6597). `logo` adds an identity-declared logo.
26
+ */
27
+ function writeSiteMdx(root: string, opts: { logo?: string } = {}): void {
28
+ writeFileSync(
29
+ join(root, "_site.mdx"),
30
+ [
31
+ "---",
32
+ 'version: "1"',
33
+ "siteName: Fixture Site",
34
+ "baseUrl: https://example.com",
35
+ "defaultLocale: en",
36
+ "siteIdentity:",
37
+ " kind: Organization",
38
+ " name: Fixture Co",
39
+ " url: https://example.com",
40
+ ...(opts.logo !== undefined ? [` logo: ${opts.logo}`] : []),
41
+ "---",
42
+ "",
43
+ ].join("\n"),
44
+ );
45
+ }
46
+
20
47
  describe("GET /api/content/:type/:slug", () => {
21
48
  let app: FastifyInstance;
22
49
 
@@ -31,6 +58,7 @@ describe("GET /api/content/:type/:slug", () => {
31
58
 
32
59
  it("returns the page with resolved JSON-LD at the document root", async () => {
33
60
  // The fixture writes "home" HomePage in beforeEach by default.
61
+ writeSiteMdx(fixture.getContentRoot());
34
62
  const res = await app.inject({
35
63
  method: "GET",
36
64
  url: "/v1/content/HomePage/home",
@@ -39,8 +67,8 @@ describe("GET /api/content/:type/:slug", () => {
39
67
  expect(res.statusCode).toBe(200);
40
68
  const body = JSON.parse(res.body);
41
69
  expect(body["@context"]).toBe("https://schema.org");
42
- // HomePage composes a Home/Landing @graph (WebPage spine + Organization +
43
- // WebSite siblings) — #2618. The page-level fields live on the spine.
70
+ // A HomePage body keeps its @graph envelope: the WebPage spine plus the
71
+ // sitewide identity from _site.mdx (#6597). Page fields live on the spine.
44
72
  const graph = body["@graph"] as Array<Record<string, unknown>>;
45
73
  expect(graph.map((n) => n["@type"])).toEqual([
46
74
  "WebPage",
@@ -405,9 +433,8 @@ describe("GET /v1/content/:type/:slug under CONTENT_ADAPTER=database (#5852)", (
405
433
  // route's capability branch is exercised through the real resolver — the file
406
434
  // header's "no internal module mocks" (#507) holds for these cases too.
407
435
 
408
- import { mkdtempSync, writeFileSync } from "node:fs";
436
+ import { mkdtempSync } from "node:fs";
409
437
  import { tmpdir } from "node:os";
410
- import { join } from "node:path";
411
438
 
412
439
  import * as contentAdapterModule from "../../../lib/content-adapter.js";
413
440
  import { resetContentAdapter } from "../../../lib/content-adapter.js";
@@ -453,7 +480,12 @@ describe("GET /v1/content/:type/:slug?include=body (#5890)", () => {
453
480
  });
454
481
 
455
482
  // AC-1, the floor half — the unparameterised read is unchanged to the byte.
483
+ // #6597 rewrote this pin by hand: the HomePage @graph is now composed from
484
+ // the fixture's _site.mdx rather than the page title (AC-9 checks every
485
+ // field of the pre-#6597 body is still present). The #5890 claim it guards
486
+ // — no `body` key without the parameter — is unchanged.
456
487
  it("serves a response byte-identical to the pre-change handler when the parameter is absent", async () => {
488
+ writeSiteMdx(fixture.getContentRoot());
457
489
  const res = await app.inject({
458
490
  method: "GET",
459
491
  url: "/v1/content/HomePage/home",
@@ -461,7 +493,7 @@ describe("GET /v1/content/:type/:slug?include=body (#5890)", () => {
461
493
 
462
494
  expect(res.statusCode).toBe(200);
463
495
  expect(res.body).toMatchInlineSnapshot(
464
- `"{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"HomePage home title","description":"HomePage home description"},{"@context":"https://schema.org","@type":"Organization","name":"HomePage home title"},{"@context":"https://schema.org","@type":"WebSite","name":"HomePage home title","inLanguage":"en"}],"page":{"type":"HomePage","slug":"home","title":"HomePage home title","description":"HomePage home description"}}"`,
496
+ `"{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"HomePage home title","description":"HomePage home description","mainEntity":{"@id":"https://example.com/#organization"},"isPartOf":{"@id":"https://example.com/#website"}},{"@context":"https://schema.org","@type":"Organization","@id":"https://example.com/#organization","name":"Fixture Co","url":"https://example.com"},{"@context":"https://schema.org","@type":"WebSite","@id":"https://example.com/#website","name":"Fixture Site","url":"https://example.com","inLanguage":"en","publisher":{"@id":"https://example.com/#organization"}}],"page":{"type":"HomePage","slug":"home","title":"HomePage home title","description":"HomePage home description"}}"`,
465
497
  );
466
498
  expect(JSON.parse(res.body)).not.toHaveProperty("body");
467
499
  });
@@ -693,3 +725,93 @@ describe("GET /v1/content/:type/:slug?include=body under CONTENT_ADAPTER=databas
693
725
  expect(prisma.artifactVersion.findFirst).not.toHaveBeenCalled();
694
726
  });
695
727
  });
728
+
729
+ // ─── #6597 AC-9 — the /v1 HomePage response keeps its @graph envelope ─────────
730
+
731
+ describe("GET /v1/content/HomePage/home — sitewide identity envelope (#6597)", () => {
732
+ let app: FastifyInstance;
733
+
734
+ beforeEach(async () => {
735
+ app = require("fastify")();
736
+ await app.register(contentRoutes);
737
+ await fixture.writeFixturePage("HomePage", "home", {
738
+ type: "HomePage",
739
+ slug: "home",
740
+ title: "HomePage home title",
741
+ description: "HomePage home description",
742
+ image: "https://example.com/home.png",
743
+ });
744
+ });
745
+
746
+ afterEach(async () => {
747
+ await app.close();
748
+ });
749
+
750
+ async function homeBody(query = ""): Promise<{
751
+ body: Record<string, unknown>;
752
+ graph: Array<Record<string, unknown>>;
753
+ }> {
754
+ const res = await app.inject({ method: "GET", url: `/v1/content/HomePage/home${query}` });
755
+ expect(res.statusCode).toBe(200);
756
+ const body = JSON.parse(res.body) as Record<string, unknown>;
757
+ return { body, graph: body["@graph"] as Array<Record<string, unknown>> };
758
+ }
759
+ const byType = (graph: Array<Record<string, unknown>>, type: string) =>
760
+ graph.find((n) => n["@type"] === type)!;
761
+
762
+ it("composes WebPage + Organization + WebSite from _site.mdx, keeping every pre-#6597 field", async () => {
763
+ writeSiteMdx(fixture.getContentRoot());
764
+ const { body, graph } = await homeBody();
765
+ expect(body["@context"]).toBe("https://schema.org");
766
+ expect(graph.map((n) => n["@type"])).toEqual(["WebPage", "Organization", "WebSite"]);
767
+
768
+ const org = byType(graph, "Organization");
769
+ expect(org["@id"]).toBe("https://example.com/#organization");
770
+ expect(org["name"]).toBe("Fixture Co");
771
+ // The pre-#6597 Organization took `logo` from the page image; kept as the
772
+ // fallback when the identity declares none.
773
+ expect(org["logo"]).toBe("https://example.com/home.png");
774
+
775
+ // Every key of every node of the pre-#6597 pinned body survives.
776
+ const pinned: Record<string, string[]> = {
777
+ WebPage: ["@context", "@type", "inLanguage", "name", "description"],
778
+ Organization: ["@context", "@type", "name"],
779
+ WebSite: ["@context", "@type", "name", "inLanguage"],
780
+ };
781
+ for (const [type, keys] of Object.entries(pinned)) {
782
+ for (const key of keys) expect(byType(graph, type)).toHaveProperty([key]);
783
+ }
784
+
785
+ const webPage = byType(graph, "WebPage");
786
+ expect(byType(graph, "WebSite")["inLanguage"]).toBe(webPage["inLanguage"]);
787
+ expect(webPage["inLanguage"]).toBe("en");
788
+ expect(graph.filter((n) => n["name"] === "HomePage home title")).toEqual([webPage]);
789
+ });
790
+
791
+ it("the WebSite and WebPage inLanguage stay one value under ?locale=<second locale>", async () => {
792
+ writeSiteMdx(fixture.getContentRoot());
793
+ const { graph } = await homeBody("?locale=pt-BR");
794
+ expect(byType(graph, "WebSite")["inLanguage"]).toBe(byType(graph, "WebPage")["inLanguage"]);
795
+ });
796
+
797
+ it("an identity-declared logo wins over the page image", async () => {
798
+ writeSiteMdx(fixture.getContentRoot(), { logo: "https://example.com/logo.png" });
799
+ const { graph } = await homeBody();
800
+ expect(byType(graph, "Organization")["logo"]).toBe("https://example.com/logo.png");
801
+ });
802
+
803
+ it("with no _site.mdx the body still has @context and a one-node @graph", async () => {
804
+ const { body, graph } = await homeBody();
805
+ expect(body["@context"]).toBe("https://schema.org");
806
+ expect(graph.map((n) => n["@type"])).toEqual(["WebPage"]);
807
+ });
808
+
809
+ it("other types keep their bare JSON-LD — no @graph key", async () => {
810
+ writeSiteMdx(fixture.getContentRoot());
811
+ const res = await app.inject({ method: "GET", url: "/v1/content/AboutPage/about" });
812
+ expect(res.statusCode).toBe(200);
813
+ const body = JSON.parse(res.body) as Record<string, unknown>;
814
+ expect(body).not.toHaveProperty("@graph");
815
+ expect(body["@type"]).toBe("AboutPage");
816
+ });
817
+ });
@@ -23,7 +23,15 @@
23
23
  * independent `getPageSource` call. Two independent adapter reads can
24
24
  * straddle a publish that lands between them (frontmatter from revision N,
25
25
  * body from revision N+1); one read cannot.
26
+ *
27
+ * #6597: a `HomePage` body keeps its `@context` + `@graph` envelope, now
28
+ * composed from the deployment's `_site.mdx` identity (identity node, optional
29
+ * secondary, WebSite) rather than the page title. Every other type's body is
30
+ * the page's own JSON-LD, spread at the root, as before.
26
31
  */
32
+ import { readFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+
27
35
  import {
28
36
  SCHEMA_ORG_TYPES,
29
37
  type ContentAdapter,
@@ -32,10 +40,23 @@ import {
32
40
  type MdxPageSource,
33
41
  type SchemaOrgType,
34
42
  } from "@working-theory/content-adapters";
35
- import { buildJsonLd, type ContentPageInput } from "@working-theory/schema";
43
+ import {
44
+ buildJsonLd,
45
+ composeGraph,
46
+ graphNodes,
47
+ type ContentPageInput,
48
+ type JsonLdObject,
49
+ type SitewideIdentityInput,
50
+ } from "@working-theory/schema";
51
+ import {
52
+ SiteConfigSchema,
53
+ type SiteConfig,
54
+ } from "@working-theory/site-config/schema";
36
55
  import type { FastifyPluginAsync } from "fastify";
56
+ import { parse as parseYaml } from "yaml";
37
57
  import { z } from "zod";
38
58
 
59
+ import { getContentRoot } from "../../lib/content-adapter.js";
39
60
  import { resolveLocale } from "../../lib/locales.js";
40
61
  import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
41
62
 
@@ -64,6 +85,67 @@ function hasPageSource(
64
85
  );
65
86
  }
66
87
 
88
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
89
+
90
+ /** `_site.mdx` per content root, read once; `null` when it does not load. */
91
+ const siteConfigByRoot = new Map<string, SiteConfig | null>();
92
+
93
+ /**
94
+ * The deployment's `_site.mdx`, validated against `SiteConfigSchema`, or
95
+ * `undefined` on any failure (absent, unreadable, invalid). A local read with
96
+ * the client-safe `/schema` entry: `@working-theory/site-config/server` imports
97
+ * `server-only`, which throws outside a React Server Components runtime.
98
+ */
99
+ function readSiteConfig(contentRoot: string): SiteConfig | undefined {
100
+ if (!siteConfigByRoot.has(contentRoot)) {
101
+ let config: SiteConfig | null = null;
102
+ try {
103
+ const raw = readFileSync(join(contentRoot, "_site.mdx"), "utf8");
104
+ const match = FRONTMATTER_RE.exec(raw);
105
+ if (match) {
106
+ const parsed = SiteConfigSchema.safeParse(parseYaml(match[1] ?? ""));
107
+ if (parsed.success) config = parsed.data;
108
+ }
109
+ } catch {
110
+ // Absent or unreadable — no site.
111
+ }
112
+ siteConfigByRoot.set(contentRoot, config);
113
+ }
114
+ return siteConfigByRoot.get(contentRoot) ?? undefined;
115
+ }
116
+
117
+ /**
118
+ * The `HomePage` body's `@graph` (#6597, spec D11): the spine plus the site's
119
+ * sitewide identity nodes. The WebSite's `inLanguage` is the spine's own, so
120
+ * both carry one value; the Organization falls back to the page's `image` as
121
+ * its `logo` when the identity declares none, as the title-derived node did.
122
+ */
123
+ function homePageGraph(
124
+ primary: JsonLdObject,
125
+ image: string | undefined,
126
+ ): { "@context": "https://schema.org"; "@graph": JsonLdObject[] } {
127
+ const config = readSiteConfig(getContentRoot());
128
+ let site: SitewideIdentityInput | undefined;
129
+ if (config !== undefined) {
130
+ const identity = config.siteIdentity;
131
+ site = {
132
+ origin: config.baseUrl.replace(/\/+$/, ""),
133
+ siteName: config.siteName,
134
+ baseUrl: config.baseUrl,
135
+ inLanguage:
136
+ typeof primary["inLanguage"] === "string" ? primary["inLanguage"] : undefined,
137
+ identity:
138
+ identity.kind !== "Person" && identity.logo === undefined && image !== undefined
139
+ ? { ...identity, logo: image }
140
+ : identity,
141
+ };
142
+ }
143
+ return {
144
+ "@context": "https://schema.org",
145
+ "@graph": graphNodes(composeGraph({ primary, site, isHomePage: true })),
146
+ };
147
+ }
148
+
67
149
  export const getPageRoute: FastifyPluginAsync = async (fastify) => {
68
150
  fastify.get<{
69
151
  Params: { type: string; slug: string };
@@ -165,7 +247,9 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
165
247
  // now preserves `sections`; before, it was stripped at validation.
166
248
  ...(page.sections !== undefined && { sections: page.sections }),
167
249
  };
168
- const jsonLd = buildJsonLd(input);
250
+ const primary = buildJsonLd(input);
251
+ const jsonLd =
252
+ page.type === "HomePage" ? homePageGraph(primary, page.image) : primary;
169
253
  return reply.send({
170
254
  ...jsonLd,
171
255
  page,
@@ -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
+ });