a2a-exposed 0.7.11 → 0.7.13

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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
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
  }
@@ -13,13 +13,15 @@
13
13
  // Usage push is not in this change. Approval OIDC, when configured (self-host settings, or the pushed tenant
14
14
  // `approval` object), uses the same pages as self-host.
15
15
  import { DurableObject } from "cloudflare:workers";
16
- import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite } from "./index.ts";
16
+ import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite, runOwnerRpc } from "./index.ts";
17
+ import { accountIdOf, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
17
18
  import { doSqlD1, migrateDo, type SqlStorageLike, type TxRunner } from "./storage.ts";
18
19
  import { MIGRATIONS } from "./migrations.ts";
19
20
  import { bookmarkForTime as readBookmark, exportRows, restoreBookmark as applyBookmark, restartAfterResult, toSql, ExportError, type BookmarkStorage } from "./export.ts";
20
21
  import { hostedDailyBackup } from "./backup.ts";
21
22
  import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
22
23
  import { hostedTenantContext, namespaceForRegion, parseGates, type WorkerBindings } from "./tenancy.ts";
24
+ import { WAKE_SECRET_KEYS, openWakeSecrets } from "./wake-secrets.ts";
23
25
 
24
26
  export { clearDirectoryCache, directoryKey, lookupDirectory, parseDirectoryEntry, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME } from "./directory.ts";
25
27
  export type { DirectoryEntry };
@@ -32,7 +34,7 @@ const gone = () => Response.json({ error: "this agent is gone" }, { status: 410,
32
34
 
33
35
  const TENANT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
34
36
 
35
- /** Versioned config pushed by the control plane. `secretsEnc` is stored and not decrypted here. */
37
+ /** Versioned config pushed by the control plane. `secretsEnc` is decrypted into the wake credentials when the context is built. */
36
38
  export interface TenantConfigPush {
37
39
  version: number;
38
40
  tenantId: string;
@@ -151,14 +153,29 @@ export class TenantStore extends DurableObject<WorkerBindings> {
151
153
  });
152
154
  }
153
155
 
154
- #tenant(fallbackOrigin = "") {
156
+ async #tenant(fallbackOrigin = "") {
155
157
  const row = this.#row;
156
158
  if (!row) return null;
157
- const domain = parseGates(this.env).gates.tenantDomain;
159
+ const parsed = parseGates(this.env);
160
+ const domain = parsed.gates.tenantDomain;
158
161
  const publicUrl = domain ? `https://${row.name}.${domain}` : (fallbackOrigin || this.env.PUBLIC_URL || "");
162
+ const config = { ...row.config };
163
+ // Once a ciphertext is stored, the secret wake fields come only from it. A plaintext copy in
164
+ // config_json is ignored, and a blob that cannot be opened leaves those fields unset.
165
+ if (row.secretsEnc) {
166
+ const opened = await openWakeSecrets(parsed.secretsKey || "", row.tenantId, row.secretsEnc);
167
+ if (!opened) {
168
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "wake_secrets_unreadable", tenant: row.tenantId }));
169
+ }
170
+ for (const key of WAKE_SECRET_KEYS) {
171
+ const value = opened?.[key];
172
+ if (value) config[key] = value;
173
+ else delete config[key];
174
+ }
175
+ }
159
176
  return hostedTenantContext(this.env, {
160
177
  id: row.tenantId, db: this.#db, publicUrl, ownerTokenHash: row.ownerTokenHash,
161
- config: row.config, limits: row.limits, approval: row.approval, status: row.status,
178
+ config, limits: row.limits, approval: row.approval, status: row.status,
162
179
  });
163
180
  }
164
181
 
@@ -198,7 +215,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
198
215
  await storage.deleteAlarm();
199
216
  return;
200
217
  }
201
- const tenant = this.#tenant();
218
+ const tenant = await this.#tenant();
202
219
  if (!tenant) return;
203
220
  const at = await nextCronAt(tenant);
204
221
  if (at == null || !Number.isFinite(at)) {
@@ -226,7 +243,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
226
243
  }
227
244
 
