create-ailk 0.23.0 → 0.24.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 (96) hide show
  1. package/README.md +27 -14
  2. package/dist/add-module.js +6 -1
  3. package/dist/add-route.js +7 -1
  4. package/dist/derive-architecture.js +17 -0
  5. package/dist/lib/scaffold-render-sweep.d.ts +33 -0
  6. package/dist/lib/scaffold-render-sweep.js +45 -0
  7. package/dist/module-architecture.js +17 -0
  8. package/dist/sync-routes.d.ts +30 -4
  9. package/dist/sync-routes.js +271 -18
  10. package/package.json +3 -3
  11. package/templates/.env.example +4 -0
  12. package/templates/CLAUDE.md +4 -2
  13. package/templates/apps/api/.env.example +7 -0
  14. package/templates/apps/api/CLAUDE.md +25 -11
  15. package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
  16. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  17. package/templates/apps/api/src/lib/site.ts +4 -1
  18. package/templates/apps/api/src/lib/stripe.ts +3 -0
  19. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  20. package/templates/apps/api/src/openapi/spec.ts +333 -0
  21. package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
  22. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
  23. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  24. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  25. package/templates/apps/api/src/routes/content/search.ts +16 -45
  26. package/templates/apps/api/src/routes/leads/index.ts +364 -298
  27. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  28. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  29. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  30. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  31. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
  32. package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
  33. package/templates/apps/api/src/server.ts +25 -0
  34. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  35. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
  36. package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
  37. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
  38. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  39. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  40. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  41. package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
  42. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
  43. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
  44. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
  45. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
  46. package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
  47. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
  48. package/templates/apps/api/src/services/offerings/connection.ts +83 -3
  49. package/templates/apps/api/src/services/offerings/index.ts +6 -6
  50. package/templates/apps/api/src/services/person.ts +23 -0
  51. package/templates/apps/api/src/services/project-listings.ts +43 -31
  52. package/templates/apps/mcp/CLAUDE.md +11 -5
  53. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  54. package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
  55. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  56. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  57. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  58. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  59. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  60. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  61. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  62. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  63. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  64. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  65. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  66. package/templates/apps/mcp/package.json +1 -0
  67. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  68. package/templates/apps/mcp/src/http-transport.ts +47 -9
  69. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  70. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  71. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  72. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  73. package/templates/apps/mcp/src/tools/index.ts +22 -21
  74. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  75. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  76. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  77. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  78. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  79. package/templates/apps/web/CLAUDE.md +12 -3
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  81. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
  82. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  83. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
  84. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
  85. package/templates/apps/web/lib/magnet-routes.ts +3 -1
  86. package/templates/apps/web/lib/newsletter.actions.ts +9 -6
  87. package/templates/database/CHANGELOG.md +20 -0
  88. package/templates/database/__tests__/seed.test.ts +33 -1
  89. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  90. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  91. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  92. package/templates/database/package.json +1 -1
  93. package/templates/database/seed.ts +27 -0
  94. package/templates/tools/lint-architecture.ts +205 -1
  95. package/versions.json +34 -34
  96. package/templates/apps/api/src/routes/content/slug.ts +0 -9
@@ -2,15 +2,19 @@
2
2
  * Tests for the search_content MCP tool handler.
3
3
  *
4
4
  * Acceptance criteria verified here:
5
- * - searchContentInputSchema validates correct inputs (query required, type + limit optional)
6
- * - searchContentInputSchema rejects query longer than 200 chars
7
- * - searchContentInputSchema rejects limit > 50
8
- * - searchContentInputSchema rejects empty query
9
- * - ailkSearchContent happy path returns results array with correct shape
10
- * - ailkSearchContent returns empty results for a query with no matches
5
+ * - contentSearchQuerySchema validates correct inputs (q required, type + limit optional)
6
+ * - contentSearchQuerySchema rejects q longer than 200 chars
7
+ * - contentSearchQuerySchema rejects limit > 50
8
+ * - contentSearchQuerySchema rejects empty q
9
+ * - ailkSearchContent happy path returns the route's ItemList with items of the correct shape
10
+ * - ailkSearchContent returns an empty ItemList for a query with no matches
11
11
  * - Malformed input causes Zod rejection
