@tineira/apc-conformance 0.8.0 → 0.9.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/hub.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ export type StandInHubMode = "ack" | "no_ack" | "redirect" | "error_500";
2
+ export interface StandInPairing {
3
+ id: string;
4
+ agent: string;
5
+ mcp_url: string;
6
+ app_origin: string;
7
+ status: "pending" | "paired";
8
+ /** The code the app sent, raw, until it is typed right; null for an app that sent none. */
9
+ pairing_code: string | null;
10
+ attempts: number;
11
+ }
12
+ export interface StandInHubOptions {
13
+ /** The hub's origin; "https://dock.test" by default. */
14
+ origin?: string;
15
+ /** How the hub reaches the app (its `/apc/revoke` and the grant's MCP URL): in process, the app's own handler. Without it the hub calls nobody. */
16
+ app?: (request: Request) => Promise<Response>;
17
+ /** Milliseconds since the epoch, for tickets; Date.now by default. */
18
+ now?: () => number;
19
+ }
20
+ export interface StandInHub {
21
+ readonly origin: string;
22
+ /** What the app's server calls the hub with (pass it as the app's fetch, or route the hub's origin to it). */
23
+ readonly fetch: typeof fetch;
24
+ /** How the hub answers `/apc/pair`: "ack" (a hub of 0.9) unless a check sets another. */
25
+ mode: StandInHubMode;
26
+ /** A new single-use ticket for this agent, as the dock puts on a drag. */
27
+ ticket(agent: string): string;
28
+ /** The pairings it holds, pending and paired. */
29
+ readonly pairings: StandInPairing[];
30
+ /** Every `/apc/pair` body the app sent, oldest first, whatever the hub answered. */
31
+ readonly received: Array<Record<string, unknown>>;
32
+ /** The MCP URL of the agent's latest pairing. */
33
+ urlOf(agent: string): string | undefined;
34
+ /** The dock owner approves (with what they typed) or denies a pairing. */
35
+ decide(pairingId: string, action: "approve" | "deny", code?: unknown): Promise<{
36
+ ok: boolean;
37
+ error?: string;
38
+ attempts_left?: number;
39
+ }>;
40
+ /** The pairing code's time ran out: every pending pairing with a code goes, its grant given back. */
41
+ expire(): Promise<void>;
42
+ }
43
+ export declare function standInHub(options?: StandInHubOptions): StandInHub;
package/dist/hub.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * A stand-in for a dock's hub (the Dock profile), for testing an app in process: what `@tineira/apc-conformance`
3
+ * checks a Dock app against, and what an app's own tests can pair with. It is faithful to the parts of a hub an
4
+ * app meets, as of APC 0.9:
5
+ *
6
+ * - **Tickets**: `ticket(agent)` mints a single-use ticket for an agent, valid `TICKET_TTL_MS`.
7
+ * - **`POST {hub}/apc/pair`**: checks the body before it spends the ticket (a `pairing_code` present but not a
8
+ * code is `invalid_pairing_code`), keeps the code with the pending pairing and answers the acknowledgement
9
+ * (`pairing_code: "required"`). An app that sends no code still pairs, as a hub in `when_sent` mode lets it.
10
+ * - **`POST {hub}/apc/confirm`**: whether it still pairs that agent with that MCP URL.
11
+ * - **The dock owner's answer** (`decide`): approving a pairing with a code needs that code; wrong ones count, and
12
+ * the last of `PAIRING_CODE_MAX_ATTEMPTS` removes the pairing and gives the grant back
13
+ * (`POST {app}/apc/revoke`); the right one pairs it and introduces the hub to the app (`initialize` on the
14
+ * grant). `expire()` is the pairing code's time running out: every pending pairing with a code goes, its
15
+ * grant given back.
16
+ * - **A broken hub**, for the checks that an app fails closed (`mode`): `"no_ack"` answers a yes without the
17
+ * acknowledgement (a hub from before 0.9), `"redirect"` redirects `/apc/pair` to another host that says yes,
18
+ * `"error_500"` says yes with HTTP 500.
19
+ *
20
+ * It never shows a code: `received` keeps what the app sent, for the checks to compare with what the app showed.
21
+ */
22
+ import { normalizePairingCode, PAIRING_CODE_ACK, PAIRING_CODE_MAX_ATTEMPTS, pairingCodesMatch, TICKET_TTL_MS } from "@tineira/apc-protocol";
23
+ const randomHex = (bytes) => [...crypto.getRandomValues(new Uint8Array(bytes))].map((b) => b.toString(16).padStart(2, "0")).join("");
24
+ export function standInHub(options = {}) {
25
+ const origin = options.origin ?? "https://dock.test";
26
+ const now = options.now ?? Date.now;
27
+ const did = randomHex(16);
28
+ const tickets = new Map();
29
+ const pairings = [];
30
+ const received = [];
31
+ const json = (body, status = 200) => Response.json(body, { status });
32
+ const callApp = async (url, body) => {
33
+ if (!options.app)
34
+ return;
35
+ const response = await options.app(new Request(url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify(body) }));
36
+ await response.body?.cancel().catch(() => { });
37
+ };
38
+ const giveBack = async (p) => {
39
+ pairings.splice(pairings.indexOf(p), 1);
40
+ await callApp(`${p.app_origin}/apc/revoke`, { mcp_url: p.mcp_url });
41
+ };
42
+ const live = (ticket, agent) => {
43
+ const t = typeof ticket === "string" ? tickets.get(ticket) : undefined;
44
+ return !!t && t.agent === agent && t.expires_at > now();
45
+ };
46
+ const pair = (b) => {
47
+ if (typeof b.ticket !== "string" || typeof b.agent !== "string" || typeof b.mcp_url !== "string")
48
+ return json({ ok: false, error: "invalid" }, 400);
49
+ const app = b.app;
50
+ if (typeof app?.name !== "string" || !app.name || typeof app.origin !== "string")
51
+ return json({ ok: false, error: "app_not_allowed" }, 400);
52
+ // The body first: a malformed code leaves the ticket good.
53
+ const code = b.pairing_code === undefined ? null : normalizePairingCode(b.pairing_code);
54
+ if (b.pairing_code !== undefined && !code)
55
+ return json({ ok: false, error: "invalid_pairing_code" }, 400);
56
+ if (!live(b.ticket, b.agent))
57
+ return json({ ok: false, error: "ticket_expired" }, 400);
58
+ tickets.delete(b.ticket);
59
+ const p = { id: `pr_${randomHex(6)}`, agent: b.agent, mcp_url: b.mcp_url, app_origin: app.origin, status: "pending", pairing_code: code, attempts: 0 };
60
+ pairings.push(p);
61
+ return json({ ok: true, pairing_id: p.id, status: "pending", agent: { name: "Agent via dock", icon: null }, ...(code ? { pairing_code: PAIRING_CODE_ACK } : {}) });
62
+ };
63
+ const hub = {
64
+ origin,
65
+ mode: "ack",
66
+ pairings,
67
+ received,
68
+ ticket(agent) {
69
+ const ticket = `${did}.${randomHex(16)}`;
70
+ tickets.set(ticket, { agent, expires_at: now() + TICKET_TTL_MS });
71
+ return ticket;
72
+ },
73
+ urlOf: (agent) => pairings.filter((p) => p.agent === agent).at(-1)?.mcp_url,
74
+ fetch: (async (input, init) => {
75
+ const request = input instanceof Request ? input : new Request(input, init);
76
+ const url = new URL(request.url);
77
+ if (url.origin !== origin || request.method !== "POST")
78
+ return json({ ok: false, error: "not_found" }, 404);
79
+ const body = ((await request.json().catch(() => null)) ?? {});
80
+ if (url.pathname === "/apc/pair") {
81
+ received.push(body);
82
+ if (hub.mode === "redirect") {
83
+ // A fetch that refuses redirects (as an app's must) fails; one that follows lands on another host that says yes.
84
+ if ((init?.redirect ?? request.redirect) === "error")
85
+ throw new TypeError(`redirect from ${request.url} refused`);
86
+ const elsewhere = json({ ok: true, pairing_id: "pr_elsewhere", status: "pending", agent: { name: "Agent via dock", icon: null }, pairing_code: PAIRING_CODE_ACK });
87
+ Object.defineProperty(elsewhere, "redirected", { value: true });
88
+ return elsewhere;
89
+ }
90
+ if (hub.mode === "error_500")
91
+ return json({ ok: true, pairing_id: "pr_500", status: "pending", agent: { name: "Agent via dock", icon: null }, pairing_code: PAIRING_CODE_ACK }, 500);
92
+ const answer = pair(body);
93
+ if (hub.mode === "no_ack" && answer.ok) {
94
+ const rest = (await answer.json());
95
+ delete rest.pairing_code;
96
+ return json(rest);
97
+ }
98
+ return answer;
99
+ }
100
+ if (url.pathname === "/apc/confirm") {
101
+ if (!live(body.ticket, body.agent))
102
+ return json({ ok: false, error: "ticket_expired" }, 400);
103
+ const p = pairings.find((x) => x.agent === body.agent && x.mcp_url === body.mcp_url);
104
+ if (!p)
105
+ return json({ ok: false, error: "not_paired" }, 400);
106
+ tickets.delete(body.ticket);
107
+ return json({ ok: true, status: p.status, agent: { name: "Agent via dock", icon: null } });
108
+ }
109
+ return json({ ok: false, error: "not_found" }, 404);
110
+ }),
111
+ async decide(pairingId, action, code) {
112
+ const p = pairings.find((x) => x.id === pairingId);
113
+ if (!p)
114
+ return { ok: false, error: "unknown_pairing" };
115
+ if (action === "deny") {
116
+ await giveBack(p);
117
+ return { ok: true };
118
+ }
119
+ if (p.status !== "pending")
120
+ return { ok: false, error: "invalid_action" };
121
+ if (p.pairing_code) {
122
+ if (code === undefined || code === null || code === "")
123
+ return { ok: false, error: "pairing_code_required" };
124
+ const given = normalizePairingCode(code);
125
+ if (!given)
126
+ return { ok: false, error: "invalid_pairing_code" };
127
+ if (!pairingCodesMatch(p.pairing_code, given)) {
128
+ p.attempts++;
129
+ if (p.attempts < PAIRING_CODE_MAX_ATTEMPTS)
130
+ return { ok: false, error: "wrong_pairing_code", attempts_left: PAIRING_CODE_MAX_ATTEMPTS - p.attempts };
131
+ await giveBack(p);
132
+ return { ok: false, error: "pairing_code_refused" };
133
+ }
134
+ p.pairing_code = null;
135
+ }
136
+ p.status = "paired";
137
+ // The hub introduces itself to the app, as a real one does on approval.
138
+ await callApp(p.mcp_url, { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "apc-hub", title: "Agent via dock", version: "0.9.0" } } });
139
+ return { ok: true };
140
+ },
141
+ async expire() {
142
+ for (const p of pairings.filter((x) => x.status === "pending" && x.pairing_code))
143
+ await giveBack(p);
144
+ },
145
+ };
146
+ return hub;
147
+ }
package/dist/index.d.ts CHANGED
@@ -8,7 +8,11 @@
8
8
  * (`GET {origin}/.well-known/apc.json`, through the adapter's `fetch`): its shape, its origin, the
9
9
  * standard, and that its profiles and `mcp.path` match what the app serves. Since 0.6 an adapter may
10
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.
11
+ * page, that the bar cannot be collapsed, and that a finger can pin and unpin with no mouse drag. Since 0.9
12
+ * an app on the Dock profile gives `dock` too: the suite pairs it with a stand-in hub (`standInHub`) and checks
13
+ * the pairing code (the consent shows one, new each time, and sends that one to the hub; the app keeps no grant
14
+ * when the hub does not acknowledge the code; wrong codes and the code's time running out give the grant back;
15
+ * the right code pairs).
12
16
  *
13
17
  * Framework-free: `apcConformance(app)` returns named checks that throw `ConformanceError`, for any
14
18
  * test runner (`for (const c of apcConformance(app)) test(c.name, c.run)` under `node --test`), and
@@ -19,7 +23,9 @@
19
23
  * and a revoked grant's answer); the `apc-conformance` command (`cli.ts`) runs them from a terminal.
20
24
  */
21
25
  import { type ApcProfile, type FocusTarget, type PresenceEvent } from "@tineira/apc-protocol";
26
+ import type { StandInHub } from "./hub.js";
22
27
  export { ConformanceError } from "./shared.js";
28
+ export { standInHub, type StandInHub, type StandInHubMode, type StandInHubOptions, type StandInPairing } from "./hub.js";
23
29
  export { HttpConformanceUsageError, IN_PROCESS_ONLY, httpConformance, redactGrantUrl, redactSecrets, runHttpConformance, type HttpCheck, type HttpCheckResult, type HttpConformanceOptions, type HttpConformanceReport, } from "./http.js";
24
30
  /**
25
31
  * One agent's way into the app: the MCP URL its holder calls (the agent itself, or a dock's hub), the
@@ -69,6 +75,26 @@ export interface ApcMobilePage {
69
75
  /** The person removes the agent's pin with a finger (the × beside the agent in the bar). False when the page offers no way. */
70
76
  unpinByTouch(grantId: string): Promise<boolean>;
71
77
  }
