create-ailk 0.7.0 → 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 (169) hide show
  1. package/README.md +44 -10
  2. package/component-catalog.json +520 -5
  3. package/component-catalog.md +9 -6
  4. package/dist/cli.js +22 -24
  5. package/dist/lib/extract-module-bundle.js +5 -1
  6. package/dist/lib/module-license-gate.d.ts +79 -0
  7. package/dist/lib/module-license-gate.js +160 -12
  8. package/dist/lib/module-tier.d.ts +45 -16
  9. package/dist/lib/module-tier.js +76 -0
  10. package/dist/lib/pro-callout.js +10 -2
  11. package/dist/parse-args.d.ts +78 -10
  12. package/dist/parse-args.js +204 -14
  13. package/dist/programmatic.d.ts +39 -9
  14. package/dist/programmatic.js +163 -25
  15. package/dist/site-roster.js +11 -6
  16. package/dist/starter-grid.d.ts +216 -0
  17. package/dist/starter-grid.js +449 -0
  18. package/dist/surfaces.d.ts +4 -4
  19. package/dist/surfaces.js +102 -43
  20. package/dist/template-manifest.d.ts +36 -17
  21. package/dist/template-manifest.js +121 -367
  22. package/dist/template-names.d.ts +14 -12
  23. package/dist/template-names.js +13 -17
  24. package/package.json +3 -3
  25. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -1
  26. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  27. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  28. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  29. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  30. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
  32. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  33. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  34. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  35. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  36. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  37. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
  38. package/site-templates/_base/content/en/UseCase/persona-1.mdx +69 -0
  39. package/site-templates/_base/content/en/UseCase/persona-2.mdx +69 -0
  40. package/site-templates/_base/content/en/UseCase/persona-3.mdx +69 -0
  41. package/site-templates/designs/standard/DESIGN.md +5 -0
  42. package/site-templates/marketing-site/DESIGN.md +5 -0
  43. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +89 -25
  44. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +35 -14
  45. package/site-templates/marketing-site-free/DESIGN.md +5 -0
  46. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +95 -29
  47. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +74 -44
  48. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  49. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  50. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +17 -29
  51. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -1
  52. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  53. package/site-templates/saas-product/DESIGN.md +5 -0
  54. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  55. package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +96 -76
  56. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +302 -0
  57. package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
  58. package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
  59. package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
  60. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  61. package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
  62. package/site-templates/{simple-service → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
  63. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
  64. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
  65. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
  66. package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
  67. package/site-templates/shapes/local-business-service/provenance.json +4 -0
  68. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +362 -0
  69. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +300 -0
  70. package/site-templates/shapes/organization-service/ailk-template.json +10 -0
  71. package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
  72. package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
  73. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  74. package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
  75. package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  76. package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +38 -0
  77. package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +38 -0
  78. package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +38 -0
  79. package/site-templates/shapes/organization-service/content/en/ServicesList/services.mdx +72 -0
  80. package/site-templates/shapes/organization-service/provenance.json +4 -0
  81. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +369 -0
  82. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +299 -0
  83. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  84. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  85. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  86. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  87. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  88. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +169 -0
  89. package/site-templates/shapes/person-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  90. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  91. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  92. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  93. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  94. package/site-templates/shapes/person-service/provenance.json +4 -0
  95. package/templates/.claude/skills/README.md +3 -2
  96. package/templates/.claude/skills/add-page/references/page-types.md +4 -0
  97. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  98. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  99. package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
  100. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +19 -28
  101. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
  102. package/templates/DESIGN.md +5 -0
  103. package/templates/README.md +46 -38
  104. package/templates/apps/api/CLAUDE.md +3 -2
  105. package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
  106. package/templates/apps/api/src/config/index.ts +35 -16
  107. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  108. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  109. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  110. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  111. package/templates/apps/api/src/openapi/spec.ts +63 -12
  112. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  113. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  114. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  115. package/templates/apps/api/src/server.ts +7 -0
  116. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  117. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  118. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  119. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  120. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  121. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  122. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
  123. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
  124. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  125. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  126. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
  127. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  128. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  129. package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
  130. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  131. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  132. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  133. package/templates/apps/web/app/[locale]/layout.tsx +37 -5
  134. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  135. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  136. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  137. package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
  138. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  139. package/templates/apps/web/app/[locale]/portfolio/page.tsx +49 -0
  140. package/templates/apps/web/app/[locale]/use-cases/[slug]/page.tsx +18 -3
  141. package/templates/apps/web/lib/nav-config.ts +48 -36
  142. package/templates/apps/web/lib/site-identity.ts +14 -0
  143. package/templates/apps/web/messages/en.json +28 -2
  144. package/templates/content/_site.mdx +4 -2
  145. package/templates/content/en/BlogList/blog.mdx +37 -16
  146. package/templates/content/en/ContactPage/contact.mdx +14 -44
  147. package/templates/content/en/HomePage/home.mdx +8 -16
  148. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  149. package/templates/content/en/UseCasesList/use-cases.mdx +6 -6
  150. package/templates/database/CHANGELOG.md +7 -0
  151. package/templates/database/package.json +1 -1
  152. package/versions.json +33 -33
  153. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
  154. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
  155. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
  156. package/site-templates/simple-service/DESIGN.md +0 -162
  157. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
  158. package/site-templates/simple-service/ailk-template.json +0 -11
  159. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
  160. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
  161. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  162. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  163. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  164. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  165. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  166. package/site-templates/simple-service/provenance.json +0 -4
  167. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
  168. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
  169. package/templates/content/en/SoftwareProduct/product.mdx +0 -175
@@ -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
+ }
@@ -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"],
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @file capture.test.ts
3
+ * `GET /v1/leads/capture` (#6730 D4) — the four D1 environments, and the
4
+ * body's exact shape: two booleans, never the mode or an env name.
5
+ */
6
+
7
+ import type { FastifyInstance } from "fastify";
8
+
9
+ // The route module also carries the write routes; their dependencies are
10
+ // external boundaries this read never reaches.
11
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts is the repo-standard double
12
+ jest.mock("../../../lib/prisma.js");
13
+ jest.mock("@working-theory/email", () => ({
14
+ sendLeadNotification: jest.fn(),
15
+ sendRequestConfirmEmail: jest.fn(),
16
+ sendRequestPaidLinkEmail: jest.fn(),
17
+ }));
18
+
19
+ import { leadCaptureStatusRoute } from "../index";
20
+
21
+ const ENV_KEYS = [
22
+ "DATABASE_URL",
23
+ "NODE_ENV",
24
+ "RESEND_API_KEY",
25
+ "AILK_LEAD_NOTIFICATION_TO",
26
+ "AILK_LEAD_NOTIFICATION_FROM",
27
+ "LEAD_CAPTURE_FILE_DISABLED",
28
+ ] as const;
29
+
30
+ const EMAIL_ENV = {
31
+ RESEND_API_KEY: "re_test",
32
+ AILK_LEAD_NOTIFICATION_TO: "owner@example.com",
33
+ AILK_LEAD_NOTIFICATION_FROM: "noreply@example.com",
34
+ };
35
+
36
+ describe("GET /v1/leads/capture", () => {
37
+ let app: FastifyInstance;
38
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
39
+
40
+ beforeEach(async () => {
41
+ saved = {};
42
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
43
+ for (const k of ENV_KEYS) delete process.env[k];
44
+ const fastify = require("fastify")();
45
+ await fastify.register(leadCaptureStatusRoute);
46
+ app = fastify;
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await app.close();
51
+ for (const k of ENV_KEYS) {
52
+ if (saved[k] === undefined) delete process.env[k];
53
+ else process.env[k] = saved[k];
54
+ }
55
+ });
56
+
57
+ const get = () => app.inject({ method: "GET", url: "/v1/leads/capture" });
58
+
59
+ it.each([
60
+ ["inbox", { DATABASE_URL: "postgres://x", NODE_ENV: "production" }, true, true],
61
+ ["email", { ...EMAIL_ENV, NODE_ENV: "production" }, true, false],
62
+ ["local", { NODE_ENV: "development" }, true, true],
63
+ ["none", { NODE_ENV: "production" }, false, false],
64
+ ] as const)("%s → contact=%s newsletter=%s", async (_mode, env, contact, newsletter) => {
65
+ Object.assign(process.env, env);
66
+
67
+ const res = await get();
68
+
69
+ expect(res.statusCode).toBe(200);
70
+ expect(res.json()).toEqual({ ok: true, data: { contact, newsletter } });
71
+ });
72
+
73
+ it("the body carries exactly ok + data{contact,newsletter} — no mode, no env names", async () => {
74
+ Object.assign(process.env, EMAIL_ENV, { NODE_ENV: "production" });
75
+
76
+ const res = await get();
77
+ const body = res.json();
78
+
79
+ expect(Object.keys(body).sort()).toEqual(["data", "ok"]);
80
+ expect(Object.keys(body.data).sort()).toEqual(["contact", "newsletter"]);
81
+ expect(res.body).not.toMatch(/email|inbox|RESEND|DATABASE|owner@example\.com/);
82
+ });
83
+
84
+ it("is cacheable for a minute", async () => {
85
+ const res = await get();
86
+ expect(res.headers["cache-control"]).toBe("public, max-age=60");
87
+ });
88
+ });
@@ -1236,3 +1236,103 @@ describe("#6335 — the shared prisma mock declares no workspace default", () =>
1236
1236
  });
