a2a-exposed 0.8.21 → 0.8.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.21",
3
+ "version": "0.8.22",
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": {
@@ -1339,22 +1339,31 @@ function changed(res: { meta?: { changes?: number } } | null | undefined): numbe
1339
1339
  return Number(res?.meta?.changes ?? 0);
1340
1340
  }
1341
1341
 
1342
+ /** Outbound rows whose peer label is still active. A row with no inbound label stays eligible, because send does not require one. */
1343
+ const LIVE_OUTBOUND = "SELECT o.* FROM outbound o LEFT JOIN peers p ON p.label = o.peer WHERE p.revoked_at IS NULL";
1344
+
1342
1345
  /** The outbound row this push may update. A callback that arrives before SendMessage returns matches the
1343
1346
  * pending row by token hash and claims the peer's task id. A row already stored under that id must
1344
- * present the same hash. */
1347
+ * present the same hash. A revoked peer matches neither, so the handler returns 401 and stores nothing. */
1345
1348
  async function outboundForPush(ctx: TenantContext, taskId: string, givenHash: string): Promise<Json | null> {
1346
1349
  if (!givenHash) return null;
1347
- const rec: Json = await ctx.DB.prepare("SELECT * FROM outbound WHERE task_id = ?").bind(taskId).first();
1348
- if (rec) return rec.push_token_hash && A.timingSafeEqualStr(rec.push_token_hash, givenHash) ? rec : null;
1349
- const pending: Json = await ctx.DB.prepare("SELECT * FROM outbound WHERE push_token_hash = ? AND task_id LIKE 'pend.%'").bind(givenHash).first();
1350
- if (!pending?.push_token_hash || !A.timingSafeEqualStr(pending.push_token_hash, givenHash)) return null;
1350
+ const same = (row: Json | null) => row?.push_token_hash && A.timingSafeEqualStr(row.push_token_hash, givenHash) ? row : null;
1351
+ const rec: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.task_id = ?`).bind(taskId).first();
1352
+ if (rec) return same(rec);
1353
+ const pending: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.push_token_hash = ? AND o.task_id LIKE 'pend.%'`).bind(givenHash).first();
1354
+ if (!same(pending)) return null;
1351
1355
  try {
1352
- const renamed = await ctx.DB.prepare("UPDATE outbound SET task_id = ? WHERE task_id = ? AND push_token_hash = ?")
1353
- .bind(taskId, pending.task_id, givenHash).run();
1354
- if (changed(renamed) === 1) return { ...pending, task_id: taskId };
1356
+ const renamed = await ctx.DB.prepare(
1357
+ `UPDATE outbound SET task_id = ? WHERE task_id = ? AND push_token_hash = ?
1358
+ AND NOT EXISTS (SELECT 1 FROM peers WHERE label = outbound.peer AND revoked_at IS NOT NULL)`,
1359
+ ).bind(taskId, pending.task_id, givenHash).run();
1360
+ if (changed(renamed) === 1) {
1361
+ const claimed: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.task_id = ?`).bind(taskId).first();
1362
+ return same(claimed);
1363
+ }
1355
1364
  } catch { /* the send path already stored this task id */ }
1356
- const now: Json = await ctx.DB.prepare("SELECT * FROM outbound WHERE task_id = ?").bind(taskId).first();
1357
- return now?.push_token_hash && A.timingSafeEqualStr(now.push_token_hash, givenHash) ? now : null;
1365
+ const now: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.task_id = ?`).bind(taskId).first();
1366
+ return same(now);
1358
1367
  }
1359
1368
 
1360
1369
  /** Move a pending push row onto the peer's task id without dropping an update that already arrived. */
@@ -1691,8 +1700,17 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1691
1700
  }