228
245
  async #runAlarm(): Promise<void> {
229
- const tenant = this.#row?.status === "active" ? this.#tenant() : null;
246
+ const tenant = this.#row?.status === "active" ? await this.#tenant() : null;
230
247
  if (tenant) {
231
248
  try { await cronFlush(tenant, this.#ectx()); }
232
249
  catch (e) { console.log(JSON.stringify({ ts: new Date().toISOString(), event: "alarm_failed", error: String(e).slice(0, 200) })); }
@@ -337,6 +354,27 @@ export class TenantStore extends DurableObject<WorkerBindings> {
337
354
  return out;
338
355
  }
339
356
 
357
+ /**
358
+ * Control-plane owner call. The caller has already checked that `accountId` owns this tenant.
359
+ * The path is an allowlist, the account id is logged, and a response that contains a peer token
360
+ * or a decrypted secret is withheld. The network owner API is unchanged and still needs the owner token.
361
+ * An object with no config row answers 404 and writes nothing.
362
+ */
363
+ async owner(input: OwnerRpcRequest): Promise<OwnerRpcResult> {
364
+ if (!this.#row) {
365
+ const accountId = accountIdOf(input && typeof input === "object" ? (input as { accountId?: unknown }).accountId : undefined);
366
+ if (!accountId) {
367
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "owner_rpc_refused", tenant: null, reason: "account" }));
368
+ return { status: 400, body: { error: "accountId is invalid" } };
369
+ }
370
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "owner_rpc_refused", tenant: null, accountId, reason: "absent" }));
371
+ return { status: 404, body: { error: "no such agent" } };
372
+ }
373
+ const tenant = await this.#tenant();
374
+ if (!tenant) return { status: 404, body: { error: "no such agent" } };
375
+ return runOwnerRpc(tenant, this.#ectx(), input);
376
+ }
377
+
340
378
  async fetch(req: Request): Promise<Response> {
341
379
  const row = this.#row;
342
380
  if (!row) return notFound();
@@ -346,7 +384,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
346
384
  if (row.status === "deleting") return gone();
347
385
  return notFound();
348
386
  }
349
- const tenant = this.#tenant(new URL(req.url).origin);
387
+ const tenant = await this.#tenant(new URL(req.url).origin);
350
388
  if (!tenant) return notFound();
351
389
  // Do not arm from the rows visible here. sendWake runs in waitUntil and, on 429/503, inserts the retry
352
390
  // after this response returns. requeueWake arms in that insert. Arm again when those tasks finish so a
@@ -10,6 +10,7 @@ import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaul
10
10
  import { fetchCheckedWake } from "./wake-target.ts";
11
11
  import * as P from "./pairing.ts";
12
12
  import * as M from "./mcp.ts";
