a2a-exposed 0.8.20 → 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/lib/deploy.mjs CHANGED
@@ -11,6 +11,14 @@ import { readHidden } from "./pair.mjs";
11
11
  import * as WD from "./workersdev.mjs";
12
12
  import { printStar } from "./feedback.mjs";
13
13
 
14
+ export const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
15
+
16
+ /** Arguments shared by deploy attempts, including workers.dev registration retries. */
17
+ export function deployArgs(version, isoDate) {
18
+ if (!/^[A-Za-z0-9._-]+$/.test(version)) throw new Error("invalid Worker version tag");
19
+ return ["deploy", "--message", `a2a-exposed ${version} ${isoDate}`, "--tag", `a2a-exposed-${version}`];
20
+ }
21
+
14
22
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
23
  /** Each Claude Code routine fire is a new cloud session: a 2xx only means a session started. */
16
24
  export const CLAUDE_CODE_NOTE = [
@@ -392,7 +400,7 @@ function runTee(cmd, args, { cwd, env }) {
392
400
  /** cf deploy (secrets via a temporary chmod-600 file). If the account has no workers.dev subdomain yet and
393
401
  * `register` names one, retry through cf's own registration prompt. Returns cf's output. */
394
402
  async function cfDeploy(dir, secrets, { register } = {}) {
395
- const base = ["deploy", "--message", `a2a-exposed ${new Date().toISOString()}`];
403
+ const base = deployArgs(VERSION, new Date().toISOString());
396
404
  const go = async (file) => {
397
405
  const args = cfArgs(file ? [...base, "--secrets-file", file] : base);
398
406
  let r = await runTee(cfBin(dir), args, { cwd: dir, env: deployEnv() });
package/lib/main.mjs CHANGED
@@ -88,7 +88,7 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
88
88
  e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
89
89
  --worker-dir where the Worker project (template copy) lives (default
90
90
  <config dir>/worker). The config dir is --config-dir / A2A_CONFIG_DIR
91
- deploy [same flags] redeploy with saved settings (secrets persist)
91
+ deploy [same flags] redeploy with saved settings (secrets persist); tag the Worker version as a2a-exposed-<version>
92
92
  wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
93
93
  [--cli-command CMD] [--debounce S] [--max-per-hour N]
94
94
  save wake settings, upload wake secrets from env, and redeploy (one step)
@@ -145,11 +145,12 @@ Remove
145
145
  --dir and --worker-dir are the Worker project directory (default <config dir>/worker)
146
146
 
147
147
  Status
148
- status [--json] deployment, base URL, agent card check (fetched by the CLI: no curl needed), wake
148
+ status [--json] deployment, Worker version, base URL, agent card check (fetched by the CLI), wake
149
149
  mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
150
150
  upstream card, Access credential and upstream bearer configured (separately), the
151
151
  live upstream check, the last failed peer call, card-leak check; warnings for peer
152
152
  token variables shadowing saved tokens; and the next step to run.
153
+ Older Workers need deploy (exit 1); npm latest hints use the local update cache.
153
154
  When GET /owner/mcp reports enabled and the agent card is reachable, prints
154
155
  \`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
155
156
  A failed probe, an unreachable card, or a Worker that reports MCP off leaves that line out.
package/lib/status.mjs CHANGED
@@ -8,6 +8,17 @@ import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJ
8
8
  import { baseUrl, owner, ownerTry } from "./commands.mjs";
9
9
  import * as D from "./deploy.mjs";
10
10
  import * as T from "./tunnel.mjs";
11
+ import { cacheFile, readCache, newer } from "./update.mjs";
12
+
13
+ /** Public health probe. Only a valid health answer establishes missing version reporting. */
14
+ export async function checkHealth(base) {
15
+ try {
16
+ const { status, data } = await httpJson(`${base}/health`, { timeout: 15000 });
17
+ if (status === 200 && data?.ok === true)
18
+ return { ok: true, version: typeof data.version === "string" && data.version ? data.version : null };
19
+ } catch { /* unreachable: version stays unknown */ }
20
+ return { ok: false, version: null };
21
+ }
11
22
 
12
23
  /** Presets whose webhook usually listens only locally: wakes need the tunnel (or another reverse proxy). */
13
24
  export const LOCAL_PRESETS = ["openclaw-wake", "openclaw-agent", "hermes"];
@@ -111,6 +122,9 @@ export function secretsPropagating(tunnel, now = Date.now()) {
111
122
  * as separate lines (so the next step stays one short instruction). */
112
123
  export function nextStep(s) {
113
124
  const n = nextStepCore(s);
125
+ const version = s.cliVersion || D.VERSION;
126
+ if (s.workerVersion && s.latestVersion && newer(s.latestVersion, s.workerVersion) && !newer(s.workerVersion, version))
127
+ n.also.push(`a2a-exposed ${s.latestVersion} is available: run npx -y a2a-exposed@latest deploy`);
114
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`);
115
129
  return sh.length ? { ...n, also: [...n.also, ...sh] } : n;
116
130
  }
@@ -133,6 +147,11 @@ function nextStepCore(s) {
133
147
  return fail(/HTTP 401/.test(s.ownerApi.error)
134
148
  ? `the owner token in ${s.configFile} does not match the Worker: run \`${CLI} init --rotate-owner-token\` from the machine that owns this deployment`
135
149
  : `the owner API failed (${s.ownerApi.error}): run \`${CLI} deploy\``);
150
+ const version = s.cliVersion || D.VERSION;
151
+ if (s.workerVersion && newer(version, s.workerVersion))
152
+ return fail(`the deployed Worker is ${s.workerVersion}, older than this CLI (${version}): run \`${CLI} deploy\``);
153
+ if (s.health?.ok && s.health.version === null)
154
+ return fail(`the deployed Worker predates version reporting (older than ${version}): run \`${CLI} deploy\``);
136
155
  // proxy / expose mode: the upstream answers peers, so wakes are optional (pairing notifications only)
137
156
  const fc = s.facade;
138
157
  if (fc) {
@@ -220,12 +239,14 @@ export async function collect() {
220
239
  upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null, upstreamVerify: null, upstreamVerifyError: "", facadePeerAuth: null,
221
240
  upstreamNoToken: C.get("A2A_UPSTREAM_NO_TOKEN") === "1", localUpstreamToken: fingerprint(process.env.UPSTREAM_TOKEN),
222
241
  peerTokenShadowed: shadowedPeerTokens(),
223
- mcp: "unknown",
242
+ mcp: "unknown", health: null, workerVersion: null, cliVersion: D.VERSION, latestVersion: readCache(cacheFile())?.latest || null,
224
243
  };
225
244
  const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
226
245
  if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
227
246
  if (!s.deployed || !s.baseUrl) return s;
228
247
  s.card = await checkCard(s.baseUrl);
248
+ s.health = await checkHealth(s.baseUrl);
249
+ s.workerVersion = s.health.version;
229
250
  if (s.hasOwnerToken) {
230
251
  try {
231
252
  const p = await owner("GET", "/owner/wake/preview");
@@ -335,6 +356,7 @@ export async function status(o) {
335
356
  if (s.deployed && s.baseUrl) {
336
357
  rows.push(["agent card", !s.card.ok ? `FAILED: ${s.card.error}` : s.card.urlProblem ? `WRONG URL: "${s.card.name}", but ${s.card.urlProblem}`
337
358
  : `OK: "${s.card.name}" (A2A ${s.card.versions.join(", ") || "?"})`]);
359
+ rows.push(["worker version", `${s.workerVersion || "unknown"} (CLI ${s.cliVersion})`]);
338
360
  rows.push(["owner API", s.ownerApi.ok ? "OK" : `FAILED: ${s.ownerApi.error}`]);
339
361
  if (s.upstream) {
340
362
  const f = s.facade || {};
package/lib/update.mjs CHANGED
@@ -36,7 +36,7 @@ export function notice(current, cache) {
36
36
 
37
37
  export const due = (cache, now = Date.now()) => !cache || !(now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS);
38
38
 
39
- function readCache(file) {
39
+ export function readCache(file) {
40
40
  try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
41
41
  }
42
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.20",
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": {
@@ -4,6 +4,7 @@
4
4
  // Worker serves a rewritten agent card and device-flow pairing, and forwards authenticated JSON-RPC to the upstream.
5
5
  // Handlers take a TenantContext (tenancy.ts) built from the Worker bindings. With the hosted gates unset, that
6
6
  // context is the single self-host tenant and behaviour matches a Worker that read env.* directly.
7
+ import { VERSION } from "./version.ts";
7
8
  import * as A from "./a2a.ts";
8
9
  import * as F from "./facade.ts";
9
10
  import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
@@ -1338,22 +1339,31 @@ function changed(res: { meta?: { changes?: number } } | null | undefined): numbe
1338
1339
  return Number(res?.meta?.changes ?? 0);
1339
1340
  }
1340
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
+
1341
1345
  /** The outbound row this push may update. A callback that arrives before SendMessage returns matches the
1342
1346
  * pending row by token hash and claims the peer's task id. A row already stored under that id must
1343
- * present the same hash. */
1347
+ * present the same hash. A revoked peer matches neither, so the handler returns 401 and stores nothing. */
1344
1348
  async function outboundForPush(ctx: TenantContext, taskId: string, givenHash: string): Promise<Json | null> {
1345
1349
  if (!givenHash) return null;
1346
- const rec: Json = await ctx.DB.prepare("SELECT * FROM outbound WHERE task_id = ?").bind(taskId).first();
1347
- if (rec) return rec.push_token_hash && A.timingSafeEqualStr(rec.push_token_hash, givenHash) ? rec : null;
1348
- const pending: Json = await ctx.DB.prepare("SELECT * FROM outbound WHERE push_token_hash = ? AND task_id LIKE 'pend.%'").bind(givenHash).first();
1349
- 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;
1350
1355
  try {
1351
- const renamed = await ctx.DB.prepare("UPDATE outbound SET task_id = ? WHERE task_id = ? AND push_token_hash = ?")
1352
- .bind(taskId, pending.task_id, givenHash).run();
1353
- 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
+ }
1354
1364
  } catch { /* the send path already stored this task id */ }
1355
- const now: Json = await ctx.DB.prepare("SELECT * FROM outbound WHERE task_id = ?").bind(taskId).first();
1356
- 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);
1357
1367
  }
1358
1368
 
1359
1369
  /** Move a pending push row onto the peer's task id without dropping an update that already arrived. */
@@ -1690,8 +1700,17 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1690
1700
  }
1691
1701
  if (m === "DELETE" && seg[1]) {
1692
1702
  const label = decodeURIComponent(seg[1]);
1693
- const r = await ctx.DB.prepare("UPDATE peers SET revoked_at = ? WHERE label = ? AND revoked_at IS NULL").bind(A.nowIso(), label).run();
1694
- 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) {
1695
1714
  const ex: Json = await ctx.DB.prepare("SELECT revoked_at FROM peers WHERE label = ?").bind(label).first();
1696
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);
1697
1716
  }
@@ -3404,7 +3423,7 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
3404
3423
  // legacy discovery path (A2A 0.2): the 0.3-shaped card that clients of that era parse
3405
3424
  if (req.method === "GET" && path === "/.well-known/agent.json")
3406
3425
  return json(agentCard03(ctx, await agentCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
3407
- if (req.method === "GET" && path === "/health") return json({ ok: true, publicUrl: ctx.PUBLIC_URL, mode: proxyMode(ctx) ? "proxy" : "inbox", mcp: mcpOn(ctx) });
3426
+ if (req.method === "GET" && path === "/health") return json({ ok: true, version: VERSION, publicUrl: ctx.PUBLIC_URL, mode: proxyMode(ctx) ? "proxy" : "inbox", mcp: mcpOn(ctx) });
3408
3427
  if (path === "/mcp") return mcpOn(ctx) ? await handleMcp(req, ctx, ectx, url) : json({ error: "not_found", error_description: "the MCP server is turned off on this A2A endpoint (MCP=off)" }, 404);
3409
3428
  if (mcpOn(ctx)) {
3410
3429
  if (req.method === "GET" && path === "/.well-known/oauth-protected-resource/mcp") return json(mcpResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.20";
2
+ export const VERSION = "0.8.22";
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { createRequire } from "node:module";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { Miniflare, convertV4MiniflareOptions } from "miniflare";
10
+ import { VERSION } from "../src/version.ts";
10
11
  import worker from "../src/index.ts";
11
12
  import { sha256 } from "../src/a2a.ts";
12
13
  import { sealWakeSecrets } from "../src/wake-secrets.ts";
@@ -81,6 +82,7 @@ test("gates unset: the hosted entry matches the self-host handler, including TEN
81
82
  const base = "https://agent.example.com";
82
83
  const nodeEnv = { DB: d1(MIGRATIONS), PUBLIC_URL: base, OWNER_TOKEN: "owner-secret" };
83
84
  const nodeHealth = await text(await worker.fetch(new Request(base + "/health"), nodeEnv as never, ectx as never));
85
+ assert.deepEqual(JSON.parse(nodeHealth), { ok: true, version: VERSION, publicUrl: base, mode: "inbox", mcp: true });
84
86
  const nodeDenied = await text(await worker.fetch(new Request(base + "/", {
85
87
  method: "POST", headers: { "content-type": "application/json" },
86
88
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
@@ -180,6 +182,9 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
180
182
  assert.equal(bobPush.result.applied, true, JSON.stringify(bobPush.result));
181
183
  await kv.put("tenant:bobby", JSON.stringify({ id: "id-bobby", status: "active", region: "", version: 1 }));
182
184
 
185
+ assert.deepEqual(await (await mf.dispatchFetch("https://alice.example.com/health")).json(),
186
+ { ok: true, version: VERSION, publicUrl: "https://alice.example.com", mode: "inbox", mcp: true });
187
+
183
188
  const card = async (name: string, headers?: HeadersInit) => (await mf.dispatchFetch(`https://${name}.example.com/.well-known/agent-card.json`, { headers })).json() as Promise<{ name: string }>;
184
189
  const aliceCard = await mf.dispatchFetch("https://alice.example.com/.well-known/agent-card.json");
185
190
  assert.equal(aliceCard.headers.get("cache-control"), "public, max-age=60");
@@ -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
+ });
@@ -1,5 +1,6 @@
1
1
  // Tenant context: gates default off, self-host owner auth and peer-token key stay compatible, hosted uses a hash
2
2
  // and TENANT_SECRETS_KEY. Run: npm test
3
+ import { VERSION } from "../src/version.ts";
3
4
  import { test } from "node:test";
4
5
  import assert from "node:assert/strict";
5
6
  import worker from "../src/index.ts";
@@ -258,7 +259,7 @@ test("fetch path: gates set or unset, the self-host Worker still answers from OW
258
259
  const plain = await call(base, "/health");
259
260
  const reserved = await call({ ...base, ...gates }, "/health");
260
261
  const empty = await call({ ...base, TENANCY: "", QUOTAS: "", DATA_REGION: "", SIGNUP_URL: "" }, "/health");
261
- assert.deepEqual(plain.data, { ok: true, publicUrl: BASE, mode: "inbox", mcp: true });
262
+ assert.deepEqual(plain.data, { ok: true, version: VERSION, publicUrl: BASE, mode: "inbox", mcp: true });
262
263
  assert.deepEqual(reserved.data, plain.data);
263
264
  assert.deepEqual(empty.data, plain.data);
264
265