a2a-exposed 0.7.12 → 0.7.14

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.
@@ -1,7 +1,14 @@
1
1
  // Wake URL checks for WAKE_TARGET_POLICY=public-https. Unset stays the previous fetch
2
2
  // (any URL the operator configured, redirects followed by the platform).
3
+ //
4
+ // The TCP connection is to an address from the DNS check. TLS is not the runtime's
5
+ // startTls({ expectedServerHostname }): on the Workers edge that sends no SNI when the
6
+ // socket was opened to an IP (cloudflare/workerd#6903), so the peer aborts before HTTP.
7
+ // The handshake runs in-process, with the URL hostname as SNI, over that pinned socket.
8
+ // A socket still cannot open a Cloudflare address, so a *.workers.dev target does not connect.
9
+ import { CertificateError, Client, TlsError } from "tunnelfetch";
3
10
  import { isPrivateHost } from "./a2a.ts";
4
- import { isPublicIp, net, parseHttpResponse, type PinnedSocket } from "./mcp.ts";
11
+ import { isPublicIp } from "./mcp.ts";
5
12
 
6
13
  /** DNS-over-HTTPS resolver. Same endpoint the client-metadata fetch uses. */
7
14
  export const DOH_URL = "https://cloudflare-dns.com/dns-query";
@@ -9,14 +16,33 @@ export const DOH_URL = "https://cloudflare-dns.com/dns-query";
9
16
  /** Original request plus this many redirects. Each hop is checked before it is requested. */
10
17
  const MAX_REDIRECTS = 3;
11
18
 
19
+ const REQUEST_TIMEOUT_MS = 15_000;
20
+
12
21
  /** Host to addresses. `null` means the lookup failed and the caller must fail closed. */
13
22
  export type AddressLookup = (host: string) => Promise<string[] | null>;
14
23
 
15
- type Connect = NonNullable<typeof net.connect>;
24
+ type WakeSocket = {
25
+ readable: ReadableStream<Uint8Array>;
26
+ writable: WritableStream<Uint8Array>;
27
+ opened?: Promise<{ remoteAddress?: string | null }>;
28
+ close?: () => Promise<void>;
29
+ };
30
+
31
+ /** Raw TCP. Production uses `cloudflare:sockets`. Tests replace it. */
32
+ export type WakeConnect = (address: { hostname: string; port: number }, options?: { secureTransport?: "off" | "on" | "starttls"; allowHalfOpen?: boolean }) => WakeSocket;
33
+
34
+ /** One checked hop. `ip` is the address the socket must use. `hostname` is the TLS name. */
35
+ export type CheckedDeliver = (url: string, init: RequestInit, target: { ip: string; hostname: string }) => Promise<Response>;
36
+
37
+ export const wakeNet: { connect: null | WakeConnect; deliver: null | CheckedDeliver } = { connect: null, deliver: null };
16
38
 
17
39
  export type WakeDeps = {
18
40
  lookup?: AddressLookup;
19
- connect?: Connect;
41
+ connect?: WakeConnect;
42
+ deliver?: CheckedDeliver;
43
+ /** Deliver a URL on this host in-process. Used for a wake aimed at this inbox. */
44
+ localHost?: string;
45
+ local?: (url: string, init: RequestInit) => Promise<Response>;
20
46
  };
21
47
 