13
+ import { accountIdOf, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
13
14
  import { resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
14
15
  import { exportRows, toSql } from "./export.ts";
15
16
  import { backupDatabase, DAILY_CRON } from "./backup.ts";
@@ -82,7 +83,17 @@ function wakeConfig(ctx: TenantContext): WakeConfig {
82
83
  }
83
84
 
84
85
  const debounceMs = (ctx: TenantContext) => Number(ctx.WAKE_DEBOUNCE_SECONDS || defaultDebounceSeconds(preset(ctx))) * 1000;
85
- 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
+ }
86
97
 
87
98
  async function wakeBudgetOk(ctx: TenantContext): Promise<boolean> {
88
99
  const cap = maxPerHour(ctx);
@@ -117,7 +128,10 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
117
128
  try {
118
129
  let r: Response;
119
130
  if (ctx.gates.wakeTargetPolicy === "public-https") {
120
- 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
+ });
121
135
  if ("refused" in checked) {
122
136
  log("wake_refused", { preset: preset(ctx), contextId: ev.contextId, reason: checked.refused });
123
137
  return { status: null, info: `refused: ${checked.refused}` };
@@ -898,12 +912,28 @@ function ownerTaskView(t: Json): Json {
898
912
  }
899
913
 
900
914
  async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionContext, path: string, url: URL, trusted = false): Promise<Response> {
901
- // trusted: an in-process call from an MCP tool (fixed, allowlisted paths; see ownerCall), never from the network
915
+ // trusted: an in-process call. MCP tools use ownerCall (paths fixed in the tool code). The hosted
916
+ // owner() RPC uses runOwnerRpc, which has its own allowlist. A request from the network is never trusted.
902
917
  if (!trusted && !(await isOwner(ctx, req.headers.get("authorization")))) return json({ error: "unauthorized" }, 401);
903
918
  const m = req.method;
904
919
  const seg = path.split("/").filter(Boolean).slice(1); // after "owner"
905
920
  const body = async () => { const raw = await readBody(req, ctx); return raw ? JSON.parse(raw) : {}; };
906
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
+ }
907
937
  if (seg[0] === "inbox" && m === "GET") {
908
938
  const contextFilter = url.searchParams.get("context");
909
939
  const all = url.searchParams.get("all") === "1";
@@ -1017,7 +1047,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1017
1047
  return json({ revoked: true, label });
1018
1048
  }
1019
1049
  }
1020
- 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);
1021
1051
  if (seg[0] === "mcp" && !seg[1] && m === "GET") {
1022
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();
1023
1053
  return json({ enabled: mcpOn(ctx), url: oauthUrls(ctx).mcp, grants: (rows.results as Json[]).map((r) => ({ ...r, refresh_expires_ms: undefined,
@@ -1077,7 +1107,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1077
1107
  return json({ mode: "proxy", ...(await verifyUpstream(ctx)) });
1078
1108
  }
1079
1109
  if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
1080
- // 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
1081
1111
  // uploaded secrets so the owner can compare them with local values without revealing them
1082
1112
  const ev: WakeEvent = { contextId: "ctx-preview", taskId: "task-preview", taskIds: ["task-preview"], from: "example-peer",
1083
1113
  preview: "Example message preview", kind: "test", publicUrl: ctx.PUBLIC_URL };
@@ -1574,7 +1604,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Re
1574
1604
  }
1575
1605
 
1576
1606
  /** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
1577
- async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>): Promise<Response> {
1607
+ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
1578
1608
  const mode = pairingMode(ctx);
1579
1609
  if (!seg[0] && m === "GET") {
1580
1610
  const now = Date.now();
@@ -1625,9 +1655,12 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
1625
1655
  const code = P.normUserCode(decodeURIComponent(seg[0]));
1626
1656
  if (!code) return json({ error: "not a valid code (it looks like WDJB-4827)" }, 400);
1627
1657
  const approve = seg[1] === "approve";
1628
- if (approve && mode !== "agent")
1658
+ // The header is set only by runOwnerRpc for actor "human". A network request is not trusted, so the same
1659
+ // header on /owner/* does not approve and does not change who is logged.
1660
+ const dashboardHuman = trusted && req.headers.get(DASHBOARD_ACTOR_HEADER) === "human";
1661
+ if (approve && mode !== "agent" && !dashboardHuman)
1629
1662
  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);
1630
- const d = await decide(ctx, code, approve, approve ? "agent" : "owner");
1663
+ const d = await decide(ctx, code, approve, dashboardHuman ? "human" : approve ? "agent" : "owner");
1631
1664
  if (!d.ok) return json({ error: d.error }, 409);
1632
1665
  return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
1633
1666
  clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
@@ -2073,6 +2106,57 @@ async function mcpGrantOf(ctx: TenantContext, header: string | null): Promise<Js
2073
2106
  return await ctx.DB.prepare("SELECT * FROM mcp_grants WHERE access_hash = ? AND revoked_at IS NULL AND access_expires_ms > ?").bind(await A.sha256(m[1]), Date.now()).first();
2074
2107
  }
2075
2108
 
2109
+ /**
2110
+ * Hosted owner RPC. The control plane has already checked that `accountId` owns this tenant.
2111
+ * Only an allowlisted path runs, and the response is withheld when it contains a peer token or a secret.
2112
+ * Self-host (`mode` single) is refused here; its owner API stays the bearer check in `handleOwner`.
2113
+ */
2114
+ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, input: OwnerRpcRequest): Promise<OwnerRpcResult> {
2115
+ const raw = input && typeof input === "object" ? input as OwnerRpcRequest & { body?: unknown } : null;
2116
+ const accountId = accountIdOf(raw?.accountId);
2117
+ const tenant = ctx.id || null;
2118
+ const refuse = (status: number, error: string, reason: string): OwnerRpcResult => {
2119
+ log("owner_rpc_refused", { tenant, ...(accountId ? { accountId } : {}), reason });
2120
+ return { status, body: { error } };
2121
+ };
2122
+ if (!accountId) return refuse(400, "accountId is invalid", "account");
2123
+ if (ctx.mode !== "host") return refuse(404, "not found", "tenancy");
2124
+ if (ctx.status !== "active") {
2125
+ const status = ctx.status === "suspended" ? 403 : ctx.status === "deleting" ? 410 : 404;
2126
+ const error = ctx.status === "suspended" ? "this agent is suspended" : ctx.status === "deleting" ? "this agent is gone" : "no such agent";
2127
+ return refuse(status, error, "status");
2128
+ }
2129
+ const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
2130
+ if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
2131
+ if (raw?.body !== undefined && raw.body !== null) return refuse(400, "this call takes no body", "body");
2132
+ const actor = (raw as { actor?: unknown } | null)?.actor;
2133
+ if (actor !== undefined && actor !== "human") return refuse(400, "actor is invalid", "actor");
2134
+ const pairingDecision = matched.route.name === "POST /owner/pairing/:code/approve" || matched.route.name === "POST /owner/pairing/:code/deny";
2135
+ if (actor === "human" && !pairingDecision) return refuse(400, "actor is invalid", "actor");
2136
+ let origin = "https://tenant.invalid";
2137
+ try {
2138
+ const u = new URL(ctx.PUBLIC_URL);
2139
+ if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
2140
+ } catch { /* the handler reads the path, not this origin */ }
2141
+ const url = new URL(origin + matched.route.path + matched.route.search);
2142
+ const headers = new Headers({ "content-type": "application/json" });
2143
+ if (actor === "human") headers.set(DASHBOARD_ACTOR_HEADER, "human");
2144
+ const req = new Request(url, { method: matched.route.method, headers });
2145
+ let res: Response;
2146
+ try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
2147
+ catch (e: any) {
2148
+ const status = e instanceof HttpError ? e.status : 400;
2149
+ const error = String(e?.message || "bad request").slice(0, 200);
2150
+ if (ownerResponseLeaks(error)) return refuse(500, "response withheld", "secret");
2151
+ log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
2152
+ return { status, body: { error } };
2153
+ }
2154
+ const data = scrubOwnerClaims(await res.json().catch(() => null));
2155
+ if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
2156
+ log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
2157
+ return { status: res.status, body: data };
2158
+ }
2159
+
2076
2160
  /** Call an owner API route in-process (MCP tools only; the paths are fixed in the tool code). */
2077
2161
  async function ownerCall(ctx: TenantContext, ectx: ExecutionContext, method: string, path: string, body?: Json): Promise<{ status: number; data: Json }> {
2078
2162
  const url = new URL(ctx.PUBLIC_URL.replace(/\/$/, "") + path);
@@ -0,0 +1,211 @@
1
+ // Hosted owner RPC allowlist. The control plane calls TenantStore.owner() after it has checked that
2
+ // the account owns the tenant. This list is the only paths that call may run, and it is stricter than
3
+ // the owner HTTP API: routes that mint a peer token, return a setup secret, accept a plaintext peer
4
+ // token, or dump credential material (export) are not here. Self-host never calls this.
5
+
6
+ import { ID_RE } from "./a2a.ts";
7
+ import { credentialIsMasked, previewHeaderPlain } from "./wake.ts";
8
+
9
+ /** Control-plane account id. Slashes and dots are refused so the value cannot be a path. */
10
+ export const ACCOUNT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
11
+
12
+ const LABEL = /^[A-Za-z0-9_-]{1,32}$/;
13
+ const MCP_LABEL = /^[A-Za-z0-9_-]{1,64}$/;
14
+ const ALIAS = /^[A-Za-z0-9_.-]{1,64}$/;
15
+ /** Display form WDJB-4827 or the stored form WDJB4827. Vowels and 0/1 are not in a real code. */
16
+ const USER_CODE = /^[BCDFGHJKLMNPQRSTVWXZ]{4}-?[2-9]{4}$/i;
17
+
18
+ const TOKEN_RE = /^(?:a2aow_|s2a_|a2amcp_|a2amcpr_)[A-Za-z0-9_-]{20,}$/;
19
+ const SETUP_RE = /(?:\?|&)t=[A-Za-z0-9_-]{16,}/;
20
+ const USERINFO_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?\s]*@/i;
21
+ /** A non-empty string under one of these keys is a secret, even when it is not a token shape. */
22
+ const SECRET_KEYS = new Set([
23
+ "token", "access_token", "refresh_token", "device_code",
24
+ "ownertoken", "owner_token", "secretsenc", "secrets_enc",
25
+ "clientsecret", "client_secret", "pushtoken", "push_token", "authorization",
26
+ ]);
27
+
28
+ /**
29
+ * Header `runOwnerRpc` sets when `actor` is `human`. `handleOwner` honors it only on an in-process call.
30
+ * A request from the network cannot become that decision by sending the header.
31
+ */
32
+ export const DASHBOARD_ACTOR_HEADER = "x-a2a-dashboard-actor";
33
+
34
+ export interface OwnerRpcRequest {
35
+ accountId: string;
36
+ method: string;
37
+ path: string;
38
+ /** Allowlisted query parameters only. */
39
+ query?: Record<string, string>;
40
+ /** Refused. The owner HTTP API is where a body is accepted. */
41
+ body?: unknown;
42
+ /**
43
+ * Dashboard approve or deny after the control plane has checked a recent sign-in.
44
+ * Any other value is refused. Absent keeps the owner HTTP API's actor (`agent` or `owner`).
45
+ */
46
+ actor?: "human";
47
+ }
48
+
49
+ export interface OwnerRpcResult {
50
+ status: number;
51
+ body: unknown;
52
+ }
53
+
54
+ export interface OwnerRoute {
55
+ method: "GET" | "POST" | "DELETE";
56
+ path: string;
57
+ search: string;
58
+ /** Log name. Ids and codes are not included. */
59
+ name: string;
60
+ }
61
+
62
+ export type OwnerMatch = { ok: true; route: OwnerRoute } | { ok: false; reason: "path" | "query" };
63
+
64
+ export function accountIdOf(v: unknown): string | null {
65
+ return typeof v === "string" && ACCOUNT_ID_RE.test(v) ? v : null;
66
+ }
67
+
68
+ function segments(path: unknown): string[] | null {
69
+ if (typeof path !== "string" || path.length > 300 || !path.startsWith("/owner/")) return null;
70
+ if (path.includes("%") || path.includes("\\") || path.includes("?") || path.includes("#") || path.includes("//")) return null;
71
+ const seg = path.split("/").filter(Boolean);
72
+ if (seg[0] !== "owner" || seg.some((s) => s === "." || s === "..")) return null;
73
+ return seg;
74
+ }
75
+
76
+ function queryRecord(query: unknown): Record<string, string> | null {
77
+ if (query == null) return {};
78
+ if (typeof query !== "object" || Array.isArray(query)) return null;
79
+ const out: Record<string, string> = {};
80
+ for (const [k, v] of Object.entries(query)) {
81
+ if (typeof v !== "string" || v.length > 128) return null;
82
+ out[k] = v;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function idOk(s: string): boolean {
88
+ return ID_RE.test(s) && s !== "." && s !== "..";
89
+ }
90
+
91
+ function noQuery(q: Record<string, string>): string | null {
92
+ return Object.keys(q).length === 0 ? "" : null;
93
+ }
94
+
95
+ function inboxQuery(q: Record<string, string>): string | null {
96
+ for (const k of Object.keys(q)) if (k !== "context" && k !== "all") return null;
97
+ if (q.context !== undefined && !idOk(q.context)) return null;
98
+ if (q.all !== undefined && q.all !== "1") return null;
99
+ const sp = new URLSearchParams();
100
+ if (q.context) sp.set("context", q.context);
101
+ if (q.all) sp.set("all", "1");
102
+ const s = sp.toString();
103
+ return s ? `?${s}` : "";
104
+ }
105
+
106
+ function historyQuery(q: Record<string, string>): string | null {
107
+ for (const k of Object.keys(q)) if (k !== "n") return null;
108
+ if (q.n === undefined) return "";
109
+ if (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000) return null;
110
+ return `?n=${q.n}`;
111
+ }
112
+
113
+ interface RouteDef {
114
+ method: "GET" | "POST" | "DELETE";
115
+ name: string;
116
+ take: (seg: string[]) => boolean;
117
+ search: (q: Record<string, string>) => string | null;
118
+ path: (seg: string[]) => string;
119
+ }
120
+
121
+ const ROUTES: RouteDef[] = [
122
+ { method: "GET", name: "GET /owner/inbox", take: (s) => s.length === 2 && s[1] === "inbox", search: inboxQuery, path: () => "/owner/inbox" },
123
+ { method: "GET", name: "GET /owner/summary", take: (s) => s.length === 2 && s[1] === "summary", search: noQuery, path: () => "/owner/summary" },
124
+ { 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]}` },
125
+ { 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]}` },
126
+ { 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]}` },
127
+ { method: "GET", name: "GET /owner/peers", take: (s) => s.length === 2 && s[1] === "peers", search: noQuery, path: () => "/owner/peers" },
128
+ { method: "DELETE", name: "DELETE /owner/peers/:label", take: (s) => s.length === 3 && s[1] === "peers" && LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/peers/${s[2]}` },
129
+ { method: "GET", name: "GET /owner/pairing", take: (s) => s.length === 2 && s[1] === "pairing", search: noQuery, path: () => "/owner/pairing" },
130
+ { method: "POST", name: "POST /owner/pairing/:code/approve", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "approve", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/approve` },
131
+ { method: "POST", name: "POST /owner/pairing/:code/deny", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "deny", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/deny` },
132
+ { method: "GET", name: "GET /owner/mcp", take: (s) => s.length === 2 && s[1] === "mcp", search: noQuery, path: () => "/owner/mcp" },
133
+ { method: "DELETE", name: "DELETE /owner/mcp/:label", take: (s) => s.length === 3 && s[1] === "mcp" && MCP_LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/mcp/${s[2]}` },
134
+ { method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
135
+ { method: "DELETE", name: "DELETE /owner/outbound-peers/:alias", take: (s) => s.length === 3 && s[1] === "outbound-peers" && ALIAS.test(s[2]!) && s[2] !== "." && s[2] !== "..", search: noQuery, path: (s) => `/owner/outbound-peers/${s[2]}` },
136
+ { method: "GET", name: "GET /owner/facade", take: (s) => s.length === 2 && s[1] === "facade", search: noQuery, path: () => "/owner/facade" },
137
+ { method: "POST", name: "POST /owner/facade/verify", take: (s) => s.length === 3 && s[1] === "facade" && s[2] === "verify", search: noQuery, path: () => "/owner/facade/verify" },
138
+ { 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
+ { 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
+ { method: "GET", name: "GET /owner/contexts", take: (s) => s.length === 2 && s[1] === "contexts", search: noQuery, path: () => "/owner/contexts" },
141
+ ];
142
+
143
+ /** Method + path (+ query) on the owner RPC allowlist, or why it is not. */
144
+ export function matchOwnerRpc(method: unknown, path: unknown, query: unknown): OwnerMatch {
145
+ if (method !== "GET" && method !== "POST" && method !== "DELETE") return { ok: false, reason: "path" };
146
+ const seg = segments(path);
147
+ if (!seg) return { ok: false, reason: "path" };
148
+ const def = ROUTES.find((r) => r.method === method && r.take(seg));
149
+ if (!def) return { ok: false, reason: "path" };
150
+ const q = queryRecord(query);
151
+ if (!q) return { ok: false, reason: "query" };
152
+ const search = def.search(q);
153
+ if (search === null) return { ok: false, reason: "query" };
154
+ return { ok: true, route: { method, path: def.path(seg), search, name: def.name } };
155
+ }
156
+
157
+ function stringLeaks(value: string, key: string | null): boolean {
158
+ if (TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value)) return true;
159
+ return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
160
+ }
161
+
162
+ /** Claimed display fields. A token-shaped value is blanked so one request cannot withhold the list. */
163
+ const CLAIM_KEYS = new Set(["clientname", "client_name", "clientid", "client_id", "agentcardurl", "agent_card_url"]);
164
+
165
+ function claimLeaks(value: string): boolean {
166
+ return TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value);
167
+ }
168
+
169
+ /**
170
+ * Replace a claimed name, alias, or card that is shaped like a peer token, a setup secret, or URL
171
+ * userinfo with an empty string. Other keys are left for `ownerResponseLeaks`.
172
+ */
173
+ export function scrubOwnerClaims(value: unknown, depth = 0): unknown {
174
+ if (value == null || depth > 20) return value;
175
+ if (Array.isArray(value)) return value.map((item) => scrubOwnerClaims(item, depth + 1));
176
+ if (typeof value !== "object") return value;
177
+ const out: Record<string, unknown> = {};
178
+ for (const [key, item] of Object.entries(value)) {
179
+ out[key] = typeof item === "string" && CLAIM_KEYS.has(key.toLowerCase()) && claimLeaks(item)
180
+ ? ""
181
+ : scrubOwnerClaims(item, depth + 1);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ /** A rendered wake whose credential header was not masked. The header name is the caller's choice. */
187
+ function wakePreviewLeaks(value: object): boolean {
188
+ const headers = (value as { method?: unknown; headers?: unknown }).headers;
189
+ if ((value as { method?: unknown }).method !== "POST" || !headers || typeof headers !== "object" || Array.isArray(headers)) return false;
190
+ for (const [k, v] of Object.entries(headers)) {
191
+ if (typeof v !== "string" || previewHeaderPlain(k) || credentialIsMasked(v)) continue;
192
+ return true;
193
+ }
194
+ return false;
195
+ }
196
+
197
+ /** True when a JSON value carries a peer token, a setup secret, a URL userinfo, or a secret field. */
198
+ export function ownerResponseLeaks(value: unknown, depth = 0, key: string | null = null): boolean {
199
+ if (depth > 20) return true;
200
+ if (typeof value === "string") return stringLeaks(value, key);
201
+ if (typeof value === "number" || typeof value === "boolean" || value == null) return false;
202
+ if (Array.isArray(value)) return value.some((v) => ownerResponseLeaks(v, depth + 1, null));
203
+ if (typeof value === "object") {
204
+ if (wakePreviewLeaks(value)) return true;
205
+ for (const [k, v] of Object.entries(value)) {
206
+ if (ownerResponseLeaks(v, depth + 1, k)) return true;
207
+ }
208
+ return false;
209
+ }
210
+ return true;
211
+ }
@@ -47,8 +47,9 @@ export type ConfigFields = { [K in ConfigKey]?: string };
47
47
 
48
48
  /**
49
49
  * Non-secret platform defaults a hosted tenant inherits when its pushed config omits them.
50
- * Wake secrets, upstream secrets and OWNER_TOKEN are never inherited: in hosted mode they come only from the
51
- * tenant overlay (or not at all). Decrypting `secrets_enc` into wake credentials is a follow-up.
50
+ * Wake secrets, upstream secrets and OWNER_TOKEN are never inherited. In hosted mode the secret wake
51
+ * fields come from decrypting `secrets_enc` (see the tenant object). A plaintext copy in the pushed
52
+ * config is used only when that ciphertext is absent.
52
53
  */
53
54
  const HOSTED_PLATFORM_DEFAULTS: readonly ConfigKey[] = ["MAX_BODY", "RATE_PER_MIN", "PBKDF2_ITERATIONS", "MCP", "PAIRING_APPROVAL"];
54
55