a2a-exposed 0.8.21 → 0.8.23

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.
@@ -0,0 +1,129 @@
1
+ // Announce this inbox's address to paired peers, and keep the previous address forwarding for a grace period.
2
+ // The pairing identity private key stays in peer-identity.json (mode 600) or sealed on the Worker. It is never printed.
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import * as C from "./config.mjs";
7
+ import { CLI, die, httpJson } from "./a2a.mjs";
8
+ import { baseUrl } from "./commands.mjs";
9
+
10
+ const IDENTITY_FILE = () => path.join(C.CONFIG_DIR, "peer-identity.json");
11
+
12
+ function b64url(buf) {
13
+ return Buffer.from(buf).toString("base64url");
14
+ }
15
+
16
+ /** Local Ed25519 key, created on first use. The file is mode 600. */
17
+ export function loadOrCreateLocalIdentity() {
18
+ const file = IDENTITY_FILE();
19
+ try {
20
+ const cur = JSON.parse(fs.readFileSync(file, "utf8"));
21
+ if (cur && typeof cur.publicKey === "string" && typeof cur.privateKey === "string" && cur.publicKey && cur.privateKey)
22
+ return { publicKey: cur.publicKey, privateKey: cur.privateKey };
23
+ } catch { /* create below */ }
24
+ const kp = crypto.generateKeyPairSync("ed25519");
25
+ // JWK `x` is the raw 32-byte public key. PKCS8 is what the Worker imports. Both are base64url.
26
+ const publicKey = kp.publicKey.export({ format: "jwk" }).x;
27
+ const privateKey = b64url(kp.privateKey.export({ type: "pkcs8", format: "der" }));
28
+ const ident = { publicKey, privateKey };
29
+ if (!ident.publicKey || !ident.privateKey) die("could not create a pairing identity");
30
+ fs.mkdirSync(C.CONFIG_DIR, { recursive: true, mode: 0o700 });
31
+ fs.writeFileSync(file, JSON.stringify(ident, null, 2) + "\n", { mode: 0o600 });
32
+ try { fs.chmodSync(file, 0o600); } catch { /* the write mode is already 600 */ }
33
+ return ident;
34
+ }
35
+
36
+ async function ownerCall(method, p, body) {
37
+ const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
38
+ if (!base || !tok) return null;
39
+ return httpJson(base + p, { method, body, headers: { authorization: `Bearer ${tok}` } });
40
+ }
41
+
42
+ const ownerError = (r) => (r.data && typeof r.data === "object" && typeof r.data.error === "string" ? r.data.error : `HTTP ${r.status}`);
43
+
44
+ /** Public key to name in a pairing request. Prefers the key this inbox already serves on its card. */
45
+ export async function publicIdentityForConnect() {
46
+ let local = null;
47
+ const ensureLocal = () => local || (local = loadOrCreateLocalIdentity());
48
+ const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
49
+ if (!base || !tok) return ensureLocal().publicKey;
50
+ let got;
51
+ try { got = await ownerCall("GET", "/owner/identity"); }
52
+ catch { return ensureLocal().publicKey; }
53
+ if (!got) return ensureLocal().publicKey;
54
+ if (got.status === 200 && got.data && typeof got.data.publicKey === "string" && got.data.publicKey) {
55
+ const file = IDENTITY_FILE();
56
+ if (fs.existsSync(file)) {
57
+ try {
58
+ const cur = JSON.parse(fs.readFileSync(file, "utf8"));
59
+ if (cur.publicKey && cur.publicKey !== got.data.publicKey)
60
+ console.error("note: the local pairing identity differs from this A2A endpoint. connect uses the endpoint's identity, which is the one its card can prove.");
61
+ } catch { /* unreadable local file: the endpoint key still wins */ }
62
+ }
63
+ return got.data.publicKey;
64
+ }
65
+ if (got.status === 200 && got.data && got.data.publicKey == null) {
66
+ const ident = ensureLocal();
67
+ try {
68
+ const put = await ownerCall("PUT", "/owner/identity", { publicKey: ident.publicKey, privateKey: ident.privateKey });
69
+ if (put && put.status === 200 && put.data && put.data.publicKey) return put.data.publicKey;
70
+ } catch { /* the request still carries the local public key */ }
71
+ return ident.publicKey;
72
+ }
73
+ return ensureLocal().publicKey;
74
+ }
75
+
76
+ async function requireIdentityOnWorker() {
77
+ const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
78
+ if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
79
+ let got;
80
+ try { got = await ownerCall("GET", "/owner/identity"); }
81
+ catch (e) { die(`could not reach this A2A endpoint: ${e?.message || e}`); }
82
+ if (!got || got.status === 404) die(`this A2A endpoint's Worker predates address announcements: run \`${CLI} deploy\` first`);
83
+ if (got.status !== 200) die(`could not read the pairing identity: ${ownerError(got)}`);
84
+ if (got.data && got.data.publicKey) return got.data.publicKey;
85
+ const ident = loadOrCreateLocalIdentity();
86
+ const put = await ownerCall("PUT", "/owner/identity", { publicKey: ident.publicKey, privateKey: ident.privateKey });
87
+ if (!put || put.status !== 200) die(`could not install the pairing identity: ${put ? ownerError(put) : "no answer"}`);
88
+ return put.data.publicKey;
89
+ }
90
+
91
+ export async function announce(o = {}) {
92
+ if (!!o.to === !!o.all) die(`usage: ${CLI} peer announce (--to <alias> | --all) [--reissue] [--json]`);
93
+ if (o.reissue && (o.all || !o.to)) die("--reissue needs --to <alias>");
94
+ if (o.to !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(o.to)) die("--to must be a peer alias");
95
+ await requireIdentityOnWorker();
96
+ const r = await ownerCall("POST", "/owner/peers/announce", o.all ? { all: true } : { to: o.to, ...(o.reissue ? { reissue: true } : {}) });
97
+ if (!r || r.status !== 200) die(`could not announce this address: ${r ? ownerError(r) : "no answer"}`);
98
+ const results = Array.isArray(r.data.results) ? r.data.results : [];
99
+ if (results.length === 1 && results[0].alias === "*") die(results[0].detail || "could not announce this address");
100
+ if (o.json) return console.log(JSON.stringify({ results }));
101
+ if (!results.length) return console.log("No synced peer to announce to. Run peers sync after connect.");
102
+ for (const row of results) {
103
+ const host = row.host ? ` (${row.host})` : "";
104
+ console.log(`${row.alias}${host}: ${row.status}${row.detail ? ` — ${row.detail}` : ""}`);
105
+ }
106
+ }
107
+
108
+ export async function forward(o = {}) {
109
+ const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
110
+ if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
111
+ if (o.off) {
112
+ const r = await ownerCall("DELETE", "/owner/address-forward");
113
+ if (!r || r.status === 404) die(`this A2A endpoint's Worker predates address forwarding: run \`${CLI} deploy\` first`);
114
+ if (r.status !== 200) die(`could not clear the grace forward: ${ownerError(r)}`);
115
+ return console.log("grace forward cleared");
116
+ }
117
+ if (!o.to) die(`usage: ${CLI} peer forward --to <card-url> [--hours N] | ${CLI} peer forward --off`);
118
+ let hours;
119
+ if (o.hours !== undefined) {
120
+ if (!/^\d{1,4}$/.test(String(o.hours))) die("--hours must be a whole number from 1 to 720 (default 168)");
121
+ hours = Number(o.hours);
122
+ }
123
+ const r = await ownerCall("PUT", "/owner/address-forward", { cardUrl: o.to, ...(hours ? { hours } : {}) });
124
+ if (!r || r.status === 404) die(`this A2A endpoint's Worker predates address forwarding: run \`${CLI} deploy\` first`);
125
+ if (r.status !== 200) die(`could not set the grace forward: ${ownerError(r)}`);
126
+ const until = r.data.until || "";
127
+ if (o.json) return console.log(JSON.stringify({ cardUrl: r.data.cardUrl, until }));
128
+ console.log(`grace forward to ${r.data.cardUrl}${until ? ` until ${until}` : ""}`);
129
+ }
package/lib/commands.mjs CHANGED
@@ -152,7 +152,8 @@ export async function contexts() {
152
152
  export async function token(action, label, o) {
153
153
  if (action === "list") {
154
154
  for (const r of await owner("GET", "/owner/peers")) {
155
- const via = r.source === "pairing" ? `\tvia pairing: code ${r.user_code || "?"}${r.client_name ? `, ${JSON.stringify(r.client_name)}` : ""}` : "";
155
+ const via = r.source === "pairing" ? `\tvia pairing: code ${r.user_code || "?"}${r.client_name ? `, ${JSON.stringify(r.client_name)}` : ""}`
156
+ : r.source === "reverse" ? "\tvia reverse pairing (return token; revoke ends the return path)" : "";
156
157
  console.log(`${r.label}\tcreated=${r.created_at}\t${r.revoked_at ? "REVOKED " + r.revoked_at : "active"}${via}`);
157
158
  }
158
159
  // MCP connectors (Worker 0.5+): their own tokens, listed and revoked by label like peer tokens
package/lib/main.mjs CHANGED
@@ -7,6 +7,7 @@ import * as dep from "./deploy.mjs";
7
7
  import * as tun from "./tunnel.mjs";
8
8
  import * as st from "./status.mjs";
9
9
  import * as pair from "./pair.mjs";
10
+ import * as address from "./address.mjs";
10
11
  import * as account from "./account.mjs";
11
12
  import * as upd from "./update.mjs";
12
13
  import * as fb from "./feedback.mjs";
@@ -181,6 +182,7 @@ Inbox (owner side)
181
182
  working <taskId> mark task working
182
183
  reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
183
184
  [--artifact] [--artifact-name N] [--force]
185
+ delivery waits at most 18 s; identical retries read the first result
184
186
  history <contextId> [--from PEER] [-n 50] [--json]
185
187
  conversation log. --from is one peer (the wake hint includes it)
186
188
  contexts recent conversations
@@ -235,6 +237,14 @@ Outbound (agents you call)
235
237
  peers sync [alias...] | peers unsync <alias>
236
238
  upload peers (URL + token, encrypted on the Worker) so the MCP connector can send to
237
239
  them; default: all peers. Re-run after connect, token changes or an owner-token rotation
240
+ peer announce (--to <alias> | --all) [--reissue] [--json]
241
+ tell paired peers this inbox's current card URL. They switch only after the card
242
+ proves the same pairing identity. A peer that does not support this keeps the
243
+ current pairing. A revoked return token stays revoked unless --to and --reissue.
244
+ The return token is not printed
245
+ peer forward --to <card-url> [--hours N] | peer forward --off
246
+ on the old inbox, forward direct replies and answer with a moved pointer for N hours
247
+ (default 168, max 720)
238
248
  a token variable set in the environment overrides config.env; when a peer token
239
249
  differs between the two, connect/send/poll/peers/status warn (values never shown)
240
250
  send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
@@ -324,6 +334,7 @@ const SPEC = {
324
334
  history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
325
335
  token: { rotate: B },
326
336
  peers: { "token-env": S, "token-stdin": B },
337
+ peer: { to: S, all: B, reissue: B, hours: S, off: B, json: B },
327
338
  send: { to: S, text: S, context: S, task: S, push: B, proto: S },
328
339
  poll: { to: S, proto: S },
329
340
  status: { json: B },
@@ -432,6 +443,12 @@ export async function main(argv) {
432
443
  case "export": return cmd.exportInbox(o);
433
444
  case "token": return cmd.token(need(p[0], "token issue|list|revoke|rotate [label]"), p[1], o);
434
445
  case "peers": return cmd.peers(p[0] || "list", p.slice(1), o);
446
+ case "peer": {
447
+ const sub = need(p[0], "peer announce (--to <alias> | --all) | peer forward --to <card-url> [--hours N] | peer forward --off");
448
+ if (sub === "announce") return address.announce(o);
449
+ if (sub === "forward") return address.forward(o);
450
+ throw new CliError(`unknown peer action ${sub} (announce|forward)`);
451
+ }
435
452
  case "signup":
436
453
  case "login": {
437
454
  const result = await account.deviceLogin({ controlUrl: o["control-url"] || C.get("CONTROL_URL"), intent: name });
package/lib/pair.mjs CHANGED
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import * as C from "./config.mjs";
8
8
  import { CLI, describeHttp, die, fetchCard, httpJson, isPrivateHost, pickEndpoint } from "./a2a.mjs";
9
9
  import { baseUrl, peerToken, warnPeerTokenEnv } from "./commands.mjs";
10
+ import { publicIdentityForConnect } from "./address.mjs";
10
11
 
11
12
  export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
12
13
  export const PBKDF2_ITERATIONS = 100000; // default (the Workers runtime's maximum); the Worker advertises its own setting
@@ -168,6 +169,8 @@ export async function list(o) {
168
169
  for (const p of d.pending) {
169
170
  console.log(`--- ${p.userCode} ${JSON.stringify(p.clientName || p.clientId || "(no name)")} (claimed, untrusted) expires ${p.expiresAt}${p.replacesLabel ? ` replaces the active token "${p.replacesLabel}"` : ""}`);
170
171
  console.log(` card: ${p.agentCardUrl || "-"} from: ${[p.ip || "?", p.country].filter(Boolean).join(", ")}`);
172
+ if (p.identityFingerprint) console.log(` identity: ${p.identityFingerprint}`);
173
+ if (!p.agentCardUrl) console.log(" no callback address yet: replies stay on the task until this agent announces one");
171
174
  console.log(` link: ${p.verificationUriComplete}`);
172
175
  }
173
176
  console.log(`Ask your human about each request; never approve on your own.`);
@@ -356,6 +359,8 @@ A different agent at the same URL? Pass another --alias.`);
356
359
  // deployment exists) wins. With no override, read this deployment's card, including an http one.
357
360
  const nameUrl = sentCard || (!o["card-url"] && /^https?:\/\//.test(own || "") ? `${own.replace(/\/$/, "")}/.well-known/agent-card.json` : "");
358
361
  const params = { client_name: pairingClientName({ explicit: o.name, cardName: await readCardName(nameUrl), agentName: C.get("A2A_AGENT_NAME"), workerName: C.get("A2A_WORKER_NAME"), ownUrl: own }), client_id: C.get("A2A_WORKER_NAME") || alias };
362
+ const identity = await publicIdentityForConnect();
363
+ if (identity) params.agent_identity = identity;
359
364
  if (sentCard) params.agent_card_url = sentCard;
360
365
  // replacing: present the old token, so a peer that supports it swaps the token under the same label (no orphan)
361
366
  const r = await postForm(ep.device, params, replace && oldToken ? oldToken : null);
package/lib/status.mjs CHANGED
@@ -126,6 +126,11 @@ export function nextStep(s) {
126
126
  if (s.workerVersion && s.latestVersion && newer(s.latestVersion, s.workerVersion) && !newer(s.workerVersion, version))
127
127
  n.also.push(`a2a-exposed ${s.latestVersion} is available: run npx -y a2a-exposed@latest deploy`);
128
128
  const sh = (s.peerTokenShadowed || []).map((k) => `${k} is set in the environment and overrides the different peer token saved in ${s.configFile} (e.g. a stale value from before \`connect\` stored a new one): send/poll use the environment value. Unset ${k} where it is set to use the saved token`);
129
+ const addr = s.addresses;
130
+ if (addr && Array.isArray(addr.announcePending) && addr.announcePending.length)
131
+ n.also.push(`an address announcement is still pending: run \`${CLI} peer announce --all\``);
132
+ if (addr && Array.isArray(addr.inboundNoAddress) && addr.inboundNoAddress.length)
133
+ n.also.push("paired peers with no callback keep replies on the task until they announce an address");
129
134
  return sh.length ? { ...n, also: [...n.also, ...sh] } : n;
130
135
  }
131
136
 
@@ -238,7 +243,7 @@ export async function collect() {
238
243
  card: { ok: false, error: "not checked" }, ownerApi: { ok: false, error: "not checked" }, wake: null, tunnel: null, zones: null, pairing: null,
239
244
  upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null, upstreamVerify: null, upstreamVerifyError: "", facadePeerAuth: null,
240
245
  upstreamNoToken: C.get("A2A_UPSTREAM_NO_TOKEN") === "1", localUpstreamToken: fingerprint(process.env.UPSTREAM_TOKEN),
241
- peerTokenShadowed: shadowedPeerTokens(),
246
+ peerTokenShadowed: shadowedPeerTokens(), addresses: null,
242
247
  mcp: "unknown", health: null, workerVersion: null, cliVersion: D.VERSION, latestVersion: readCache(cacheFile())?.latest || null,
243
248
  };
244
249
  const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
@@ -263,6 +268,10 @@ export async function collect() {
263
268
  ...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}),
264
269
  ...(typeof pr.manageUrl === "string" && pr.manageUrl ? { manageUrl: pr.manageUrl } : {}) };
265
270
  } catch { /* older Worker (no pairing yet): leave unknown */ }
271
+ try {
272
+ const ad = await ownerTry("GET", "/owner/address");
273
+ if (ad.status === 200 && ad.data && typeof ad.data === "object") s.addresses = ad.data;
274
+ } catch { /* older Worker: no address lifecycle */ }
266
275
  if (s.upstream) {
267
276
  try { s.facade = await owner("GET", "/owner/facade"); }
268
277
  catch (e) { s.facade = { mode: "unknown", error: e.message }; }
@@ -378,6 +387,16 @@ export async function status(o) {
378
387
  rows.push(["tunnel", !t ? "none" : `${t.wakeUrl} -> ${t.origin || "?"}; ${!t.complete ? "INCOMPLETE" : t.tokenFileOk ? "set up" : "set up, but the connector token file is MISSING"}` +
379
388
  (t.connections != null ? `; ${tunnelStateLabel(t.state)}, ${t.connections} connector connection(s)` : t.error ? `; state unknown (${t.error})` : "")]);
380
389
  if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
390
+ const addr = s.addresses;
391
+ if (addr) {
392
+ const bits = [];
393
+ if (Array.isArray(addr.inboundNoAddress) && addr.inboundNoAddress.length)
394
+ bits.push(`${addr.inboundNoAddress.length} paired peer(s) with no callback (${addr.inboundNoAddress.map((x) => x.label).join(", ")})`);
395
+ if (Array.isArray(addr.announcePending) && addr.announcePending.length)
396
+ bits.push(`announce still pending for ${addr.announcePending.map((x) => x.alias).join(", ")}`);
397
+ if (addr.forward && addr.forward.active) bits.push(`grace forward until ${addr.forward.until}`);
398
+ if (bits.length) rows.push(["addresses", bits.join("; ")]);
399
+ }
381
400
  const pg = s.pairing;
382
401
  if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"
383
402
  : `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.oidcIssuer ? `; OpenID Connect (${pg.oidcIssuer})` : ""}${pg.manageUrl ? `; dashboard ${pg.manageUrl}` : ""}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.21",
3
+ "version": "0.8.23",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,10 @@
1
+ -- Pairing identity and the last verified callback address. Existing rows stay valid: every new column is nullable,
2
+ -- so a peer paired before this migration keeps its token and has no callback until it announces one.
3
+ ALTER TABLE peers ADD COLUMN identity_key TEXT;
4
+ ALTER TABLE peers ADD COLUMN card_url TEXT;
5
+ ALTER TABLE peers ADD COLUMN previous_card_url TEXT;
6
+ ALTER TABLE peers ADD COLUMN previous_until_ms INTEGER;
7
+ -- Sealed copy of a return token this inbox minted (source = 'reverse'). Null for every other peer.
8
+ -- The owner API never selects this column. Export omits it.
9
+ ALTER TABLE peers ADD COLUMN token_enc TEXT;
10
+ ALTER TABLE device_requests ADD COLUMN identity_key TEXT;
@@ -0,0 +1,3 @@
1
+ -- Who created an outbound peer. NULL is an owner upload (`peers sync`). `announce` is a row this
2
+ -- inbox created from a verified PeerAddressUpdate. An announcement must not repoint any other row.
3
+ ALTER TABLE outbound_peers ADD COLUMN source TEXT;
@@ -0,0 +1,220 @@
1
+ // Pairing identity and address proofs. Web Crypto only, so this runs in Workers and in Node's test runner.
2
+ // The private key never appears in a return value of the HTTP handlers. Callers seal it before storage.
3
+
4
+ export const IDENTITY_URI = "https://a2a.exposed/ext/peer-identity/v1";
5
+ export const MOVED_URI = "https://a2a.exposed/ext/moved/v1";
6
+ export const MOVED_CODE = -32011;
7
+ export const DEFAULT_GRACE_HOURS = 168;
8
+ export const MAX_GRACE_HOURS = 720;
9
+ export const IDENTITY_SETTING = "peer_identity";
10
+ export const FORWARD_SETTING = "address_forward";
11
+ export const ANNOUNCE_SETTING = "announce_status";
12
+ /** Additional data for the sealed identity blob. Not a peer label. */
13
+ export const IDENTITY_AAD = "peer-identity";
14
+
15
+ const IDENTITY_TEXT = "a2a-exposed-peer-identity-v1";
16
+ const UPDATE_TEXT = "a2a-exposed-address-update-v1";
17
+
18
+ export function b64urlEncode(b: Uint8Array): string {
19
+ let s = "";
20
+ for (const byte of b) s += String.fromCharCode(byte);
21
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
22
+ }
23
+
24
+ export function b64urlDecode(s: string): Uint8Array | null {
25
+ if (typeof s !== "string" || !/^[A-Za-z0-9_-]{1,512}$/.test(s)) return null;
26
+ try {
27
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
28
+ const raw = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
29
+ const out = new Uint8Array(raw.length);
30
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
31
+ return out;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Canonical base64url of a raw 32-byte Ed25519 public key, or "". */
38
+ export function cleanPublicKey(v: unknown): string {
39
+ if (typeof v !== "string") return "";
40
+ const raw = b64urlDecode(v.trim());
41
+ return raw && raw.length === 32 ? b64urlEncode(raw) : "";
42
+ }
43
+
44
+ /** A peer token the caller grants this inbox, so this inbox can call the new address back. Anything else is refused. */
45
+ export function cleanReverseToken(v: unknown): string {
46
+ return typeof v === "string" && /^(?:a2aow_|s2a_)[A-Za-z0-9_-]{43}$/.test(v) ? v : "";
47
+ }
48
+
49
+ /** https agent-card URL, no query, no hash, no credentials. */
50
+ export function canonicalCardUrl(v: unknown): string {
51
+ if (typeof v !== "string" || !v || v.length > 300) return "";
52
+ try {
53
+ const u = new URL(v);
54
+ if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) return "";
55
+ if (u.pathname !== "/.well-known/agent-card.json") return "";
56
+ return u.href;
57
+ } catch {
58
+ return "";
59
+ }
60
+ }
61
+
62
+ /** Card URL for this deployment. Empty unless the public URL is https. */
63
+ export function cardUrlFor(publicUrl: string): string {
64
+ try {
65
+ const u = new URL(publicUrl);
66
+ if (u.protocol !== "https:") return "";
67
+ return new URL("/.well-known/agent-card.json", u.origin).href;
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
72
+
73
+ export function originOf(url: string): string {
74
+ try { return new URL(url).origin; } catch { return ""; }
75
+ }
76
+
77
+ export function hostOf(url: string): string {
78
+ try { return new URL(url).host; } catch { return ""; }
79
+ }
80
+
81
+ /** Grace length in hours. `undefined` is the default. Anything else outside 1–720 is refused (`null`). */
82
+ export function graceHours(v: unknown): number | null {
83
+ if (v === undefined || v === null || v === "") return DEFAULT_GRACE_HOURS;
84
+ const n = typeof v === "number" ? v : typeof v === "string" && /^\d{1,4}$/.test(v) ? Number(v) : NaN;
85
+ if (!Number.isInteger(n) || n < 1 || n > MAX_GRACE_HOURS) return null;
86
+ return n;
87
+ }
88
+
89
+ export function identityMessage(cardUrl: string, publicKey: string): Uint8Array {
90
+ return new TextEncoder().encode(`${IDENTITY_TEXT}\n${cardUrl}\n${publicKey}`);
91
+ }
92
+
93
+ export function updateMessage(cardUrl: string, publicKey: string, reverseHash: string): Uint8Array {
94
+ return new TextEncoder().encode(`${UPDATE_TEXT}\n${cardUrl}\n${publicKey}\n${reverseHash || "-"}`);
95
+ }
96
+
97
+ const ed25519 = { name: "Ed25519" };
98
+
99
+ export async function generateIdentity(): Promise<{ publicKey: string; privateKey: string }> {
100
+ const kp = await crypto.subtle.generateKey(ed25519 as any, true, ["sign", "verify"]) as CryptoKeyPair;
101
+ const pub = new Uint8Array(await (crypto.subtle.exportKey as any)("raw", kp.publicKey));
102
+ const priv = new Uint8Array(await (crypto.subtle.exportKey as any)("pkcs8", kp.privateKey));
103
+ return { publicKey: b64urlEncode(pub), privateKey: b64urlEncode(priv) };
104
+ }
105
+
106
+ async function importPrivate(pkcs8: string): Promise<CryptoKey | null> {
107
+ const raw = b64urlDecode(pkcs8);
108
+ if (!raw) return null;
109
+ try {
110
+ return await crypto.subtle.importKey("pkcs8", raw, ed25519 as any, false, ["sign"]);
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ async function importPublic(publicKey: string): Promise<CryptoKey | null> {
117
+ const raw = b64urlDecode(publicKey);
118
+ if (!raw || raw.length !== 32) return null;
119
+ try {
120
+ return await crypto.subtle.importKey("raw", raw, ed25519 as any, false, ["verify"]);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ export async function signBytes(privateKey: string, message: Uint8Array): Promise<string | null> {
127
+ const key = await importPrivate(privateKey);
128
+ if (!key) return null;
129
+ try {
130
+ return b64urlEncode(new Uint8Array(await crypto.subtle.sign(ed25519 as any, key, message as BufferSource)));
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ export async function verifyBytes(publicKey: string, message: Uint8Array, signature: string): Promise<boolean> {
137
+ const key = await importPublic(publicKey);
138
+ const sig = b64urlDecode(signature);
139
+ if (!key || !sig) return false;
140
+ try {
141
+ return await crypto.subtle.verify(ed25519 as any, key, sig as BufferSource, message as BufferSource);
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /** True when this private key produces signatures this public key accepts. */
148
+ export async function identityMatches(privateKey: string, publicKey: string): Promise<boolean> {
149
+ const clean = cleanPublicKey(publicKey);
150
+ if (!clean) return false;
151
+ const sig = await signBytes(privateKey, new TextEncoder().encode("a2a-exposed-identity-check-v1"));
152
+ return !!sig && await verifyBytes(clean, new TextEncoder().encode("a2a-exposed-identity-check-v1"), sig);
153
+ }
154
+
155
+ export function identityExtension(publicKey: string, cardUrl: string, signature: string): JsonExt {
156
+ return {
157
+ uri: IDENTITY_URI,
158
+ description: "Proves this card URL is served by this pairing identity",
159
+ params: { publicKey, cardUrl, signature },
160
+ };
161
+ }
162
+
163
+ export function movedExtension(cardUrl: string, until: string): JsonExt {
164
+ return {
165
+ uri: MOVED_URI,
166
+ description: "This inbox answers with a moved pointer until the grace time, and forwards a direct reply when the new inbox accepts it",
167
+ params: { cardUrl, until },
168
+ };
169
+ }
170
+
171
+ type JsonExt = { uri: string; description: string; params: Record<string, string> };
172
+
173
+ export function extensionParams(card: unknown, uri: string): Record<string, unknown> | null {
174
+ const caps = card && typeof card === "object" ? (card as { capabilities?: { extensions?: unknown } }).capabilities : null;
175
+ const list = caps?.extensions;
176
+ if (!Array.isArray(list)) return null;
177
+ for (const item of list) {
178
+ if (!item || typeof item !== "object") continue;
179
+ const ext = item as { uri?: unknown; params?: unknown };
180
+ if (ext.uri === uri && ext.params && typeof ext.params === "object" && !Array.isArray(ext.params))
181
+ return ext.params as Record<string, unknown>;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ export function movedFromCard(card: unknown, now = Date.now()): { cardUrl: string; until: string } | null {
187
+ const params = extensionParams(card, MOVED_URI);
188
+ if (!params) return null;
189
+ const cardUrl = canonicalCardUrl(params.cardUrl);
190
+ const until = typeof params.until === "string" ? params.until : "";
191
+ const untilMs = Date.parse(until);
192
+ if (!cardUrl || !Number.isFinite(untilMs) || untilMs <= now) return null;
193
+ return { cardUrl, until };
194
+ }
195
+
196
+ /** The card's identity proof, when the public key and signature match this card URL. */
197
+ export async function cardProves(card: unknown, publicKey: string, cardUrl: string): Promise<boolean> {
198
+ const want = cleanPublicKey(publicKey);
199
+ const url = canonicalCardUrl(cardUrl);
200
+ if (!want || !url) return false;
201
+ const params = extensionParams(card, IDENTITY_URI);
202
+ if (!params) return false;
203
+ if (cleanPublicKey(params.publicKey) !== want) return false;
204
+ if (canonicalCardUrl(params.cardUrl) !== url) return false;
205
+ if (typeof params.signature !== "string") return false;
206
+ return verifyBytes(want, identityMessage(url, want), params.signature);
207
+ }
208
+
209
+ export type AnnounceStatus = "updated" | "unsupported" | "rejected" | "failed";
210
+
211
+ /** What an address announcement got back. Method-not-found keeps the current pairing. */
212
+ export function announceOutcome(status: number, body: unknown): { status: AnnounceStatus; detail: string } {
213
+ const obj = body && typeof body === "object" ? body as { result?: { ok?: unknown }; error?: { code?: unknown; message?: unknown } } : null;
214
+ if (status === 200 && obj?.result?.ok === true) return { status: "updated", detail: "Peer stored the address." };
215
+ const code = obj?.error?.code;
216
+ if (code === -32601) return { status: "unsupported", detail: "This peer does not accept address announcements. The current pairing is unchanged." };
217
+ if (status === 401 || status === 403 || code === -32000) return { status: "rejected", detail: "The pairing token was rejected." };
218
+ const message = typeof obj?.error?.message === "string" ? obj.error.message.slice(0, 200) : "";
219
+ return { status: "failed", detail: message || `HTTP ${status}` };
220
+ }
@@ -0,0 +1,22 @@
1
+ /** Stop waiting even when an internal binding ignores cancellation. Callers pass
2
+ * the signal into every request and check it before starting another side effect. */
3
+ export async function withDeadline<T>(ms: number, run: (signal: AbortSignal) => Promise<T>, parent?: AbortSignal): Promise<T> {
4
+ const controller = new AbortController();
5
+ const cancel = () => controller.abort(parent?.reason);
6
+ if (parent?.aborted) cancel();
7
+ else parent?.addEventListener("abort", cancel, { once: true });
8
+ const timer = setTimeout(() => controller.abort(new Error("Reply delivery timed out; its outcome may be uncertain")), ms);
9
+ let stop!: () => void;
10
+ const aborted = new Promise<never>((_resolve, reject) => {
11
+ stop = () => reject(controller.signal.reason);
12
+ controller.signal.addEventListener("abort", stop, { once: true });
13
+ });
14
+ try {
15
+ controller.signal.throwIfAborted();
16
+ return await Promise.race([run(controller.signal), aborted]);
17
+ } finally {
18
+ clearTimeout(timer);
19
+ parent?.removeEventListener("abort", cancel);
20
+ controller.signal.removeEventListener("abort", stop);
21
+ }
22
+ }
@@ -100,6 +100,7 @@ const OMIT_COLUMNS: Record<string, readonly string[]> = {
100
100
  outbound: ["push_token_hash"],
101
101
  mcp_grants: ["access_hash", "refresh_hash"],
102
102
  outbound_peers: ["token_enc"],
103
+ peers: ["token_enc"],
103
104
  device_requests: ["replaces_hash", "ip", "country"],
104
105
  };
105
106
 
@@ -137,7 +138,7 @@ export function redactExport(file: ExportFile): ExportFile {
137
138
  const omit = new Set(OMIT_COLUMNS[name] ?? []);
138
139
  const rows: Record<string, unknown>[] = [];
139
140
  for (const [index, row] of (file.tables[name] ?? []).entries()) {
140
- if (name === "settings" && (row.key === "approval_password" || row.key === "password_setup")) continue;
141
+ if (name === "settings" && (row.key === "approval_password" || row.key === "password_setup" || row.key === "peer_identity")) continue;
141
142
  const out: Record<string, unknown> = {};
142
143
  for (const [key, value] of Object.entries(row)) {
143
144
  if (omit.has(key) || key.toLowerCase() === "replaces_hash") continue;