1237
1237
  });
1238
1238
  });
1239
+
1240
+ // ─── #6730 D2/D5 — email delivery and the production refusal, over HTTP ─────
1241
+
1242
+ describe("POST /v1/leads/submit — #6730 delivery modes", () => {
1243
+ let app: FastifyInstance;
1244
+ const ENV_KEYS = [
1245
+ "DATABASE_URL",
1246
+ "NODE_ENV",
1247
+ "RESEND_API_KEY",
1248
+ "AILK_LEAD_NOTIFICATION_TO",
1249
+ "AILK_LEAD_NOTIFICATION_FROM",
1250
+ "LEAD_CAPTURE_FILE",
1251
+ "AILK_INBOX_FALLBACK_DIR",
1252
+ ] as const;
1253
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1254
+ const sendLeadNotification = jest.requireMock("@working-theory/email")
1255
+ .sendLeadNotification as jest.Mock;
1256
+ const writeLeadFallbackSpy = jest.spyOn(
1257
+ jest.requireActual("../../../lib/file-fallback.js"),
1258
+ "writeLeadFallback",
1259
+ );
1260
+
1261
+ const submit = () =>
1262
+ app.inject({
1263
+ method: "POST",
1264
+ url: "/v1/leads/submit",
1265
+ payload: {
1266
+ kind: "submit",
1267
+ source: "contact",
1268
+ email: "visitor@example.com",
1269
+ payload: { message: "hello" },
1270
+ },
1271
+ });
1272
+
1273
+ beforeEach(async () => {
1274
+ jest.clearAllMocks();
1275
+ saved = {};
1276
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
1277
+ for (const k of ENV_KEYS) delete process.env[k];
1278
+ process.env.NODE_ENV = "production";
1279
+ const fastify = require("fastify")();
1280
+ await fastify.register(leadSubmitRoute);
1281
+ app = fastify;
1282
+ });
1283
+
1284
+ afterEach(async () => {
1285
+ await app.close();
1286
+ for (const k of ENV_KEYS) {
1287
+ if (saved[k] === undefined) delete process.env[k];
1288
+ else process.env[k] = saved[k];
1289
+ }
1290
+ });
1291
+
1292
+ describe("email delivery (no DATABASE_URL, the three email vars)", () => {
1293
+ beforeEach(() => {
1294
+ process.env.RESEND_API_KEY = "re_test";
1295
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1296
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1297
+ });
1298
+
1299
+ it("an accepted email → 201 persisted:email; no Prisma write", async () => {
1300
+ sendLeadNotification.mockResolvedValueOnce({ ok: true, messageId: "msg-e" });
1301
+
1302
+ const res = await submit();
1303
+
1304
+ expect(res.statusCode).toBe(201);
1305
+ expect(res.json()).toMatchObject({ persisted: "email" });
1306
+ expect(res.json().id).toMatch(/^email-/);
1307
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1308
+ expect(sendLeadNotification).toHaveBeenCalledTimes(1);
1309
+ expect(sendLeadNotification.mock.calls[0][0].recipientEmail).toBe("owner@example.com");
1310
+ });
1311
+
1312
+ it("a refused email → 503 delivery_failed, never 201", async () => {
1313
+ sendLeadNotification.mockResolvedValueOnce({
1314
+ ok: false,
1315
+ error: { kind: "provider_error", message: "rejected" },
1316
+ });
1317
+
1318
+ const res = await submit();
1319
+
1320
+ expect(res.statusCode).toBe(503);
1321
+ expect(res.json()).toEqual({
1322
+ error: "delivery_failed",
1323
+ message: "The message could not be delivered.",
1324
+ });
1325
+ });
1326
+ });
1327
+
1328
+ it("production with neither → 503 lead_capture_not_configured; no Prisma, no file, no email", async () => {
1329
+ const res = await submit();
1330
+
1331
+ expect(res.statusCode).toBe(503);
1332
+ expect(res.json().error).toBe("lead_capture_not_configured");
1333
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1334
+ expect(writeLeadFallbackSpy).not.toHaveBeenCalled();
1335
+ expect(sendLeadNotification).not.toHaveBeenCalled();
1336
+ await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
1337
+ });
1338
+ });
@@ -5,6 +5,16 @@
5
5
  * POST /v1/leads/submit — lead submission (contact form / inquiry)
