a2a-exposed 0.7.11 → 0.7.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.11",
3
+ "version": "0.7.12",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,13 +13,15 @@
13
13
  // Usage push is not in this change. Approval OIDC, when configured (self-host settings, or the pushed tenant
14
14
  // `approval` object), uses the same pages as self-host.
15
15
  import { DurableObject } from "cloudflare:workers";
16
- import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite } from "./index.ts";
16
+ import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite, runOwnerRpc } from "./index.ts";
17
+ import { accountIdOf, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
17
18
  import { doSqlD1, migrateDo, type SqlStorageLike, type TxRunner } from "./storage.ts";
18
19
  import { MIGRATIONS } from "./migrations.ts";
19
20
  import { bookmarkForTime as readBookmark, exportRows, restoreBookmark as applyBookmark, restartAfterResult, toSql, ExportError, type BookmarkStorage } from "./export.ts";
20
21
  import { hostedDailyBackup } from "./backup.ts";
21
22
  import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
22
23
  import { hostedTenantContext, namespaceForRegion, parseGates, type WorkerBindings } from "./tenancy.ts";
24
+ import { WAKE_SECRET_KEYS, openWakeSecrets } from "./wake-secrets.ts";
23
25
 
24
26
  export { clearDirectoryCache, directoryKey, lookupDirectory, parseDirectoryEntry, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME } from "./directory.ts";
25
27
  export type { DirectoryEntry };
@@ -32,7 +34,7 @@ const gone = () => Response.json({ error: "this agent is gone" }, { status: 410,
32
34
 
33
35
  const TENANT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
34
36
 
35
- /** Versioned config pushed by the control plane. `secretsEnc` is stored and not decrypted here. */
37
+ /** Versioned config pushed by the control plane. `secretsEnc` is decrypted into the wake credentials when the context is built. */
36
38
  export interface TenantConfigPush {
37
39
  version: number;
38
40
  tenantId: string;
@@ -151,14 +153,29 @@ export class TenantStore extends DurableObject<WorkerBindings> {
151
153
  });
152
154
  }
153
155
 
154
- #tenant(fallbackOrigin = "") {
156
+ async #tenant(fallbackOrigin = "") {
155
157
  const row = this.#row;
156
158
  if (!row) return null;
157
- const domain = parseGates(this.env).gates.tenantDomain;
159
+ const parsed = parseGates(this.env);
160
+ const domain = parsed.gates.tenantDomain;
158
161
  const publicUrl = domain ? `https://${row.name}.${domain}` : (fallbackOrigin || this.env.PUBLIC_URL || "");
162
+ const config = { ...row.config };
163
+ // Once a ciphertext is stored, the secret wake fields come only from it. A plaintext copy in
164
+ // config_json is ignored, and a blob that cannot be opened leaves those fields unset.
165
+ if (row.secretsEnc) {
166
+ const opened = await openWakeSecrets(parsed.secretsKey || "", row.tenantId, row.secretsEnc);
167
+ if (!opened) {
168
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "wake_secrets_unreadable", tenant: row.tenantId }));
169
+ }
170
+ for (const key of WAKE_SECRET_KEYS) {
171
+ const value = opened?.[key];
172
+ if (value) config[key] = value;
173
+ else delete config[key];
174
+ }
175
+ }
159
176
  return hostedTenantContext(this.env, {
160
177
  id: row.tenantId, db: this.#db, publicUrl, ownerTokenHash: row.ownerTokenHash,
161
- config: row.config, limits: row.limits, approval: row.approval, status: row.status,
178
+ config, limits: row.limits, approval: row.approval, status: row.status,
162
179
  });
163
180
  }
164
181
 
@@ -198,7 +215,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
198
215
  await storage.deleteAlarm();
199
216
  return;
200
217
  }
201
- const tenant = this.#tenant();
218
+ const tenant = await this.#tenant();
202
219
  if (!tenant) return;
203
220
  const at = await nextCronAt(tenant);
204
221
  if (at == null || !Number.isFinite(at)) {
@@ -226,7 +243,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
226
243
  }
227
244
 
228
245
  async #runAlarm(): Promise<void> {
229
- const tenant = this.#row?.status === "active" ? this.#tenant() : null;
246
+ const tenant = this.#row?.status === "active" ? await this.#tenant() : null;
230
247
  if (tenant) {
231
248
  try { await cronFlush(tenant, this.#ectx()); }
232
249
  catch (e) { console.log(JSON.stringify({ ts: new Date().toISOString(), event: "alarm_failed", error: String(e).slice(0, 200) })); }
@@ -337,6 +354,27 @@ export class TenantStore extends DurableObject<WorkerBindings> {
337
354
  return out;
338
355
  }
339
356
 
357
+ /**
358
+ * Control-plane owner call. The caller has already checked that `accountId` owns this tenant.
359
+ * The path is an allowlist, the account id is logged, and a response that contains a peer token
360
+ * or a decrypted secret is withheld. The network owner API is unchanged and still needs the owner token.
361
+ * An object with no config row answers 404 and writes nothing.
362
+ */
363
+ async owner(input: OwnerRpcRequest): Promise<OwnerRpcResult> {
364
+ if (!this.#row) {
365
+ const accountId = accountIdOf(input && typeof input === "object" ? (input as { accountId?: unknown }).accountId : undefined);
366
+ if (!accountId) {
367
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "owner_rpc_refused", tenant: null, reason: "account" }));
368
+ return { status: 400, body: { error: "accountId is invalid" } };
369
+ }
370
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "owner_rpc_refused", tenant: null, accountId, reason: "absent" }));
371
+ return { status: 404, body: { error: "no such agent" } };
372
+ }
373
+ const tenant = await this.#tenant();
374
+ if (!tenant) return { status: 404, body: { error: "no such agent" } };
375
+ return runOwnerRpc(tenant, this.#ectx(), input);
376
+ }
377
+
340
378
  async fetch(req: Request): Promise<Response> {
341
379
  const row = this.#row;
342
380
  if (!row) return notFound();
@@ -346,7 +384,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
346
384
  if (row.status === "deleting") return gone();
347
385
  return notFound();
348
386
  }
