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
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Tests for the subscribe_newsletter MCP tool handler.
3
3
  *
4
- * Acceptance criteria verified here:
4
+ * The tool takes the body `POST /v1/leads/subscribe` takes — `{ email,
5
+ * source }` — and returns the route's 201 body (#7172 AC-11 — the tool
6
+ * adopted the API). Verified here:
5
7
  * 1. Malformed input → { ok: false, error: { kind: 'invalid_input', issues } }, api-client never called
6
- * 2. Happy path → forwards to apiClient.leads.subscribe, 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
8
+ * 2. Happy path → posts exactly the parsed body to apiClient.leads.subscribe —
9
+ * no `siteId`, whatever AILK_SITE_ID says, no stamped `source`, no
10
+ * `firstName` — and returns the route's body unchanged, `email` included
11
+ * 3. API 400 / 503 / 429 → { ok: false, error: { kind: 'api_error', status } }
12
+ * 4. #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
 
@@ -29,145 +31,81 @@ jest.mock("@working-theory/api-client", () => {
29
31
  };
30
32
  });
31
33
 
32
-
34
+
33
35
  const { apiClient } = require("@working-theory/api-client") as {
34
36
  apiClient: { leads: { subscribe: jest.Mock } };
35
37
  };
36
38
 
37
- const VALID_INPUT = {
38
- email: "jane@example.com",
39
+ /** The route body, as an agent sends it. */
40
+ const VALID_INPUT = { email: "jane@example.com", source: "mcp" };
41
+
42
+ /** The route's 201 body, as the api-client hands it back. */
43
+ const WIRE = {
44
+ id: "subscr_00000000-0000-7000-8000-0000000000d1",
45
+ persisted: "db",
46
+ email: { skipped: true, reason: "no sender configured" },
39
47
  };
40
48
 
41
49
  beforeEach(() => {
42
50
  jest.clearAllMocks();
51
+ delete process.env.AILK_SITE_ID;
43
52
  });
44
53
 
45
54
  describe("ailkSubscribeNewsletter — input validation", () => {
46
- it("rejects malformed input (invalid email) without calling api-client", async () => {
47
- const result = await ailkSubscribeNewsletter({ email: "not-an-email" });
48
-
49
- expect(result.ok).toBe(false);
50
- if (!result.ok) {
51
- expect(result.error.kind).toBe("invalid_input");
52
- if (result.error.kind === "invalid_input") {
53
- expect(Array.isArray(result.error.issues)).toBe(true);
54
- expect(result.error.issues.length).toBeGreaterThan(0);
55
- }
55
+ it.each([
56
+ ["an invalid email", { email: "not-an-email", source: "mcp" }],
57
+ ["no source", { email: "jane@example.com" }],
58
+ ["a source with a control character", { email: "jane@example.com", source: "a\nb" }],
59
+ ])("rejects %s without calling api-client", async (_label, input) => {
60
+ const result = await ailkSubscribeNewsletter(input as never);
61
+
62
+ expect(result).toMatchObject({ ok: false, error: { kind: "invalid_input" } });
63
+ if (!result.ok && result.error.kind === "invalid_input") {
64
+ expect(result.error.issues.length).toBeGreaterThan(0);
56
65
  }
57
66
  expect(apiClient.leads.subscribe).not.toHaveBeenCalled();
58
67
  });
59
68
  });
60
69
 
61
70
  describe("ailkSubscribeNewsletter — happy path", () => {
62
- it("forwards canonical body to api-client and returns ok:true with id and persisted", async () => {
63
- apiClient.leads.subscribe.mockResolvedValueOnce({
64
- ok: true,
65
- body: { id: "subscr_00000000-0000-7000-8000-0000000000d1", persisted: "db" },
66
- });
67
-
68
- const result = await ailkSubscribeNewsletter(VALID_INPUT);
69
-
70
- expect(apiClient.leads.subscribe).toHaveBeenCalledWith({
71
- siteId: "default",
72
- email: "jane@example.com",
73
- source: "mcp",
74
- });
75
- expect(result.ok).toBe(true);
76
- if (result.ok) {
77
- expect(result.id).toBe("subscr_00000000-0000-7000-8000-0000000000d1");
78
- expect(result.persisted).toBe("db");
79
- }
80
- });
81
- });
82
-
83
- describe("ailkSubscribeNewsletter — API error mapping", () => {
84
- it("maps API 400 to { ok: false, error: { kind: 'api_error', status: 400 } }", async () => {
85
- apiClient.leads.subscribe.mockResolvedValueOnce({
86
- ok: false,
87
- status: 400,
88
- body: { error: "bad_request" },
89
- });
90
-
91
- const result = await ailkSubscribeNewsletter(VALID_INPUT);
92
-
93
- expect(result.ok).toBe(false);
94
- if (!result.ok) {
95
- expect(result.error.kind).toBe("api_error");
96
- if (result.error.kind === "api_error") {
97
- expect(result.error.status).toBe(400);
98
- }
99
- }
100
- });
101
-
102
- it("maps API 503 to { ok: false, error: { kind: 'api_error', status: 503 } }", async () => {
103
- apiClient.leads.subscribe.mockResolvedValueOnce({
104
- ok: false,
105
- status: 503,
106
- body: { error: "service_unavailable" },
107
- });
108
-
109
- const result = await ailkSubscribeNewsletter(VALID_INPUT);
110
-
111
- expect(result.ok).toBe(false);
112
- if (!result.ok) {
113
- expect(result.error.kind).toBe("api_error");
114
- if (result.error.kind === "api_error") {
115
- expect(result.error.status).toBe(503);
116
- }
117
- }
118
- });
119
-
120
- it("maps API 429 to { ok: false, error: { kind: 'api_error', status: 429 } }", async () => {
121
- apiClient.leads.subscribe.mockResolvedValueOnce({
122
- ok: false,
123
- status: 429,
124
- body: { error: "rate_limited" },
125
- });
71
+ it("posts exactly the parsed body and returns the route's body unchanged, email included", async () => {
72
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
126
73
 
127
74
  const result = await ailkSubscribeNewsletter(VALID_INPUT);
128
75
 
129
- expect(result.ok).toBe(false);
130
- if (!result.ok) {
131
- expect(result.error.kind).toBe("api_error");
132
- if (result.error.kind === "api_error") {
133
- expect(result.error.status).toBe(429);
134
- }
135
- }
76
+ expect(apiClient.leads.subscribe).toHaveBeenCalledWith(VALID_INPUT);
77
+ expect(result).toEqual(WIRE);
136
78
  });
137
- });
138
79
 
139
- describe("ailkSubscribeNewsletter — siteId default", () => {
140
- it("uses siteId 'default' when AILK_SITE_ID is unset", async () => {
141
- delete process.env.AILK_SITE_ID;
142
- apiClient.leads.subscribe.mockResolvedValueOnce({
143
- ok: true,
144
- body: { id: "subscr_00000000-0000-7000-8000-0000000000d2", persisted: "db" },
145
- });
80
+ it("never sends a siteId or a firstName, whatever AILK_SITE_ID says or the agent sent", async () => {
81
+ process.env.AILK_SITE_ID = "site-abc";
82
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
146
83
 
147
- await ailkSubscribeNewsletter(VALID_INPUT);
84
+ await ailkSubscribeNewsletter({
85
+ ...VALID_INPUT,
86
+ siteId: "site-xyz",
87
+ firstName: "Jane",
88
+ } as never);
148
89
 
149
- const call = apiClient.leads.subscribe.mock.calls[0][0] as { siteId: string };
150
- expect(call.siteId).toBe("default");
90
+ expect(apiClient.leads.subscribe.mock.calls[0][0]).toEqual(VALID_INPUT);
151
91
  });
152
92
  });
153
93
 
154
- describe("ailkSubscribeNewsletter — siteId override", () => {
155
- afterEach(() => {
156
- delete process.env.AILK_SITE_ID;
157
- });
158
-
159
- it("uses AILK_SITE_ID env var when set", async () => {
160
- process.env.AILK_SITE_ID = "site-abc";
161
- apiClient.leads.subscribe.mockResolvedValueOnce({
162
- ok: true,
163
- body: { id: "subscr_00000000-0000-7000-8000-0000000000d3", persisted: "db" },
164
- });
165
-
166
- await ailkSubscribeNewsletter(VALID_INPUT);
167
-
168
- const call = apiClient.leads.subscribe.mock.calls[0][0] as { siteId: string };
169
- expect(call.siteId).toBe("site-abc");
170
- });
94
+ describe("ailkSubscribeNewsletter — API error mapping", () => {
95
+ it.each([400, 503, 429])(
96
+ "maps API %i to { ok: false, error: { kind: 'api_error', status } }",
97
+ async (status) => {
98
+ apiClient.leads.subscribe.mockResolvedValueOnce({
99
+ ok: false,
100
+ status,
101
+ body: { error: "refused" },
102
+ });
103
+
104
+ const result = await ailkSubscribeNewsletter(VALID_INPUT);
105
+
106
+ expect(result).toEqual({ ok: false, error: { kind: "api_error", status } });
107
+ },
108
+ );
171
109
  });
172
110
 
173
111
  describe("ailkSubscribeNewsletter — visitor-forwarding headers (#8073)", () => {
@@ -176,10 +114,7 @@ describe("ailkSubscribeNewsletter — visitor-forwarding headers (#8073)", () =>
176
114
  });
177
115
 
178
116
  it("calls api-client with exactly one argument when there is no trusted address", async () => {
179
- apiClient.leads.subscribe.mockResolvedValueOnce({
180
- ok: true,
181
- body: { id: "subscr_00000000-0000-7000-8000-0000000000e1", persisted: "db" },
182
- });
117
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
183
118
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
184
119
 
185
120
  await ailkSubscribeNewsletter(VALID_INPUT);
@@ -188,10 +123,7 @@ describe("ailkSubscribeNewsletter — visitor-forwarding headers (#8073)", () =>
188
123
  });
189
124
 
190
125
  it("forwards the trusted address and the forward key as the two visitor headers", async () => {
191
- apiClient.leads.subscribe.mockResolvedValueOnce({
192
- ok: true,
193
- body: { id: "subscr_00000000-0000-7000-8000-0000000000e2", persisted: "db" },
194
- });
126
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
195
127
  process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
196
128
 
197
129
  await trustedCallerAddress.run("203.0.113.9", () =>
@@ -208,10 +140,7 @@ describe("ailkSubscribeNewsletter — visitor-forwarding headers (#8073)", () =>
208
140
  });
209
141
 
210
142
  it("sends no headers when there is a trusted address but no forward key", async () => {
211
- apiClient.leads.subscribe.mockResolvedValueOnce({
212
- ok: true,
213
- body: { id: "subscr_00000000-0000-7000-8000-0000000000e3", persisted: "db" },
214
- });
143
+ apiClient.leads.subscribe.mockResolvedValueOnce({ ok: true, body: WIRE });
215
144
  delete process.env.AILK_VISITOR_FORWARD_KEY;
216
145
 
217
146
  await trustedCallerAddress.run("203.0.113.9", () =>
@@ -27,6 +27,7 @@
27
27
  "@working-theory/license": "workspace:*",
28
28
  "@working-theory/metadata": "workspace:*",
29
29
  "@working-theory/schema": "workspace:*",
30
+ "@working-theory/site-config": "workspace:*",
30
31
  "@working-theory/validation": "workspace:*",
31
32
  "fastify": "^5.12.5",
32
33
  "yaml": "^2.9.0",
@@ -28,14 +28,18 @@ import { buildDenyList, isUserAgentBlocked } from "./user-agent-deny.js";
28
28
 
29
29
  /**
30
30
  * Tools that require email format validation on their input, each mapped to
31
- * where the email sits in it: top level for the lead tools, `attendee.email`
32
- * for create_booking (#8071 D3). submit_flow_step has no fixed email field —
33
- * its `values` is a free record the hub's flow engine validates.
31
+ * the paths the email may sit at, read in order — the first string wins: top
32
+ * level for the lead tools, then `payload.email`, since the route lifts the
33
+ * address from there when the top level has none (#7171, #7172: `email` is
34
+ * optional at the top level of both bodies, so an agent could otherwise nest
35
+ * a malformed one past this check); `attendee.email` for create_booking
36
+ * (#8071 D3). submit_flow_step has no fixed email field — its `values` is a
37
+ * free record the hub's flow engine validates.
34
38
  */
35
- const EMAIL_VALIDATED_TOOLS = new Map<string, readonly string[]>([
36
- ["capture_lead", ["email"]],
37
- ["subscribe_newsletter", ["email"]],
38
- ["create_booking", ["attendee", "email"]],
39
+ const EMAIL_VALIDATED_TOOLS = new Map<string, readonly (readonly string[])[]>([
40
+ ["capture_lead", [["email"], ["payload", "email"]]],
41
+ ["subscribe_newsletter", [["email"], ["payload", "email"]]],
42
+ ["create_booking", [["attendee", "email"]]],
39
43
  ]);
40
44
 
41
45
  /**
@@ -274,12 +278,15 @@ export class AbuseGuard {
274
278
  };
275
279
  }
276
280
 
277
- // Rule 3: Email format validation, at the tool's own email path. A
281
+ // Rule 3: Email format validation, at the tool's own email paths — the
282
+ // first path holding a string is the address the route will read. A
278
283
  // missing or non-string email passes — the handler's Zod schema is the
279
284
  // authority on required fields.
280
- const emailPath = EMAIL_VALIDATED_TOOLS.get(toolName);
281
- if (emailPath !== undefined) {
282
- const email = valueAt(input, emailPath);
285
+ const emailPaths = EMAIL_VALIDATED_TOOLS.get(toolName);
286
+ if (emailPaths !== undefined) {
287
+ const email = emailPaths
288
+ .map((path) => valueAt(input, path))
289
+ .find((value) => typeof value === "string");
283
290
  if (typeof email === "string" && !isValidEmail(email)) {
284
291
  return {
285
292
  allowed: false,
@@ -201,6 +201,17 @@ function isOriginAllowedForWrite(
201
201
  return allowedOrigins.includes(origin);
202
202
  }
203
203
 
204
+ /**
205
+ * The "retry in …" wording of the write bucket's refusal, matching what
206
+ * @fastify/rate-limit's default error says for the same window — `format()`
207
+ * from `@lukeed/ms` over whole seconds, which for a one-minute window is
208
+ * "1 minute" or "N second(s)".
209
+ */
210
+ function retryIn(seconds: number): string {
211
+ if (seconds >= 60) return "1 minute";
212
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
213
+ }
214
+
204
215
  /**
205
216
  * An agent key's shape (#3591 D2): `ailk_agt_` plus 32 random bytes in
206
217
  * base64url. apps/mcp checks only this; apps/api is where the key is checked.
@@ -322,14 +333,25 @@ export async function registerHttpTransport(
322
333
  });
323
334
 
324
335
  // ── Rate limiting ─────────────────────────────────────────────────────────
325
- // Global ceiling — the lower write limit is enforced per-request via the
326
- // preHandler by checking the tool name after body parse.
336
+ // Global ceiling — readPerMin per IP, counted on every request (writes
337
+ // included) by the plugin's own onRequest hook.
327
338
  await app.register(rateLimit, {
328
339
  max: config.rateLimits.readPerMin,
329
340
  timeWindow: "1 minute",
330
341
  keyGenerator: (request: FastifyRequest) => request.ip,
331
342
  });
332
343
 
344
+ // The write bucket — writePerMin per IP, charged in the POST preHandler
345
+ // below once per write-tool call, after the body is parsed and classified
346
+ // (#9251). `createRateLimit()` gives it its own in-memory store with the
347
+ // ceiling's key generator and window; it is the plugin's seam for a second
348
+ // limiter on a request the ceiling has already counted (a hook from
349
+ // `app.rateLimit()` would skip such a request as already limited).
350
+ const writeLimit = app.createRateLimit({
351
+ max: config.rateLimits.writePerMin,
352
+ timeWindow: "1 minute",
353
+ });
354
+
333
355
  // ── MCP route ─────────────────────────────────────────────────────────────
334
356
 
335
357
  // GET — SSE stream for an existing session
@@ -362,13 +384,29 @@ export async function registerHttpTransport(
362
384
  return;
363
385
  }
364
386
 
365
- // Write-tool rate limit (stricter than read ceiling)
366
- // We re-use the same in-memory store but apply a tighter key.
367
- // @fastify/rate-limit tracks by keyGenerator; we check manually here.
368
- // Simple approach: use a second rate-limit store baked into the guard.
369
- // For now, we rely on the global ceiling for writes (configurable).
370
- // TODO: per-tool write rate limit when @fastify/rate-limit v11 lands
371
- // a per-route override API compatible with our stateless route design.
387
+ // Write-tool rate limit (#9251) — writePerMin per IP, tighter than
388
+ // the ceiling and counting write-tool calls only. Charged once per
389
+ // call, like the abuse guard below, so a batch of N spends N units.
390
+ // The refusal is the one the ceiling emits — the plugin's four
391
+ // headers and Fastify's 429 error body — so a caller sees one
392
+ // rate-limit shape whichever bucket ran out, never a tool-level
393
+ // error (see ABUSE_REFUSAL_STATUS on why that distinction matters).
394
+ for (const _call of writeCalls) {
395
+ const limit = await writeLimit(request);
396
+ if (limit.isAllowed || !limit.isExceeded) continue;
397
+ reply
398
+ .code(429)
399
+ .header("x-ratelimit-limit", limit.max)
400
+ .header("x-ratelimit-remaining", 0)
401
+ .header("x-ratelimit-reset", limit.ttlInSeconds)
402
+ .header("retry-after", limit.ttlInSeconds)
403
+ .send({
404
+ statusCode: 429,
405
+ error: "Too Many Requests",
406
+ message: `Rate limit exceeded, retry in ${retryIn(limit.ttlInSeconds)}`,
407
+ });
408
+ return;
409
+ }
372
410
 
373
411
  // Abuse heuristics — charged ONCE PER CALL, not once per request, so a
374
412
  // batch of N cannot spend one cap unit and execute N times. The whole
@@ -114,29 +114,3 @@ export function getMcpContentAdapter(
114
114
  // untouched and gate only the four write methods (D13).
115
115
  return readOnlyContentAdapter(adapter as MdxContentAdapter);
116
116
  }
117
-
118
- /**
119
- * The same resolution, narrowed to the MDX adapter's richer surface.
120
- *
121
- * `read_content` returns a page's raw MDX body, which only
122
- * `MdxContentAdapter.getPageSource()` provides — the base `ContentAdapter`
123
- * contract returns frontmatter only. Rather than cast, this asserts the
124
- * capability on the adapter the resolver actually built, so a server pointed
125
- * at a non-MDX `CONTENT_ADAPTER` gets a named error instead of a
126
- * `getPageSource is not a function` TypeError. On the default `mdx` path the
127
- * check always passes and behaviour is unchanged. Under `database`, both the
128
- * fail-closed empty adapter and the read-only-wrapped database adapter also
129
- * provide `getPageSource`, so the check passes there too (D5).
130
- */
131
- export function getMcpMdxContentAdapter(
132
- options: McpContentAdapterOptions = {},
133
- ): MdxContentAdapter {
134
- const adapter = getMcpContentAdapter(options);
135
- if (typeof (adapter as MdxContentAdapter).getPageSource !== "function") {
136
- throw new Error(
137
- "This tool needs the MDX adapter's getPageSource(): the adapter selected by " +
138
- "CONTENT_ADAPTER does not provide a raw MDX body. Use CONTENT_ADAPTER=mdx (the default).",
139
- );
140
- }
141
- return adapter as MdxContentAdapter;
142
- }
@@ -18,12 +18,13 @@
18
18
  *
19
19
  * 1. Every content tool's own not-found/empty mapping fires with NO tool
20
20
  * edit — `read_content` and `get_*` already treat `null` as not-found;
21
- * `list_pages` and `search_content` already treat `[]` as empty.
21
+ * `list_pages` and `search_content` already answer their empty
22
+ * `ItemList` over `[]` (#7167: the routes' own envelopes).
22
23
  * 2. "Before any content lookup" (NFR-5) is structural, not asserted —
23
24
  * there is nothing here to look anything up in.
24
- * 3. `getMcpMdxContentAdapter()`'s `typeof getPageSource === "function"`
25
- * capability check (`content-adapter.ts`) passes, so `read_content`
26
- * never sees THAT named throw on the fail-closed path.
25
+ * 3. `readContentDocument`'s `typeof getPageSource === "function"`
26
+ * capability check (`@working-theory/content-adapters`) passes, so
27
+ * `read_content` never sees THAT named throw on the fail-closed path.
27
28
  */
28
29
  import type {
29
30
  ContentPage,
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The deployment's `_site.mdx`, read once per content root (#7169).
3
+ *
4
+ * `read_content` answers through `readContentDocument` in
5
+ * `@working-theory/content-adapters`, which takes the parsed site config for
6
+ * the `HomePage` graph and the `origin` read. That package may not import
7
+ * `@working-theory/site-config` (`architecture.yaml`), so the read stays with
8
+ * each app: this is the same reader `apps/api/src/routes/content/get-page.ts`
9
+ * carries — the one duplication the import rule leaves, kept identical so a
10
+ * `HomePage` read through the tool is the route's to the byte. The rejected
11
+ * alternative, `@working-theory/aeo`'s `loadSitewideIdentity`, applies its own
12
+ * acceptance rules and a parent-directory fallback, which could make the two
13
+ * differ on an edge site.
14
+ *
15
+ * A local read with the client-safe `/schema` entry:
16
+ * `@working-theory/site-config/server` imports `server-only`, which throws
17
+ * outside a React Server Components runtime.
18
+ */
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ import {
23
+ SiteConfigSchema,
24
+ type SiteConfig,
25
+ } from "@working-theory/site-config/schema";
26
+ import { parse as parseYaml } from "yaml";
27
+
28
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
29
+
30
+ /** `_site.mdx` per content root, read once; `null` when it does not load. */
31
+ const siteConfigByRoot = new Map<string, SiteConfig | null>();
32
+
33
+ /**
34
+ * The deployment's `_site.mdx`, validated against `SiteConfigSchema`, or
35
+ * `undefined` on any failure (absent, unreadable, invalid).
36
+ */
37
+ export function readSiteConfig(contentRoot: string): SiteConfig | undefined {
38
+ if (!siteConfigByRoot.has(contentRoot)) {
39
+ let config: SiteConfig | null = null;
40
+ try {
41
+ const raw = readFileSync(join(contentRoot, "_site.mdx"), "utf8");
42
+ const match = FRONTMATTER_RE.exec(raw);
43
+ if (match) {
44
+ const parsed = SiteConfigSchema.safeParse(parseYaml(match[1] ?? ""));
45
+ if (parsed.success) config = parsed.data;
46
+ }
47
+ } catch {
48
+ // Absent or unreadable — no site.
49
+ }
50
+ siteConfigByRoot.set(contentRoot, config);
51
+ }
52
+ return siteConfigByRoot.get(contentRoot) ?? undefined;
53
+ }
@@ -1,9 +1,18 @@
1
1
  /**
2
- * capture_lead — capture a contact-form lead.
2
+ * capture_lead — capture a lead: the body `POST /v1/leads/submit` takes.
3
3
  *
4
- * Validates input via Zod, forwards to POST /v1/leads/submit via
5
- * @working-theory/api-client, and shapes the response into the MCP output schema.
6
- * No business logic — orchestration lives in apps/api B6.
4
+ * The tool adopted the API, arguments and results (#7171 — the operator's
5
+ * decision of 2026-10-19). Its input IS the route's `leadSubmitBodySchema`
6
+ * (the catalog registers the same shape, so the SDK has already parsed the
7
+ * arguments against it; the `safeParse` here keeps the stdio path and the
8
+ * `invalid_input` branch honest), the parsed body is posted as the agent
9
+ * sent it — no `siteId`, no stamped `source`, no wrapping — and the route's
10
+ * 201 body `{ id, persisted, email }` comes back unchanged. One addition
11
+ * survives, because it is deployment attribution rather than an argument:
12
+ * the spoke marker `payload.site` from `AILK_LEAD_SITE` (#7397), spread LAST
13
+ * over the agent's `payload` as the site's own writer
14
+ * (`apps/web/src/lib/lead-write.ts`) does; unset or blank, the payload goes
15
+ * as sent. No business logic — orchestration lives in apps/api.
7
16
  *
8
17
  * Paired with captureLeadAction Server Action per ADR 0013.
9
18
  */
@@ -11,21 +20,21 @@
11
20
  import { apiClient } from "@working-theory/api-client";
12
21
  import {
13
22
  LEAD_SITE_MARKER_KEY,
14
- submitLeadInputSchema,
15
- submitLeadOutputSchema,
16
- type SubmitLeadInput,
17
- type SubmitLeadOutput,
23
+ leadSubmitBodySchema,
24
+ leadWriteOutputSchema,
25
+ type LeadSubmitBody,
26
+ type LeadWriteOutput,
18
27
  } from "@working-theory/validation";
19
28
 
20
29
  import { visitorForwardHeadersForThisCall } from "../request-context.js";
21
30
 
22
- export { submitLeadInputSchema, submitLeadOutputSchema };
23
- export type { SubmitLeadInput, SubmitLeadOutput };
31
+ export { leadSubmitBodySchema, leadWriteOutputSchema };
32
+ export type { LeadSubmitBody, LeadWriteOutput };
24
33
 
25
34
  export async function ailkCaptureLead(
26
- input: SubmitLeadInput,
27
- ): Promise<SubmitLeadOutput> {
28
- const parsed = submitLeadInputSchema.safeParse(input);
35
+ input: LeadSubmitBody,
36
+ ): Promise<LeadWriteOutput> {
37
+ const parsed = leadSubmitBodySchema.safeParse(input);
29
38
  if (!parsed.success) {
30
39
  return {
31
40
  ok: false,
@@ -33,21 +42,14 @@ export async function ailkCaptureLead(
33
42
  };
34
43
  }
35
44
 
36
- const { name, email, message, phone } = parsed.data;
37
- const siteId = process.env.AILK_SITE_ID ?? "default";
38
- // The spoke marker (#7397) — unset or blank leaves the payload unmarked.
45
+ // The spoke marker (#7397) — unset or blank leaves the payload as sent.
39
46
  const site = process.env.AILK_LEAD_SITE?.trim();
40
- const body = {
41
- siteId,
42
- source: "mcp",
43
- email,
44
- payload: {
45
- name,
46
- ...(message !== undefined && { message }),
47
- ...(phone !== undefined && { phone }),
48
- ...(site && { [LEAD_SITE_MARKER_KEY]: site }),
49
- },
50
- };
47
+ const body = site
48
+ ? {
49
+ ...parsed.data,
50
+ payload: { ...parsed.data.payload, [LEAD_SITE_MARKER_KEY]: site },
51
+ }
52
+ : parsed.data;
51
53
 
52
54
  // #8073 — forward the address the transport trusted (never an identity
53
55
  // argument on this tool's own schema) so the hub counts the write in that
@@ -60,5 +62,5 @@ export async function ailkCaptureLead(
60
62
  return { ok: false, error: { kind: "api_error", status: res.status } };
61
63
  }
62
64
 
63
- return { ok: true, id: res.body.id, persisted: res.body.persisted };
65
+ return res.body;
64
66
  }