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,22 @@
2
2
  * Tests for the list_pages MCP tool handler.
3
3
  *
4
4
  * Acceptance criteria verified here:
5
- * - listPagesInputSchema validates correct inputs (optional type filter)
6
- * - listPagesInputSchema rejects unknown type values
7
- * - ailkListPages with a type arg returns pages for that type
8
- * - ailkListPages with no type arg returns pages across all SCHEMA_ORG_TYPES
9
- * - Output validates against listPagesOutputSchema
5
+ * - contentListParamsSchema requires `type` and rejects unknown type values
6
+ * - contentListQuerySchema takes an optional `page` / `pageSize` window
7
+ * - ailkListPages returns the route's paginated ItemList for a type
8
+ * - ailkListPages defaults the window to page 1 / pageSize 20 and clamps
9
+ * pageSize to 100, as the route does
10
+ * - Output validates against contentListResultSchema
10
11
  * - No console.* calls in handler (structural test)
12
+ *
13
+ * #7168 (the operator's decision of 2026-10-19): the tool adopted its route's
14
+ * arguments (`type` required, `page`/`pageSize`) and result (the paginated
15
+ * `ItemList`, not `{ pages }`); the all-types fan-out is gone with the
16
+ * optional `type`.
11
17
  */
12
18
 
13
- import { readFileSync } from "node:fs";
19
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
14
21
  import { resolve, join } from "node:path";
15
22
 
