@tineira/apc-server 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/consent.d.ts CHANGED
@@ -5,18 +5,36 @@ import type { ApcKv } from "./kv.js";
5
5
  * the app's own page. The page cannot be the judge of that: it decides from the state it last loaded, and
6
6
  * a page that missed an update (a revoke, a dock that forgot the pairing) would pair without asking. So the
7
7
  * server decides: asked to pair an agent it holds no grant for, it answers `consent_required` with a nonce
8
- * (`ask`); the page shows its consent with that nonce, and sends it back with the person's Allow; the server
9
- * makes the grant only when it takes the nonce (`take`): once, for the same dock and agent, within
10
- * `DOCK_CONSENT_TTL_MS`.
8
+ * and a pairing code (`ask`); the page shows its consent with both, and sends the nonce back with the
9
+ * person's Allow; the server makes the grant only when it takes the nonce (`takeConsent`): once, for the same
10
+ * dock and agent, within `DOCK_CONSENT_TTL_MS`. Taking it gives back the pairing code (APC 0.9), which the app
11
+ * sends to the hub in `/apc/pair` and the dock owner must type to approve.
11
12
  *
12
13
  * The nonce lives in the app's own storage, reached only through the person's own session (the app's owner
13
14
  * cookie), so it is kept as is: it proves the request went through the page's consent, not who sent it.
14
- * `ask` and `take` are synchronous: in a Durable Object nothing runs between the check and the write.
15
+ * `ask` and `takeConsent` are synchronous: in a Durable Object nothing runs between the check and the write.
15
16
  */
16
17
  /** The answer an app gives when it needs the person's yes on its page first. */
17
18
  export declare const CONSENT_REQUIRED = "consent_required";
18
- /** How long a consent nonce works: longer than a drag's ticket (60 s), which ends the pairing anyway. */
19
+ /** How long a consent nonce and its pairing code work: two minutes, within a drag's ticket (240 s, APC 0.9). */
19
20
  export declare const DOCK_CONSENT_TTL_MS = 120000;
21
+ /** What `ask` gives the page: the nonce to send back with the Allow, and the code to show (raw; format it to show it). */
22
+ export interface ConsentAsk {
23
+ nonce: string;
24
+ pairing_code: string;
25
+ }
26
+ /** What `takeConsent` gives: the code to send to the hub, or nothing (no `true` to test by mistake). */
27
+ export type ConsentTaken = {
28
+ ok: true;
29
+ pairing_code: string;
30
+ } | {
31
+ ok: false;
32
+ };
33
+ /**
34
+ * A new pairing code (APC 0.9): `PAIRING_CODE_LENGTH` characters of Crockford base32, 5 random bits each (a
35
+ * byte masked to 31, which is unbiased since 256 is a multiple of 32): 30 bits. Raw, as sent and stored.
36
+ */
37
+ export declare function newPairingCode(): string;
20
38
  export declare class DockConsent {
21
39
  private kv;
22
40
  private clock;
@@ -24,8 +42,11 @@ export declare class DockConsent {
24
42
  now?: () => number;
25
43
  });
26
44
  private now;
27
- /** A new nonce for the page to show its consent with, for this dock and agent. Expired ones go first. */
28
- ask(hub: string, agent: string): string;
29
- /** True once: the nonce was asked for this dock and agent and has not expired. Used or not, it goes. */
30
- take(nonce: unknown, hub: string, agent: string): boolean;
45
+ /** A new nonce and pairing code for the page to show its consent with, for this dock and agent. Expired ones go first. */
46
+ ask(hub: string, agent: string): ConsentAsk;
47
+ /**
48
+ * Once: the nonce was asked for this dock and agent and has not expired, and here is the pairing code it was
49
+ * shown with, to send to the hub. Used or not, it goes.
50
+ */
51
+ takeConsent(nonce: unknown, hub: string, agent: string): ConsentTaken;
31
52
  }
