a2a-exposed 0.8.7 → 0.8.9

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
@@ -34,7 +34,8 @@ export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_
34
34
  export const UPSTREAM_SECRETS = ["UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET"];
35
35
  /** OpenID Connect client secret for approval. Environment only; never config.env. */
36
36
  export const OIDC_SECRETS = ["APPROVAL_OIDC_CLIENT_SECRET"];
37
- const DEPLOY_SECRETS = [...WAKE_SECRETS, ...UPSTREAM_SECRETS, ...OIDC_SECRETS];
37
+ export const EVENT_SECRETS = ["EVENT_SECRET"];
38
+ const DEPLOY_SECRETS = [...WAKE_SECRETS, ...UPSTREAM_SECRETS, ...OIDC_SECRETS, ...EVENT_SECRETS];
38
39
 
39
40
  // Non-secret deploy settings persisted in config.env and passed to cloudflare.config.ts as env vars.
40
41
  const DEPLOY_KEYS = [
@@ -171,7 +172,7 @@ export function wakeSecretsFromEnv() {
171
172
  /** Secrets init/deploy upload when exported: the wake secrets plus, for proxy mode, the upstream credentials. */
172
173
  export function deploySecretsFromEnv() {
173
174
  const s = wakeSecretsFromEnv();
174
- for (const k of [...UPSTREAM_SECRETS, ...OIDC_SECRETS]) if (process.env[k]) s[k] = process.env[k];
175
+ for (const k of [...UPSTREAM_SECRETS, ...OIDC_SECRETS, ...EVENT_SECRETS]) if (process.env[k]) s[k] = process.env[k];
175
176
  return s;
176
177
  }
177
178
 
package/lib/main.mjs CHANGED
@@ -168,7 +168,7 @@ Inbox (owner side)
168
168
  Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens into chat)
169
169
  connect <base-or-card-url> [--alias A] [--name N] [--card-url URL] [--replace] [--no-wait] [--json]
170
170
  ask another A2A endpoint for a token: prints a code + link for your human (the code lasts
171
- 10 minutes; they confirm the code with that A2A endpoint's owner), waits for approval, stores
171
+ 60 minutes; they confirm the code with that A2A endpoint's owner), waits for approval, stores
172
172
  the token as outbound peer A
173
173
  (never printed). --no-wait: print the code and exit; run the same command again to
174
174
  check (same code). An expired code exits 1 (nothing new is requested silently).
package/lib/pair.mjs CHANGED
@@ -364,7 +364,7 @@ A different agent at the same URL? Pass another --alias.`);
364
364
  }
365
365
  st = { base, alias, device: ep.device, token: ep.token, deviceCode: r.data.device_code, userCode: r.data.user_code,
366
366
  verificationUri: r.data.verification_uri || "", verificationUriComplete: r.data.verification_uri_complete || r.data.verification_uri || "",
367
- interval: Math.max(1, Number(r.data.interval) || 5), expiresAt: Date.now() + (Number(r.data.expires_in) || 600) * 1000,
367
+ interval: Math.max(1, Number(r.data.interval) || 5), expiresAt: Date.now() + (Number(r.data.expires_in) || 3600) * 1000,
368
368
  replacesLabel: r.data.replaces_label || null, replacing: replace && !!oldToken };
369
369
  saveState(alias, st); // chmod 600: the device code is a bearer secret until it is redeemed
370
370
  const mins = Math.round((st.expiresAt - Date.now()) / 60000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
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": {
@@ -62,6 +62,10 @@ const plain: Record<string, string> = {
62
62
  APPROVAL_OIDC_CLIENT_ID: v("APPROVAL_OIDC_CLIENT_ID"),
63
63
  APPROVAL_OIDC_ALLOWED_SUBJECTS: v("APPROVAL_OIDC_ALLOWED_SUBJECTS"),
64
64
  APPROVAL_METHODS: v("APPROVAL_METHODS"),
65
+ // Optional https URL of this agent's dashboard page. A pairing wake and the MCP inbox line include it as manageUrl.
66
+ MANAGE_URL: v("MANAGE_URL"),
67
+ // Optional https URL for a signed pairing event. EVENT_SECRET is uploaded as a secret. Both are required for a post.
68
+ EVENT_URL: v("EVENT_URL"),
65
69
  };
66
70
  const hosted = v("TENANCY") === "host";
67
71
  // Daily R2 snapshots. Unset: no binding and no extra cron, so a self-host deploy stays the single-tenant Worker.
@@ -101,7 +105,8 @@ for (const [k, val] of Object.entries(plain)) if (val !== "" || (k === "WAKE_KEY
101
105
  const SECRETS = ["OWNER_TOKEN", "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
102
106
  // Declared for `cf dev` only when a deploy asks for them, so a self-host .dev.vars without these still starts.
103
107
  ...(v("TENANCY") === "host" ? ["TENANT_SECRETS_KEY"] : []),
104
- ...(v("TENANCY") === "host" || v("APPROVAL_OIDC_ISSUER") ? ["APPROVAL_OIDC_CLIENT_SECRET"] : [])];
108
+ ...(v("TENANCY") === "host" || v("APPROVAL_OIDC_ISSUER") ? ["APPROVAL_OIDC_CLIENT_SECRET"] : []),
109
+ ...(v("EVENT_URL") ? ["EVENT_SECRET"] : [])];
105
110
 
106
111
  export default defineConfig((ctx) => ({
107
112
  ...(v("CLOUDFLARE_ACCOUNT_ID") ? { accountId: v("CLOUDFLARE_ACCOUNT_ID") } : {}),
@@ -44,6 +44,13 @@ WAKE_PRESET=generic
44
44
  # APPROVAL_OIDC_ALLOWED_SUBJECTS=
45
45
  # APPROVAL_METHODS=password
46
46
  # Secrets (same secrets file, never here): APPROVAL_OIDC_CLIENT_SECRET.
47
+ # Optional https URL of this agent's dashboard page. When set, a pairing wake and the MCP inbox line
48
+ # include it as manageUrl.
49
+ # MANAGE_URL=https://example.com/app/agents/name#pairing
50
+ # Optional pairing event. When EVENT_URL is https and EVENT_SECRET is set, a new request and an
51
+ # approval or denial POST a signed JSON body to that URL. The secret is uploaded on deploy, not stored here.
52
+ # EVENT_URL=https://example.com/api/agent-events
53
+ # Secret (same secrets file, never here): EVENT_SECRET.
47
54
  # Daily SQL snapshots to R2. Unset: no BACKUP_BUCKET binding and no extra cron. The minute wake cron stays A2A_ENABLE_CRON.
48
55
  # A2A_BACKUP_BUCKET=1
49
56
  # A2A_BACKUP_BUCKET_NAME=a2a-exposed-backups
@@ -20,7 +20,7 @@ import { bookmarkForTime as readBookmark, exportRows, restoreBookmark as applyBo
20
20
  import { hostedDailyBackup } from "./backup.ts";
21
21
  import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
22
22
  import { hostedTenantContext, namespaceForRegion, parseGates, type WorkerBindings } from "./tenancy.ts";
23
- import { WAKE_SECRET_KEYS, openWakeSecrets } from "./wake-secrets.ts";
23
+ import { WAKE_SECRET_KEYS, omitSealedSecrets, openWakeSecrets } from "./wake-secrets.ts";
24
24
 
25
25
  export { clearDirectoryCache, directoryKey, lookupDirectory, parseDirectoryEntry, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME } from "./directory.ts";
26
26
  export type { DirectoryEntry };
@@ -279,19 +279,20 @@ export class TenantStore extends DurableObject<WorkerBindings> {
279
279
  await this.#enqueueArm(false);
280
280
  return { version: current.version, applied: false };
281
281
  }
282
- const config = requireObject(body.config, "config");
283
282
  const limits = requireObject(body.limits, "limits");
284
283
  const approval = requireObject(body.approval, "approval");
284
+ const secretsEnc = body.secretsEnc ?? null;
285
+ const storedConfig = omitSealedSecrets(requireObject(body.config, "config"), secretsEnc);
285
286
  const next: StoredConfig = {
286
287
  version: body.version,
287
288
  tenantId: body.tenantId,
288
289
  name: body.name,
289
290
  status: body.status,
290
- config: stringRecord(config),
291
+ config: stringRecord(storedConfig),
291
292
  limits,
292
293
  approval,
293
294
  ownerTokenHash: body.ownerTokenHash ? body.ownerTokenHash.toLowerCase() : null,
294
- secretsEnc: body.secretsEnc ?? null,
295
+ secretsEnc,
295
296
  };
296
297
  const now = new Date().toISOString();
297
298
  this.#tx(() => {
@@ -304,7 +305,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
304
305
  config_json = excluded.config_json, limits_json = excluded.limits_json, approval_json = excluded.approval_json,
305
306
  owner_token_hash = excluded.owner_token_hash, secrets_enc = excluded.secrets_enc, updated_at = excluded.updated_at`,
306
307
  next.version, next.tenantId, next.name, next.status,
307
- JSON.stringify(config), JSON.stringify(limits), JSON.stringify(approval),
308
+ JSON.stringify(storedConfig), JSON.stringify(limits), JSON.stringify(approval),
308
309
  next.ownerTokenHash, next.secretsEnc, now,
309
310
  );
310
311
  });
@@ -11,7 +11,8 @@ import { fetchCheckedWake } from "./wake-target.ts";
11
11
  import * as P from "./pairing.ts";
12
12
  import * as M from "./mcp.ts";
