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
@@ -5,10 +5,11 @@ jest.mock('dotenv', () => ({
5
5
 
6
6
  // Mock the observability logger so assertions don't depend on pino's stdout serialization.
7
7
  const mockLoggerError = jest.fn();
8
+ const mockLoggerWarn = jest.fn();
8
9
  jest.mock('@working-theory/observability', () => ({
9
10
  logger: {
10
11
  error: (...args: unknown[]) => mockLoggerError(...args),
11
- warn: jest.fn(),
12
+ warn: (...args: unknown[]) => mockLoggerWarn(...args),
12
13
  info: jest.fn(),
13
14
  debug: jest.fn(),
14
15
  },
@@ -41,22 +42,74 @@ describe('Config', () => {
41
42
  expect(dotenv.config).toHaveBeenCalled();
42
43
  });
43
44
 
44
- it('should exit on missing DATABASE_URL', () => {
45
- // ARRANGE
45
+ // #6730 security review (M1): a production build whose enabled modules keep
46
+ // their state in the database still refuses to boot without DATABASE_URL —
47
+ // a missing database there is a broken deploy (e.g. a commerce licence read
48
+ // as absent would ack-and-drop signed Stripe events), not a Free site.
49
+ it.each(['commerce', 'content-write', 'multi-tenant', 'scheduling', 'signal-collectors', 'workspace'])(
50
+ 'should exit on missing DATABASE_URL in production when the %s module is enabled',
51
+ (moduleName) => {
52
+ process.env.NODE_ENV = 'production';
53
+ process.env.AILK_ENABLED_MODULES = `marketing,${moduleName}`;
54
+ delete process.env.DATABASE_URL;
55
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
56
+ throw new Error('Process exit called');
57
+ });
58
+
59
+ expect(() => require('../index')).toThrow('Process exit called');
60
+ expect(exitSpy).toHaveBeenCalledWith(1);
61
+ expect(mockLoggerError).toHaveBeenCalled();
62
+
63
+ exitSpy.mockRestore();
64
+ },
65
+ );
66
+
67
+ it('should start database-less in production with only non-database modules enabled', () => {
68
+ process.env.NODE_ENV = 'production';
69
+ process.env.AILK_ENABLED_MODULES = 'marketing,docs-blog';
46
70
  delete process.env.DATABASE_URL;
71
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
72
+ throw new Error('Process exit called');
73
+ });
74
+
75
+ const { config } = require('../index');
76
+
77
+ expect(exitSpy).not.toHaveBeenCalled();
78
+ expect(config.DATABASE_URL).toBeUndefined();
79
+ exitSpy.mockRestore();
80
+ });
47
81
 
82
+ // #6482 — a missing DATABASE_URL starts the API database-less (one
83
+ // warning) instead of exiting, so a fresh scaffold's `pnpm dev` keeps its
84
+ // "no database needed" promise. #6730 (operator decision (a)) extends that
85
+ // to production: a Free site delivers leads by email with no Postgres.
86
+ it.each([
87
+ ['production', 'production'],
88
+ ['development', 'development'],
89
+ ['unset', undefined],
90
+ ['test', 'test'],
91
+ ])('should start database-less with DATABASE_URL unset when NODE_ENV is %s', (_label, nodeEnv) => {
92
+ // ARRANGE
93
+ if (nodeEnv === undefined) {
94
+ delete process.env.NODE_ENV;
95
+ } else {
96
+ process.env.NODE_ENV = nodeEnv;
97
+ }
98
+ delete process.env.DATABASE_URL;
48
99
  const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
49
100
  throw new Error('Process exit called');
50
101
  });
51
- mockLoggerError.mockClear();
52
102
 
53
- // ACT & ASSERT
54
- expect(() => {
55
- require('../index');
56
- }).toThrow('Process exit called');
103
+ // ACT
104
+ const { config } = require('../index');
57
105
 
58
- expect(exitSpy).toHaveBeenCalledWith(1);
59
- expect(mockLoggerError).toHaveBeenCalled();
106
+ // ASSERT
107
+ expect(exitSpy).not.toHaveBeenCalled();
108
+ expect(config.DATABASE_URL).toBeUndefined();
109
+ expect(config.NODE_ENV).toBe(nodeEnv ?? 'development');
110
+ expect(mockLoggerError).not.toHaveBeenCalled();
111
+ expect(mockLoggerWarn).toHaveBeenCalledTimes(1);
112
+ expect(mockLoggerWarn.mock.calls[0]?.[1]).toContain('database_not_configured');
60
113
 
61
114
  exitSpy.mockRestore();
62
115
  });
@@ -95,3 +148,86 @@ describe('Config', () => {
95
148
  expect(config.FRONTEND_URL).toBe('http://localhost:3000');
96
149
  });
97
150
  });