78
+ /**
79
+ * The Dock profile's pairing (APC 0.9), from an app on that profile: required of one that claims 0.9.0 or later.
80
+ * The app pairs with `hub`, a stand-in from this package (`standInHub({ app })`, whose `app` routes to the app's
81
+ * handler), which the app's server reaches as its hub.
82
+ */
83
+ export interface ApcDockAdapter {
84
+ hub: StandInHub;
85
+ /**
86
+ * The person drops an agent the app holds no grant for, with a ticket from `hub.ticket(agent)`: the pairing code
87
+ * the app's consent shows, as the person sees it (formatted or not), and their Allow on that consent, with what
88
+ * the app answered the page.
89
+ */
90
+ drop(agent: string, ticket: string): Promise<{
91
+ shown: string;
92
+ allow(): Promise<{
93
+ ok: boolean;
94
+ error?: string;
95
+ }>;
96
+ }>;
97
+ }
72
98
  /** What the suite needs from an app. */
73
99
  export interface ApcAppAdapter {
74
100
  /** The app's name, for the report. */
@@ -102,6 +128,8 @@ export interface ApcAppAdapter {
102
128
  };
103
129
  /** An app that offers both profiles: the `apc_link` checks run too. */
104
130
  link?: ApcLinkAdapter;
131
+ /** The Dock profile's pairing code (APC 0.9): required with `profile: "dock"` of an app that claims 0.9.0 or later. */
132
+ dock?: ApcDockAdapter;
105
133
  }