13
13
  import { accountIdOf, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
14
- import { resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
14
+ import { pairingAgent, pairingEventTarget, postPairingEvent } from "./pairing-events.ts";
15
+ import { publicManageUrl, resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
15
16
  import { exportRows, redactExport, REDACTED_SQL_SKIP, scrubSecretText, toSql } from "./export.ts";
16
17
  import { backupDatabase, DAILY_CRON } from "./backup.ts";
17
18
  import * as O from "./approval-oidc.ts";
@@ -159,7 +160,7 @@ const WAKE_RETRIES = 3;
159
160
  /** The wake endpoint said 429 / 503 (e.g. a Claude Code routine over its fire limit): keep the inbound wake pending and
160
161
  * flush it once Retry-After (30 s - 1 h) has passed, on the next request to the Worker or the optional cron. At most
161
162
  * WAKE_RETRIES times per wake; a newer message for the conversation merges in and starts over. Pairing and test wakes
162
- * are not retried (a pairing code expires in minutes). */
163
+ * are not retried (a pairing code lasts 60 minutes). */
163
164
  async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload: Json, retryAfter: string | null) {
164
165
  if (payload.kind !== "inbound") return;
165
166
  const attempts = (Number(payload.attempts) || 0) + 1;
@@ -1058,7 +1059,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1058
1059
  return json({ revoked: true, label });
1059
1060
  }
1060
1061
  }
1061
- if (seg[0] === "pairing") return await ownerPairing(req, ctx, m, seg.slice(1), body, trusted);
1062
+ if (seg[0] === "pairing") return await ownerPairing(req, ctx, ectx, m, seg.slice(1), body, trusted);
1062
1063
  if (seg[0] === "mcp" && !seg[1] && m === "GET") {
1063
1064
  const rows = await ctx.DB.prepare("SELECT label, client_id, client_name, redirect_host, scope, created_at, last_used_at, revoked_at, refresh_expires_ms FROM mcp_grants ORDER BY created_at").all();
1064
1065
  return json({ enabled: mcpOn(ctx), url: oauthUrls(ctx).mcp, grants: (rows.results as Json[]).map((r) => ({ ...r, refresh_expires_ms: undefined,
@@ -1241,6 +1242,11 @@ async function bump(ctx: TenantContext, key: string, windowS: number, peek = fal
1241
1242
 
1242
1243
  const clientIp = (req: Request) => req.headers.get("cf-connecting-ip") || "";
1243
1244
  const clientCountry = (req: Request) => String(((req as Json).cf || {}).country || "");
1245
+ const clientTimeZone = (req: Request) => {
1246
+ const zone = ((req as Json).cf || {}).timezone;
1247
+ return typeof zone === "string" ? zone : "";
1248
+ };
1249
+ const dashboardUrl = (ctx: TenantContext) => publicManageUrl(ctx.MANAGE_URL);
1244
1250
 
1245
1251
  async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: ExecutionContext): Promise<Response> {
1246
1252
  const now = Date.now(), ip = clientIp(req);
@@ -1265,13 +1271,15 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1265
1271
  return oauthError("slow_down", "this A2A endpoint has too many pairing requests waiting; try again later", 429, retry);
1266
1272
  }
1267
1273
  const deviceCode = P.newDeviceCode();
1274
+ const deviceHash = await A.sha256(deviceCode);
1275
+ const country = clientCountry(req);
1268
1276
  let userCode = "";
1269
1277
  for (let i = 0; i < 5 && !userCode; i++) {
1270
1278
  const c = P.newUserCode();
1271
1279
  const r = await ctx.DB.prepare(
1272
1280
  `INSERT INTO device_requests (device_hash, user_code, client_id, client_name, agent_card_url, ip, country, status, created_ms, expires_ms, interval_s)
1273
1281
  VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) ON CONFLICT DO NOTHING`,
1274
- ).bind(await A.sha256(deviceCode), c, clientId || null, clientName || null, cardUrl || null, ip || null, clientCountry(req) || null,
1282
+ ).bind(deviceHash, c, clientId || null, clientName || null, cardUrl || null, ip || null, country || null,
1275
1283
  now, now + P.EXPIRES_S * 1000, P.INTERVAL_S).run();
1276
1284
  if (r.meta.changes === 1) userCode = c;
1277
1285
  }
@@ -1288,13 +1296,17 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1288
1296
  const complete = `${u.page}?user_code=${shown}`;
1289
1297
  log("pairing_requested", { ip, userCode: shown, clientName, clientId, ...(replaces ? { replaces: replaces.label } : {}) });
1290
1298
  const mode = pairingMode(ctx) as "human" | "agent";
1299
+ const manage = dashboardUrl(ctx);
1291
1300
  ectx.waitUntil((async () => {
1292
1301
  if (!(await wakeBudgetOk(ctx))) return log("wake_skipped", { reason: "hourly cap", kind: "pairing_request" });
1293
1302
  await sendWake(ctx, ectx, { contextId: "pairing", taskId: "none", taskIds: [], from: clientName || clientId || "unknown agent", preview: "",
1294
1303
  kind: "pairing_request", pairing: { userCode: shown, verificationUriComplete: complete, approval: mode, clientName, clientId, agentCardUrl: cardUrl,
1295
1304
  ...(cardUrl && P.cardIsPrivate(cardUrl) ? { agentCardPrivate: true } : {}), expiresIn: P.EXPIRES_S,
1305
+ ...(manage ? { manageUrl: manage } : {}),
1296
1306
  ...(replaces ? { replacesLabel: replaces.label } : {}) } });
1297
1307
  })());
1308
+ publishPairingEvent(ctx, ectx, { type: "pairing_requested", requestId: deviceHash, code: shown, name: clientName, alias: clientId,
1309
+ ip, country, createdAt: new Date(now).toISOString(), expiresAt: new Date(now + P.EXPIRES_S * 1000).toISOString() });
1298
1310
  return json({ device_code: deviceCode, user_code: shown, verification_uri: u.page, verification_uri_complete: complete,
1299
1311
  expires_in: P.EXPIRES_S, interval: P.INTERVAL_S, ...(replaces ? { replaces_label: replaces.label } : {}) }, 200, { pragma: "no-cache" });
1300
1312
  }
@@ -1368,7 +1380,7 @@ const pendingRequest = async (ctx: TenantContext, code: string): Promise<Json |
1368
1380
  ctx.DB.prepare("SELECT * FROM device_requests WHERE user_code = ?").bind(code).first();
1369
1381
 
1370
1382
  /** Approve or deny a pending request. Returns the label (approve) or an error message. */
1371
- async function decide(ctx: TenantContext, code: string, approve: boolean, by: string): Promise<{ ok: boolean; label?: string; error?: string; request?: Json; replaced?: boolean }> {
1383
+ async function decide(ctx: TenantContext, ectx: ExecutionContext, code: string, approve: boolean, by: string): Promise<{ ok: boolean; label?: string; error?: string; request?: Json; replaced?: boolean }> {
1372
1384
  const r = await pendingRequest(ctx, code);
1373
1385
  if (!r) return { ok: false, error: "no pairing request with that code" };
1374
1386
  if (r.status !== "pending") return { ok: false, error: `that request was already ${r.status === "denied" ? "denied" : "approved"}` };
@@ -1380,9 +1392,46 @@ async function decide(ctx: TenantContext, code: string, approve: boolean, by: st
1380
1392
  .bind(approve ? "approved" : "denied", label, Date.now(), by, r.device_hash).run();
1381
1393
  if (u.meta.changes !== 1) return { ok: false, error: "that request was decided meanwhile" };
1382
1394
  log(approve ? "pairing_approved" : "pairing_denied", { userCode: P.formatUserCode(code), by, label, ...(replacing ? { replaces: label } : {}) });
1395
+ publishPairingEvent(ctx, ectx, { type: "pairing_decided", requestId: String(r.device_hash), code: P.formatUserCode(code),
1396
+ name: r.client_name || "", alias: r.client_id || "", ip: r.ip || "", country: r.country || "",
1397
+ createdAt: new Date(r.created_ms).toISOString(), expiresAt: new Date(r.expires_ms).toISOString(),
1398
+ decision: approve ? "approved" : "denied" });
1383
1399
  return { ok: true, label: label || undefined, request: r, replaced: replacing };
1384
1400
  }
1385
1401
 
1402
+ /**
1403
+ * Hand the open-count query and the EVENT_URL post to waitUntil.
1404
+ * The HTTP response returns before either of them runs. A missing URL or secret leaves the request as it was.
1405
+ */
1406
+ function publishPairingEvent(ctx: TenantContext, ectx: ExecutionContext, event: {
1407
+ type: "pairing_requested" | "pairing_decided";
1408
+ requestId: string;
1409
+ code: string;
1410
+ name: string;
1411
+ alias: string;
1412
+ ip: string;
1413
+ country: string;
1414
+ createdAt: string;
1415
+ expiresAt: string;
1416
+ decision?: "approved" | "denied";
1417
+ }): void {
1418
+ const target = pairingEventTarget(ctx.EVENT_URL, ctx.EVENT_SECRET);
1419
+ if (!target) return;
1420
+ const agent = pairingAgent(ctx.PUBLIC_URL, ctx.id);
1421
+ try {
1422
+ ectx.waitUntil((async () => {
1423
+ try {
1424
+ const row: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM device_requests WHERE status = 'pending' AND expires_ms > ?").bind(Date.now()).first();
1425
+ await postPairingEvent(target, { ...event, agent, pending: Number(row?.n ?? 0) });
1426
+ } catch (error) {
1427
+ log("pairing_event_failed", { type: event.type, error: String(error).slice(0, 200) });
1428
+ }
1429
+ })());
1430
+ } catch (error) {
1431
+ log("pairing_event_failed", { type: event.type, error: String(error).slice(0, 200) });
1432
+ }
1433
+ }
1434
+
1386
1435
  const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, clientName: r.client_name || "", clientId: r.client_id || "",
1387
1436
  agentCardUrl: r.agent_card_url || "", ip: r.ip || "", country: r.country || "", createdMs: r.created_ms, expiresMs: r.expires_ms,
1388
1437
  replacesLabel: r.replaces_label || undefined });
@@ -1432,14 +1481,14 @@ function cookie(req: Request, name: string): string {
1432
1481
  }
1433
1482
 
1434
1483
  /** GET/POST /device: the owner approves or denies a request with the approval password (no scripts, strict CSP). */
1435
- async function devicePage(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
1484
+ async function devicePage(req: Request, ctx: TenantContext, ectx: ExecutionContext, url: URL, head = false): Promise<Response> {
1436
1485
  const nonce = P.randomB64(), ip = clientIp(req);
1437
1486
  const csrfName = "a2a_device_csrf";
1438
1487
  let csrf = cookie(req, csrfName);
1439
1488
  const fresh = !/^[A-Za-z0-9_-]{24}$/.test(csrf);
1440
1489
  if (fresh) csrf = P.randomB64();
1441
1490
  const pw = await approvalPassword(ctx);
1442
- const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, ...approvalPageBits(ctx) };
1491
+ const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, timeZone: clientTimeZone(req), ...approvalPageBits(ctx) };
1443
1492
  const send = (status = 200) => {
1444
1493
  const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
1445
1494
  h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
@@ -1487,7 +1536,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1487
1536
  }
1488
1537
  const r0 = await pendingRequest(ctx, code);
1489
1538
  if (!r0 || r0.status !== "pending" || Date.now() >= r0.expires_ms) { await show(form.user_code); return send(404); }
1490
- const d0 = await decide(ctx, code, false, "page");
1539
+ const d0 = await decide(ctx, ectx, code, false, "page");
1491
1540
  if (!d0.ok) { await show(null, { kind: "error", text: d0.error! }); return send(409); }
1492
1541
  model.settled = "denied";
1493
1542
  model.notice = { kind: "ok", text: `Denied. ${r0.client_name || r0.client_id || "the agent"} gets no token.` };
@@ -1527,14 +1576,14 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1527
1576
  const left = P.LIMITS.wrongPerCode - (res?.failed_attempts ?? P.LIMITS.wrongPerCode);
1528
1577
  log("pairing_wrong_password", { ip, userCode: P.formatUserCode(code), left });
1529
1578
  if (left <= 0) {
1530
- await decide(ctx, code, false, "lockout");
1579
+ await decide(ctx, ectx, code, false, "lockout");
1531
1580
  await show(null, { kind: "error", text: "Wrong password. Too many wrong attempts for this code: the request was denied." });
1532
1581
  return send(403);
1533
1582
  }
1534
1583
  await show(form.user_code, { kind: "error", text: `Wrong password. ${left} attempt${left === 1 ? "" : "s"} left for this code.` });
1535
1584
  return send(403);
1536
1585
  }
1537
- const d = await decide(ctx, code, true, "human");
1586
+ const d = await decide(ctx, ectx, code, true, "human");
1538
1587
  if (!d.ok) { await show(null, { kind: "error", text: d.error! }); return send(409); }
1539
1588
  const who = r.client_name || r.client_id || "the agent";
1540
1589
  model.settled = "approved";
@@ -1543,10 +1592,10 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1543
1592
  }
1544
1593
 
1545
1594
  /** GET /device/oidc/callback: the identity provider returns here. State is the CSRF; the cookie is not re-checked. */
1546
- async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
1595
+ async function deviceOidcCallback(req: Request, ctx: TenantContext, ectx: ExecutionContext, url: URL, head = false): Promise<Response> {
1547
1596
  const nonce = P.randomB64();
1548
1597
  const pw = await approvalPassword(ctx);
1549
- const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
1598
+ const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, timeZone: clientTimeZone(req), ...approvalPageBits(ctx) };
1550
1599
  const send = (status = 200) => {
1551
1600
  const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
1552
1601
  h["set-cookie"] = `a2a_device_csrf=${model.csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
@@ -1586,7 +1635,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
1586
1635
  model.notice = { kind: "error", text: done.error };
1587
1636
  return send(400);
1588
1637
  }
1589
- const d = await decide(ctx, done.userCode || "", true, "oidc");
1638
+ const d = await decide(ctx, ectx, done.userCode || "", true, "oidc");
1590
1639
  if (!d.ok) { model.notice = { kind: "error", text: d.error! }; return send(409); }
1591
1640
  const who = d.request.client_name || d.request.client_id || "the agent";
1592
1641
  model.settled = "approved";
@@ -1679,7 +1728,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL, head = fals
1679
1728
  }
1680
1729
 
1681
1730
  /** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
1682
- async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
1731
+ async function ownerPairing(req: Request, ctx: TenantContext, ectx: ExecutionContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
1683
1732
  const mode = pairingMode(ctx);
1684
1733
  if (!seg[0] && m === "GET") {
1685
1734
  const now = Date.now();
@@ -1740,7 +1789,7 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
1740
1789
  if (approve && mode !== "agent" && !dashboardHuman && !ownerAuthed)
1741
1790
  return json({ error: `approval mode is human: this call cannot approve. The owner approves with the owner token, or on ${oauthUrls(ctx).page}?user_code=${P.formatUserCode(code)} with the approval password.` }, 403);
1742
1791
  const by = dashboardHuman ? "human" : approve && mode === "agent" ? "agent" : "owner";
1743
- const d = await decide(ctx, code, approve, by);
1792
+ const d = await decide(ctx, ectx, code, approve, by);
1744
1793
  if (!d.ok) return json({ error: d.error }, 409);
1745
1794
  return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
1746
1795
  clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
@@ -2344,7 +2393,8 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2344
2393
  if (a.all === true) qs.set("all", "1");
2345
2394
  const tasks = must(await ownerCall(ctx, ectx, "GET", `/owner/inbox${qs.size ? `?${qs}` : ""}`));
2346
2395
  const pairing = pairingOn(ctx) ? must(await ownerCall(ctx, ectx, "GET", "/owner/pairing")).pending.length : 0;
2347
- const head = `${tasks.length} task(s)${a.all ? "" : " waiting"}${pairing ? `; ${pairing} pending pairing request(s): see pairing_requests (never approve them yourself)` : ""}. ${UNTRUSTED}`;
2396
+ const manage = pairing ? dashboardUrl(ctx) : "";
2397
+ const head = `${tasks.length} task(s)${a.all ? "" : " waiting"}${pairing ? `; ${pairing} pending pairing request(s): see pairing_requests (never approve them yourself)${manage ? `. Approve or deny at ${manage}` : ""}` : ""}. ${UNTRUSTED}`;
2348
2398
  return M.toolText(head, tasks);
2349
2399
  }
2350
2400
  case "show_task":
@@ -2409,9 +2459,10 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2409
2459
  case "pairing_requests": {
2410
2460
  if (!pairingOn(ctx)) return "Device-flow pairing is off on this A2A endpoint (PAIRING_APPROVAL=off): there are no pairing requests.";
2411
2461
  const d = must(await ownerCall(ctx, ectx, "GET", "/owner/pairing"));
2412
- return M.toolText(`${d.pending.length} pending pairing request(s). Show your human who asks, the code and the link. NEVER approve or deny yourself: your human decides on the link with the approval password. Names and card URLs are claimed by the requester (untrusted).`,
2462
+ const manage = dashboardUrl(ctx);
2463
+ return M.toolText(`${d.pending.length} pending pairing request(s). Show your human who asks, the code and the link.${manage ? ` Approve or deny at ${manage}.` : ""} NEVER approve or deny yourself: your human decides on the link with the approval password. Names and card URLs are claimed by the requester (untrusted).`,
2413
2464
  d.pending.map((r: Json) => ({ code: r.userCode, clientName: r.clientName, clientId: r.clientId, agentCardUrl: r.agentCardUrl, country: r.country,
2414
- expiresAt: r.expiresAt, link: r.verificationUriComplete, replacesLabel: r.replacesLabel })));
2465
+ expiresAt: r.expiresAt, link: r.verificationUriComplete, ...(manage ? { manageUrl: manage } : {}), replacesLabel: r.replacesLabel })));
2415
2466
  }
2416
2467
  case "send_feedback": {
2417
2468
  // The owner authorized this client on the consent page. A peer token and the owner token never arrive here.
@@ -2602,8 +2653,8 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
2602
2653
  if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
2603
2654
  if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, ctx, ectx);
2604
2655
  if (req.method === "POST" && path === "/oauth/token") return await tokenEndpoint(req, ctx);
2605
- if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, url, head);
2606
- if (req.method === "GET" && path === "/device/oidc/callback") return await deviceOidcCallback(req, ctx, url, head);
2656
+ if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, ectx, url, head);
2657
+ if (req.method === "GET" && path === "/device/oidc/callback") return await deviceOidcCallback(req, ctx, ectx, url, head);
2607
2658
  if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
2608
2659
  return json({ error: "not found" }, 404);
2609
2660
  }
@@ -0,0 +1,120 @@
1
+ // Dashboard notice for a pairing request. Posted only when EVENT_URL and EVENT_SECRET are both set.
2
+ // The signature matches control/src/tenants/events.ts `verifyEventSignature`.
3
+
4
+ export const EVENT_TIMEOUT_MS = 3000;
5
+
6
+ export type PairingEventType = "pairing_requested" | "pairing_decided";
7
+
8
+ export interface PairingEvent {
9
+ type: PairingEventType;
10
+ agent: string;
11
+ requestId: string;
12
+ code: string;
13
+ name: string;
14
+ alias: string;
15
+ ip: string;
16
+ country: string;
17
+ createdAt: string;
18
+ expiresAt: string;
19
+ pending: number;
20
+ decision?: "approved" | "denied";
21
+ }
22
+
23
+ const URL_MAX = 2000;
24
+ const SECRET_MAX = 512;
25
+
26
+ function httpsEventUrl(value: string | undefined): string {
27
+ const raw = (value || "").trim();
28
+ if (!raw || raw.length > URL_MAX) return "";
29
+ try {
30
+ const url = new URL(raw);
31
+ if (url.protocol !== "https:" || url.username || url.password) return "";
32
+ return raw;
33
+ } catch {
34
+ return "";
35
+ }
36
+ }
37
+
38
+ function eventSecretText(value: string | undefined): string {
39
+ const text = (value || "").trim();
40
+ if (!text || text.length > SECRET_MAX || /[\u0000-\u001f]/.test(text)) return "";
41
+ return text;
42
+ }
43
+
44
+ /** Public host, or the tenant id when the public URL has no host. */
45
+ export function pairingAgent(publicUrl: string, tenantId: string): string {
46
+ try {
47
+ const host = new URL(publicUrl).host;
48
+ if (host) return host;
49
+ } catch { /* tenant id below */ }
50
+ return tenantId || "";
51
+ }
52
+
53
+ /** The post target, or null when either value is missing or unusable. */
54
+ export function pairingEventTarget(url: string | undefined, secret: string | undefined): { url: string; secret: string } | null {
55
+ const target = httpsEventUrl(url);
56
+ const key = eventSecretText(secret);
57
+ if (!target || !key) return null;
58
+ return { url: target, secret: key };
59
+ }
60
+
61
+ /** Stable JSON. `decision` is present only on a decided request. */
62
+ export function serializePairingEvent(event: PairingEvent): string {
63
+ const body: Record<string, string | number> = {
64
+ type: event.type,
65
+ agent: event.agent,
66
+ requestId: event.requestId,
67
+ code: event.code,
68
+ name: event.name,
69
+ alias: event.alias,
70
+ ip: event.ip,
71
+ country: event.country,
72
+ createdAt: event.createdAt,
73
+ expiresAt: event.expiresAt,
74
+ pending: event.pending,
75
+ };
76
+ if (event.type === "pairing_decided" && (event.decision === "approved" || event.decision === "denied")) body.decision = event.decision;
77
+ return JSON.stringify(body);
78
+ }
79
+
80
+ async function hmacHex(secret: string, message: string): Promise<string> {
81
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
82
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
83
+ return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
84
+ }
85
+
86
+ const log = (event: string, kw: Record<string, unknown> = {}) => console.log(JSON.stringify({ ts: new Date().toISOString(), event, ...kw }));
87
+
88
+ /**
89
+ * POST the event. One retry after a timeout or a failed response. The promise settles
90
+ * on its own: a network error stays inside this function.
91
+ */
92
+ export async function postPairingEvent(
93
+ target: { url: string; secret: string },
94
+ event: PairingEvent,
95
+ opts: { fetchImpl?: typeof fetch; timeoutMs?: number; now?: number } = {},
96
+ ): Promise<void> {
97
+ try {
98
+ const body = serializePairingEvent(event);
99
+ const timestamp = String(Math.floor((opts.now ?? Date.now()) / 1000));
100
+ const signature = `sha256=${await hmacHex(target.secret, `${timestamp}.${body}`)}`;
101
+ const fetchImpl = opts.fetchImpl ?? fetch;
102
+ const timeoutMs = opts.timeoutMs ?? EVENT_TIMEOUT_MS;
103
+ const headers = { "content-type": "application/json", "x-a2a-timestamp": timestamp, "x-a2a-signature": signature };
104
+ for (let attempt = 1; attempt <= 2; attempt++) {
105
+ try {
106
+ const res = await fetchImpl(target.url, { method: "POST", headers, body, redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
107
+ try { await res.body?.cancel(); } catch { /* the status is enough */ }
108
+ if (res.ok) {
109
+ log("pairing_event_sent", { type: event.type });
110
+ return;
111
+ }
112
+ log("pairing_event_failed", { type: event.type, attempt, status: res.status });
113
+ } catch (error) {
114
+ log("pairing_event_failed", { type: event.type, attempt, error: String(error).slice(0, 200) });
115
+ }
116
+ }
117
+ } catch (error) {
118
+ log("pairing_event_failed", { type: event.type, error: String(error).slice(0, 200) });
119
+ }
120
+ }
@@ -8,7 +8,7 @@ import { pageStyle } from "./theme.ts";
8
8
  export const DEFAULT_CLI = "npx -y a2a-exposed@latest";
9
9
 
10
10
  export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
11
- export const EXPIRES_S = 600; // device / user code lifetime
11
+ export const EXPIRES_S = 3600; // device / user code lifetime (60 minutes)
12
12
  export const INTERVAL_S = 5; // minimum polling interval (slow_down adds 5 s each time)
13
13
  export const LIMITS = {
14
14
  maxPending: 10, // outstanding (unexpired, undecided) requests, globally: caps approval prompts
@@ -173,12 +173,33 @@ export async function verifyPassword(password: string, rec: PasswordRecord): Pro
173
173
  // ------------------------------------------------------------------ /device page (no scripts, no external assets)
174
174
  export const esc = (s: unknown) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
175
175
 
176
- /** "Expires" cell. A live code with under a minute left says so, instead of "0 min". */
177
- export function expiresText(expiresMs: number, now = Date.now()): string {
176
+ /** "Expires" cell. Clock time in the visitor's time zone, then how long is left. An unknown zone uses UTC. */
177
+ export function expiresText(expiresMs: number, now = Date.now(), timeZone?: string): string {
178
178
  const left = expiresMs - now;
179
179
  if (left <= 0) return "expired";
180
- if (left < 60_000) return "less than a minute";
181
- return `${Math.round(left / 60000)} min`;
180
+ const relative = left < 60_000 ? "less than a minute" : `${Math.round(left / 60000)} min`;
181
+ const { zone, suffix } = clockZone(timeZone);
182
+ return `${clockTime(expiresMs, zone)}${suffix} (${relative})`;
183
+ }
184
+
185
+ function clockZone(timeZone: string | undefined): { zone: string; suffix: string } {
186
+ const zone = (timeZone || "").trim();
187
+ if (!zone || zone.toUpperCase() === "UTC" || zone === "Etc/UTC" || zone === "Etc/GMT") return { zone: "UTC", suffix: " UTC" };
188
+ try {
189
+ new Intl.DateTimeFormat("en-GB", { timeZone: zone }).format(0);
190
+ return { zone, suffix: "" };
191
+ } catch {
192
+ return { zone: "UTC", suffix: " UTC" };
193
+ }
194
+ }
195
+
196
+ function clockTime(ms: number, timeZone: string): string {
197
+ const parts = new Intl.DateTimeFormat("en-GB", {
198
+ hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone,
199
+ }).formatToParts(new Date(ms));
200
+ const hour = parts.find((part) => part.type === "hour")?.value ?? "00";
201
+ const minute = parts.find((part) => part.type === "minute")?.value ?? "00";
202
+ return `${hour}:${minute}`;
182
203
  }
183
204
 
184
205
  export type PageRequest = { userCode: string; clientName: string; clientId: string; agentCardUrl: string; ip: string; country: string; createdMs: number; expiresMs: number; replacesLabel?: string; tokenLabel?: string };
@@ -196,6 +217,8 @@ export type PageModel = {
196
217
  oidc?: { label: string };
197
218
  /** False hides the password field. Absent means the password field is shown as before. */
198
219
  passwordEnabled?: boolean;
220
+ /** IANA time zone from the request, when Cloudflare provides one. The expiry clock uses it. */
221
+ timeZone?: string;
199
222
  /** Set after Approve or Deny: the page shows that result and no code form. */
200
223
  settled?: "approved" | "denied";
201
224
  };
@@ -213,7 +236,7 @@ export function devicePage(m: PageModel, nonce: string): string {
213
236
  ...(r.clientId && r.clientName && r.clientId !== r.clientName ? [["Alias (claimed)", r.clientId]] : []),
214
237
  ["Agent card (claimed)", r.agentCardUrl ? r.agentCardUrl + (cardIsPrivate(r.agentCardUrl) ? " (private network address, e.g. a Tailnet: not publicly reachable, so this A2A endpoint can't check it; informational only)" : "") : "(none given)"],
215
238
  ["Requested from", `${[r.ip || "unknown address", r.country].filter(Boolean).join(", ")} (IP address of the machine that asked to connect)`],
216
- ["Expires", expiresText(r.expiresMs)],
239
+ ["Expires", expiresText(r.expiresMs, Date.now(), m.timeZone)],
217
240
  ...(r.replacesLabel ? [["Replaces", `the active token "${r.replacesLabel}" (the requester proved it holds it); approving issues a new token under the same label and the old one stops working`]] : []),
218
241
  ] : [];
219
242
  const tokenLabel = r?.tokenLabel || "<label>";
@@ -39,6 +39,12 @@ export const CONFIG_KEYS = [
39
39
  "PAIRING_APPROVAL", "PBKDF2_ITERATIONS",
40
40
  "UPSTREAM_URL", "UPSTREAM_CARD_URL", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
41
41
  "MCP",
42
+ /** Optional https URL of this agent's dashboard page. Omitted from pairing output when unset or not https. */
43
+ "MANAGE_URL",
44
+ /** Optional https URL that receives a signed pairing event. Omitted from the post when the secret is absent. */
45
+ "EVENT_URL",
46
+ /** HMAC key for that post. A Worker secret on a self-hosted agent. On a hosted agent the sealed secrets blob supplies it. */
47
+ "EVENT_SECRET",
42
48
  ] as const;
43
49
 
44
50
  export type ConfigKey = (typeof CONFIG_KEYS)[number];
@@ -205,6 +211,19 @@ export async function derivePeerKey(ikm: string, info = PEER_KEY_INFO): Promise<
205
211
  );
206
212
  }
207
213
 
214
+ /** https dashboard URL with no userinfo, at most 500 characters. Any other value returns "". */
215
+ export function publicManageUrl(value: string | undefined): string {
216
+ const s = (value || "").trim();
217
+ if (!s || s.length > 500) return "";
218
+ try {
219
+ const u = new URL(s);
220
+ if (u.protocol !== "https:" || u.username || u.password) return "";
221
+ return s;
222
+ } catch {
223
+ return "";
224
+ }
225
+ }
226
+
208
227
  function csv(v: string | undefined): string[] {
209
228
  return (v || "").split(",").map((s) => s.trim()).filter(Boolean);
210
229
  }
@@ -367,6 +386,9 @@ export class TenantContext implements ConfigFields {
367
386
  readonly UPSTREAM_ACCESS_CLIENT_ID?: string;
368
387
  readonly UPSTREAM_ACCESS_CLIENT_SECRET?: string;
369
388
  readonly MCP?: string;
389
+ readonly MANAGE_URL?: string;
390
+ readonly EVENT_URL?: string;
391
+ readonly EVENT_SECRET?: string;
370
392
 
371
393
  /** Self-host only. Never set on a hosted context, so a platform OWNER_TOKEN cannot unlock a tenant. */
372
394
  readonly #ownerToken?: string;
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.7";
2
+ export const VERSION = "0.8.9";
@@ -5,12 +5,13 @@
5
5
  const INFO = "tenant-wake-secrets v1";
6
6
  const SALT = "a2a-exposed";
7
7
 
8
- /** Secret wake fields. Anything else in the blob is ignored. */
8
+ /** Sealed secret fields. Anything else in the blob is ignored. */
9
9
  export const WAKE_SECRET_KEYS = [
10
10
  "WAKE_WEBHOOK_KEY",
11
11
  "WAKE_HMAC_SECRET",
12
12
  "WAKE_ACCESS_CLIENT_ID",
13
13
  "WAKE_ACCESS_CLIENT_SECRET",
14
+ "EVENT_SECRET",
14
15
  ] as const;
15
16
 
16
17
  export type WakeSecretKey = (typeof WAKE_SECRET_KEYS)[number];
@@ -51,6 +52,17 @@ async function derive(ikm: string, tenantId: string): Promise<CryptoKey | null>
51
52
  );
52
53
  }
53
54
 
55
+ /**
56
+ * Config written next to a sealed blob. The sealed fields stay in `secrets_enc`.
57
+ * With no ciphertext, the config is returned unchanged.
58
+ */
59
+ export function omitSealedSecrets<T extends Record<string, unknown>>(config: T, secretsEnc: string | null | undefined): T {
60
+ if (typeof secretsEnc !== "string" || secretsEnc === "") return config;
61
+ const stored = { ...config };
62
+ for (const key of WAKE_SECRET_KEYS) delete stored[key];
63
+ return stored;
64
+ }
65
+
54
66
  function payloadOf(secrets: WakeSecrets): Record<string, string> | null {
55
67
  const payload: Record<string, string> = {};
56
68
  for (const key of WAKE_SECRET_KEYS) {
@@ -23,6 +23,8 @@ export type PairingInfo = {
23
23
  agentCardUrl: string;
24
24
  agentCardPrivate?: boolean; // the claimed card is on a private network (Tailnet, LAN): informational, not reachable
25
25
  expiresIn: number;
26
+ /** Dashboard page for this agent. Present only when MANAGE_URL is an https URL. */
27
+ manageUrl?: string;
26
28
  replacesLabel?: string; // re-pairing: the requester proved it holds this active token; approval replaces it
27
29
  };
28
30
 
@@ -78,11 +80,14 @@ export function wakePayload(ev: WakeEvent, cli: string): Record<string, unknown>
78
80
 
79
81
  /** What the woken agent must do with a pairing request: ask its human, never approve on its own. */
80
82
  export function pairingInstructions(p: PairingInfo, cli: string): string {
83
+ const dash = p.manageUrl ? ` The dashboard page for this request is ${p.manageUrl}.` : "";
81
84
  return p.approval === "human"
82
85
  ? `Ask your human; never approve on your own. Show them the code ${p.userCode} and give them this link: ${p.verificationUriComplete} . ` +
83
- `They approve there with their approval password, or with \`${cli} pair approve ${p.userCode}\` (the owner token), after checking the code with the other agent's owner. Deny with \`${cli} pair deny ${p.userCode}\`. Details: \`${cli} pair list\`.`
86
+ `They approve there with their approval password, or with \`${cli} pair approve ${p.userCode}\` (the owner token), after checking the code with the other agent's owner. Deny with \`${cli} pair deny ${p.userCode}\`. Details: \`${cli} pair list\`.` +
87
+ dash
84
88
  : `Ask your human; never approve on your own. Show them the code ${p.userCode}, who is asking, and the link ${p.verificationUriComplete} . ` +
85
- `Only if they say yes, run \`${cli} pair approve ${p.userCode}\`; if they say no (or don't answer), \`${cli} pair deny ${p.userCode}\`.`;
89
+ `Only if they say yes, run \`${cli} pair approve ${p.userCode}\`; if they say no (or don't answer), \`${cli} pair deny ${p.userCode}\`.` +
90
+ dash;
86
91
  }
87
92
 
88
93
  function hintFor(ev: WakeEvent, cli: string): string {
@@ -421,10 +421,34 @@ test("tools: inbox, show_task, history, mark_working, reply (with state), pairin
421
421
  assert.match(pr.content[0].text, /NEVER approve/);
422
422
  assert.match(pr.content[0].text, new RegExp(st.data.user_code));
423
423
  assert.ok(!pr.content[0].text.includes('"ip"'), "requester IPs aren't handed to the model");
424
- assert.match((await s.tool(a.access_token, "inbox")).content[0].text, /1 pending pairing request/);
424
+ const pendingLine = (await s.tool(a.access_token, "inbox")).content[0].text;
425
+ assert.match(pendingLine, /1 pending pairing request/);
426
+ assert.equal(pendingLine.includes("manageUrl"), false);
427
+ assert.equal(pendingLine.includes("Approve or deny at"), false);
425
428
  assert.equal((s.DB.db.prepare("SELECT status FROM device_requests").get() as any).status, "pending");
426
429
  });
427
430
 
431
+ test("MANAGE_URL is on the MCP inbox line and pairing_requests for a pending request", async (t) => {
432
+ const pageUrl = "https://example.com/app/agents/test#pairing";
433
+ const s = setup({ MANAGE_URL: pageUrl });
434
+ t.after(s.restore);
435
+ const a = await s.connect();
436
+ await s.call("POST", "/oauth/device_authorization", { form: { client_name: "Mallory", client_id: "m" } });
437
+ const inbox = (await s.tool(a.access_token, "inbox")).content[0].text;
438
+ assert.match(inbox, new RegExp(`1 pending pairing request\\(s\\): see pairing_requests \\(never approve them yourself\\)\\. Approve or deny at ${pageUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
439
+ const listed = (await s.tool(a.access_token, "pairing_requests")).content[0].text;
440
+ assert.match(listed, new RegExp(`Approve or deny at ${pageUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
441
+ assert.match(listed, new RegExp(`"manageUrl": "${pageUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
442
+
443
+ const off = setup({ MANAGE_URL: "not a url" });
444
+ t.after(off.restore);
445
+ const grant = await off.connect();
446
+ await off.call("POST", "/oauth/device_authorization", { form: { client_name: "Mallory", client_id: "m" } });
447
+ const quiet = (await off.tool(grant.access_token, "pairing_requests")).content[0].text;
448
+ assert.equal(quiet.includes("manageUrl"), false);
449
+ assert.equal(quiet.includes("Approve or deny at"), false);
450
+ });
451
+
428
452
  test("tools: send / poll_outbound through synced peers; each peer's token stays with that peer; tokens never shown", async (t) => {
429
453
  const s = setup(); t.after(s.restore);
430
454
  const a = await s.connect();
@@ -0,0 +1,263 @@
1
+ // Pairing events: signature, retry, payload, and the hosted tenant path.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import worker, { dispatch } from "../src/index.ts";
5
+ import { hostedTenantContext, type WorkerBindings } from "../src/tenancy.ts";
6
+ import { postPairingEvent, type PairingEvent } from "../src/pairing-events.ts";
7
+ import { openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
8
+ import { EVENT_REPLAY_WINDOW_S, verifyEventSignature } from "../../control/src/tenants/events.ts";
9
+ import { d1 } from "./d1.ts";
10
+
11
+ const BASE = "https://agent.example.com";
12
+ const EVENT_URL = "https://example.com/api/agent-events";
13
+ const SECRET = "event-secret";
14
+
15
+ function sample(over: Partial<PairingEvent> = {}): PairingEvent {
16
+ return {
17
+ type: "pairing_requested", agent: "agent.example.com", requestId: "abc", code: "WDJB-4827",
18
+ name: "Barry Bot", alias: "barry", ip: "198.51.100.7", country: "DE",
19
+ createdAt: "2026-10-16T14:00:00.000Z", expiresAt: "2026-10-16T15:00:00.000Z", pending: 1, ...over,
20
+ };
21
+ }
22
+
23
+ test("verifyEventSignature accepts the posted bytes and rejects a replay, a tampered body, and a wrong secret", async () => {
24
+ const now = Date.UTC(2026, 9, 16, 14, 0, 0);
25
+ const seen: { body: string; headers: Headers }[] = [];
26
+ await postPairingEvent({ url: EVENT_URL, secret: SECRET }, sample(), {
27
+ now,
28
+ fetchImpl: (async (url: string, init: RequestInit) => {
29
+ seen.push({ body: String(init.body), headers: new Headers(init.headers) });
30
+ assert.equal(url, EVENT_URL);
31
+ assert.equal(init.redirect, "manual");
32
+ return new Response("ok");
33
+ }) as typeof fetch,
34
+ });
35
+ assert.equal(seen.length, 1);
36
+ const { body, headers } = seen[0];
37
+ const parsed = JSON.parse(body);
38
+ assert.equal(parsed.type, "pairing_requested");
39
+ assert.equal(parsed.pending, 1);
40
+ assert.equal("decision" in parsed, false);
41
+ assert.equal(await verifyEventSignature(body, headers, SECRET, now), true);
42
+ assert.equal(await verifyEventSignature(body, headers, SECRET, now + EVENT_REPLAY_WINDOW_S * 1000), true);
43
+ assert.equal(await verifyEventSignature(body, headers, SECRET, now + (EVENT_REPLAY_WINDOW_S + 1) * 1000), false);
44
+ assert.equal(await verifyEventSignature(body + " ", headers, SECRET, now), false);
45
+ assert.equal(await verifyEventSignature(body, headers, "other-secret", now), false);
46
+ const shifted = new Headers(headers);
47
+ shifted.set("x-a2a-timestamp", String(Math.floor(now / 1000) + 1));
48
+ assert.equal(await verifyEventSignature(body, shifted, SECRET, now), false);
49
+
50
+ const decided = sample({ type: "pairing_decided", decision: "approved", pending: 0 });
51
+ let decidedBody = "";
52
+ await postPairingEvent({ url: EVENT_URL, secret: SECRET }, decided, {
53
+ now,
54
+ fetchImpl: (async (_url: string, init: RequestInit) => {
55
+ decidedBody = String(init.body);
56
+ return new Response("ok");
57
+ }) as typeof fetch,
58
+ });
59
+ assert.equal(JSON.parse(decidedBody).decision, "approved");
60
+ });
61
+
62
+ test("a timeout is retried once and a failed post stays inside the function", async () => {
63
+ let calls = 0;
64
+ await postPairingEvent({ url: EVENT_URL, secret: SECRET }, sample(), {
65
+ timeoutMs: 30,
66
+ fetchImpl: (async (_url: string, init: RequestInit) => {
67
+ calls++;
68
+ if (calls === 1) {
69
+ await new Promise((_resolve, reject) => {
70
+ const timer = setTimeout(() => reject(new Error("the attempt was not aborted")), 1000);
71
+ init.signal?.addEventListener("abort", () => {
72
+ clearTimeout(timer);
73
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
74
+ });
75
+ });
76
+ }
77
+ return new Response("ok");
78
+ }) as typeof fetch,
79
+ });
80
+ assert.equal(calls, 2);
81
+
82
+ let failed = 0;
83
+ await postPairingEvent({ url: EVENT_URL, secret: SECRET }, sample(), {
84
+ timeoutMs: 30,
85
+ fetchImpl: (async () => {
86
+ failed++;
87
+ return new Response("no", { status: 503 });
88
+ }) as typeof fetch,
89
+ });
90
+ assert.equal(failed, 2);
91
+
92
+ await assert.doesNotReject(postPairingEvent({ url: EVENT_URL, secret: SECRET }, sample(), {
93
+ timeoutMs: 30,
94
+ fetchImpl: (async () => { throw new Error("network down"); }) as typeof fetch,
95
+ }));
96
+ });
97
+
98
+ function env(over: Record<string, string> = {}) {
99
+ const DB = d1(new URL("../migrations/", import.meta.url));
100
+ return { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox", ...over };
101
+ }
102
+
103
+ async function start(bindings: Record<string, string>, fetchImpl: typeof fetch) {
104
+ const pending: Promise<unknown>[] = [];
105
+ const bindingsEnv = env(bindings);
106
+ // Durable Object storage applies migrations on the first query. Run that query before the timed request
107
+ // so the race covers the pairing response.
108
+ await bindingsEnv.DB.prepare("SELECT 1").first();
109
+ const previous = globalThis.fetch;
110
+ globalThis.fetch = fetchImpl;
111
+ const req = new Request(BASE + "/oauth/device_authorization", {
112
+ method: "POST",
113
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.7" },
114
+ body: new URLSearchParams({ client_name: "Barry Bot", client_id: "barry", agent_card_url: "https://barry.example.org/card" }).toString(),
115
+ });
116
+ (req as { cf?: unknown }).cf = { country: "DE" };
117
+ const response = worker.fetch(req, bindingsEnv as WorkerBindings, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as ExecutionContext);
118
+ return {
119
+ response, pending, restore: () => { globalThis.fetch = previous; },
120
+ };
121
+ }
122
+
123
+ test("the response returns before the event post, and the payload carries the open count", async (t) => {
124
+ let release: () => void = () => {};
125
+ const gate = new Promise<void>((resolve) => { release = resolve; });
126
+ let body = "";
127
+ let headers = new Headers();
128
+ let started = false;
129
+ const run = await start({ EVENT_URL, EVENT_SECRET: SECRET }, (async (url: string, init: RequestInit) => {
130
+ if (String(url) !== EVENT_URL) return new Response("ok");
131
+ started = true;
132
+ body = String(init?.body);
133
+ headers = new Headers(init?.headers);
134
+ await gate;
135
+ return new Response("ok");
136
+ }) as typeof fetch);
137
+ t.after(async () => {
138
+ release();
139
+ await run.response.catch(() => {});
140
+ await Promise.allSettled(run.pending);
141
+ run.restore();
142
+ });
143
+ const res = await Promise.race([
144
+ run.response,
145
+ new Promise<Response>((_resolve, reject) => setTimeout(() => reject(new Error("the pairing response waited for the event post")), 500)),
146
+ ]);
147
+ assert.equal(started, false);
148
+ assert.equal(res.status, 200);
149
+ const data = await res.json() as { user_code: string };
150
+ const deadline = Date.now() + 2000;
151
+ while (!started && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
152
+ assert.equal(started, true);
153
+ release();
154
+ await Promise.all(run.pending);
155
+ const event = JSON.parse(body);
156
+ assert.equal(event.type, "pairing_requested");
157
+ assert.equal(event.agent, "agent.example.com");
158
+ assert.equal(event.code, data.user_code);
159
+ assert.equal(event.name, "Barry Bot");
160
+ assert.equal(event.alias, "barry");
161
+ assert.equal(event.ip, "198.51.100.7");
162
+ assert.equal(event.country, "DE");
163
+ assert.equal(event.pending, 1);
164
+ assert.match(event.requestId, /^[0-9a-f]{64}$/);
165
+ assert.equal(await verifyEventSignature(body, headers, SECRET), true);
166
+
167
+ const quiet: string[] = [];
168
+ const off = await start({}, (async (url: string) => {
169
+ quiet.push(String(url));
170
+ return new Response("ok");
171
+ }) as typeof fetch);
172
+ assert.equal((await off.response).status, 200);
173
+ await Promise.all(off.pending);
174
+ off.restore();
175
+ assert.equal(quiet.some((url) => url === EVENT_URL), false);
176
+ });
177
+
178
+ test("a hosted tenant posts with the control plane's EVENT_URL and EVENT_SECRET", async () => {
179
+ const DB = d1(new URL("../migrations/", import.meta.url));
180
+ await DB.prepare("SELECT 1").first();
181
+ const sealed = await sealWakeSecrets("platform-secret", "ten_north", { EVENT_SECRET: SECRET });
182
+ assert.ok(sealed);
183
+ assert.equal(sealed.includes(SECRET), false);
184
+ const opened = await openWakeSecrets("platform-secret", "ten_north", sealed);
185
+ const config: Record<string, string> = { EVENT_URL, EVENT_SECRET: "plaintext-copy" };
186
+ for (const name of WAKE_SECRET_KEYS) {
187
+ const value = opened?.[name];
188
+ if (value) config[name] = value;
189
+ else delete config[name];
190
+ }
191
+ const ctx = hostedTenantContext({ DB, TENANCY: "host", EVENT_URL: "https://platform.example/other", EVENT_SECRET: "platform" } as WorkerBindings, {
192
+ id: "ten_north", db: DB, publicUrl: "https://northwind.example.com",
193
+ config,
194
+ });
195
+ assert.equal(ctx.mode, "host");
196
+ assert.equal(ctx.EVENT_URL, EVENT_URL);
197
+ assert.equal(ctx.EVENT_SECRET, SECRET);
198
+ const posts: { body: string; headers: Headers }[] = [];
199
+ const previous = globalThis.fetch;
200
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
201
+ if (String(url) === EVENT_URL) posts.push({ body: String(init.body), headers: new Headers(init.headers) });
202
+ return new Response("ok");
203
+ }) as typeof fetch;
204
+ const pending: Promise<unknown>[] = [];
205
+ const req = new Request("https://northwind.example.com/oauth/device_authorization", {
206
+ method: "POST",
207
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "203.0.113.9" },
208
+ body: new URLSearchParams({ client_name: "Claude", client_id: "claude" }).toString(),
209
+ });
210
+ (req as { cf?: unknown }).cf = { country: "US" };
211
+ const res = await dispatch(req, ctx, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as ExecutionContext);
212
+ await Promise.all(pending);
213
+ globalThis.fetch = previous;
214
+ assert.equal(res.status, 200);
215
+ assert.equal(posts.length, 1);
216
+ const event = JSON.parse(posts[0].body);
217
+ assert.equal(event.agent, "northwind.example.com");
218
+ assert.equal(event.name, "Claude");
219
+ assert.equal(event.alias, "claude");
220
+ assert.equal(event.country, "US");
221
+ assert.equal(event.pending, 1);
222
+ assert.equal(await verifyEventSignature(posts[0].body, posts[0].headers, SECRET), true);
223
+
224
+ const plain = hostedTenantContext({ DB, TENANCY: "host", EVENT_URL, EVENT_SECRET: SECRET } as WorkerBindings, {
225
+ id: "ten_plain", db: DB, publicUrl: "https://plain.example.com",
226
+ });
227
+ assert.equal(plain.EVENT_URL, undefined);
228
+ assert.equal(plain.EVENT_SECRET, undefined);
229
+ });
230
+
231
+ test("approve posts pairing_decided with the remaining open count", async () => {
232
+ const bindings = env({ EVENT_URL, EVENT_SECRET: SECRET });
233
+ const posts: { body: string }[] = [];
234
+ const previous = globalThis.fetch;
235
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
236
+ if (String(url) === EVENT_URL) posts.push({ body: String(init.body) });
237
+ return new Response("ok");
238
+ }) as typeof fetch;
239
+ const pending: Promise<unknown>[] = [];
240
+ const ectx = { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as ExecutionContext;
241
+ const call = async (method: string, path: string, body?: string, headers: Record<string, string> = {}) => {
242
+ const res = await worker.fetch(new Request(BASE + path, { method, headers, body }), bindings as WorkerBindings, ectx);
243
+ await Promise.all(pending.splice(0));
244
+ return res;
245
+ };
246
+ const started = await call("POST", "/oauth/device_authorization", new URLSearchParams({ client_name: "Barry Bot", client_id: "barry" }).toString(), {
247
+ "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.7",
248
+ });
249
+ const first = await started.json() as { user_code: string };
250
+ const secondRes = await call("POST", "/oauth/device_authorization", new URLSearchParams({ client_name: "Other", client_id: "other" }).toString(), {
251
+ "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.8",
252
+ });
253
+ assert.equal(secondRes.status, 200);
254
+ const denied = await call("POST", `/owner/pairing/${first.user_code}/deny`, "{}", { authorization: "Bearer owner-secret", "content-type": "application/json" });
255
+ globalThis.fetch = previous;
256
+ assert.equal(denied.status, 200);
257
+ const decided = posts.map((post) => JSON.parse(post.body)).filter((event) => event.type === "pairing_decided");
258
+ assert.equal(decided.length, 1);
259
+ assert.equal(decided[0].decision, "denied");
260
+ assert.equal(decided[0].code, first.user_code);
261
+ assert.equal(decided[0].pending, 1);
262
+ assert.equal(decided[0].name, "Barry Bot");
263
+ });
@@ -35,14 +35,16 @@ function setup(over: Record<string, string> = {}) {
35
35
  wakes.push({ url: String(url), body: JSON.parse(init.body) });
36
36
  return new Response("ok");
37
37
  }) as any;
38
- const call = async (method: string, path: string, o: { form?: Record<string, string>; json?: any; headers?: Record<string, string>; ip?: string; cookie?: string } = {}) => {
38
+ const call = async (method: string, path: string, o: { form?: Record<string, string>; json?: any; headers?: Record<string, string>; ip?: string; cookie?: string; cf?: Record<string, unknown> } = {}) => {
39
39
  const headers: Record<string, string> = { "cf-connecting-ip": o.ip || "198.51.100.7", ...(o.headers || {}) };
40
40
  let body: string | undefined;
41
41
  if (o.form) { headers["content-type"] = "application/x-www-form-urlencoded"; body = new URLSearchParams(o.form).toString(); }
42
42
  if (o.json !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(o.json); }
43
43
  if (o.cookie) headers.cookie = o.cookie;
44
44
  const pending: Promise<unknown>[] = [];
45
- const res = await worker.fetch(new Request(BASE + path, { method, headers, body }) as any, env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
45
+ const req = new Request(BASE + path, { method, headers, body }) as any;
46
+ if (o.cf) req.cf = o.cf;
47
+ const res = await worker.fetch(req, env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
46
48
  await Promise.allSettled(pending);
47
49
  const text = await res.text();
48
50
  let data: any = text;
@@ -255,7 +257,7 @@ test("human approval: pending, slow_down, page approve with the password, single
255
257
  assert.match(d.user_code, /^[BCDFGHJKLMNPQRSTVWXZ]{4}-[2-9]{4}$/);
256
258
  assert.equal(d.verification_uri, `${BASE}/device`);
257
259
  assert.equal(d.verification_uri_complete, `${BASE}/device?user_code=${d.user_code}`);
258
- assert.deepEqual([d.expires_in, d.interval], [600, 5]);
260
+ assert.deepEqual([d.expires_in, d.interval], [3600, 5]);
259
261
  // the device code is stored hashed only
260
262
  const row: any = s.DB.db.prepare("SELECT * FROM device_requests").get();
261
263
  assert.notEqual(row.device_hash, d.device_code);
@@ -268,6 +270,7 @@ test("human approval: pending, slow_down, page approve with the password, single
268
270
  assert.equal(w.hint, "npx -y a2a-exposed@latest pair list");
269
271
  assert.deepEqual([w.pairing.userCode, w.pairing.verificationUriComplete, w.pairing.approval, w.pairing.clientId], [d.user_code, d.verification_uri_complete, "human", "barry"]);
270
272
  assert.equal(w.pairing.agentCardUrl, "https://barry.example.org/.well-known/agent-card.json");
273
+ assert.equal("manageUrl" in w.pairing, false);
271
274
  assert.match(w.pairing.instructions, /never approve on your own[\s\S]*give them this link/);
272
275
 
273
276
  let p = await s.poll(d.device_code);
@@ -447,7 +450,7 @@ test("labels are deduplicated; denied and expired requests; JSON bodies; bad req
447
450
  assert.equal((await s.poll(c.device_code)).data.error, "invalid_grant", "reported once, then gone");
448
451
 
449
452
  const e = (await s.start(undefined, "203.0.113.4")).data;
450
- advance(601);
453
+ advance(P.EXPIRES_S + 1);
451
454
  assert.equal((await s.poll(e.device_code)).data.error, "expired_token");
452
455
  assert.equal((await s.owner("POST", `/owner/pairing/${e.user_code}/approve`)).status, 409);
453
456
  advance(3600);
@@ -500,7 +503,7 @@ test("flood control: requests per IP and outstanding requests are capped (no flo
500
503
  assert.equal(over.status, 429);
501
504
  assert.match(over.data.error_description, /too many pairing requests waiting/);
502
505
  // expiry frees the slots
503
- advance(601);
506
+ advance(P.EXPIRES_S + 1);
504
507
  assert.equal((await s.start(undefined, "198.51.100.99")).status, 200);
505
508
  // the page: code lookups per IP are capped (no enumerating pending requests)
506
509
  for (let i = 0; i < 30; i++) assert.doesNotMatch((await s.call("GET", "/device?user_code=BCDF-2345", { ip: "192.0.2.77" })).text, /Too many code lookups/);
@@ -510,9 +513,9 @@ test("flood control: requests per IP and outstanding requests are capped (no flo
510
513
 
511
514
  test("pairing wakes: summary for each preset; requester text only where previews are allowed", async () => {
512
515
  const ev: WakeEvent = { contextId: "pairing", taskId: "none", taskIds: [], from: "Barry", preview: "", kind: "pairing_request", publicUrl: BASE,
513
- pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Barry", clientId: "barry", agentCardUrl: "https://barry.example.org/card", expiresIn: 600 } };
516
+ pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Barry", clientId: "barry", agentCardUrl: "https://barry.example.org/card", expiresIn: 3600 } };
514
517
  const sum = wakeSummary(ev, "npx -y a2a-exposed@latest");
515
- assert.match(sum, /^A2A pairing request: an agent calling itself "Barry" \(claimed card: "https:\/\/barry\.example\.org\/card"\) asks to connect to your A2A endpoint \(code WDJB-2345, expires in 10 minutes\)\./);
518
+ assert.match(sum, /^A2A pairing request: an agent calling itself "Barry" \(claimed card: "https:\/\/barry\.example\.org\/card"\) asks to connect to your A2A endpoint \(code WDJB-2345, expires in 60 minutes\)\./);
516
519
  assert.match(sum, /never approve on your own\. Show them the code WDJB-2345 and give them this link: https:\/\/agent\.example\.com\/device\?user_code=WDJB-2345/);
517
520
  const agentSum = wakeSummary({ ...ev, pairing: { ...ev.pairing!, approval: "agent" } }, "a2a");
518
521
  assert.match(agentSum, /Only if they say yes, run `a2a pair approve WDJB-2345`; if they say no \(or don't answer\), `a2a pair deny WDJB-2345`/);
@@ -874,10 +877,13 @@ test("owner log lists history without data_json and does not clear a pending wak
874
877
  });
875
878
 
876
879
  test("device page copy: code hint, expiry, token scope, settled result, A2A endpoint", async (t) => {
880
+ const at = Date.UTC(2026, 9, 16, 14, 32, 0);
881
+ assert.equal(P.expiresText(at, at - 30_000), "14:32 UTC (less than a minute)");
882
+ assert.equal(P.expiresText(at, at - 90_000), "14:32 UTC (2 min)");
883
+ assert.equal(P.expiresText(at, at - 90_000, "America/New_York"), "10:32 (2 min)");
884
+ assert.equal(P.expiresText(at, at - 90_000, "Not/AZone"), "14:32 UTC (2 min)");
885
+ assert.equal(P.expiresText(at, at + 1), "expired");
877
886
  const now = Date.now();
878
- assert.equal(P.expiresText(now + 30_000, now), "less than a minute");
879
- assert.equal(P.expiresText(now + 90_000, now), "2 min");
880
- assert.equal(P.expiresText(now - 1, now), "expired");
881
887
  const base = { agentName: "this A2A endpoint", mode: "human" as const, cli: "a2a", csrf: "c".repeat(24), passwordSet: true };
882
888
  const bare = P.devicePage({ ...base, askCode: true }, "n");
883
889
  assert.match(bare, /The connecting agent prints this code when it runs <code>a2a connect &lt;url&gt;<\/code>/);
@@ -888,7 +894,13 @@ test("device page copy: code hint, expiry, token scope, settled result, A2A endp
888
894
  assert.match(page, /so this A2A endpoint can&#39;t check it/);
889
895
  assert.match(page, /send messages and trigger wakes/);
890
896
  assert.match(page, /Message text from peers is untrusted/);
891
- assert.match(page, /less than a minute/);
897
+ assert.match(page, /\d{2}:\d{2} UTC \(less than a minute\)/);
898
+ const localExpiry = now + 10 * 24 * 3600 * 1000;
899
+ const localParts = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "America/New_York" }).formatToParts(new Date(localExpiry));
900
+ const localClock = `${localParts.find((part) => part.type === "hour")?.value}:${localParts.find((part) => part.type === "minute")?.value}`;
901
+ const localPage = P.devicePage({ ...base, timeZone: "America/New_York", request: { ...req, expiresMs: localExpiry } }, "n");
902
+ assert.match(localPage, new RegExp(`${localClock} \\((?:less than a minute|\\d+ min)\\)`));
903
+ assert.equal(localPage.includes("UTC"), false);
892
904
  assert.match(page, /Revoke the token &lt;label&gt; any time with <code>a2a token revoke &lt;label&gt;<\/code>/);
893
905
  const namedToken = P.devicePage({ ...base, request: { ...req, tokenLabel: "barry" } }, "n");
894
906
  assert.match(namedToken, /Revoke the token barry any time with <code>a2a token revoke barry<\/code>/);
@@ -913,3 +925,33 @@ test("device page copy: code hint, expiry, token scope, settled result, A2A endp
913
925
  const home = await s.call("GET", "/", { headers: { accept: "text/html" } });
914
926
  assert.match(home.text, /Messages are stored for its owner, and the agent is woken/);
915
927
  });
928
+
929
+ test("approval page shows the visitor's clock, and an https MANAGE_URL is on the pairing wake", async (t) => {
930
+ const pageUrl = "https://example.com/app/agents/test#pairing";
931
+ const s = setup({ MANAGE_URL: pageUrl });
932
+ t.after(s.restore);
933
+ const st = await s.start(undefined, "203.0.113.80");
934
+ assert.equal(st.status, 200, JSON.stringify(st.data));
935
+ const row = s.DB.db.prepare("SELECT expires_ms FROM device_requests").get() as { expires_ms: number };
936
+ const parts = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "America/New_York" }).formatToParts(new Date(row.expires_ms));
937
+ const clock = `${parts.find((part) => part.type === "hour")?.value}:${parts.find((part) => part.type === "minute")?.value}`;
938
+ const page = await s.call("GET", `/device?user_code=${st.data.user_code}`, { cf: { timezone: "America/New_York" }, ip: "203.0.113.80" });
939
+ assert.match(page.text, new RegExp(`${clock.replace(":", "\\:")} \\(60 min\\)`));
940
+ assert.equal(page.text.includes(" UTC "), false);
941
+ const wake = s.wakes[0].body.pairing;
942
+ assert.equal(wake.manageUrl, pageUrl);
943
+ assert.equal(wake.expiresIn, 3600);
944
+ assert.ok(wake.instructions.includes(`The dashboard page for this request is ${pageUrl}`));
945
+
946
+ const bare = setup();
947
+ t.after(bare.restore);
948
+ const plain = await bare.start(undefined, "203.0.113.81");
949
+ assert.equal("manageUrl" in bare.wakes[0].body.pairing, false);
950
+ const utc = await bare.call("GET", `/device?user_code=${plain.data.user_code}`, { ip: "203.0.113.81" });
951
+ assert.match(utc.text, /\d{2}:\d{2} UTC \(60 min\)/);
952
+
953
+ const bad = setup({ MANAGE_URL: "http://example.com/app" });
954
+ t.after(bad.restore);
955
+ await bad.start(undefined, "203.0.113.82");
956
+ assert.equal("manageUrl" in bad.wakes[0].body.pairing, false);
957
+ });
@@ -5,7 +5,8 @@ import assert from "node:assert/strict";
5
5
  import worker from "../src/index.ts";
6
6
  import { sha256 } from "../src/a2a.ts";
7
7
  import { openPeerToken, openPeerTokenWithKey, sealPeerToken, sealPeerTokenWithKey } from "../src/mcp.ts";
8
- import { hostedTenantContext, namespaceForRegion, parseGates, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
8
+ import { hostedTenantContext, namespaceForRegion, parseGates, publicManageUrl, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
9
+ import { openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
9
10
  import { d1 } from "./d1.ts";
10
11
 
11
12
  const dummy = { prepare() { throw new Error("storage should not be touched"); } } as any;
@@ -15,6 +16,51 @@ function selfEnv(over: Record<string, unknown> = {}): WorkerBindings {
15
16
  return { DB: dummy, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", ...over } as WorkerBindings;
16
17
  }
17
18
 
19
+ test("publicManageUrl keeps an https dashboard page and drops anything else", () => {
20
+ const page = "https://example.com/app/agents/test#pairing";
21
+ assert.equal(publicManageUrl(page), page);
22
+ assert.equal(publicManageUrl(` ${page} `), page);
23
+ assert.equal(publicManageUrl(""), "");
24
+ assert.equal(publicManageUrl(undefined), "");
25
+ assert.equal(publicManageUrl("http://example.com/app"), "");
26
+ assert.equal(publicManageUrl("https://user:pw@example.com/app"), "");
27
+ assert.equal(resolveTenant(selfEnv({ MANAGE_URL: page })).MANAGE_URL, page);
28
+ assert.equal(resolveTenant(selfEnv()).MANAGE_URL, undefined);
29
+ const hosted = hostedTenantContext(selfEnv({ TENANCY: "host", MANAGE_URL: "https://platform.example/other" }), {
30
+ id: "ten_a", db: dummy, publicUrl: BASE, config: { MANAGE_URL: page },
31
+ });
32
+ assert.equal(hosted.MANAGE_URL, page);
33
+ const hostedUnset = hostedTenantContext(selfEnv({ TENANCY: "host", MANAGE_URL: page }), {
34
+ id: "ten_a", db: dummy, publicUrl: BASE,
35
+ });
36
+ assert.equal(hostedUnset.MANAGE_URL, undefined);
37
+ });
38
+
39
+ test("EVENT_URL and EVENT_SECRET come from the tenant on a hosted agent", async () => {
40
+ const url = "https://example.com/api/agent-events";
41
+ assert.equal(resolveTenant(selfEnv({ EVENT_URL: url, EVENT_SECRET: "event-secret" })).EVENT_SECRET, "event-secret");
42
+ const sealed = await sealWakeSecrets("platform-secret", "ten_a", { EVENT_SECRET: "tenant-secret" });
43
+ assert.ok(sealed);
44
+ assert.equal(sealed.includes("tenant-secret"), false);
45
+ const opened = await openWakeSecrets("platform-secret", "ten_a", sealed);
46
+ const config: Record<string, string> = { EVENT_URL: url, EVENT_SECRET: "plaintext-copy" };
47
+ for (const name of WAKE_SECRET_KEYS) {
48
+ const value = opened?.[name];
49
+ if (value) config[name] = value;
50
+ else delete config[name];
51
+ }
52
+ const hosted = hostedTenantContext(selfEnv({ TENANCY: "host", EVENT_URL: "https://platform.example/other", EVENT_SECRET: "platform" }), {
53
+ id: "ten_a", db: dummy, publicUrl: BASE, config,
54
+ });
55
+ assert.equal(hosted.EVENT_URL, url);
56
+ assert.equal(hosted.EVENT_SECRET, "tenant-secret");
57
+ const hostedUnset = hostedTenantContext(selfEnv({ TENANCY: "host", EVENT_URL: url, EVENT_SECRET: "platform" }), {
58
+ id: "ten_a", db: dummy, publicUrl: BASE,
59
+ });
60
+ assert.equal(hostedUnset.EVENT_URL, undefined);
61
+ assert.equal(hostedUnset.EVENT_SECRET, undefined);
62
+ });
63
+
18
64
  test("gates: unset is single-tenant and every reserved gate is off", () => {
19
65
  const g = parseGates({ DB: dummy } as WorkerBindings).gates;
20
66
  assert.equal(g.tenancy, "single");
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { MAX_WAKE_SEALED, MAX_WAKE_VALUE, openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
3
+ import { MAX_WAKE_SEALED, MAX_WAKE_VALUE, omitSealedSecrets, openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
4
4
 
5
5
  const key = "platform-secret";
6
6
  const tenant = "ten_a";
@@ -30,7 +30,7 @@ test("a wake secret blob keeps only the known fields", async () => {
30
30
  assert.equal(await sealWakeSecrets(key, tenant, { WAKE_WEBHOOK_KEY: "x".repeat(4097) }), null);
31
31
  });
32
32
 
33
- test("four maximum-length wake secrets round-trip", async () => {
33
+ test("every maximum-length sealed secret round-trips", async () => {
34
34
  const value = "x".repeat(MAX_WAKE_VALUE);
35
35
  const secrets = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, value]));
36
36
  const sealed = await sealWakeSecrets(key, tenant, secrets);
@@ -47,3 +47,16 @@ test("four maximum-length wake secrets round-trip", async () => {
47
47
  assert.ok(sealedWorst!.length <= MAX_WAKE_SEALED);
48
48
  assert.deepEqual(await openWakeSecrets(key, tenant, sealedWorst), worst);
49
49
  });
50
+
51
+ test("a sealed blob's fields are left out of the stored config", () => {
52
+ const url = "https://example.com/api/agent-events";
53
+ const stored = omitSealedSecrets({
54
+ AGENT_NAME: "northwind", EVENT_URL: url, EVENT_SECRET: "event-secret", WAKE_WEBHOOK_KEY: "hook-key",
55
+ }, "v1.iv.ct");
56
+ assert.equal(stored.EVENT_URL, url);
57
+ assert.equal(stored.AGENT_NAME, "northwind");
58
+ assert.equal("EVENT_SECRET" in stored, false);
59
+ assert.equal("WAKE_WEBHOOK_KEY" in stored, false);
60
+ assert.equal(omitSealedSecrets({ EVENT_SECRET: "event-secret" }, null).EVENT_SECRET, "event-secret");
61
+ assert.equal(omitSealedSecrets({ EVENT_SECRET: "event-secret" }, "").EVENT_SECRET, "event-secret");
62
+ });