151
+
152
+ // #6730 (operator decision (a)) — the REAL server boots in production with
153
+ // no database: an email-only Free site must be able to run its API, answer
154
+ // `GET /v1/leads/capture`, and refuse authenticated routes with a 503 rather
155
+ // than exit at startup.
156
+ describe('production boot without a database (#6730)', () => {
157
+ const originalEnv = process.env;
158
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the Fastify instance comes from an isolated require; its generics add nothing here
159
+ let app: any;
160
+
161
+ const EMAIL_ENV = {
162
+ RESEND_API_KEY: 're_test',
163
+ AILK_LEAD_NOTIFICATION_TO: 'owner@example.com',
164
+ AILK_LEAD_NOTIFICATION_FROM: 'noreply@example.com',
165
+ };
166
+
167
+ // External boundaries only, as src/__tests__/server.test.ts mocks them.
168
+ beforeAll(() => {
169
+ jest.doMock('@fastify/cors', () => jest.fn().mockResolvedValue(undefined));
170
+ jest.doMock('@fastify/rate-limit', () => jest.fn().mockResolvedValue(undefined));
171
+ jest.doMock('@working-theory/email', () => ({
172
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: 'm' }),
173
+ sendRequestConfirmEmail: jest.fn(),
174
+ sendRequestPaidLinkEmail: jest.fn(),
175
+ }));
176
+ });
177
+
178
+ afterEach(async () => {
179
+ if (app) await app.close();
180
+ app = undefined;
181
+ process.env = originalEnv;
182
+ });
183
+
184
+ async function bootProduction(extra: Record<string, string> = {}) {
185
+ jest.resetModules();
186
+ const env: NodeJS.ProcessEnv = { ...originalEnv, NODE_ENV: 'production', ...extra };
187
+ delete env.DATABASE_URL;
188
+ delete env.DATABASE_URL_NON_POOLED;
189
+ delete env.RESEND_API_KEY;
190
+ delete env.AILK_LEAD_NOTIFICATION_TO;
191
+ delete env.AILK_LEAD_NOTIFICATION_FROM;
192
+ Object.assign(env, extra);
193
+ process.env = env;
194
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
195
+ throw new Error('Process exit called');
196
+ });
197
+ try {
198
+ const { build } = require('../../server');
199
+ app = await build();
200
+ } finally {
201
+ expect(exitSpy).not.toHaveBeenCalled();
202
+ exitSpy.mockRestore();
203
+ }
204
+ return app;
205
+ }
206
+
207
+ it('with only the email vars, starts and reports contact accepted, newsletter not', async () => {
208
+ const server = await bootProduction(EMAIL_ENV);
209
+
210
+ const res = await server.inject({ method: 'GET', url: '/v1/leads/capture' });
211
+
212
+ expect(res.statusCode).toBe(200);
213
+ expect(res.json()).toEqual({ ok: true, data: { contact: true, newsletter: false } });
214
+ });
215
+
216
+ it('with nothing configured, starts and reports nothing accepted', async () => {
217
+ const server = await bootProduction();
218
+
219
+ const res = await server.inject({ method: 'GET', url: '/v1/leads/capture' });
220
+
221
+ expect(res.statusCode).toBe(200);
222
+ expect(res.json()).toEqual({ ok: true, data: { contact: false, newsletter: false } });
223
+ });
224
+
225
+ it('refuses an authenticated route with 503 database_not_configured', async () => {
226
+ const server = await bootProduction(EMAIL_ENV);
227
+
228
+ const res = await server.inject({ method: 'GET', url: '/v1/leads' });
229
+
230
+ expect(res.statusCode).toBe(503);
231
+ expect(res.json()).toEqual({ error: 'database_not_configured' });
232
+ });
233
+ });
@@ -7,6 +7,8 @@
7
7
  import { logger } from '@working-theory/observability';
