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.
package/lib/main.mjs CHANGED
@@ -68,8 +68,11 @@ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
68
68
  wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
69
69
  [--cli-command CMD] [--debounce S] [--max-per-hour N]
70
70
  save wake settings, upload wake secrets from env, and redeploy (one step)
71
- wake preview rendered wake request (partially masked) + sha256 fingerprints of the
72
- uploaded URL/key/HMAC secret; compared with local env values if exported
71
+ --max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
72
+ which defaults to 25 when this is unset
73
+ wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
74
+ + sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
75
+ local env values if exported
73
76
  wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* in the local environment
74
77
  wake test send a test wake now and print the HTTP status
75
78
  wake unset remove wake secrets (fall back to polling)
@@ -179,8 +182,17 @@ Control plane account (only when that deployment's login is on)
179
182
  browser can follow the redirect. Buttons say Continue with that
180
183
  provider. Until the check finishes they stay muted and the page says
181
184
  Waiting for the check…. A reused token says The check expired. Please try again.
182
- After sign-in, /app shows the account and its agents. Sign out is on that
183
- 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
+ That page is not stored.
184
196
  FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
185
197
  Unset, there is no limit. The database rejects another insert.
186
198
  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.12",
3
+ "version": "0.7.14",
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": {
@@ -49,7 +49,7 @@ const plain: Record<string, string> = {
49
49
  // Hosted multi-tenant gates. All empty by default, and empty is omitted, so a self-host deploy is unchanged.
50
50
  // TENANCY=host switches the entrypoint to src/hosted.ts and adds TENANT_DO + TENANT_DIRECTORY below.
51
51
  // QUOTAS, USAGE_SINK, SIGNUP_URL and BRANDING are parsed and reserved.
52
- // WAKE_TARGET_POLICY=public-https refuses a wake that is not public https (see wake-target.ts).
52
+ // WAKE_TARGET_POLICY=public-https refuses a wake that is not public https. The socket opens to an address from that check, and TLS names the hostname (see wake-target.ts).
53
53
  // APPROVAL_OIDC_* turn on OpenID Connect approval when issuer, client id, secret and allowlist are all set.
54
54
  TENANCY: v("TENANCY"),
55
55
  TENANT_DOMAIN: v("TENANT_DOMAIN"),
@@ -20,5 +20,8 @@
20
20
  "miniflare": "^5.20261006.0-alpha",
21
21
  "typescript": "^7.0.2",
22
22
  "vite": "^8.3.0"
23
+ },
24
+ "dependencies": {
25
+ "tunnelfetch": "1.13.0"
23
26
  }
24
27
  }
@@ -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"],
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);
@@ -10,9 +10,9 @@ import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaul
10
10
  import { fetchCheckedWake } from "./wake-target.ts";
11
11
  import * as P from "./pairing.ts";
12
12
  import * as M from "./mcp.ts";
13
- import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
13
+ 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;
@@ -83,7 +83,17 @@ function wakeConfig(ctx: TenantContext): WakeConfig {
83
83
  }
84
84
 
85
85
  const debounceMs = (ctx: TenantContext) => Number(ctx.WAKE_DEBOUNCE_SECONDS || defaultDebounceSeconds(preset(ctx))) * 1000;
86
- const maxPerHour = (ctx: TenantContext) => Number(ctx.WAKE_MAX_PER_HOUR || defaultMaxPerHour(preset(ctx)));
86
+
87
+ /**
88
+ * Hourly wake cap. 0 means unlimited, including an explicit "0".
89
+ * An empty or unset value uses the preset default (unlimited, or 25 for Claude Code).
90
+ */
91
+ function maxPerHour(ctx: TenantContext): number {
92
+ const raw = (ctx.WAKE_MAX_PER_HOUR ?? "").trim();
93
+ const n = raw === "" ? defaultMaxPerHour(preset(ctx)) : Number(raw);
94
+ if (!Number.isFinite(n) || n <= 0) return 0;
95
+ return Math.floor(n);
96
+ }
87
97
 
