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.
- package/README.md +27 -14
- package/dist/add-module.js +6 -1
- package/dist/add-route.js +7 -1
- package/dist/derive-architecture.js +17 -0
- package/dist/lib/scaffold-render-sweep.d.ts +33 -0
- package/dist/lib/scaffold-render-sweep.js +45 -0
- package/dist/module-architecture.js +17 -0
- package/dist/sync-routes.d.ts +30 -4
- package/dist/sync-routes.js +271 -18
- package/package.json +3 -3
- package/templates/.env.example +4 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +25 -11
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +333 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/leads/index.ts +364 -298
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
- package/templates/apps/api/src/server.ts +25 -0
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
- package/templates/apps/api/src/services/offerings/connection.ts +83 -3
- package/templates/apps/api/src/services/offerings/index.ts +6 -6
- package/templates/apps/api/src/services/person.ts +23 -0
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/mcp/CLAUDE.md +11 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/web/CLAUDE.md +12 -3
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
- package/templates/apps/web/lib/magnet-routes.ts +3 -1
- package/templates/apps/web/lib/newsletter.actions.ts +9 -6
- package/templates/database/CHANGELOG.md +20 -0
- package/templates/database/__tests__/seed.test.ts +33 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +27 -0
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +34 -34
- 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
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* - ailkListPages
|
|
8
|
-
* - ailkListPages
|
|
9
|
-
*
|
|
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
|
-
|
|
24
|
-
|
|
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("
|
|
97
|
-
it("
|
|
98
|
-
const result =
|
|
99
|
-
expect(result.success).toBe(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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.
|
|
130
|
-
for (const page of result.
|
|
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("
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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("
|
|
151
|
-
const result = await ailkListPages(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
199
|
+
it("output validates against contentListResultSchema", async () => {
|
|
161
200
|
const result = await ailkListPages({ type: "HomePage" }, CONTENT_ROOT);
|
|
162
|
-
const parsed =
|
|
201
|
+
const parsed = contentListResultSchema.safeParse(result);
|
|
163
202
|
expect(parsed.success).toBe(true);
|
|
164
203
|
});
|
|
165
204
|
});
|
|
166
205
|
|
|
167
|
-
describe("
|
|
206
|
+
describe("contentListParamsSchema — Zod boundary security", () => {
|
|
168
207
|
it("rejects input with path traversal characters in type", () => {
|
|
169
|
-
const result =
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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("
|
|
30
|
-
|
|
31
|
-
type: "HomePage",
|
|
32
|
-
|
|
33
|
-
|
|
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("
|
|
39
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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("
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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("
|
|
66
|
-
|
|
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("
|
|
74
|
-
|
|
75
|
-
|
|
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("
|
|
82
|
-
const r =
|
|
83
|
-
|
|
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).
|
|
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).
|
|
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.
|
|
116
|
-
expect(result.
|
|
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.
|
|
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
|
-
{
|
|
115
|
+
{ q: "AI", locale: "en" },
|
|
131
116
|
CONTENT_ROOT,
|
|
132
117
|
);
|
|
133
|
-
expect(Array.isArray(result.
|
|
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
|
+
});
|