8
8
  import * as dotenv from 'dotenv';
9
9
 
10
+ import { isModuleEnabled } from './modules.js';
11
+
10
12
  dotenv.config();
11
13
 
12
14
  export interface Config {
@@ -15,8 +17,9 @@ export interface Config {
15
17
  PORT: number;
16
18
  FRONTEND_URL: string;
17
19
 
18
- // Local Database (for auth: User, Session, ApiToken, etc.)
19
- DATABASE_URL: string;
20
+ // Local Database (for auth: User, Session, ApiToken, etc.). Required in
21
+ // production; unset elsewhere starts the API database-less (#6482).
22
+ DATABASE_URL?: string;
20
23
 
21
24
  // Stripe — consumed by apps/api/src/lib/stripe.ts via @working-theory/stripe-client wrapper
22
25
  STRIPE_SECRET_KEY?: string;
@@ -91,26 +94,55 @@ export interface Config {
91
94
  CAL_WEBHOOK_SECRET?: string;
92
95
  }
93
96
 
94
- function validateConfig(): Config {
95
- const errors: string[] = [];
97
+ /** Enabled modules whose state lives in the database (see validateConfig). */
98
+ const DATABASE_MODULES = [
99
+ 'commerce',
100
+ 'content-write',
101
+ 'multi-tenant',
102
+ 'scheduling',
103
+ 'signal-collectors',
104
+ 'workspace',
105
+ ] as const;
96
106
 
97
- // Required: Local database for auth
107
+ function validateConfig(): Config {
108
+ const nodeEnv = (process.env.NODE_ENV || 'development') as Config['NODE_ENV'];
109
+
110
+ // No database is a supported shape in every environment, production
111
+ // included (#6730 operator decision (a)): a Free site delivers leads by
112
+ // email and needs no Postgres. Start database-less and let the auth
113
+ // preHandler answer 503 database_not_configured (#6482) — it reads
114
+ // DATABASE_URL per request, whatever NODE_ENV says, so no authenticated
115
+ // route runs without a database.
116
+ //
117
+ // Except a production build whose ENABLED modules keep their state in the
118
+ // database. There a missing DATABASE_URL is a broken deploy, not a Free
119
+ // site: e.g. a commerce site's licence lives in the database-backed store,
120
+ // so booting without it would read the site as unlicensed and ack-and-drop
121
+ // every signed Stripe event (#6730 security review, M1). Those builds keep
122
+ // the loud startup failure.
98
123
  if (!process.env.DATABASE_URL) {
99
- errors.push('DATABASE_URL is required for authentication');
100
- }
101
-
102
- // Exit on errors
103
- if (errors.length > 0) {
104
- logger.error({ errors }, '❌ Configuration validation failed');
105
- process.exit(1);
124
+ const needsDatabase = DATABASE_MODULES.filter((m) => isModuleEnabled(m));
125
+ if (nodeEnv === 'production' && needsDatabase.length > 0) {
126
+ logger.error(
127
+ { modules: needsDatabase },
128
+ '❌ DATABASE_URL is required in production when these modules are enabled',
129
+ );
130
+ process.exit(1);
131
+ }
132
+ logger.warn(
133
+ { nodeEnv },
134
+ '⚠️ DATABASE_URL not set — starting database-less; authenticated routes answer 503 database_not_configured',
135
+ );
106
136
  }
107
137
 
108
138
  return {
109
- NODE_ENV: (process.env.NODE_ENV || 'development') as Config['NODE_ENV'],
139
+ NODE_ENV: nodeEnv,
110
140
  PORT: parseInt(process.env.PORT || '3002', 10),
111
141
  FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:3000',
112
142
 
113
- DATABASE_URL: process.env.DATABASE_URL!,
143
+ ...(process.env.DATABASE_URL !== undefined && {
144
+ DATABASE_URL: process.env.DATABASE_URL,
145
+ }),
114
146
 
115
147
  ...(process.env.STRIPE_SECRET_KEY !== undefined && {
116
148
  STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
@@ -96,6 +96,36 @@ describe("appendLeadRecord", () => {
96
96
  expect(filePath).toMatch(/leads\.jsonl$/);
97
97
  });
98
98
 
99
+ // #6730 D3 — the default file is local-only: in production it does not
100
+ // outlive a serverless instance, so it is skipped unless explicitly pointed
101
+ // at a durable path.
102
+ describe("in production (#6730 D3)", () => {
103
+ const savedNodeEnv = process.env.NODE_ENV;
104
+ afterEach(() => {
105
+ process.env.NODE_ENV = savedNodeEnv;
106
+ });
107
+
108
+ it("skips the write when LEAD_CAPTURE_FILE is unset", async () => {
109
+ process.env.NODE_ENV = "production";
110
+
111
+ const result = await appendLeadRecord(record);
112
+
113
+ expect(result).toEqual({ ok: true, skipped: true });
114
+ expect(mockAppendFile).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("writes when LEAD_CAPTURE_FILE is set explicitly", async () => {
118
+ process.env.NODE_ENV = "production";
119
+ process.env.LEAD_CAPTURE_FILE = "/data/leads.jsonl";
120
+
121
+ const result = await appendLeadRecord(record);
122
+
123
+ expect(result).toEqual({ ok: true });
124
+ expect(mockAppendFile).toHaveBeenCalledTimes(1);
125
+ expect(mockAppendFile.mock.calls[0][0]).toBe("/data/leads.jsonl");
126
+ });
127
+ });
128
+
99
129
  // AC4 — LEAD_CAPTURE_FILE_DISABLED=true skips the write
100
130
  it("short-circuits write when LEAD_CAPTURE_FILE_DISABLED=true", async () => {
101
131
  process.env.LEAD_CAPTURE_FILE_DISABLED = "true";
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @file lead-delivery.test.ts
3
+ * @description The #6730 D1 delivery table, row by row.
4
+ */
5
+
6
+ import { acceptsKind, resolveLeadDelivery } from "../lead-delivery";
7
+
8
+ const EMAIL = {
9
+ RESEND_API_KEY: "re_test",
10
+ AILK_LEAD_NOTIFICATION_TO: "owner@example.com",
11
+ AILK_LEAD_NOTIFICATION_FROM: "noreply@example.com",
12
+ };
13
+
14
+ describe("resolveLeadDelivery", () => {
15
+ it("DATABASE_URL alone → inbox", () => {
16
+ expect(resolveLeadDelivery({ DATABASE_URL: "postgres://x", NODE_ENV: "production" })).toBe(
17
+ "inbox",
18
+ );
19
+ });
20
+
21
+ it("DATABASE_URL wins over the email vars", () => {
22
+ expect(resolveLeadDelivery({ DATABASE_URL: "postgres://x", ...EMAIL })).toBe("inbox");
23
+ });
24
+
25
+ it("the three email vars, no database → email", () => {
26
+ expect(resolveLeadDelivery({ ...EMAIL, NODE_ENV: "production" })).toBe("email");
27
+ expect(resolveLeadDelivery({ ...EMAIL, NODE_ENV: "development" })).toBe("email");
28
+ });
29
+
30
+ it("a bare RESEND_API_KEY is not email delivery — local in dev, none in production", () => {
31
+ expect(resolveLeadDelivery({ RESEND_API_KEY: "re_test", NODE_ENV: "development" })).toBe(
32
+ "local",
33
+ );
34
+ expect(resolveLeadDelivery({ RESEND_API_KEY: "re_test", NODE_ENV: "production" })).toBe(
35
+ "none",
36
+ );
37
+ });
38
+
39
+ it("the key and one address only → not email", () => {
40
+ expect(
41
+ resolveLeadDelivery({
42
+ RESEND_API_KEY: "re_test",
43
+ AILK_LEAD_NOTIFICATION_TO: "owner@example.com",
44
+ NODE_ENV: "production",
45
+ }),
46
+ ).toBe("none");
47
+ });
48
+
49
+ it("nothing configured in production → none", () => {
50
+ expect(resolveLeadDelivery({ NODE_ENV: "production" })).toBe("none");
51
+ });
52
+
53
+ it("nothing configured outside production → local", () => {
54
+ expect(resolveLeadDelivery({ NODE_ENV: "test" })).toBe("local");
55
+ expect(resolveLeadDelivery({})).toBe("local");
56
+ });
57
+
58
+ it("LEAD_CAPTURE_FILE_DISABLED=true removes the local path → none", () => {
59
+ expect(
60
+ resolveLeadDelivery({ NODE_ENV: "development", LEAD_CAPTURE_FILE_DISABLED: "true" }),
61
+ ).toBe("none");
62
+ });
63
+
64
+ it("reads process.env by default", () => {
65
+ const saved = process.env.DATABASE_URL;
66
+ process.env.DATABASE_URL = "postgres://x";
67
+ try {
68
+ expect(resolveLeadDelivery()).toBe("inbox");
69
+ } finally {
70
+ if (saved === undefined) delete process.env.DATABASE_URL;
71
+ else process.env.DATABASE_URL = saved;
72
+ }
73
+ });
74
+ });
75
+
76
+ describe("acceptsKind", () => {
77
+ it("none accepts nothing", () => {
78
+ expect(acceptsKind("none", "submit")).toBe(false);
79
+ expect(acceptsKind("none", "subscribe")).toBe(false);
80
+ expect(acceptsKind("none", "callback")).toBe(false);
81
+ });
82
+
83
+ it("email accepts submit and callback, never subscribe", () => {
84
+ expect(acceptsKind("email", "submit")).toBe(true);
85
+ expect(acceptsKind("email", "callback")).toBe(true);
86
+ expect(acceptsKind("email", "subscribe")).toBe(false);
87
+ });
88
+
89
+ it("inbox and local accept every kind", () => {
90
+ for (const d of ["inbox", "local"] as const) {
91
+ expect(acceptsKind(d, "submit")).toBe(true);
92
+ expect(acceptsKind(d, "subscribe")).toBe(true);
93
+ expect(acceptsKind(d, "callback")).toBe(true);
94
+ }
95
+ });
96
+ });
@@ -12,6 +12,15 @@
12
12
  *
13
13
  * Env vars (all read inside function bodies — module is import-side-effect-free):
14
14
  * LEAD_CAPTURE_FILE — output path (default: apps/api/leads.jsonl relative to cwd)
15
+ *
16
+ * The default path is LOCAL-ONLY (#6730 D3). In production
17
+ * (`NODE_ENV=production`) with `LEAD_CAPTURE_FILE` unset, the write is skipped:
18
+ * on serverless hosting a file under the process cwd does not outlive the
19
+ * instance, so it is not a record of anything. An operator with a durable
20
+ * volume can keep a side copy by setting `LEAD_CAPTURE_FILE` explicitly —
21
+ * a copy only: in production it never makes a lead count as delivered
22
+ * (`lib/lead-delivery.ts` needs a database or the email vars).
23
+ *
15
24
  * LEAD_CAPTURE_FILE_DISABLED — set to "true" to skip writes (opt-out)
16
25
  * LEAD_CAPTURE_INCLUDE_IP — set to "true" to include metadata.ip in the record
17
26
  *
@@ -47,6 +56,9 @@ export async function appendLeadRecord(
47
56
  if (disabled) {
48
57
  return { ok: true, skipped: true };
49
58
  }
59
+ if (process.env.NODE_ENV === "production" && !process.env.LEAD_CAPTURE_FILE) {
60
+ return { ok: true, skipped: true };
61
+ }
50
62
 
51
63
  const includeIp = process.env.LEAD_CAPTURE_INCLUDE_IP === "true";
52
64
  const outputPath = resolveOutputPath();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @file lead-delivery.ts
3
+ * @description Where a captured lead goes, resolved from the environment (#6730 D1).
4
+ *
5
+ * inbox — `DATABASE_URL` is set: the database is the destination (the lead
6
+ * inbox at `/leads`); the owner email is still sent best-effort.
7
+ * email — no database, but `RESEND_API_KEY` plus both notification
8
+ * addresses are set: the owner email IS the delivery.
9
+ * local — neither, outside production (`pnpm dev`, tests): the local file
10
+ * fallback + `leads.jsonl`, so local work needs nothing configured.
11
+ * none — neither, in production: nothing is accepted, so no visitor is
12
+ * ever told a message was sent when it reached nobody.
13
+ *
14
+ * A key alone delivers nothing (`sendEmail()` also needs both addresses), so
15
+ * it is not counted as configured. `LEAD_CAPTURE_FILE_DISABLED=true` removes
16
+ * the local file path, so outside production it resolves to `none` too.
17
+ *
18
+ * Import-side-effect-free; the environment is read at call time.
19
+ */
20
+
21
+ export type LeadDelivery = "inbox" | "email" | "local" | "none";
22
+
23
+ export type LeadDeliveryKind = "submit" | "subscribe" | "callback";
24
+
25
+ export function resolveLeadDelivery(
26
+ env: NodeJS.ProcessEnv = process.env,
27
+ ): LeadDelivery {
28
+ if (env.DATABASE_URL) return "inbox";
29
+ if (
30
+ env.RESEND_API_KEY &&
31
+ env.AILK_LEAD_NOTIFICATION_TO &&
32
+ env.AILK_LEAD_NOTIFICATION_FROM
33
+ ) {
34
+ return "email";
35
+ }
36
+ if (env.NODE_ENV !== "production" && env.LEAD_CAPTURE_FILE_DISABLED !== "true") {
37
+ return "local";
38
+ }
39
+ return "none";
40
+ }
41
+
42
+ /**
43
+ * Whether a delivery mode accepts a lead of this kind. A newsletter
44
+ * subscription is a list entry, not a message — emailing the owner one
45
+ * address per signup is not a list — so `subscribe` needs somewhere to keep
46
+ * it (the database, or the local file in development).
47
+ */
48
+ export function acceptsKind(d: LeadDelivery, kind: LeadDeliveryKind): boolean {
49
+ if (d === "none") return false;
50
+ if (kind === "subscribe") return d === "inbox" || d === "local";
51
+ return true;
52
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Registers the durable home of a renewed licence key (#6677 DD-6).
3
+ *
4
+ * `@working-theory/license` renews the deployment's key before it lapses and
5
+ * keeps the renewed key in a `LicenseKeyStore`. Its default store is a file,
6
+ * which a Vercel redeploy wipes — so a redeployed site would fall back to the
7
+ * older `AILK_LICENSE_KEY` and, past the renewal grace, to free. With a
8
+ * database configured, this registers the store backed by ONE deployment-wide
9
+ * `SiteSetting` row, `(DEFAULT_WORKSPACE_ID, "license.renewed_key")`, shared
10
+ * by this app, apps/mcp and apps/web.
11
+ *
12
+ * The row is deployment-wide, not tenant data: the licence is the
13
+ * deployment's, so it sits under the seeded default workspace in single- and
14
+ * multi-tenant builds alike (the named tenant-scope exclusion in
15
+ * architecture.yaml). A DB-less scaffold keeps the file store.
16
+ */
17
+
18
+ import { DEFAULT_WORKSPACE_ID } from "@working-theory/database/tenancy";
19
+ import {
20
+ createSiteSettingLicenseKeyStore,
21
+ setLicenseKeyStore,
22
+ } from "@working-theory/license";
23
+ import { logger } from "@working-theory/observability";
24
+
25
+ import { prisma } from "./prisma.js";
26
+
27
+ /**
28
+ * Register the SiteSetting-backed store when `DATABASE_URL` is set. An
29
+ * explicitly set `AILK_LICENSE_STORAGE_PATH` is the operator choosing the
30
+ * file store, and is honoured. Never throws: a failure logs and leaves the
31
+ * file store in place.
32
+ */
33
+ export async function registerLicenseKeyStore(): Promise<void> {
34
+ if (!process.env.DATABASE_URL || process.env.AILK_LICENSE_STORAGE_PATH) return;
35
+ try {
36
+ setLicenseKeyStore(
37
+ createSiteSettingLicenseKeyStore(prisma.siteSetting, DEFAULT_WORKSPACE_ID),
38
+ );
39
+ } catch (err) {
40
+ logger.warn(
41
+ { errName: err instanceof Error ? err.name : "UnknownError" },
42
+ "[license] could not register the SiteSetting licence-key store — the file store stays in place",
43
+ );
44
+ }
45
+ }
@@ -144,9 +144,11 @@ export async function resolveWorkspaceIdForAlias(
144
144
  * 1. **No `DATABASE_URL` configured** — the literal `requireDb()` guard
145
145
  * `apps/api/src/services/flow-engine.ts` uses for the flow services:
146
146
  * presence, not reachability. (`apps/api/src/config/index.ts`'s own
147
- * eager `validateConfig()` already refuses a real `apps/api` boot with
148
- * `DATABASE_URL` unset before `build()` is ever reached, so this arm is
149
- * for any OTHER caller of `build()`.)
147
+ * eager `validateConfig()` refuses a **production** `apps/api` boot with
148
+ * `DATABASE_URL` unset before `build()` is ever reached; outside
149
+ * production a real boot starts database-less (#6482), so this arm is
150
+ * reachable from a real dev boot as well as from any other caller of
151
+ * `build()`.)
150
152
  * 2. **`prisma.workspace.findUnique` is not even a function** — the case
151
153
  * that surfaced this arm: `apps/api`'s shared/global prisma mocks
152
154
  * (`lib/__mocks__/prisma-client.js`, the module-map-level double every
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file auth.database-not-configured.test.ts
3
+ *
4
+ * DATABASE_URL unset → 503 database_not_configured (#6482).
5
+ *
6
+ * Outside production apps/api boots database-less. The preHandler must say
7
+ * so before it reads any credential: placed after the lookups, the existing
8
+ * `.catch(() => undefined)` would turn the connection failure into a
9
+ * misleading 401.
10
+ */
11
+
12
+ import Fastify from "fastify";
13
+ import type { FastifyInstance } from "fastify";
14
+
15
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
16
+ jest.mock("../../lib/prisma.js", () => ({
17
+ prisma: {
18
+ session: { findUnique: jest.fn() },
19
+ apiToken: {
20
+ findUnique: jest.fn(),
21
+ update: jest.fn().mockResolvedValue(undefined),
22
+ },
23
+ },
24
+ }));
25
+
26
+ import { prisma } from "../../lib/prisma.js";
27
+ import { authPreHandlerPlugin } from "../auth.js";
28
+
29
+ const echoHandler = jest.fn(async () => ({ ok: true }));
30
+
31
+ function buildApp(): FastifyInstance {
32
+ const app = Fastify();
33
+ app.register(authPreHandlerPlugin, {
34
+ frontendOrigin: "https://app.example.com",
35
+ });
36
+ app.get("/echo", echoHandler);
37
+ app.post("/echo", echoHandler);
38
+ return app;
39
+ }
40
+
41
+ describe("auth preHandler — DATABASE_URL not configured", () => {
42
+ const originalDatabaseUrl = process.env.DATABASE_URL;
43
+ let app: FastifyInstance;
44
+
45
+ beforeEach(async () => {
46
+ jest.clearAllMocks();
47
+ delete process.env.DATABASE_URL;
48
+ app = buildApp();
49
+ await app.ready();
50
+ });
51
+
52
+ afterEach(async () => {
53
+ await app.close();
54
+ if (originalDatabaseUrl === undefined) {
55
+ delete process.env.DATABASE_URL;
56
+ } else {
57
+ process.env.DATABASE_URL = originalDatabaseUrl;
58
+ }
59
+ });
60
+
61
+ it.each([
62
+ ["no credentials", "GET", {}],
63
+ ["a bearer token", "GET", { authorization: "Bearer ailk_abc123" }],
64
+ ["a session cookie", "GET", { cookie: "better-auth.session_token=tok" }],
65
+ [
66
+ "a non-GET with a session cookie",
67
+ "POST",
68
+ {
69
+ cookie: "better-auth.session_token=tok",
70
+ origin: "https://app.example.com",
71
+ },
72
+ ],
73
+ ] as const)(
74
+ 'returns 503 { error: "database_not_configured" } for %s without any lookup',
75
+ async (_label, method, headers) => {
76
+ const res = await app.inject({ method, url: "/echo", headers });
77
+
78
+ expect(res.statusCode).toBe(503);
79
+ expect(JSON.parse(res.body)).toEqual({
80
+ error: "database_not_configured",
81
+ });
82
+ expect(prisma.session.findUnique).not.toHaveBeenCalled();
83
+ expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
84
+ expect(echoHandler).not.toHaveBeenCalled();
85
+ },
86
+ );
87
+
88
+ it("control: with DATABASE_URL set and no credentials, answers 401 unauthorized", async () => {
89
+ process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
90
+
91
+ const res = await app.inject({ method: "GET", url: "/echo" });
92
+
93
+ expect(res.statusCode).toBe(401);
94
+ expect(JSON.parse(res.body)).toEqual({ error: "unauthorized" });
95
+ expect(echoHandler).not.toHaveBeenCalled();
96
+ });
97
+ });
@@ -22,6 +22,7 @@
22
22
  * - Both modes invalid → 401 unauthorized.
23
23
  * - Cookie expired + bearer valid → 401 (no silent fallback).
24
24
  * - Cookie absent, bearer malformed → 401 (no silent fallback).
25
+ * - DATABASE_URL unset → 503 database_not_configured (#6482).
25
26
  *
26
27
  * Public routes (health, content, leads, webhooks, openapi) are NOT registered
27
28
  * with this preHandler — server.ts narrows scope via a sub-plugin.
@@ -48,6 +49,7 @@ const COOKIE_NAMES = [
48
49
  ] as const;
49
50
 
50
51
  const UNAUTHORIZED = { error: "unauthorized" } as const;
52
+ const DATABASE_NOT_CONFIGURED = { error: "database_not_configured" } as const;
51
53
 
52
54
  declare module "fastify" {
53
55
  interface FastifyRequest {
@@ -201,6 +203,13 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
201
203
  request: FastifyRequest,
202
204
  reply: FastifyReply,
203
205
  ): Promise<void> {
206
+ // A database-less dev boot (#6482). First, and read per request: after
207
+ // the lookups below, their `.catch` would already have answered 401.
208
+ if (!process.env.DATABASE_URL) {
209
+ reply.code(503).send(DATABASE_NOT_CONFIGURED);
210
+ return;
211
+ }
212
+
204
213
  const now = getClock().now();
205
214
  const cookieToken = readSessionCookie(request);
206
215