12
- * - #5831: searchContentInputSchema accepts a registry category slug and
12
+ * - #5831: contentSearchQuerySchema accepts a registry category slug and
13
13
  * rejects one outside the registry; the handler forwards it to the adapter
14
+ *
15
+ * #7167 (the operator's decision of 2026-10-19): the tool adopted its route's
16
+ * arguments (`q`, not `query`) and result (the `ItemList`, not `{ results }`),
17
+ * so the cases below read the new names.
14
18
  */
15
19
 
16
20
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -19,102 +23,108 @@ import { join, resolve } from "node:path";
19
23
 
20
24
  import {
21
25
  ailkSearchContent,
22
- searchContentInputSchema,
23
- searchContentOutputSchema,
26
+ contentSearchQuerySchema,
27
+ contentSearchResultSchema,
24
28
  } from "../src/tools/search_content.js";
25
29
 
26
30
  const REPO_ROOT = resolve(__dirname, "../../..");
27
31
  const CONTENT_ROOT = `${REPO_ROOT}/content`;
28
32
 
29
- describe("searchContentInputSchema", () => {
30
- it("accepts a valid query", () => {
31
- const result = searchContentInputSchema.safeParse({ query: "hello" });
33
+ describe("contentSearchQuerySchema", () => {
34
+ it("accepts a valid q", () => {
35
+ const result = contentSearchQuerySchema.safeParse({ q: "hello" });
32
36
  expect(result.success).toBe(true);
33
37
  });
34
38
 
35
- it("accepts query with optional type filter", () => {
36
- const result = searchContentInputSchema.safeParse({
37
- query: "hello",
39
+ it("accepts q with optional type filter", () => {
40
+ const result = contentSearchQuerySchema.safeParse({
41
+ q: "hello",
38
42
  type: "HomePage",
39
43
  });
40
44
  expect(result.success).toBe(true);
41
45
  });
42
46
 
43
- it("accepts query with optional limit", () => {
44
- const result = searchContentInputSchema.safeParse({
45
- query: "hello",
47
+ it("accepts q with optional limit", () => {
48
+ const result = contentSearchQuerySchema.safeParse({
49
+ q: "hello",
46
50
  limit: 10,
47
51
  });
48
52
  expect(result.success).toBe(true);
49
53
  });
50
54
 
51
- it("rejects an empty query", () => {
52
- const result = searchContentInputSchema.safeParse({ query: "" });
55
+ it("rejects an empty q", () => {
56
+ const result = contentSearchQuerySchema.safeParse({ q: "" });
53
57
  expect(result.success).toBe(false);
54
58
  });
55
59
 
56
- it("rejects a query longer than 200 chars", () => {
57
- const result = searchContentInputSchema.safeParse({
58
- query: "a".repeat(201),
60
+ it("rejects a q longer than 200 chars", () => {
61
+ const result = contentSearchQuerySchema.safeParse({
62
+ q: "a".repeat(201),
59
63
  });
60
64
  expect(result.success).toBe(false);
61
65
  });
62
66
 
63
- it("accepts a query exactly 200 chars", () => {
64
- const result = searchContentInputSchema.safeParse({
65
- query: "a".repeat(200),
67
+ it("accepts a q exactly 200 chars", () => {
68
+ const result = contentSearchQuerySchema.safeParse({
69
+ q: "a".repeat(200),
66
70
  });
67
71
  expect(result.success).toBe(true);
68
72
  });
69
73
 
70
74
  it("rejects limit of 0", () => {
71
- const result = searchContentInputSchema.safeParse({
72
- query: "hello",
75
+ const result = contentSearchQuerySchema.safeParse({
76
+ q: "hello",
73
77
  limit: 0,
74
78
  });
75
79
  expect(result.success).toBe(false);
76
80
  });
77
81
 
78
82
  it("rejects limit > 50", () => {
79
- const result = searchContentInputSchema.safeParse({
80
- query: "hello",
83
+ const result = contentSearchQuerySchema.safeParse({
84
+ q: "hello",
81
85
  limit: 51,
82
86
  });
83
87
  expect(result.success).toBe(false);
84
88
  });
85
89
 
86
90
  it("accepts limit of 50", () => {
87
- const result = searchContentInputSchema.safeParse({
88
- query: "hello",
91
+ const result = contentSearchQuerySchema.safeParse({
92
+ q: "hello",
89
93
  limit: 50,
90
94
  });
91
95
  expect(result.success).toBe(true);
92
96
  });
93
97
 
98
+ it("coerces a string limit, as the route's query string parses (#7167 DD1)", () => {
99
+ const result = contentSearchQuerySchema.safeParse({ q: "hello", limit: "5" });
100
+ expect(result.success).toBe(true);
101
+ expect(result.data!.limit).toBe(5);
102
+ });
103
+
94
104
  it("rejects an unknown type string", () => {
95
- const result = searchContentInputSchema.safeParse({
96
- query: "hello",
105
+ const result = contentSearchQuerySchema.safeParse({
106
+ q: "hello",
97
107
  type: "NotAType",
98
108
  });
99
109
  expect(result.success).toBe(false);
100
110
  });
101
111
 
102
- it("rejects missing query (malformed input)", () => {
103
- const result = searchContentInputSchema.safeParse({});
112
+ it("rejects missing q (malformed input)", () => {
113
+ const result = contentSearchQuerySchema.safeParse({});
104
114
  expect(result.success).toBe(false);
105
115
  });
106
116
 
107
117
  it("accepts a category slug from the registry (#5831)", () => {
108
- const result = searchContentInputSchema.safeParse({
109
- query: "roof",
118
+ const result = contentSearchQuerySchema.safeParse({
119
+ q: "roof",
110
120
  category: "roofing",
111
121
  });
112
122
  expect(result.success).toBe(true);
113
123
  });
114
124
 
115
125
  it("rejects a category slug outside the registry with an enum error (#5831)", () => {
116
- const result = searchContentInputSchema.safeParse({
117
- query: "roof",
126
+ const result = contentSearchQuerySchema.safeParse({
127
+ q: "roof",
118
128
  category: "not-a-real-category",
119
129
  });
120
130
  expect(result.success).toBe(false);
@@ -125,8 +135,8 @@ describe("searchContentInputSchema", () => {
125
135
  });
126
136
 
127
137
  it("rejects a GROUP slug — groups are derived, never tagged (#5831)", () => {
128
- const result = searchContentInputSchema.safeParse({
129
- query: "roof",
138
+ const result = contentSearchQuerySchema.safeParse({
139
+ q: "roof",
130
140
  category: "professional-services",
131
141
  });
132
142
  expect(result.success).toBe(false);
@@ -182,15 +192,15 @@ describe("ailkSearchContent — category filter (#5831)", () => {
182
192
 
183
193
  it("forwards category to the adapter — only the tagged page comes back", async () => {
184
194
  const result = await ailkSearchContent(
185
- { query: "roof", category: "roofing" },
195
+ { q: "roof", category: "roofing" },
186
196
  root,
187
197
  );
188
- expect(result.results.map((r) => r.slug)).toEqual(["roof-replacement"]);
198
+ expect(result.items.map((r) => r.slug)).toEqual(["roof-replacement"]);
189
199
  });
190
200
 
191
201
  it("returns every match when no category is passed", async () => {
192
- const result = await ailkSearchContent({ query: "roof" }, root);
193
- expect(result.results.map((r) => r.slug).sort()).toEqual([
202
+ const result = await ailkSearchContent({ q: "roof" }, root);
203
+ expect(result.items.map((r) => r.slug).sort()).toEqual([
194
204
  "attic-ventilation",
195
205
  "roof-replacement",
196
206
  ]);
@@ -198,22 +208,26 @@ describe("ailkSearchContent — category filter (#5831)", () => {
198
208
 
199
209
  it("returns [] for a registry slug nothing is tagged with", async () => {
200
210
  const result = await ailkSearchContent(
201
- { query: "roof", category: "legal" },
211
+ { q: "roof", category: "legal" },
202
212
  root,
203
213
  );
204
- expect(result.results).toEqual([]);
214
+ expect(result.items).toEqual([]);
205
215
  });
206
216
  });
207
217
 
208
218
  describe("ailkSearchContent handler", () => {
209
- it("returns a results array on a known query", async () => {
210
- const result = await ailkSearchContent({ query: "launch" }, CONTENT_ROOT);
211
- expect(Array.isArray(result.results)).toBe(true);
219
+ it("returns the route's ItemList on a known query", async () => {
220
+ const result = await ailkSearchContent({ q: "launch" }, CONTENT_ROOT);
221
+ expect(result["@context"]).toBe("https://schema.org");
222
+ expect(result["@type"]).toBe("ItemList");
223
+ expect(result.query).toBe("launch");
224
+ expect(Array.isArray(result.items)).toBe(true);
225
+ expect(result.totalItems).toBe(result.items.length);
212
226
  });
213
227
 
214
228
  it("result items have required shape fields", async () => {
215
- const result = await ailkSearchContent({ query: "launch" }, CONTENT_ROOT);
216
- for (const item of result.results) {
229
+ const result = await ailkSearchContent({ q: "launch" }, CONTENT_ROOT);
230
+ for (const item of result.items) {
217
231
  expect(typeof item.slug).toBe("string");
218
232
  expect(typeof item.type).toBe("string");
219
233
  expect(typeof item.title).toBe("string");
@@ -222,25 +236,26 @@ describe("ailkSearchContent handler", () => {
222
236
  }
223
237
  });
224
238
 
225
- it("returns empty results for a query that matches nothing", async () => {
239
+ it("returns an empty ItemList for a query that matches nothing", async () => {
226
240
  const result = await ailkSearchContent(
227
- { query: "xqzxqzxqzxqz_no_match_ever" },
241
+ { q: "xqzxqzxqzxqz_no_match_ever" },
228
242
  CONTENT_ROOT,
229
243
  );
230
- expect(result.results).toEqual([]);
244
+ expect(result.items).toEqual([]);
245
+ expect(result.totalItems).toBe(0);
231
246
  });
232
247
 
233
248
  it("respects limit option", async () => {
234
249
  const result = await ailkSearchContent(
235
- { query: "a", limit: 1 },
250
+ { q: "a", limit: 1 },
236
251
  CONTENT_ROOT,
237
252
  );
238
- expect(result.results.length).toBeLessThanOrEqual(1);
253
+ expect(result.items.length).toBeLessThanOrEqual(1);
239
254
  });
240
255
 
241
- it("output validates against searchContentOutputSchema", async () => {
242
- const result = await ailkSearchContent({ query: "launch" }, CONTENT_ROOT);
243
- const parsed = searchContentOutputSchema.safeParse(result);
256
+ it("output validates against contentSearchResultSchema", async () => {
257
+ const result = await ailkSearchContent({ q: "launch" }, CONTENT_ROOT);
258
+ const parsed = contentSearchResultSchema.safeParse(result);
244
259
  expect(parsed.success).toBe(true);
245
260
  });
246
261
  });
@@ -1,17 +1,22 @@
1
1
  /**
2
- * Tests for the submit_lead MCP tool handler.
2
+ * Tests for the capture_lead MCP tool handler.
3
3
  *
4
- * Acceptance criteria verified here:
4
+ * The tool takes the body `POST /v1/leads/submit` takes and returns the
5
+ * route's 201 body (#7171 AC-11 — the tool adopted the API). Verified here:
5
6
  * 1. Malformed input → { ok: false, error: { kind: 'invalid_input', issues } }, api-client never called
6
- * 2. Happy path → forwards to apiClient.leads.submit, returns { ok: true, id, persisted }
7
- * 3. API 400 → { ok: false, error: { kind: 'api_error', status: 400 } }
8
- * 4. API 503 → { ok: false, error: { kind: 'api_error', status: 503 } }
9
- * 5. API 429 → { ok: false, error: { kind: 'api_error', status: 429 } }
10
- * 6. #8073 — forwards the trusted caller address + AILK_VISITOR_FORWARD_KEY
7
+ * 2. Happy path → posts exactly the parsed body to apiClient.leads.submit —
8
+ * no `siteId`, whatever AILK_SITE_ID says, and no stamped `source` — and
9
+ * returns the route's body unchanged, `email` included
10
+ * 3. API 400 / 503 / 429 → { ok: false, error: { kind: 'api_error', status } }
11
+ * 4. #7397 — the spoke marker `payload.site` from AILK_LEAD_SITE, spread last
12
+ * 5. #8073 — forwards the trusted caller address + AILK_VISITOR_FORWARD_KEY
11
13
  * as the two visitor-forwarding headers only when both are present
12
14
  */
13
15
 
14
- import { LEAD_SITE_MARKER_KEY, leadSubmitSchema } from "@working-theory/validation";
16
+ import {
17
+ LEAD_SITE_MARKER_KEY,
18
+ leadSubmitBodySchema,
19
+ } from "@working-theory/validation";
15
20
  import { ailkCaptureLead } from "../src/tools/capture_lead.js";
16
21
  import { trustedCallerAddress } from "../src/request-context.js";
17
22
 
@@ -30,222 +35,141 @@ jest.mock("@working-theory/api-client", () => {
30
35
  };
31
36
  });
32
37
 
33
-
38
+
34
39
  const { apiClient } = require("@working-theory/api-client") as {
35
40
  apiClient: { leads: { submit: jest.Mock } };
36
41
  };
37
42
 
43
+ /** The route body, as an agent sends it. */
38
44
  const VALID_INPUT = {
39
- name: "Jane Smith",
45
+ source: "mcp",
40
46
  email: "jane@example.com",
41
- message: "I am interested in your services.",
47
+ payload: { name: "Jane Smith", message: "I am interested in your services." },
48
+ };
49
+
50
+ /** The route's 201 body, as the api-client hands it back. */
51
+ const WIRE = {
52
+ id: "lead_00000000-0000-7000-8000-0000000000d1",
53
+ persisted: "db",
54
+ email: { ok: true, messageId: "msg-1" },
42
55
  };
43
56
 
44
57
  beforeEach(() => {
45
58
  jest.clearAllMocks();
46
59
  // The exact-body assertions below assume no spoke marker (#7397).
47
60
  delete process.env.AILK_LEAD_SITE;
61
+ delete process.env.AILK_SITE_ID;
48
62
  });
49
63
 
50
64
  describe("ailkCaptureLead — input validation", () => {
51
- it("rejects malformed input (missing required fields) without calling api-client", async () => {
65
+ it("rejects malformed input (no source) without calling api-client", async () => {
52
66
  const result = await ailkCaptureLead({ email: "not-an-email" } as never);
53
67
 
54
- expect(result.ok).toBe(false);
55
- if (!result.ok) {
56
- expect(result.error.kind).toBe("invalid_input");
57
- if (result.error.kind === "invalid_input") {
58
- expect(Array.isArray(result.error.issues)).toBe(true);
59
- expect(result.error.issues.length).toBeGreaterThan(0);
60
- }
68
+ expect(result).toMatchObject({ ok: false, error: { kind: "invalid_input" } });
69
+ if (!result.ok && result.error.kind === "invalid_input") {
70
+ expect(result.error.issues.length).toBeGreaterThan(0);
61
71
  }
62
72
  expect(apiClient.leads.submit).not.toHaveBeenCalled();
63
73
  });
64
- });
65
74
 
66
- describe("ailkCaptureLead — happy path", () => {
67
- it("forwards canonical body to api-client and returns ok:true with id and persisted", async () => {
68
- apiClient.leads.submit.mockResolvedValueOnce({
69
- ok: true,
70
- body: { id: "lead_00000000-0000-7000-8000-0000000000d1", persisted: "db" },
71
- });
75
+ it("admits the StepFlow envelope — email inside payload, none at the top level — as the route does", async () => {
76
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
77
+ const envelope = { source: "step-flow", payload: { email: "env@example.com" } };
72
78
 
73
- const result = await ailkCaptureLead(VALID_INPUT);
79
+ await ailkCaptureLead(envelope);
74
80
 
75
- expect(apiClient.leads.submit).toHaveBeenCalledWith({
76
- siteId: "default",
77
- source: "mcp",
78
- email: "jane@example.com",
79
- payload: { name: "Jane Smith", message: "I am interested in your services." },
80
- });
81
- expect(result.ok).toBe(true);
82
- if (result.ok) {
83
- expect(result.id).toBe("lead_00000000-0000-7000-8000-0000000000d1");
84
- expect(result.persisted).toBe("db");
85
- }
81
+ expect(apiClient.leads.submit).toHaveBeenCalledWith(envelope);
86
82
  });
87
83
  });
88
84
 
89
- describe("ailkCaptureLead — API error mapping", () => {
90
- it("maps API 400 to { ok: false, error: { kind: 'api_error', status: 400 } }", async () => {
91
- apiClient.leads.submit.mockResolvedValueOnce({
92
- ok: false,
93
- status: 400,
94
- body: { error: "bad_request" },
95
- });
96
-
97
- const result = await ailkCaptureLead(VALID_INPUT);
98
-
99
- expect(result.ok).toBe(false);
100
- if (!result.ok) {
101
- expect(result.error.kind).toBe("api_error");
102
- if (result.error.kind === "api_error") {
103
- expect(result.error.status).toBe(400);
104
- }
105
- }
106
- });
107
-
108
- it("maps API 503 to { ok: false, error: { kind: 'api_error', status: 503 } }", async () => {
109
- apiClient.leads.submit.mockResolvedValueOnce({
110
- ok: false,
111
- status: 503,
112
- body: { error: "service_unavailable" },
113
- });
114
-
115
- const result = await ailkCaptureLead(VALID_INPUT);
116
-
117
- expect(result.ok).toBe(false);
118
- if (!result.ok) {
119
- expect(result.error.kind).toBe("api_error");
120
- if (result.error.kind === "api_error") {
121
- expect(result.error.status).toBe(503);
122
- }
123
- }
124
- });
125
-
126
- it("maps API 429 to { ok: false, error: { kind: 'api_error', status: 429 } }", async () => {
127
- apiClient.leads.submit.mockResolvedValueOnce({
128
- ok: false,
129
- status: 429,
130
- body: { error: "rate_limited" },
131
- });
85
+ describe("ailkCaptureLead — happy path", () => {
86
+ it("posts exactly the parsed body and returns the route's body unchanged, email included", async () => {
87
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
132
88
 
133
89
  const result = await ailkCaptureLead(VALID_INPUT);
134
90
 
135
- expect(result.ok).toBe(false);
136
- if (!result.ok) {
137
- expect(result.error.kind).toBe("api_error");
138
- if (result.error.kind === "api_error") {
139
- expect(result.error.status).toBe(429);
140
- }
141
- }
91
+ expect(apiClient.leads.submit).toHaveBeenCalledWith(VALID_INPUT);
92
+ expect(result).toEqual(WIRE);
142
93
  });
143
- });
144
94
 
145
- describe("ailkCaptureLead — siteId default", () => {
146
- it("uses siteId 'default' when AILK_SITE_ID is unset", async () => {
147
- delete process.env.AILK_SITE_ID;
148
- apiClient.leads.submit.mockResolvedValueOnce({
149
- ok: true,
150
- body: { id: "lead_00000000-0000-7000-8000-0000000000d2", persisted: "db" },
151
- });
95
+ it("never sends a siteId, whatever AILK_SITE_ID says, and strips one the agent sent", async () => {
96
+ process.env.AILK_SITE_ID = "site-abc";
97
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
152
98
 
153
- await ailkCaptureLead(VALID_INPUT);
99
+ await ailkCaptureLead({ ...VALID_INPUT, siteId: "site-xyz" } as never);
154
100
 
155
- const call = apiClient.leads.submit.mock.calls[0][0] as { siteId: string };
156
- expect(call.siteId).toBe("default");
101
+ expect(apiClient.leads.submit.mock.calls[0][0]).toEqual(VALID_INPUT);
157
102
  });
158
103
  });
159
104
 
160
- describe("ailkCaptureLead — siteId override", () => {
161
- afterEach(() => {
162
- delete process.env.AILK_SITE_ID;
163
- });
164
-
165
- it("uses AILK_SITE_ID env var when set", async () => {
166
- process.env.AILK_SITE_ID = "site-abc";
167
- apiClient.leads.submit.mockResolvedValueOnce({
168
- ok: true,
169
- body: { id: "lead_00000000-0000-7000-8000-0000000000d3", persisted: "db" },
170
- });
171
-
172
- await ailkCaptureLead(VALID_INPUT);
173
-
174
- const call = apiClient.leads.submit.mock.calls[0][0] as { siteId: string };
175
- expect(call.siteId).toBe("site-abc");
176
- });
105
+ describe("ailkCaptureLead — API error mapping", () => {
106
+ it.each([400, 503, 429])(
107
+ "maps API %i to { ok: false, error: { kind: 'api_error', status } }",
108
+ async (status) => {
109
+ apiClient.leads.submit.mockResolvedValueOnce({
110
+ ok: false,
111
+ status,
112
+ body: { error: "refused" },
113
+ });
114
+
115
+ const result = await ailkCaptureLead(VALID_INPUT);
116
+
117
+ expect(result).toEqual({ ok: false, error: { kind: "api_error", status } });
118
+ },
119
+ );
177
120
  });
178
121
 
179
122
  // #7397 — a spoke of a hub-and-spokes deployment is marked in the lead
180
- // PAYLOAD (`payload.site`); `source` stays "mcp" because
181
- // MESSAGE_LEAD_SOURCE_LIST is frozen. The marker comes from the MCP server's
182
- // own env, so an MCP-origin lead reaches the hub inbox attributed.
123
+ // PAYLOAD (`payload.site`). The marker comes from the MCP server's own env,
124
+ // so an MCP-origin lead reaches the hub inbox attributed; it is the one key
125
+ // the tool adds to the body the agent sent.
183
126
  describe("ailkCaptureLead — payload site marker (#7397)", () => {
184
- afterEach(() => {
185
- delete process.env.AILK_LEAD_SITE;
186
- });
187
-
188
127
  it("names the marker key 'site', the key the issue's hub-and-spokes design names", () => {
189
128
  expect(LEAD_SITE_MARKER_KEY).toBe("site");
190
129
  });
191
130
 
192
- it("carries payload.site from AILK_LEAD_SITE, leaving source 'mcp'", async () => {
131
+ it("spreads payload.site from AILK_LEAD_SITE LAST, over an agent's own value", async () => {
193
132
  process.env.AILK_LEAD_SITE = "spoke-a.example";
194
- apiClient.leads.submit.mockResolvedValueOnce({
195
- ok: true,
196
- body: { id: "lead_00000000-0000-7000-8000-0000000000d4", persisted: "db" },
197
- });
133
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
198
134
 
199
- await ailkCaptureLead(VALID_INPUT);
135
+ await ailkCaptureLead({
136
+ ...VALID_INPUT,
137
+ payload: { ...VALID_INPUT.payload, site: "agent-said" },
138
+ });
200
139
 
201
140
  expect(apiClient.leads.submit).toHaveBeenCalledWith({
202
- siteId: "default",
203
- source: "mcp",
204
- email: "jane@example.com",
205
- payload: {
206
- name: "Jane Smith",
207
- message: "I am interested in your services.",
208
- site: "spoke-a.example",
209
- },
141
+ ...VALID_INPUT,
142
+ payload: { ...VALID_INPUT.payload, site: "spoke-a.example" },
210
143
  });
211
144
  });
212
145
 
213
- it("sends a marked body the API's submit schema accepts", async () => {
146
+ it("marks a body the agent sent without a payload", async () => {
214
147
  process.env.AILK_LEAD_SITE = "spoke-a.example";
215
- apiClient.leads.submit.mockResolvedValueOnce({
216
- ok: true,
217
- body: { id: "lead_00000000-0000-7000-8000-0000000000d5", persisted: "db" },
218
- });
148
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
219
149
 
220
- await ailkCaptureLead(VALID_INPUT);
150
+ await ailkCaptureLead({ source: "mcp", email: "jane@example.com" });
221
151
 
222
152
  const body = apiClient.leads.submit.mock.calls[0][0] as Record<string, unknown>;
223
- expect(leadSubmitSchema.safeParse({ kind: "submit", ...body }).success).toBe(true);
153
+ expect(body["payload"]).toEqual({ site: "spoke-a.example" });
154
+ expect(leadSubmitBodySchema.safeParse(body).success).toBe(true);
224
155
  });
225
156
 
226
157
  it.each([
227
158
  ["unset", undefined],
228
159
  ["empty", ""],
229
160
  ["whitespace only", " "],
230
- ])("omits payload.site when AILK_LEAD_SITE is %s", async (_label, value) => {
161
+ ])("sends the payload as it came when AILK_LEAD_SITE is %s", async (_label, value) => {
231
162
  if (value !== undefined) process.env.AILK_LEAD_SITE = value;
232
- apiClient.leads.submit.mockResolvedValueOnce({
233
- ok: true,
234
- body: { id: "lead_00000000-0000-7000-8000-0000000000d6", persisted: "db" },
235
- });
163
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
236
164
 
237
165
  await ailkCaptureLead(VALID_INPUT);
238
166
 
239
- const call = apiClient.leads.submit.mock.calls[0][0] as { payload: Record<string, unknown> };
240
- expect(call.payload).not.toHaveProperty("site");
167
+ expect(apiClient.leads.submit.mock.calls[0][0]).toEqual(VALID_INPUT);
241
168
  });
242
169
 
243
170
  it("trims surrounding whitespace from AILK_LEAD_SITE", async () => {
244
171
  process.env.AILK_LEAD_SITE = " spoke-a.example\n";
245
- apiClient.leads.submit.mockResolvedValueOnce({
246
- ok: true,
247
- body: { id: "lead_00000000-0000-7000-8000-0000000000d7", persisted: "db" },
248
- });
172
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
249
173
 
250
174
  await ailkCaptureLead(VALID_INPUT);
251
175
 
@@ -260,10 +184,7 @@ describe("ailkCaptureLead — visitor-forwarding headers (#8073)", () => {
260
184
  });
261
185
 
262
186
  it("calls api-client with exactly one argument when there is no trusted address", async () => {
263
- apiClient.leads.submit.mockResolvedValueOnce({
264
- ok: true,
265
- body: { id: "lead_00000000-0000-7000-8000-0000000000e1", persisted: "db" },
266
- });
187
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
267
188
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
268
189
 
269
190
  await ailkCaptureLead(VALID_INPUT);
@@ -272,10 +193,7 @@ describe("ailkCaptureLead — visitor-forwarding headers (#8073)", () => {
272
193
  });
273
194
 
274
195
  it("forwards the trusted address and the forward key as the two visitor headers", async () => {
275
- apiClient.leads.submit.mockResolvedValueOnce({
276
- ok: true,
277
- body: { id: "lead_00000000-0000-7000-8000-0000000000e2", persisted: "db" },
278
- });
196
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
279
197
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
280
198
 
281
199
  await trustedCallerAddress.run("203.0.113.9", () =>
@@ -292,10 +210,7 @@ describe("ailkCaptureLead — visitor-forwarding headers (#8073)", () => {
292
210
  });
293
211
 
294
212
  it("sends no headers when there is a trusted address but no forward key", async () => {
295
- apiClient.leads.submit.mockResolvedValueOnce({
296
- ok: true,
297
- body: { id: "lead_00000000-0000-7000-8000-0000000000e3", persisted: "db" },
298
- });
213
+ apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
299
214
  delete process.env.AILK_VISITOR_FORWARD_KEY;
300
215
 
301
216
  await trustedCallerAddress.run("203.0.113.9", () =>