6
6
  * POST /v1/leads/subscribe — newsletter subscription
7
7
  * POST /v1/leads/callback — callback request (phone-back)
8
+ * GET /v1/leads/capture — whether this deployment accepts a contact
9
+ * message / a newsletter signup (#6730 D4)
10
+ *
11
+ * API↔MCP PARITY — `GET /v1/leads/capture` has NO MCP twin, a recorded
12
+ * exception on the same footing as the anonymous form-lifecycle routes. It
13
+ * exists so a web page can choose between the form and the contact email
14
+ * before rendering; an agent never needs that preflight, because the three
15
+ * write tools (`capture_lead`, `subscribe_newsletter`, `request_callback`)
16
+ * already return a typed `api_error` carrying the 503 when capture is not
17
+ * accepted — the same answer, at the moment it matters.
8
18
  *
9
19
  * TENANT RESOLUTION (#6203, spec §10.4 operator ruling — reverses this
10
20
  * file's former byte-unchanged posture). `siteId` used to be taken VERBATIM
@@ -24,6 +34,7 @@
24
34
  import { track } from "@working-theory/analytics/server";
25
35
  import type { FastifyPluginAsync } from "fastify";
26
36
 
37
+ import { acceptsKind, resolveLeadDelivery } from "../../lib/lead-delivery.js";
27
38
  import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
28
39
  import {
29
40
  type LeadKind,
@@ -175,7 +186,20 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
175
186
  );
176
187
  return reply.status(503).send({ error: "persist_failed" });
177
188
  }
178
- // config_missing — lead capture not configured (file disabled + no DB)
189
+ if (result.error.kind === "delivery_failed") {
190
+ // Email was the delivery and the provider did not accept it —
191
+ // never reported as sent (#6730 D5). Detail stays server-side.
192
+ // `{ kind, siteId }` only (spec D5): provider error text can echo
193
+ // an address.
194
+ request.log.error({ kind, siteId: workspaceId }, "lead delivery_failed");
195
+ return reply.status(503).send({
196
+ error: "delivery_failed",
197
+ message: "The message could not be delivered.",
198
+ });
199
+ }
200
+ // config_missing — this deployment has nowhere to put this kind of
201
+ // lead (#6730 D1: production with neither a database nor the email
202
+ // vars, or a newsletter signup with no database).
179
203
  return reply.status(503).send({
180
204
  error: "lead_capture_not_configured",
181
205
  message: "Lead capture is not configured.",
@@ -249,3 +273,24 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
249
273
  export const leadSubmitRoute = makeLeadsRoute("submit");
250
274
  export const leadSubscribeRoute = makeLeadsRoute("subscribe");
251
275
  export const leadCallbackRoute = makeLeadsRoute("callback");
276
+
277
+ /**
278
+ * `GET /v1/leads/capture` (#6730 D4) — two booleans and nothing else: never
279
+ * an address or which env var is missing. (The pair implies as much of the
280
+ * delivery mode as the rendered page already shows.) Registered at
281
+ * the top level in `server.ts`, outside `leadsScope`'s strict per-IP limit
282
+ * (a web server's single egress IP already spends that on form submissions),
283
+ * so only the server-wide unauthenticated ceiling applies.
284
+ */
285
+ export const leadCaptureStatusRoute: FastifyPluginAsync = async (fastify) => {
286
+ fastify.get("/v1/leads/capture", async (_request, reply) => {
287
+ const delivery = resolveLeadDelivery();
288
+ return reply.header("Cache-Control", "public, max-age=60").send({
289
+ ok: true,
290
+ data: {
291
+ contact: acceptsKind(delivery, "submit"),
292
+ newsletter: acceptsKind(delivery, "subscribe"),
293
+ },
294
+ });
295
+ });
296
+ };
@@ -58,6 +58,7 @@ import {
58
58
  import { searchContentRoute } from "./routes/content/search.js";
59
59
  import {
60
60
  leadCallbackRoute,
61
+ leadCaptureStatusRoute,
61
62
  leadSubmitRoute,
62
63
  leadSubscribeRoute,
63
64
  } from "./routes/leads/index.js";
@@ -1145,6 +1146,12 @@ export async function build() {
1145
1146
  await authEmailScope.register(transactionalEmailRoute);
1146
1147
  });
1147
1148
 
1149
+ // Lead-capture status (#6730 D4) — a cheap read the web page makes before
1150
+ // choosing the form or the contact email. Top level, NOT in `leadsScope`:
1151
+ // the web server's single egress IP already spends that scope's strict
1152
+ // 10/min on form submissions. The global unauth ceiling above applies.
1153
+ await server.register(leadCaptureStatusRoute);
1154
+
1148
1155
  // Leads routes — rate-limit middleware scoped to this sub-router. Own
1149
1156
  // default (10/min, pre-#4730) rather than inheriting the wider
1150
1157
  // flows-sized default (#5111, DEF-004 — #4730's shared-default bump was