package/dist/consent.js CHANGED
@@ -1,25 +1,34 @@
1
+ import { PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH } from "@tineira/apc-protocol";
1
2
  /**
2
3
  * Consent in the app, checked by the app's server (the Dock profile, APC 0.7, `core.ts` in @tineira/apc-protocol).
3
4
  * A dock agent dropped on an app that holds no grant for it gets one only after the person allowed it on
4
5
  * the app's own page. The page cannot be the judge of that: it decides from the state it last loaded, and
5
6
  * a page that missed an update (a revoke, a dock that forgot the pairing) would pair without asking. So the
6
7
  * server decides: asked to pair an agent it holds no grant for, it answers `consent_required` with a nonce
7
- * (`ask`); the page shows its consent with that nonce, and sends it back with the person's Allow; the server
8
- * makes the grant only when it takes the nonce (`take`): once, for the same dock and agent, within
9
- * `DOCK_CONSENT_TTL_MS`.
8
+ * and a pairing code (`ask`); the page shows its consent with both, and sends the nonce back with the
9
+ * person's Allow; the server makes the grant only when it takes the nonce (`takeConsent`): once, for the same
10
+ * dock and agent, within `DOCK_CONSENT_TTL_MS`. Taking it gives back the pairing code (APC 0.9), which the app
11
+ * sends to the hub in `/apc/pair` and the dock owner must type to approve.
10
12
  *
11
13
  * The nonce lives in the app's own storage, reached only through the person's own session (the app's owner
12
14
  * cookie), so it is kept as is: it proves the request went through the page's consent, not who sent it.
13
- * `ask` and `take` are synchronous: in a Durable Object nothing runs between the check and the write.
15
+ * `ask` and `takeConsent` are synchronous: in a Durable Object nothing runs between the check and the write.
14
16
  */
15
17
  /** The answer an app gives when it needs the person's yes on its page first. */
16
18
  export const CONSENT_REQUIRED = "consent_required";
17
- /** How long a consent nonce works: longer than a drag's ticket (60 s), which ends the pairing anyway. */
19
+ /** How long a consent nonce and its pairing code work: two minutes, within a drag's ticket (240 s, APC 0.9). */
18
20
  export const DOCK_CONSENT_TTL_MS = 120_000;
19
21
  const PREFIX = "consent:";
20
22
  function randomHex(bytes) {
21
23
  return [...crypto.getRandomValues(new Uint8Array(bytes))].map((b) => b.toString(16).padStart(2, "0")).join("");
22
24
  }