1692
1701
  if (m === "DELETE" && seg[1]) {
1693
1702
  const label = decodeURIComponent(seg[1]);
1694
- const r = await ctx.DB.prepare("UPDATE peers SET revoked_at = ? WHERE label = ? AND revoked_at IS NULL").bind(A.nowIso(), label).run();
1695
- if (r.meta.changes !== 1) {
1703
+ const now = A.nowIso();
1704
+ // Drop push hashes only while this label is still active, in the same transaction as the revoke.
1705
+ // An unknown or already-revoked label leaves every outbound row alone, including an alias of the same name.
1706
+ const [, revoked] = await ctx.DB.batch([
1707
+ ctx.DB.prepare(
1708
+ `UPDATE outbound SET push_token_hash = NULL
1709
+ WHERE peer = ? AND EXISTS (SELECT 1 FROM peers WHERE label = ? AND revoked_at IS NULL)`,
1710
+ ).bind(label, label),
1711
+ ctx.DB.prepare("UPDATE peers SET revoked_at = ? WHERE label = ? AND revoked_at IS NULL").bind(now, label),
1712
+ ]);
1713
+ if (changed(revoked) !== 1) {
1696
1714
  const ex: Json = await ctx.DB.prepare("SELECT revoked_at FROM peers WHERE label = ?").bind(label).first();
1697
1715
  return json({ revoked: false, error: ex ? `the token with label ${label} was already revoked (${ex.revoked_at})` : `no active token with label ${label} (see token list)` }, 404);
1698
1716
  }
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.21";
2
+ export const VERSION = "0.8.22";
@@ -2,6 +2,7 @@
2
2
  // A connector send attaches a push config only when the peer card advertises it, and stores the token
3
3
  // hash before SendMessage returns. Inbox marks a reply seen only when the stored update is still the
4
4
  // snapshot that response returned, on every storage backend this file runs against.
5
+ // Revoking the peer label clears that peer's push hashes. A later push with the old token is 401.
5
6
  import { test } from "node:test";
6
7
  import assert from "node:assert/strict";
7
8
  import { sha256 } from "../src/a2a.ts";
@@ -340,3 +341,192 @@ test("message and artifactUpdate pushes supply inbox text", async () => {
340
341
  assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === c.text && r.state === c.state), true, history.text);
341
342
  }
342
343
  });