22
48
  function bareHost(host: string): string {
@@ -69,8 +95,8 @@ export async function publicWakeProblem(url: string, lookup: AddressLookup = loo
69
95
  return "refused" in checked ? checked.refused : "";
70
96
  }
71
97
 
72
- /** The address the next request must use. A name is resolved once; the returned IP is from that answer. */
73
- export async function checkedWakeAddress(url: string, lookup: AddressLookup = lookupAddresses): Promise<{ ip: string } | { refused: string }> {
98
+ /** Every public address from one lookup. The socket uses the first. The TLS name is the URL host. */
99
+ async function checkedWakeAddresses(url: string, lookup: AddressLookup): Promise<{ hostname: string; ips: string[] } | { refused: string }> {
74
100
  let parsed: URL;
75
101
  try { parsed = new URL(url); } catch { return { refused: "not a URL" }; }
76
102
  if (parsed.protocol !== "https:") return { refused: "must be https" };
@@ -78,79 +104,99 @@ export async function checkedWakeAddress(url: string, lookup: AddressLookup = lo
78
104
  const host = parsed.hostname;
79
105
  if (!host || isPrivateHost(host)) return { refused: "private or internal host" };
80
106
  const bare = bareHost(host);
81
- if (isIpLiteral(host)) return isPublicIp(bare) ? { ip: bare } : { refused: "private or reserved address" };
107
+ if (isIpLiteral(host)) return isPublicIp(bare) ? { hostname: bare, ips: [bare] } : { refused: "private or reserved address" };
82
108
  let ips: string[] | null;
83
109
  try { ips = await lookup(host); } catch { return { refused: "its address could not be checked" }; }
84
110
  if (ips == null) return { refused: "its address could not be checked" };
85
111
  if (!ips.length) return { refused: "the host has no address" };
86
112
  if (!ips.every((ip) => isPublicIp(ip))) return { refused: "resolves to a private or reserved address" };
87
- return { ip: ips[0] };
113
+ return { hostname: host, ips };
88
114
  }
89
115
 
90
- function headerEntries(headers: HeadersInit | undefined): [string, string][] {
91
- const out: [string, string][] = [];
92
- const list = new Headers(headers);
93
- list.forEach((value, key) => out.push([key, value]));
94
- return out;
116
+ /** The address the next request must use. A name is resolved once; the returned IP is from that answer. */
117
+ export async function checkedWakeAddress(url: string, lookup: AddressLookup = lookupAddresses): Promise<{ ip: string } | { refused: string }> {
118
+ const checked = await checkedWakeAddresses(url, lookup);
119
+ if ("refused" in checked) return checked;
120
+ return { ip: checked.ips[0]! };
95
121
  }
96
122
 
97
- function requestBytes(method: string, url: URL, headers: HeadersInit | undefined, body: Uint8Array): Uint8Array {
98
- const lines = [`${method} ${url.pathname}${url.search} HTTP/1.1`, `host: ${url.host}`];
99
- for (const [key, value] of headerEntries(headers)) {
100
- if (key === "host" || key === "content-length" || key === "connection" || key === "transfer-encoding") continue;
101
- if (/[\r\n]/.test(key) || /[\r\n]/.test(value)) throw new Error("invalid header");
102
- lines.push(`${key}: ${value}`);
103
- }
104
- if (body.byteLength) lines.push(`content-length: ${body.byteLength}`);
105
- lines.push("connection: close");
106
- const head = new TextEncoder().encode(lines.join("\r\n") + "\r\n\r\n");
107
- const raw = new Uint8Array(head.byteLength + body.byteLength);
108
- raw.set(head);
109
- raw.set(body, head.byteLength);
110
- return raw;
123
+ /**
124
+ * Dial `ip`, ignoring the hostname the TLS stack passes in. The handshake names that hostname
125
+ * itself; this function only chooses the TCP address.
126
+ */
127
+ export function dialChecked(ip: string, connect: WakeConnect): WakeConnect {
128
+ return (addr) => connect({ hostname: ip, port: addr.port }, { secureTransport: "off", allowHalfOpen: false });
111
129
  }
112
130
 
113
- async function readSocket(sock: PinnedSocket, cap: number): Promise<Uint8Array> {
114
- const reader = sock.readable.getReader();
115
- const chunks: Uint8Array[] = [];
116
- let n = 0;
117
- for (;;) {
118
- const { done, value } = await reader.read();
119
- if (done) break;
120
- n += value.byteLength;
121
- if (n > cap) break;
122
- chunks.push(value);
123
- }
124
- const raw = new Uint8Array(Math.min(n, cap));
125
- let o = 0;
126
- for (const chunk of chunks) {
127
- const take = Math.min(chunk.byteLength, raw.byteLength - o);
128
- raw.set(chunk.subarray(0, take), o);
129
- o += take;
130
- }
131
- return raw;
131
+ async function runtimeConnect(): Promise<WakeConnect> {
132
+ if (wakeNet.connect) return wakeNet.connect;
133
+ const sockets = await import("cloudflare:sockets");
134
+ return sockets.connect as unknown as WakeConnect;
132
135
  }
133
136
 
137
+ /** Enough of an error body to recognise a Cloudflare status page. A success body is not kept. */
138
+ const HINT_BYTES = 4096;
139
+
134
140
  /**
135
- * HTTPS to `ip`, with TLS checked for the URL's hostname. The platform does not resolve the name again.
141
+ * HTTPS to the checked IP, with SNI and certificate verification for the URL host.
142
+ * `forceTunnel` keeps the platform from fetching the hostname again.
143
+ * The body is copied before the client closes, because closing drops the socket.
136
144
  */
137
- async function pinnedRequest(ip: string, url: string, method: string, headers: HeadersInit | undefined, body: Uint8Array, connect: Connect): Promise<Response> {
138
- const parsed = new URL(url);
139
- const sock = connect({ hostname: ip, port: Number(parsed.port || 443) }, { secureTransport: "starttls" }).startTls({ expectedServerHostname: parsed.hostname });
145
+ async function deliverPinned(url: string, init: RequestInit, target: { ip: string; hostname: string }, connect: WakeConnect): Promise<Response> {
146
+ const client = new Client({
147
+ connect: dialChecked(target.ip, connect),
148
+ forceTunnel: true,
149
+ maxRedirects: 0,
150
+ maxBodyBytes: Infinity,
151
+ http2: false,
152
+ keepAlive: false,
153
+ timeouts: { totalMs: REQUEST_TIMEOUT_MS },
154
+ });
140
155
  try {
141
- const writer = sock.writable.getWriter();
142
- await writer.write(requestBytes(method, parsed, headers, body));
143
- writer.releaseLock();
144
- const raw = await readSocket(sock, 65536);
145
- const message = parseHttpResponse(raw);
146
- if (typeof message === "string") throw new Error(message);
147
- const responseHeaders = new Headers();
148
- for (const [key, value] of Object.entries(message.headers)) responseHeaders.set(key, value);
149
- const payload = message.status === 204 || message.status === 304 ? null : message.body;
150
- return new Response(payload, { status: message.status, headers: responseHeaders });
156
+ const res = await client.fetch(url, {
157
+ method: init.method,
158
+ headers: init.headers,
159
+ body: init.body,
160
+ redirect: "manual",
161
+ });
162
+ return await responseForCaller(res);
163
+ } catch (e) {
164
+ if (e instanceof TlsError || e instanceof CertificateError) throw new Error("TLS Handshake Failed.");
165
+ throw e;
151
166
  } finally {
152
- await sock.close().catch(() => {});
167
+ await client.close().catch(() => {});
168
+ }
169
+ }
170
+
171
+ /** Status and headers, plus the start of an error body. The live socket is not returned. */
172
+ async function responseForCaller(res: Response): Promise<Response> {
173
+ const headers = new Headers(res.headers);
174
+ if (res.ok || !res.body) {
175
+ await res.body?.cancel().catch(() => {});
176
+ return new Response(null, { status: res.status, headers });
177
+ }
178
+ const reader = res.body.getReader();
179
+ const chunks: Uint8Array[] = [];
180
+ let total = 0;
181
+ try {
182
+ while (total < HINT_BYTES) {
183
+ const next = await reader.read();
184
+ if (next.done || !next.value) break;
185
+ chunks.push(next.value);
186
+ total += next.value.byteLength;
187
+ }
188
+ } finally {
189
+ await reader.cancel().catch(() => {});
190
+ }
191
+ const merged = new Uint8Array(Math.min(total, HINT_BYTES));
192
+ let offset = 0;
193
+ for (const chunk of chunks) {
194
+ const take = Math.min(chunk.byteLength, merged.byteLength - offset);
195
+ if (take <= 0) break;
196
+ merged.set(chunk.subarray(0, take), offset);
197
+ offset += take;
153
198
  }
199
+ return new Response(merged, { status: res.status, headers });
154
200
  }
155
201
 
156
202
  function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
@@ -161,8 +207,9 @@ function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
161
207
  }
162
208
 
163
209
  /**
164
- * POST (or whatever `init` says) to `url` on the address that passed the public-https check.
165
- * A redirect is followed only when it stays on the same origin, and that hop is checked and pinned again.
210
+ * POST (or whatever `init` says) to `url` after the public-https check.
211
+ * The socket opens to an address from that check. TLS uses the hostname.
212
+ * A redirect is followed only when it stays on the same origin, and that hop is checked again.
166
213
  * A cross-origin redirect is refused, so wake credentials are not sent to another host.
167
214
  * 301, 302, and 303 drop the body. 307 and 308 keep it.
168
215
  */
@@ -172,24 +219,31 @@ export async function fetchCheckedWake(
172
219
  deps: WakeDeps = {},
173
220
  ): Promise<{ response: Response } | { refused: string }> {
174
221
  const lookup = deps.lookup ?? lookupAddresses;
175
- const connect = async (): Promise<Connect> => {
176
- if (deps.connect) return deps.connect;
177
- if (net.connect) return net.connect;
178
- return (await import("cloudflare:sockets")).connect as unknown as Connect;
179
- };
180
222
  let current = url;
181
223
  let method = (init.method || "GET").toUpperCase();
182
224
  let body = bodyBytes(method === "GET" || method === "HEAD" ? undefined : init.body);
183
225
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
184
- const checked = await checkedWakeAddress(current, lookup);
226
+ const checked = await checkedWakeAddresses(current, lookup);
185
227
  if ("refused" in checked) return checked;
186
- const res = await pinnedRequest(checked.ip, current, method, init.headers, method === "GET" || method === "HEAD" ? new Uint8Array() : body, await connect());
228
+ const nextInit: RequestInit = {
229
+ method,
230
+ headers: init.headers,
231
+ body: method === "GET" || method === "HEAD" || body.byteLength === 0 ? undefined : body,
232
+ redirect: "manual",
233
+ };
234
+ const target = { ip: checked.ips[0]!, hostname: checked.hostname };
235
+ let res: Response;
236
+ if (deps.local && deps.localHost && new URL(current).host === deps.localHost) res = await deps.local(current, nextInit);
237
+ else if (deps.deliver) res = await deps.deliver(current, nextInit, target);
238
+ else if (wakeNet.deliver) res = await wakeNet.deliver(current, nextInit, target);
239
+ else res = await deliverPinned(current, nextInit, target, deps.connect ?? wakeNet.connect ?? await runtimeConnect());
187
240
  const location = res.headers.get("location");
188
241
  if (res.status < 300 || res.status >= 400 || !location) return { response: res };
242
+ await res.body?.cancel().catch(() => {});
189
243
  if (hop === MAX_REDIRECTS) return { refused: "too many redirects" };
190
244
  let next: URL;
191
245
  try { next = new URL(location, current); } catch { return { refused: "not a URL" }; }
192
- const ahead = await checkedWakeAddress(next.href, lookup);
246
+ const ahead = await checkedWakeAddresses(next.href, lookup);
193
247
  if ("refused" in ahead) return ahead;
194
248
  if (next.origin !== new URL(current).origin) return { refused: "cross-origin redirect" };
195
249
  if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== "GET" && method !== "HEAD")) {
@@ -51,7 +51,10 @@ export function defaultDebounceSeconds(preset: string): number {
51
51
  return preset === "claude-code" ? 20 : 10;
52
52
  }
53
53
 
54
- /** Default hourly wake cap per preset (0 = unlimited). Claude Code routines allow 30 fires/hour. */
54
+ /**
55
+ * Default hourly wake cap per preset. 0 means unlimited. Claude Code routines allow 30 fires/hour,
56
+ * so that preset defaults to 25 only when WAKE_MAX_PER_HOUR is unset. An explicit 0 stays unlimited.
57
+ */
55
58
  export function defaultMaxPerHour(preset: string): number {
56
59
  return preset === "claude-code" ? 25 : 0;
57
60
  }
@@ -256,11 +259,21 @@ export function previewHeaderPlain(name: string): boolean {
256
259
  return PREVIEW_PLAIN_HEADERS.has(name.toLowerCase());
257
260
  }
258
261
 
262
+ /** A credential header safe to show. No character of the secret is kept. */
263
+ export function maskedCredential(value: string): string {
264
+ return /^bearer\s/i.test(value) ? "Bearer ••••" : "••••";
265
+ }
266
+
267
+ /** True only for the exact value `maskedCredential` returns. A prefix plus an ellipsis is still a secret. */
268
+ export function credentialIsMasked(value: string): boolean {
269
+ return value === "••••" || value === "Bearer ••••";
270
+ }
271
+
259
272
  /** Copy of a rendered request with credentials masked (for previews/logs). */
260
273
  export function redact(req: WakeRequest): WakeRequest {
261
274
  const h: Record<string, string> = {};
262
275
  for (const [k, v] of Object.entries(req.headers))
263
- h[k] = previewHeaderPlain(k) ? v : v.replace(/(Bearer\s+)?(.{0,4}).*/s, "$1$2…");
276
+ h[k] = previewHeaderPlain(k) ? v : maskedCredential(v);
264
277
  let url = req.url;
265
278
  try { const u = new URL(req.url); url = `${u.protocol}//${u.host}${u.pathname.replace(/\/[^/]{16,}/g, "/…")}`; } catch { /* keep */ }
266
279
  return { ...req, url, headers: h };
@@ -41,6 +41,23 @@ test("nextCronAt is the earliest wake, rate row, or expiry", async () => {
41
41
  assert.equal(await nextCronAt(tenant, now), now + 801);
42
42
  });
43
43
 
44
+ test("zero and an unset generic cap stay unlimited; an unset Claude cap is 25", async () => {
45
+ const now = 1_700_000_000_000;
46
+ const hour = Math.floor(now / 3600000);
47
+ const due = now - 60_000;
48
+ const open = due + 10_000;
49
+ async function arm(over: Record<string, string>, count: number) {
50
+ const { DB, tenant } = ctx(over);
51
+ await DB.prepare("INSERT INTO wake_budget (hour, count) VALUES (?, ?)").bind(hour, count).run();
52
+ await DB.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES ('c', ?, '{}', ?)").bind(due, now).run();
53
+ return nextCronAt(tenant, now);
54
+ }
55
+ assert.equal(await arm({ WAKE_MAX_PER_HOUR: "0" }, 100), open);
56
+ assert.equal(await arm({ WAKE_MAX_PER_HOUR: "" }, 100), open);
57
+ assert.equal(await arm({ WAKE_PRESET: "claude-code", WAKE_MAX_PER_HOUR: "0" }, 100), open);
58
+ assert.equal(await arm({ WAKE_PRESET: "claude-code", WAKE_MAX_PER_HOUR: "" }, 25), (hour + 1) * 3600000);
59
+ });
60
+
44
61
  test("an hourly cap holds an already-due wake until the next hour", async () => {
45
62
  const now = 1_700_000_000_000;
46
63
  const hour = Math.floor(now / 3600000);
@@ -10,7 +10,8 @@ import { Miniflare, convertV4MiniflareOptions } from "miniflare";
10
10
  import worker from "../src/index.ts";
11
11
  import { d1 } from "./d1.ts";
12
12
  import { sha256 } from "../src/a2a.ts";
13
- import { exportRows, toSql, bookmarkForTime, restoreBookmark, restartAfterResult, ExportError, EXPORT_TABLES, RESTORE_RESTART_DELAY_MS } from "../src/export.ts";
13
+ import { exportRows, redactExport, REDACTED_SQL_SKIP, scrubSecretText, toSql, bookmarkForTime, restoreBookmark, restartAfterResult, ExportError, EXPORT_TABLES, RESTORE_RESTART_DELAY_MS } from "../src/export.ts";
14
+ import { ownerResponseLeaks, TOKEN_PREFIXES } from "../src/owner-rpc.ts";
14
15
  import { doSqlD1, type SqlStorageLike, type TxRunner } from "../src/storage.ts";
15
16
  import { backupDatabase, backupDirectory, exportTenantSql, hostedDailyBackup, olderThanRetention, pruneSnapshots, snapshotKey, DAILY_CRON, type R2BucketLike } from "../src/backup.ts";
16
17
  import { namespaceForRegion, type DoNamespace } from "../src/tenancy.ts";
@@ -70,6 +71,121 @@ test("export SQL writes a NUL byte as char(0) so the script stays valid", () =>
70
71
  assert.equal(hex.h, Buffer.from(raw, "utf8").toString("hex").toUpperCase());
71
72
  });
72
73
 
74
+ test("redactExport drops credential material and leaves the full dump unchanged", async () => {
75
+ const DB = db();
76
+ const now = "2026-10-11T00:00:00.000Z";
77
+ const token = "a2aow_" + "a".repeat(20);
78
+ const owner = "a2aot_" + "Q".repeat(43);
79
+ const hash = "ab".repeat(32);
80
+ const replaces = "cd".repeat(32);
81
+ const setupHash = "ef".repeat(32);
82
+ const setup = "c".repeat(16);
83
+ await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', ?, ?)").bind(hash, now).run();
84
+ await DB.prepare("INSERT INTO history (context_id, ts, dir, text) VALUES ('ctx-1', ?, 'in', ?)").bind(now, `see ${token} and ${owner} at https://user:secret@hooks.example/wake?t=${setup}`).run();
85
+ await DB.prepare("INSERT INTO history (context_id, ts, dir, text) VALUES ('ctx-owner', ?, 'in', ?)").bind(now, owner).run();
86
+ await DB.prepare("INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, push_configs_json) VALUES ('task-1', 'ctx-1', 'ada', 'submitted', '1.0', ?, ?, '{}', '[]', ?)").bind(now, now, JSON.stringify([{ token }])).run();
87
+ await DB.prepare("INSERT INTO outbound (task_id, context_id, peer, push_token_hash, sent_at, task_json) VALUES ('task-1', 'ctx-1', 'ada', 'push-hash-secret', ?, ?)").bind(now, JSON.stringify({ token })).run();
88
+ await DB.prepare("INSERT INTO settings (key, value) VALUES ('approval_password', ?)").bind(JSON.stringify({ hash: "x".repeat(40) })).run();
89
+ await DB.prepare("INSERT INTO settings (key, value) VALUES ('password_setup', ?)").bind(JSON.stringify({ hash: setupHash, createdMs: 1, expiresMs: 2, failures: 0 })).run();
90
+ await DB.prepare("INSERT INTO settings (key, value) VALUES ('theme', 'kept')").run();
91
+ await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada', ?)").bind(replaces).run();
92
+ await DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES ('code-hash', 'client', 'https://client.example/cb', 'challenge-secret-value', 'mcp', 'https://agent.example/mcp', 1, 2)").run();
93
+ await DB.prepare("INSERT INTO mcp_grants (label, client_id, scope, access_hash, refresh_hash, created_at) VALUES ('mcp-claude', 'client', 'mcp', 'access-hash-secret', 'refresh-hash-secret', ?)").bind(now).run();
94
+ await DB.prepare("INSERT INTO outbound_peers (alias, url, token_enc, created_at, updated_at) VALUES ('ada', 'https://ada.example', 'ciphertext-secret-value', ?, ?)").bind(now, now).run();
95
+ await DB.prepare("INSERT INTO oidc_txns (state, nonce, verifier, kind, csrf, payload_json, expires_ms) VALUES ('state-1', 'nonce-1', 'verifier-secret-value', 'device', 'csrf-secret-value', '{}', 1)").run();
96
+ const file = await exportRows(DB, new Date(now));
97
+ const redacted = redactExport(file);
98
+ assert.equal(file.tables.peers?.[0]?.token_hash, hash, "the CLI dump still has the hash");
99
+ assert.equal(file.tables.device_requests?.[0]?.replaces_hash, replaces);
100
+ assert.equal(file.tables.settings?.some((row) => row.key === "password_setup"), true);
101
+ assert.equal(file.tables.oidc_txns?.[0]?.verifier, "verifier-secret-value");
102
+ assert.equal(redacted.tables.peers?.[0]?.token_hash, "redacted:ada");
103
+ assert.equal(redacted.tables.peers?.[0]?.label, "ada");
104
+ assert.equal(redacted.tables.device_requests?.[0]?.device_hash, "redacted:WDJB4827");
105
+ assert.equal(redacted.tables.device_requests?.[0]?.replaces_hash, undefined);
106
+ assert.equal(redacted.tables.device_requests?.[0]?.replaces_label, "ada");
107
+ assert.equal(redacted.tables.device_requests?.[0]?.user_code, "WDJB4827");
108
+ assert.equal(redacted.tables.tasks?.[0]?.push_configs_json, undefined);
109
+ assert.equal(redacted.tables.outbound?.[0]?.push_token_hash, undefined);
110
+ assert.equal(redacted.tables.outbound?.[0]?.task_json, JSON.stringify({ token: "[redacted]" }));
111
+ assert.equal(redacted.tables.mcp_grants?.[0]?.access_hash, undefined);
112
+ assert.equal(redacted.tables.mcp_grants?.[0]?.refresh_hash, undefined);
113
+ assert.equal(redacted.tables.mcp_grants?.[0]?.label, "mcp-claude");
114
+ assert.equal(redacted.tables.outbound_peers?.[0]?.token_enc, undefined);
115
+ assert.equal(redacted.tables.outbound_peers?.[0]?.alias, "ada");
116
+ assert.deepEqual(redacted.tables.oauth_codes, []);
117
+ assert.deepEqual(redacted.tables.oidc_txns, []);
118
+ assert.deepEqual(redacted.tables.settings?.map((row) => row.key), ["theme"]);
119
+ const history = redacted.tables.history ?? [];
120
+ assert.equal(history.find((row) => row.context_id === "ctx-1")?.text, "see [redacted] and [redacted] at https://[redacted]@hooks.example/wake?t=[redacted]");
121
+ assert.equal(history.find((row) => row.context_id === "ctx-owner")?.text, "[redacted]");
122
+ const dumped = JSON.stringify(redacted);
123
+ for (const secret of [token, owner, hash, replaces, setupHash, "device-hash-secret", "ciphertext-secret-value", "verifier-secret-value", "csrf-secret-value", "challenge-secret-value", "access-hash-secret", "refresh-hash-secret", "push-hash-secret", "approval_password", "password_setup", "user:secret", `t=${setup}`]) {
124
+ assert.equal(dumped.includes(secret), false, secret);
125
+ }
126
+ assert.equal(ownerResponseLeaks(owner), true);
127
+ assert.equal(ownerResponseLeaks(redacted), false, "a pasted hosted owner token is redacted, so the dashboard export is returned");
128
+ const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
129
+ assert.match(sql, /redacted:ada/);
130
+ assert.equal(sql.includes("oauth_codes"), false);
131
+ assert.equal(sql.includes("oidc_txns"), false);
132
+ assert.equal(sql.includes("replaces_hash"), false);
133
+ assert.equal(sql.includes("password_setup"), false);
134
+ assert.equal(ownerResponseLeaks({ sql }), false);
135
+ for (const secret of [token, owner, hash, replaces, setupHash]) {
136
+ assert.equal(sql.includes(secret), false, secret);
137
+ }
138
+ assert.match(toSql(file), /verifier-secret-value/);
139
+ assert.match(toSql(file), new RegExp(hash));
140
+ assert.match(toSql(file), /replaces_hash/);
141
+ });
142
+
143
+ test("scrubSecretText redacts a hosted owner token on the log and export paths", () => {
144
+ assert.ok(TOKEN_PREFIXES.includes("a2aot_"));
145
+ for (const prefix of TOKEN_PREFIXES) {
146
+ const token = prefix + "Q".repeat(43);
147
+ assert.equal(scrubSecretText(`see ${token} end`), "see [redacted] end", prefix);
148
+ assert.equal(scrubSecretText(token), "[redacted]", prefix);
149
+ assert.equal(ownerResponseLeaks(token), true, prefix);
150
+ }
151
+ const owner = "a2aot_" + "Q".repeat(43);
152
+ const redacted = redactExport({
153
+ format: "a2a-exposed-export",
154
+ version: 1,
155
+ exportedAt: "2026-10-11T00:00:00.000Z",
156
+ tables: { history: [{ context_id: "ctx-owner", ts: "2026-10-11T00:00:00.000Z", dir: "in", text: owner }] },
157
+ });
158
+ assert.equal(redacted.tables.history?.[0]?.text, "[redacted]");
159
+ assert.equal(toSql(redacted, { skip: REDACTED_SQL_SKIP }).includes(owner), false);
160
+ assert.equal(ownerResponseLeaks(redacted), false);
161
+ });
162
+
163
+ test("redactExport removes device_requests.replaces_hash", () => {
164
+ const replaces = "cd".repeat(32);
165
+ const redacted = redactExport({
166
+ format: "a2a-exposed-export",
167
+ version: 1,
168
+ exportedAt: "2026-10-11T00:00:00.000Z",
169
+ tables: {
170
+ device_requests: [{
171
+ device_hash: "device-hash-secret",
172
+ user_code: "WDJB4827",
173
+ status: "pending",
174
+ replaces_label: "ada",
175
+ replaces_hash: replaces,
176
+ }],
177
+ },
178
+ });
179
+ const row = redacted.tables.device_requests?.[0] ?? {};
180
+ assert.equal(Object.hasOwn(row, "replaces_hash"), false);
181
+ assert.equal(row.replaces_label, "ada");
182
+ assert.equal(row.device_hash, "redacted:WDJB4827");
183
+ const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
184
+ assert.equal(sql.includes("replaces_hash"), false);
185
+ assert.equal(sql.includes(replaces), false);
186
+ assert.equal(JSON.stringify(redacted).includes(replaces), false);
187
+ });
188
+
73
189
  test("exportRows reads every application table in one batch", async () => {
74
190
  const DB = db();
75
191
  await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', 'h', 't')").run();