@acid-cat/cli 0.9.0 → 0.10.1
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 +21 -8
- package/conformance/fixture.acidcat.json +167 -1
- package/dist/catalog.js +45 -15
- package/dist/conform/check.d.ts +13 -1
- package/dist/conform/check.js +73 -19
- package/dist/conform/cli.js +132 -10
- package/dist/conform/http.js +208 -4
- package/dist/conform/index.d.ts +20 -0
- package/dist/conform/index.js +11 -5
- package/dist/conform/mcp-stdio.d.ts +2 -0
- package/dist/conform/mcp-stdio.js +69 -4
- package/dist/conform/mcp.d.ts +64 -8
- package/dist/conform/mcp.js +469 -9
- package/dist/conform/poll.d.ts +13 -0
- package/dist/conform/poll.js +288 -0
- package/dist/conform/properties.js +2 -3
- package/dist/conform/serve.js +5 -3
- package/dist/conform/sse.d.ts +15 -0
- package/dist/conform/sse.js +56 -0
- package/dist/conform/webhook.d.ts +28 -0
- package/dist/conform/webhook.js +474 -0
- package/dist/conform/webmcp.js +7 -3
- package/dist/index.d.ts +1 -1
- package/dist/json/catalog.schema.json.js +27 -4
- package/dist/json/fixture.acidcat.json.js +118 -3
- package/dist/json/package.json.js +2 -3
- package/dist/json.js +1 -2
- package/dist/main.js +53 -7
- package/package.json +9 -9
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { isObject, memberOf, parseJson } from "../json.js";
|
|
2
|
+
import { expect, expectEqual, expectOccurrence, until } from "./check.js";
|
|
3
|
+
import { count, eventRequests, httpChannel } from "./mcp.js";
|
|
4
|
+
import { MCP_WEBHOOK_DEFAULTS, isOccurrence, webhookSignature, webhookSubscriptionId } from "@acid-cat/spec-ts";
|
|
5
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
|
+
import { createServer } from "node:https";
|
|
7
|
+
// src/conform/webhook.ts
|
|
8
|
+
/**
|
|
9
|
+
* Conformance checks for webhook delivery of the MCP events extension (spec/bindings/mcp.md#webhook), through HTTP.
|
|
10
|
+
* conform runs an HTTPS receiver on 127.0.0.1 with the certificate that it gets. The deployment under test must trust
|
|
11
|
+
* that certificate, allow deliveries to 127.0.0.1, and give the principal of `WebhookSettings` to each request with the
|
|
12
|
+
* header `Authorization: Bearer <principal>`.
|
|
13
|
+
*
|
|
14
|
+
* Each check uses its own path on the receiver, so its URL is new: the server verifies it again, and the subscriptions
|
|
15
|
+
* of one check, or of an earlier run of conform, never have the key of another check.
|
|
16
|
+
*/
|
|
17
|
+
const RULE = "spec/bindings/mcp.md#webhook";
|
|
18
|
+
/** True if the body of a request is the control message `verification`. */
|
|
19
|
+
const isVerification = (request) => memberOf(request.body, "type") === "verification";
|
|
20
|
+
/**
|
|
21
|
+
* Start the receiver. By default, it answers a verification with its challenge, and each other request with 200. A
|
|
22
|
+
* check can choose other answers for its path.
|
|
23
|
+
*/
|
|
24
|
+
async function startReceiver(settings) {
|
|
25
|
+
const received = /* @__PURE__ */ new Map();
|
|
26
|
+
const answerers = /* @__PURE__ */ new Map();
|
|
27
|
+
const server = createServer({
|
|
28
|
+
cert: settings.cert,
|
|
29
|
+
key: settings.key
|
|
30
|
+
}, (request, response) => {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
33
|
+
request.on("end", () => {
|
|
34
|
+
const path = request.url ?? "/";
|
|
35
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
36
|
+
let body;
|
|
37
|
+
try {
|
|
38
|
+
body = parseJson(text);
|
|
39
|
+
} catch {
|
|
40
|
+
body = void 0;
|
|
41
|
+
}
|
|
42
|
+
const each = {
|
|
43
|
+
headers: request.headers,
|
|
44
|
+
text,
|
|
45
|
+
body
|
|
46
|
+
};
|
|
47
|
+
const list = received.get(path) ?? [];
|
|
48
|
+
list.push(each);
|
|
49
|
+
received.set(path, list);
|
|
50
|
+
const index = list.filter((one) => !isVerification(one)).length - 1;
|
|
51
|
+
const verification = {
|
|
52
|
+
status: 200,
|
|
53
|
+
body: JSON.stringify({ challenge: memberOf(body, "challenge") })
|
|
54
|
+
};
|
|
55
|
+
const chosen = answerers.get(path)?.(each, index) ?? (isVerification(each) ? verification : { status: 200 });
|
|
56
|
+
response.writeHead(chosen.status, { "Content-Type": "application/json" }).end(chosen.body ?? "");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
60
|
+
const address = server.address();
|
|
61
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
62
|
+
return {
|
|
63
|
+
/** A new URL on the receiver, for one check. */
|
|
64
|
+
url: (answer) => {
|
|
65
|
+
const path = `/hook/${randomUUID()}`;
|
|
66
|
+
if (answer !== void 0) answerers.set(path, answer);
|
|
67
|
+
return {
|
|
68
|
+
url: `https://127.0.0.1:${port}${path}`,
|
|
69
|
+
received: () => received.get(path) ?? []
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
close: () => new Promise((resolve) => {
|
|
73
|
+
server.closeAllConnections();
|
|
74
|
+
server.close(() => resolve());
|
|
75
|
+
})
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** A new Standard Webhooks secret. */
|
|
79
|
+
const newSecret = () => `whsec_${randomBytes(32).toString("base64")}`;
|
|
80
|
+
/** One header of a request, as text. */
|
|
81
|
+
function header(request, name) {
|
|
82
|
+
const value = request.headers[name];
|
|
83
|
+
expect(typeof value === "string", `the request must have the header ${name}, got ${JSON.stringify(request.headers)}`);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Check the headers of a request of the server: the media type, the id, the timestamp, the subscription id, and a
|
|
88
|
+
* signature with each secret. `type` is the type of a control message; with none, the request is an occurrence.
|
|
89
|
+
*/
|
|
90
|
+
async function expectSigned(request, subscription, secrets, type) {
|
|
91
|
+
expect(header(request, "content-type").startsWith("application/json"), `Content-Type must be application/json, got ${header(request, "content-type")}`);
|
|
92
|
+
const id = header(request, "webhook-id");
|
|
93
|
+
const pattern = type === void 0 ? /^msg_[A-Za-z0-9]+$/ : new RegExp(`^msg_${type}_[A-Za-z0-9]+$`);
|
|
94
|
+
expect(pattern.test(id), `webhook-id must match ${pattern}, got ${JSON.stringify(id)}`);
|
|
95
|
+
const timestampText = header(request, "webhook-timestamp");
|
|
96
|
+
const timestamp = Number(timestampText);
|
|
97
|
+
expect(/^\d+$/.test(timestampText) && Math.abs(timestamp - Date.now() / 1e3) <= 300, `webhook-timestamp must be the time in seconds, got ${JSON.stringify(timestampText)}`);
|
|
98
|
+
expectEqual(header(request, "x-mcp-subscription-id"), subscription, "X-MCP-Subscription-Id");
|
|
99
|
+
const signatures = header(request, "webhook-signature").split(" ");
|
|
100
|
+
for (const secret of secrets) {
|
|
101
|
+
const expected = await webhookSignature([secret], id, timestamp, request.text);
|
|
102
|
+
expect(signatures.includes(expected), `webhook-signature must have the signature ${expected} of a secret, got ${JSON.stringify(signatures)}`);
|
|
103
|
+
}
|
|
104
|
+
return id;
|
|
105
|
+
}
|
|
106
|
+
/** The body of a delivery of an occurrence: the occurrence without `truncated`. */
|
|
107
|
+
function expectDelivered(request, name) {
|
|
108
|
+
expect(isObject(request.body) && !("truncated" in request.body), `the body must be an occurrence without truncated, got ${request.text}`);
|
|
109
|
+
return expectOccurrence(request.body, name);
|
|
110
|
+
}
|
|
111
|
+
/** The checks of webhook delivery through the MCP server at `url`. */
|
|
112
|
+
async function checkWebhook(checks, url, settings) {
|
|
113
|
+
let receiver;
|
|
114
|
+
try {
|
|
115
|
+
receiver = await startReceiver(settings);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
await checks.run("conform starts an HTTPS receiver with the certificate and the key", RULE, () => {
|
|
118
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await checkWithReceiver(checks, url, settings, receiver);
|
|
124
|
+
} finally {
|
|
125
|
+
await receiver.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function checkWithReceiver(checks, url, settings, receiver) {
|
|
129
|
+
const principal = settings.principal;
|
|
130
|
+
const channel = httpChannel(url, { Authorization: `Bearer ${principal}` });
|
|
131
|
+
const anonymous = httpChannel(url);
|
|
132
|
+
const { errorOf, watching, signalKey } = eventRequests(channel);
|
|
133
|
+
const subscribeParams = (name, input, hook, secret, extra = {}) => ({
|
|
134
|
+
name,
|
|
135
|
+
arguments: input,
|
|
136
|
+
delivery: {
|
|
137
|
+
mode: "webhook",
|
|
138
|
+
url: hook,
|
|
139
|
+
secret
|
|
140
|
+
},
|
|
141
|
+
...extra
|
|
142
|
+
});
|
|
143
|
+
const unsubscribeParams = (name, input, hook) => ({
|
|
144
|
+
name,
|
|
145
|
+
arguments: input,
|
|
146
|
+
delivery: { url: hook }
|
|
147
|
+
});
|
|
148
|
+
const resultOf = (message, method) => {
|
|
149
|
+
const result = memberOf(message, "result");
|
|
150
|
+
expect(isObject(result), `${method} must give a result, got ${JSON.stringify(message)}`);
|
|
151
|
+
return result;
|
|
152
|
+
};
|
|
153
|
+
const main = {
|
|
154
|
+
channel,
|
|
155
|
+
principal
|
|
156
|
+
};
|
|
157
|
+
const subscribe = async (name, input, hook, secret, signal, extra = {}, client = main) => {
|
|
158
|
+
const result = resultOf(await client.channel.request("events/subscribe", subscribeParams(name, input, hook, secret, extra), signal), "events/subscribe");
|
|
159
|
+
expectEqual(result.resultType, "complete", "resultType");
|
|
160
|
+
expectEqual(result.id, await webhookSubscriptionId(client.principal, hook, name, input), "the subscription id");
|
|
161
|
+
const refreshBefore = result.refreshBefore;
|
|
162
|
+
expect(typeof refreshBefore === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(refreshBefore), `refreshBefore must be an RFC 3339 date and time, got ${JSON.stringify(refreshBefore)}`);
|
|
163
|
+
expect(Date.parse(refreshBefore) > Date.now(), `refreshBefore must be in the future, got ${refreshBefore}`);
|
|
164
|
+
expect(result.cursor === null || typeof result.cursor === "string", `cursor must be the watermark or null, got ${JSON.stringify(result.cursor)}`);
|
|
165
|
+
expectEqual(result.truncated, false, "truncated");
|
|
166
|
+
const status = result.deliveryStatus;
|
|
167
|
+
expectEqual(memberOf(status, "active"), true, "deliveryStatus.active");
|
|
168
|
+
const last = memberOf(status, "lastDeliveryAt");
|
|
169
|
+
expect(last === null || typeof last === "string", `deliveryStatus.lastDeliveryAt must be a date or null, got ${JSON.stringify(last)}`);
|
|
170
|
+
expect(isObject(status) && "lastError" in status, `deliveryStatus must have lastError, got ${JSON.stringify(status)}`);
|
|
171
|
+
return {
|
|
172
|
+
id: String(result.id),
|
|
173
|
+
refreshBefore: Date.parse(refreshBefore)
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
const unsubscribe = (name, input, hook, signal, client = main) => client.channel.request("events/unsubscribe", unsubscribeParams(name, input, hook), signal);
|
|
177
|
+
/** End a subscription at the end of a check. It can be gone already. */
|
|
178
|
+
const cleanup = (name, input, hook, client = main) => unsubscribe(name, input, hook, AbortSignal.timeout(checks.deadline), client).catch(() => void 0);
|
|
179
|
+
/** The requests of a URL that are not verifications. */
|
|
180
|
+
const deliveries = (hook) => hook.received().filter((each) => !isVerification(each));
|
|
181
|
+
const atLeast = (hook, number, signal) => until(`the receiver gets ${number} deliveries`, async () => deliveries(hook), (list) => list.length >= number, signal);
|
|
182
|
+
const watchedKey = (key) => (keys) => keys.includes(key);
|
|
183
|
+
const minTtlMs = settings.minTtlMs ?? MCP_WEBHOOK_DEFAULTS.minTtlMs;
|
|
184
|
+
const maxTtlMs = settings.maxTtlMs ?? MCP_WEBHOOK_DEFAULTS.maxTtlMs;
|
|
185
|
+
await checks.run("events/subscribe and events/unsubscribe with no principal are Forbidden", RULE, async (signal) => {
|
|
186
|
+
const hook = receiver.url();
|
|
187
|
+
const input = { key: randomUUID() };
|
|
188
|
+
const subscribed = errorOf(await anonymous.request("events/subscribe", subscribeParams("fixture.watch", input, hook.url, newSecret()), signal));
|
|
189
|
+
expectEqual(subscribed.code, -32024, "the JSON-RPC error code of events/subscribe");
|
|
190
|
+
const unsubscribed = errorOf(await anonymous.request("events/unsubscribe", unsubscribeParams("fixture.watch", input, hook.url), signal));
|
|
191
|
+
expectEqual(unsubscribed.code, -32024, "the JSON-RPC error code of events/unsubscribe");
|
|
192
|
+
expectEqual(hook.received().length, 0, "the requests that the receiver got");
|
|
193
|
+
});
|
|
194
|
+
await checks.run("a URL that is not https, or a secret that is not valid, is the error -32602", RULE, async (signal) => {
|
|
195
|
+
const hook = receiver.url();
|
|
196
|
+
const http = hook.url.replace(/^https:/, "http:");
|
|
197
|
+
for (const [what, params] of [
|
|
198
|
+
["a URL that is not https", subscribeParams("fixture.watch", { key: randomUUID() }, http, newSecret())],
|
|
199
|
+
["a secret of 16 bytes", subscribeParams("fixture.watch", { key: randomUUID() }, hook.url, `whsec_${randomBytes(16).toString("base64")}`)],
|
|
200
|
+
["a secret with no prefix", subscribeParams("fixture.watch", { key: randomUUID() }, hook.url, randomBytes(32).toString("base64"))]
|
|
201
|
+
]) expectEqual(errorOf(await channel.request("events/subscribe", params, signal)).code, -32602, `the JSON-RPC error code for ${what}`);
|
|
202
|
+
expectEqual(hook.received().length, 0, "the requests that the receiver got");
|
|
203
|
+
});
|
|
204
|
+
await checks.run("the server verifies a new URL with a challenge, then answers with the subscription", RULE, async (signal) => {
|
|
205
|
+
const hook = receiver.url();
|
|
206
|
+
const input = {
|
|
207
|
+
count: 0,
|
|
208
|
+
key: randomUUID()
|
|
209
|
+
};
|
|
210
|
+
const secret = newSecret();
|
|
211
|
+
try {
|
|
212
|
+
const { id } = await subscribe("fixture.ticks", input, hook.url, secret, signal);
|
|
213
|
+
const [verification] = hook.received();
|
|
214
|
+
expect(verification !== void 0 && isVerification(verification), `the first request must be the verification, got ${JSON.stringify(verification?.body)}`);
|
|
215
|
+
expect(typeof memberOf(verification.body, "challenge") === "string", `the verification must have a challenge, got ${verification.text}`);
|
|
216
|
+
await expectSigned(verification, id, [secret], "verification");
|
|
217
|
+
} finally {
|
|
218
|
+
await cleanup("fixture.ticks", input, hook.url);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
await checks.run("an endpoint that does not answer the challenge is CallbackEndpointError, and the subscription does not start", RULE, async (signal) => {
|
|
222
|
+
const hook = receiver.url((request) => isVerification(request) ? {
|
|
223
|
+
status: 200,
|
|
224
|
+
body: JSON.stringify({ challenge: "wrong" })
|
|
225
|
+
} : void 0);
|
|
226
|
+
const input = { key: randomUUID() };
|
|
227
|
+
const error = errorOf(await channel.request("events/subscribe", subscribeParams("fixture.watch", input, hook.url, newSecret()), signal));
|
|
228
|
+
expectEqual(error.code, -32027, "the JSON-RPC error code");
|
|
229
|
+
expectEqual(memberOf(error.data, "reason"), "challenge_failed", "data.reason");
|
|
230
|
+
await until("the key is not in fixture.watching", () => watching(signal), (keys) => !keys.includes(input.key), signal);
|
|
231
|
+
expectEqual(hook.received().length, 1, "the requests that the receiver got: only the verification");
|
|
232
|
+
});
|
|
233
|
+
await checks.run("each occurrence is one signed POST, in order, whose cursor is the watermark", RULE, async (signal) => {
|
|
234
|
+
const hook = receiver.url();
|
|
235
|
+
const input = {
|
|
236
|
+
count: 3,
|
|
237
|
+
key: randomUUID()
|
|
238
|
+
};
|
|
239
|
+
const secret = newSecret();
|
|
240
|
+
try {
|
|
241
|
+
const { id } = await subscribe("fixture.ticks", input, hook.url, secret, signal);
|
|
242
|
+
const list = await atLeast(hook, 3, signal);
|
|
243
|
+
const ids = /* @__PURE__ */ new Set();
|
|
244
|
+
for (const [index, request] of list.entries()) {
|
|
245
|
+
const n = index + 1;
|
|
246
|
+
ids.add(await expectSigned(request, id, [secret]));
|
|
247
|
+
const occurrence = expectDelivered(request, "fixture.ticks");
|
|
248
|
+
expectEqual([
|
|
249
|
+
occurrence.data,
|
|
250
|
+
occurrence.eventId,
|
|
251
|
+
occurrence.cursor
|
|
252
|
+
], [
|
|
253
|
+
{ n },
|
|
254
|
+
String(n),
|
|
255
|
+
String(n)
|
|
256
|
+
], "the data, the eventId and the cursor");
|
|
257
|
+
}
|
|
258
|
+
expectEqual(ids.size, 3, "the number of different webhook-id headers");
|
|
259
|
+
} finally {
|
|
260
|
+
await cleanup("fixture.ticks", input, hook.url);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
await checks.run("a subscription gets the things that happen; a renewal grants a new lifetime and signs with both secrets; events/unsubscribe ends it", RULE, async (signal) => {
|
|
264
|
+
const hook = receiver.url();
|
|
265
|
+
const input = { key: randomUUID() };
|
|
266
|
+
const [first, second] = [newSecret(), newSecret()];
|
|
267
|
+
try {
|
|
268
|
+
const started = await subscribe("fixture.watch", input, hook.url, first, signal, { ttlMs: minTtlMs });
|
|
269
|
+
await until("fixture.watching gives the key", () => watching(signal), watchedKey(input.key), signal);
|
|
270
|
+
expectEqual(await signalKey(input.key, "a", signal), 1, "the subscriptions that fixture.signal reached");
|
|
271
|
+
const [a] = await atLeast(hook, 1, signal);
|
|
272
|
+
if (a === void 0) throw new Error("the receiver got no delivery");
|
|
273
|
+
await expectSigned(a, started.id, [first]);
|
|
274
|
+
const occurrence = expectDelivered(a, "fixture.watch");
|
|
275
|
+
expectEqual(occurrence.data, {
|
|
276
|
+
...input,
|
|
277
|
+
value: "a"
|
|
278
|
+
}, "the data");
|
|
279
|
+
expect(!("cursor" in occurrence), `with no cursor of the provider, the delivery has no cursor, got ${a.text}`);
|
|
280
|
+
const renewed = await subscribe("fixture.watch", input, hook.url, second, signal);
|
|
281
|
+
expectEqual(renewed.id, started.id, "the subscription id of the renewal");
|
|
282
|
+
expect(renewed.refreshBefore >= started.refreshBefore, "a renewal with no ttlMs, which grants the maximum lifetime, must not grant a shorter lifetime than the first grant of the minimum lifetime");
|
|
283
|
+
expectEqual(count(await watching(signal), input.key), 1, "the subscriptions of the key after the renewal");
|
|
284
|
+
expectEqual(await signalKey(input.key, "b", signal), 1, "the subscriptions that fixture.signal reached after the renewal");
|
|
285
|
+
const [, b] = await atLeast(hook, 2, signal);
|
|
286
|
+
if (b === void 0) throw new Error("the receiver got no second delivery");
|
|
287
|
+
await expectSigned(b, started.id, [second, first]);
|
|
288
|
+
expectEqual(memberOf(resultOf(await unsubscribe("fixture.watch", input, hook.url, signal), "events/unsubscribe"), "resultType"), "complete", "the resultType of events/unsubscribe");
|
|
289
|
+
await until("the key leaves fixture.watching", () => watching(signal), (keys) => !keys.includes(input.key), signal);
|
|
290
|
+
const again = errorOf(await unsubscribe("fixture.watch", input, hook.url, signal));
|
|
291
|
+
expectEqual(again.code, -32023, "the JSON-RPC error code of a second events/unsubscribe");
|
|
292
|
+
expectEqual(memberOf(again.data, "kind"), "subscription", "data.kind");
|
|
293
|
+
} finally {
|
|
294
|
+
await cleanup("fixture.watch", input, hook.url);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
await checks.run("a delivery that gets a status that is not 2xx is sent again, with the same webhook-id", RULE, async (signal) => {
|
|
298
|
+
const hook = receiver.url((request, index) => !isVerification(request) && index === 0 ? { status: 500 } : void 0);
|
|
299
|
+
const input = { key: randomUUID() };
|
|
300
|
+
const secret = newSecret();
|
|
301
|
+
try {
|
|
302
|
+
const { id } = await subscribe("fixture.watch", input, hook.url, secret, signal);
|
|
303
|
+
await until("fixture.watching gives the key", () => watching(signal), watchedKey(input.key), signal);
|
|
304
|
+
await signalKey(input.key, "again", signal);
|
|
305
|
+
const [failed, retried] = await atLeast(hook, 2, signal);
|
|
306
|
+
if (failed === void 0 || retried === void 0) throw new Error("the receiver got no retry");
|
|
307
|
+
const firstId = await expectSigned(failed, id, [secret]);
|
|
308
|
+
expectEqual(await expectSigned(retried, id, [secret]), firstId, "the webhook-id of the second attempt");
|
|
309
|
+
expectEqual(retried.text, failed.text, "the body of the second attempt");
|
|
310
|
+
} finally {
|
|
311
|
+
await cleanup("fixture.watch", input, hook.url);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
await checks.run("a 410 abandons the delivery at once, and the next occurrence follows", RULE, async (signal) => {
|
|
315
|
+
const hook = receiver.url((request, index) => !isVerification(request) && index === 0 ? { status: 410 } : void 0);
|
|
316
|
+
const input = { key: randomUUID() };
|
|
317
|
+
try {
|
|
318
|
+
await subscribe("fixture.watch", input, hook.url, newSecret(), signal);
|
|
319
|
+
await until("fixture.watching gives the key", () => watching(signal), watchedKey(input.key), signal);
|
|
320
|
+
await signalKey(input.key, "gone", signal);
|
|
321
|
+
await atLeast(hook, 1, signal);
|
|
322
|
+
await signalKey(input.key, "next", signal);
|
|
323
|
+
const [gone, next] = await atLeast(hook, 2, signal);
|
|
324
|
+
if (gone === void 0 || next === void 0) throw new Error("the receiver got no second delivery");
|
|
325
|
+
expectEqual(expectDelivered(gone, "fixture.watch").data, {
|
|
326
|
+
...input,
|
|
327
|
+
value: "gone"
|
|
328
|
+
}, "the data of the first delivery");
|
|
329
|
+
expectEqual(expectDelivered(next, "fixture.watch").data, {
|
|
330
|
+
...input,
|
|
331
|
+
value: "next"
|
|
332
|
+
}, "the data of the second delivery");
|
|
333
|
+
} finally {
|
|
334
|
+
await cleanup("fixture.watch", input, hook.url);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
await checks.run("a defect after an occurrence sends the control message terminated", RULE, async (signal) => {
|
|
338
|
+
const hook = receiver.url();
|
|
339
|
+
const secret = newSecret();
|
|
340
|
+
try {
|
|
341
|
+
const { id } = await subscribe("fixture.broken", {}, hook.url, secret, signal);
|
|
342
|
+
const [occurrence, terminated] = await atLeast(hook, 2, signal);
|
|
343
|
+
if (occurrence === void 0 || terminated === void 0) throw new Error("the receiver got no control message");
|
|
344
|
+
expectEqual(expectDelivered(occurrence, "fixture.broken").data, { n: 1 }, "the data of the occurrence");
|
|
345
|
+
expect(!isOccurrence(terminated.body), `the second request must be a control message, got ${terminated.text}`);
|
|
346
|
+
expectEqual(memberOf(terminated.body, "type"), "terminated", "the type of the control message");
|
|
347
|
+
const error = memberOf(terminated.body, "error");
|
|
348
|
+
expectEqual(memberOf(error, "code"), -32603, "the error code of the control message");
|
|
349
|
+
expect(typeof memberOf(error, "message") === "string" && !/\n\s+at /.test(String(memberOf(error, "message"))), "the message must be text with no stack trace");
|
|
350
|
+
await expectSigned(terminated, id, [secret], "terminated");
|
|
351
|
+
} finally {
|
|
352
|
+
await cleanup("fixture.broken", {}, hook.url);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
await checks.run("before an occurrence after a gap, the server sends the control message gap with the watermark", RULE, async (signal) => {
|
|
356
|
+
const hook = receiver.url();
|
|
357
|
+
const input = {
|
|
358
|
+
count: 3,
|
|
359
|
+
gap: 2,
|
|
360
|
+
key: randomUUID()
|
|
361
|
+
};
|
|
362
|
+
const secret = newSecret();
|
|
363
|
+
try {
|
|
364
|
+
const { id } = await subscribe("fixture.ticks", input, hook.url, secret, signal);
|
|
365
|
+
const [first, gap, second, third] = await atLeast(hook, 4, signal);
|
|
366
|
+
if (first === void 0 || gap === void 0 || second === void 0 || third === void 0) throw new Error("the receiver got less than four requests");
|
|
367
|
+
expectEqual(expectDelivered(first, "fixture.ticks").data, { n: 1 }, "the data of the first delivery");
|
|
368
|
+
expectEqual(gap.body, {
|
|
369
|
+
type: "gap",
|
|
370
|
+
cursor: "1"
|
|
371
|
+
}, "the control message gap, with the watermark after the first delivery");
|
|
372
|
+
await expectSigned(gap, id, [secret], "gap");
|
|
373
|
+
const after = expectDelivered(second, "fixture.ticks");
|
|
374
|
+
expectEqual([after.data, after.cursor], [{ n: 2 }, "2"], "the data and the cursor of the delivery after the gap");
|
|
375
|
+
expectEqual(expectDelivered(third, "fixture.ticks").data, { n: 3 }, "the data of the last delivery");
|
|
376
|
+
} finally {
|
|
377
|
+
await cleanup("fixture.ticks", input, hook.url);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
await checks.run("the server grants ttlMs between the minimum and the maximum lifetime, and the maximum with no ttlMs", RULE, async (signal) => {
|
|
381
|
+
const hook = receiver.url();
|
|
382
|
+
const cases = [
|
|
383
|
+
[
|
|
384
|
+
"a ttlMs below the minimum",
|
|
385
|
+
{ ttlMs: Math.floor(minTtlMs / 2) },
|
|
386
|
+
minTtlMs
|
|
387
|
+
],
|
|
388
|
+
[
|
|
389
|
+
"a ttlMs above the maximum",
|
|
390
|
+
{ ttlMs: maxTtlMs * 2 },
|
|
391
|
+
maxTtlMs
|
|
392
|
+
],
|
|
393
|
+
[
|
|
394
|
+
"ttlMs: null",
|
|
395
|
+
{ ttlMs: null },
|
|
396
|
+
maxTtlMs
|
|
397
|
+
],
|
|
398
|
+
[
|
|
399
|
+
"no ttlMs",
|
|
400
|
+
{},
|
|
401
|
+
maxTtlMs
|
|
402
|
+
]
|
|
403
|
+
];
|
|
404
|
+
for (const [what, extra, granted] of cases) {
|
|
405
|
+
const input = { key: randomUUID() };
|
|
406
|
+
try {
|
|
407
|
+
const before = Date.now();
|
|
408
|
+
const { refreshBefore } = await subscribe("fixture.watch", input, hook.url, newSecret(), signal, extra);
|
|
409
|
+
const after = Date.now();
|
|
410
|
+
expect(refreshBefore >= before + granted - 1 && refreshBefore <= after + granted + 1, `for ${what}, refreshBefore must be ${granted} ms after the request, got ${refreshBefore - before} ms after its start`);
|
|
411
|
+
} finally {
|
|
412
|
+
await cleanup("fixture.watch", input, hook.url);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
if (settings.minTtlMs !== void 0) await checks.run("a subscription with no renewal ends at the end of its lifetime, and gets no more deliveries", RULE, async (signal) => {
|
|
417
|
+
const hook = receiver.url();
|
|
418
|
+
const input = { key: randomUUID() };
|
|
419
|
+
try {
|
|
420
|
+
const before = Date.now();
|
|
421
|
+
await subscribe("fixture.watch", input, hook.url, newSecret(), signal, { ttlMs: minTtlMs });
|
|
422
|
+
await until("fixture.watching gives the key", () => watching(signal), watchedKey(input.key), signal);
|
|
423
|
+
await until("the lifetime ends, and the key leaves fixture.watching", () => watching(signal), (keys) => !keys.includes(input.key), signal);
|
|
424
|
+
const elapsed = Date.now() - before;
|
|
425
|
+
expect(elapsed >= minTtlMs, `the subscription ended ${elapsed} ms after the request, before its lifetime of ${minTtlMs} ms`);
|
|
426
|
+
expectEqual(await signalKey(input.key, "late", signal), 0, "the subscriptions that fixture.signal reached after the end");
|
|
427
|
+
expectEqual(deliveries(hook).length, 0, "the deliveries that the receiver got");
|
|
428
|
+
const ended = errorOf(await unsubscribe("fixture.watch", input, hook.url, signal));
|
|
429
|
+
expectEqual([ended.code, memberOf(ended.data, "kind")], [-32023, "subscription"], "the error of events/unsubscribe after the end");
|
|
430
|
+
} finally {
|
|
431
|
+
await cleanup("fixture.watch", input, hook.url);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
if (settings.maxSubscriptions !== void 0) {
|
|
435
|
+
const max = settings.maxSubscriptions;
|
|
436
|
+
const other = settings.otherPrincipal;
|
|
437
|
+
const name = `a new subscription over the limit of a principal is ResourceExhausted, and a renewal still works${other === void 0 ? "" : ", and another principal can still subscribe"}`;
|
|
438
|
+
await checks.run(name, RULE, async (signal) => {
|
|
439
|
+
const client = other === void 0 ? main : {
|
|
440
|
+
channel: httpChannel(url, { Authorization: `Bearer ${other}` }),
|
|
441
|
+
principal: other
|
|
442
|
+
};
|
|
443
|
+
const hook = receiver.url();
|
|
444
|
+
const created = [];
|
|
445
|
+
const extra = { key: randomUUID() };
|
|
446
|
+
try {
|
|
447
|
+
let refused;
|
|
448
|
+
while (refused === void 0 && created.length <= max) {
|
|
449
|
+
const input = { key: randomUUID() };
|
|
450
|
+
const message = await client.channel.request("events/subscribe", subscribeParams("fixture.watch", input, hook.url, newSecret()), signal);
|
|
451
|
+
if (memberOf(message, "error") === void 0) {
|
|
452
|
+
resultOf(message, "events/subscribe");
|
|
453
|
+
created.push(input);
|
|
454
|
+
} else refused = errorOf(message);
|
|
455
|
+
}
|
|
456
|
+
if (refused === void 0) throw new Error(`the server granted ${created.length} subscriptions to one principal, over the limit of ${max}`);
|
|
457
|
+
expectEqual(refused.code, -32025, "the JSON-RPC error code");
|
|
458
|
+
expectEqual(refused.data, {
|
|
459
|
+
limit: "subscriptions",
|
|
460
|
+
max
|
|
461
|
+
}, "data");
|
|
462
|
+
if (other !== void 0) expectEqual(created.length, max, "the subscriptions that the server granted before the limit");
|
|
463
|
+
const [first] = created;
|
|
464
|
+
if (first === void 0) throw new Error("the principal had no free subscription, so the check cannot renew one");
|
|
465
|
+
await subscribe("fixture.watch", first, hook.url, newSecret(), signal, {}, client);
|
|
466
|
+
if (other !== void 0) await subscribe("fixture.watch", extra, hook.url, newSecret(), signal);
|
|
467
|
+
} finally {
|
|
468
|
+
for (const input of created) await cleanup("fixture.watch", input, hook.url, client);
|
|
469
|
+
await cleanup("fixture.watch", extra, hook.url);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
export { checkWebhook };
|
package/dist/conform/webmcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isArrayOf, isObject, isStringArray, memberOf, parseJson } from "../json.js";
|
|
2
2
|
import { Checks, abortable, expect, expectEqual, fixture, idsOf, sorted, until } from "./check.js";
|
|
3
3
|
import { REFERENCE, visitObjects } from "@acid-cat/spec-ts";
|
|
4
|
-
|
|
4
|
+
// src/conform/webmcp.ts
|
|
5
5
|
/**
|
|
6
6
|
* Conformance checks for the WebMCP binding (spec/bindings/webmcp.md).
|
|
7
7
|
*
|
|
@@ -152,7 +152,7 @@ function isBundled(schema) {
|
|
|
152
152
|
visitObjects(schema, (node) => {
|
|
153
153
|
if (typeof node.$ref !== "string") return;
|
|
154
154
|
const name = REFERENCE.exec(node.$ref)?.[1];
|
|
155
|
-
if (name === void 0 || !(name
|
|
155
|
+
if (name === void 0 || !Object.hasOwn(types, name)) bundled = false;
|
|
156
156
|
});
|
|
157
157
|
return bundled;
|
|
158
158
|
}
|
|
@@ -172,6 +172,11 @@ async function checkWebMcp(driver, options) {
|
|
|
172
172
|
await checks.run("each available capability of the view is a tool", `${RULE}#tools`, async (signal) => {
|
|
173
173
|
await until(`the tools are ${expected.join(", ")}`, () => names(driver, signal), (tools) => JSON.stringify(tools) === JSON.stringify(expected), signal);
|
|
174
174
|
});
|
|
175
|
+
await checks.run("an event is not a tool", `${RULE}#events`, async (signal) => {
|
|
176
|
+
const tools = await names(driver, signal);
|
|
177
|
+
const events = Object.keys(fixture.events ?? {}).filter((id) => tools.includes(id));
|
|
178
|
+
expectEqual(events, [], "the tools that have the id of an event");
|
|
179
|
+
});
|
|
175
180
|
await checks.run("a tool has the description and the bundled input schema of its capability", `${RULE}#tools`, async (signal) => {
|
|
176
181
|
for (const tool of await driver.tools(signal)) {
|
|
177
182
|
expectEqual(tool.description, view[tool.name]?.description, `description of ${tool.name}`);
|
|
@@ -244,5 +249,4 @@ async function checkWebMcp(driver, options) {
|
|
|
244
249
|
});
|
|
245
250
|
return checks;
|
|
246
251
|
}
|
|
247
|
-
//#endregion
|
|
248
252
|
export { browserDriver, checkWebMcp, createTestHost, processDriver };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
export { bundle, check, viewOf } from "./catalog.js";
|
|
3
3
|
export type { Catalog, Report } from "./catalog.js";
|
|
4
4
|
export { conform, fixture, serve } from "./conform/index.js";
|
|
5
|
-
export type { Check, ConformOptions, Routes, Served, WebHandler } from "./conform/index.js";
|
|
5
|
+
export type { Check, ConformOptions, PollSettings, Routes, Served, WebHandler, WebhookSettings } from "./conform/index.js";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
// ../../schemas/catalog.schema.json
|
|
2
2
|
var catalog_schema_default = {
|
|
3
3
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
$id: "https://acid-cat.dev/schemas/0.1/catalog.schema.json",
|
|
5
5
|
title: "AcidCat catalog",
|
|
6
|
-
description: "One document that states what an application can do and which targets see
|
|
6
|
+
description: "One document that states what an application can do, which events it gives, and which targets see each one. See spec/catalog.md.",
|
|
7
7
|
type: "object",
|
|
8
8
|
required: [
|
|
9
9
|
"acidcat",
|
|
@@ -34,6 +34,12 @@ var catalog_schema_default = {
|
|
|
34
34
|
"type": "object",
|
|
35
35
|
"propertyNames": { "$ref": "#/$defs/id" },
|
|
36
36
|
"additionalProperties": { "$ref": "#/$defs/capability" }
|
|
37
|
+
},
|
|
38
|
+
"events": {
|
|
39
|
+
"description": "The events, keyed by id. See spec/events.md.",
|
|
40
|
+
"type": "object",
|
|
41
|
+
"propertyNames": { "$ref": "#/$defs/id" },
|
|
42
|
+
"additionalProperties": { "$ref": "#/$defs/event" }
|
|
37
43
|
}
|
|
38
44
|
},
|
|
39
45
|
$defs: {
|
|
@@ -73,7 +79,7 @@ var catalog_schema_default = {
|
|
|
73
79
|
"type": ["object", "boolean"]
|
|
74
80
|
},
|
|
75
81
|
"objectSchema": {
|
|
76
|
-
"description": "An input schema: it states that the
|
|
82
|
+
"description": "An input or payload schema: it states that the value is an object, or it references a type.",
|
|
77
83
|
"type": "object",
|
|
78
84
|
"anyOf": [{
|
|
79
85
|
"required": ["type"],
|
|
@@ -110,8 +116,25 @@ var catalog_schema_default = {
|
|
|
110
116
|
"readOnly": { "type": "boolean" },
|
|
111
117
|
"state": { "$ref": "#/$defs/schema" }
|
|
112
118
|
}
|
|
119
|
+
},
|
|
120
|
+
"event": {
|
|
121
|
+
"type": "object",
|
|
122
|
+
"required": ["description"],
|
|
123
|
+
"properties": {
|
|
124
|
+
"description": {
|
|
125
|
+
"type": "string",
|
|
126
|
+
"minLength": 1,
|
|
127
|
+
"pattern": "\\S"
|
|
128
|
+
},
|
|
129
|
+
"audience": {
|
|
130
|
+
"type": "array",
|
|
131
|
+
"items": { "$ref": "#/$defs/targetName" },
|
|
132
|
+
"uniqueItems": true
|
|
133
|
+
},
|
|
134
|
+
"input": { "$ref": "#/$defs/objectSchema" },
|
|
135
|
+
"payload": { "$ref": "#/$defs/objectSchema" }
|
|
136
|
+
}
|
|
113
137
|
}
|
|
114
138
|
}
|
|
115
139
|
};
|
|
116
|
-
//#endregion
|
|
117
140
|
export { catalog_schema_default as default };
|