a2a-exposed 0.7.13 → 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.
- package/lib/main.mjs +11 -2
- package/package.json +1 -1
- package/worker/src/export.ts +67 -2
- package/worker/src/index.ts +41 -3
- package/worker/src/owner-rpc.ts +15 -3
- package/worker/test/export.test.ts +117 -1
- package/worker/test/hosted.test.ts +43 -2
- package/worker/test/owner-rpc.test.ts +8 -1
- package/worker/test/pairing.test.ts +32 -0
package/lib/main.mjs
CHANGED
|
@@ -182,8 +182,17 @@ 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.
|
|
186
|
-
|
|
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
|
+
That page is not stored.
|
|
187
196
|
FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
|
|
188
197
|
Unset, there is no limit. The database rejects another insert.
|
|
189
198
|
SIGNUP_ALLOWLIST, when set, admits only those verified emails. Unset, sign-in stays open.
|
package/package.json
CHANGED
package/worker/src/export.ts
CHANGED
|
@@ -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
|
-
/**
|
|
96
|
-
export
|
|
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"],
|
|
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).
|
|
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);
|
package/worker/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,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();
|
|
@@ -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
|
-
|
|
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,
|
|
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" });
|
|
@@ -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: "
|
|
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
|
+
});
|