344
+
345
+ type OutSnap = {
346
+ task_id?: string;
347
+ context_id?: string;
348
+ peer?: string;
349
+ task_json?: string | null;
350
+ updates_json?: string;
351
+ updates_seen?: number;
352
+ hash_cleared?: number;
353
+ };
354
+
355
+ test("revoking a peer rejects its old push token on the self-hosted and hosted paths", async (t) => {
356
+ const logs: string[] = [];
357
+ const origLog = console.log;
358
+ console.log = (...args: unknown[]) => {
359
+ logs.push(args.map((a) => String(a)).join(" "));
360
+ origLog(...args);
361
+ };
362
+ const pushByHost = new Map<string, string>();
363
+ const origFetch = globalThis.fetch;
364
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
365
+ const url = new URL(reqUrl(input));
366
+ if (init?.method !== "POST") {
367
+ return new Response(JSON.stringify(card(`https://${url.host}`, "1.0", true)), { status: 200, headers: { "content-type": "application/json" } });
368
+ }
369
+ const body = JSON.parse(String(init.body)) as { params?: { configuration?: { taskPushNotificationConfig?: { token?: string } } } };
370
+ const token = body.params?.configuration?.taskPushNotificationConfig?.token || "";
371
+ pushByHost.set(url.host, token);
372
+ const taskId = url.host.startsWith("bea.") ? "task-bea" : "task-ada";
373
+ const contextId = url.host.startsWith("bea.") ? "ctx-bea" : "ctx-ada";
374
+ return new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: taskResult(taskId, contextId, "1.0") }), { status: 200, headers: { "content-type": "application/json" } });
375
+ }) as typeof fetch;
376
+ t.after(() => {
377
+ globalThis.fetch = origFetch;
378
+ console.log = origLog;
379
+ });
380
+
381
+ const secrets: string[] = [];
382
+ const outputs: string[] = [];
383
+ const hides = (blob: string, where: string) => {
384
+ for (const secret of secrets) assert.equal(secret.length > 0 && blob.includes(secret), false, `${where} contains a token`);
385
+ };
386
+
387
+ for (const mode of ["single", "host"] as const) {
388
+ pushByHost.clear();
389
+ const db = d1(MIGRATIONS);
390
+ const publicUrl = "https://owner.example.net";
391
+ const ctx = mode === "single"
392
+ ? resolveTenant({ DB: db, PUBLIC_URL: publicUrl, OWNER_TOKEN: OWNER, AGENT_NAME: "Owner" } as WorkerBindings)
393
+ : hostedTenantContext({
394
+ DB: db, TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", PUBLIC_URL: publicUrl, OWNER_TOKEN: "platform-owner",
395
+ } as WorkerBindings, {
396
+ id: "ten_revoke_push", db, publicUrl, ownerTokenHash: await sha256(OWNER), status: "active", config: { AGENT_NAME: "Owner" },
397
+ });
398
+ const http = (method: string, path: string, body?: unknown) => owner(ctx, method, path, body);
399
+ const call = async (method: string, path: string, body?: unknown) => {
400
+ if (mode === "single") return http(method, path, body);
401
+ const res = await runOwnerRpc(ctx, ectx as never, {
402
+ accountId: "acct_owner", method, path, ...(body === undefined ? {} : { body }),
403
+ });
404
+ const text = JSON.stringify(res.body);
405
+ return { status: res.status, data: res.body, text };
406
+ };
407
+ const note = (res: { text: string }) => { outputs.push(res.text); return res; };
408
+
409
+ const issue = async (label: string) => {
410
+ const issued = await http("POST", "/owner/peers", { label });
411
+ assert.equal(issued.status, 200, issued.text);
412
+ const token = (issued.data as { token?: string }).token || "";
413
+ assert.match(token, /^a2aow_/);
414
+ secrets.push(token);
415
+ return token;
416
+ };
417
+ const beaBearer = await issue("bea");
418
+ const adaBearer = await issue("ada");
419
+ for (const [alias, bearer] of [["bea", beaBearer], ["ada", adaBearer]] as const) {
420
+ const synced = note(await call("PUT", `/owner/outbound-peers/${alias}`, { url: `https://${alias}.example.net`, token: bearer }));
421
+ assert.equal(synced.status, 200, synced.text);
422
+ assert.equal(JSON.stringify(synced.data).includes(bearer), false);
423
+ }
424
+ for (const alias of ["bea", "ada"] as const) {
425
+ const sent = note(await call("POST", "/owner/mcp/send", { to: alias, text: `hello ${alias}`, contextId: `ctx-${alias}` }));
426
+ assert.equal(sent.status, 200, sent.text);
427
+ assert.equal((sent.data as SendBody).isError, false, sent.text);
428
+ assert.match((sent.data as SendBody).text || "", new RegExp(`task task-${alias}`));
429
+ }
430
+ const beaPush = pushByHost.get("bea.example.net") || "";
431
+ const adaPush = pushByHost.get("ada.example.net") || "";
432
+ assert.equal(beaPush.length > 0 && adaPush.length > 0 && beaPush !== adaPush, true);
433
+ secrets.push(beaPush, adaPush);
434
+ const pendingToken = "pending-push-token";
435
+ secrets.push(pendingToken);
436
+ await db.prepare(
437
+ `INSERT INTO outbound (task_id, context_id, peer, endpoint, protocol, push_token_hash, sent_at, updates_json, updates_seen)
438
+ VALUES ('pend.revoked', 'ctx-bea', 'bea', 'https://bea.example.net/', '1.0', ?, '2026-01-01T00:00:00.000Z', '[]', 0)`,
439
+ ).bind(await sha256(pendingToken)).run();
440
+
441
+ const missed = note(await call("DELETE", "/owner/peers/nobody"));
442
+ assert.equal(missed.status, 404, missed.text);
443
+ const beforeRevoke = await db.prepare("SELECT push_token_hash IS NULL AS cleared FROM outbound WHERE task_id = ?").bind("task-bea").first() as { cleared?: number };
444
+ assert.equal(Number(beforeRevoke.cleared), 0);
445
+
446
+ const revoked = note(await call("DELETE", "/owner/peers/bea"));
447
+ assert.equal(revoked.status, 200, revoked.text);
448
+ assert.deepEqual(revoked.data, { revoked: true, label: "bea" });
449
+ const again = note(await call("DELETE", "/owner/peers/bea"));
450
+ assert.equal(again.status, 404, again.text);
451
+
452
+ const cleared = async (taskId: string) => Number((await db.prepare("SELECT push_token_hash IS NULL AS cleared FROM outbound WHERE task_id = ?").bind(taskId).first() as { cleared?: number }).cleared);
453
+ assert.equal(await cleared("task-bea"), 1);
454
+ assert.equal(await cleared("pend.revoked"), 1);
455
+ assert.equal(await cleared("task-ada"), 0);
456
+
457
+ const snap = async (taskId: string) => await db.prepare(
458
+ `SELECT task_id, context_id, peer, task_json, updates_json, updates_seen, push_token_hash IS NULL AS hash_cleared
459
+ FROM outbound WHERE task_id = ?`,
460
+ ).bind(taskId).first() as OutSnap;
461
+ const histCount = async (contextId: string) => Number((await db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = ?").bind(contextId).first() as { n: number }).n);
462
+ const taskCount = async () => Number((await db.prepare("SELECT COUNT(*) AS n FROM tasks").first() as { n: number }).n);
463
+ const beaBefore = await snap("task-bea");
464
+ const beaHist = await histCount("ctx-bea");
465
+ const tasksBefore = await taskCount();
466
+ const push = async (token: string, taskId: string, text: string) => {
467
+ const res = await dispatch(new Request("https://owner.example.net/push", {
468
+ method: "POST",
469
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
470
+ body: JSON.stringify({ task: { id: taskId, contextId: "ctx-bea", status: { state: "completed", message: { parts: [{ text }] } } } }),
471
+ }), ctx, ectx as never);
472
+ const body = await res.text();
473
+ outputs.push(body);
474
+ return { status: res.status, body };
475
+ };
476
+ const rejected = await push(beaPush, "task-bea", "revoked reply");
477
+ assert.equal(rejected.status, 401);
478
+ assert.equal(rejected.body, '{"error":"unauthorized"}');
479
+ assert.deepEqual(await snap("task-bea"), beaBefore);
480
+ assert.equal(await histCount("ctx-bea"), beaHist);
481
+ assert.equal(await taskCount(), tasksBefore);
482
+ const wake = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-bea").first() as { pending_json?: string | null } | null;
483
+ assert.equal(wake?.pending_json ?? null, null);
484
+
485
+ const pending = await push(pendingToken, "task-claimed", "pending reply");
486
+ assert.equal(pending.status, 401);
487
+ assert.equal(pending.body, '{"error":"unauthorized"}');
488
+ assert.equal(await cleared("pend.revoked"), 1);
489
+ const claimed = await db.prepare("SELECT task_id FROM outbound WHERE task_id = ?").bind("task-claimed").first();
490
+ assert.equal(claimed, null);
491
+ assert.equal(await histCount("ctx-bea"), beaHist);
492
+ assert.deepEqual(await snap("task-bea"), beaBefore);
493
+
494
+ const standin = "restored-push-token";
495
+ secrets.push(standin);
496
+ await db.prepare("UPDATE outbound SET push_token_hash = ? WHERE task_id = ?").bind(await sha256(standin), "task-bea").run();
497
+ const restored = await push(standin, "task-bea", "restored reply");
498
+ assert.equal(restored.status, 401);
499
+ assert.equal(restored.body, '{"error":"unauthorized"}');
500
+ const afterRestore = await snap("task-bea");
501
+ assert.equal(Number(afterRestore.hash_cleared), 0);
502
+ assert.equal(afterRestore.task_json, beaBefore.task_json);
503
+ assert.equal(afterRestore.updates_json, beaBefore.updates_json);
504
+ assert.equal(afterRestore.updates_seen, beaBefore.updates_seen);
505
+ assert.equal(await histCount("ctx-bea"), beaHist);
506
+
507
+ const accepted = await dispatch(new Request("https://owner.example.net/push", {
508
+ method: "POST",
509
+ headers: { "content-type": "application/json", "x-a2a-notification-token": adaPush },
510
+ body: JSON.stringify({ task: { id: "task-ada", contextId: "ctx-ada", status: { state: "completed", message: { parts: [{ text: "ada reply" }] } } } }),
511
+ }), ctx, ectx as never);
512
+ const acceptedText = await accepted.text();
513
+ outputs.push(acceptedText);
514
+ assert.equal(accepted.status, 200, acceptedText);
515
+ const adaRow = await snap("task-ada");
516
+ assert.match(String(adaRow.updates_json), /ada reply/);
517
+ assert.equal(Number(adaRow.hash_cleared), 0);
518
+ const adaHist = note(await call("GET", "/owner/history/ctx-ada"));
519
+ assert.equal((adaHist.data as Hist[]).some((r) => r.event === "push" && r.text === "ada reply"), true, adaHist.text);
520
+ const beaHistory = note(await call("GET", "/owner/history/ctx-bea"));
521
+ assert.equal((beaHistory.data as Hist[]).some((r) => r.text === "revoked reply" || r.text === "pending reply" || r.text === "restored reply"), false, beaHistory.text);
522
+ assert.deepEqual(await snap("task-bea"), afterRestore);
523
+ }
524
+
525
+ hides(outputs.join("\n"), "response");
526
+ hides(logs.join("\n"), "log");
527
+ for (const secret of secrets) {
528
+ const hash = await sha256(secret);
529
+ assert.equal(outputs.join("\n").includes(hash), false, "response contains a token hash");
530
+ assert.equal(logs.join("\n").includes(hash), false, "log contains a token hash");
531
+ }
532
+ });