349
- const tenant = this.#tenant(new URL(req.url).origin);
387
+ const tenant = await this.#tenant(new URL(req.url).origin);
350
388
  if (!tenant) return notFound();
351
389
  // Do not arm from the rows visible here. sendWake runs in waitUntil and, on 429/503, inserts the retry
352
390
  // after this response returns. requeueWake arms in that insert. Arm again when those tasks finish so a
@@ -10,6 +10,7 @@ import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaul
10
10
  import { fetchCheckedWake } from "./wake-target.ts";
11
11
  import * as P from "./pairing.ts";
12
12
  import * as M from "./mcp.ts";
13
+ import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
13
14
  import { resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
14
15
  import { exportRows, toSql } from "./export.ts";
15
16
  import { backupDatabase, DAILY_CRON } from "./backup.ts";
@@ -898,7 +899,8 @@ function ownerTaskView(t: Json): Json {
898
899
  }
899
900
 
900
901
  async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionContext, path: string, url: URL, trusted = false): Promise<Response> {
901
- // trusted: an in-process call from an MCP tool (fixed, allowlisted paths; see ownerCall), never from the network
902
+ // trusted: an in-process call. MCP tools use ownerCall (paths fixed in the tool code). The hosted
903
+ // owner() RPC uses runOwnerRpc, which has its own allowlist. A request from the network is never trusted.
902
904
  if (!trusted && !(await isOwner(ctx, req.headers.get("authorization")))) return json({ error: "unauthorized" }, 401);
903
905
  const m = req.method;
904
906
  const seg = path.split("/").filter(Boolean).slice(1); // after "owner"
@@ -2073,6 +2075,51 @@ async function mcpGrantOf(ctx: TenantContext, header: string | null): Promise<Js
2073
2075
  return await ctx.DB.prepare("SELECT * FROM mcp_grants WHERE access_hash = ? AND revoked_at IS NULL AND access_expires_ms > ?").bind(await A.sha256(m[1]), Date.now()).first();
2074
2076
  }
2075
2077
 
2078
+ /**
2079
+ * Hosted owner RPC. The control plane has already checked that `accountId` owns this tenant.
2080
+ * Only an allowlisted path runs, and the response is withheld when it contains a peer token or a secret.
2081
+ * Self-host (`mode` single) is refused here; its owner API stays the bearer check in `handleOwner`.
2082
+ */
2083
+ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, input: OwnerRpcRequest): Promise<OwnerRpcResult> {
2084
+ const raw = input && typeof input === "object" ? input as OwnerRpcRequest & { body?: unknown } : null;
2085
+ const accountId = accountIdOf(raw?.accountId);
2086
+ const tenant = ctx.id || null;
2087
+ const refuse = (status: number, error: string, reason: string): OwnerRpcResult => {
2088
+ log("owner_rpc_refused", { tenant, ...(accountId ? { accountId } : {}), reason });
2089
+ return { status, body: { error } };
2090
+ };
2091
+ if (!accountId) return refuse(400, "accountId is invalid", "account");
2092
+ if (ctx.mode !== "host") return refuse(404, "not found", "tenancy");
2093
+ if (ctx.status !== "active") {
2094
+ const status = ctx.status === "suspended" ? 403 : ctx.status === "deleting" ? 410 : 404;
2095
+ const error = ctx.status === "suspended" ? "this agent is suspended" : ctx.status === "deleting" ? "this agent is gone" : "no such agent";
2096
+ return refuse(status, error, "status");
2097
+ }
2098
+ const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
2099
+ if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
2100
+ if (raw?.body !== undefined && raw.body !== null) return refuse(400, "this call takes no body", "body");
2101
+ let origin = "https://tenant.invalid";
2102
+ try {
2103
+ const u = new URL(ctx.PUBLIC_URL);
2104
+ if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
2105
+ } catch { /* the handler reads the path, not this origin */ }
2106
+ const url = new URL(origin + matched.route.path + matched.route.search);
2107
+ const req = new Request(url, { method: matched.route.method, headers: { "content-type": "application/json" } });
2108
+ let res: Response;
2109
+ try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
2110
+ catch (e: any) {
2111
+ const status = e instanceof HttpError ? e.status : 400;
2112
+ const error = String(e?.message || "bad request").slice(0, 200);
2113
+ if (ownerResponseLeaks(error)) return refuse(500, "response withheld", "secret");
2114
+ log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
2115
+ return { status, body: { error } };
2116
+ }
2117
+ const data = await res.json().catch(() => null);
2118
+ if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
2119
+ log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
2120
+ return { status: res.status, body: data };
2121
+ }
2122
+
2076
2123
  /** Call an owner API route in-process (MCP tools only; the paths are fixed in the tool code). */