106
134
  export interface ConformanceCheck {
107
135
  name: string;
package/dist/index.js CHANGED
@@ -8,7 +8,11 @@
8
8
  * (`GET {origin}/.well-known/apc.json`, through the adapter's `fetch`): its shape, its origin, the
9
9
  * standard, and that its profiles and `mcp.path` match what the app serves. Since 0.6 an adapter may
10
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.
11
+ * page, that the bar cannot be collapsed, and that a finger can pin and unpin with no mouse drag. Since 0.9
12
+ * an app on the Dock profile gives `dock` too: the suite pairs it with a stand-in hub (`standInHub`) and checks
13
+ * the pairing code (the consent shows one, new each time, and sends that one to the hub; the app keeps no grant
14
+ * when the hub does not acknowledge the code; wrong codes and the code's time running out give the grant back;
15
+ * the right code pairs).
12
16
  *
13
17
  * Framework-free: `apcConformance(app)` returns named checks that throw `ConformanceError`, for any
14
18
  * test runner (`for (const c of apcConformance(app)) test(c.name, c.run)` under `node --test`), and
@@ -18,9 +22,10 @@
18
22
  * of the suite that HTTP reaches (apc.json, and with a grant's MCP URL the core tools' shapes and timing,
19
23
  * and a revoked grant's answer); the `apc-conformance` command (`cli.ts`) runs them from a terminal.
20
24
  */
21
- import { APC_AGENT_CLIENT_META, APC_JSON_MAX_BYTES, APC_JSON_PATH, APC_MOBILE_VIEWPORT, APC_STANDARD_VERSION, APC_LINK_KEY, APC_LINK_TOOL, APC_LINK_TTL_S, CORE_TOOLS, LINK_REFUSED_ERROR, REVOKED_ERROR, REVOKED_HTTP_STATUS, REVOKED_HTTP_STATUS_BEARER, WAIT_CLAIM_GAP_S, canonicalTarget, compareStandard, declaresMcpUrl, isRevokedAnswer, parseApcManifest, } from "@tineira/apc-protocol";
25
+ import { APC_AGENT_CLIENT_META, APC_JSON_MAX_BYTES, APC_JSON_PATH, APC_MOBILE_VIEWPORT, APC_STANDARD_VERSION, APC_LINK_KEY, APC_LINK_TOOL, APC_LINK_TTL_S, CORE_TOOLS, LINK_REFUSED_ERROR, REVOKED_ERROR, REVOKED_HTTP_STATUS, REVOKED_HTTP_STATUS_BEARER, WAIT_CLAIM_GAP_S, canonicalTarget, compareStandard, declaresMcpUrl, isRevokedAnswer, normalizePairingCode, PAIRING_CODE_MAX_ATTEMPTS, parseApcManifest, } from "@tineira/apc-protocol";
22
26
  import { ConformanceError, ISO, attentionOf, checkCoreToolList, checkFocusShape, checkView, data, expect, isObject, resultKey, rpcBody, standardSupport, text } from "./shared.js";
23
27
  export { ConformanceError } from "./shared.js";
28
+ export { standInHub } from "./hub.js";
24
29
  export { HttpConformanceUsageError, IN_PROCESS_ONLY, httpConformance, redactGrantUrl, redactSecrets, runHttpConformance, } from "./http.js";
25
30
  /** A grant's credential travels in a header (a bearer token), not in its URL: several grants may share the URL (APC 0.8). */
26
31
  const sharesUrl = (conn) => !!conn.headers && Object.keys(conn.headers).some((h) => h.toLowerCase() === "authorization");
@@ -356,9 +361,127 @@ export function apcConformance(app) {
356
361
  await Promise.race([waiting, sleep(11_000)]);
357
362
  })),
358
363
  ...mobileChecks(app, app.page.mobile),
364
+ ...(app.profile === "dock" ? dockChecks(app, app.dock) : []),
359
365
  ...(app.link ? linkChecks(app, app.link) : []),
360
366
  ];