25
+ /**
26
+ * A new pairing code (APC 0.9): `PAIRING_CODE_LENGTH` characters of Crockford base32, 5 random bits each (a
27
+ * byte masked to 31, which is unbiased since 256 is a multiple of 32): 30 bits. Raw, as sent and stored.
28
+ */
29
+ export function newPairingCode() {
30
+ return [...crypto.getRandomValues(new Uint8Array(PAIRING_CODE_LENGTH))].map((b) => PAIRING_CODE_ALPHABET[b & 31]).join("");
31
+ }
23
32
  export class DockConsent {
24
33
  kv;
25
34
  clock;
@@ -30,7 +39,7 @@ export class DockConsent {
30
39
  now() {
31
40
  return this.clock();
32
41
  }
33
- /** A new nonce for the page to show its consent with, for this dock and agent. Expired ones go first. */
42
+ /** A new nonce and pairing code for the page to show its consent with, for this dock and agent. Expired ones go first. */
34
43
  ask(hub, agent) {
35
44
  const now = this.now();
36
45
  for (const [key, raw] of this.kv.list(PREFIX)) {
@@ -43,24 +52,30 @@ export class DockConsent {
43
52
  }
44
53
  }
45
54
  const nonce = `cn_${randomHex(16)}`;
46
- this.kv.put(`${PREFIX}${nonce}`, JSON.stringify({ hub, agent, expires_at: now + DOCK_CONSENT_TTL_MS }));
47
- return nonce;
55
+ const pairing_code = newPairingCode();
56
+ this.kv.put(`${PREFIX}${nonce}`, JSON.stringify({ hub, agent, expires_at: now + DOCK_CONSENT_TTL_MS, pairing_code }));
57
+ return { nonce, pairing_code };
48
58
  }
49
- /** True once: the nonce was asked for this dock and agent and has not expired. Used or not, it goes. */
50
- take(nonce, hub, agent) {
59
+ /**
60
+ * Once: the nonce was asked for this dock and agent and has not expired, and here is the pairing code it was
61
+ * shown with, to send to the hub. Used or not, it goes.
62
+ */
63
+ takeConsent(nonce, hub, agent) {
51
64
  if (typeof nonce !== "string" || !/^cn_[0-9a-f]{32}$/.test(nonce))
52
- return false;
65
+ return { ok: false };
53
66
  const key = `${PREFIX}${nonce}`;
54
67
  const raw = this.kv.get(key);
55
68
  if (!raw)
56
- return false;
69
+ return { ok: false };
57
70
  this.kv.delete(key);
58
71
  try {
59
72
  const row = JSON.parse(raw);
60
- return row.hub === hub && row.agent === agent && row.expires_at > this.now();
73
+ if (row.hub !== hub || row.agent !== agent || row.expires_at <= this.now() || typeof row.pairing_code !== "string")
74
+ return { ok: false };
75
+ return { ok: true, pairing_code: row.pairing_code };
61
76
  }
62
77
  catch {
63
- return false;
78
+ return { ok: false };
64
79
  }
65
80
  }
66
81
  }
@@ -0,0 +1,58 @@
1
+ import { type DockPairRequest } from "@tineira/apc-protocol";
2
+ /**
3
+ * An app's calls to a dock's hub, server to server (the Dock profile; APC 0.9): `pairWithHub` after the
4
+ * person's Allow, `confirmWithHub` on every later drop. Both fail closed. A call never follows a redirect
5
+ * (`redirect: "error"`: the app's hub list is its trust anchor, and a redirect would hand the grant and the
6
+ * pairing code to another host), gives up after `timeoutMs` (10 s by default), and believes only these
7
+ * answers:
8
+ *
9
+ * - **HTTP 200** with `ok: true` and, for a pairing, the acknowledgement `pairing_code: "required"` and a
10
+ * string `pairing_id`; for a confirm, `status` `"pending"` or `"paired"`.
11
+ * - **HTTP 400** with `ok: false` and an `error` in the hub's vocabulary (`/^[a-z_]{1,64}$/`): the hub's
12
+ * own refusal, passed on.
13
+ *
14
+ * Anything else is a refusal of the app's own: `hub_unreachable` (no answer, a time limit, a redirect the
15
+ * runtime refused), `hub_ignores_pairing_code` (a yes without the acknowledgement: a hub that would not check
16
+ * the code), `hub_refused` (any other status, a redirect that came through, JSON that is not an answer).
17
+ * On any refusal after `/apc/pair` the app deletes the grant it made, before it answers its page: a grant is
18
+ * kept only when the hub said it holds the code.
19
+ */
20
+ export interface HubCallOptions {
21
+ /** How the app reaches the hub; the global fetch by default. */
22
+ fetch?: typeof fetch;
23
+ /** Milliseconds before the call gives up: 10 000 by default. */
24
+ timeoutMs?: number;
25
+ }
26
+ /** The agent as the hub names it, checked: strings or null, nothing else. */
27
+ export interface HubAgent {
28
+ name: string | null;
29
+ icon: string | null;
30
+ }
31
+ export type PairWithHubResult = {
32
+ ok: true;
33
+ pairing_id: string;
34
+ status: "pending";
35
+ agent: HubAgent;
36
+ } | {
37
+ ok: false;
38
+ error: string;
39
+ };
40
+ export type ConfirmWithHubResult = {
41
+ ok: true;
42
+ status: "pending" | "paired";
43
+ agent: HubAgent;
44
+ } | {
45
+ ok: false;
46
+ error: string;
47
+ };
48
+ /**
49
+ * `POST {hub}/apc/pair` with the pairing code (APC 0.9). `{ ok: true }` only on HTTP 200 with the
50
+ * acknowledgement; on anything else the caller deletes the grant it made before it answers.
51
+ */
52
+ export declare function pairWithHub(hub: string, body: DockPairRequest, options?: HubCallOptions): Promise<PairWithHubResult>;
53
+ /** `POST {hub}/apc/confirm` on a drop of an agent the app holds a grant for: whether the hub still pairs them. */
54
+ export declare function confirmWithHub(hub: string, body: {
55
+ ticket: string;
56
+ agent: string;
57
+ mcp_url: string;
58
+ }, options?: HubCallOptions): Promise<ConfirmWithHubResult>;
@@ -0,0 +1,69 @@
1
+ import { PAIRING_CODE_ACK } from "@tineira/apc-protocol";
2
+ const HUB_ERROR = /^[a-z_]{1,64}$/;
3
+ const agentOf = (raw) => {
4
+ const a = raw && typeof raw === "object" ? raw : {};
5
+ return { name: typeof a.name === "string" ? a.name : null, icon: typeof a.icon === "string" ? a.icon : null };
6
+ };
7
+ async function post(hub, path, body, options) {
8
+ const send = options.fetch ?? ((...args) => fetch(...args));
9
+ let response;
10
+ try {
11
+ response = await send(`${hub}${path}`, {
12
+ method: "POST",
13
+ headers: { "content-type": "application/json" },
14
+ body: JSON.stringify(body),
15
+ redirect: "error",
16
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
17
+ });
18
+ }
19
+ catch {
20
+ return { error: "hub_unreachable" };
21
+ }
22
+ // A fetch that followed a redirect anyway (or gave the redirect itself back) is not the hub answering.
23
+ if (response.redirected || response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
24
+ await response.body?.cancel().catch(() => { });
25
+ return { error: "hub_refused" };
26
+ }
27
+ try {
28
+ const parsed = (await response.json());
29
+ return { status: response.status, body: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null };
30
+ }
31
+ catch {
32
+ return { status: response.status, body: null };
33
+ }
34
+ }
35
+ /** The hub's own refusal (HTTP 400, `ok: false`, an error in its words), or null. */
36
+ function refusal(a) {
37
+ return a.status === 400 && a.body?.ok === false && typeof a.body.error === "string" && HUB_ERROR.test(a.body.error) ? a.body.error : null;
38
+ }
39
+ /**
40
+ * `POST {hub}/apc/pair` with the pairing code (APC 0.9). `{ ok: true }` only on HTTP 200 with the
41
+ * acknowledgement; on anything else the caller deletes the grant it made before it answers.
42
+ */
43
+ export async function pairWithHub(hub, body, options = {}) {
44
+ const a = await post(hub, "/apc/pair", body, options);
45
+ if ("error" in a)
46
+ return { ok: false, error: a.error };
47
+ const refused = refusal(a);
48
+ if (refused)
49
+ return { ok: false, error: refused };
50
+ if (a.status !== 200 || a.body?.ok !== true)
51
+ return { ok: false, error: "hub_refused" };
52
+ if (a.body.pairing_code !== PAIRING_CODE_ACK)
53
+ return { ok: false, error: "hub_ignores_pairing_code" };
54
+ if (typeof a.body.pairing_id !== "string" || !a.body.pairing_id)
55
+ return { ok: false, error: "hub_refused" };
56
+ return { ok: true, pairing_id: a.body.pairing_id, status: "pending", agent: agentOf(a.body.agent) };
57
+ }
58
+ /** `POST {hub}/apc/confirm` on a drop of an agent the app holds a grant for: whether the hub still pairs them. */
59
+ export async function confirmWithHub(hub, body, options = {}) {
60
+ const a = await post(hub, "/apc/confirm", body, options);
61
+ if ("error" in a)
62
+ return { ok: false, error: a.error };
63
+ const refused = refusal(a);
64
+ if (refused)
65
+ return { ok: false, error: refused };
66
+ if (a.status !== 200 || a.body?.ok !== true || (a.body.status !== "pending" && a.body.status !== "paired"))
67
+ return { ok: false, error: "hub_refused" };
68
+ return { ok: true, status: a.body.status, agent: agentOf(a.body.agent) };
69
+ }
package/dist/index.d.ts CHANGED
@@ -9,3 +9,4 @@ export * from "./direct.js";
9
9
  export * from "./links.js";
10
10
  export * from "./apc-json.js";
11
11
  export * from "./consent.js";
12
+ export * from "./hub-client.js";
package/dist/index.js CHANGED
@@ -9,3 +9,4 @@ export * from "./direct.js";
9
9
  export * from "./links.js";
10
10
  export * from "./apc-json.js";
11
11
  export * from "./consent.js";
12
+ export * from "./hub-client.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tineira/apc-server",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Agent Pointed Context (APC) for an app's server: the MCP tools of the core, the attention envelope, presence, revoke, apc.json, Direct grants, Dock consent and apc_link.",
5
5
  "keywords": [
6
6
  "apc",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@tineira/apc-protocol": "^0.8.0"
43
+ "@tineira/apc-protocol": "^0.9.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@modelcontextprotocol/sdk": "^1.32.1",