a2a-exposed 0.7.13 → 0.7.15

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/main.mjs CHANGED
@@ -182,8 +182,18 @@ Control plane account (only when that deployment's login is on)
182
182
  browser can follow the redirect. Buttons say Continue with that
183
183
  provider. Until the check finishes they stay muted and the page says
184
184
  Waiting for the check…. A reused token says The check expired. Please try again.
185
- After sign-in, /app shows the account and its agents. Sign out is on that
186
- page. Creating an agent shows the owner token once. That page is not stored.
185
+ After sign-in, /app shows the account and its agents. Each name opens
186
+ that agent's page: MCP connectors (revoke needs a recent sign-in), a
187
+ message log that does not mark tasks seen, and a SQL download that
188
+ replaces a pasted owner token and leaves other credential material out. Peer times are labeled UTC. A pairing
189
+ token shows its code. An empty outbound list notes that peers the CLI
190
+ added locally are not shown. Sign out is on that
191
+ page. Creating an agent shows the owner token once. The agent page rotates
192
+ that token after a confirmation and a recent sign-in. The new token is
193
+ shown once on that agent's page. The previous one stops working immediately.
194
+ The token is shown only when the stored hash is still that token.
195
+ Deleting an agent asks for its name and a recent sign-in. A self-hosted inbox has no such delete.
196
+ That page is not stored.
187
197
  FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
188
198
  Unset, there is no limit. The database rejects another insert.
189
199
  SIGNUP_ALLOWLIST, when set, admits only those verified emails. Unset, sign-in stays open.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.13",
3
+ "version": "0.7.15",
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": {
@@ -92,15 +92,80 @@ export async function exportRows(db: SqlDb, now = new Date()): Promise<ExportFil
92
92
  return { format: "a2a-exposed-export", version: 1, exportedAt: now.toISOString(), tables };
93
93
  }
94
94
 
