@tineira/apc-conformance 0.0.0-stage → 0.7.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/dist/http.js ADDED
@@ -0,0 +1,455 @@
1
+ /**
2
+ * The conformance checks that run over plain HTTP against a live app (what `npx @tineira/apc-conformance` runs):
3
+ * no adapter, only the app's origin and, if the person has them, the MCP URL of a live Direct grant and
4
+ * of one they revoked. It reads `{origin}/.well-known/apc.json` and talks JSON-RPC to the grant the way
5
+ * an agent does. What only the person's page can do (drop a pin, open or close the page, see presence,
6
+ * press Revoke, a phone's touch) is not reachable over HTTP: those checks are listed in
7
+ * `IN_PROCESS_ONLY` and run with `apcConformance(adapter)` in the app's own tests.
8
+ *
9
+ * Safe to point at any URL: https only (http only with `allowHttp`, and then only on a loopback host),
10
+ * no credentials in URLs, every grant URL on the app's own origin, redirects never followed (a 3xx is a
11
+ * failure), a byte cap on every body and a timeout on every request.
12
+ */
13
+ import { APC_JSON_MAX_BYTES, APC_JSON_PATH, APC_STANDARD_VERSION, CORE_TOOLS, REVOKED_HTTP_STATUS, compareStandard, declaresMcpUrl, isRevokedAnswer, parseApcManifest, } from "@tineira/apc-protocol";
14
+ import { ConformanceError, ISO, attentionOf, checkCoreToolList, checkFocusShape, checkView, data, expect, isObject, resultKey, rpcBody } from "./shared.js";
15
+ /**
16
+ * The checks of the suite that need the person's page (an adapter in process), not reachable over HTTP:
17
+ * `apcConformance(adapter)` runs them in the app's own tests.
18
+ */
19
+ export const IN_PROCESS_ONLY = [
20
+ { name: "a pin rides as attention on any result until it is read, and next reads it", why: "needs the person to drop a pin in the page" },
21
+ { name: "get_current_view returns the view the page reported, and no focus before a pin", why: "needs the page to report a view (the CLI checks the shape of whatever view and focus it gets)" },
22
+ { name: "wait_for_focus returns a pin dropped during the wait", why: "needs the person to drop a pin during the wait" },
23
+ { name: "wait_for_focus ends when the person has no page open", why: "needs to open and close the person's page (the CLI accepts either outcome)" },
24
+ { name: "then rides on results only while the person's page is open", why: "needs to open and close the person's page (the CLI checks then's shape where it appears)" },
25
+ { name: "presence names what a read touched, with ids only", why: "presence goes to the person's page, not to the agent" },
26
+ { name: "revoke from the app cuts that agent at once, and only that agent", why: "needs the person's Revoke (the CLI checks a grant you revoked, with --revoked)" },
27
+ { name: "a call already running when the grant is revoked ends revoked, with no data", why: "needs the person's Revoke during a call" },
28
+ { name: "mobile: every agent is shown in the page, and the agent bar cannot be collapsed", why: "needs the page on a phone (page.mobile)" },
29
+ { name: "mobile: a person pins and unpins with a finger", why: "needs the page on a phone (page.mobile)" },
30
+ { name: "apc_link: the offer, the code and its refusals, revoke and unlink of a linked agent", why: "needs Dock connections through a hub and the agent bar (link adapter)" },
31
+ ];
32
+ const DEFAULT_TIMEOUT_MS = 15_000;
33
+ /** The most bytes read of an MCP reply. */
34
+ const MCP_MAX_BYTES = 1_048_576;
35
+ const PROTOCOL_VERSION = "2025-06-18";
36
+ /**
37
+ * The hosts `--allow-http` lets through, literally (after the URL parser's normalizing, so `127.1` is
38
+ * `127.0.0.1`). Not `*.localhost`: Node resolves those through DNS, which may answer anything.
39
+ */
40
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
41
+ /** `wait_for_focus({ timeout_s: 1 })` answers within this, as in the in-process suite; the request is cut there too. */
42
+ const WAIT_LIMIT_MS = 5000;
43
+ /** Why a URL the person gave cannot be used; thrown before any request is sent. */
44
+ export class HttpConformanceUsageError extends Error {
45
+ }
46
+ /**
47
+ * A URL the checks may call: https (or http on a loopback host with `allowHttp`), no credentials. The input is
48
+ * never echoed when it does not parse: a grant URL holds a secret.
49
+ */
50
+ export function safeUrl(raw, what, allowHttp) {
51
+ let url;
52
+ try {
53
+ url = new URL(raw);
54
+ }
55
+ catch {
56
+ throw new HttpConformanceUsageError(`${what} is not a valid URL (not shown: a grant URL holds a secret)`);
57
+ }
58
+ if (url.username || url.password)
59
+ throw new HttpConformanceUsageError(`${what} carries credentials; leave them out`);
60
+ if (url.protocol === "https:")
61
+ return url;
62
+ if (url.protocol === "http:") {
63
+ if (!allowHttp)
64
+ throw new HttpConformanceUsageError(`${what} is http: use https, or --allow-http for a dev server on localhost`);
65
+ if (!LOOPBACK.has(url.hostname))
66
+ throw new HttpConformanceUsageError(`${what}: --allow-http is for a loopback host only (localhost, 127.0.0.1, [::1]), not ${url.hostname}`);
67
+ return url;
68
+ }
69
+ throw new HttpConformanceUsageError(`${what} is not http(s): ${url.protocol}`);
70
+ }
71
+ /** The options, checked: the origin as an origin, and every grant URL on it. Throws `HttpConformanceUsageError`. */
72
+ export function checkOptions(options) {
73
+ const allowHttp = !!options.allowHttp;
74
+ const o = safeUrl(options.origin, "the app's origin", allowHttp);
75
+ if ((o.pathname !== "/" && o.pathname !== "") || o.search || o.hash)
76
+ throw new HttpConformanceUsageError(`the app's origin has a path or query: give ${o.origin}`);
77
+ const origin = o.origin;
78
+ const onOrigin = (raw, what) => {
79
+ if (raw === undefined)
80
+ return undefined;
81
+ const url = safeUrl(raw, what, allowHttp);
82
+ if (url.origin !== origin)
83
+ throw new HttpConformanceUsageError(`${what} is on ${url.origin}, not on the app's origin ${origin}`);
84
+ return url;
85
+ };
86
+ const out = { origin };
87
+ const grant = onOrigin(options.grant, "--grant");
88
+ const revoked = onOrigin(options.revoked, "--revoked");
89
+ if (grant)
90
+ out.grant = grant;
91
+ if (revoked)
92
+ out.revoked = revoked;
93
+ return out;
94
+ }
95
+ /** A grant's MCP URL as it may be shown: the last path segment (its secret) replaced. */
96
+ export function redactGrantUrl(url) {
97
+ try {
98
+ const u = new URL(url);
99
+ const parts = u.pathname.split("/");
100
+ const last = parts.findLastIndex((p) => p.length > 0);
101
+ if (last >= 0)
102
+ parts[last] = "<secret>";
103
+ return `${u.origin}${parts.join("/")}`;
104
+ }
105
+ catch {
106
+ return "<grant URL>";
107
+ }
108
+ }
109
+ /** The secret of each grant URL: its last path segment, as sent and decoded. Segments under 4 characters are no secret. */
110
+ function secretsOf(urls) {
111
+ const out = new Set();
112
+ for (const u of urls) {
113
+ if (!u)
114
+ continue;
115
+ let path;
116
+ try {
117
+ path = new URL(String(u)).pathname;
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ const last = path.split("/").filter(Boolean).at(-1);
123
+ if (!last)
124
+ continue;
125
+ out.add(last);
126
+ try {
127
+ const decoded = decodeURIComponent(last);
128
+ out.add(decoded);
129
+ out.add(encodeURIComponent(decoded));
130
+ }
131
+ catch {
132
+ // not decodable
133
+ }
134
+ out.add(encodeURIComponent(last));
135
+ }
136
+ return [...out].filter((s) => s.length >= 4).sort((a, b) => b.length - a.length);
137
+ }
138
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
139
+ /**
140
+ * Any text with every grant's secret (the last path segment of each URL in `urls`) replaced by `<secret>`,
141
+ * wherever it appears (in a URL, a quoted body, an echoed error), in any letter case, as sent, decoded or
142
+ * percent-encoded.
143
+ */
144
+ export function redactSecrets(text, urls) {
145
+ let out = text;
146
+ // In any case (a server may echo it upper-cased) and in each of its encodings, longest first.
147
+ for (const s of secretsOf(urls))
148
+ out = out.replace(new RegExp(escapeRegExp(s), "gi"), "<secret>");
149
+ return out;
150
+ }
151
+ /** The HTTP checks against a live app, in order. Throws `HttpConformanceUsageError` when the options name an unsafe URL. */
152
+ export function httpConformance(options) {
153
+ return build(options).checks;
154
+ }
155
+ function build(options) {
156
+ const { origin, grant, revoked } = checkOptions(options);
157
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
158
+ const doFetch = options.fetch ?? ((request) => fetch(request));
159
+ let manifest = null;
160
+ let manifestRaw = undefined;
161
+ /** One request: no redirect followed, a timeout (`abortMs`, or the run's), at most `maxBytes` read. */
162
+ async function get(url, init, maxBytes, abortMs = timeoutMs) {
163
+ const signal = AbortSignal.timeout(abortMs);
164
+ let response;
165
+ try {
166
+ response = await doFetch(new Request(url, { ...init, redirect: "manual", credentials: "omit", signal }));
167
+ }
168
+ catch (error) {
169
+ const why = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") ? `no answer within ${abortMs} ms` : `the request failed (${error instanceof Error ? error.message : String(error)})`;
170
+ throw new ConformanceError(`${init.method ?? "GET"} ${shown(url)}: ${why}`);
171
+ }
172
+ if ((response.status >= 300 && response.status < 400) || response.type === "opaqueredirect") {
173
+ await response.body?.cancel().catch(() => { });
174
+ throw new ConformanceError(`${init.method ?? "GET"} ${shown(url)}: answered with a redirect (HTTP ${response.status}); APC never follows one`);
175
+ }
176
+ return { status: response.status, contentType: response.headers.get("content-type") ?? "", text: await readCapped(response, maxBytes, url) };
177
+ }
178
+ async function readCapped(response, maxBytes, url) {
179
+ const declared = Number(response.headers.get("content-length") ?? NaN);
180
+ if (Number.isFinite(declared) && declared > maxBytes) {
181
+ await response.body?.cancel().catch(() => { });
182
+ throw new ConformanceError(`${shown(url)}: the answer is ${declared} bytes, over the ${maxBytes} this check reads`);
183
+ }
184
+ if (!response.body)
185
+ return "";
186
+ const reader = response.body.getReader();
187
+ const chunks = [];
188
+ let size = 0;
189
+ for (;;) {
190
+ const { done, value } = await reader.read();
191
+ if (done)
192
+ break;
193
+ size += value.byteLength;
194
+ if (size > maxBytes) {
195
+ await reader.cancel().catch(() => { });
196
+ throw new ConformanceError(`${shown(url)}: the answer is over the ${maxBytes} bytes this check reads`);
197
+ }
198
+ chunks.push(value);
199
+ }
200
+ const all = new Uint8Array(size);
201
+ let at = 0;
202
+ for (const c of chunks) {
203
+ all.set(c, at);
204
+ at += c.byteLength;
205
+ }
206
+ return new TextDecoder().decode(all);
207
+ }
208
+ /** A grant URL is never shown whole: its secret is the grant. */
209
+ function shown(url) {
210
+ return url === `${origin}${APC_JSON_PATH}` ? url : redactGrantUrl(url);
211
+ }
212
+ async function rpc(url, method, params, abortMs) {
213
+ const started = Date.now();
214
+ const r = await get(url.href, {
215
+ method: "POST",
216
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": PROTOCOL_VERSION },
217
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
218
+ }, MCP_MAX_BYTES, abortMs);
219
+ return { status: r.status, body: rpcBody(r.contentType, r.text), ms: Date.now() - started };
220
+ }
221
+ async function result(url, method, params = {}, abortMs) {
222
+ const { status, body, ms } = await rpc(url, method, params, abortMs);
223
+ expect(status === 200, `${method}: HTTP ${status}, expected 200 (${JSON.stringify(body).slice(0, 200)})`);
224
+ expect(isObject(body) && !("error" in body && body.error), `${method}: JSON-RPC error ${JSON.stringify(body).slice(0, 200)}`);
225
+ return { value: body.result, ms };
226
+ }
227
+ async function call(url, tool, args = {}, abortMs) {
228
+ const { value, ms } = await result(url, "tools/call", { name: tool, arguments: args }, abortMs);
229
+ expect(isObject(value), `${tool}: no result`);
230
+ return { r: value, ms };
231
+ }
232
+ const noGrant = "give --grant <mcp-url>: the MCP URL of an agent connected directly (Direct profile)";
233
+ const withGrant = (run) => () => (grant ? run(grant) : Promise.resolve(noGrant));
234
+ const textOnly = "the result has no structuredContent: attention and then were read from the text alone, with nothing to compare them to";
235
+ /** `then` where it appears: a string, the same in the structured result and the text. */
236
+ const checkThen = (r, where) => {
237
+ const { value } = resultKey(r, "then");
238
+ if (value !== undefined)
239
+ expect(typeof value === "string", `${where}: then is not a string`);
240
+ };
241
+ const checks = [
242
+ {
243
+ id: "apc-json-served",
244
+ name: `serves ${APC_JSON_PATH}: HTTP 200, no redirect, application/json, at most ${APC_JSON_MAX_BYTES} bytes, JSON`,
245
+ async run() {
246
+ const r = await get(`${origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } }, APC_JSON_MAX_BYTES);
247
+ expect(r.status === 200, `GET ${APC_JSON_PATH}: HTTP ${r.status}, expected 200`);
248
+ expect(r.contentType.includes("application/json"), `${APC_JSON_PATH} is served as ${r.contentType || "no content type"}, not application/json`);
249
+ try {
250
+ manifestRaw = JSON.parse(r.text);
251
+ }
252
+ catch {
253
+ throw new ConformanceError(`${APC_JSON_PATH} is not JSON`);
254
+ }
255
+ },
256
+ },
257
+ {
258
+ id: "apc-json-valid",
259
+ name: "apc.json has the shape of the standard and names the origin it is served from",
260
+ async run() {
261
+ if (manifestRaw === undefined)
262
+ return "apc.json could not be read";
263
+ const parsed = parseApcManifest(manifestRaw, origin);
264
+ expect(parsed.ok, parsed.ok ? "" : `${parsed.error}: ${parsed.detail}`);
265
+ manifest = parsed.manifest;
266
+ },
267
+ },
268
+ {
269
+ id: "apc-json-standard",
270
+ name: `its standard is one these checks know (0.5.0 to ${APC_STANDARD_VERSION})`,
271
+ async run() {
272
+ if (!manifest)
273
+ return "apc.json did not parse";
274
+ expect(compareStandard(manifest.standard, "0.5.0") >= 0, `standard is ${manifest.standard}: apc.json exists since 0.5.0`);
275
+ expect(compareStandard(manifest.standard, APC_STANDARD_VERSION) <= 0, `standard is ${manifest.standard}, newer than the ${APC_STANDARD_VERSION} these checks know: update @tineira/apc-conformance`);
276
+ },
277
+ },
278
+ {
279
+ id: "apc-json-profiles",
280
+ name: "it is an app: its profiles are direct, dock or both, and a Direct app names its connect page on its origin",
281
+ async run() {
282
+ if (!manifest)
283
+ return "apc.json did not parse";
284
+ expect(!manifest.profiles.includes("hub"), "it declares itself a hub (a dock), not an app: run the checks on an app's origin");
285
+ if (manifest.profiles.includes("direct"))
286
+ expect(manifest.direct && new URL(manifest.direct.connect).origin === origin, "direct.connect is not the app's own page");
287
+ },
288
+ },
289
+ {
290
+ id: "grant-declared",
291
+ name: "the grant's MCP URL lies under the origin and mcp.path its apc.json declares",
292
+ run: withGrant(async (g) => {
293
+ if (!manifest)
294
+ return "apc.json did not parse";
295
+ expect(declaresMcpUrl(manifest, g.href), `the grant's MCP URL ${redactGrantUrl(g.href)} is not under ${origin}${manifest.mcp.path} (mcp.path)`);
296
+ }),
297
+ },
298
+ {
299
+ id: "mcp-initialize",
300
+ name: "initialize answers as an MCP server with tools",
301
+ run: withGrant(async (g) => {
302
+ const { value } = await result(g, "initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "apc-conformance", version: APC_STANDARD_VERSION } });
303
+ expect(isObject(value), "initialize has no result");
304
+ expect(typeof value.protocolVersion === "string", "initialize names no protocolVersion");
305
+ expect(isObject(value.capabilities) && isObject(value.capabilities.tools), "initialize offers no tools capability");
306
+ expect(isObject(value.serverInfo) && typeof value.serverInfo.name === "string", "initialize names no serverInfo");
307
+ }),
308
+ },
309
+ {
310
+ id: "mcp-tools-list",
311
+ name: "lists the core tools (get_current_view, wait_for_focus), with their arguments",
312
+ run: withGrant(async (g) => {
313
+ checkCoreToolList((await result(g, "tools/list")).value, CORE_TOOLS);
314
+ }),
315
+ },
316
+ {
317
+ id: "core-get-current-view",
318
+ name: "get_current_view answers view and focus in the core's format, with attention and then the same in the structured result and the text",
319
+ run: withGrant(async (g) => {
320
+ const { r } = await call(g, "get_current_view");
321
+ expect(!r.isError, `get_current_view failed: ${JSON.stringify(r.content).slice(0, 200)}`);
322
+ const d = data(r);
323
+ expect("view" in d && "focus" in d, "the result has view and focus");
324
+ if (d.view !== null)
325
+ checkView(d.view, "get_current_view");
326
+ if (d.focus !== null)
327
+ checkFocusShape(d.focus, "get_current_view");
328
+ const attention = attentionOf(r);
329
+ if (attention !== undefined) {
330
+ expect(isObject(attention) && typeof attention.note === "string", "attention is { focus, note }");
331
+ checkFocusShape(attention.focus, "attention");
332
+ }
333
+ checkThen(r, "get_current_view");
334
+ if (!r.structuredContent)
335
+ return { note: textOnly };
336
+ }),
337
+ },
338
+ {
339
+ id: "core-wait-for-focus",
340
+ name: `wait_for_focus({ timeout_s: 1 }) with no pin times out at 1 s (900 to ${WAIT_LIMIT_MS} ms), or ends at once when no page is open, with a note and no then`,
341
+ run: withGrant(async (g) => {
342
+ // Cut at the limit: a wait that runs past it fails as "no answer", not after the run's own timeout.
343
+ const { r, ms } = await call(g, "wait_for_focus", { timeout_s: 1 }, WAIT_LIMIT_MS);
344
+ expect(!r.isError, `wait_for_focus failed: ${JSON.stringify(r.content).slice(0, 200)}`);
345
+ expect(ms < WAIT_LIMIT_MS, `it came back at ${ms} ms for timeout_s 1, past ${WAIT_LIMIT_MS} ms`);
346
+ const d = data(r);
347
+ if (d.focus !== null && d.focus !== undefined) {
348
+ // The person pinned the agent during the wait: that is the other answer of the core.
349
+ checkFocusShape(d.focus, "wait_for_focus");
350
+ expect(d.timed_out === false, "a wait that got a pin did not time out");
351
+ return;
352
+ }
353
+ expect(d.focus === null, `focus is null when no pin came: ${JSON.stringify(d).slice(0, 200)}`);
354
+ expect(typeof d.note === "string" && d.note.length > 0, "a wait with no pin carries a note for the agent");
355
+ expect(resultKey(r, "then").value === undefined, "a wait that came back empty ends the loop: no then");
356
+ if (d.page_open === false) {
357
+ expect(d.timed_out === false, "a wait ended by a closed page has timed_out false");
358
+ }
359
+ else {
360
+ expect(d.timed_out === true, `a wait with no pin and a page open (or not told) times out: ${JSON.stringify(d).slice(0, 200)}`);
361
+ expect(d.page_open === undefined || d.page_open === true, "page_open is true or left out on a timeout");
362
+ expect(ms >= 900, `it came back at ${ms} ms, before timeout_s 1`);
363
+ }
364
+ }),
365
+ },
366
+ {
367
+ id: "grant-unknown-404",
368
+ name: "an unknown grant answers HTTP 404, told apart from a revoked one (403)",
369
+ run: withGrant(async (g) => {
370
+ const unknown = new URL(g.href);
371
+ const parts = unknown.pathname.split("/");
372
+ const last = parts.findLastIndex((p) => p.length > 0);
373
+ const random = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
374
+ parts[last] = random;
375
+ unknown.pathname = parts.join("/");
376
+ const { status } = await rpc(unknown, "tools/list", {});
377
+ expect(status === 404, `the grant's URL with another secret (its last path segment) answers HTTP ${status}, expected 404`);
378
+ }),
379
+ },
380
+ {
381
+ id: "revoked-answers",
382
+ name: `a revoked grant answers HTTP ${REVOKED_HTTP_STATUS} { error: "revoked", revoked_by, revoked_at } to every method, POST, GET and DELETE`,
383
+ async run() {
384
+ if (!revoked)
385
+ return "give --revoked <mcp-url>: the MCP URL of a grant you revoked in the app";
386
+ if (manifest)
387
+ expect(declaresMcpUrl(manifest, revoked.href), `the revoked grant's MCP URL is not under ${origin}${manifest.mcp.path} (mcp.path)`);
388
+ for (const [method, params] of [
389
+ ["initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "apc-conformance", version: APC_STANDARD_VERSION } }],
390
+ ["tools/list", {}],
391
+ ["tools/call", { name: "get_current_view", arguments: {} }],
392
+ ]) {
393
+ const { status, body } = await rpc(revoked, method, params);
394
+ expect(status === REVOKED_HTTP_STATUS, `${method} on the revoked grant: HTTP ${status}, expected ${REVOKED_HTTP_STATUS}`);
395
+ expect(isRevokedAnswer(body) && body.revoked_by.length > 0 && ISO.test(body.revoked_at), `${method} on the revoked grant answers ${JSON.stringify(body).slice(0, 200)}, not { error: "revoked", revoked_by, revoked_at }`);
396
+ }
397
+ for (const method of ["GET", "DELETE"]) {
398
+ const r = await get(revoked.href, { method, headers: { accept: "application/json, text/event-stream", "mcp-protocol-version": PROTOCOL_VERSION } }, MCP_MAX_BYTES);
399
+ let body = null;
400
+ try {
401
+ body = JSON.parse(r.text);
402
+ }
403
+ catch {
404
+ // not JSON
405
+ }
406
+ expect(r.status === REVOKED_HTTP_STATUS, `${method} on the revoked grant: HTTP ${r.status}, expected ${REVOKED_HTTP_STATUS}`);
407
+ expect(isRevokedAnswer(body), `${method} on the revoked grant answers ${r.text.slice(0, 200)}, not { error: "revoked", ... }`);
408
+ }
409
+ },
410
+ },
411
+ ];
412
+ // Whatever a check says (a quoted body, an echoed URL, a skip reason, a note), never a grant's secret.
413
+ const hide = (s) => redactSecrets(s, [grant, revoked]);
414
+ const safe = checks.map((c) => ({
415
+ ...c,
416
+ async run() {
417
+ try {
418
+ const out = await c.run();
419
+ if (typeof out === "string")
420
+ return hide(out);
421
+ if (out && typeof out === "object")
422
+ return { note: hide(out.note) };
423
+ return out;
424
+ }
425
+ catch (error) {
426
+ throw error instanceof ConformanceError ? new ConformanceError(hide(error.message)) : new Error(hide(error instanceof Error ? error.message : String(error)));
427
+ }
428
+ },
429
+ }));
430
+ return { origin, checks: safe, manifest: () => manifest };
431
+ }
432
+ /** Every HTTP check, one after another, then the in-process ones as skipped: the report. */
433
+ export async function runHttpConformance(options) {
434
+ const { origin, checks, manifest } = build(options);
435
+ const results = [];
436
+ for (const c of checks) {
437
+ const started = Date.now();
438
+ try {
439
+ const out = await c.run();
440
+ if (typeof out === "string")
441
+ results.push({ id: c.id, name: c.name, status: "skip", detail: out });
442
+ else
443
+ results.push({ id: c.id, name: c.name, status: "pass", ...(out ? { note: out.note } : {}), ms: Date.now() - started });
444
+ }
445
+ catch (error) {
446
+ results.push({ id: c.id, name: c.name, status: "fail", detail: error instanceof Error ? error.message : String(error), ms: Date.now() - started });
447
+ }
448
+ }
449
+ for (const c of IN_PROCESS_ONLY)
450
+ results.push({ id: "in-process", name: c.name, status: "skip", detail: `in process only: ${c.why}` });
451
+ const m = manifest();
452
+ const app = m ? { name: m.name, standard: m.standard, profiles: [...m.profiles] } : undefined;
453
+ const count = (s) => results.filter((r) => r.status === s).length;
454
+ return { origin, ...(app ? { app } : {}), checks_standard: APC_STANDARD_VERSION, passed: count("pass"), failed: count("fail"), skipped: count("skip"), results };
455
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The APC conformance suite (APC 0.2, TIN-577; `apc_link` since 0.3, TIN-578): checks that an app
3
+ * implements the core of the standard (`core.ts` in @tineira/apc-protocol), on either profile, through its MCP
4
+ * endpoint, the way an agent or a dock's hub reaches it, and, for an app that offers both profiles,
5
+ * `apc_link` (give `link` in the adapter). What happens in the person's page (the view they report, the pin they drop,
6
+ * the presence the app shows them, Revoke in the agent bar) comes through a small adapter the app
7
+ * provides, since only the app knows its page. Since 0.5 it also reads the app's `apc.json`
8
+ * (`GET {origin}/.well-known/apc.json`, through the adapter's `fetch`): its shape, its origin, the
9
+ * standard, and that its profiles and `mcp.path` match what the app serves. Since 0.6 an adapter may
10
+ * also give `page.mobile` (the page on a phone): the suite then checks that every agent is shown in the
11
+ * page, that the bar cannot be collapsed, and that a finger can pin and unpin with no mouse drag.
12
+ *
13
+ * Framework-free: `apcConformance(app)` returns named checks that throw `ConformanceError`, for any
14
+ * test runner (`for (const c of apcConformance(app)) test(c.name, c.run)` under `node --test`), and
15
+ * `runConformance(app)` runs them all and reports.
16
+ *
17
+ * Against a live app with no adapter, `httpConformance` / `runHttpConformance` (`http.ts`) run the part
18
+ * of the suite that HTTP reaches (apc.json, and with a grant's MCP URL the core tools' shapes and timing,
19
+ * and a revoked grant's answer); the `apc-conformance` command (`cli.ts`) runs them from a terminal.
20
+ */
21
+ import { type ApcProfile, type FocusTarget, type PresenceEvent } from "@tineira/apc-protocol";
22
+ export { ConformanceError } from "./shared.js";
23
+ export { HttpConformanceUsageError, IN_PROCESS_ONLY, httpConformance, redactGrantUrl, redactSecrets, runHttpConformance, type HttpCheck, type HttpCheckResult, type HttpConformanceOptions, type HttpConformanceReport, } from "./http.js";
24
+ /** One agent's way into the app: the MCP URL its holder calls (the agent itself, or a dock's hub) and the app's id for the grant. */
25
+ export interface AgentConnection {
26
+ mcpUrl: string;
27
+ grantId: string;
28
+ }
29
+ /**
30
+ * What the `apc_link` checks need (APC 0.4), from an app that offers both profiles. The suite makes the
31
+ * connections arrive itself: an agent's own `initialize` on Direct, a hub's (naming the agent's client
32
+ * in `_meta`) on Dock.
33
+ */
34
+ export interface ApcLinkAdapter {
35
+ /** A new agent with full access on this path, made the way the profile makes one, with no pin and no `initialize` yet. */
36
+ connect(path: ApcProfile): Promise<AgentConnection>;
37
+ /** The agents the agent bar shows: each entry's grants, two for a linked agent. */
38
+ agents(): Promise<Array<{
39
+ grants: string[];
40
+ }>>;
41
+ /** The person's Unlink in the agent bar; false when the app refuses. */
42
+ unlink(grantId: string): Promise<boolean>;
43
+ /** Makes every code the app gave expire now, as if two minutes passed. Without it the check waits the code out (APC_LINK_TTL_S). */
44
+ expireCodes?(): Promise<void>;
45
+ }
46
+ /**
47
+ * The person's page on a phone (APC 0.6): a viewport of `APC_MOBILE_VIEWPORT` with a touch pointer and no
48
+ * mouse. Optional in the adapter; with it the suite runs the mobile checks. An app's own test can model
49
+ * the page with the pieces of @tineira/apc-web (the bar's collapse rule, tap to pin); the real thing is a browser
50
+ * with touch emulation (`scripts/mobile-e2e.mjs` does that for Pulse and Signals).
51
+ */
52
+ export interface ApcMobilePage {
53
+ /**
54
+ * What the page shows at that viewport, with these agents connected: the grants whose mark is visible in
55
+ * the page and can be pinned from, and whether the person can collapse or hide the agent bar there.
56
+ */
57
+ agentBar(): Promise<{
58
+ visible: string[];
59
+ collapsible: boolean;
60
+ }>;
61
+ /** The person pins with a finger, with no mouse drag: taps the agent's mark, then the target. False when the app refuses the pin. */
62
+ pinByTouch(grantId: string, target: FocusTarget): Promise<boolean>;
63
+ /** The person removes the agent's pin with a finger (the × beside the agent in the bar). False when the page offers no way. */
64
+ unpinByTouch(grantId: string): Promise<boolean>;
65
+ }
66
+ /** What the suite needs from an app. */
67
+ export interface ApcAppAdapter {
68
+ /** The app's name, for the report. */
69
+ name: string;
70
+ profile: ApcProfile;
71
+ /** Sends one HTTP request to the app; the global fetch when left out (a deployed app). An app run in process routes to its handler. */
72
+ fetch?: (request: Request) => Promise<Response>;
73
+ /** A new agent with full access, made the way the profile makes one (a grant in the app, or a pairing through a hub), with no pin yet. */
74
+ connect(): Promise<AgentConnection>;
75
+ /** Two different targets on the person's screen that a full-access agent may be pinned to. */
76
+ targets: readonly [FocusTarget, FocusTarget];
77
+ /** One of the app's own read tools that does not read the pin, with valid arguments: the suite checks that `attention` rides on any result. */
78
+ probe: {
79
+ tool: string;
80
+ args: Record<string, unknown>;
81
+ };
82
+ /** The person's page, as the app sees it. */
83
+ page: {
84
+ /** The person has a page of the app open (true) or none (false). */
85
+ setOpen(open: boolean): void | Promise<void>;
86
+ /** The page reports the screen the person is on. */
87
+ showView(): Promise<void>;
88
+ /** The person drops the agent's mark on a target; false when the app refuses the pin. */
89
+ pin(grantId: string, target: FocusTarget): Promise<boolean>;
90
+ /** The presence events the app published to the person's page since the last call, oldest first. */
91
+ takePresence(): PresenceEvent[];
92
+ /** The person's Revoke in the agent bar; false when the app refuses. */
93
+ revoke(grantId: string): Promise<boolean>;
94
+ /** The page on a phone (APC 0.6). Left out: the mobile checks are skipped. */
95
+ mobile?: ApcMobilePage;
96
+ };
97
+ /** An app that offers both profiles: the `apc_link` checks run too. */
98
+ link?: ApcLinkAdapter;
99
+ }
100
+ export interface ConformanceCheck {
101
+ name: string;
102
+ run(): Promise<void>;
103
+ }
104
+ export interface ConformanceReport {
105
+ app: string;
106
+ profile: ApcProfile;
107
+ passed: number;
108
+ failed: number;
109
+ results: Array<{
110
+ name: string;
111
+ ok: boolean;
112
+ error?: string;
113
+ }>;
114
+ }
115
+ /** The checks of the core, in order. Each makes its own agents, so any one can run alone. */
116
+ export declare function apcConformance(app: ApcAppAdapter): ConformanceCheck[];
117
+ /** Every check, one after another: what passed and what failed, with why. */
118
+ export declare function runConformance(app: ApcAppAdapter): Promise<ConformanceReport>;