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
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the subscribe_newsletter MCP tool handler.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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 →
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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("
|
|
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(
|
|
130
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
84
|
+
await ailkSubscribeNewsletter({
|
|
85
|
+
...VALID_INPUT,
|
|
86
|
+
siteId: "site-xyz",
|
|
87
|
+
firstName: "Jane",
|
|
88
|
+
} as never);
|
|
148
89
|
|
|
149
|
-
|
|
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 —
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
*
|
|
32
|
-
* for
|
|
33
|
-
*
|
|
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
|
|
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
|
|
281
|
-
if (
|
|
282
|
-
const email =
|
|
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 —
|
|
326
|
-
//
|
|
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 (
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
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
|
|
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. `
|
|
25
|
-
* capability check (
|
|
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
|
|
2
|
+
* capture_lead — capture a lead: the body `POST /v1/leads/submit` takes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
type
|
|
17
|
-
type
|
|
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 {
|
|
23
|
-
export type {
|
|
31
|
+
export { leadSubmitBodySchema, leadWriteOutputSchema };
|
|
32
|
+
export type { LeadSubmitBody, LeadWriteOutput };
|
|
24
33
|
|
|
25
34
|
export async function ailkCaptureLead(
|
|
26
|
-
input:
|
|
27
|
-
): Promise<
|
|
28
|
-
const parsed =
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
65
|
+
return res.body;
|
|
64
66
|
}
|