95
- /** DELETE then INSERT for every allowlisted table. Safe to run on a database that already has the schema. */
96
- export function toSql(file: ExportFile): string {
95
+ /** Live-credential tables. A dashboard SQL copy skips them so the script does not delete rows it does not return. */
96
+ export const REDACTED_SQL_SKIP = ["oauth_codes", "oidc_txns"] as const;
97
+
98
+ const OMIT_COLUMNS: Record<string, readonly string[]> = {
99
+ tasks: ["push_configs_json"],
100
+ outbound: ["push_token_hash"],
101
+ mcp_grants: ["access_hash", "refresh_hash"],
102
+ outbound_peers: ["token_enc"],
103
+ device_requests: ["replaces_hash", "ip", "country"],
104
+ };
105
+
106
+ const SETUP_SUB = /([?&])t=[A-Za-z0-9_-]{16,}/g;
107
+ const USERINFO_SUB = /([a-z][a-z0-9+.-]*:\/\/)[^/?\s]*@/gi;
108
+
109
+ /** Replace a peer token, a hosted owner token (`a2aot_`), a setup secret, or URL userinfo. The rest of the text stays. */
110
+ export function scrubSecretText(value: string): string {
111
+ const tokens = /(?:a2aow_|s2a_|a2amcp_|a2amcpr_|a2aot_)[A-Za-z0-9_-]{20,}/g;
112
+ return value.replace(tokens, "[redacted]").replace(SETUP_SUB, "$1t=[redacted]").replace(USERINFO_SUB, "$1[redacted]@");
113
+ }
114
+
115
+ function placeholder(table: string, row: Record<string, unknown>, index: number): string {
116
+ const label = table === "peers" ? row.label : row.user_code;
117
+ const suffix = typeof label === "string" && label ? label : String(index);
118
+ return `redacted:${suffix}`;
119
+ }
120
+
121
+ /**
122
+ * A copy of `file` safe to show on the dashboard.
123
+ * Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace) and `device_requests.ip` and `device_requests.country` (the requester's address).
124
+ * `peers.token_hash` and `device_requests.device_hash` are NOT NULL, so they become `redacted:…` (not the hash, and not a token that authenticates).
125
+ * `oauth_codes` and `oidc_txns` are emptied. The approval-password row and the password-setup row (its value is the hash of the live setup token) are removed.
126
+ * Token-shaped text, including a hosted owner token (`a2aot_`), is scrubbed. The input is not changed: the CLI dump stays complete.
127
+ */
128
+ export function redactExport(file: ExportFile): ExportFile {
129
+ if (file.format !== "a2a-exposed-export" || file.version !== 1) throw new ExportError("export: bad file");
130
+ if (!/^[\dTZ:.-]+$/.test(file.exportedAt)) throw new ExportError("export: bad timestamp");
131
+ const tables: ExportFile["tables"] = {};
132
+ for (const name of EXPORT_TABLES) {
133
+ if ((REDACTED_SQL_SKIP as readonly string[]).includes(name)) {
134
+ tables[name] = [];
135
+ continue;
136
+ }
137
+ const omit = new Set(OMIT_COLUMNS[name] ?? []);
138
+ const rows: Record<string, unknown>[] = [];
139
+ for (const [index, row] of (file.tables[name] ?? []).entries()) {
140
+ if (name === "settings" && (row.key === "approval_password" || row.key === "password_setup")) continue;
141
+ const out: Record<string, unknown> = {};
142
+ for (const [key, value] of Object.entries(row)) {
143
+ if (omit.has(key) || key.toLowerCase() === "replaces_hash") continue;
144
+ if ((name === "peers" && key === "token_hash") || (name === "device_requests" && key === "device_hash")) {
145
+ out[key] = placeholder(name, row, index);
146
+ continue;
147
+ }
148
+ out[key] = typeof value === "string" ? scrubSecretText(value) : value;
149
+ }
150
+ delete out.replaces_hash;
151
+ rows.push(out);
152
+ }
153
+ tables[name] = rows;
154
+ }
155
+ return { format: "a2a-exposed-export", version: 1, exportedAt: file.exportedAt, tables };
156
+ }
157
+
158
+ /** DELETE then INSERT for every allowlisted table. Safe to run on a database that already has the schema. `skip` leaves those tables out of the script. */
159
+ export function toSql(file: ExportFile, options?: { skip?: readonly string[] }): string {
97
160
  if (file.format !== "a2a-exposed-export" || file.version !== 1) throw new ExportError("export: bad file");
98
161
  if (!/^[\dTZ:.-]+$/.test(file.exportedAt)) throw new ExportError("export: bad timestamp");
162
+ const skip = new Set(options?.skip ?? []);
99
163
  const lines = [
100
164
  `-- a2a-exposed-export ${file.version}`,
101
165
  `-- exported_at ${file.exportedAt}`,
102
166
  ];
103
167
  for (const name of EXPORT_TABLES) {
168
+ if (skip.has(name)) continue;
104
169
  lines.push(`DELETE FROM ${quoteIdent(name)};`);
105
170
  for (const row of file.tables[name] ?? []) {
106
171
  const cols = Object.keys(row);
@@ -269,6 +269,9 @@ export class TenantStore extends DurableObject<WorkerBindings> {
269
269
  async pushConfig(body: TenantConfigPush): Promise<{ version: number; applied: boolean }> {
270
270
  return await this.ctx.blockConcurrencyWhile(async () => {
271
271
  checkPush(body);
272
+ const deleted = await (this.ctx.storage as unknown as { get(key: string): Promise<unknown> }).get("tenant_deleted");
273
+ // deleteStorage leaves this key after wiping SQL. A later push must not recreate the agent.
274
+ if (deleted != null) return { version: 0, applied: false };
272
275
  const current = this.#row ?? readRow(this.#sql);
273
276
  if (current && body.version <= current.version) {
274
277
  // A previous push may have stored this version and then failed while migrating. Catch up; don't rewrite the row.
@@ -322,6 +325,30 @@ export class TenantStore extends DurableObject<WorkerBindings> {
322
325
  return { configured: this.#row !== null, version: this.#row?.version ?? 0, tables: appTables(this.#sql) };
323
326
  }
324
327
 
328
+ /**
329
+ * Drop this object's alarm and storage. A second call on an empty object succeeds.
330
+ * Not an owner HTTP route. Self-host never calls it.
331
+ */
332
+ async deleteStorage(): Promise<{ deleted: true }> {
333
+ return await this.ctx.blockConcurrencyWhile(async () => {
334
+ const storage = this.ctx.storage as unknown as {
335
+ deleteAlarm(): Promise<void>;
336
+ deleteAll(): Promise<void>;
337
+ put(key: string, value: string): Promise<void>;
338
+ };
339
+ const tenantId = this.#row?.tenantId ?? null;
340
+ await storage.deleteAlarm();
341
+ await storage.deleteAll();
342
+ const left = readRow(this.#sql);
343
+ if (left) throw new Error("deleteStorage: data remains");
344
+ // After SQL is gone. A later pushConfig sees this and does not recreate the agent.
345
+ await storage.put("tenant_deleted", "1");
346
+ this.#row = null;
347
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "tenant_storage_deleted", tenant: tenantId }));
348
+ return { deleted: true };
349
+ });
350
+ }
351
+
325
352
  /** SQL dump of the application tables. Refuses an object that has no config row. */
326
353
  async exportSql(): Promise<{ sql: string; exportedAt: string }> {
327
354
  return await this.ctx.blockConcurrencyWhile(async () => {
@@ -12,7 +12,7 @@ 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
14
  import { resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
15
- import { exportRows, toSql } from "./export.ts";
15
+ import { exportRows, redactExport, REDACTED_SQL_SKIP, scrubSecretText, toSql } from "./export.ts";
16
16
  import { backupDatabase, DAILY_CRON } from "./backup.ts";
17
17
  import * as O from "./approval-oidc.ts";
18
18
  type Json = any;
@@ -1146,6 +1146,27 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1146
1146
  const rows = await ctx.DB.prepare("SELECT context_id, COUNT(*) AS entries, MAX(ts) AS last FROM history GROUP BY context_id ORDER BY last DESC LIMIT 100").all();
1147
1147
  return json(rows.results);
1148
1148
  }
1149
+ if (seg[0] === "log" && !seg[1] && m === "GET") {
1150
+ // Recent history, newest first. Unlike GET /owner/inbox this does not clear a pending wake, and it does not return data_json.
1151
+ const clip = (v: unknown, max: number) => typeof v === "string" ? scrubSecretText(v).slice(0, max) : null;
1152
+ const rows = await ctx.DB.prepare(
1153
+ "SELECT seq, context_id, ts, dir, peer, task_id, role, state, event, text FROM history ORDER BY seq DESC LIMIT 100",
1154
+ ).all();
1155
+ return json({
1156
+ entries: (rows.results as Json[]).map((r) => ({
1157
+ seq: typeof r.seq === "number" ? r.seq : Number(r.seq),
1158
+ contextId: clip(r.context_id, 200),
1159
+ ts: clip(r.ts, 40),
1160
+ dir: clip(r.dir, 16),
1161
+ peer: clip(r.peer, 200),
1162
+ taskId: clip(r.task_id, 200),
1163
+ role: clip(r.role, 32),
1164
+ state: clip(r.state, 32),
1165
+ event: clip(r.event, 64),
1166
+ text: clip(r.text, 2000),
1167
+ })),
1168
+ });
1169
+ }
1149
1170
  if (seg[0] === "export" && !seg[1] && m === "GET") {
1150
1171
  const file = await exportRows(ctx.DB);
1151
1172
  if (url.searchParams.get("format") === "sql") {
@@ -2138,7 +2159,9 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
2138
2159
  const u = new URL(ctx.PUBLIC_URL);
2139
2160
  if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
2140
2161
  } catch { /* the handler reads the path, not this origin */ }
2141
- const url = new URL(origin + matched.route.path + matched.route.search);
2162
+ // format=sql is text/plain on the HTTP API. The RPC always reads the JSON dump, then redacts, then builds SQL.
2163
+ const search = matched.route.path === "/owner/export" ? "" : matched.route.search;
2164
+ const url = new URL(origin + matched.route.path + search);
2142
2165
  const headers = new Headers({ "content-type": "application/json" });
2143
2166
  if (actor === "human") headers.set(DASHBOARD_ACTOR_HEADER, "human");
2144
2167
  const req = new Request(url, { method: matched.route.method, headers });
@@ -2151,7 +2174,22 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
2151
2174
  log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
2152
2175
  return { status, body: { error } };
2153
2176
  }
2154
- const data = scrubOwnerClaims(await res.json().catch(() => null));
2177
+ let data = scrubOwnerClaims(await res.json().catch(() => null));
2178
+ if (matched.route.path === "/owner/export" && res.ok && data && typeof data === "object") {
2179
+ try {
2180
+ const file = redactExport(data as Parameters<typeof redactExport>[0]);
2181
+ data = matched.route.search === "?format=sql"
2182
+ ? {
2183
+ exportedAt: file.exportedAt,
2184
+ sql: "-- Dashboard copy. This is not a backup. Do not load it onto a live inbox.\n"
2185
+ + "-- Stored token hashes are replaced with redacted: values that do not authenticate, or left out. Ciphertext, push tokens, re-pair hashes, OAuth codes, the approval-password hash, password-setup hashes, and OpenID Connect sign-in rows are omitted.\n"
2186
+ + toSql(file, { skip: REDACTED_SQL_SKIP }),
2187
+ }
2188
+ : file;
2189
+ } catch {
2190
+ return refuse(500, "response withheld", "secret");
2191
+ }
2192
+ }
2155
2193
  if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
2156
2194
  log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
2157
2195
  return { status: res.status, body: data };
@@ -1,7 +1,7 @@
1
1
  // Hosted owner RPC allowlist. The control plane calls TenantStore.owner() after it has checked that
2
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.
3
+ // the owner HTTP API: routes that mint a peer token, return a setup secret, or accept a plaintext peer
4
+ // token are not here. GET /owner/export is here only because runOwnerRpc redacts it first. Self-host never calls this.
5
5
 
6
6
  import { ID_RE } from "./a2a.ts";
7
7
  import { credentialIsMasked, previewHeaderPlain } from "./wake.ts";
@@ -15,7 +15,9 @@ const ALIAS = /^[A-Za-z0-9_.-]{1,64}$/;
15
15
  /** Display form WDJB-4827 or the stored form WDJB4827. Vowels and 0/1 are not in a real code. */
16
16
  const USER_CODE = /^[BCDFGHJKLMNPQRSTVWXZ]{4}-?[2-9]{4}$/i;
17
17
 
18
- const TOKEN_RE = /^(?:a2aow_|s2a_|a2amcp_|a2amcpr_)[A-Za-z0-9_-]{20,}$/;
18
+ /** Peer tokens, MCP tokens, and the hosted owner token (`a2aot_`). */
19
+ export const TOKEN_PREFIXES = ["a2aow_", "s2a_", "a2amcp_", "a2amcpr_", "a2aot_"] as const;
20
+ const TOKEN_RE = new RegExp(`^(?:${TOKEN_PREFIXES.join("|")})[A-Za-z0-9_-]{20,}$`);
19
21
  const SETUP_RE = /(?:\?|&)t=[A-Za-z0-9_-]{16,}/;
20
22
  const USERINFO_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?\s]*@/i;
21
23
  /** A non-empty string under one of these keys is a secret, even when it is not a token shape. */
@@ -110,6 +112,14 @@ function historyQuery(q: Record<string, string>): string | null {
110
112
  return `?n=${q.n}`;
111
113
  }
112
114
 
115
+ /** `format=sql` is the only export query. The RPC still calls the JSON handler and redacts before building SQL. */
116
+ function exportQuery(q: Record<string, string>): string | null {
117
+ for (const k of Object.keys(q)) if (k !== "format") return null;
118
+ if (q.format === undefined) return "";
119
+ if (q.format !== "sql") return null;
120
+ return "?format=sql";
121
+ }
122
+
113
123
  interface RouteDef {
114
124
  method: "GET" | "POST" | "DELETE";
115
125
  name: string;
@@ -138,6 +148,8 @@ const ROUTES: RouteDef[] = [
138
148
  { method: "GET", name: "GET /owner/wake/preview", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "preview", search: noQuery, path: () => "/owner/wake/preview" },
139
149
  { method: "POST", name: "POST /owner/wake/test", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "test", search: noQuery, path: () => "/owner/wake/test" },
140
150
  { method: "GET", name: "GET /owner/contexts", take: (s) => s.length === 2 && s[1] === "contexts", search: noQuery, path: () => "/owner/contexts" },
151
+ { method: "GET", name: "GET /owner/log", take: (s) => s.length === 2 && s[1] === "log", search: noQuery, path: () => "/owner/log" },
152
+ { method: "GET", name: "GET /owner/export", take: (s) => s.length === 2 && s[1] === "export", search: exportQuery, path: () => "/owner/export" },
141
153
  ];
142
154
 
143
155
  /** Method + path (+ query) on the owner RPC allowlist, or why it is not. */
@@ -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,146 @@ 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, ip, country) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada', ?, '203.0.113.50', 'XQ')").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.device_requests?.[0]?.ip, "203.0.113.50");
101
+ assert.equal(file.tables.device_requests?.[0]?.country, "XQ");
102
+ assert.equal(file.tables.settings?.some((row) => row.key === "password_setup"), true);
103
+ assert.equal(file.tables.oidc_txns?.[0]?.verifier, "verifier-secret-value");
104
+ assert.equal(redacted.tables.peers?.[0]?.token_hash, "redacted:ada");
105
+ assert.equal(redacted.tables.peers?.[0]?.label, "ada");
106
+ assert.equal(redacted.tables.device_requests?.[0]?.device_hash, "redacted:WDJB4827");
107
+ assert.equal(redacted.tables.device_requests?.[0]?.replaces_hash, undefined);
108
+ assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "ip"), false);
109
+ assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "country"), false);
110
+ assert.equal(redacted.tables.device_requests?.[0]?.replaces_label, "ada");
111
+ assert.equal(redacted.tables.device_requests?.[0]?.user_code, "WDJB4827");
112
+ assert.equal(redacted.tables.tasks?.[0]?.push_configs_json, undefined);
113
+ assert.equal(redacted.tables.outbound?.[0]?.push_token_hash, undefined);
114
+ assert.equal(redacted.tables.outbound?.[0]?.task_json, JSON.stringify({ token: "[redacted]" }));
115
+ assert.equal(redacted.tables.mcp_grants?.[0]?.access_hash, undefined);
116
+ assert.equal(redacted.tables.mcp_grants?.[0]?.refresh_hash, undefined);
117
+ assert.equal(redacted.tables.mcp_grants?.[0]?.label, "mcp-claude");
118
+ assert.equal(redacted.tables.outbound_peers?.[0]?.token_enc, undefined);
119
+ assert.equal(redacted.tables.outbound_peers?.[0]?.alias, "ada");
120
+ assert.deepEqual(redacted.tables.oauth_codes, []);
121
+ assert.deepEqual(redacted.tables.oidc_txns, []);
122
+ assert.deepEqual(redacted.tables.settings?.map((row) => row.key), ["theme"]);
123
+ const history = redacted.tables.history ?? [];
124
+ assert.equal(history.find((row) => row.context_id === "ctx-1")?.text, "see [redacted] and [redacted] at https://[redacted]@hooks.example/wake?t=[redacted]");
125
+ assert.equal(history.find((row) => row.context_id === "ctx-owner")?.text, "[redacted]");
126
+ const dumped = JSON.stringify(redacted);
127
+ 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}`]) {
128
+ assert.equal(dumped.includes(secret), false, secret);
129
+ }
130
+ assert.equal(ownerResponseLeaks(owner), true);
131
+ assert.equal(ownerResponseLeaks(redacted), false, "a pasted hosted owner token is redacted, so the dashboard export is returned");
132
+ const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
133
+ assert.match(sql, /redacted:ada/);
134
+ assert.equal(sql.includes("oauth_codes"), false);
135
+ assert.equal(sql.includes("oidc_txns"), false);
136
+ assert.equal(sql.includes("replaces_hash"), false);
137
+ assert.equal(sql.includes("203.0.113.50"), false);
138
+ assert.equal(sql.includes("XQ"), false);
139
+ assert.equal(sql.includes("password_setup"), false);
140
+ assert.equal(ownerResponseLeaks({ sql }), false);
141
+ for (const secret of [token, owner, hash, replaces, setupHash]) {
142
+ assert.equal(sql.includes(secret), false, secret);
143
+ }
144
+ assert.match(toSql(file), /verifier-secret-value/);
145
+ assert.match(toSql(file), new RegExp(hash));
146
+ assert.match(toSql(file), /replaces_hash/);
147
+ assert.match(toSql(file), /203\.0\.113\.50/);
148
+ assert.match(toSql(file), /'XQ'/);
149
+ });
150
+
151
+ test("scrubSecretText redacts a hosted owner token on the log and export paths", () => {
152
+ assert.ok(TOKEN_PREFIXES.includes("a2aot_"));
153
+ for (const prefix of TOKEN_PREFIXES) {
154
+ const token = prefix + "Q".repeat(43);
155
+ assert.equal(scrubSecretText(`see ${token} end`), "see [redacted] end", prefix);
156
+ assert.equal(scrubSecretText(token), "[redacted]", prefix);
157
+ assert.equal(ownerResponseLeaks(token), true, prefix);
158
+ }
159
+ const owner = "a2aot_" + "Q".repeat(43);
160
+ const redacted = redactExport({
161
+ format: "a2a-exposed-export",
162
+ version: 1,
163
+ exportedAt: "2026-10-11T00:00:00.000Z",
164
+ tables: { history: [{ context_id: "ctx-owner", ts: "2026-10-11T00:00:00.000Z", dir: "in", text: owner }] },
165
+ });
166
+ assert.equal(redacted.tables.history?.[0]?.text, "[redacted]");
167
+ assert.equal(toSql(redacted, { skip: REDACTED_SQL_SKIP }).includes(owner), false);
168
+ assert.equal(ownerResponseLeaks(redacted), false);
169
+ });
170
+
171
+ test("redactExport removes device_requests.replaces_hash", () => {
172
+ const replaces = "cd".repeat(32);
173
+ const redacted = redactExport({
174
+ format: "a2a-exposed-export",
175
+ version: 1,
176
+ exportedAt: "2026-10-11T00:00:00.000Z",
177
+ tables: {
178
+ device_requests: [{
179
+ device_hash: "device-hash-secret",
180
+ user_code: "WDJB4827",
181
+ status: "pending",
182
+ replaces_label: "ada",
183
+ replaces_hash: replaces,
184
+ ip: "203.0.113.50",
185
+ country: "XQ",
186
+ }],
187
+ },
188
+ });
189
+ const row = redacted.tables.device_requests?.[0] ?? {};
190
+ assert.equal(Object.hasOwn(row, "replaces_hash"), false);
191
+ assert.equal(Object.hasOwn(row, "ip"), false);
192
+ assert.equal(Object.hasOwn(row, "country"), false);
193
+ assert.equal(row.replaces_label, "ada");
194
+ assert.equal(row.device_hash, "redacted:WDJB4827");
195
+ const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
196
+ assert.equal(sql.includes("replaces_hash"), false);
197
+ assert.equal(sql.includes("203.0.113.50"), false);
198
+ assert.equal(sql.includes(replaces), false);
199
+ assert.equal(JSON.stringify(redacted).includes(replaces), false);
200
+ });
201
+
202
+ test("self-host owner HTTP has no delete route", async () => {
203
+ const DB = db();
204
+ const env = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret" };
205
+ const res = await worker.fetch(new Request(BASE + "/owner/delete", {
206
+ method: "POST",
207
+ headers: { authorization: "Bearer owner-secret" },
208
+ }) as never, env as never, ectx as never);
209
+ assert.equal(res.status, 404);
210
+ const tables = await DB.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'peers'").all<{ name: string }>();
211
+ assert.equal(tables.results.length, 1);
212
+ });
213
+
73
214
  test("exportRows reads every application table in one batch", async () => {
74
215
  const DB = db();
75
216
  await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', 'h', 't')").run();
@@ -221,7 +221,14 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
221
221
 
222
222
  // workerd's local runtime throws "Jurisdiction restrictions are not implemented" on namespace.jurisdiction().
223
223
  // namespaceForRegion() is covered in tenancy.test.ts; production Durable Objects do honour eu / fedramp.
224
- await alice.stub.pushConfig({ version: 4, tenantId: "id-alice", name: "alice", status: "suspended", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice 3" } });
224
+ const tokenA2 = "owner-alice-2";
225
+ const hashA2 = await sha256(tokenA2);
226
+ const rotated = await alice.stub.pushConfig({ version: 4, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA2, config: { AGENT_NAME: "Alice 3" } });
227
+ assert.equal(rotated.applied, true);
228
+ assert.equal((await call("https://alice.example.com/owner/summary", "GET", `Bearer ${tokenA}`)).status, 401, "the previous owner token stops working");
229
+ assert.equal((await call("https://alice.example.com/owner/summary", "GET", `Bearer ${tokenA2}`)).status, 200);
230
+ assert.equal((await call("https://alice.example.com/owner/summary", "GET", "Bearer platform-owner")).status, 401, "self-host OWNER_TOKEN still does not unlock a tenant");
231
+ await alice.stub.pushConfig({ version: 5, tenantId: "id-alice", name: "alice", status: "suspended", ownerTokenHash: hashA2, config: { AGENT_NAME: "Alice 3" } });
225
232
  assert.equal((await mf.dispatchFetch("https://alice.example.com/health")).status, 403, "the object enforces a newer status than the directory cache");
226
233
  });
227
234
 
@@ -586,8 +593,42 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
586
593
 
587
594
  const setup = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/password-link" });
588
595
  assert.equal(setup.status, 404);
596
+ const pasted = "a2aow_" + "b".repeat(20);
597
+ const sentPaste = await http("POST", "/", `Bearer ${issued.token}`, {
598
+ jsonrpc: "2.0", id: 2, method: "message/send",
599
+ params: { message: { kind: "message", role: "user", messageId: "m-paste", parts: [{ kind: "text", text: `note ${pasted} end` }] } },
600
+ });
601
+ assert.equal(sentPaste.status, 200, await sentPaste.clone().text());
602
+ const httpExport = await (await http("GET", "/owner/export", "Bearer owner-carol")).json() as { tables?: { peers?: { token_hash?: string }[] } };
603
+ assert.match(httpExport.tables?.peers?.[0]?.token_hash ?? "", /^[0-9a-f]{64}$/);
604
+ assert.equal(JSON.stringify(httpExport).includes(pasted), true, "the owner HTTP export stays the full dump");
589
605
  const dumped = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export" });
590
- assert.equal(dumped.status, 404);
606
+ assert.equal(dumped.status, 200);
607
+ const dumpedText = JSON.stringify(dumped.body);
608
+ assert.match(dumpedText, /"token_hash":"redacted:ada"/);
609
+ assert.match(dumpedText, /note \[redacted\] end/);
610
+ assert.equal(dumpedText.includes(pasted), false);
611
+ assert.equal(dumpedText.includes(issued.token ?? ""), false);
612
+ assert.equal(dumpedText.includes("tenant-secret-value"), false);
613
+ assert.equal(dumpedText.includes(httpExport.tables?.peers?.[0]?.token_hash ?? "missing-hash"), false);
614
+ const sqlDump = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export", query: { format: "sql" } });
615
+ assert.equal(sqlDump.status, 200);
616
+ const sql = (sqlDump.body as { sql?: string }).sql ?? "";
617
+ assert.match(sql, /note \[redacted\] end/);
618
+ assert.match(sql, /redacted:ada/);
619
+ assert.match(sql, /Do not load it onto a live inbox/);
620
+ assert.equal(sql.includes(pasted), false);
621
+ assert.equal(sql.includes("oauth_codes"), false);
622
+ assert.equal(sql.includes("oidc_txns"), false);
623
+ assert.equal(sql.includes(issued.token ?? ""), false);
624
+ const badFormat = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export", query: { format: "json" } });
625
+ assert.equal(badFormat.status, 400);
626
+ const logged = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/log" });
627
+ assert.equal(logged.status, 200);
628
+ const loggedText = JSON.stringify(logged.body);
629
+ assert.match(loggedText, /note \[redacted\] end/);
630
+ assert.equal(loggedText.includes(pasted), false);
631
+ assert.equal(loggedText.includes("data_json"), false);
591
632
  const oddQuery = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/inbox", query: { format: "sql" } });
592
633
  assert.equal(oddQuery.status, 400);
593
634
  const pairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
@@ -709,3 +750,58 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
709
750
  assert.equal(await saw('"reason":"account"'), true);
710
751
  assert.equal(lines.some((l) => l.includes("../acct")), false);
711
752
  });
753
+
754
+ test("TenantStore.deleteStorage wipes the object and a second call succeeds", async (t) => {
755
+ const mf = await start({
756
+ bindings: {
757
+ TENANCY: "host",
758
+ TENANT_DOMAIN: "example.com",
759
+ OWNER_TOKEN: "platform-owner",
760
+ },
761
+ kvNamespaces: { TENANT_DIRECTORY: "tenants-delete" },
762
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
763
+ });
764
+ t.after(() => mf.dispose());
765
+ await mf.ready;
766
+ const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
767
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO");
768
+ const hash = await sha256("owner-delta");
769
+ type Stub = {
770
+ pushConfig(body: Record<string, unknown>): Promise<{ version: number; applied: boolean }>;
771
+ deleteStorage(): Promise<{ deleted: boolean }>;
772
+ storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
773
+ };
774
+ const stub = ns.get(ns.idFromName("id-delta")) as unknown as Stub;
775
+ await stub.pushConfig({
776
+ version: 1, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
777
+ config: { AGENT_NAME: "delta" },
778
+ });
779
+ await kv.put("tenant:delta", JSON.stringify({ id: "id-delta", status: "active", region: "default", version: 1 }));
780
+ const before = await stub.storageStatus();
781
+ assert.equal(before.configured, true);
782
+ assert.ok(before.tables.includes("tenant_config"));
783
+ const http = await mf.dispatchFetch("https://delta.example.com/owner/delete", {
784
+ method: "POST",
785
+ headers: { authorization: "Bearer owner-delta" },
786
+ });
787
+ assert.equal(http.status, 404);
788
+ assert.equal((await stub.storageStatus()).configured, true, "the owner HTTP API does not wipe storage");
789
+ const wiped = await stub.deleteStorage();
790
+ assert.equal(wiped.deleted, true);
791
+ const after = await stub.storageStatus();
792
+ assert.equal(after.configured, false);
793
+ assert.deepEqual(after.tables, []);
794
+ const again = await stub.deleteStorage();
795
+ assert.equal(again.deleted, true);
796
+ assert.equal((await stub.storageStatus()).configured, false);
797
+ assert.deepEqual((await stub.storageStatus()).tables, []);
798
+ const refused = await stub.pushConfig({
799
+ version: 2, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
800
+ config: { AGENT_NAME: "delta" },
801
+ });
802
+ assert.equal(refused.applied, false);
803
+ assert.equal(refused.version, 0);
804
+ const stayed = await stub.storageStatus();
805
+ assert.equal(stayed.configured, false);
806
+ assert.deepEqual(stayed.tables, []);
807
+ });
@@ -29,13 +29,19 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
29
29
  assert.equal(ok("GET", "/owner/wake/preview")?.name, "GET /owner/wake/preview");
30
30
  assert.equal(ok("POST", "/owner/wake/test")?.name, "POST /owner/wake/test");
31
31
  assert.equal(ok("GET", "/owner/contexts")?.name, "GET /owner/contexts");
32
+ assert.equal(ok("GET", "/owner/log")?.name, "GET /owner/log");
33
+ assert.equal(ok("GET", "/owner/export")?.name, "GET /owner/export");
34
+ assert.equal(ok("GET", "/owner/export", { format: "sql" })?.search, "?format=sql");
32
35
 
33
36
  const refused = (method: string, path: string, query?: Record<string, string>) => matchOwnerRpc(method, path, query);
34
37
  assert.deepEqual(refused("POST", "/owner/peers"), { ok: false, reason: "path" });
35
38
  assert.deepEqual(refused("POST", "/owner/pairing/password-link"), { ok: false, reason: "path" });
36
39
  assert.deepEqual(refused("PUT", "/owner/pairing/password"), { ok: false, reason: "path" });
37
40
  assert.deepEqual(refused("PUT", "/owner/outbound-peers/ada"), { ok: false, reason: "path" });
38
- assert.deepEqual(refused("GET", "/owner/export"), { ok: false, reason: "path" });
41
+ assert.deepEqual(refused("GET", "/owner/export", { format: "json" }), { ok: false, reason: "query" });
42
+ assert.deepEqual(refused("GET", "/owner/export", { format: "sql", n: "1" }), { ok: false, reason: "query" });
43
+ assert.deepEqual(refused("POST", "/owner/export"), { ok: false, reason: "path" });
44
+ assert.deepEqual(refused("GET", "/owner/log", { n: "10" }), { ok: false, reason: "query" });
39
45
  assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
40
46
  assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
41
47
  assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
@@ -54,6 +60,7 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
54
60
 
55
61
  test("owner RPC withholds peer tokens and decrypted secrets", () => {
56
62
  const token = "a2aow_" + "a".repeat(43);
63
+ assert.equal(ownerResponseLeaks("a2aot_" + "Q".repeat(43)), true);
57
64
  assert.equal(ownerResponseLeaks({ label: "ada", revoked_at: null }), false);
58
65
  assert.equal(ownerResponseLeaks([{ text: "hello" }]), false);
59
66
  assert.equal(ownerResponseLeaks({ token }), true);
@@ -722,3 +722,35 @@ test("owner summary counts tasks and pending pairing requests without their text
722
722
  assert.equal(text.includes(taskId), false);
723
723
  assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, pending);
724
724
  });
725
+
726
+ test("owner log lists history without data_json and does not clear a pending wake", async (t) => {
727
+ const s = setup(); t.after(s.restore);
728
+ const issued = await s.owner("POST", "/owner/peers", { label: "ada" });
729
+ assert.match(issued.data.token, /^a2aow_/);
730
+ const pasted = "a2aow_" + "b".repeat(20);
731
+ const owner = "a2aot_" + "Q".repeat(43);
732
+ const sent = await s.call("POST", "/", {
733
+ json: { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "m-log", parts: [{ kind: "text", text: "hello from ada" }] } } },
734
+ headers: { authorization: `Bearer ${issued.data.token}` },
735
+ });
736
+ assert.equal(sent.status, 200, JSON.stringify(sent.data));
737
+ const taskId = sent.data.result.id;
738
+ const pending = JSON.stringify({ taskIds: [taskId] });
739
+ const now = Date.now();
740
+ s.DB.db.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?)").run("ctx-keep", now, pending, now);
741
+ const secret = JSON.stringify({ token: pasted, messageId: "m-log" });
742
+ s.DB.db.prepare("UPDATE history SET text = ?, data_json = ? WHERE dir = 'in'").run(`hello ${pasted} and ${owner} there`, secret);
743
+ const log = await s.owner("GET", "/owner/log");
744
+ assert.equal(log.status, 200);
745
+ const text = JSON.stringify(log.data);
746
+ assert.match(text, /hello \[redacted\] and \[redacted\] there/);
747
+ assert.equal(text.includes(pasted), false);
748
+ assert.equal(text.includes(owner), false);
749
+ assert.equal(text.includes(secret), false);
750
+ assert.equal(text.includes("data_json"), false);
751
+ assert.equal(text.includes('"data"'), false);
752
+ assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, pending);
753
+ const inbox = await s.owner("GET", "/owner/inbox");
754
+ assert.equal(inbox.status, 200);
755
+ assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, null);
756
+ });