a2a-exposed 0.8.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.8",
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": {
@@ -64,6 +64,8 @@ const plain: Record<string, string> = {
64
64
  APPROVAL_METHODS: v("APPROVAL_METHODS"),
65
65
  // Optional https URL of this agent's dashboard page. A pairing wake and the MCP inbox line include it as manageUrl.
66
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"),
67
69
  };
68
70
  const hosted = v("TENANCY") === "host";
69
71
  // Daily R2 snapshots. Unset: no binding and no extra cron, so a self-host deploy stays the single-tenant Worker.
@@ -103,7 +105,8 @@ for (const [k, val] of Object.entries(plain)) if (val !== "" || (k === "WAKE_KEY
103
105
  const SECRETS = ["OWNER_TOKEN", "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
104
106
  // Declared for `cf dev` only when a deploy asks for them, so a self-host .dev.vars without these still starts.
105
107
  ...(v("TENANCY") === "host" ? ["TENANT_SECRETS_KEY"] : []),
106
- ...(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"] : [])];
107
110
 
108
111
  export default defineConfig((ctx) => ({
109
112
  ...(v("CLOUDFLARE_ACCOUNT_ID") ? { accountId: v("CLOUDFLARE_ACCOUNT_ID") } : {}),
@@ -47,6 +47,10 @@ WAKE_PRESET=generic
47
47
  # Optional https URL of this agent's dashboard page. When set, a pairing wake and the MCP inbox line
48
48
  # include it as manageUrl.
49
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.
50
54
  # Daily SQL snapshots to R2. Unset: no BACKUP_BUCKET binding and no extra cron. The minute wake cron stays A2A_ENABLE_CRON.
51
55
  # A2A_BACKUP_BUCKET=1
52
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,6 +11,7 @@ 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 { pairingAgent, pairingEventTarget, postPairingEvent } from "./pairing-events.ts";
14
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";
@@ -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,
@@ -1270,13 +1271,15 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1270
1271
  return oauthError("slow_down", "this A2A endpoint has too many pairing requests waiting; try again later", 429, retry);
1271
1272
  }
1272
1273
  const deviceCode = P.newDeviceCode();
1274
+ const deviceHash = await A.sha256(deviceCode);
1275
+ const country = clientCountry(req);
1273
1276
  let userCode = "";
1274
1277
  for (let i = 0; i < 5 && !userCode; i++) {
1275
1278
  const c = P.newUserCode();
1276
1279
  const r = await ctx.DB.prepare(
1277
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)
1278
1281
  VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) ON CONFLICT DO NOTHING`,
1279
- ).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,
1280
1283
  now, now + P.EXPIRES_S * 1000, P.INTERVAL_S).run();
1281
1284
  if (r.meta.changes === 1) userCode = c;
1282
1285
  }
@@ -1302,6 +1305,8 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1302
1305
  ...(manage ? { manageUrl: manage } : {}),
1303
1306
  ...(replaces ? { replacesLabel: replaces.label } : {}) } });
1304
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() });
1305
1310
  return json({ device_code: deviceCode, user_code: shown, verification_uri: u.page, verification_uri_complete: complete,
1306
1311
  expires_in: P.EXPIRES_S, interval: P.INTERVAL_S, ...(replaces ? { replaces_label: replaces.label } : {}) }, 200, { pragma: "no-cache" });
1307
1312
  }
@@ -1375,7 +1380,7 @@ const pendingRequest = async (ctx: TenantContext, code: string): Promise<Json |
1375
1380
  ctx.DB.prepare("SELECT * FROM device_requests WHERE user_code = ?").bind(code).first();
1376
1381
 
1377
1382
  /** Approve or deny a pending request. Returns the label (approve) or an error message. */
1378
- 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 }> {
1379
1384
  const r = await pendingRequest(ctx, code);
1380
1385
  if (!r) return { ok: false, error: "no pairing request with that code" };
1381
1386
  if (r.status !== "pending") return { ok: false, error: `that request was already ${r.status === "denied" ? "denied" : "approved"}` };
@@ -1387,9 +1392,46 @@ async function decide(ctx: TenantContext, code: string, approve: boolean, by: st
1387
1392
  .bind(approve ? "approved" : "denied", label, Date.now(), by, r.device_hash).run();
1388
1393
  if (u.meta.changes !== 1) return { ok: false, error: "that request was decided meanwhile" };
1389
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" });
1390
1399
  return { ok: true, label: label || undefined, request: r, replaced: replacing };
1391
1400
  }
1392
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
+
1393
1435
  const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, clientName: r.client_name || "", clientId: r.client_id || "",
1394
1436
  agentCardUrl: r.agent_card_url || "", ip: r.ip || "", country: r.country || "", createdMs: r.created_ms, expiresMs: r.expires_ms,
1395
1437
  replacesLabel: r.replaces_label || undefined });
@@ -1439,7 +1481,7 @@ function cookie(req: Request, name: string): string {
1439
1481
  }
1440
1482
 
1441
1483
  /** GET/POST /device: the owner approves or denies a request with the approval password (no scripts, strict CSP). */
1442
- 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> {
1443
1485
  const nonce = P.randomB64(), ip = clientIp(req);
1444
1486
  const csrfName = "a2a_device_csrf";
1445
1487
  let csrf = cookie(req, csrfName);
@@ -1494,7 +1536,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1494
1536
  }
1495
1537
  const r0 = await pendingRequest(ctx, code);
1496
1538
  if (!r0 || r0.status !== "pending" || Date.now() >= r0.expires_ms) { await show(form.user_code); return send(404); }
1497
- const d0 = await decide(ctx, code, false, "page");
1539
+ const d0 = await decide(ctx, ectx, code, false, "page");
1498
1540
  if (!d0.ok) { await show(null, { kind: "error", text: d0.error! }); return send(409); }
1499
1541
  model.settled = "denied";
1500
1542
  model.notice = { kind: "ok", text: `Denied. ${r0.client_name || r0.client_id || "the agent"} gets no token.` };
@@ -1534,14 +1576,14 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1534
1576
  const left = P.LIMITS.wrongPerCode - (res?.failed_attempts ?? P.LIMITS.wrongPerCode);
1535
1577
  log("pairing_wrong_password", { ip, userCode: P.formatUserCode(code), left });
1536
1578
  if (left <= 0) {
1537
- await decide(ctx, code, false, "lockout");
1579
+ await decide(ctx, ectx, code, false, "lockout");
1538
1580
  await show(null, { kind: "error", text: "Wrong password. Too many wrong attempts for this code: the request was denied." });
1539
1581
  return send(403);
1540
1582
  }
1541
1583
  await show(form.user_code, { kind: "error", text: `Wrong password. ${left} attempt${left === 1 ? "" : "s"} left for this code.` });
1542
1584
  return send(403);
1543
1585
  }
1544
- const d = await decide(ctx, code, true, "human");
1586
+ const d = await decide(ctx, ectx, code, true, "human");
1545
1587
  if (!d.ok) { await show(null, { kind: "error", text: d.error! }); return send(409); }
1546
1588
  const who = r.client_name || r.client_id || "the agent";
1547
1589
  model.settled = "approved";
@@ -1550,7 +1592,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1550
1592
  }
1551
1593
 
1552
1594
  /** GET /device/oidc/callback: the identity provider returns here. State is the CSRF; the cookie is not re-checked. */
1553
- 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> {
1554
1596
  const nonce = P.randomB64();
1555
1597
  const pw = await approvalPassword(ctx);
1556
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) };
@@ -1593,7 +1635,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
1593
1635
  model.notice = { kind: "error", text: done.error };
1594
1636
  return send(400);
1595
1637
  }
1596
- const d = await decide(ctx, done.userCode || "", true, "oidc");
1638
+ const d = await decide(ctx, ectx, done.userCode || "", true, "oidc");
1597
1639
  if (!d.ok) { model.notice = { kind: "error", text: d.error! }; return send(409); }
1598
1640
  const who = d.request.client_name || d.request.client_id || "the agent";
1599
1641
  model.settled = "approved";
@@ -1686,7 +1728,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL, head = fals
1686
1728
  }
1687
1729
 
1688
1730
  /** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
1689
- 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> {
1690
1732
  const mode = pairingMode(ctx);
1691
1733
  if (!seg[0] && m === "GET") {
1692
1734
  const now = Date.now();
@@ -1747,7 +1789,7 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
1747
1789
  if (approve && mode !== "agent" && !dashboardHuman && !ownerAuthed)
1748
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);
1749
1791
  const by = dashboardHuman ? "human" : approve && mode === "agent" ? "agent" : "owner";
1750
- const d = await decide(ctx, code, approve, by);
1792
+ const d = await decide(ctx, ectx, code, approve, by);
1751
1793
  if (!d.ok) return json({ error: d.error }, 409);
1752
1794
  return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
1753
1795
  clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
@@ -2611,8 +2653,8 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
2611
2653
  if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
2612
2654
  if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, ctx, ectx);
2613
2655
  if (req.method === "POST" && path === "/oauth/token") return await tokenEndpoint(req, ctx);
2614
- if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, url, head);
2615
- 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);
2616
2658
  if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
2617
2659
  return json({ error: "not found" }, 404);
2618
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
+ }
@@ -41,6 +41,10 @@ export const CONFIG_KEYS = [
41
41
  "MCP",
42
42
  /** Optional https URL of this agent's dashboard page. Omitted from pairing output when unset or not https. */
43
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",
44
48
  ] as const;
45
49
 
46
50
  export type ConfigKey = (typeof CONFIG_KEYS)[number];
@@ -383,6 +387,8 @@ export class TenantContext implements ConfigFields {
383
387
  readonly UPSTREAM_ACCESS_CLIENT_SECRET?: string;
384
388
  readonly MCP?: string;
385
389
  readonly MANAGE_URL?: string;
390
+ readonly EVENT_URL?: string;
391
+ readonly EVENT_SECRET?: string;
386
392
 
387
393
  /** Self-host only. Never set on a hosted context, so a platform OWNER_TOKEN cannot unlock a tenant. */
388
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.8";
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) {
@@ -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
+ });
@@ -6,6 +6,7 @@ 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
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;
@@ -35,6 +36,31 @@ test("publicManageUrl keeps an https dashboard page and drops anything else", ()
35
36
  assert.equal(hostedUnset.MANAGE_URL, undefined);
36
37
  });
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
+
38
64
  test("gates: unset is single-tenant and every reserved gate is off", () => {
39
65
  const g = parseGates({ DB: dummy } as WorkerBindings).gates;
40
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
+ });