2077
2124
  async function ownerCall(ctx: TenantContext, ectx: ExecutionContext, method: string, path: string, body?: Json): Promise<{ status: number; data: Json }> {
2078
2125
  const url = new URL(ctx.PUBLIC_URL.replace(/\/$/, "") + path);
@@ -0,0 +1,175 @@
1
+ // Hosted owner RPC allowlist. The control plane calls TenantStore.owner() after it has checked that
2
+ // the account owns the tenant. This list is the only paths that call may run, and it is stricter than
3
+ // the owner HTTP API: routes that mint a peer token, return a setup secret, accept a plaintext peer
4
+ // token, or dump credential material (export) are not here. Self-host never calls this.
5
+
6
+ import { ID_RE } from "./a2a.ts";
7
+ import { previewHeaderPlain } from "./wake.ts";
8
+
9
+ /** Control-plane account id. Slashes and dots are refused so the value cannot be a path. */
10
+ export const ACCOUNT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
11
+
12
+ const LABEL = /^[A-Za-z0-9_-]{1,32}$/;
13
+ const MCP_LABEL = /^[A-Za-z0-9_-]{1,64}$/;
14
+ const ALIAS = /^[A-Za-z0-9_.-]{1,64}$/;
15
+ /** Display form WDJB-4827 or the stored form WDJB4827. Vowels and 0/1 are not in a real code. */
16
+ const USER_CODE = /^[BCDFGHJKLMNPQRSTVWXZ]{4}-?[2-9]{4}$/i;
17
+
18
+ const TOKEN_RE = /^(?:a2aow_|s2a_|a2amcp_|a2amcpr_)[A-Za-z0-9_-]{20,}$/;
19
+ const SETUP_RE = /(?:\?|&)t=[A-Za-z0-9_-]{16,}/;
20
+ const USERINFO_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?\s]*@/i;
21
+ /** A non-empty string under one of these keys is a secret, even when it is not a token shape. */
22
+ const SECRET_KEYS = new Set([
23
+ "token", "access_token", "refresh_token", "device_code",
24
+ "ownertoken", "owner_token", "secretsenc", "secrets_enc",
25
+ "clientsecret", "client_secret", "pushtoken", "push_token", "authorization",
26
+ ]);
27
+
28
+ export interface OwnerRpcRequest {
29
+ accountId: string;
30
+ method: string;
31
+ path: string;
32
+ /** Allowlisted query parameters only. */
33
+ query?: Record<string, string>;
34
+ /** Refused. The owner HTTP API is where a body is accepted. */
35
+ body?: unknown;
36
+ }
37
+
38
+ export interface OwnerRpcResult {
39
+ status: number;
40
+ body: unknown;
41
+ }
42
+
43
+ export interface OwnerRoute {
44
+ method: "GET" | "POST" | "DELETE";
45
+ path: string;
46
+ search: string;
47
+ /** Log name. Ids and codes are not included. */
48
+ name: string;
49
+ }
50
+
51
+ export type OwnerMatch = { ok: true; route: OwnerRoute } | { ok: false; reason: "path" | "query" };
52
+
53
+ export function accountIdOf(v: unknown): string | null {
54
+ return typeof v === "string" && ACCOUNT_ID_RE.test(v) ? v : null;
55
+ }
56
+
57
+ function segments(path: unknown): string[] | null {
58
+ if (typeof path !== "string" || path.length > 300 || !path.startsWith("/owner/")) return null;
59
+ if (path.includes("%") || path.includes("\\") || path.includes("?") || path.includes("#") || path.includes("//")) return null;
60
+ const seg = path.split("/").filter(Boolean);
61
+ if (seg[0] !== "owner" || seg.some((s) => s === "." || s === "..")) return null;
62
+ return seg;
63
+ }
64
+
65
+ function queryRecord(query: unknown): Record<string, string> | null {
66
+ if (query == null) return {};
67
+ if (typeof query !== "object" || Array.isArray(query)) return null;
68
+ const out: Record<string, string> = {};
69
+ for (const [k, v] of Object.entries(query)) {
70
+ if (typeof v !== "string" || v.length > 128) return null;
71
+ out[k] = v;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function idOk(s: string): boolean {
77
+ return ID_RE.test(s) && s !== "." && s !== "..";
78
+ }
79
+
80
+ function noQuery(q: Record<string, string>): string | null {
81
+ return Object.keys(q).length === 0 ? "" : null;
82
+ }
83
+
84
+ function inboxQuery(q: Record<string, string>): string | null {
85
+ for (const k of Object.keys(q)) if (k !== "context" && k !== "all") return null;
86
+ if (q.context !== undefined && !idOk(q.context)) return null;
87
+ if (q.all !== undefined && q.all !== "1") return null;
88
+ const sp = new URLSearchParams();
89
+ if (q.context) sp.set("context", q.context);
90
+ if (q.all) sp.set("all", "1");
91
+ const s = sp.toString();
92
+ return s ? `?${s}` : "";
93
+ }
94
+
95
+ function historyQuery(q: Record<string, string>): string | null {
96
+ for (const k of Object.keys(q)) if (k !== "n") return null;
97
+ if (q.n === undefined) return "";
98
+ if (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000) return null;
99
+ return `?n=${q.n}`;
100
+ }
101
+
102
+ interface RouteDef {
103
+ method: "GET" | "POST" | "DELETE";
104
+ name: string;
105
+ take: (seg: string[]) => boolean;
106
+ search: (q: Record<string, string>) => string | null;
107
+ path: (seg: string[]) => string;
108
+ }
109
+
110
+ const ROUTES: RouteDef[] = [
111
+ { method: "GET", name: "GET /owner/inbox", take: (s) => s.length === 2 && s[1] === "inbox", search: inboxQuery, path: () => "/owner/inbox" },
112
+ { method: "GET", name: "GET /owner/tasks/:id", take: (s) => s.length === 3 && s[1] === "tasks" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/tasks/${s[2]}` },
113
+ { method: "GET", name: "GET /owner/history/:id", take: (s) => s.length === 3 && s[1] === "history" && idOk(s[2]!), search: historyQuery, path: (s) => `/owner/history/${s[2]}` },
114
+ { method: "GET", name: "GET /owner/outbound/:id", take: (s) => s.length === 3 && s[1] === "outbound" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/outbound/${s[2]}` },
115
+ { method: "GET", name: "GET /owner/peers", take: (s) => s.length === 2 && s[1] === "peers", search: noQuery, path: () => "/owner/peers" },
116
+ { method: "DELETE", name: "DELETE /owner/peers/:label", take: (s) => s.length === 3 && s[1] === "peers" && LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/peers/${s[2]}` },
117
+ { method: "GET", name: "GET /owner/pairing", take: (s) => s.length === 2 && s[1] === "pairing", search: noQuery, path: () => "/owner/pairing" },
118
+ { method: "POST", name: "POST /owner/pairing/:code/approve", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "approve", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/approve` },
119
+ { method: "POST", name: "POST /owner/pairing/:code/deny", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "deny", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/deny` },
120
+ { method: "GET", name: "GET /owner/mcp", take: (s) => s.length === 2 && s[1] === "mcp", search: noQuery, path: () => "/owner/mcp" },
121
+ { method: "DELETE", name: "DELETE /owner/mcp/:label", take: (s) => s.length === 3 && s[1] === "mcp" && MCP_LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/mcp/${s[2]}` },
122
+ { method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
123
+ { method: "DELETE", name: "DELETE /owner/outbound-peers/:alias", take: (s) => s.length === 3 && s[1] === "outbound-peers" && ALIAS.test(s[2]!) && s[2] !== "." && s[2] !== "..", search: noQuery, path: (s) => `/owner/outbound-peers/${s[2]}` },
124
+ { method: "GET", name: "GET /owner/facade", take: (s) => s.length === 2 && s[1] === "facade", search: noQuery, path: () => "/owner/facade" },
125
+ { method: "POST", name: "POST /owner/facade/verify", take: (s) => s.length === 3 && s[1] === "facade" && s[2] === "verify", search: noQuery, path: () => "/owner/facade/verify" },
126
+ { method: "GET", name: "GET /owner/wake/preview", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "preview", search: noQuery, path: () => "/owner/wake/preview" },
127
+ { method: "POST", name: "POST /owner/wake/test", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "test", search: noQuery, path: () => "/owner/wake/test" },
128
+ { method: "GET", name: "GET /owner/contexts", take: (s) => s.length === 2 && s[1] === "contexts", search: noQuery, path: () => "/owner/contexts" },
129
+ ];
130
+
131
+ /** Method + path (+ query) on the owner RPC allowlist, or why it is not. */
132
+ export function matchOwnerRpc(method: unknown, path: unknown, query: unknown): OwnerMatch {
133
+ if (method !== "GET" && method !== "POST" && method !== "DELETE") return { ok: false, reason: "path" };
134
+ const seg = segments(path);
135
+ if (!seg) return { ok: false, reason: "path" };
136
+ const def = ROUTES.find((r) => r.method === method && r.take(seg));
137
+ if (!def) return { ok: false, reason: "path" };
138
+ const q = queryRecord(query);
139
+ if (!q) return { ok: false, reason: "query" };
140
+ const search = def.search(q);
141
+ if (search === null) return { ok: false, reason: "query" };
142
+ return { ok: true, route: { method, path: def.path(seg), search, name: def.name } };
143
+ }
144
+
145
+ function stringLeaks(value: string, key: string | null): boolean {
146
+ if (TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value)) return true;
147
+ return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
148
+ }
149
+
150
+ /** A rendered wake whose credential header was not masked. The header name is the caller's choice. */
151
+ function wakePreviewLeaks(value: object): boolean {
152
+ const headers = (value as { method?: unknown; headers?: unknown }).headers;
153
+ if ((value as { method?: unknown }).method !== "POST" || !headers || typeof headers !== "object" || Array.isArray(headers)) return false;
154
+ for (const [k, v] of Object.entries(headers)) {
155
+ if (typeof v !== "string" || previewHeaderPlain(k) || v.endsWith("…")) continue;
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /** True when a JSON value carries a peer token, a setup secret, a URL userinfo, or a secret field. */
162
+ export function ownerResponseLeaks(value: unknown, depth = 0, key: string | null = null): boolean {
163
+ if (depth > 20) return true;
164
+ if (typeof value === "string") return stringLeaks(value, key);
165
+ if (typeof value === "number" || typeof value === "boolean" || value == null) return false;
166
+ if (Array.isArray(value)) return value.some((v) => ownerResponseLeaks(v, depth + 1, null));
167
+ if (typeof value === "object") {
168
+ if (wakePreviewLeaks(value)) return true;
169
+ for (const [k, v] of Object.entries(value)) {
170
+ if (ownerResponseLeaks(v, depth + 1, k)) return true;
171
+ }
172
+ return false;
173
+ }
174
+ return true;
175
+ }
@@ -47,8 +47,9 @@ export type ConfigFields = { [K in ConfigKey]?: string };
47
47
 
48
48
  /**
49
49
  * Non-secret platform defaults a hosted tenant inherits when its pushed config omits them.
50
- * Wake secrets, upstream secrets and OWNER_TOKEN are never inherited: in hosted mode they come only from the
51
- * tenant overlay (or not at all). Decrypting `secrets_enc` into wake credentials is a follow-up.
50
+ * Wake secrets, upstream secrets and OWNER_TOKEN are never inherited. In hosted mode the secret wake
51
+ * fields come from decrypting `secrets_enc` (see the tenant object). A plaintext copy in the pushed
52
+ * config is used only when that ciphertext is absent.
52
53
  */
53
54
  const HOSTED_PLATFORM_DEFAULTS: readonly ConfigKey[] = ["MAX_BODY", "RATE_PER_MIN", "PBKDF2_ITERATIONS", "MCP", "PAIRING_APPROVAL"];
54
55
 
@@ -0,0 +1,113 @@
1
+ // Wake secrets sealed for one hosted tenant. The control plane writes `secrets_enc`; the tenant
2
+ // object decrypts it when it builds the tenant context. Self-host does not use this.
3
+ // Keep control/src/tenants/wake-secrets.ts in step with this file.
4
+
5
+ const INFO = "tenant-wake-secrets v1";
6
+ const SALT = "a2a-exposed";
7
+
8
+ /** Secret wake fields. Anything else in the blob is ignored. */
9
+ export const WAKE_SECRET_KEYS = [
10
+ "WAKE_WEBHOOK_KEY",
11
+ "WAKE_HMAC_SECRET",
12
+ "WAKE_ACCESS_CLIENT_ID",
13
+ "WAKE_ACCESS_CLIENT_SECRET",
14
+ ] as const;
15
+
16
+ export type WakeSecretKey = (typeof WAKE_SECRET_KEYS)[number];
17
+ export type WakeSecrets = Partial<Record<WakeSecretKey, string>>;
18
+
19
+ /** One secret. The form uses the same cap. */
20
+ export const MAX_WAKE_VALUE = 4096;
21
+
22
+ /**
23
+ * Largest JSON payload `sealWakeSecrets` will write. A character can become `\u0000` (six characters),
24
+ * and every known field may be present. `openWakeSecrets` accepts this and nothing larger.
25
+ */
26
+ export const MAX_WAKE_PLAIN = (() => {
27
+ let n = 2;
28
+ for (let i = 0; i < WAKE_SECRET_KEYS.length; i++) {
29
+ if (i) n += 1;
30
+ n += WAKE_SECRET_KEYS[i].length + 5 + MAX_WAKE_VALUE * 6;
31
+ }
32
+ return n;
33
+ })();
34
+
35
+ /** `v1.` + base64(12-byte iv) + `.` + base64(utf-8 plaintext + 16-byte tag). */
36
+ export const MAX_WAKE_SEALED = 3 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
37
+
38
+ function b64(bytes: Uint8Array): string {
39
+ let raw = "";
40
+ for (let i = 0; i < bytes.length; i += 0x8000) raw += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
41
+ return btoa(raw);
42
+ }
43
+ const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
44
+
45
+ async function derive(ikm: string, tenantId: string): Promise<CryptoKey | null> {
46
+ if (!ikm || !tenantId) return null;
47
+ const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(ikm), "HKDF", false, ["deriveKey"]);
48
+ return crypto.subtle.deriveKey(
49
+ { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(SALT), info: new TextEncoder().encode(`${INFO}\0${tenantId}`) },
50
+ base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
51
+ );
52
+ }
53
+
54
+ function payloadOf(secrets: WakeSecrets): Record<string, string> | null {
55
+ const payload: Record<string, string> = {};
56
+ for (const key of WAKE_SECRET_KEYS) {
57
+ const value = secrets[key];
58
+ if (value == null || value === "") continue;
59
+ if (typeof value !== "string" || value.length > MAX_WAKE_VALUE) return null;
60
+ payload[key] = value;
61
+ }
62
+ return payload;
63
+ }
64
+
65
+ /** `v1.<iv>.<ciphertext>`, or null when the key or a value is unusable. */
66
+ export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: WakeSecrets): Promise<string | null> {
67
+ const key = await derive(ikm, tenantId);
68
+ const payload = payloadOf(secrets);
69
+ if (!key || !payload) return null;
70
+ const json = JSON.stringify(payload);
71
+ const encoded = new TextEncoder().encode(json);
72
+ if (json.length > MAX_WAKE_PLAIN || encoded.length > MAX_WAKE_PLAIN * 3) return null;
73
+ const iv = crypto.getRandomValues(new Uint8Array(12));
74
+ const ct = new Uint8Array(await crypto.subtle.encrypt(
75
+ { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(tenantId) },
76
+ key,
77
+ encoded,
78
+ ));
79
+ const sealed = `v1.${b64(iv)}.${b64(ct)}`;
80
+ return sealed.length <= MAX_WAKE_SEALED ? sealed : null;
81
+ }
82
+
83
+ /**
84
+ * The sealed fields, `{}` when there is nothing to open, or null when the blob cannot be read.
85
+ * A null result must not be treated as "no secrets": the caller keeps the previous ciphertext.
86
+ */
87
+ export async function openWakeSecrets(ikm: string, tenantId: string, sealed: string | null | undefined): Promise<WakeSecrets | null> {
88
+ if (sealed == null || sealed === "") return {};
89
+ if (sealed.length > MAX_WAKE_SEALED) return null;
90
+ const key = await derive(ikm, tenantId);
91
+ if (!key) return null;
92
+ const [version, iv, ct, extra] = sealed.split(".");
93
+ if (version !== "v1" || !iv || !ct || extra !== undefined) return null;
94
+ try {
95
+ const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
96
+ { name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(tenantId) },
97
+ key,
98
+ unb64(ct),
99
+ ));
100
+ if (plain.length > MAX_WAKE_PLAIN) return null;
101
+ const parsed = JSON.parse(plain) as unknown;
102
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
103
+ const out: WakeSecrets = {};
104
+ for (const name of WAKE_SECRET_KEYS) {
105
+ const value = (parsed as Record<string, unknown>)[name];
106
+ if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
107
+ out[name] = value;
108
+ }
109
+ return out;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
@@ -203,7 +203,11 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
203
203
  break;
204
204
  case "generic":
205
205
  default: {
206
- if (cfg.key) headers[(cfg.keyHeader || "authorization").toLowerCase()] = (cfg.keyPrefix ?? "Bearer ") + cfg.key;
206
+ // A header the preview shows in full must not carry the key.
207
+ if (cfg.key) {
208
+ const requested = (cfg.keyHeader || "authorization").toLowerCase();
209
+ headers[previewHeaderPlain(requested) ? "authorization" : requested] = (cfg.keyPrefix ?? "Bearer ") + cfg.key;
210
+ }
207
211
  headers["x-request-id"] = requestId;
208
212
  body = cfg.bodyTemplate ? renderTemplate(cfg.bodyTemplate, ev, cli) : JSON.stringify(wakePayload(ev, cli));
209
213
  if (cfg.hmacSecret) await hermesSign(headers, cfg.hmacSecret, body, nowMs);
@@ -243,11 +247,20 @@ export function cloudflareErrorHint(status: number, body: string): string {
243
247
  return "";
244
248
  }
245
249
 
250
+ /** Headers a wake preview may show in full. Every other header carries a credential, whatever its name. */
251
+ export const PREVIEW_PLAIN_HEADERS = new Set([
252
+ "content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp",
253
+ ]);
254
+
255
+ export function previewHeaderPlain(name: string): boolean {
256
+ return PREVIEW_PLAIN_HEADERS.has(name.toLowerCase());
257
+ }
258
+
246
259
  /** Copy of a rendered request with credentials masked (for previews/logs). */
247
260
  export function redact(req: WakeRequest): WakeRequest {
248
261
  const h: Record<string, string> = {};
249
262
  for (const [k, v] of Object.entries(req.headers))
250
- h[k] = /authorization|key|token|secret|signature|access-client-id/i.test(k) && !/^idempotency-key$/i.test(k) ? v.replace(/(Bearer\s+)?(.{0,4}).*/s, "$1$2…") : v;
263
+ h[k] = previewHeaderPlain(k) ? v : v.replace(/(Bearer\s+)?(.{0,4}).*/s, "$1$2…");
251
264
  let url = req.url;
252
265
  try { const u = new URL(req.url); url = `${u.protocol}//${u.host}${u.pathname.replace(/\/[^/]{16,}/g, "/…")}`; } catch { /* keep */ }
253
266
  return { ...req, url, headers: h };
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import { Miniflare, convertV4MiniflareOptions } from "miniflare";
10
10
  import worker from "../src/index.ts";
11
11
  import { sha256 } from "../src/a2a.ts";
12
+ import { sealWakeSecrets } from "../src/wake-secrets.ts";
12
13
  import { d1 } from "./d1.ts";
13
14
 
14
15
  const require = createRequire(new URL("../package.json", import.meta.url));
@@ -481,3 +482,132 @@ test("an apex issuer is fetched with that Host, and a control worker that does n
481
482
  assert.equal(stalledPost.status, 502);
482
483
  assert.match(await stalledPost.text(), /The identity provider could not be reached/);
483
484
  });
485
+
486
+ test("TenantStore.owner runs an allowlisted owner call and does not return a peer token", async (t) => {
487
+ const mf = await start({
488
+ bindings: {
489
+ TENANCY: "host",
490
+ TENANT_DOMAIN: "example.com",
491
+ TENANT_SECRETS_KEY: "platform-secret",
492
+ OWNER_TOKEN: "platform-owner",
493
+ },
494
+ kvNamespaces: { TENANT_DIRECTORY: "tenants-owner-rpc" },
495
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
496
+ });
497
+ t.after(() => mf.dispose());
498
+ await mf.ready;
499
+ const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
500
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO");
501
+ const hash = await sha256("owner-carol");
502
+ type OwnerStub = {
503
+ owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown }): Promise<{ status: number; body: unknown }>;
504
+ storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
505
+ pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }>;
506
+ };
507
+ const lines: string[] = [];
508
+ const orig = console.log;
509
+ console.log = (...args: unknown[]) => {
510
+ lines.push(args.map((a) => String(a)).join(" "));
511
+ orig(...args);
512
+ };
513
+ t.after(() => { console.log = orig; });
514
+ // Workerd delivers console.log on a later turn. A busy runner (the storage-backend jobs) can
515
+ // return the RPC before that line is in this process.
516
+ const saw = async (part: string) => {
517
+ const start = Date.now();
518
+ while (!lines.some((l) => l.includes(part))) {
519
+ if (Date.now() - start > 2000) return false;
520
+ await new Promise((r) => setTimeout(r, 20));
521
+ }
522
+ return true;
523
+ };
524
+ const empty = ns.get(ns.idFromName("id-empty")) as unknown as OwnerStub;
525
+ const missing = await empty.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
526
+ assert.equal(missing.status, 404);
527
+ const emptyStatus = await empty.storageStatus();
528
+ assert.equal(emptyStatus.configured, false);
529
+ assert.deepEqual(emptyStatus.tables, []);
530
+ assert.equal(await saw('"reason":"absent"'), true);
531
+
532
+ const stub = ns.get(ns.idFromName("id-carol")) as unknown as OwnerStub;
533
+ await stub.pushConfig({
534
+ version: 1, tenantId: "id-carol", name: "carol", status: "active", ownerTokenHash: hash,
535
+ approval: { issuer: "https://idp.example", clientId: "client-1", clientSecret: "tenant-secret-value", allowedSubjects: ["acct_carol"] },
536
+ config: { AGENT_NAME: "Carol" },
537
+ });
538
+ await kv.put("tenant:carol", JSON.stringify({ id: "id-carol", status: "active", region: "", version: 1 }));
539
+ const http = (method: string, path: string, auth: string, json?: unknown) => mf.dispatchFetch(`https://carol.example.com${path}`, {
540
+ method, headers: { authorization: auth, "content-type": "application/json" }, body: json === undefined ? undefined : JSON.stringify(json),
541
+ });
542
+ assert.equal((await http("POST", "/owner/peers", "Bearer platform-owner", { label: "nope" })).status, 401, "self-host owner token still does not unlock a tenant");
543
+ const issued = await (await http("POST", "/owner/peers", "Bearer owner-carol", { label: "ada" })).json() as { token?: string };
544
+ assert.match(issued.token ?? "", /^a2aow_/, "the owner HTTP API still returns the new token");
545
+
546
+ const listed = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
547
+ assert.equal(listed.status, 200);
548
+ const peers = listed.body as { label: string; token?: string }[];
549
+ assert.deepEqual(peers.map((p) => p.label), ["ada"]);
550
+ assert.equal(JSON.stringify(listed.body).includes("a2aow_"), false);
551
+ assert.equal(await saw('"route":"GET /owner/peers"'), true);
552
+
553
+ const minted = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/peers", body: { label: "bob" } });
554
+ assert.equal(minted.status, 404);
555
+ assert.equal(JSON.stringify(minted.body).includes("a2aow_"), false);
556
+ const withBody = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers", body: { label: "bob" } });
557
+ assert.equal(withBody.status, 400);
558
+ const again = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/peers" });
559
+ assert.equal(again.status, 404);
560
+ const still = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
561
+ assert.deepEqual((still.body as { label: string }[]).map((p) => p.label), ["ada"], "a refused call does not mint a token");
562
+
563
+ const setup = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/password-link" });
564
+ assert.equal(setup.status, 404);
565
+ const dumped = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export" });
566
+ assert.equal(dumped.status, 404);
567
+ const oddQuery = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/inbox", query: { format: "sql" } });
568
+ assert.equal(oddQuery.status, 400);
569
+ const pairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
570
+ assert.equal(pairing.status, 200);
571
+ assert.equal(JSON.stringify(pairing.body).includes("tenant-secret-value"), false);
572
+ const preview = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/wake/preview" });
573
+ assert.equal(preview.status, 200);
574
+ assert.equal(JSON.stringify(preview.body).includes("platform-secret"), false);
575
+ assert.equal(JSON.stringify(preview.body).includes("tenant-secret-value"), false);
576
+
577
+ const wakeKey = "z9Qx-secret-value-0199";
578
+ const sealed = await sealWakeSecrets("platform-secret", "id-carol", { WAKE_WEBHOOK_KEY: wakeKey });
579
+ assert.ok(sealed);
580
+ assert.equal(sealed!.includes(wakeKey), false);
581
+ await stub.pushConfig({
582
+ version: 2, tenantId: "id-carol", name: "carol", status: "active", ownerTokenHash: hash,
583
+ secretsEnc: sealed,
584
+ config: {
585
+ AGENT_NAME: "Carol",
586
+ WAKE_WEBHOOK_URL: "https://hooks.example/wake",
587
+ WAKE_PRESET: "generic",
588
+ WAKE_KEY_HEADER: "X-Credential",
589
+ WAKE_WEBHOOK_KEY: "plaintext-key-should-not-appear",
590
+ },
591
+ });
592
+ const woken = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/wake/preview" });
593
+ assert.equal(woken.status, 200);
594
+ const wokenText = JSON.stringify(woken.body);
595
+ assert.equal(wokenText.includes(wakeKey), false);
596
+ assert.equal(wokenText.includes("plaintext-key-should-not-appear"), false);
597
+ assert.equal(wokenText.includes("platform-secret"), false);
598
+ const wokenBody = woken.body as { hasKey?: boolean; request?: { headers?: Record<string, string> } };
599
+ assert.equal(wokenBody.hasKey, true);
600
+ assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer z9Qx…");
601
+ const httpPreview = await (await http("GET", "/owner/wake/preview", "Bearer owner-carol")).json() as { hasKey?: boolean };
602
+ assert.equal(httpPreview.hasKey, true);
603
+ assert.equal(JSON.stringify(httpPreview).includes(wakeKey), false);
604
+
605
+ await stub.pushConfig({ version: 3, tenantId: "id-carol", name: "carol", status: "suspended", ownerTokenHash: hash, config: { AGENT_NAME: "Carol" } });
606
+ const suspended = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
607
+ assert.equal(suspended.status, 403);
608
+ assert.equal(await saw('"reason":"status"'), true);
609
+ const badAccount = await stub.owner({ accountId: "../acct", method: "GET", path: "/owner/peers" });
610
+ assert.equal(badAccount.status, 400);
611
+ assert.equal(await saw('"reason":"account"'), true);
612
+ assert.equal(lines.some((l) => l.includes("../acct")), false);
613
+ });
@@ -0,0 +1,72 @@
1
+ // Owner RPC allowlist and response filter. No Durable Object: the hosted test calls TenantStore.owner.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { accountIdOf, matchOwnerRpc, ownerResponseLeaks } from "../src/owner-rpc.ts";
5
+
6
+ test("owner RPC allowlist is the management reads and the calls that do not return a secret", () => {
7
+ const ok = (method: string, path: string, query?: Record<string, string>) => {
8
+ const m = matchOwnerRpc(method, path, query);
9
+ assert.equal(m.ok, true, `${method} ${path}`);
10
+ return m.ok ? m.route : null;
11
+ };
12
+ assert.equal(ok("GET", "/owner/inbox")?.name, "GET /owner/inbox");
13
+ assert.equal(ok("GET", "/owner/inbox", { context: "ctx-1", all: "1" })?.search, "?context=ctx-1&all=1");
14
+ assert.equal(ok("GET", "/owner/tasks/task-1")?.path, "/owner/tasks/task-1");
15
+ assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50" })?.search, "?n=50");
16
+ assert.equal(ok("GET", "/owner/outbound/task-1")?.name, "GET /owner/outbound/:id");
17
+ assert.equal(ok("GET", "/owner/peers")?.name, "GET /owner/peers");
18
+ assert.equal(ok("DELETE", "/owner/peers/ada")?.path, "/owner/peers/ada");
19
+ assert.equal(ok("GET", "/owner/pairing")?.name, "GET /owner/pairing");
20
+ assert.equal(ok("POST", "/owner/pairing/WDJB-4827/approve")?.name, "POST /owner/pairing/:code/approve");
21
+ assert.equal(ok("POST", "/owner/pairing/wdjb4827/deny")?.name, "POST /owner/pairing/:code/deny");
22
+ assert.equal(ok("GET", "/owner/mcp")?.name, "GET /owner/mcp");
23
+ assert.equal(ok("DELETE", "/owner/mcp/mcp-claude")?.path, "/owner/mcp/mcp-claude");
24
+ assert.equal(ok("GET", "/owner/outbound-peers")?.name, "GET /owner/outbound-peers");
25
+ assert.equal(ok("DELETE", "/owner/outbound-peers/ada.bot")?.path, "/owner/outbound-peers/ada.bot");
26
+ assert.equal(ok("GET", "/owner/facade")?.name, "GET /owner/facade");
27
+ assert.equal(ok("POST", "/owner/facade/verify")?.name, "POST /owner/facade/verify");
28
+ assert.equal(ok("GET", "/owner/wake/preview")?.name, "GET /owner/wake/preview");
29
+ assert.equal(ok("POST", "/owner/wake/test")?.name, "POST /owner/wake/test");
30
+ assert.equal(ok("GET", "/owner/contexts")?.name, "GET /owner/contexts");
31
+
32
+ const refused = (method: string, path: string, query?: Record<string, string>) => matchOwnerRpc(method, path, query);
33
+ assert.deepEqual(refused("POST", "/owner/peers"), { ok: false, reason: "path" });
34
+ assert.deepEqual(refused("POST", "/owner/pairing/password-link"), { ok: false, reason: "path" });
35
+ assert.deepEqual(refused("PUT", "/owner/pairing/password"), { ok: false, reason: "path" });
36
+ assert.deepEqual(refused("PUT", "/owner/outbound-peers/ada"), { ok: false, reason: "path" });
37
+ assert.deepEqual(refused("GET", "/owner/export"), { ok: false, reason: "path" });
38
+ assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
39
+ assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
40
+ assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
41
+ assert.deepEqual(refused("GET", "/owner/peers/../export"), { ok: false, reason: "path" });
42
+ assert.deepEqual(refused("GET", "/owner/%2e%2e/peers"), { ok: false, reason: "path" });
43
+ assert.deepEqual(refused("GET", "/owner/inbox?all=1"), { ok: false, reason: "path" });
44
+ assert.deepEqual(refused("POST", "/owner/pairing/AEIO-4827/approve"), { ok: false, reason: "path" });
45
+ assert.deepEqual(refused("GET", "/owner/inbox", { format: "sql" }), { ok: false, reason: "query" });
46
+ assert.deepEqual(refused("GET", "/owner/history/ctx-1", { n: "1001" }), { ok: false, reason: "query" });
47
+ assert.deepEqual(refused("GET", "/owner/history/.."), { ok: false, reason: "path" });
48
+ assert.equal(accountIdOf("acct_carol"), "acct_carol");
49
+ assert.equal(accountIdOf("../acct"), null);
50
+ assert.equal(accountIdOf(""), null);
51
+ });
52
+
53
+ test("owner RPC withholds peer tokens and decrypted secrets", () => {
54
+ const token = "a2aow_" + "a".repeat(43);
55
+ assert.equal(ownerResponseLeaks({ label: "ada", revoked_at: null }), false);
56
+ assert.equal(ownerResponseLeaks([{ text: "hello" }]), false);
57
+ assert.equal(ownerResponseLeaks({ token }), true);
58
+ assert.equal(ownerResponseLeaks({ nested: { access_token: "a2amcp_" + "b".repeat(30) } }), true);
59
+ assert.equal(ownerResponseLeaks({ url: "https://agent.example/device/setup?t=" + "c".repeat(20) }), true);
60
+ assert.equal(ownerResponseLeaks({ clientSecret: "tenant-secret-value" }), true);
61
+ assert.equal(ownerResponseLeaks({ secretsEnc: "v1.ciphertext-value" }), true);
62
+ assert.equal(ownerResponseLeaks("https://user:pass@hooks.example/wake"), true);
63
+ assert.equal(ownerResponseLeaks({ authorization: "Bearer abcd…" }), false);
64
+ assert.equal(ownerResponseLeaks({
65
+ request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supersecretvalue", "content-type": "application/json" }, body: "{}" },
66
+ }), true);
67
+ assert.equal(ownerResponseLeaks({
68
+ request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supe…", "content-type": "application/json", "x-request-id": "req-1" }, body: "{}" },
69
+ }), false);
70
+ assert.equal(ownerResponseLeaks({ fingerprints: { url: "0123456789ab", key: null } }), false);
71
+ assert.equal(ownerResponseLeaks({ token_hash: "ab".repeat(32) }), false);
72
+ });
@@ -0,0 +1,49 @@
1
+ import { test } from "node:test";
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";
4
+
5
+ const key = "platform-secret";
6
+ const tenant = "ten_a";
7
+
8
+ test("wake secrets round-trip for one tenant and stay unreadable for another", async () => {
9
+ const secrets = { WAKE_WEBHOOK_KEY: "z9Qx-secret-value-0199", WAKE_HMAC_SECRET: "hmac-value" };
10
+ const sealed = await sealWakeSecrets(key, tenant, secrets);
11
+ assert.ok(sealed);
12
+ assert.equal(sealed!.includes("z9Qx-secret-value-0199"), false);
13
+ assert.equal(sealed!.startsWith("v1."), true);
14
+ assert.deepEqual(await openWakeSecrets(key, tenant, sealed), secrets);
15
+ assert.equal(await openWakeSecrets(key, "ten_b", sealed), null);
16
+ assert.equal(await openWakeSecrets("other-key", tenant, sealed), null);
17
+ assert.equal(await openWakeSecrets("", tenant, sealed), null);
18
+ assert.deepEqual(await openWakeSecrets(key, tenant, null), {});
19
+ assert.equal(await openWakeSecrets(key, tenant, sealed + ".extra"), null);
20
+ assert.equal(await openWakeSecrets(key, tenant, "v1.not-base64.nope"), null);
21
+ });
22
+
23
+ test("a wake secret blob keeps only the known fields", async () => {
24
+ const sealed = await sealWakeSecrets(key, tenant, {
25
+ WAKE_WEBHOOK_KEY: "hook-key",
26
+ WAKE_ACCESS_CLIENT_SECRET: "access-secret",
27
+ });
28
+ const opened = await openWakeSecrets(key, tenant, sealed);
29
+ assert.deepEqual(opened, { WAKE_WEBHOOK_KEY: "hook-key", WAKE_ACCESS_CLIENT_SECRET: "access-secret" });
30
+ assert.equal(await sealWakeSecrets(key, tenant, { WAKE_WEBHOOK_KEY: "x".repeat(4097) }), null);
31
+ });
32
+
33
+ test("four maximum-length wake secrets round-trip", async () => {
34
+ const value = "x".repeat(MAX_WAKE_VALUE);
35
+ const secrets = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, value]));
36
+ const sealed = await sealWakeSecrets(key, tenant, secrets);
37
+ assert.ok(sealed);
38
+ assert.ok(sealed!.length > 16384);
39
+ assert.ok(sealed!.length <= MAX_WAKE_SEALED);
40
+ assert.deepEqual(await openWakeSecrets(key, tenant, sealed), secrets);
41
+ assert.equal(await openWakeSecrets(key, tenant, "v1." + "a".repeat(MAX_WAKE_SEALED)), null);
42
+
43
+ const escaped = "\u0000".repeat(MAX_WAKE_VALUE);
44
+ const worst = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, escaped]));
45
+ const sealedWorst = await sealWakeSecrets(key, tenant, worst);
46
+ assert.ok(sealedWorst);
47
+ assert.ok(sealedWorst!.length <= MAX_WAKE_SEALED);
48
+ assert.deepEqual(await openWakeSecrets(key, tenant, sealedWorst), worst);
49
+ });
@@ -116,6 +116,22 @@ test("generic custom key header is rendered with prefix and redacted in previews
116
116
  assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
117
117
  });
118
118
 
119
+ test("a key header the preview shows in full is not used for the key", async () => {
120
+ const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "Content-Type", keyPrefix: "" }, ev))!;
121
+ assert.equal(req.headers["authorization"], "supersecretvalue");
122
+ assert.equal(req.headers["content-type"], "application/json");
123
+ assert.equal(redact(req).headers["authorization"], "supe…");
124
+ });
125
+
126
+ test("generic key header is redacted even when its name is not a secret word", async () => {
127
+ const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "X-Credential", keyPrefix: "" }, ev))!;
128
+ assert.equal(req.headers["x-credential"], "supersecretvalue");
129
+ const red = redact(req);
130
+ assert.equal(red.headers["x-credential"], "supe…");
131
+ assert.equal(red.headers["content-type"], "application/json");
132
+ assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
133
+ });
134
+
119
135
  // ---------------------------------------------------------------- Cloudflare Access service token (tunnel wakes)
120
136
  const ACCESS = { accessClientId: "abc123.access", accessClientSecret: "s3cr3t-value-0123456789" };
121
137