16
23
  import {
@@ -20,8 +27,9 @@ import {
20
27
 
21
28
  import {
22
29
  ailkListPages,
23
- listPagesInputSchema,
24
- listPagesOutputSchema,
30
+ contentListParamsSchema,
31
+ contentListQuerySchema,
32
+ contentListResultSchema,
25
33
  } from "../src/tools/list_pages.js";
26
34
  import {
27
35
  __setMcpPublicWorkspaceForTest,
@@ -93,80 +101,111 @@ function makeArtifactPrismaClientStub(): ArtifactPrismaClient {
93
101
  };
94
102
  }
95
103
 
96
- describe("listPagesInputSchema", () => {
97
- it("accepts empty input (type is optional)", () => {
98
- const result = listPagesInputSchema.safeParse({});
99
- expect(result.success).toBe(true);
104
+ describe("contentListParamsSchema", () => {
105
+ it("requires type (#7168 — the route's required param)", () => {
106
+ const result = contentListParamsSchema.safeParse({});
107
+ expect(result.success).toBe(false);
100
108
  });
101
109
 
102
110
  it("accepts a valid SchemaOrgType", () => {
103
- const result = listPagesInputSchema.safeParse({ type: "HomePage" });
111
+ const result = contentListParamsSchema.safeParse({ type: "HomePage" });
104
112
  expect(result.success).toBe(true);
105
113
  });
106
114
 
107
115
  it("accepts HomePage", () => {
108
- const result = listPagesInputSchema.safeParse({ type: "HomePage" });
116
+ const result = contentListParamsSchema.safeParse({ type: "HomePage" });
109
117
  expect(result.success).toBe(true);
110
118
  });
111
119
 
112
120
  it("rejects an unknown type string", () => {
113
- const result = listPagesInputSchema.safeParse({ type: "NotAType" });
121
+ const result = contentListParamsSchema.safeParse({ type: "NotAType" });
114
122
  expect(result.success).toBe(false);
115
123
  });
116
124
 
117
125
  it("rejects a numeric type", () => {
118
- const result = listPagesInputSchema.safeParse({ type: 42 });
126
+ const result = contentListParamsSchema.safeParse({ type: 42 });
119
127
  expect(result.success).toBe(false);
120
128
  });
121
129
  });
122
130
 
131
+ describe("contentListQuerySchema", () => {
132
+ it("accepts an empty window (page and pageSize are optional)", () => {
133
+ const result = contentListQuerySchema.safeParse({});
134
+ expect(result.success).toBe(true);
135
+ });
136
+
137
+ it("coerces a string page and pageSize, as the route's query string parses", () => {
138
+ const result = contentListQuerySchema.safeParse({ page: "2", pageSize: "5" });
139
+ expect(result.success).toBe(true);
140
+ expect(result.data).toEqual({ page: 2, pageSize: 5 });
141
+ });
142
+
143
+ it("rejects page 0 and pageSize 0", () => {
144
+ expect(contentListQuerySchema.safeParse({ page: 0 }).success).toBe(false);
145
+ expect(contentListQuerySchema.safeParse({ pageSize: 0 }).success).toBe(false);
146
+ });
147
+
148
+ it("admits pageSize 500 — the clamp to 100 is the builder's, not a 400 (AC-13)", () => {
149
+ expect(contentListQuerySchema.safeParse({ pageSize: 500 }).success).toBe(true);
150
+ });
151
+ });
152
+
123
153
  describe("ailkListPages handler", () => {
124
- it("returns pages array for a specific type", async () => {
154
+ it("returns the route's paginated ItemList for a specific type", async () => {
125
155
  const result = await ailkListPages({ type: "HomePage" }, CONTENT_ROOT);
126
156
 
127
- expect(Array.isArray(result.pages)).toBe(true);
157
+ expect(result["@context"]).toBe("https://schema.org");
158
+ expect(result["@type"]).toBe("ItemList");
159
+ expect(result.type).toBe("HomePage");
160
+ expect(Array.isArray(result.items)).toBe(true);
128
161
  // The real content/ tree has at least one HomePage
129
- expect(result.pages.length).toBeGreaterThan(0);
130
- for (const page of result.pages) {
162
+ expect(result.items.length).toBeGreaterThan(0);
163
+ for (const page of result.items) {
131
164
  expect(page.type).toBe("HomePage");
132
165
  expect(typeof page.slug).toBe("string");
133
166
  expect(typeof page.title).toBe("string");
134
167
  }
135
168
  });
136
169
 
137
- it("returns empty pages array for a type with no content", async () => {
138
- // FAQPage has no files in content/ by default
139
- const result = await ailkListPages({ type: "FAQPage" }, CONTENT_ROOT);
140
- expect(Array.isArray(result.pages)).toBe(true);
170
+ it("defaults the window to page 1 / pageSize 20, as the route does", async () => {
171
+ const result = await ailkListPages({ type: "HomePage" }, CONTENT_ROOT);
172
+ expect(result.pagination).toEqual({
173
+ page: 1,
174
+ pageSize: 20,
175
+ totalItems: result.items.length,
176
+ totalPages: 1,
177
+ });
141
178
  });
142
179
 
143
- it("returns pages across all SCHEMA_ORG_TYPES when type is omitted", async () => {
144
- const result = await ailkListPages({}, CONTENT_ROOT);
145
- expect(Array.isArray(result.pages)).toBe(true);
146
- // Should include at least one page (we know HomePage has content)
147
- expect(result.pages.length).toBeGreaterThan(0);
180
+ it("returns an empty ItemList for a type with no content", async () => {
181
+ // An empty content root of its own (#9422): a scaffold's content/ seeds
182
+ // TeamMember placeholders (#8850), so the repo's tree cannot stand in.
183
+ const emptyRoot = mkdtempSync(join(tmpdir(), "list-pages-empty-"));
184
+ const result = await ailkListPages({ type: "TeamMember" }, emptyRoot);
185
+ rmSync(emptyRoot, { recursive: true, force: true });
186
+ expect(result.items).toEqual([]);
187
+ expect(result.pagination.totalItems).toBe(0);
188
+ expect(result.pagination.totalPages).toBe(1);
148
189
  });
149
190
 
150
- it("output shape has required slug + type + title fields", async () => {
151
- const result = await ailkListPages({ type: "HomePage" }, CONTENT_ROOT);
152
-
153
- for (const page of result.pages) {
154
- expect(typeof page.slug).toBe("string");
155
- expect(typeof page.type).toBe("string");
156
- expect(typeof page.title).toBe("string");
157
- }
191
+ it("clamps pageSize 500 to 100 in pagination.pageSize (AC-13)", async () => {
192
+ const result = await ailkListPages(
193
+ { type: "HomePage", pageSize: 500 },
194
+ CONTENT_ROOT,
195
+ );
196
+ expect(result.pagination.pageSize).toBe(100);
158
197
  });
159
198
 
160
- it("output validates against listPagesOutputSchema", async () => {
199
+ it("output validates against contentListResultSchema", async () => {
161
200
  const result = await ailkListPages({ type: "HomePage" }, CONTENT_ROOT);
162
- const parsed = listPagesOutputSchema.safeParse(result);
201
+ const parsed = contentListResultSchema.safeParse(result);
163
202
  expect(parsed.success).toBe(true);
164
203
  });
165
204
  });
166
205
 
167
- describe("listPagesInputSchema — Zod boundary security", () => {
206
+ describe("contentListParamsSchema — Zod boundary security", () => {
168
207
  it("rejects input with path traversal characters in type", () => {
169
- const result = listPagesInputSchema.safeParse({ type: "../etc/passwd" });
208
+ const result = contentListParamsSchema.safeParse({ type: "../etc/passwd" });
170
209
  expect(result.success).toBe(false);
171
210
  });
172
211
  });
@@ -196,7 +235,7 @@ describe("ailkListPages — CONTENT_ADAPTER=database (#5876)", () => {
196
235
 
197
236
  const result = await ailkListPages({ type: "HomePage" });
198
237
 
199
- expect(result.pages).toEqual([
238
+ expect(result.items).toEqual([
200
239
  expect.objectContaining({ slug: "home", type: "HomePage" }),
201
240
  ]);
202
241
  expect(stub.artifact.findMany).toHaveBeenCalledWith(
@@ -206,13 +245,14 @@ describe("ailkListPages — CONTENT_ADAPTER=database (#5876)", () => {
206
245
  );
207
246
  });
208
247
 
209
- it("AC-2: returns { pages: [] } when the workspace is unresolved, with no query", async () => {
248
+ it("AC-2: returns the empty ItemList when the workspace is unresolved, with no query", async () => {
210
249
  const stub = makeArtifactPrismaClientStub();
211
250
  __setMcpPublicWorkspaceForTest({ kind: "unresolved", reason: "ambiguous" });
212
251
 
213
252
  const result = await ailkListPages({ type: "HomePage" });
214
253
 
215
- expect(result.pages).toEqual([]);
254
+ expect(result.items).toEqual([]);
255
+ expect(result.pagination.totalItems).toBe(0);
216
256
  expect(stub.artifact.findMany).not.toHaveBeenCalled();
217
257
  });
218
258
  });
@@ -5,7 +5,11 @@
5
5
  * - read_content accepts an optional `locale` parameter
6
6
  * - list_pages accepts an optional `locale` parameter
7
7
  * - search_content accepts an optional `locale` parameter
8
- * - Tool schemas accept BCP-47 tags and reject malformed values
8
+ * - Tool schemas accept any string `locale`, as the routes' do — the
9
+ * allowlist is the handler's `resolveLocale`, never the schema (#7167 DD1,
10
+ * the operator's decision of 2026-10-19: the tool adopted the API's
11
+ * arguments, so a malformed tag falls back silently instead of being
12
+ * refused)
9
13
  * - Tool handlers return data when valid locale is passed (back-compat)
10
14
  * - Invalid/unknown locales silently fall back to default (AC-i18n-07)
11
15
  */
@@ -13,9 +17,11 @@
13
17
  import { resolve, join } from "node:path";
14
18
 
15
19
  import {
16
- listPagesInputSchema,
17
- readContentInputSchema,
18
- searchContentInputSchema,
20
+ contentListParamsSchema,
21
+ contentListQuerySchema,
22
+ contentPageParamsSchema,
23
+ contentPageQuerySchema,
24
+ contentSearchQuerySchema,
19
25
  } from "@working-theory/validation";
20
26
 
21
27
  import { ailkListPages } from "../src/tools/list_pages.js";
@@ -26,61 +32,42 @@ const REPO_ROOT = resolve(__dirname, "../../..");
26
32
  const CONTENT_ROOT = join(REPO_ROOT, "content");
27
33
 
28
34
  describe("locale parameter — input schemas", () => {
29
- it("readContentInputSchema accepts a valid BCP-47 tag", () => {
30
- const r = readContentInputSchema.safeParse({
31
- type: "HomePage",
32
- slug: "home",
33
- locale: "en",
34
- });
35
- expect(r.success).toBe(true);
35
+ it("contentPageParamsSchema + contentPageQuerySchema accept a valid BCP-47 tag", () => {
36
+ expect(
37
+ contentPageParamsSchema.safeParse({ type: "HomePage", slug: "home" }).success,
38
+ ).toBe(true);
39
+ expect(contentPageQuerySchema.safeParse({ locale: "en" }).success).toBe(true);
36
40
  });
37
41
 
38
- it("readContentInputSchema accepts region-tagged BCP-47 (zh-CN)", () => {
39
- const r = readContentInputSchema.safeParse({
40
- type: "HomePage",
41
- slug: "home",
42
- locale: "zh-CN",
43
- });
44
- expect(r.success).toBe(true);
42
+ it("contentPageQuerySchema accepts region-tagged BCP-47 (zh-CN)", () => {
43
+ expect(contentPageQuerySchema.safeParse({ locale: "zh-CN" }).success).toBe(true);
45
44
  });
46
45
 
47
- it("readContentInputSchema rejects malformed locale (uppercase)", () => {
48
- const r = readContentInputSchema.safeParse({
49
- type: "HomePage",
50
- slug: "home",
51
- locale: "EN",
52
- });
53
- expect(r.success).toBe(false);
46
+ // #7167 DD1 — the route's `locale` is a bare optional string, and the tool
47
+ // parses the same export: a malformed tag is admitted here and falls back
48
+ // silently in the handler (`resolveLocale`), as it does on the route.
49
+ it("contentPageQuerySchema admits a malformed locale (uppercase) — the handler falls back", () => {
50
+ expect(contentPageQuerySchema.safeParse({ locale: "EN" }).success).toBe(true);
54
51
  });
55
52
 
56
- it("readContentInputSchema rejects path-traversal locale", () => {
57
- const r = readContentInputSchema.safeParse({
58
- type: "HomePage",
59
- slug: "home",
60
- locale: "../../etc/passwd",
61
- });
62
- expect(r.success).toBe(false);
53
+ it("contentPageQuerySchema admits a path-traversal-shaped locale — the handler falls back, never the filesystem", () => {
54
+ expect(
55
+ contentPageQuerySchema.safeParse({ locale: "../../etc/passwd" }).success,
56
+ ).toBe(true);
63
57
  });
64
58
 
65
- it("readContentInputSchema accepts absent locale (optional)", () => {
66
- const r = readContentInputSchema.safeParse({
67
- type: "HomePage",
68
- slug: "home",
69
- });
70
- expect(r.success).toBe(true);
59
+ it("contentPageQuerySchema accepts absent locale (optional)", () => {
60
+ expect(contentPageQuerySchema.safeParse({}).success).toBe(true);
71
61
  });
72
62
 
73
- it("listPagesInputSchema accepts a valid locale", () => {
74
- const r = listPagesInputSchema.safeParse({
75
- type: "HomePage",
76
- locale: "en",
77
- });
78
- expect(r.success).toBe(true);
63
+ it("contentListParamsSchema + contentListQuerySchema accept a valid locale", () => {
64
+ expect(contentListParamsSchema.safeParse({ type: "HomePage" }).success).toBe(true);
65
+ expect(contentListQuerySchema.safeParse({ locale: "en" }).success).toBe(true);
79
66
  });
80
67
 
81
- it("searchContentInputSchema accepts a valid locale", () => {
82
- const r = searchContentInputSchema.safeParse({
83
- query: "foo",
68
+ it("contentSearchQuerySchema accepts a valid locale", () => {
69
+ const r = contentSearchQuerySchema.safeParse({
70
+ q: "foo",
84
71
  locale: "en",
85
72
  });
86
73
  expect(r.success).toBe(true);
@@ -93,8 +80,7 @@ describe("locale parameter — handler behavior (real adapter)", () => {
93
80
  { type: "HomePage", slug: "home", locale: "en" },
94
81
  CONTENT_ROOT,
95
82
  );
96
- expect(result).not.toBeNull();
97
- expect(result!.slug).toBe("home");
83
+ expect(result).toHaveProperty("page", expect.objectContaining({ slug: "home" }));
98
84
  });
99
85
 
100
86
  it("ailkReadContent silently falls back when locale=zz (unknown)", async () => {
@@ -103,8 +89,7 @@ describe("locale parameter — handler behavior (real adapter)", () => {
103
89
  CONTENT_ROOT,
104
90
  );
105
91
  // AC-i18n-07: silent fallback to default locale's content.
106
- expect(result).not.toBeNull();
107
- expect(result!.slug).toBe("home");
92
+ expect(result).toHaveProperty("page", expect.objectContaining({ slug: "home" }));
108
93
  });
109
94
 
110
95
  it("ailkListPages returns pages when locale=en", async () => {
@@ -112,8 +97,8 @@ describe("locale parameter — handler behavior (real adapter)", () => {
112
97
  { type: "HomePage", locale: "en" },
113
98
  CONTENT_ROOT,
114
99
  );
115
- expect(Array.isArray(result.pages)).toBe(true);
116
- expect(result.pages.length).toBeGreaterThan(0);
100
+ expect(Array.isArray(result.items)).toBe(true);
101
+ expect(result.items.length).toBeGreaterThan(0);
117
102
  });
118
103
 
119
104
  it("ailkListPages silently falls back when locale=zz", async () => {
@@ -122,14 +107,14 @@ describe("locale parameter — handler behavior (real adapter)", () => {
122
107
  CONTENT_ROOT,
123
108
  );
124
109
  // Falls back to default-locale list.
125
- expect(Array.isArray(result.pages)).toBe(true);
110
+ expect(Array.isArray(result.items)).toBe(true);
126
111
  });
127
112
 
128
113
  it("ailkSearchContent returns results when locale=en", async () => {
129
114
  const result = await ailkSearchContent(
130
- { query: "AI", locale: "en" },
115
+ { q: "AI", locale: "en" },
131
116
  CONTENT_ROOT,
132
117
  );
133
- expect(Array.isArray(result.results)).toBe(true);
118
+ expect(Array.isArray(result.items)).toBe(true);
134
119
  });
135
120
  });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The write-tool per-minute rate limit (#9251).
3
+ *
4
+ * `MCP_RATE_LIMIT_WRITE_PER_MIN` (`config.rateLimits.writePerMin`) used to be
5
+ * parsed and ignored: one app-wide @fastify/rate-limit bucket at `readPerMin`
6
+ * counted every request, and the TODO in the write preHandler deferred the
7
+ * tighter write bucket. These tests pin what the transport now enforces:
8
+ *
9
+ * - with `writePerMin: 2`, the third anonymous write-tool call from one IP
10
+ * inside a minute is refused with the limiter's 429 shape — the same body
11
+ * and headers the read ceiling emits — before the tool (and so the hub) is
12
+ * reached, while reads from that IP keep answering 200;
13
+ * - the write bucket counts ONLY write-tool calls: reads spend none of it;
14
+ * - a JSON-RPC batch of N write calls spends N units, not one.
15
+ *
16
+ * Same harness as http-transport.test.ts: a real Fastify server on an
17
+ * ephemeral port, stateless mode, and the hub stubbed through
18
+ * `apiClient.leads.submit` so an allowed `capture_lead` succeeds.
19
+ */
20
+
21
+ import type { AddressInfo } from "node:net";
22
+
23
+ import type { FastifyInstance } from "fastify";
24
+ import { apiClient } from "@working-theory/api-client";
25
+
26
+ import { buildHttpTransportApp } from "../src/http-transport.js";
27
+
28
+ type RateLimitErrorBody = {
29
+ statusCode?: number;
30
+ error?: string;
31
+ message?: string;
32
+ };
33
+
34
+ const WRITE_LIMITED_CONFIG = {
35
+ path: "/mcp",
36
+ stateless: true,
37
+ rateLimits: { readPerMin: 60, writePerMin: 2 },
38
+ cors: { writeAllowedOrigins: [] as string[] },
39
+ abuse: {
40
+ captureLeadDailyCapPerIp: 50,
41
+ blockedUserAgents: "",
42
+ },
43
+ } as const;
44
+
45
+ const TOOLS_LIST_PAYLOAD = {
46
+ jsonrpc: "2.0",
47
+ id: 1,
48
+ method: "tools/list",
49
+ params: {},
50
+ };
51
+
52
+ function captureLead(id: number) {
53
+ return {
54
+ jsonrpc: "2.0",
55
+ id,
56
+ method: "tools/call",
57
+ params: {
58
+ name: "capture_lead",
59
+ arguments: {
60
+ source: "mcp",
61
+ email: `jane${id}@example.com`,
62
+ payload: { name: "Jane" },
63
+ },
64
+ },
65
+ };
66
+ }
67
+
68
+ async function mcpPost(port: number, payload: object): Promise<Response> {
69
+ return fetch(`http://127.0.0.1:${port}/mcp`, {
70
+ method: "POST",
71
+ body: JSON.stringify(payload),
72
+ headers: {
73
+ "content-type": "application/json",
74
+ accept: "application/json, text/event-stream",
75
+ },
76
+ });
77
+ }
78
+
79
+ async function listen(
80
+ config: Parameters<typeof buildHttpTransportApp>[0],
81
+ ): Promise<{ app: FastifyInstance; port: number }> {
82
+ const app = await buildHttpTransportApp(config);
83
+ await app.listen({ host: "127.0.0.1", port: 0 });
84
+ return { app, port: (app.server.address() as AddressInfo).port };
85
+ }
86
+
87
+ describe("MCP_RATE_LIMIT_WRITE_PER_MIN is enforced on the write tools (#9251)", () => {
88
+ let app: FastifyInstance;
89
+ let port: number;
90
+ let submitSpy: jest.SpiedFunction<typeof apiClient.leads.submit>;
91
+
92
+ beforeEach(async () => {
93
+ submitSpy = jest.spyOn(apiClient.leads, "submit").mockResolvedValue({
94
+ ok: true,
95
+ body: {
96
+ id: "lead_1",
97
+ persisted: "db",
98
+ email: { ok: true, messageId: "msg_1" },
99
+ },
100
+ });
101
+ ({ app, port } = await listen(WRITE_LIMITED_CONFIG));
102
+ });
103
+
104
+ afterEach(async () => {
105
+ await app.close();
106
+ submitSpy.mockRestore();
107
+ });
108
+
109
+ it("refuses the third write call from one IP within a minute with the limiter's 429, while reads continue", async () => {
110
+ expect((await mcpPost(port, captureLead(1))).status).toBe(200);
111
+ expect((await mcpPost(port, captureLead(2))).status).toBe(200);
112
+
113
+ const refused = await mcpPost(port, captureLead(3));
114
+
115
+ expect(refused.status).toBe(429);
116
+ const body = (await refused.json()) as RateLimitErrorBody;
117
+ expect(body).toEqual({
118
+ statusCode: 429,
119
+ error: "Too Many Requests",
120
+ message: expect.stringMatching(/^Rate limit exceeded, retry in /),
121
+ });
122
+ expect(refused.headers.get("x-ratelimit-limit")).toBe("2");
123
+ expect(refused.headers.get("x-ratelimit-remaining")).toBe("0");
124
+ expect(refused.headers.get("x-ratelimit-reset")).toMatch(/^\d+$/);
125
+ expect(refused.headers.get("retry-after")).toMatch(/^\d+$/);
126
+ // Refused in the preHandler: the tool never ran, so the hub saw two calls.
127
+ expect(submitSpy).toHaveBeenCalledTimes(2);
128
+
129
+ const read = await mcpPost(port, TOOLS_LIST_PAYLOAD);
130
+ expect(read.status).toBe(200);
131
+ });
132
+
133
+ it("counts only write-tool calls — reads spend none of the write bucket", async () => {
134
+ for (let i = 0; i < 10; i += 1) {
135
+ expect((await mcpPost(port, TOOLS_LIST_PAYLOAD)).status).toBe(200);
136
+ }
137
+
138
+ expect((await mcpPost(port, captureLead(11))).status).toBe(200);
139
+ expect((await mcpPost(port, captureLead(12))).status).toBe(200);
140
+ expect((await mcpPost(port, captureLead(13))).status).toBe(429);
141
+ });
142
+
143
+ it("a batch of N write calls spends N units, not one", async () => {
144
+ const res = await mcpPost(port, [
145
+ captureLead(21),
146
+ captureLead(22),
147
+ captureLead(23),
148
+ ]);
149
+
150
+ expect(res.status).toBe(429);
151
+ expect(submitSpy).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it("emits the same 429 shape as the read ceiling", async () => {
155
+ const readLimited = await listen({
156
+ ...WRITE_LIMITED_CONFIG,
157
+ rateLimits: { readPerMin: 1, writePerMin: 2 },
158
+ });
159
+ try {
160
+ await mcpPost(readLimited.port, TOOLS_LIST_PAYLOAD);
161
+ const readRefusal = await mcpPost(readLimited.port, TOOLS_LIST_PAYLOAD);
162
+ expect(readRefusal.status).toBe(429);
163
+
164
+ await mcpPost(port, captureLead(31));
165
+ await mcpPost(port, captureLead(32));
166
+ const writeRefusal = await mcpPost(port, captureLead(33));
167
+ expect(writeRefusal.status).toBe(429);
168
+
169
+ const readBody = (await readRefusal.json()) as RateLimitErrorBody;
170
+ const writeBody = (await writeRefusal.json()) as RateLimitErrorBody;
171
+ expect(Object.keys(writeBody).sort()).toEqual(
172
+ Object.keys(readBody).sort(),
173
+ );
174
+ expect(writeBody.error).toBe(readBody.error);
175
+ expect(writeBody.message).toBe(readBody.message);
176
+ for (const header of [
177
+ "x-ratelimit-limit",
178
+ "x-ratelimit-remaining",
179
+ "x-ratelimit-reset",
180
+ "retry-after",
181
+ ]) {
182
+ expect(readRefusal.headers.has(header)).toBe(true);
183
+ expect(writeRefusal.headers.has(header)).toBe(true);
184
+ }
185
+ } finally {
186
+ await readLimited.app.close();
187
+ }
188
+ });
189
+ });