361
367
  }
368
+ /** What a check of a 0.9 rule says of an app that claims an earlier standard: passed, not checked. */
369
+ const NOT_09 = { note: "not checked: the app's apc.json claims a standard before 0.9.0, which does not have the pairing code" };
370
+ /** The standard an app's apc.json claims, read from the origin of one of its grants. */
371
+ async function claimedStandard(app, conn) {
372
+ const request = new Request(`${new URL(conn.mcpUrl).origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
373
+ const response = await (app.fetch ? app.fetch(request) : fetch(request));
374
+ return (await response.json().catch(() => ({}))).standard;
375
+ }
376
+ /**
377
+ * The pairing code (APC 0.9), on the Dock profile: the consent shows a well-formed code, new for every consent,
378
+ * and the app sends that code to the hub; the app keeps no grant unless the hub acknowledges the code with a
379
+ * plain 200; five wrong codes, or the code's time running out, give the grant back; the right code pairs.
380
+ */
381
+ function dockChecks(app, dock) {
382
+ const check = (name, run) => noted(`${app.name} (dock): pairing code: ${name}`, run);
383
+ let n = 0;
384
+ /** A new agent's drop, Allowed: what the consent showed, the app's answer, and the pairing the hub got. */
385
+ const allowNew = async (d) => {
386
+ const agent = `ag_code_${++n}_${Date.now().toString(36)}`;
387
+ const drop = await d.drop(agent, d.hub.ticket(agent));
388
+ const answer = await drop.allow();
389
+ return { agent, shown: drop.shown, answer, sent: d.hub.received.at(-1), pairing: d.hub.pairings.find((p) => p.agent === agent) };
390
+ };
391
+ /** The HTTP status the app gives a grant's MCP URL: 404 once it holds no grant there. */
392
+ const statusOf = async (mcpUrl) => (await send(app, { mcpUrl, grantId: "" }, "initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "apc-conformance", version: "1" } })).status;
393
+ /** Whether the app claims 0.9.0 or later, read through a grant made the usual way. */
394
+ const claims09 = async () => {
395
+ const claimed = await claimedStandard(app, await app.connect());
396
+ return typeof claimed === "string" && compareStandard(claimed, "0.9.0") >= 0;
397
+ };
398
+ return [
399
+ check("the dock adapter is given (required of an app that claims 0.9.0 or later on the Dock profile)", async () => {
400
+ if (dock)
401
+ return;
402
+ const claimed = await claimedStandard(app, await app.connect());
403
+ expect(typeof claimed === "string" && compareStandard(claimed, "0.9.0") < 0, `apc.json claims standard ${String(claimed)}, which requires the pairing code to be checked: give dock in the adapter (ApcDockAdapter: hub, drop)`);
404
+ }),
405
+ check("the consent shows a well-formed code, and the app sends that code, raw, to the hub", async () => {
406
+ if (!dock)
407
+ return;
408
+ if (!(await claims09()))
409
+ return NOT_09;
410
+ const { shown, answer, sent } = await allowNew(dock);
411
+ const code = normalizePairingCode(shown);
412
+ expect(code, `the consent shows ${JSON.stringify(shown)}, not a pairing code (6 characters of Crockford base32)`);
413
+ expect(answer.ok, `the Allow did not pair: ${answer.error}`);
414
+ expect(isObject(sent), "the app sent the hub no /apc/pair");
415
+ expect(sent.pairing_code !== undefined, "the app sent /apc/pair without pairing_code: an app of 0.9 must send the code its consent showed");
416
+ expect(normalizePairingCode(sent.pairing_code) === sent.pairing_code, `the app sent pairing_code ${JSON.stringify(sent.pairing_code)}: send it raw, 6 characters, no dash`);
417
+ expect(sent.pairing_code === code, "the app sent the hub another code than the one its consent showed");
418
+ }),
419
+ check("every consent shows a new code", async () => {
420
+ if (!dock)
421
+ return;
422
+ if (!(await claims09()))
423
+ return NOT_09;
424
+ const a = `ag_two_${++n}`;
425
+ const b = `ag_two_${++n}`;
426
+ const first = normalizePairingCode((await dock.drop(a, dock.hub.ticket(a))).shown);
427
+ const second = normalizePairingCode((await dock.drop(b, dock.hub.ticket(b))).shown);
428
+ expect(first && second, "a consent shows no pairing code");
429
+ expect(first !== second, `two consents showed the same code, ${first}`);
430
+ }),
431
+ check("a hub that does not acknowledge the code, redirects, or says yes with HTTP 500 holds no grant", async () => {
432
+ if (!dock)
433
+ return;
434
+ if (!(await claims09()))
435
+ return NOT_09;
436
+ try {
437
+ for (const mode of ["no_ack", "redirect", "error_500"]) {
438
+ dock.hub.mode = mode;
439
+ const { answer, sent } = await allowNew(dock);
440
+ expect(!answer.ok, `with a hub that answers ${mode}, the Allow says it paired: the app must refuse it`);
441
+ expect(isObject(sent) && typeof sent.mcp_url === "string", `with a hub that answers ${mode}, the app sent no MCP URL to check`);
442
+ const status = await statusOf(sent.mcp_url);
443
+ expect(status === 404, `with a hub that answers ${mode}, the grant the app made still answers (HTTP ${status}): it must be deleted before the app answers its page`);
444
+ }
445
+ }
446
+ finally {
447
+ dock.hub.mode = "ack";
448
+ }
449
+ }),
450
+ check(`${PAIRING_CODE_MAX_ATTEMPTS} wrong codes, or the code's time running out, give the grant back`, async () => {
451
+ if (!dock)
452
+ return;
453
+ if (!(await claims09()))
454
+ return NOT_09;
455
+ const wrong = await allowNew(dock);
456
+ expect(wrong.answer.ok && wrong.pairing, `the Allow did not pair: ${wrong.answer.error}`);
457
+ const code = normalizePairingCode(wrong.shown);
458
+ const other = code.startsWith("A") ? "BBBBBB" : "AAAAAA";
459
+ for (let i = 1; i <= PAIRING_CODE_MAX_ATTEMPTS; i++)
460
+ await dock.hub.decide(wrong.pairing.id, "approve", other);
461
+ expect(!dock.hub.pairings.includes(wrong.pairing), "the stand-in hub kept a pairing after the last wrong code");
462
+ const afterWrong = await statusOf(wrong.pairing.mcp_url);
463
+ expect(afterWrong === 404, `after ${PAIRING_CODE_MAX_ATTEMPTS} wrong codes the hub gave the grant back, and it still answers (HTTP ${afterWrong})`);
464
+ const late = await allowNew(dock);
465
+ expect(late.answer.ok && late.pairing, `the Allow did not pair: ${late.answer.error}`);
466
+ await dock.hub.expire();
467
+ const afterExpiry = await statusOf(late.pairing.mcp_url);
468
+ expect(afterExpiry === 404, `the code's time ran out and the hub gave the grant back, and it still answers (HTTP ${afterExpiry})`);
469
+ }),
470
+ check("the right code pairs, and the grant answers", async () => {
471
+ if (!dock)
472
+ return;
473
+ if (!(await claims09()))
474
+ return NOT_09;
475
+ const { shown, answer, pairing } = await allowNew(dock);
476
+ expect(answer.ok && pairing, `the Allow did not pair: ${answer.error}`);
477
+ const decided = await dock.hub.decide(pairing.id, "approve", shown);
478
+ expect(decided.ok, `the code the consent showed did not approve the pairing: ${decided.error}`);
479
+ const conn = { mcpUrl: pairing.mcp_url, grantId: "" };
480
+ const listed = await send(app, conn, "tools/list", {});
481
+ expect(listed.status === 200, `the paired grant answers tools/list with HTTP ${listed.status}`);
482
+ }),
483
+ ];
484
+ }
362
485
  /** The mobile rule (APC 0.6): on a phone the agents are in the page, never collapsed, and a finger can pin and unpin. */
363
486
  function mobileChecks(app, mobile) {
364
487
  const [first, second] = app.targets;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tineira/apc-conformance",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Agent Pointed Context (APC) conformance: checks an app against the standard, in process with an adapter, or live over HTTP with the apc-conformance command.",
5
5
  "keywords": [
6
6
  "apc",
@@ -45,7 +45,7 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
- "@tineira/apc-protocol": "^0.8.0"
48
+ "@tineira/apc-protocol": "^0.9.0"
49
49
  },
50
50
  "scripts": {},
51
51
  "types": "./dist/index.d.ts",