a2a-exposed 0.4.0 → 0.5.0

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/status.mjs CHANGED
@@ -3,8 +3,8 @@
3
3
  // itself, so agents need no curl (some agent sandboxes flag *.dev URLs in shell commands).
4
4
  import fs from "node:fs";
5
5
  import * as C from "./config.mjs";
6
- import { cardUrlProblem, cfErrorCode, describeHttp, fingerprint, httpJson } from "./a2a.mjs";
7
- import { baseUrl, owner } from "./commands.mjs";
6
+ import { cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
7
+ import { baseUrl, owner, ownerTry } from "./commands.mjs";
8
8
  import * as D from "./deploy.mjs";
9
9
  import * as T from "./tunnel.mjs";
10
10
 
@@ -35,9 +35,58 @@ export function wakeLine(s) {
35
35
  return `webhook (preset ${w.preset}${s.tunnel ? ", through the tunnel" : ""}); auth: ${auth.join(" + ") || "none"}; URL fingerprint ${w.urlFingerprint || "-"}`;
36
36
  }
37
37
 
38
+ // ---------------------------------------------------------------- proxy mode: upstream diagnosis
39
+ /** What to do for each reason code of the Worker's upstream check (`POST /owner/facade/verify`, Worker log
40
+ * `upstream_error`). `d` is the check's detail line (HTTP status, Cloudflare error; never a secret). */
41
+ export const UPSTREAM_FIX = {
42
+ access_credentials_missing: (d) => `Cloudflare Access refused the Worker (${d}). Export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET (the service token the upstream's Access app admits) and run \`${CLI} deploy\``,
43
+ access_rejected: (d) => `Cloudflare Access refused the Worker's service token (${d}): the Access app on the upstream hostname needs a Service Auth policy for that token, and UPSTREAM_ACCESS_CLIENT_ID / _SECRET must be its current pair (export them and run \`${CLI} deploy\`)`,
44
+ upstream_auth_missing: (d) => `Access let the Worker through, but the agent refused the call (${d}). Export UPSTREAM_TOKEN (the bearer the agent expects; its card advertises it) and run \`${CLI} deploy\` (or pipe it: \`<print token> | ${CLI} deploy --upstream-token-stdin\`)`,
45
+ upstream_auth_rejected: (d) => `Access let the Worker through, but the agent refused its UPSTREAM_TOKEN (${d}): wrong or rotated. Export the current one and run \`${CLI} deploy\``,
46
+ tunnel_down: (d) => `Cloudflare can't reach the upstream tunnel (${d}): start the connector on the agent's machine (\`cloudflared tunnel run ...\`)`,
47
+ upstream_unavailable: (d) => `the tunnel answers but the agent's A2A server behind it does not (${d}): start the agent, and check the tunnel ingress \`service:\` URL`,
48
+ network: (d) => `the Worker could not connect to the upstream (${d}): check the tunnel hostname (DNS record, certificate)`,
49
+ unexpected_response: (d) => `the upstream answered without JSON-RPC (${d}): --upstream must be the agent's JSON-RPC endpoint (not its web UI or card URL); fix it with \`${CLI} deploy --upstream <URL>\``,
50
+ misconfigured: (d) => `the upstream URL is unusable (${d}): fix it with \`${CLI} deploy --upstream <tunnel URL>\``,
51
+ };
52
+
53
+ /** The upstream check as layer-by-layer rows: façade peer auth (public), Cloudflare Access, the agent's bearer check, the
54
+ * A2A app. `v`: the Worker's verify answer; `fa`: the public peer-auth probe ({ status }) or null. Pure; unit-tested. */
55
+ export function verifyLayers(v, fa = null) {
56
+ const rows = [];
57
+ if (fa) rows.push(["façade peer auth", fa.status === 401 ? "enforced (an unauthenticated call gets 401 from the façade)"
58
+ : fa.error ? `not checked (${fa.error})` : `UNEXPECTED: an unauthenticated call got HTTP ${fa.status} (expected 401)`]);
59
+ if (!v) return rows;
60
+ const r = v.reason, sent = v.sent || {};
61
+ const notReached = "not reached";
62
+ const access = r === "access_credentials_missing" ? "FAILED: refused by Cloudflare Access; the Worker has no Access service token"
63
+ : r === "access_rejected" ? "FAILED: Cloudflare Access refused the Worker's service token (policy or wrong token)"
64
+ : ["upstream_auth_missing", "upstream_auth_rejected", "reachable", "upstream_unavailable"].includes(r) ? (sent.accessServiceToken ? "passed" : "no service token sent (the upstream hostname is not behind Access?)")
65
+ : r === "tunnel_down" || r === "network" ? notReached : "?";
66
+ const bearer = r === "upstream_auth_missing" ? "FAILED: the agent wants a credential; the Worker has no UPSTREAM_TOKEN"
67
+ : r === "upstream_auth_rejected" ? "FAILED: the agent refused the Worker's UPSTREAM_TOKEN"
68
+ : r === "reachable" ? (sent.bearer ? "accepted" : "none sent (the agent accepted the call without one)")
69
+ : notReached;
70
+ const app = r === "reachable" ? `reachable (${v.detail}${v.rpcErrorCode === -32601 ? ": method not found, as expected" : ""}; no task created)`
71
+ : ["tunnel_down", "upstream_unavailable", "network", "unexpected_response", "misconfigured"].includes(r) ? `FAILED: ${v.detail}` : notReached;
72
+ rows.push(["Access", access], ["upstream bearer", bearer], ["A2A app", app]);
73
+ return rows;
74
+ }
75
+
76
+ /** Peer token variables set in the environment that shadow a different token saved in config.env (names only). */
77
+ export function shadowedPeerTokens() {
78
+ return [...new Set(Object.values(C.loadPeers()).map((p) => p && p.token_env).filter(Boolean))].filter((k) => C.envOverridesFile(k));
79
+ }
80
+
38
81
  /** The single most useful next step for a status snapshot (pure; unit-tested). `also`: optional extra hints, printed
39
82
  * as separate lines (so the next step stays one short instruction). */
40
83
  export function nextStep(s) {
84
+ const n = nextStepCore(s);
85
+ const sh = (s.peerTokenShadowed || []).map((k) => `${k} is set in the environment and overrides the different peer token saved in ${s.configFile} (e.g. a stale value from before \`connect\` stored a new one): send/poll use the environment value. Unset ${k} where it is set to use the saved token`);
86
+ return sh.length ? { ...n, also: [...n.also, ...sh] } : n;
87
+ }
88
+
89
+ function nextStepCore(s) {
41
90
  const fail = (text) => ({ ok: false, text, also: [] });
42
91
  const done = (text, also = []) => ({ ok: true, text, also });
43
92
  if (!s.deployed) return fail(`nothing is deployed from ${s.configFile}: run \`${CLI} init ...\` (setup skill, section 2). Another bot's deployment? Set A2A_CONFIG_DIR.`);
@@ -62,12 +111,28 @@ export function nextStep(s) {
62
111
  return fail(`${s.configFile} has A2A_UPSTREAM_URL, but the Worker is not in proxy mode (an older template or an interrupted deploy): run \`${CLI} deploy\``);
63
112
  if (fc.upstreamProblem) return fail(`the upstream URL is unusable: ${fc.upstreamProblem}. Fix it with \`${CLI} deploy --upstream <tunnel URL>\``);
64
113
  if (fc.upstreamCardProblem) return fail(`the upstream card URL is refused: ${fc.upstreamCardProblem}. Fix it with \`${CLI} deploy --upstream-card-url <URL on the upstream's origin>\` (or \`--upstream-card-url none\` for the default)`);
114
+ // the live check (owner-authenticated, run by the Worker with its stored secrets) is the precise diagnosis
115
+ const v = s.upstreamVerify;
116
+ if (v && v.reason && v.reason !== "reachable" && UPSTREAM_FIX[v.reason]) return fail(UPSTREAM_FIX[v.reason](v.detail || v.reason));
117
+ // an Access-only configuration must not look complete when the agent's card asks for a bearer
118
+ const wantsBearer = fc.upstreamAuth && fc.upstreamAuth.bearer === true;
119
+ if (wantsBearer && !fc.hasUpstreamToken && !(v && v.ok) && !s.upstreamNoToken)
120
+ return fail(`the upstream's card asks for a bearer token (${(fc.upstreamAuth.schemes || []).join(", ")}) and the Worker has no UPSTREAM_TOKEN: Cloudflare Access only lets the Worker through the tunnel, so peers' calls would fail with HTTP 502. Export UPSTREAM_TOKEN and run \`${CLI} deploy\``);
65
121
  if (fc.upstreamCard !== "ok")
66
122
  return fail(`the Worker can't fetch the upstream's agent card (${fc.upstreamCard}): is the tunnel connector running on the agent's machine, and does the Access app admit the service token you exported as UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET (re-run \`${CLI} deploy\` with them exported)?`);
67
123
  if (fc.publicCardLeaks && fc.publicCardLeaks.length)
68
124
  return fail(`the public card still names a private URL (${fc.publicCardLeaks[0]}): report this as a bug; meanwhile override the field with --agent-description / --agent-skills and run \`${CLI} deploy\``);
69
125
  const also = [];
70
126
  if (!fc.hasUpstreamAccessServiceToken) also.push("no Access service token for the upstream: unless the upstream hostname is protected another way, put it behind Cloudflare Access and export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET, then deploy");
127
+ if (wantsBearer && !fc.hasUpstreamToken)
128
+ also.push(`the upstream's card asks for a bearer token but the Worker has none${s.upstreamNoToken ? " (--no-upstream-token)" : ""}${v && v.ok ? "; the agent accepts calls without it today" : ""}: if peers get HTTP 502, export UPSTREAM_TOKEN and run \`${CLI} deploy\``);
129
+ if (fc.upstreamAuth && fc.upstreamAuth.unsupported && fc.upstreamAuth.unsupported.length)
130
+ also.push(`the upstream's card requires ${fc.upstreamAuth.unsupported.join(", ")}; the façade only presents a bearer token (UPSTREAM_TOKEN) and the Access service token`);
131
+ if (s.localUpstreamToken && fc.fingerprints && fc.fingerprints.upstreamToken !== s.localUpstreamToken)
132
+ also.push(`the UPSTREAM_TOKEN exported here (fingerprint ${s.localUpstreamToken}) differs from the Worker's (${fc.fingerprints.upstreamToken || "none"}): run \`${CLI} deploy\` to upload it`);
133
+ if (!v && s.upstreamVerifyError) also.push(`the live upstream check was not run (${s.upstreamVerifyError || "unavailable"})${/HTTP 404/.test(s.upstreamVerifyError || "") ? `: run \`${CLI} deploy\` for a Worker with \`upstream verify\`` : ""}`);
134
+ if (fc.lastFailure && fc.lastFailure.reason && v && v.ok)
135
+ also.push(`last failed peer call: ${fc.lastFailure.reason}${fc.lastFailure.status ? ` (HTTP ${fc.lastFailure.status})` : ""} at ${fc.lastFailure.at}; the live check passes now`);
71
136
  if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
72
137
  also.push(`peers can't connect yet: run \`${CLI} pair set-password --web\` and send your human the one-time link (or they run \`${CLI} pair set-password\` in a terminal); never set it yourself`);
73
138
  if (s.pairing && s.pairing.pending) also.push(`${s.pairing.pending} pending pairing request(s): \`${CLI} pair list\`, then tell your human (never approve on your own)`);
@@ -117,7 +182,9 @@ export async function collect() {
117
182
  baseUrl: baseUrl(), urlKind: C.get("A2A_HOSTNAME") ? "custom domain" : "workers.dev", cron: C.get("A2A_ENABLE_CRON") === "1",
118
183
  hasOwnerToken: !!C.get("A2A_OWNER_TOKEN"), baseUrlSaved: (C.fileConfig().A2A_BASE_URL || "").replace(/\/$/, ""), baseUrlEnv: "",
119
184
  card: { ok: false, error: "not checked" }, ownerApi: { ok: false, error: "not checked" }, wake: null, tunnel: null, zones: null, pairing: null,
120
- upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null,
185
+ upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null, upstreamVerify: null, upstreamVerifyError: "", facadePeerAuth: null,
186
+ upstreamNoToken: C.get("A2A_UPSTREAM_NO_TOKEN") === "1", localUpstreamToken: fingerprint(process.env.UPSTREAM_TOKEN),
187
+ peerTokenShadowed: shadowedPeerTokens(),
121
188
  };
122
189
  const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
123
190
  if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
@@ -140,6 +207,7 @@ export async function collect() {
140
207
  if (s.upstream) {
141
208
  try { s.facade = await owner("GET", "/owner/facade"); }
142
209
  catch (e) { s.facade = { mode: "unknown", error: e.message }; }
210
+ if (s.facade && s.facade.mode === "proxy") Object.assign(s, await upstreamCheck(s.baseUrl));
143
211
  }
144
212
  }
145
213
  }
@@ -163,6 +231,42 @@ export async function collect() {
163
231
  return s;
164
232
  }
165
233
 
234
+ const VERIFY_METHOD = "a2a-exposed/verify-unknown-method";
235
+
236
+ /** The two upstream probes, neither creating anything: (1) public: an unauthenticated JSON-RPC call to the façade, which
237
+ * must answer 401 before looking at the method; (2) owner-authenticated: the Worker sends a JSON-RPC call with an
238
+ * unknown method to the upstream with its stored secrets and classifies the answer (POST /owner/facade/verify). */
239
+ export async function upstreamCheck(base) {
240
+ const out = { facadePeerAuth: null, upstreamVerify: null, upstreamVerifyError: "" };
241
+ try {
242
+ const r = await httpJson(base + "/", { method: "POST", body: { jsonrpc: "2.0", id: "a2a-exposed-verify", method: VERIFY_METHOD, params: {} }, headers: { "a2a-version": "1.0" }, timeout: 15000 });
243
+ out.facadePeerAuth = { status: r.status };
244
+ } catch (e) { out.facadePeerAuth = { status: null, error: e.message }; }
245
+ try {
246
+ const r = await ownerTry("POST", "/owner/facade/verify", {});
247
+ if (r.status === 200 && r.data && typeof r.data === "object" && r.data.reason) out.upstreamVerify = r.data;
248
+ else out.upstreamVerifyError = describeHttp(r.status, r.data);
249
+ } catch (e) { out.upstreamVerifyError = e.message; }
250
+ return out;
251
+ }
252
+
253
+ /** `upstream verify`: the façade's whole path, layer by layer; exit 1 unless the upstream app answered. */
254
+ export async function upstreamVerify(o) {
255
+ if (!C.get("A2A_UPSTREAM_URL")) die(`not a façade: ${C.CONFIG_FILE} has no A2A_UPSTREAM_URL (proxy mode: \`${CLI} deploy --upstream <tunnel URL>\`). For an inbox, check wakes with \`${CLI} wake test\``);
256
+ const base = baseUrl();
257
+ if (!base || !C.get("A2A_OWNER_TOKEN")) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
258
+ const r = await upstreamCheck(base);
259
+ const v = r.upstreamVerify;
260
+ if (o.json) console.log(JSON.stringify({ ...r, ok: !!(v && v.ok) }, null, 2));
261
+ else {
262
+ const rows = [["upstream", C.get("A2A_UPSTREAM_URL")], ...verifyLayers(v, r.facadePeerAuth)];
263
+ if (!v) rows.push(["check", `FAILED: ${r.upstreamVerifyError}${/HTTP 404/.test(r.upstreamVerifyError) ? ` (this Worker predates \`upstream verify\`: run \`${CLI} deploy\`)` : ""}`]);
264
+ else rows.push(["result", v.ok ? "OK: the façade reaches the agent with its stored credentials" : `${v.reason}: ${UPSTREAM_FIX[v.reason] ? UPSTREAM_FIX[v.reason](v.detail || v.reason) : v.detail}`]);
265
+ for (const [k, val] of rows) console.log(`${(k + ":").padEnd(18)} ${val}`);
266
+ }
267
+ if (!(v && v.ok) || (r.facadePeerAuth && r.facadePeerAuth.status !== null && r.facadePeerAuth.status !== 401)) process.exitCode = 1;
268
+ }
269
+
166
270
  export async function status(o) {
167
271
  const s = await collect();
168
272
  const next = nextStep(s);
@@ -179,7 +283,18 @@ export async function status(o) {
179
283
  rows.push(["owner API", s.ownerApi.ok ? "OK" : `FAILED: ${s.ownerApi.error}`]);
180
284
  if (s.upstream) {
181
285
  const f = s.facade || {};
182
- rows.push(["upstream", `${s.upstream} (proxy mode)${f.mode === "proxy" ? `; card ${f.upstreamCard}${f.upstreamVersions && f.upstreamVersions.length ? ` (A2A ${f.upstreamVersions.join(", ")})` : ""}; credential ${f.hasUpstreamToken ? "set" : "none"}; Access service token ${f.hasUpstreamAccessServiceToken ? "set" : "NONE"}; public card ${f.publicCardLeaks && f.publicCardLeaks.length ? "LEAKS a private URL" : "clean"}` : f.error ? `; ${f.error}` : "; the Worker is not in proxy mode"}`]);
286
+ rows.push(["upstream", `${s.upstream} (proxy mode)${f.mode === "proxy" ? `; card ${f.upstreamCard}${f.upstreamVersions && f.upstreamVersions.length ? ` (A2A ${f.upstreamVersions.join(", ")})` : ""}; public card ${f.publicCardLeaks && f.publicCardLeaks.length ? "LEAKS a private URL" : "clean"}` : f.error ? `; ${f.error}` : "; the Worker is not in proxy mode"}`]);
287
+ if (f.mode === "proxy") {
288
+ const ua = f.upstreamAuth || {};
289
+ rows.push(["upstream Access", f.hasUpstreamAccessServiceToken ? `credential configured (UPSTREAM_ACCESS_CLIENT_ID fingerprint ${f.fingerprints?.upstreamAccessClientId || "?"})` : "NONE (UPSTREAM_ACCESS_CLIENT_ID / _SECRET not on the Worker)"]);
290
+ rows.push(["upstream bearer", f.hasUpstreamToken ? `bearer configured (UPSTREAM_TOKEN fingerprint ${f.fingerprints?.upstreamToken || "?"})`
291
+ : ua.bearer === true ? `NONE, but the upstream card asks for one (${(ua.schemes || []).join(", ")})${s.upstreamNoToken ? "; --no-upstream-token" : ""}`
292
+ : ua.bearer === false ? "none (the upstream card declares no bearer auth)" : `none${s.upstreamNoToken ? " (--no-upstream-token)" : ""}`]);
293
+ const v = s.upstreamVerify;
294
+ rows.push(["upstream check", !v ? `not run (${s.upstreamVerifyError || "?"})` : v.ok ? `OK: ${verifyLayers(v).map(([k, x]) => `${k} ${x.replace(/ \(.*$/, "")}`).join("; ")}` : `FAILED (${v.reason}): ${v.detail}`]);
295
+ if (s.facadePeerAuth) rows.push(["peer auth", verifyLayers(null, s.facadePeerAuth)[0][1]]);
296
+ if (f.lastFailure && f.lastFailure.reason) rows.push(["last failure", `${f.lastFailure.reason}${f.lastFailure.status ? ` (HTTP ${f.lastFailure.status})` : ""} on ${f.lastFailure.method || "?"} from peer ${f.lastFailure.peer || "?"} at ${f.lastFailure.at}`]);
297
+ }
183
298
  }
184
299
  rows.push(["wake", wakeLine(s)]);
185
300
  const t = s.tunnel;
@@ -192,7 +307,8 @@ export async function status(o) {
192
307
  }
193
308
  rows.push(["next step", next.text]);
194
309
  for (const a of next.also || []) rows.push(["also", a]);
195
- for (const [k, v] of rows) console.log(`${(k + ":").padEnd(12)} ${v}`);
310
+ const w = Math.max(12, ...rows.map(([k]) => k.length + 2));
311
+ for (const [k, v] of rows) console.log(`${(k + ":").padEnd(w)} ${v}`);
196
312
  }
197
313
  if (!next.ok) process.exitCode = 1;
198
314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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": {
@@ -39,6 +39,7 @@ const plain: Record<string, string> = {
39
39
  RATE_PER_MIN: v("A2A_RATE_PER_MIN"),
40
40
  RETIRED_HOSTNAMES: v("A2A_RETIRED_HOSTNAMES"), // custom domains this agent moved away from (see movedResponse)
41
41
  PAIRING_APPROVAL: v("PAIRING_APPROVAL") || "human", // device-flow pairing: human (approval password on /device) | agent | off
42
+ MCP: v("A2A_MCP"), // remote MCP server at /mcp (connector for Claude etc.): on unless "off"; never in proxy mode
42
43
  PBKDF2_ITERATIONS: v("A2A_PBKDF2_ITERATIONS"), // approval password hashing; default 100000 (the Workers maximum), at least 50000
43
44
  // proxy / expose mode: forward A2A to an agent that already speaks it, published through a Tunnel hostname behind
44
45
  // Access (never a Tailnet / LAN URL: the Worker can't reach those, and they never appear in the public card)
@@ -0,0 +1,43 @@
1
+ -- Remote MCP server (/mcp): OAuth 2.1 clients (dynamic registration), one-time authorization codes, and the grants
2
+ -- (owner-approved, inbox-scoped tokens) that `token list` shows and `token revoke` ends. Peer tokens stay in `peers`
3
+ -- and are never accepted on /mcp; MCP tokens are never accepted on the A2A endpoint or /owner/*.
4
+ CREATE TABLE IF NOT EXISTS oauth_clients (
5
+ client_id TEXT PRIMARY KEY,
6
+ client_name TEXT,
7
+ redirect_uris_json TEXT NOT NULL,
8
+ created_ms INTEGER NOT NULL,
9
+ ip TEXT
10
+ );
11
+ CREATE TABLE IF NOT EXISTS oauth_codes (
12
+ code_hash TEXT PRIMARY KEY,
13
+ client_id TEXT NOT NULL,
14
+ redirect_uri TEXT NOT NULL,
15
+ code_challenge TEXT NOT NULL,
16
+ scope TEXT NOT NULL,
17
+ resource TEXT NOT NULL,
18
+ created_ms INTEGER NOT NULL,
19
+ expires_ms INTEGER NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS mcp_grants (
22
+ label TEXT PRIMARY KEY,
23
+ client_id TEXT NOT NULL,
24
+ client_name TEXT,
25
+ redirect_host TEXT,
26
+ scope TEXT NOT NULL,
27
+ access_hash TEXT UNIQUE,
28
+ access_expires_ms INTEGER,
29
+ refresh_hash TEXT UNIQUE,
30
+ refresh_expires_ms INTEGER,
31
+ created_at TEXT NOT NULL,
32
+ last_used_at TEXT,
33
+ revoked_at TEXT
34
+ );
35
+ -- Peers this inbox sends to from the Worker (MCP `send` / `poll_outbound`), uploaded by `peers sync`. The token is
36
+ -- AES-GCM ciphertext under a key derived from the Worker's OWNER_TOKEN secret, so a D1 export alone doesn't reveal it.
37
+ CREATE TABLE IF NOT EXISTS outbound_peers (
38
+ alias TEXT PRIMARY KEY,
39
+ url TEXT NOT NULL,
40
+ token_enc TEXT,
41
+ created_at TEXT NOT NULL,
42
+ updated_at TEXT NOT NULL
43
+ );
@@ -0,0 +1,10 @@
1
+ -- MCP authorization (2026-07-28): OAuth Client ID Metadata Documents. A client_id that is an https URL names a JSON
2
+ -- document the Worker fetches (SSRF-guarded, 5 KB, 5 s) and validates; valid documents are cached here for their
3
+ -- Cache-Control max-age (60 s - 24 h, default 1 h). Errors and invalid documents are never cached.
4
+ CREATE TABLE IF NOT EXISTS oauth_client_metadata (
5
+ client_id TEXT PRIMARY KEY,
6
+ client_name TEXT,
7
+ redirect_uris_json TEXT NOT NULL,
8
+ fetched_ms INTEGER NOT NULL,
9
+ expires_ms INTEGER NOT NULL
10
+ );
@@ -252,3 +252,121 @@ export function wantsPush(op: string, p: Json): boolean {
252
252
  if (op !== "send" || !p || typeof p !== "object" || !p.configuration || typeof p.configuration !== "object") return false;
253
253
  return Object.entries(p.configuration).some(([k, v]) => /push/i.test(k) && v !== undefined && v !== null);
254
254
  }
255
+
256
+ // ------------------------------------------------------------------ upstream auth + failure diagnosis (proxy mode)
257
+
258
+ /** How an upstream security scheme authenticates: "bearer" (HTTP bearer; OAuth 2.0 and OpenID Connect also send
259
+ * `Authorization: Bearer`), "basic" / "http" (other HTTP schemes), "apiKey", "mtls", or "unknown". Accepts the A2A 1.0
260
+ * shape ({ httpAuthSecurityScheme: { scheme } }, { oauth2SecurityScheme }, ...) and the 0.3 / OpenAPI shape
261
+ * ({ type: "http", scheme: "bearer" }, { type: "oauth2" }, ...). */
262
+ export function schemeKind(s: Json): string {
263
+ if (!s || typeof s !== "object") return "unknown";
264
+ const http = (scheme: unknown) => { const v = String(scheme || "").toLowerCase(); return v === "bearer" ? "bearer" : v === "basic" ? "basic" : "http"; };
265
+ if (s.httpAuthSecurityScheme) return http(s.httpAuthSecurityScheme.scheme);
266
+ if (s.oauth2SecurityScheme || s.openIdConnectSecurityScheme) return "bearer";
267
+ if (s.apiKeySecurityScheme) return "apiKey";
268
+ if (s.mtlsSecurityScheme || s.mutualTlsSecurityScheme) return "mtls";
269
+ switch (String(s.type || "").toLowerCase()) {
270
+ case "http": return http(s.scheme);
271
+ case "oauth2": case "openidconnect": return "bearer";
272
+ case "apikey": return "apiKey";
273
+ case "mutualtls": return "mtls";
274
+ }
275
+ return "unknown";
276
+ }
277
+
278
+ export type UpstreamAuth = {
279
+ /** true: the card asks for a bearer token (UPSTREAM_TOKEN); false: it declares no bearer / HTTP auth (no schemes, or
280
+ * anonymous access allowed, or only schemes the façade can't present); null: no card to tell. */
281
+ bearer: boolean | null;
282
+ /** Declared schemes as "name: kind" (names and kinds only, never values). */
283
+ schemes: string[];
284
+ /** Required schemes the façade can't present (it only sends `Authorization: Bearer <UPSTREAM_TOKEN>`). */
285
+ unsupported: string[];
286
+ };
287
+
288
+ /** What the upstream card says about authenticating to it. Security requirements are read from A2A 1.0
289
+ * `securityRequirements` ([{ schemes: { name: { list: [] } } }]) or 0.3 `security` ([{ name: [] }]); without
290
+ * requirements every declared scheme counts. An empty requirement ({}) means anonymous access is allowed. */
291
+ export function upstreamAuth(card: Json): UpstreamAuth {
292
+ if (!card || typeof card !== "object" || Array.isArray(card)) return { bearer: null, schemes: [], unsupported: [] };
293
+ const defs: Record<string, Json> = card.securitySchemes && typeof card.securitySchemes === "object" && !Array.isArray(card.securitySchemes) ? card.securitySchemes : {};
294
+ const kinds: Record<string, string> = {};
295
+ for (const [n, s] of Object.entries(defs)) kinds[n] = schemeKind(s);
296
+ const schemes = Object.entries(kinds).map(([n, k]) => `${n}: ${k}`);
297
+ const raw = Array.isArray(card.securityRequirements) ? card.securityRequirements : Array.isArray(card.security) ? card.security : [];
298
+ const alts: string[][] = raw.filter((r: Json) => r && typeof r === "object" && !Array.isArray(r))
299
+ .map((r: Json) => Object.keys(r.schemes && typeof r.schemes === "object" && !Array.isArray(r.schemes) ? r.schemes : r));
300
+ const kindOf = (n: string) => kinds[n] || "unknown";
301
+ if (!alts.length) {
302
+ const all = Object.keys(kinds);
303
+ return { bearer: all.some((n) => kinds[n] === "bearer"), schemes, unsupported: all.some((n) => kinds[n] === "bearer") ? [] : all.filter((n) => kinds[n] !== "unknown").map((n) => `${n}: ${kinds[n]}`) };
304
+ }
305
+ if (alts.some((a) => a.length === 0)) return { bearer: false, schemes, unsupported: [] }; // anonymous allowed
306
+ const bearer = alts.some((a) => a.some((n) => kindOf(n) === "bearer"));
307
+ const unsupported = bearer ? [] : [...new Set(alts.flat())].map((n) => `${n}: ${kindOf(n)}`);
308
+ return { bearer, schemes, unsupported };
309
+ }
310
+
311
+ /** Why a call to the upstream did not produce a JSON-RPC answer, as a stable reason code (Worker logs, the owner
312
+ * diagnostics, `status`). Never shown to peers: their 502 stays generic. */
313
+ export type UpstreamReason =
314
+ | "reachable" // a JSON-RPC response came back (any result or error): Access, the tunnel and the bearer all passed
315
+ | "access_credentials_missing" // Cloudflare Access refused the request and the façade has no Access service token
316
+ | "access_rejected" // Cloudflare Access refused the façade's service token (wrong token, or not admitted by the app's policy)
317
+ | "upstream_auth_missing" // the agent answered 401/403 and the façade has no UPSTREAM_TOKEN
318
+ | "upstream_auth_rejected" // the agent answered 401/403 to the façade's UPSTREAM_TOKEN
319
+ | "tunnel_down" // Cloudflare could not reach the tunnel / origin (error 1033, 1016, 530, ...)
320
+ | "upstream_unavailable" // a 5xx without JSON-RPC (cloudflared up, the agent's server not answering)
321
+ | "network" // the fetch itself failed (DNS, TLS, timeout)
322
+ | "unexpected_response" // anything else that is not JSON-RPC (a 404 page, HTML, a redirect elsewhere)
323
+ | "misconfigured"; // UPSTREAM_URL unusable
324
+
325
+ export type UpstreamVerdict = { reason: UpstreamReason; status: number | null; cloudflareError?: string; rpcErrorCode?: number; detail: string };
326
+
327
+ /** Classify one upstream HTTP answer (or a fetch failure: status null + error). Pure; `bearerSent` says whether the
328
+ * façade sent UPSTREAM_TOKEN, `accessSent` whether it sent an Access service token (false turns an Access refusal into
329
+ * access_credentials_missing). Access is recognised by its login redirect (<team>.cloudflareaccess.com,
330
+ * /cdn-cgi/access/), its CF-Access-* response headers, or its error page. */
331
+ export function classifyUpstream(i: { status: number | null; headers?: Headers | Record<string, string>; body?: string; error?: string; bearerSent: boolean; accessSent?: boolean }): UpstreamVerdict {
332
+ if (i.status === null) return { reason: "network", status: null, detail: `request failed: ${String(i.error || "unknown error").slice(0, 200)}` };
333
+ const status = i.status;
334
+ const body = String(i.body || "");
335
+ const get = (k: string): string => {
336
+ const h: any = i.headers;
337
+ if (!h) return "";
338
+ if (typeof h.get === "function") return h.get(k) || "";
339
+ for (const [n, v] of Object.entries(h)) if (n.toLowerCase() === k) return String(v);
340
+ return "";
341
+ };
342
+ const names: string[] = [];
343
+ const h: any = i.headers;
344
+ if (h && typeof h.forEach === "function" && typeof h.get === "function") h.forEach((_v: string, k: string) => names.push(k.toLowerCase()));
345
+ else if (h) names.push(...Object.keys(h).map((k) => k.toLowerCase()));
346
+ let rpc: Json = null;
347
+ try { rpc = JSON.parse(body); } catch { /* not JSON */ }
348
+ if (rpc && typeof rpc === "object" && !Array.isArray(rpc) && rpc.jsonrpc === "2.0" && status !== 401 && status !== 403) {
349
+ const code = rpc.error && typeof rpc.error === "object" && typeof rpc.error.code === "number" ? rpc.error.code : undefined;
350
+ return { reason: "reachable", status, ...(code !== undefined ? { rpcErrorCode: code } : {}),
351
+ detail: code !== undefined ? `JSON-RPC error ${code} (HTTP ${status})` : `JSON-RPC result (HTTP ${status})` };
352
+ }
353
+ const location = get("location");
354
+ const accessPage = /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(location) || /cloudflareaccess\.com|\/cdn-cgi\/access\/|Cloudflare Access/i.test(body.slice(0, 8192));
355
+ const accessHeader = names.some((n) => n.startsWith("cf-access-"));
356
+ if ((status >= 300 && status < 400 && /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(location)) || ((status === 401 || status === 403) && (accessPage || accessHeader)))
357
+ return { reason: i.accessSent === false ? "access_credentials_missing" : "access_rejected", status,
358
+ detail: `${status >= 300 && status < 400 ? `HTTP ${status} redirect to the Cloudflare Access login` : `HTTP ${status} from Cloudflare Access`}: ${i.accessSent === false
359
+ ? "the façade has no Access service token (UPSTREAM_ACCESS_CLIENT_ID / _SECRET)" : "Access refused the façade's service token (wrong token, or the app's policy doesn't admit it)"}` };
360
+ if (status === 401 || status === 403)
361
+ return i.bearerSent
362
+ ? { reason: "upstream_auth_rejected", status, detail: `HTTP ${status} from the agent: it refused the façade's UPSTREAM_TOKEN` }
363
+ : { reason: "upstream_auth_missing", status, detail: `HTTP ${status} from the agent: it wants a credential and the façade has no UPSTREAM_TOKEN` };
364
+ const m = /error code:?\s*(\d{4})|\bError\s+(\d{4})\b|errorCode:?\s*(\d{4})|cf-error-code[^>]*>\s*(\d{4})/i.exec(body.slice(0, 8192));
365
+ const cf = m ? m[1] || m[2] || m[3] || m[4] : "";
366
+ if (cf === "1033" || cf === "1016" || status === 530)
367
+ return { reason: "tunnel_down", status, ...(cf ? { cloudflareError: cf } : {}), detail: `HTTP ${status}${cf ? `, Cloudflare error ${cf}` : ""}: Cloudflare can't reach the tunnel (is cloudflared running?)` };
368
+ if (status >= 500)
369
+ return { reason: "upstream_unavailable", status, ...(cf ? { cloudflareError: cf } : {}), detail: `HTTP ${status}${cf ? `, Cloudflare error ${cf}` : ""} without a JSON-RPC answer: the agent's server behind the tunnel is not answering` };
370
+ return { reason: "unexpected_response", status, ...(cf ? { cloudflareError: cf } : {}),
371
+ detail: `HTTP ${status} without a JSON-RPC answer${status >= 300 && status < 400 && location ? " (a redirect)" : ""}: is UPSTREAM_URL the agent's JSON-RPC endpoint?` };
372
+ }