88
98
  async function wakeBudgetOk(ctx: TenantContext): Promise<boolean> {
89
99
  const cap = maxPerHour(ctx);
@@ -118,7 +128,10 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
118
128
  try {
119
129
  let r: Response;
120
130
  if (ctx.gates.wakeTargetPolicy === "public-https") {
121
- const checked = await fetchCheckedWake(req.url, { method: "POST", headers: req.headers, body: req.body });
131
+ const checked = await fetchCheckedWake(req.url, { method: "POST", headers: req.headers, body: req.body }, {
132
+ localHost: (() => { try { return new URL(ctx.PUBLIC_URL).host; } catch { return ""; } })(),
133
+ local: (url, init) => doFetch(ctx, ectx, url, init),
134
+ });
122
135
  if ("refused" in checked) {
123
136
  log("wake_refused", { preset: preset(ctx), contextId: ev.contextId, reason: checked.refused });
124
137
  return { status: null, info: `refused: ${checked.refused}` };
@@ -906,6 +919,21 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
906
919
  const seg = path.split("/").filter(Boolean).slice(1); // after "owner"
907
920
  const body = async () => { const raw = await readBody(req, ctx); return raw ? JSON.parse(raw) : {}; };
908
921
 
922
+ if (seg[0] === "summary" && !seg[1] && m === "GET") {
923
+ // Counts only. Unlike GET /owner/inbox this does not return message text or clear a pending wake.
924
+ const known = ["submitted", "working", "input-required", "completed", "failed", "rejected", "canceled"];
925
+ const tasks: Record<string, number> = {};
926
+ for (const state of known) tasks[state] = 0;
927
+ const rows = await ctx.DB.prepare("SELECT state, COUNT(*) AS n FROM tasks GROUP BY state").all();
928
+ for (const r of rows.results as Json[]) {
929
+ if (typeof r.state !== "string" || !known.includes(r.state)) continue;
930
+ const n = Number(r.n);
931
+ if (Number.isSafeInteger(n) && n >= 0) tasks[r.state] = n;
932
+ }
933
+ const pending: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM device_requests WHERE status = 'pending' AND expires_ms > ?").bind(Date.now()).first();
934
+ const pairing = Number(pending?.n ?? 0);
935
+ return json({ tasks, pairingPending: Number.isSafeInteger(pairing) && pairing >= 0 ? pairing : 0 });
936
+ }
909
937
  if (seg[0] === "inbox" && m === "GET") {
910
938
  const contextFilter = url.searchParams.get("context");
911
939
  const all = url.searchParams.get("all") === "1";
@@ -1019,7 +1047,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1019
1047
  return json({ revoked: true, label });
1020
1048
  }
1021
1049
  }
1022
- if (seg[0] === "pairing") return await ownerPairing(req, ctx, m, seg.slice(1), body);
1050
+ if (seg[0] === "pairing") return await ownerPairing(req, ctx, m, seg.slice(1), body, trusted);
1023
1051
  if (seg[0] === "mcp" && !seg[1] && m === "GET") {
1024
1052
  const rows = await ctx.DB.prepare("SELECT label, client_id, client_name, redirect_host, scope, created_at, last_used_at, revoked_at, refresh_expires_ms FROM mcp_grants ORDER BY created_at").all();
1025
1053
  return json({ enabled: mcpOn(ctx), url: oauthUrls(ctx).mcp, grants: (rows.results as Json[]).map((r) => ({ ...r, refresh_expires_ms: undefined,
@@ -1079,7 +1107,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1079
1107
  return json({ mode: "proxy", ...(await verifyUpstream(ctx)) });
1080
1108
  }
1081
1109
  if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
1082
- // rendered wake request for a sample event, partially masked, plus short sha256 fingerprints of the
1110
+ // rendered wake request for a sample event, credentials shown as Bearer •••• or ••••, plus short sha256 fingerprints of the
1083
1111
  // uploaded secrets so the owner can compare them with local values without revealing them
1084
1112
  const ev: WakeEvent = { contextId: "ctx-preview", taskId: "task-preview", taskIds: ["task-preview"], from: "example-peer",
1085
1113
  preview: "Example message preview", kind: "test", publicUrl: ctx.PUBLIC_URL };
@@ -1118,6 +1146,27 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1118
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();
1119
1147
  return json(rows.results);
1120
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
+ }
1121
1170
  if (seg[0] === "export" && !seg[1] && m === "GET") {
1122
1171
  const file = await exportRows(ctx.DB);
1123
1172
  if (url.searchParams.get("format") === "sql") {
@@ -1576,7 +1625,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Re
1576
1625
  }
1577
1626
 
1578
1627
  /** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
1579
- async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>): Promise<Response> {
1628
+ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
1580
1629
  const mode = pairingMode(ctx);
1581
1630
  if (!seg[0] && m === "GET") {
1582
1631
  const now = Date.now();
@@ -1627,9 +1676,12 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
1627
1676
  const code = P.normUserCode(decodeURIComponent(seg[0]));
1628
1677
  if (!code) return json({ error: "not a valid code (it looks like WDJB-4827)" }, 400);
1629
1678
  const approve = seg[1] === "approve";
1630
- if (approve && mode !== "agent")
1679
+ // The header is set only by runOwnerRpc for actor "human". A network request is not trusted, so the same
1680
+ // header on /owner/* does not approve and does not change who is logged.
1681
+ const dashboardHuman = trusted && req.headers.get(DASHBOARD_ACTOR_HEADER) === "human";
1682
+ if (approve && mode !== "agent" && !dashboardHuman)
1631
1683
  return json({ error: `approval mode is human: the owner approves on ${oauthUrls(ctx).page}?user_code=${P.formatUserCode(code)} with the approval password. \`pair approve\` works only after \`${cliCommand(ctx)} deploy --pairing-approval agent\`, and only if your human wants the agent to approve. \`pair deny\` works in every mode.` }, 403);
1632
- const d = await decide(ctx, code, approve, approve ? "agent" : "owner");
1684
+ const d = await decide(ctx, code, approve, dashboardHuman ? "human" : approve ? "agent" : "owner");
1633
1685
  if (!d.ok) return json({ error: d.error }, 409);
1634
1686
  return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
1635
1687
  clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
@@ -2098,13 +2150,21 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
2098
2150
  const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
2099
2151
  if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
2100
2152
  if (raw?.body !== undefined && raw.body !== null) return refuse(400, "this call takes no body", "body");
2153
+ const actor = (raw as { actor?: unknown } | null)?.actor;
2154
+ if (actor !== undefined && actor !== "human") return refuse(400, "actor is invalid", "actor");
2155
+ const pairingDecision = matched.route.name === "POST /owner/pairing/:code/approve" || matched.route.name === "POST /owner/pairing/:code/deny";
2156
+ if (actor === "human" && !pairingDecision) return refuse(400, "actor is invalid", "actor");
2101
2157
  let origin = "https://tenant.invalid";
2102
2158
  try {
2103
2159
  const u = new URL(ctx.PUBLIC_URL);
2104
2160
  if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
2105
2161
  } catch { /* the handler reads the path, not this origin */ }
2106
- const url = new URL(origin + matched.route.path + matched.route.search);
2107
- const req = new Request(url, { method: matched.route.method, headers: { "content-type": "application/json" } });
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);
2165
+ const headers = new Headers({ "content-type": "application/json" });
2166
+ if (actor === "human") headers.set(DASHBOARD_ACTOR_HEADER, "human");
2167
+ const req = new Request(url, { method: matched.route.method, headers });
2108
2168
  let res: Response;
2109
2169
  try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
2110
2170
  catch (e: any) {
@@ -2114,7 +2174,22 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
2114
2174
  log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
2115
2175
  return { status, body: { error } };
2116
2176
  }
2117
- const data = 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
+ }
2118
2193
  if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
2119
2194
  log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
2120
2195
  return { status: res.status, body: data };
@@ -1,10 +1,10 @@
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
- import { previewHeaderPlain } from "./wake.ts";
7
+ import { credentialIsMasked, previewHeaderPlain } from "./wake.ts";
8
8
 
9
9
  /** Control-plane account id. Slashes and dots are refused so the value cannot be a path. */
10
10
  export const ACCOUNT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
@@ -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. */
@@ -25,6 +27,12 @@ const SECRET_KEYS = new Set([
25
27
  "clientsecret", "client_secret", "pushtoken", "push_token", "authorization",
26
28
  ]);
27
29
 
30
+ /**
31
+ * Header `runOwnerRpc` sets when `actor` is `human`. `handleOwner` honors it only on an in-process call.
32
+ * A request from the network cannot become that decision by sending the header.
33
+ */
34
+ export const DASHBOARD_ACTOR_HEADER = "x-a2a-dashboard-actor";
35
+
28
36
  export interface OwnerRpcRequest {
29
37
  accountId: string;
30
38
  method: string;
@@ -33,6 +41,11 @@ export interface OwnerRpcRequest {
33
41
  query?: Record<string, string>;
34
42
  /** Refused. The owner HTTP API is where a body is accepted. */
35
43
  body?: unknown;
44
+ /**
45
+ * Dashboard approve or deny after the control plane has checked a recent sign-in.
46
+ * Any other value is refused. Absent keeps the owner HTTP API's actor (`agent` or `owner`).
47
+ */
48
+ actor?: "human";
36
49
  }
37
50
 
38
51
  export interface OwnerRpcResult {
@@ -99,6 +112,14 @@ function historyQuery(q: Record<string, string>): string | null {
99
112
  return `?n=${q.n}`;
100
113
  }
101
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
+
102
123
  interface RouteDef {
103
124
  method: "GET" | "POST" | "DELETE";
104
125
  name: string;
@@ -109,6 +130,7 @@ interface RouteDef {
109
130
 
110
131
  const ROUTES: RouteDef[] = [
111
132
  { method: "GET", name: "GET /owner/inbox", take: (s) => s.length === 2 && s[1] === "inbox", search: inboxQuery, path: () => "/owner/inbox" },
133
+ { method: "GET", name: "GET /owner/summary", take: (s) => s.length === 2 && s[1] === "summary", search: noQuery, path: () => "/owner/summary" },
112
134
  { method: "GET", name: "GET /owner/tasks/:id", take: (s) => s.length === 3 && s[1] === "tasks" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/tasks/${s[2]}` },
113
135
  { method: "GET", name: "GET /owner/history/:id", take: (s) => s.length === 3 && s[1] === "history" && idOk(s[2]!), search: historyQuery, path: (s) => `/owner/history/${s[2]}` },
114
136
  { method: "GET", name: "GET /owner/outbound/:id", take: (s) => s.length === 3 && s[1] === "outbound" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/outbound/${s[2]}` },
@@ -126,6 +148,8 @@ const ROUTES: RouteDef[] = [
126
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" },
127
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" },
128
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" },
129
153
  ];
130
154
 
131
155
  /** Method + path (+ query) on the owner RPC allowlist, or why it is not. */
@@ -147,12 +171,36 @@ function stringLeaks(value: string, key: string | null): boolean {
147
171
  return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
148
172
  }
149
173
 
174
+ /** Claimed display fields. A token-shaped value is blanked so one request cannot withhold the list. */
175
+ const CLAIM_KEYS = new Set(["clientname", "client_name", "clientid", "client_id", "agentcardurl", "agent_card_url"]);
176
+
177
+ function claimLeaks(value: string): boolean {
178
+ return TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value);
179
+ }
180
+
181
+ /**
182
+ * Replace a claimed name, alias, or card that is shaped like a peer token, a setup secret, or URL
183
+ * userinfo with an empty string. Other keys are left for `ownerResponseLeaks`.
184
+ */
185
+ export function scrubOwnerClaims(value: unknown, depth = 0): unknown {
186
+ if (value == null || depth > 20) return value;
187
+ if (Array.isArray(value)) return value.map((item) => scrubOwnerClaims(item, depth + 1));
188
+ if (typeof value !== "object") return value;
189
+ const out: Record<string, unknown> = {};
190
+ for (const [key, item] of Object.entries(value)) {
191
+ out[key] = typeof item === "string" && CLAIM_KEYS.has(key.toLowerCase()) && claimLeaks(item)
192
+ ? ""
193
+ : scrubOwnerClaims(item, depth + 1);
194
+ }
195
+ return out;
196
+ }
197
+
150
198
  /** A rendered wake whose credential header was not masked. The header name is the caller's choice. */
151
199
  function wakePreviewLeaks(value: object): boolean {
152
200
  const headers = (value as { method?: unknown; headers?: unknown }).headers;
153
201
  if ((value as { method?: unknown }).method !== "POST" || !headers || typeof headers !== "object" || Array.isArray(headers)) return false;
154
202
  for (const [k, v] of Object.entries(headers)) {
155
- if (typeof v !== "string" || previewHeaderPlain(k) || v.endsWith("…")) continue;
203
+ if (typeof v !== "string" || previewHeaderPlain(k) || credentialIsMasked(v)) continue;
156
204
  return true;
157
205
  }
158
206
  return false;