a2a-exposed 0.0.0-stage → 0.4.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.
@@ -0,0 +1,1445 @@
1
+ // a2a-exposed Worker: public A2A endpoint (JSON-RPC; A2A 1.0 primary, 0.3 compatible),
2
+ // D1-backed inbox, wake webhooks (presets), and an owner API for the local CLI.
3
+ // Proxy mode (UPSTREAM_URL set): a public façade for an agent that already speaks A2A on a private network; the
4
+ // Worker serves a rewritten agent card and device-flow pairing, and forwards authenticated JSON-RPC to the upstream.
5
+ import * as A from "./a2a.ts";
6
+ import * as F from "./facade.ts";
7
+ import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
8
+ import * as P from "./pairing.ts";
9
+ type Json = any;
10
+
11
+ interface Env {
12
+ DB: D1Database;
13
+ PUBLIC_URL: string; // may be empty on a first workers.dev deploy (request origin is used then; see A.publicOrigin)
14
+ RETIRED_HOSTNAMES?: string; // old custom domains (comma-separated): 301 for the card, 410 for everything else
15
+ AGENT_NAME?: string;
16
+ AGENT_DESCRIPTION?: string;
17
+ AGENT_VERSION?: string;
18
+ AGENT_SKILLS?: string; // JSON array of AgentSkill
19
+ PROVIDER_ORGANIZATION?: string;
20
+ PROVIDER_URL?: string;
21
+ DOCUMENTATION_URL?: string;
22
+ // secrets
23
+ OWNER_TOKEN?: string;
24
+ WAKE_WEBHOOK_URL?: string;
25
+ WAKE_WEBHOOK_KEY?: string;
26
+ WAKE_HMAC_SECRET?: string;
27
+ WAKE_ACCESS_CLIENT_ID?: string; // Cloudflare Access service token (wake URL behind Access, e.g. `tunnel create`)
28
+ WAKE_ACCESS_CLIENT_SECRET?: string;
29
+ // wake config (plain vars)
30
+ WAKE_PRESET?: string;
31
+ WAKE_AGENT_ID?: string;
32
+ WAKE_KEY_HEADER?: string;
33
+ WAKE_KEY_PREFIX?: string;
34
+ WAKE_BODY_TEMPLATE?: string;
35
+ WAKE_CLI_COMMAND?: string;
36
+ WAKE_DEBOUNCE_SECONDS?: string;
37
+ WAKE_MAX_PER_HOUR?: string;
38
+ // limits
39
+ MAX_BODY?: string;
40
+ RATE_PER_MIN?: string;
41
+ // device-flow pairing: human (default) | agent | off
42
+ PAIRING_APPROVAL?: string;
43
+ PBKDF2_ITERATIONS?: string; // approval password hashing (default 100000, the Workers maximum; at least 50000)
44
+ // proxy / expose mode: forward A2A JSON-RPC to an existing agent (reached through a Tunnel hostname behind Access)
45
+ UPSTREAM_URL?: string; // the upstream's JSON-RPC endpoint, https on a public (tunnel) hostname
46
+ UPSTREAM_CARD_URL?: string; // its agent card (default <upstream origin>/.well-known/agent-card.json)
47
+ UPSTREAM_TOKEN?: string; // secret: the one credential the façade presents upstream (Authorization: Bearer)
48
+ UPSTREAM_ACCESS_CLIENT_ID?: string; // secret: Cloudflare Access service token for the upstream's tunnel hostname
49
+ UPSTREAM_ACCESS_CLIENT_SECRET?: string;
50
+ }
51
+
52
+ class RpcError extends Error {
53
+ code: number;
54
+ constructor(code: number, msg: string) { super(msg); this.code = code; }
55
+ }
56
+ class HttpError extends Error {
57
+ status: number;
58
+ constructor(status: number, msg: string) { super(msg); this.status = status; }
59
+ }
60
+
61
+ const json = (obj: Json, status = 200, headers: Record<string, string> = {}) =>
62
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...headers } });
63
+
64
+ const log = (event: string, kw: Json = {}) => console.log(JSON.stringify({ ts: A.nowIso(), event, ...kw }));
65
+
66
+ // ------------------------------------------------------------------ storage
67
+ async function loadTask(env: Env, id: string): Promise<Json | null> {
68
+ const r: Json = await env.DB.prepare("SELECT * FROM tasks WHERE id = ?").bind(id).first();
69
+ if (!r) return null;
70
+ const msgs = await env.DB.prepare("SELECT message_json FROM messages WHERE task_id = ? ORDER BY seq").bind(id).all();
71
+ return {
72
+ id: r.id, contextId: r.context_id, status: JSON.parse(r.status_json), artifacts: JSON.parse(r.artifacts_json),
73
+ metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
74
+ history: (msgs.results || []).map((m: Json) => JSON.parse(m.message_json)),
75
+ _peer: r.peer, _protocol: r.protocol, _createdAt: r.created_at, _push: JSON.parse(r.push_configs_json),
76
+ };
77
+ }
78
+
79
+ function saveTaskStmt(env: Env, t: Json) {
80
+ return env.DB.prepare(
81
+ `INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, metadata_json, push_configs_json)
82
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
83
+ ON CONFLICT(id) DO UPDATE SET state=?4, updated_at=?7, status_json=?8, artifacts_json=?9, metadata_json=?10, push_configs_json=?11`,
84
+ ).bind(t.id, t.contextId, t._peer, t.status.state, t._protocol, t._createdAt, A.nowIso(), JSON.stringify(t.status),
85
+ JSON.stringify(t.artifacts || []), t.metadata ? JSON.stringify(t.metadata) : null, JSON.stringify(t._push || []));
86
+ }
87
+
88
+ const msgStmt = (env: Env, t: Json, m: Json) =>
89
+ env.DB.prepare("INSERT INTO messages (message_id, task_id, context_id, role, ts, message_json) VALUES (?, ?, ?, ?, ?, ?)")
90
+ .bind(m.messageId, t.id, t.contextId, m.role, A.nowIso(), JSON.stringify(m));
91
+
92
+ function histStmt(env: Env, ctx: string, e: Json) {
93
+ return env.DB.prepare(
94
+ "INSERT INTO history (context_id, ts, dir, peer, task_id, role, state, event, text, data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
95
+ ).bind(ctx, e.ts || A.nowIso(), e.dir || "local", e.peer ?? null, e.taskId ?? null, e.role ?? null, e.state ?? null,
96
+ e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null);
97
+ }
98
+
99
+ // ------------------------------------------------------------------ self-aware fetch (a Worker cannot reliably fetch its own custom domain)
100
+ async function doFetch(env: Env, ectx: ExecutionContext, url: string, init: RequestInit): Promise<Response> {
101
+ if (env.PUBLIC_URL && new URL(url).host === new URL(env.PUBLIC_URL).host) return handle(new Request(url, init), env, ectx);
102
+ return fetch(url, { ...init, signal: AbortSignal.timeout(15000) });
103
+ }
104
+
105
+ // ------------------------------------------------------------------ wake webhook (presets), debounced per context via D1
106
+ const preset = (env: Env) => (env.WAKE_PRESET || "generic").trim();
107
+
108
+ function wakeConfig(env: Env): WakeConfig {
109
+ return {
110
+ preset: preset(env), url: env.WAKE_WEBHOOK_URL, key: env.WAKE_WEBHOOK_KEY, hmacSecret: env.WAKE_HMAC_SECRET,
111
+ agentId: env.WAKE_AGENT_ID, keyHeader: env.WAKE_KEY_HEADER, keyPrefix: env.WAKE_KEY_PREFIX,
112
+ bodyTemplate: env.WAKE_BODY_TEMPLATE, cliCommand: env.WAKE_CLI_COMMAND,
113
+ accessClientId: env.WAKE_ACCESS_CLIENT_ID, accessClientSecret: env.WAKE_ACCESS_CLIENT_SECRET,
114
+ };
115
+ }
116
+
117
+ const debounceMs = (env: Env) => Number(env.WAKE_DEBOUNCE_SECONDS || defaultDebounceSeconds(preset(env))) * 1000;
118
+ const maxPerHour = (env: Env) => Number(env.WAKE_MAX_PER_HOUR || defaultMaxPerHour(preset(env)));
119
+
120
+ async function wakeBudgetOk(env: Env): Promise<boolean> {
121
+ const cap = maxPerHour(env);
122
+ if (!cap) return true;
123
+ const hour = Math.floor(Date.now() / 3600000);
124
+ const r: Json = await env.DB.prepare(
125
+ "INSERT INTO wake_budget (hour, count) VALUES (?, 1) ON CONFLICT(hour) DO UPDATE SET count = count + 1 RETURNING count",
126
+ ).bind(hour).first();
127
+ return (r?.count ?? 0) <= cap;
128
+ }
129
+
130
+ async function sendWake(env: Env, ectx: ExecutionContext, payload: Json): Promise<{ status: number | null; info: string }> {
131
+ const ev: WakeEvent = { ...payload, publicUrl: env.PUBLIC_URL };
132
+ let req;
133
+ try { req = await renderWake(wakeConfig(env), ev); } catch (e) {
134
+ log("wake_render_failed", { preset: preset(env), error: String(e).slice(0, 200) });
135
+ return { status: null, info: "render failed: " + String(e).slice(0, 200) };
136
+ }
137
+ if (!req) { log("wake_skipped", { contextId: ev.contextId, reason: "WAKE_WEBHOOK_URL unset" }); return { status: null, info: "WAKE_WEBHOOK_URL unset" }; }
138
+ try {
139
+ const r = await doFetch(env, ectx, req.url, { method: "POST", headers: req.headers, body: req.body });
140
+ // explain Cloudflare edge errors (tunnel connector down, Access block) without echoing the body
141
+ const hint = r.ok ? "" : cloudflareErrorHint(r.status, (await r.text().catch(() => "")).slice(0, 4096));
142
+ log("wake_sent", { preset: preset(env), contextId: ev.contextId, taskId: ev.taskId, status: r.status, ...(hint ? { hint } : {}) });
143
+ return { status: r.status, info: `HTTP ${r.status}${hint ? ` (${hint})` : ""}` };
144
+ } catch (e) {
145
+ log("wake_failed", { preset: preset(env), contextId: ev.contextId, error: String(e).slice(0, 200) });
146
+ return { status: null, info: String(e).slice(0, 200) };
147
+ }
148
+ }
149
+
150
+ async function wake(env: Env, ectx: ExecutionContext, ctx: string, taskId: string, from: string, preview: string, kind = "inbound") {
151
+ const payload: Json = { contextId: ctx, taskId, taskIds: [taskId], from, preview: preview.slice(0, 300), kind };
152
+ const now = Date.now(), deb = debounceMs(env);
153
+ const row: Json = await env.DB.prepare("SELECT * FROM wakes WHERE context_id = ?").bind(ctx).first();
154
+ let claimed = false;
155
+ if (!row) {
156
+ const r = await env.DB.prepare("INSERT INTO wakes (context_id, last_sent_ms) VALUES (?, ?) ON CONFLICT DO NOTHING").bind(ctx, now).run();
157
+ claimed = r.meta.changes === 1;
158
+ } else if (!row.pending_json && now - row.last_sent_ms >= deb) {
159
+ const r = await env.DB.prepare("UPDATE wakes SET last_sent_ms = ? WHERE context_id = ? AND last_sent_ms = ? AND pending_json IS NULL")
160
+ .bind(now, ctx, row.last_sent_ms).run();
161
+ claimed = r.meta.changes === 1;
162
+ }
163
+ if (claimed && (await wakeBudgetOk(env))) { ectx.waitUntil(sendWake(env, ectx, payload)); return; }
164
+ // debounce window (or hourly cap reached): merge into the pending wake and flush it later
165
+ const cur: Json = await env.DB.prepare("SELECT * FROM wakes WHERE context_id = ?").bind(ctx).first();
166
+ const prev = cur?.pending_json ? JSON.parse(cur.pending_json) : null;
167
+ payload.taskIds = [...new Set([...(prev?.taskIds || []), taskId])];
168
+ await env.DB.prepare("UPDATE wakes SET pending_json = ?, pending_since_ms = COALESCE(pending_since_ms, ?) WHERE context_id = ?")
169
+ .bind(JSON.stringify(payload), now, ctx).run();
170
+ const delay = Math.max(0, (cur?.last_sent_ms || now) + deb - now) + 100;
171
+ // waitUntil can outlive the response by ~30s; longer windows are flushed by later requests or the optional cron
172
+ if (delay <= 25000) ectx.waitUntil(new Promise((res) => setTimeout(res, delay)).then(() => flushWake(env, ectx, ctx)));
173
+ }
174
+
175
+ async function flushWake(env: Env, ectx: ExecutionContext, ctx: string) {
176
+ const row: Json = await env.DB.prepare("SELECT * FROM wakes WHERE context_id = ?").bind(ctx).first();
177
+ if (!row || !row.pending_json) return;
178
+ const now = Date.now();
179
+ if (now - row.last_sent_ms < debounceMs(env)) return;
180
+ if (maxPerHour(env)) {
181
+ const hour = Math.floor(now / 3600000);
182
+ const b: Json = await env.DB.prepare("SELECT count FROM wake_budget WHERE hour = ?").bind(hour).first();
183
+ if ((b?.count ?? 0) >= maxPerHour(env)) return; // keep pending until the next hour
184
+ }
185
+ const r = await env.DB.prepare(
186
+ "UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL, last_sent_ms = ? WHERE context_id = ? AND last_sent_ms = ? AND pending_json IS NOT NULL",
187
+ ).bind(now, ctx, row.last_sent_ms).run();
188
+ if (r.meta.changes === 1 && (await wakeBudgetOk(env))) await sendWake(env, ectx, JSON.parse(row.pending_json));
189
+ }
190
+
191
+ /** Flush every pending wake whose debounce window has passed (called opportunistically and from cron). */
192
+ async function flushDue(env: Env, ectx: ExecutionContext) {
193
+ const rows = await env.DB.prepare("SELECT context_id FROM wakes WHERE pending_json IS NOT NULL AND last_sent_ms <= ? LIMIT 20")
194
+ .bind(Date.now() - debounceMs(env)).all();
195
+ for (const r of rows.results as Json[]) await flushWake(env, ectx, r.context_id);
196
+ }
197
+
198
+ // ------------------------------------------------------------------ auth + rate limit
199
+ async function peerLabel(env: Env, header: string | null): Promise<string | null> {
200
+ const p = await peerOf(env, header);
201
+ return p ? p.label : null;
202
+ }
203
+
204
+ /** Active peer for an Authorization: Bearer header (label + the token's hash), or null. */
205
+ async function peerOf(env: Env, header: string | null): Promise<{ label: string; hash: string } | null> {
206
+ if (!header || !/^bearer /i.test(header)) return null;
207
+ const h = await A.sha256(header.slice(7).trim());
208
+ const r: Json = await env.DB.prepare("SELECT label, token_hash FROM peers WHERE token_hash = ? AND revoked_at IS NULL").bind(h).first();
209
+ return r ? { label: r.label, hash: r.token_hash } : null;
210
+ }
211
+
212
+ async function isOwner(env: Env, header: string | null): Promise<boolean> {
213
+ if (!env.OWNER_TOKEN || !header || !/^bearer /i.test(header)) return false;
214
+ return A.timingSafeEqualStr(await A.sha256(header.slice(7).trim()), await A.sha256(env.OWNER_TOKEN));
215
+ }
216
+
217
+ async function rateOk(env: Env, key: string): Promise<boolean> {
218
+ const minute = Math.floor(Date.now() / 60000);
219
+ const r: Json = await env.DB.prepare(
220
+ "INSERT INTO rate (peer, minute, count) VALUES (?, ?, 1) ON CONFLICT(peer, minute) DO UPDATE SET count = count + 1 RETURNING count",
221
+ ).bind(key, minute).first();
222
+ return (r?.count ?? 0) <= Number(env.RATE_PER_MIN || "60");
223
+ }
224
+
225
+ async function readBody(req: Request, env: Env): Promise<string> {
226
+ const max = Number(env.MAX_BODY || "1048576");
227
+ const cl = Number(req.headers.get("content-length") || "0");
228
+ if (cl > max) throw new HttpError(413, "Body too large");
229
+ const buf = await req.arrayBuffer();
230
+ if (buf.byteLength > max) throw new HttpError(413, "Body too large");
231
+ return new TextDecoder().decode(buf);
232
+ }
233
+
234
+ // ------------------------------------------------------------------ agent card (A2A 1.0 shape)
235
+ function agentSkills(env: Env): Json[] {
236
+ if (env.AGENT_SKILLS) {
237
+ try { const v = JSON.parse(env.AGENT_SKILLS); if (Array.isArray(v) && v.length) return v; } catch { /* fall through */ }
238
+ }
239
+ return [{
240
+ id: "general", name: "General requests",
241
+ description: "Accepts requests from other agents and replies asynchronously (poll GetTask or register a push notification config).",
242
+ tags: ["assistant", "async"],
243
+ }];
244
+ }
245
+
246
+ const pairingMode = (env: Env) => P.pairingMode(env.PAIRING_APPROVAL);
247
+ const pairingOn = (env: Env) => pairingMode(env) !== "off";
248
+ const oauthUrls = (env: Env) => {
249
+ const base = env.PUBLIC_URL.replace(/\/$/, "");
250
+ return { issuer: base, device: `${base}/oauth/device_authorization`, token: `${base}/oauth/token`, page: `${base}/device`,
251
+ setup: `${base}/device/setup`, metadata: `${base}/.well-known/oauth-authorization-server`,
252
+ resource: `${base}/.well-known/oauth-protected-resource` };
253
+ };
254
+ const cliCommand = (env: Env) => env.WAKE_CLI_COMMAND || "npx a2a-exposed";
255
+
256
+ const securityRequirements = (env: Env): Json[] =>
257
+ [{ schemes: { bearer: { list: [] } } }, ...(pairingOn(env) ? [{ schemes: { pairing: { list: [] } } }] : [])];
258
+
259
+ /** Bearer (every token, however issued) plus, unless pairing is off, the A2A 1.0 OAuth2 device-code flow that issues one. */
260
+ function securitySchemes(env: Env): Json {
261
+ const u = oauthUrls(env);
262
+ const schemes: Json = {
263
+ bearer: { httpAuthSecurityScheme: { scheme: "Bearer", description: pairingOn(env)
264
+ ? "Per-peer bearer token: from the device-flow pairing below (the access_token), or issued by the agent's operator"
265
+ : "Per-peer bearer token issued by the agent's operator" } },
266
+ };
267
+ if (pairingOn(env)) schemes.pairing = { oauth2SecurityScheme: {
268
+ description: "Get a bearer token without exchanging secrets in chat: OAuth 2.0 Device Authorization Grant (RFC 8628). " +
269
+ "POST client_name (your agent's name) and agent_card_url (your card) form-encoded to deviceAuthorizationUrl; show the user_code and " +
270
+ "verification_uri_complete to your human, who confirms the code with this agent's owner; poll tokenUrl with grant_type=" + P.DEVICE_GRANT +
271
+ " every `interval` seconds. The owner approves every request" + (pairingMode(env) === "human" ? " on the verification page." : ".") +
272
+ " The access_token is a per-peer token for Authorization: Bearer (no refresh token; the owner can revoke it).",
273
+ flows: { deviceCode: { deviceAuthorizationUrl: u.device, tokenUrl: u.token, scopes: { a2a: "Send messages to this agent and read the tasks you created" } } },
274
+ oauth2MetadataUrl: u.metadata,
275
+ } };
276
+ return schemes;
277
+ }
278
+
279
+ /** Inbox-mode card from the AGENT_* settings. URL fields and free text that name a private network (an operator who
280
+ * pasted a Tailnet or localhost link) are scrubbed by the same rules as the façade card (see facade.ts). */
281
+ function inboxCard(env: Env): Json {
282
+ const base = env.PUBLIC_URL.replace(/\/$/, "");
283
+ const o: F.RewriteOptions = { publicBase: base };
284
+ const card: Json = {
285
+ name: env.AGENT_NAME || "A2A Agent",
286
+ description: env.AGENT_DESCRIPTION ||
287
+ "An AI agent reachable over A2A. Messages are queued and handled asynchronously; poll GetTask or register a push notification config.",
288
+ version: env.AGENT_VERSION || "1.0.0",
289
+ supportedInterfaces: [
290
+ { url: base + "/", protocolBinding: "JSONRPC", protocolVersion: "1.0" },
291
+ { url: base + "/", protocolBinding: "JSONRPC", protocolVersion: "0.3" },
292
+ ],
293
+ securitySchemes: securitySchemes(env),
294
+ securityRequirements: securityRequirements(env),
295
+ capabilities: { streaming: false, pushNotifications: true, extendedAgentCard: false },
296
+ defaultInputModes: ["text/plain", "application/json"],
297
+ defaultOutputModes: ["text/plain", "application/json"],
298
+ skills: F.scrubDeep(agentSkills(env), o),
299
+ };
300
+ card.name = F.scrubText(card.name, o);
301
+ card.description = F.scrubText(card.description, o);
302
+ const provUrl = F.publicUrlOrNothing(env.PROVIDER_URL, o);
303
+ if (env.PROVIDER_ORGANIZATION && provUrl) card.provider = { organization: F.scrubText(env.PROVIDER_ORGANIZATION, o), url: provUrl };
304
+ const doc = F.publicUrlOrNothing(env.DOCUMENTATION_URL, o);
305
+ if (doc) card.documentationUrl = doc;
306
+ return F.scrubDeep(card, o); // catch-all over every field and key (the public base itself passes unchanged)
307
+ }
308
+
309
+ // ------------------------------------------------------------------ proxy / expose mode (façade for a private A2A agent)
310
+ const proxyMode = (env: Env) => !!(env.UPSTREAM_URL || "").trim();
311
+ const DEFAULT_DESCRIPTION = "An AI agent reachable over A2A through a public façade.";
312
+
313
+ /** Headers for a request to the upstream: the façade's own credential and Access service token, never a peer's.
314
+ * The credentials are attached only when `target` is on the UPSTREAM_URL origin (defence in depth: callers already
315
+ * refuse any other origin), so they can never be sent to a third-party host. */
316
+ function upstreamHeaders(env: Env, target: string, extra: Record<string, string> = {}): Record<string, string> {
317
+ const h: Record<string, string> = { "user-agent": "a2a-exposed-facade", ...extra };
318
+ const [ep] = F.upstreamEndpoint(env.UPSTREAM_URL);
319
+ if (!ep || !F.sameOrigin(target, ep)) return h;
320
+ if (env.UPSTREAM_TOKEN) h.authorization = `Bearer ${env.UPSTREAM_TOKEN}`;
321
+ if (env.UPSTREAM_ACCESS_CLIENT_ID && env.UPSTREAM_ACCESS_CLIENT_SECRET) {
322
+ h["cf-access-client-id"] = env.UPSTREAM_ACCESS_CLIENT_ID;
323
+ h["cf-access-client-secret"] = env.UPSTREAM_ACCESS_CLIENT_SECRET;
324
+ }
325
+ return h;
326
+ }
327
+
328
+ // upstream card cache (per isolate): 5 minutes, a stale copy is served while the upstream is unreachable
329
+ const CARD_TTL_MS = 300000;
330
+ const cardCache = new Map<string, { at: number; card: Json }>();
331
+ // failed fetches are not retried for 30 s, so anonymous card requests can't hammer a struggling upstream
332
+ const CARD_RETRY_MS = 30000;
333
+ const cardFailures = new Map<string, { at: number; status: number | null; error: string }>();
334
+
335
+ async function fetchUpstreamCard(env: Env, fresh = false): Promise<{ card: Json | null; status: number | null; error?: string; url: string }> {
336
+ const [ep] = F.upstreamEndpoint(env.UPSTREAM_URL);
337
+ if (!ep) return { card: null, status: null, error: "UPSTREAM_URL invalid", url: "" };
338
+ const [url, bad] = F.upstreamCardUrl(ep, env.UPSTREAM_CARD_URL);
339
+ if (!url) { log("upstream_card_refused", { error: bad }); return { card: null, status: null, error: bad, url: "" }; }
340
+ const hit = cardCache.get(url);
341
+ if (!fresh && hit && Date.now() - hit.at < CARD_TTL_MS) return { card: hit.card, status: 200, url };
342
+ const failed = cardFailures.get(url);
343
+ if (!fresh && failed && Date.now() - failed.at < CARD_RETRY_MS) return { card: hit?.card ?? null, status: failed.status, error: failed.error, url };
344
+ const fail = (status: number | null, error: string) => { cardFailures.set(url, { at: Date.now(), status, error }); return { card: hit?.card ?? null, status, error, url }; };
345
+ try {
346
+ const r = await fetch(url, { headers: upstreamHeaders(env, url, { accept: "application/json" }), redirect: "manual", signal: AbortSignal.timeout(10000) });
347
+ const text = (await r.text()).slice(0, 262144);
348
+ let card: Json = null;
349
+ try { card = JSON.parse(text); } catch { /* not JSON (an Access login page, a tunnel error) */ }
350
+ if (r.status === 200 && card && typeof card === "object" && !Array.isArray(card)) {
351
+ cardCache.set(url, { at: Date.now(), card });
352
+ cardFailures.delete(url);
353
+ return { card, status: 200, url };
354
+ }
355
+ const hint = cloudflareErrorHint(r.status, text.slice(0, 4096));
356
+ log("upstream_card_failed", { status: r.status, ...(hint ? { hint } : {}) });
357
+ return fail(r.status, `HTTP ${r.status}${hint ? ` (${hint})` : ""}`);
358
+ } catch (e) {
359
+ log("upstream_card_failed", { error: String(e).slice(0, 200) });
360
+ return fail(null, String(e).slice(0, 200));
361
+ }
362
+ }
363
+
364
+ /** Origins that must not appear in the public card: the upstream endpoint, its card URL, and whatever the upstream's
365
+ * own card names (typically a Tailnet or localhost URL). Never the façade's own origin. */
366
+ function upstreamOrigins(env: Env, upCard: Json): string[] {
367
+ const out = new Set<string>(F.upstreamCardOrigins(upCard));
368
+ const [ep] = F.upstreamEndpoint(env.UPSTREAM_URL);
369
+ if (ep) out.add(new URL(ep).origin); // the card URL is on the same origin (or refused)
370
+ const c = (env.UPSTREAM_CARD_URL || "").trim();
371
+ if (c) try { out.add(new URL(c).origin); } catch { /* not a URL: nothing to scrub */ }
372
+ const own = new URL(env.PUBLIC_URL).origin.toLowerCase();
373
+ return [...out].filter((x) => x.toLowerCase() !== own);
374
+ }
375
+
376
+ async function facadeAgentCard(env: Env): Promise<Json> {
377
+ const up = await fetchUpstreamCard(env);
378
+ let skills: Json[] | undefined;
379
+ if (env.AGENT_SKILLS) try { const v = JSON.parse(env.AGENT_SKILLS); if (Array.isArray(v) && v.length) skills = v; } catch { /* ignore */ }
380
+ return F.facadeCard({
381
+ upstream: up.card,
382
+ config: { name: env.AGENT_NAME, description: env.AGENT_DESCRIPTION, version: env.AGENT_VERSION, skills,
383
+ providerOrganization: env.PROVIDER_ORGANIZATION, providerUrl: env.PROVIDER_URL, documentationUrl: env.DOCUMENTATION_URL },
384
+ publicBase: env.PUBLIC_URL.replace(/\/$/, ""),
385
+ upstreamOrigins: upstreamOrigins(env, up.card),
386
+ securitySchemes: securitySchemes(env),
387
+ securityRequirements: securityRequirements(env),
388
+ defaults: { description: DEFAULT_DESCRIPTION, skills: agentSkills(env) },
389
+ });
390
+ }
391
+
392
+ /** The A2A 1.0 card this Worker serves: the inbox card, or the rewritten upstream card in proxy mode. */
393
+ const agentCard = (env: Env): Promise<Json> => (proxyMode(env) ? facadeAgentCard(env) : Promise.resolve(inboxCard(env)));
394
+
395
+ /** The same agent as an A2A 0.3 AgentCard (served at the legacy /.well-known/agent.json for 0.2/0.3 clients): top-level
396
+ * url + preferredTransport + protocolVersion, OpenAPI-style securitySchemes and `security`. 0.3 has no device-code
397
+ * flow, so the pairing scheme only points to the RFC 8414 metadata (oauth2MetadataUrl) and is not a requirement. */
398
+ function agentCard03(env: Env, c: Json): Json {
399
+ const base = env.PUBLIC_URL.replace(/\/$/, "");
400
+ const schemes: Json = { bearer: { type: "http", scheme: "bearer", description: c.securitySchemes.bearer.httpAuthSecurityScheme.description } };
401
+ if (pairingOn(env)) schemes.pairing = { type: "oauth2", description: c.securitySchemes.pairing.oauth2SecurityScheme.description,
402
+ flows: {}, oauth2MetadataUrl: oauthUrls(env).metadata };
403
+ const card: Json = {
404
+ protocolVersion: "0.3.0", name: c.name, description: c.description, url: base + "/", preferredTransport: "JSONRPC",
405
+ additionalInterfaces: [{ url: base + "/", transport: "JSONRPC" }], version: c.version,
406
+ capabilities: { streaming: false, pushNotifications: !!c.capabilities.pushNotifications, stateTransitionHistory: false,
407
+ ...(c.capabilities.extensions ? { extensions: c.capabilities.extensions } : {}) },
408
+ securitySchemes: schemes, security: [{ bearer: [] }],
409
+ defaultInputModes: c.defaultInputModes, defaultOutputModes: c.defaultOutputModes,
410
+ skills: c.skills.map((k: Json) => ({ ...k, tags: Array.isArray(k.tags) ? k.tags : [] })), supportsAuthenticatedExtendedCard: false,
411
+ };
412
+ if (c.provider) card.provider = c.provider;
413
+ if (c.documentationUrl) card.documentationUrl = c.documentationUrl;
414
+ if (c.iconUrl) card.iconUrl = c.iconUrl;
415
+ return card;
416
+ }
417
+
418
+ /** RFC 9728 protected-resource metadata: which authorization server issues tokens for this A2A endpoint. */
419
+ function protectedResourceMetadata(env: Env): Json {
420
+ const u = oauthUrls(env);
421
+ return { resource: u.issuer + "/", authorization_servers: [u.issuer], bearer_methods_supported: ["header"], scopes_supported: ["a2a"],
422
+ resource_name: env.AGENT_NAME || "A2A Agent", resource_documentation: "https://github.com/telegraphic-dev/a2a-exposed#connecting-agents-device-flow" };
423
+ }
424
+
425
+ // ------------------------------------------------------------------ JSON-RPC
426
+ const V1: Record<string, string> = {
427
+ SendMessage: "send", GetTask: "get", CancelTask: "cancel", CreateTaskPushNotificationConfig: "pushset",
428
+ SetTaskPushNotificationConfig: "pushset", GetTaskPushNotificationConfig: "pushget", ListTasks: "list",
429
+ SendStreamingMessage: "stream", SubscribeToTask: "stream",
430
+ };
431
+ const V03: Record<string, string> = {
432
+ "message/send": "send", "tasks/get": "get", "tasks/cancel": "cancel", "tasks/pushNotificationConfig/set": "pushset",
433
+ "tasks/pushNotificationConfig/get": "pushget", "message/stream": "stream", "tasks/resubscribe": "stream",
434
+ };
435
+
436
+ async function ownedTask(env: Env, id: unknown, label: string): Promise<Json> {
437
+ if (typeof id !== "string" || !A.ID_RE.test(id)) throw new RpcError(-32001, "Task not found");
438
+ const t = await loadTask(env, id);
439
+ if (!t || t._peer !== label) throw new RpcError(-32001, "Task not found");
440
+ return t;
441
+ }
442
+
443
+ function normPushCfg(cfg: Json, version: string): Json {
444
+ if (!cfg || typeof cfg !== "object" || typeof cfg.url !== "string") throw new RpcError(-32602, "push notification config needs url");
445
+ const out: Json = { id: typeof cfg.id === "string" && cfg.id ? cfg.id.slice(0, 128) : A.newId(), url: cfg.url, _version: version };
446
+ if (cfg.token) out.token = String(cfg.token).slice(0, 512);
447
+ if (cfg.authentication && typeof cfg.authentication === "object") out.authentication = cfg.authentication;
448
+ const [ok, why] = A.pushUrlAllowed(out.url);
449
+ if (!ok) throw new RpcError(-32602, `push url rejected: ${why}`);
450
+ return out;
451
+ }
452
+
453
+ function pubCfg(taskId: string, cfg: Json, version: string): Json {
454
+ const c: Json = {};
455
+ for (const [k, v] of Object.entries(cfg)) if (!k.startsWith("_")) c[k] = v;
456
+ return version.startsWith("1") ? { taskId, ...c } : { taskId, pushNotificationConfig: c };
457
+ }
458
+
459
+ type Rpc = (p: Json, label: string, version: string, env: Env, ectx: ExecutionContext) => Promise<Json>;
460
+
461
+ const rpcSend: Rpc = async (params, label, version, env, ectx) => {
462
+ const msg = A.normMessage(params.message);
463
+ if (msg.role !== "user") throw new RpcError(-32602, "message.role must be user");
464
+ const conf = params.configuration || {};
465
+ const push = conf.taskPushNotificationConfig || conf.pushNotificationConfig;
466
+ const pushCfg = push ? normPushCfg(push, version) : null;
467
+ let task: Json = null;
468
+ if (msg.taskId) {
469
+ task = await ownedTask(env, msg.taskId, label);
470
+ if (A.TERMINAL.has(task.status.state)) throw new RpcError(-32004, "Task is in a terminal state");
471
+ if (msg.contextId && msg.contextId !== task.contextId) throw new RpcError(-32602, "contextId does not match task");
472
+ }
473
+ const ctx = task ? task.contextId : msg.contextId ? A.checkId(msg.contextId, "contextId") : A.newId();
474
+ const ts = A.nowIso();
475
+ if (!task) task = { id: A.newId(), contextId: ctx, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
476
+ msg.contextId = ctx; msg.taskId = task.id;
477
+ task.history.push(msg);
478
+ task.status = { state: "submitted", timestamp: ts };
479
+ if (pushCfg) task._push = [...task._push.filter((c: Json) => c.id !== pushCfg.id), pushCfg];
480
+ if (params.metadata && typeof params.metadata === "object") task.metadata = { ...(task.metadata || {}), ...params.metadata };
481
+ const preview = A.textOf(msg);
482
+ await env.DB.batch([
483
+ saveTaskStmt(env, task), msgStmt(env, task, msg),
484
+ histStmt(env, ctx, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }),
485
+ ]);
486
+ log("message_received", { peer: label, contextId: ctx, taskId: task.id, chars: preview.length, version });
487
+ await wake(env, ectx, ctx, task.id, label, preview);
488
+ const pt = A.publicTask(task, version, conf.historyLength);
489
+ return version.startsWith("1") ? { task: pt } : pt;
490
+ };
491
+
492
+ const rpcGet: Rpc = async (p, label, version, env) => A.publicTask(await ownedTask(env, p.id, label), version, p.historyLength);
493
+
494
+ const rpcCancel: Rpc = async (p, label, version, env) => {
495
+ const t = await ownedTask(env, p.id, label);
496
+ if (A.TERMINAL.has(t.status.state)) throw new RpcError(-32002, "Task cannot be canceled");
497
+ t.status = { state: "canceled", timestamp: A.nowIso() };
498
+ await env.DB.batch([saveTaskStmt(env, t), histStmt(env, t.contextId, { dir: "in", peer: label, taskId: t.id, event: "canceled_by_peer", state: "canceled" })]);
499
+ return A.publicTask(t, version);
500
+ };
501
+
502
+ const rpcPushSet: Rpc = async (p, label, version, env) => {
503
+ let taskId: string, cfg: Json;
504
+ if (p.pushNotificationConfig && typeof p.pushNotificationConfig === "object") { taskId = p.taskId; cfg = p.pushNotificationConfig; }
505
+ else if (p.config && typeof p.config === "object") { taskId = p.taskId || String(p.parent || "").split("/").pop(); cfg = p.config; }
506
+ else { taskId = p.taskId; cfg = { id: p.id, url: p.url, token: p.token, authentication: p.authentication }; }
507
+ const t = await ownedTask(env, taskId, label);
508
+ const c = normPushCfg(cfg, version);
509
+ t._push = [...t._push.filter((x: Json) => x.id !== c.id), c];
510
+ await saveTaskStmt(env, t).run();
511
+ return pubCfg(t.id, c, version);
512
+ };
513
+
514
+ const rpcPushGet: Rpc = async (p, label, version, env) => {
515
+ const t = await ownedTask(env, p.taskId || p.id, label);
516
+ const want = p.pushNotificationConfigId || (p.taskId ? p.id : undefined);
517
+ for (const c of t._push) if (!want || c.id === want) return pubCfg(t.id, c, version);
518
+ throw new RpcError(-32001, "Push notification config not found");
519
+ };
520
+
521
+ const rpcList: Rpc = async (p, label, version, env) => {
522
+ const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
523
+ const where = p.contextId ? "peer = ? AND context_id = ?" : "peer = ?";
524
+ const args = p.contextId ? [label, p.contextId] : [label];
525
+ const total: Json = await env.DB.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ${where}`).bind(...args).first();
526
+ const rows = await env.DB.prepare(`SELECT id FROM tasks WHERE ${where} ORDER BY updated_at DESC LIMIT ?`).bind(...args, size).all();
527
+ const tasks = [];
528
+ for (const r of rows.results as Json[]) {
529
+ const t = A.publicTask(await loadTask(env, r.id), version, p.historyLength ?? 0);
530
+ if (!p.includeArtifacts) delete t.artifacts;
531
+ tasks.push(t);
532
+ }
533
+ return { tasks, nextPageToken: "", pageSize: size, totalSize: total?.n ?? tasks.length };
534
+ };
535
+
536
+ const HANDLERS: Record<string, Rpc> = { send: rpcSend, get: rpcGet, cancel: rpcCancel, pushset: rpcPushSet, pushget: rpcPushGet, list: rpcList };
537
+
538
+ /** 401 for A2A calls. Unless pairing is off, the error says how to get a token (device flow), so a generic agent can follow it.
539
+ * The JSON-RPC id is echoed when the request body parses (JSON-RPC 2.0); WWW-Authenticate points to RFC 9728 metadata. */
540
+ function unauthorized(env: Env, hadToken: boolean, rid: Json = null): Response {
541
+ const u = oauthUrls(env);
542
+ const www = `Bearer realm="a2a"${pairingOn(env) ? `, resource_metadata="${u.resource}"` : ""}${hadToken ? ', error="invalid_token", error_description="the bearer token is not valid (revoked, rotated or mistyped)"' : ""}`;
543
+ const what = hadToken ? "Unauthorized: the bearer token is not valid (revoked, rotated or mistyped)." : "Unauthorized: send Authorization: Bearer <token>.";
544
+ if (!pairingOn(env))
545
+ return json({ jsonrpc: "2.0", id: rid, error: { code: -32000, message: `${what} Ask this agent's operator for a token.` } }, 401, { "www-authenticate": www });
546
+ return json({ jsonrpc: "2.0", id: rid, error: {
547
+ code: -32000,
548
+ message: `${what} ${hadToken ? "Get a new one" : "No token? Get one"} with the OAuth 2.0 device flow (RFC 8628): POST client_name and agent_card_url to ${u.device}, show the user_code and verification_uri_complete to your human, then poll ${u.token} until this agent's owner approves.`,
549
+ data: { pairing: { grant_type: P.DEVICE_GRANT, device_authorization_endpoint: u.device, token_endpoint: u.token,
550
+ authorization_server_metadata: u.metadata, approval: pairingMode(env), cli: "npx a2a-exposed connect " + u.issuer } },
551
+ } }, 401, { "www-authenticate": www });
552
+ }
553
+
554
+ async function handleRpc(req: Request, env: Env, ectx: ExecutionContext): Promise<Response> {
555
+ const ip = req.headers.get("cf-connecting-ip") || "";
556
+ const label = await peerLabel(env, req.headers.get("authorization"));
557
+ if (!label) {
558
+ log("auth_failed", { ip });
559
+ // echo the JSON-RPC id when the (small) body parses; never more than 64 KiB is read for an unauthenticated call
560
+ let rid: Json = null;
561
+ try { const b = JSON.parse(await readBody(req, { ...env, MAX_BODY: "65536" })); if (b && typeof b === "object" && !Array.isArray(b) && ["string", "number"].includes(typeof b.id)) rid = b.id; } catch { /* id stays null */ }
562
+ return unauthorized(env, !!req.headers.get("authorization"), rid);
563
+ }
564
+ if (!(await rateOk(env, label))) {
565
+ log("rate_limited", { peer: label, ip });
566
+ return json({ jsonrpc: "2.0", id: null, error: { code: -32603, message: "Rate limit exceeded" } }, 429, { "retry-after": "60" });
567
+ }
568
+ let raw: string;
569
+ try { raw = await readBody(req, env); } catch (e) {
570
+ return json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Body too large" } }, 413);
571
+ }
572
+ let rq: Json;
573
+ try { rq = JSON.parse(raw); } catch { return json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }, 400); }
574
+ if (Array.isArray(rq)) return json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Batch not supported" } }, 400);
575
+ const rid = rq && typeof rq === "object" ? rq.id ?? null : null;
576
+ const err = (code: number, message: string) => json({ jsonrpc: "2.0", id: rid, error: { code, message } });
577
+ if (!rq || typeof rq !== "object" || rq.jsonrpc !== "2.0" || typeof rq.method !== "string") return err(-32600, "Invalid Request");
578
+ const method: string = rq.method;
579
+ let op: string, version: string;
580
+ const proxy = proxyMode(env);
581
+ if (V1[method]) { op = V1[method]; version = "1.0"; }
582
+ else if (V03[method]) { op = V03[method]; version = "0.3"; }
583
+ else if (proxy && PROXY_V1[method]) { op = PROXY_V1[method]; version = "1.0"; }
584
+ else if (proxy && PROXY_V03[method]) { op = PROXY_V03[method]; version = "0.3"; }
585
+ else return err(-32601, "Method not found");
586
+ const hv = (req.headers.get("a2a-version") || "").trim();
587
+ if (hv && !/^(0\.3|1|1\.0)(\.\d+)?$/.test(hv)) return err(-32009, `A2A version ${hv} not supported (supported: 0.3, 1.0)`);
588
+ if (op === "stream") return err(-32004, proxy ? "Streaming is not supported through this façade yet" : "Streaming is not supported");
589
+ const params = rq.params ?? {};
590
+ if (typeof params !== "object" || Array.isArray(params)) return err(-32602, "Invalid params");
591
+ if (proxy) return await proxyRpc(req, env, { raw, rid, method, op, version, params, label, ip });
592
+ try {
593
+ const result = await HANDLERS[op](params, label, version, env, ectx);
594
+ log("rpc_ok", { peer: label, method, ip });
595
+ return json({ jsonrpc: "2.0", id: rid, result });
596
+ } catch (e: any) {
597
+ if (e instanceof RpcError) { log("rpc_error", { peer: label, method, code: e.code, msg: e.message }); return err(e.code, e.message); }
598
+ if (e instanceof A.InvalidParams) { log("rpc_invalid", { peer: label, method, msg: e.message }); return err(-32602, `Invalid params: ${e.message}`); }
599
+ log("rpc_exception", { peer: label, method, error: String(e?.stack || e).slice(0, 500) });
600
+ return err(-32603, "Internal error");
601
+ }
602
+ }
603
+
604
+ // ------------------------------------------------------------------ proxy mode: forward JSON-RPC to the private upstream
605
+ // Methods only the façade forwards (the inbox has one push config per task and no delete).
606
+ const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
607
+ const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
608
+
609
+ async function ownerOf(env: Env, kind: "task" | "context", id: string): Promise<string | null> {
610
+ const r: Json = await env.DB.prepare("SELECT peer FROM facade_owners WHERE kind = ? AND id = ?").bind(kind, id).first();
611
+ return r ? r.peer : null;
612
+ }
613
+
614
+ /** Forward one authenticated, validated JSON-RPC call to UPSTREAM_URL.
615
+ * - The upstream sees the façade's credential (UPSTREAM_TOKEN, Access service token), never the peer's token; the
616
+ * peer label goes in X-A2A-Peer so the upstream can tell callers apart.
617
+ * - Isolation between peers that share that one upstream identity: the façade records which peer created each task
618
+ * and context (facade_owners) and refuses calls on another peer's task or context before they reach the upstream.
619
+ * ListTasks would list every peer's tasks and is refused.
620
+ * - Push notification configs are refused (-32003): the upstream sits on a private network and would resolve and call
621
+ * the peer's URL from there (SSRF), whatever the URL looks like from the façade.
622
+ * - An upstream 401/403 is reported as a façade problem (502), so a peer never mistakes it for its own bad token. */
623
+ async function proxyRpc(req: Request, env: Env, c: { raw: string; rid: Json; method: string; op: string; version: string; params: Json; label: string; ip: string }): Promise<Response> {
624
+ const err = (code: number, message: string, status = 200) => json({ jsonrpc: "2.0", id: c.rid, error: { code, message } }, status);
625
+ const [ep, why] = F.upstreamEndpoint(env.UPSTREAM_URL);
626
+ if (!ep) { log("upstream_misconfigured", { why }); return err(-32603, "This agent's façade is misconfigured (upstream endpoint); tell its operator", 503); }
627
+ if (c.op === "list") return err(-32004, "ListTasks is not supported through this façade (it would list other callers' tasks); use GetTask with the ids you received");
628
+ if (c.op === "send") {
629
+ const m = c.params.message;
630
+ if (!m || typeof m !== "object") return err(-32602, "Invalid params: message must be an object");
631
+ if (m.taskId !== undefined) {
632
+ if (typeof m.taskId !== "string" || (await ownerOf(env, "task", m.taskId)) !== c.label) return err(-32001, "Task not found");
633
+ }
634
+ // referenced tasks give the upstream another task's content as context: same rule as taskId
635
+ if (m.referenceTaskIds !== undefined) {
636
+ if (!Array.isArray(m.referenceTaskIds) || m.referenceTaskIds.length > 64) return err(-32602, "Invalid params: referenceTaskIds must be an array of task ids");
637
+ for (const rt of m.referenceTaskIds)
638
+ if (typeof rt !== "string" || (await ownerOf(env, "task", rt)) !== c.label) return err(-32001, "Task not found");
639
+ }
640
+ if (m.contextId !== undefined) {
641
+ if (typeof m.contextId !== "string" || !A.ID_RE.test(m.contextId)) return err(-32602, "Invalid params: invalid contextId");
642
+ // fail closed: only a context this peer got back from the upstream (recorded in facade_owners). An unrecorded id
643
+ // may still be a live upstream context (another peer's, or one opened outside the façade), and the façade can't
644
+ // tell, so client-chosen context ids are refused; omit contextId to start a conversation and reuse the returned one.
645
+ if ((await ownerOf(env, "context", m.contextId)) !== c.label)
646
+ return err(-32602, "Invalid params: unknown contextId (omit contextId to start a new conversation, then use the one returned)");
647
+ }
648
+ } else {
649
+ const tid = F.taskIdOf(c.op, c.params);
650
+ if (typeof tid !== "string" || (await ownerOf(env, "task", tid)) !== c.label) return err(-32001, "Task not found");
651
+ }
652
+ // fail closed on push (after the ownership checks, so another peer's task is still just "not found"): the upstream would call a peer's URL from inside the private network (DNS there can map a
653
+ // public-looking name to a private address), so push configs never reach it. Follow-up: a relay through the Worker.
654
+ if (F.wantsPush(c.op, c.params)) return err(-32003, "Push notifications are not supported through this façade; poll GetTask with the task id instead");
655
+ const headers = upstreamHeaders(env, ep, { "content-type": "application/json", accept: "application/json", "x-a2a-peer": c.label });
656
+ const hv = req.headers.get("a2a-version");
657
+ if (hv) headers["a2a-version"] = hv;
658
+ let r: Response;
659
+ try {
660
+ r = await fetch(ep, { method: "POST", headers, body: c.raw, redirect: "manual", signal: AbortSignal.timeout(90000) });
661
+ } catch (e) {
662
+ log("upstream_failed", { peer: c.label, method: c.method, error: String(e).slice(0, 200) });
663
+ return err(-32603, "This agent is not reachable right now (upstream unavailable); try again later", 502);
664
+ }
665
+ const text = await r.text();
666
+ if (text.length > 8 * 1048576) { log("upstream_too_large", { peer: c.label, method: c.method, bytes: text.length }); return err(-32603, "Upstream response too large", 502); }
667
+ let body: Json = null;
668
+ try { body = JSON.parse(text); } catch { /* not JSON */ }
669
+ const isRpc = body && typeof body === "object" && !Array.isArray(body) && body.jsonrpc === "2.0";
670
+ if (r.status === 401 || r.status === 403 || !isRpc) {
671
+ const hint = cloudflareErrorHint(r.status, text.slice(0, 4096));
672
+ log("upstream_error", { peer: c.label, method: c.method, status: r.status, ...(hint ? { hint } : {}) });
673
+ return err(-32603, r.status === 401 || r.status === 403
674
+ ? "This agent's façade was refused by its upstream (credential or Access policy); tell its operator"
675
+ : `This agent's upstream answered HTTP ${r.status} without a JSON-RPC response; try again later`, 502);
676
+ }
677
+ if (c.op === "send" && body.result) {
678
+ const { taskId, contextId } = F.idsFromResult(body.result);
679
+ const now = A.nowIso();
680
+ const stmts = [];
681
+ if (taskId) stmts.push(env.DB.prepare("INSERT INTO facade_owners (kind, id, peer, created_at) VALUES ('task', ?, ?, ?) ON CONFLICT DO NOTHING").bind(taskId, c.label, now));
682
+ if (contextId) stmts.push(env.DB.prepare("INSERT INTO facade_owners (kind, id, peer, created_at) VALUES ('context', ?, ?, ?) ON CONFLICT DO NOTHING").bind(contextId, c.label, now));
683
+ if (stmts.length) await env.DB.batch(stmts);
684
+ // a task id the upstream reused for another peer's task stays with its first owner (refused on the next call)
685
+ if (taskId && (await ownerOf(env, "task", taskId)) !== c.label) log("facade_owner_conflict", { peer: c.label, kind: "task" });
686
+ }
687
+ log("proxy_ok", { peer: c.label, method: c.method, status: r.status, ip: c.ip, ...(body.error ? { code: body.error.code } : {}) });
688
+ return new Response(text, { status: r.status >= 200 && r.status < 300 ? r.status : 200, headers: { "content-type": "application/json", "cache-control": "no-store" } });
689
+ }
690
+
691
+ // ------------------------------------------------------------------ push delivery (to peers) and receipt (from peers)
692
+ async function sendPush(env: Env, ectx: ExecutionContext, task: Json, cfg: Json): Promise<[boolean, string]> {
693
+ const [ok, why] = A.pushUrlAllowed(cfg.url);
694
+ if (!ok) return [false, why];
695
+ const v1 = String(cfg._version || "0.3").startsWith("1");
696
+ const headers: Record<string, string> = { "content-type": v1 ? "application/a2a+json" : "application/json", "user-agent": "a2a-exposed" };
697
+ if (v1) headers["a2a-version"] = "1.0";
698
+ if (cfg.token) headers["x-a2a-notification-token"] = cfg.token;
699
+ const auth = cfg.authentication || {};
700
+ const scheme = auth.scheme || (Array.isArray(auth.schemes) ? auth.schemes[0] : undefined);
701
+ if (scheme && auth.credentials) headers["authorization"] = `${scheme} ${auth.credentials}`;
702
+ const body = v1 ? { task: A.publicTask(task, "1.0") } : A.publicTask(task, "0.3");
703
+ try {
704
+ const r = await doFetch(env, ectx, cfg.url, { method: "POST", headers, body: JSON.stringify(body) });
705
+ return [r.status >= 200 && r.status < 300, `HTTP ${r.status}`];
706
+ } catch (e) {
707
+ return [false, String(e).slice(0, 200)];
708
+ }
709
+ }
710
+
711
+ async function notifyPeer(env: Env, ectx: ExecutionContext, t: Json): Promise<Json[]> {
712
+ const results = [];
713
+ for (const cfg of t._push || []) {
714
+ const [ok, info] = await sendPush(env, ectx, t, cfg);
715
+ results.push({ url: cfg.url, ok, info });
716
+ await histStmt(env, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, event: "push", state: t.status.state, text: `${ok ? "ok" : "FAILED"} ${info}` }).run();
717
+ }
718
+ return results;
719
+ }
720
+
721
+ async function handlePush(req: Request, env: Env, ectx: ExecutionContext): Promise<Response> {
722
+ let body: Json;
723
+ try { body = JSON.parse(await readBody(req, env)); } catch (e) {
724
+ return json({ error: e instanceof HttpError ? e.message : "parse error" }, e instanceof HttpError ? e.status : 400);
725
+ }
726
+ if (!body || typeof body !== "object") return json({ error: "bad body" }, 400);
727
+ const inner = body.task || body.statusUpdate || body.artifactUpdate || body.message || body;
728
+ const taskId = "status" in inner && "id" in inner && inner.kind !== "status-update" ? inner.id : inner.taskId || inner.id;
729
+ if (typeof taskId !== "string" || !A.ID_RE.test(taskId)) return json({ error: "no task id" }, 400);
730
+ let given = req.headers.get("x-a2a-notification-token") || "";
731
+ const a = req.headers.get("authorization") || "";
732
+ if (!given && /^bearer /i.test(a)) given = a.slice(7).trim();
733
+ const rec: Json = await env.DB.prepare("SELECT * FROM outbound WHERE task_id = ?").bind(taskId).first();
734
+ if (!rec || !rec.push_token_hash || !given || !A.timingSafeEqualStr(rec.push_token_hash, await A.sha256(given))) {
735
+ log("push_rejected", { taskId });
736
+ return json({ error: "unauthorized" }, 401);
737
+ }
738
+ // normalised copy only for state/text; the task is stored exactly as the peer sent it (1.0 or 0.3 shape)
739
+ const t = "status" in inner ? A.taskFromAny(inner) : null;
740
+ const updates = JSON.parse(rec.updates_json || "[]");
741
+ updates.push({ receivedAt: A.nowIso(), payload: body });
742
+ const state = t?.status?.state ?? null;
743
+ let text = A.textOf(t?.status?.message || {});
744
+ if (t?.artifacts?.length) {
745
+ const at = t.artifacts.map((x: Json) => A.textOf(x)).join("\n");
746
+ if (at && at !== text) text = text ? `${text}\n${at}` : at;
747
+ }
748
+ await env.DB.batch([
749
+ env.DB.prepare("UPDATE outbound SET updates_json = ?, task_json = COALESCE(?, task_json) WHERE task_id = ?")
750
+ .bind(JSON.stringify(updates.slice(-20)), t && t.id ? JSON.stringify(inner) : null, taskId),
751
+ histStmt(env, rec.context_id, { dir: "in", peer: rec.peer, taskId, event: "push", state, text }),
752
+ ]);
753
+ log("push_received", { peer: rec.peer, taskId, state });
754
+ await wake(env, ectx, rec.context_id, taskId, rec.peer, `[update on outbound task: ${state}] ${text}`, "outbound_update");
755
+ return json({ ok: true });
756
+ }
757
+
758
+ // ------------------------------------------------------------------ owner API (local CLI)
759
+ function ownerTaskView(t: Json): Json {
760
+ const { _push, _peer, _protocol, _createdAt, ...rest } = t;
761
+ return {
762
+ ...rest, from: _peer, protocol: _protocol, createdAt: _createdAt,
763
+ pushConfigs: (_push || []).map((c: Json) => ({ id: c.id, url: c.url, version: c._version, hasToken: !!c.token, hasAuth: !!c.authentication })),
764
+ };
765
+ }
766
+
767
+ async function handleOwner(req: Request, env: Env, ectx: ExecutionContext, path: string, url: URL): Promise<Response> {
768
+ if (!(await isOwner(env, req.headers.get("authorization")))) return json({ error: "unauthorized" }, 401);
769
+ const m = req.method;
770
+ const seg = path.split("/").filter(Boolean).slice(1); // after "owner"
771
+ const body = async () => { const raw = await readBody(req, env); return raw ? JSON.parse(raw) : {}; };
772
+
773
+ if (seg[0] === "inbox" && m === "GET") {
774
+ const ctx = url.searchParams.get("context");
775
+ const all = url.searchParams.get("all") === "1";
776
+ let q = "SELECT id FROM tasks WHERE 1=1";
777
+ const args: Json[] = [];
778
+ if (!all) q += " AND state IN ('submitted','working')";
779
+ if (ctx) { q += " AND context_id = ?"; args.push(ctx); }
780
+ q += " ORDER BY updated_at LIMIT 200";
781
+ const rows = await env.DB.prepare(q).bind(...args).all();
782
+ const out = [];
783
+ for (const r of rows.results as Json[]) {
784
+ const t = await loadTask(env, r.id);
785
+ const lastUser = [...t.history].reverse().find((x: Json) => x.role === "user") || {};
786
+ out.push({ taskId: t.id, contextId: t.contextId, from: t._peer, state: t.status.state, updated: t.status.timestamp, text: A.textOf(lastUser) });
787
+ }
788
+ // the owner has now seen these tasks: drop pending wakes that only cover them
789
+ const seen = new Set(out.map((o) => o.taskId));
790
+ const pend = await env.DB.prepare("SELECT context_id, pending_json FROM wakes WHERE pending_json IS NOT NULL").all();
791
+ for (const p of pend.results as Json[]) {
792
+ const ids: string[] = JSON.parse(p.pending_json).taskIds || [];
793
+ if (ids.length && ids.every((i) => seen.has(i)))
794
+ await env.DB.prepare("UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL WHERE context_id = ? AND pending_json = ?").bind(p.context_id, p.pending_json).run();
795
+ }
796
+ return json(out);
797
+ }
798
+ if (seg[0] === "tasks" && seg[1]) {
799
+ const t = await loadTask(env, A.checkId(seg[1], "taskId"));
800
+ if (!t) return json({ error: "task not found" }, 404);
801
+ if (!seg[2] && m === "GET") return json(ownerTaskView(t));
802
+ if (seg[2] === "working" && m === "POST") {
803
+ if (A.TERMINAL.has(t.status.state)) return json({ error: `task is already ${t.status.state}` }, 409);
804
+ t.status = { state: "working", timestamp: A.nowIso() };
805
+ await env.DB.batch([saveTaskStmt(env, t), histStmt(env, t.contextId, { dir: "local", taskId: t.id, event: "state", state: "working" })]);
806
+ return json({ task: ownerTaskView(t), push: await notifyPeer(env, ectx, t) });
807
+ }
808
+ if (seg[2] === "reply" && m === "POST") {
809
+ const b = await body();
810
+ const state = b.state || "completed";
811
+ if (!["completed", "input-required", "failed", "rejected", "working"].includes(state)) return json({ error: "bad state" }, 400);
812
+ if (typeof b.text !== "string" || !b.text) return json({ error: "text required" }, 400);
813
+ if (A.TERMINAL.has(t.status.state) && !b.force) return json({ error: `task is already ${t.status.state}` }, 409);
814
+ const msg = { kind: "message", role: "agent", messageId: A.newId(), contextId: t.contextId, taskId: t.id, parts: [{ kind: "text", text: b.text }] };
815
+ if (state === "completed" || b.artifact) t.artifacts.push({ artifactId: A.newId(), name: b.artifactName || "response", parts: [{ kind: "text", text: b.text }] });
816
+ t.history.push(msg);
817
+ t.status = { state, timestamp: A.nowIso(), message: msg };
818
+ await env.DB.batch([saveTaskStmt(env, t), msgStmt(env, t, msg),
819
+ histStmt(env, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, role: "agent", state, text: b.text })]);
820
+ return json({ task: ownerTaskView(t), push: await notifyPeer(env, ectx, t) });
821
+ }
822
+ }
823
+ if (seg[0] === "history" && seg[1] && m === "GET") {
824
+ const n = Math.min(Number(url.searchParams.get("n") || "50"), 1000);
825
+ const rows = await env.DB.prepare("SELECT * FROM (SELECT * FROM history WHERE context_id = ? ORDER BY seq DESC LIMIT ?) ORDER BY seq").bind(seg[1], n).all();
826
+ return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
827
+ }
828
+ if (seg[0] === "history" && !seg[1] && m === "POST") {
829
+ const b = await body();
830
+ A.checkId(b.contextId, "contextId");
831
+ await histStmt(env, b.contextId, b).run();
832
+ return json({ ok: true });
833
+ }
834
+ if (seg[0] === "outbound" && !seg[1] && m === "POST") {
835
+ const b = await body();
836
+ A.checkId(b.taskId, "taskId"); A.checkId(b.contextId, "contextId");
837
+ await env.DB.prepare(
838
+ `INSERT INTO outbound (task_id, context_id, peer, endpoint, protocol, push_token_hash, sent_at, task_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
839
+ ON CONFLICT(task_id) DO UPDATE SET task_json = excluded.task_json, push_token_hash = COALESCE(excluded.push_token_hash, outbound.push_token_hash)`,
840
+ ).bind(b.taskId, b.contextId, String(b.peer || "?"), b.endpoint || null, b.protocol || null,
841
+ b.pushToken ? await A.sha256(b.pushToken) : null, A.nowIso(), b.task ? JSON.stringify(b.task) : null).run();
842
+ return json({ ok: true });
843
+ }
844
+ if (seg[0] === "outbound" && seg[1]) {
845
+ const id = A.checkId(seg[1], "taskId");
846
+ if (m === "GET") {
847
+ const r: Json = await env.DB.prepare("SELECT * FROM outbound WHERE task_id = ?").bind(id).first();
848
+ if (!r) return json({ error: "not found" }, 404);
849
+ return json({ taskId: r.task_id, contextId: r.context_id, peer: r.peer, endpoint: r.endpoint, protocol: r.protocol, sentAt: r.sent_at,
850
+ task: r.task_json ? JSON.parse(r.task_json) : null, updates: JSON.parse(r.updates_json) });
851
+ }
852
+ if (m === "PUT") {
853
+ const b = await body();
854
+ await env.DB.prepare("UPDATE outbound SET task_json = ? WHERE task_id = ?").bind(JSON.stringify(b.task ?? null), id).run();
855
+ return json({ ok: true });
856
+ }
857
+ }
858
+ if (seg[0] === "peers") {
859
+ if (m === "GET") {
860
+ const rows = await env.DB.prepare("SELECT label, created_at, revoked_at, source, user_code, client_name FROM peers ORDER BY created_at").all();
861
+ return json((rows.results as Json[]).map((r) => ({ ...r, user_code: r.user_code ? P.formatUserCode(r.user_code) : null })));
862
+ }
863
+ if (m === "POST" && !seg[1]) {
864
+ const b = await body();
865
+ if (typeof b.label !== "string" || !/^[A-Za-z0-9_-]{1,32}$/.test(b.label)) return json({ error: "label must be [A-Za-z0-9_-]{1,32}" }, 400);
866
+ const ex: Json = await env.DB.prepare("SELECT revoked_at FROM peers WHERE label = ?").bind(b.label).first();
867
+ if (ex && !ex.revoked_at && !b.rotate) return json({ error: "label exists (pass rotate:true to replace its token)" }, 409);
868
+ if (!ex && b.mustExist) return json({ error: `no token with label ${b.label} (see token list; \`token issue ${b.label}\` creates one)` }, 404);
869
+ const token = A.randomToken();
870
+ await env.DB.prepare(
871
+ "INSERT INTO peers (label, token_hash, created_at) VALUES (?, ?, ?) ON CONFLICT(label) DO UPDATE SET token_hash = excluded.token_hash, created_at = excluded.created_at, revoked_at = NULL",
872
+ ).bind(b.label, await A.sha256(token), A.nowIso()).run();
873
+ return json({ label: b.label, token, card: env.PUBLIC_URL + "/.well-known/agent-card.json" });
874
+ }
875
+ if (m === "DELETE" && seg[1]) {
876
+ const label = decodeURIComponent(seg[1]);
877
+ const r = await env.DB.prepare("UPDATE peers SET revoked_at = ? WHERE label = ? AND revoked_at IS NULL").bind(A.nowIso(), label).run();
878
+ if (r.meta.changes !== 1) {
879
+ const ex: Json = await env.DB.prepare("SELECT revoked_at FROM peers WHERE label = ?").bind(label).first();
880
+ return json({ revoked: false, error: ex ? `the token with label ${label} was already revoked (${ex.revoked_at})` : `no active token with label ${label} (see token list)` }, 404);
881
+ }
882
+ log("token_revoked", { label });
883
+ return json({ revoked: true, label });
884
+ }
885
+ }
886
+ if (seg[0] === "pairing") return await ownerPairing(req, env, m, seg.slice(1), body);
887
+ if (seg[0] === "facade" && !seg[1] && m === "GET") {
888
+ // proxy-mode diagnostics: is the upstream card reachable, which versions it speaks, and does the public card leak
889
+ if (!proxyMode(env)) return json({ mode: "inbox" });
890
+ const [ep, why] = F.upstreamEndpoint(env.UPSTREAM_URL);
891
+ const up = await fetchUpstreamCard(env, true);
892
+ const card = await facadeAgentCard(env);
893
+ const [, cardWhy] = ep ? F.upstreamCardUrl(ep, env.UPSTREAM_CARD_URL) : ["", ""];
894
+ return json({ mode: "proxy", upstreamValid: !!ep, ...(why ? { upstreamProblem: why } : {}), ...(cardWhy ? { upstreamCardProblem: cardWhy } : {}), upstreamCardUrl: up.url || null,
895
+ upstreamCard: up.status === 200 ? "ok" : up.error || "unavailable", upstreamVersions: F.upstreamJsonRpcVersions(up.card),
896
+ hasUpstreamToken: !!env.UPSTREAM_TOKEN, hasUpstreamAccessServiceToken: !!(env.UPSTREAM_ACCESS_CLIENT_ID && env.UPSTREAM_ACCESS_CLIENT_SECRET),
897
+ fingerprints: { upstreamToken: await A.fingerprint(env.UPSTREAM_TOKEN), upstreamAccessClientId: await A.fingerprint(env.UPSTREAM_ACCESS_CLIENT_ID) },
898
+ publicCardLeaks: F.cardLeaks(card, env.PUBLIC_URL, upstreamOrigins(env, up.card)) });
899
+ }
900
+ if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
901
+ // rendered wake request for a sample event, partially masked, plus short sha256 fingerprints of the
902
+ // uploaded secrets so the owner can compare them with local values without revealing them
903
+ const ev: WakeEvent = { contextId: "ctx-preview", taskId: "task-preview", taskIds: ["task-preview"], from: "example-peer",
904
+ preview: "Example message preview", kind: "test", publicUrl: env.PUBLIC_URL };
905
+ const req = await renderWake(wakeConfig(env), ev, { requestId: "preview" });
906
+ return json({ preset: preset(env), configured: !!req, debounceSeconds: debounceMs(env) / 1000, maxPerHour: maxPerHour(env),
907
+ hasKey: !!env.WAKE_WEBHOOK_KEY, hasHmacSecret: !!env.WAKE_HMAC_SECRET,
908
+ hasAccessServiceToken: !!(env.WAKE_ACCESS_CLIENT_ID && env.WAKE_ACCESS_CLIENT_SECRET),
909
+ fingerprints: { url: await A.fingerprint(env.WAKE_WEBHOOK_URL), key: await A.fingerprint(env.WAKE_WEBHOOK_KEY),
910
+ hmacSecret: await A.fingerprint(env.WAKE_HMAC_SECRET),
911
+ accessClientId: await A.fingerprint(env.WAKE_ACCESS_CLIENT_ID), accessClientSecret: await A.fingerprint(env.WAKE_ACCESS_CLIENT_SECRET) },
912
+ request: req ? redact(req) : null });
913
+ }
914
+ if (seg[0] === "wake" && seg[1] === "test" && m === "POST") {
915
+ const res = await sendWake(env, ectx, { contextId: "a2a-wake-test", taskId: "none", taskIds: [], from: "owner", preview: "", kind: "test" });
916
+ return json({ preset: preset(env), configured: !!env.WAKE_WEBHOOK_URL, ...res });
917
+ }
918
+ if (seg[0] === "purge" && m === "POST") {
919
+ // delete conversation data for the given contexts (test cleanup)
920
+ const b = await body();
921
+ if (!Array.isArray(b.contexts) || !b.contexts.length) return json({ error: "contexts[] required" }, 400);
922
+ let n = 0;
923
+ for (const c of b.contexts) {
924
+ A.checkId(c, "contextId");
925
+ const res = await env.DB.batch([
926
+ env.DB.prepare("DELETE FROM messages WHERE context_id = ?").bind(c),
927
+ env.DB.prepare("DELETE FROM tasks WHERE context_id = ?").bind(c),
928
+ env.DB.prepare("DELETE FROM history WHERE context_id = ?").bind(c),
929
+ env.DB.prepare("DELETE FROM outbound WHERE context_id = ?").bind(c),
930
+ env.DB.prepare("DELETE FROM wakes WHERE context_id = ?").bind(c),
931
+ ]);
932
+ n += res.reduce((s, r) => s + (r.meta.changes || 0), 0);
933
+ }
934
+ return json({ deletedRows: n });
935
+ }
936
+ if (seg[0] === "contexts" && m === "GET") {
937
+ const rows = await env.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();
938
+ return json(rows.results);
939
+ }
940
+ return json({ error: "not found" }, 404);
941
+ }
942
+
943
+ // ------------------------------------------------------------------ device-flow pairing (RFC 8628; the inbox is the authorization server)
944
+ const oauthError = (error: string, description: string, status = 400, headers: Record<string, string> = {}) =>
945
+ json({ error, error_description: description }, status, { pragma: "no-cache", ...headers });
946
+
947
+ /** RFC 8414 metadata, for generic OAuth clients. */
948
+ function oauthMetadata(env: Env): Json {
949
+ const u = oauthUrls(env);
950
+ return {
951
+ issuer: u.issuer, device_authorization_endpoint: u.device, token_endpoint: u.token,
952
+ grant_types_supported: [P.DEVICE_GRANT], response_types_supported: [], token_endpoint_auth_methods_supported: ["none"],
953
+ scopes_supported: ["a2a"], service_documentation: "https://github.com/telegraphic-dev/a2a-exposed#connecting-agents-device-flow",
954
+ };
955
+ }
956
+
957
+ /** Rows to drop: requests a day past expiry (until then a poll still gets expired_token, not invalid_grant) and old rate windows. */
958
+ const pairingCleanup = (env: Env, now: number) => [
959
+ env.DB.prepare("DELETE FROM device_requests WHERE expires_ms < ?").bind(now - 86400000),
960
+ env.DB.prepare("DELETE FROM pairing_rate WHERE expires_ms < ?").bind(now),
961
+ ];
962
+
963
+ /** Fixed-window counter: increments and returns the count for `key` in the current window (peek: no increment). */
964
+ async function bump(env: Env, key: string, windowS: number, peek = false): Promise<number> {
965
+ const now = Date.now(), start = Math.floor(now / (windowS * 1000));
966
+ if (peek) {
967
+ const r: Json = await env.DB.prepare("SELECT count FROM pairing_rate WHERE key = ? AND window_start = ?").bind(key, start).first();
968
+ return r?.count ?? 0;
969
+ }
970
+ const r: Json = await env.DB.prepare(
971
+ "INSERT INTO pairing_rate (key, window_start, count, expires_ms) VALUES (?, ?, 1, ?) ON CONFLICT(key, window_start) DO UPDATE SET count = count + 1 RETURNING count",
972
+ ).bind(key, start, (start + 1) * windowS * 1000).first();
973
+ return r?.count ?? 0;
974
+ }
975
+
976
+ const clientIp = (req: Request) => req.headers.get("cf-connecting-ip") || "";
977
+ const clientCountry = (req: Request) => String(((req as Json).cf || {}).country || "");
978
+
979
+ async function deviceAuthorization(req: Request, env: Env, ectx: ExecutionContext): Promise<Response> {
980
+ const now = Date.now(), ip = clientIp(req);
981
+ let p: Record<string, string>;
982
+ try {
983
+ const raw = await readBody(req, { ...env, MAX_BODY: "4096" });
984
+ p = P.parseParams(raw, req.headers.get("content-type"));
985
+ } catch (e: any) { return oauthError("invalid_request", e instanceof HttpError ? "request too large" : e.message); }
986
+ const clientName = P.cleanText(p.client_name), clientId = P.cleanText(p.client_id);
987
+ const cardUrl = P.cleanCardUrl(p.agent_card_url);
988
+ if (p.agent_card_url && !cardUrl) return oauthError("invalid_request", "agent_card_url must be an https URL (at most 300 characters)");
989
+ await env.DB.batch(pairingCleanup(env, now));
990
+ // flood control: approval prompts (wakes, pending requests) must stay few
991
+ const retry = { "retry-after": "600" };
992
+ if ((await bump(env, `dev:ip:${ip || "?"}`, P.LIMITS.perIpWindowS)) > P.LIMITS.perIp) {
993
+ log("pairing_rate_limited", { ip, scope: "ip" });
994
+ return oauthError("slow_down", "too many pairing requests from this address; try again in 10 minutes", 429, retry);
995
+ }
996
+ const pending: Json = await env.DB.prepare("SELECT COUNT(*) AS n FROM device_requests WHERE status = 'pending' AND expires_ms > ?").bind(now).first();
997
+ if ((pending?.n ?? 0) >= P.LIMITS.maxPending || (await bump(env, "dev:global", 3600)) > P.LIMITS.perHour) {
998
+ log("pairing_rate_limited", { ip, scope: "global" });
999
+ return oauthError("slow_down", "this inbox has too many pairing requests waiting; try again later", 429, retry);
1000
+ }
1001
+ const deviceCode = P.newDeviceCode();
1002
+ let userCode = "";
1003
+ for (let i = 0; i < 5 && !userCode; i++) {
1004
+ const c = P.newUserCode();
1005
+ const r = await env.DB.prepare(
1006
+ `INSERT INTO device_requests (device_hash, user_code, client_id, client_name, agent_card_url, ip, country, status, created_ms, expires_ms, interval_s)
1007
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) ON CONFLICT DO NOTHING`,
1008
+ ).bind(await A.sha256(deviceCode), c, clientId || null, clientName || null, cardUrl || null, ip || null, clientCountry(req) || null,
1009
+ now, now + P.EXPIRES_S * 1000, P.INTERVAL_S).run();
1010
+ if (r.meta.changes === 1) userCode = c;
1011
+ }
1012
+ if (!userCode) return oauthError("temporarily_unavailable", "could not allocate a user code; try again", 503);
1013
+ // Re-pairing: a requester that sends its current, still active token for this inbox (Authorization: Bearer) asks to
1014
+ // replace it. Approval then issues the new token under the same label and the old one stops working (no orphan token,
1015
+ // no "-2" label). The request is bound to the presented token's hash: if that token is rotated or revoked before
1016
+ // redemption, the swap is refused and a fresh label is used (a compromised old token can't overwrite a rotated one).
1017
+ // Anything else in the header is ignored: the request is an ordinary new pairing.
1018
+ const replaces = await peerOf(env, req.headers.get("authorization"));
1019
+ if (replaces) await env.DB.prepare("UPDATE device_requests SET replaces_label = ?, replaces_hash = ? WHERE user_code = ?").bind(replaces.label, replaces.hash, userCode).run();
1020
+ const u = oauthUrls(env);
1021
+ const shown = P.formatUserCode(userCode);
1022
+ const complete = `${u.page}?user_code=${shown}`;
1023
+ log("pairing_requested", { ip, userCode: shown, clientName, clientId, ...(replaces ? { replaces: replaces.label } : {}) });
1024
+ const mode = pairingMode(env) as "human" | "agent";
1025
+ ectx.waitUntil((async () => {
1026
+ if (!(await wakeBudgetOk(env))) return log("wake_skipped", { reason: "hourly cap", kind: "pairing_request" });
1027
+ await sendWake(env, ectx, { contextId: "pairing", taskId: "none", taskIds: [], from: clientName || clientId || "unknown agent", preview: "",
1028
+ kind: "pairing_request", pairing: { userCode: shown, verificationUriComplete: complete, approval: mode, clientName, clientId, agentCardUrl: cardUrl,
1029
+ ...(cardUrl && P.cardIsPrivate(cardUrl) ? { agentCardPrivate: true } : {}), expiresIn: P.EXPIRES_S,
1030
+ ...(replaces ? { replacesLabel: replaces.label } : {}) } });
1031
+ })());
1032
+ return json({ device_code: deviceCode, user_code: shown, verification_uri: u.page, verification_uri_complete: complete,
1033
+ expires_in: P.EXPIRES_S, interval: P.INTERVAL_S, ...(replaces ? { replaces_label: replaces.label } : {}) }, 200, { pragma: "no-cache" });
1034
+ }
1035
+
1036
+ async function tokenEndpoint(req: Request, env: Env): Promise<Response> {
1037
+ const now = Date.now(), ip = clientIp(req);
1038
+ let p: Record<string, string>;
1039
+ try { p = P.parseParams(await readBody(req, { ...env, MAX_BODY: "4096" }), req.headers.get("content-type")); }
1040
+ catch (e: any) { return oauthError("invalid_request", e instanceof HttpError ? "request too large" : e.message); }
1041
+ if (p.grant_type !== P.DEVICE_GRANT) return oauthError("unsupported_grant_type", `only grant_type=${P.DEVICE_GRANT} is supported`);
1042
+ if (!p.device_code) return oauthError("invalid_request", "device_code is required");
1043
+ if ((await bump(env, `tok:ip:${ip || "?"}`, 60)) > P.LIMITS.pollsPerMin) return oauthError("slow_down", "too many token requests from this address", 429, { "retry-after": "60" });
1044
+ const hash = await A.sha256(p.device_code);
1045
+ const r: Json = await env.DB.prepare("SELECT * FROM device_requests WHERE device_hash = ?").bind(hash).first();
1046
+ if (!r) return oauthError("invalid_grant", "unknown, already used, or expired device_code");
1047
+ if (now >= r.expires_ms) return oauthError("expired_token", "the device_code expired; start again");
1048
+ if (r.status === "denied") {
1049
+ await env.DB.prepare("DELETE FROM device_requests WHERE device_hash = ?").bind(hash).run();
1050
+ return oauthError("access_denied", "the inbox owner denied this request");
1051
+ }
1052
+ if (r.status === "pending") {
1053
+ // RFC 8628 §3.5: polling faster than the interval -> slow_down, and the interval grows by 5 s for good
1054
+ if (r.last_poll_ms && now - r.last_poll_ms < r.interval_s * 1000) {
1055
+ await env.DB.prepare("UPDATE device_requests SET interval_s = interval_s + 5, last_poll_ms = ? WHERE device_hash = ?").bind(now, hash).run();
1056
+ return oauthError("slow_down", `polling too fast; wait at least ${r.interval_s + 5} seconds between requests`);
1057
+ }
1058
+ await env.DB.prepare("UPDATE device_requests SET last_poll_ms = ? WHERE device_hash = ?").bind(now, hash).run();
1059
+ return oauthError("authorization_pending", "waiting for the inbox owner to approve");
1060
+ }
1061
+ // approved: single use. Only the request that flips the row gets the token; the row is then deleted.
1062
+ const claimed = await env.DB.prepare("UPDATE device_requests SET status = 'redeemed' WHERE device_hash = ? AND status = 'approved'").bind(hash).run();
1063
+ if (claimed.meta.changes !== 1) return oauthError("invalid_grant", "unknown, already used, or expired device_code");
1064
+ const token = A.randomToken();
1065
+ // re-pairing (approved as a replacement): the new token takes over the label only if the presented token is still the
1066
+ // one on that label (hash match). A rotate or revoke in between leaves u.meta.changes = 0.
1067
+ if (r.replaces_label && r.replaces_hash && r.label === r.replaces_label) {
1068
+ const u = await env.DB.prepare("UPDATE peers SET token_hash = ?, created_at = ?, source = 'pairing', user_code = ?, client_name = ? WHERE label = ? AND token_hash = ? AND revoked_at IS NULL")
1069
+ .bind(await A.sha256(token), A.nowIso(), r.user_code, r.client_name || r.client_id || null, r.replaces_label, r.replaces_hash).run();
1070
+ if (u.meta.changes === 1) {
1071
+ await env.DB.prepare("DELETE FROM device_requests WHERE device_hash = ?").bind(hash).run();
1072
+ log("pairing_redeemed", { ip, label: r.replaces_label, userCode: P.formatUserCode(r.user_code), replaced: true });
1073
+ return json({ access_token: token, token_type: "Bearer", peer_label: r.replaces_label, replaced: true }, 200, { pragma: "no-cache" });
1074
+ }
1075
+ }
1076
+ // the replaced token was rotated or revoked meanwhile: an ordinary new label
1077
+ let label = r.label && r.label !== r.replaces_label ? r.label
1078
+ : r.label ? await freeLabel(env, P.labelBase(r.client_name || "", r.client_id || "")) : P.labelBase(r.client_name || "", r.client_id || "");
1079
+ for (let i = 0; i < 5; i++) {
1080
+ try {
1081
+ await env.DB.prepare("INSERT INTO peers (label, token_hash, created_at, source, user_code, client_name) VALUES (?, ?, ?, 'pairing', ?, ?)")
1082
+ .bind(label, await A.sha256(token), A.nowIso(), r.user_code, r.client_name || r.client_id || null).run();
1083
+ break;
1084
+ } catch (e) {
1085
+ if (i === 4) throw e;
1086
+ label = await freeLabel(env, P.labelBase(r.client_name || "", r.client_id || "")); // taken meanwhile
1087
+ }
1088
+ }
1089
+ await env.DB.prepare("DELETE FROM device_requests WHERE device_hash = ?").bind(hash).run();
1090
+ log("pairing_redeemed", { ip, label, userCode: P.formatUserCode(r.user_code) });
1091
+ return json({ access_token: token, token_type: "Bearer", peer_label: label }, 200, { pragma: "no-cache" });
1092
+ }
1093
+
1094
+ async function freeLabel(env: Env, base: string): Promise<string> {
1095
+ const rows = await env.DB.prepare("SELECT label FROM peers WHERE label = ? OR label LIKE ?").bind(base, `${base}-%`).all();
1096
+ const pend = await env.DB.prepare("SELECT label FROM device_requests WHERE label IS NOT NULL").all();
1097
+ return P.dedupeLabel(base, new Set([...(rows.results as Json[]), ...(pend.results as Json[])].map((x) => x.label)));
1098
+ }
1099
+
1100
+ const pendingRequest = async (env: Env, code: string): Promise<Json | null> =>
1101
+ env.DB.prepare("SELECT * FROM device_requests WHERE user_code = ?").bind(code).first();
1102
+
1103
+ /** Approve or deny a pending request. Returns the label (approve) or an error message. */
1104
+ async function decide(env: Env, code: string, approve: boolean, by: string): Promise<{ ok: boolean; label?: string; error?: string; request?: Json; replaced?: boolean }> {
1105
+ const r = await pendingRequest(env, code);
1106
+ if (!r) return { ok: false, error: "no pairing request with that code" };
1107
+ if (r.status !== "pending") return { ok: false, error: `that request was already ${r.status === "denied" ? "denied" : "approved"}` };
1108
+ if (Date.now() >= r.expires_ms) return { ok: false, error: "that request has expired" };
1109
+ const replacing = approve && r.replaces_label && r.replaces_hash
1110
+ ? !!(await env.DB.prepare("SELECT 1 FROM peers WHERE label = ? AND token_hash = ? AND revoked_at IS NULL").bind(r.replaces_label, r.replaces_hash).first()) : false;
1111
+ const label = !approve ? null : replacing ? r.replaces_label : await freeLabel(env, P.labelBase(r.client_name || "", r.client_id || ""));
1112
+ const u = await env.DB.prepare("UPDATE device_requests SET status = ?, label = ?, decided_ms = ?, decided_by = ? WHERE device_hash = ? AND status = 'pending'")
1113
+ .bind(approve ? "approved" : "denied", label, Date.now(), by, r.device_hash).run();
1114
+ if (u.meta.changes !== 1) return { ok: false, error: "that request was decided meanwhile" };
1115
+ log(approve ? "pairing_approved" : "pairing_denied", { userCode: P.formatUserCode(code), by, label, ...(replacing ? { replaces: label } : {}) });
1116
+ return { ok: true, label: label || undefined, request: r, replaced: replacing };
1117
+ }
1118
+
1119
+ const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, clientName: r.client_name || "", clientId: r.client_id || "",
1120
+ agentCardUrl: r.agent_card_url || "", ip: r.ip || "", country: r.country || "", createdMs: r.created_ms, expiresMs: r.expires_ms,
1121
+ replacesLabel: r.replaces_label || undefined });
1122
+
1123
+ async function approvalPassword(env: Env): Promise<P.PasswordRecord | null> {
1124
+ const r: Json = await env.DB.prepare("SELECT value FROM settings WHERE key = 'approval_password'").first();
1125
+ try { return r ? JSON.parse(r.value) : null; } catch { return null; }
1126
+ }
1127
+
1128
+ function cookie(req: Request, name: string): string {
1129
+ for (const part of (req.headers.get("cookie") || "").split(";")) {
1130
+ const [k, ...v] = part.trim().split("=");
1131
+ if (k === name) return v.join("=");
1132
+ }
1133
+ return "";
1134
+ }
1135
+
1136
+ /** GET/POST /device: the owner approves or denies a request with the approval password (no scripts, strict CSP). */
1137
+ async function devicePage(req: Request, env: Env, url: URL): Promise<Response> {
1138
+ const nonce = P.randomB64(), ip = clientIp(req);
1139
+ const csrfName = "a2a_device_csrf";
1140
+ let csrf = cookie(req, csrfName);
1141
+ const fresh = !/^[A-Za-z0-9_-]{24}$/.test(csrf);
1142
+ if (fresh) csrf = P.randomB64();
1143
+ const pw = await approvalPassword(env);
1144
+ const model: P.PageModel = { agentName: env.AGENT_NAME || "this A2A inbox", mode: pairingMode(env), cli: cliCommand(env), csrf, passwordSet: !!pw };
1145
+ const send = (status = 200) => {
1146
+ const h: Record<string, string> = P.pageHeaders(nonce);
1147
+ h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
1148
+ return new Response(P.devicePage(model, nonce), { status, headers: h });
1149
+ };
1150
+ const show = async (raw: string | null, notice?: P.PageModel["notice"]) => {
1151
+ model.notice = notice;
1152
+ if (raw && (await bump(env, `look:ip:${ip || "?"}`, P.LIMITS.lookupsWindowS)) > P.LIMITS.lookupsPerIp) {
1153
+ model.askCode = true;
1154
+ model.notice = { kind: "error", text: "Too many code lookups from this address. Try again in a few minutes." };
1155
+ return;
1156
+ }
1157
+ const code = P.normUserCode(raw);
1158
+ const r = code ? await pendingRequest(env, code) : null;
1159
+ if (r && r.status === "pending" && Date.now() < r.expires_ms) model.request = pageRequest(r);
1160
+ else {
1161
+ model.askCode = true;
1162
+ model.codeValue = raw || "";
1163
+ if (raw && !notice) model.notice = { kind: "error", text: !code ? "That is not a valid code (it looks like WDJB-4827)." :
1164
+ !r ? "No pairing request with that code (it may have expired or been used)." :
1165
+ r.status !== "pending" ? `That request was already ${r.status === "denied" ? "denied" : "approved"}.` : "That request has expired." };
1166
+ }
1167
+ };
1168
+ if (req.method === "GET") {
1169
+ await show(url.searchParams.get("user_code"));
1170
+ return send();
1171
+ }
1172
+ // POST: same-origin form only (Origin check + double-submit cookie); the approval password is the real gate
1173
+ const origin = req.headers.get("origin");
1174
+ let form: Record<string, string> = {};
1175
+ try { form = P.parseParams(await readBody(req, { ...env, MAX_BODY: "4096" }), "application/x-www-form-urlencoded"); } catch { /* empty */ }
1176
+ if ((origin && origin !== "null" && origin !== new URL(env.PUBLIC_URL).origin && origin !== url.origin) || fresh || !form.csrf || !A.timingSafeEqualStr(form.csrf, csrf)) {
1177
+ await show(form.user_code || null, { kind: "error", text: "This form expired or came from another site. Reload the page and try again." });
1178
+ return send(403);
1179
+ }
1180
+ const code = P.normUserCode(form.user_code);
1181
+ if (!code) { await show(form.user_code || null); return send(400); }
1182
+ // Deny needs no password: it grants nothing, and the worst a holder of the code can do is cancel a pending request
1183
+ // (the requester can ask again). No PBKDF2 runs; each deny counts against the per-IP code-lookup limit, so codes
1184
+ // can't be enumerated this way either.
1185
+ if (form.decision === "deny") {
1186
+ if ((await bump(env, `look:ip:${ip || "?"}`, P.LIMITS.lookupsWindowS)) > P.LIMITS.lookupsPerIp) {
1187
+ await show(null, { kind: "error", text: "Too many code lookups from this address. Try again in a few minutes." });
1188
+ return send(429);
1189
+ }
1190
+ const r0 = await pendingRequest(env, code);
1191
+ if (!r0 || r0.status !== "pending" || Date.now() >= r0.expires_ms) { await show(form.user_code); return send(404); }
1192
+ const d0 = await decide(env, code, false, "page");
1193
+ if (!d0.ok) { await show(null, { kind: "error", text: d0.error! }); return send(409); }
1194
+ model.notice = { kind: "ok", text: `Denied. ${r0.client_name || r0.client_id || "the agent"} gets no token.` };
1195
+ return send();
1196
+ }
1197
+ if (!pw) { await show(form.user_code); return send(409); }
1198
+ // an empty password is a slip, not a guess: no PBKDF2, no attempt counted
1199
+ if (!form.password) { await show(form.user_code, { kind: "error", text: "Enter the approval password to approve (deny works without it)." }); return send(400); }
1200
+ const ipKey = `pw:ip:${ip || "?"}`;
1201
+ if ((await bump(env, ipKey, P.LIMITS.wrongPerIpWindowS, true)) >= P.LIMITS.wrongPerIp || (await bump(env, "pw:global", 3600, true)) >= P.LIMITS.wrongGlobal) {
1202
+ log("pairing_locked_out", { ip });
1203
+ await show(form.user_code, { kind: "error", text: "Too many wrong passwords. Approvals from this address are locked for up to an hour." });
1204
+ return send(429);
1205
+ }
1206
+ const r = await pendingRequest(env, code);
1207
+ if (!r || r.status !== "pending" || Date.now() >= r.expires_ms) { await show(form.user_code); return send(404); }
1208
+ if (!(await P.verifyPassword(form.password || "", pw))) {
1209
+ await bump(env, ipKey, P.LIMITS.wrongPerIpWindowS);
1210
+ await bump(env, "pw:global", 3600);
1211
+ const res: Json = await env.DB.prepare("UPDATE device_requests SET failed_attempts = failed_attempts + 1 WHERE device_hash = ? RETURNING failed_attempts").bind(r.device_hash).first();
1212
+ const left = P.LIMITS.wrongPerCode - (res?.failed_attempts ?? P.LIMITS.wrongPerCode);
1213
+ log("pairing_wrong_password", { ip, userCode: P.formatUserCode(code), left });
1214
+ if (left <= 0) {
1215
+ await decide(env, code, false, "lockout");
1216
+ await show(null, { kind: "error", text: "Wrong password. Too many wrong attempts for this code: the request was denied." });
1217
+ return send(403);
1218
+ }
1219
+ await show(form.user_code, { kind: "error", text: `Wrong password. ${left} attempt${left === 1 ? "" : "s"} left for this code.` });
1220
+ return send(403);
1221
+ }
1222
+ const d = await decide(env, code, true, "human");
1223
+ if (!d.ok) { await show(null, { kind: "error", text: d.error! }); return send(409); }
1224
+ const who = r.client_name || r.client_id || "the agent";
1225
+ model.notice = { kind: "ok", text: `Approved. ${who} can now collect its token (label "${d.label}"${d.replaced ? ", replacing its old token" : ""}). Revoke it any time: ${cliCommand(env)} token revoke ${d.label}` };
1226
+ return send();
1227
+ }
1228
+
1229
+ // ------------------------------------------------------------------ one-time password setup link (pair set-password --web)
1230
+ type SetupRecord = { hash: string; createdMs: number; expiresMs: number; failures: number };
1231
+
1232
+ async function setupRecord(env: Env): Promise<{ rec: SetupRecord; raw: string } | null> {
1233
+ const r: Json = await env.DB.prepare("SELECT value FROM settings WHERE key = 'password_setup'").first();
1234
+ if (!r) return null;
1235
+ try { return { rec: JSON.parse(r.value), raw: r.value }; } catch { return null; }
1236
+ }
1237
+
1238
+ /** The stored setup record when `token` is its live token (unexpired, not burned), else null. */
1239
+ async function liveSetup(env: Env, token: string): Promise<{ rec: SetupRecord; raw: string } | null> {
1240
+ if (!token || token.length > 128) return null;
1241
+ const s = await setupRecord(env);
1242
+ if (!s || Date.now() >= s.rec.expiresMs || s.rec.failures >= P.LIMITS.setupFailures) return null;
1243
+ return A.timingSafeEqualStr(await A.sha256(token), s.rec.hash) ? s : null;
1244
+ }
1245
+
1246
+ /** GET/POST /device/setup: set or change the approval password from a one-time link. Same frame, CSP and CSRF as /device. */
1247
+ async function setupPage(req: Request, env: Env, url: URL): Promise<Response> {
1248
+ const nonce = P.randomB64(), ip = clientIp(req);
1249
+ const csrfName = "a2a_device_csrf";
1250
+ let csrf = cookie(req, csrfName);
1251
+ const fresh = !/^[A-Za-z0-9_-]{24}$/.test(csrf);
1252
+ if (fresh) csrf = P.randomB64();
1253
+ const pw = await approvalPassword(env);
1254
+ const model: P.SetupModel = { agentName: env.AGENT_NAME || "this A2A inbox", cli: cliCommand(env), csrf, passwordSetAt: pw?.setAt };
1255
+ const send = (status = 200) => {
1256
+ const h: Record<string, string> = P.pageHeaders(nonce);
1257
+ h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
1258
+ return new Response(P.setupPage(model, nonce), { status, headers: h });
1259
+ };
1260
+ const invalid = (status: number, text = "This setup link is invalid, expired or already used.") => {
1261
+ model.token = undefined;
1262
+ model.notice = { kind: "error", text };
1263
+ return send(status);
1264
+ };
1265
+ if ((await bump(env, `setup:ip:${ip || "?"}`, P.LIMITS.setupWindowS)) > P.LIMITS.setupPerIp) {
1266
+ log("password_setup_rate_limited", { ip });
1267
+ return invalid(429, "Too many attempts from this address. Try again in a few minutes.");
1268
+ }
1269
+ if (req.method === "GET") {
1270
+ const t = url.searchParams.get("t") || "";
1271
+ const s = await liveSetup(env, t);
1272
+ if (!s) return invalid(t ? 404 : 400);
1273
+ model.token = t; model.expiresMs = s.rec.expiresMs;
1274
+ return send();
1275
+ }
1276
+ const origin = req.headers.get("origin");
1277
+ let form: Record<string, string> = {};
1278
+ try { form = P.parseParams(await readBody(req, { ...env, MAX_BODY: "8192" }), "application/x-www-form-urlencoded"); } catch { /* empty */ }
1279
+ const s = await liveSetup(env, form.t || "");
1280
+ if ((origin && origin !== "null" && origin !== new URL(env.PUBLIC_URL).origin && origin !== url.origin) || fresh || !form.csrf || !A.timingSafeEqualStr(form.csrf, csrf)) {
1281
+ if (s) { model.token = form.t; model.expiresMs = s.rec.expiresMs; }
1282
+ model.notice = { kind: "error", text: "This form expired or came from another site. Reload the page and try again." };
1283
+ return send(403);
1284
+ }
1285
+ if (!s) return invalid(404);
1286
+ const a = form.password || "", b = form.password2 || "";
1287
+ const problem = a.length < P.MIN_PASSWORD_LENGTH ? `Too short: use at least ${P.MIN_PASSWORD_LENGTH} characters (a passphrase of a few words works well).`
1288
+ : a.length > 1024 ? "Too long: at most 1024 characters." : a !== b ? "The two entries differ." : "";
1289
+ if (problem) {
1290
+ const failures = s.rec.failures + 1;
1291
+ const burned = failures >= P.LIMITS.setupFailures;
1292
+ await env.DB.prepare("UPDATE settings SET value = ? WHERE key = 'password_setup' AND value = ?").bind(JSON.stringify({ ...s.rec, failures }), s.raw).run();
1293
+ log("password_setup_rejected", { ip, failures, burned });
1294
+ if (burned) return invalid(403, `${problem} Too many attempts: this link is now used up. Ask your agent for a new one.`);
1295
+ model.token = form.t; model.expiresMs = s.rec.expiresMs;
1296
+ model.notice = { kind: "error", text: `${problem} Nothing was changed.` };
1297
+ return send(400);
1298
+ }
1299
+ // hash on the Worker (a one-off), then burn the token; only the request that burns it stores the password
1300
+ const rec = await P.makePasswordRecord(a, P.pbkdf2Iterations(env.PBKDF2_ITERATIONS));
1301
+ const burn = await env.DB.prepare("DELETE FROM settings WHERE key = 'password_setup' AND value = ?").bind(s.raw).run();
1302
+ if (burn.meta.changes !== 1) return invalid(409);
1303
+ const setAt = A.nowIso();
1304
+ await env.DB.prepare("INSERT INTO settings (key, value) VALUES ('approval_password', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
1305
+ .bind(JSON.stringify({ ...rec, setAt, setVia: "web" })).run();
1306
+ log("pairing_password_set", { via: "web", setAt, iterations: rec.iterations, ip });
1307
+ model.done = true;
1308
+ model.notice = { kind: "ok", text: `Approval password ${pw ? "changed" : "set"} (${setAt}). Only its salted PBKDF2-SHA256 hash is stored.` };
1309
+ return send();
1310
+ }
1311
+
1312
+ /** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
1313
+ async function ownerPairing(req: Request, env: Env, m: string, seg: string[], body: () => Promise<Json>): Promise<Response> {
1314
+ const mode = pairingMode(env);
1315
+ if (!seg[0] && m === "GET") {
1316
+ const now = Date.now();
1317
+ const rows = await env.DB.prepare("SELECT * FROM device_requests WHERE status = 'pending' AND expires_ms > ? ORDER BY created_ms").bind(now).all();
1318
+ const pw = await approvalPassword(env);
1319
+ const setup = await setupRecord(env);
1320
+ const linkLive = setup && now < setup.rec.expiresMs && setup.rec.failures < P.LIMITS.setupFailures;
1321
+ // with pairing off, leftover requests can't be approved or redeemed (every pairing endpoint is 404): don't list them
1322
+ const pending = mode === "off" ? [] : (rows.results as Json[]);
1323
+ return json({ mode, passwordSet: !!pw, passwordSetAt: pw?.setAt || null, passwordSetVia: pw ? pw.setVia || "terminal" : null,
1324
+ passwordIterations: pw?.iterations || null, pbkdf2Iterations: P.pbkdf2Iterations(env.PBKDF2_ITERATIONS),
1325
+ setupLinkExpiresAt: linkLive ? new Date(setup!.rec.expiresMs).toISOString() : null, verificationUri: oauthUrls(env).page,
1326
+ pending: pending.map((r) => ({ userCode: P.formatUserCode(r.user_code), clientName: r.client_name, clientId: r.client_id,
1327
+ agentCardUrl: r.agent_card_url, ip: r.ip, country: r.country, createdAt: new Date(r.created_ms).toISOString(),
1328
+ expiresAt: new Date(r.expires_ms).toISOString(), verificationUriComplete: `${oauthUrls(env).page}?user_code=${P.formatUserCode(r.user_code)}`,
1329
+ replacesLabel: r.replaces_label || null })) });
1330
+ }
1331
+ if (seg[0] === "password-link" && m === "POST" && !seg[1]) {
1332
+ if (mode === "off") return json({ error: "device-flow pairing is disabled (PAIRING_APPROVAL=off): there is nothing to approve, so no approval password is needed" }, 409);
1333
+ const b = await body();
1334
+ const ttl = b.ttlSeconds === undefined ? P.SETUP_LINK_DEFAULT_S : Number(b.ttlSeconds);
1335
+ if (!Number.isInteger(ttl) || ttl < P.SETUP_LINK_MIN_S || ttl > P.SETUP_LINK_MAX_S)
1336
+ return json({ error: `ttlSeconds must be a whole number from ${P.SETUP_LINK_MIN_S} to ${P.SETUP_LINK_MAX_S}` }, 400);
1337
+ const token = P.randomB64(32), now = Date.now();
1338
+ // one live link at a time: storing a new record replaces (invalidates) the previous link
1339
+ await env.DB.prepare("INSERT INTO settings (key, value) VALUES ('password_setup', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
1340
+ .bind(JSON.stringify({ hash: await A.sha256(token), createdMs: now, expiresMs: now + ttl * 1000, failures: 0 })).run();
1341
+ log("password_setup_link_created", { expiresAt: new Date(now + ttl * 1000).toISOString() });
1342
+ return json({ url: `${oauthUrls(env).setup}?t=${token}`, expiresAt: new Date(now + ttl * 1000).toISOString(), expiresIn: ttl, mode,
1343
+ passwordSet: !!(await approvalPassword(env)) });
1344
+ }
1345
+ if (seg[0] === "password" && m === "PUT") {
1346
+ const b = await body();
1347
+ const rec = { alg: b.alg, iterations: b.iterations, salt: b.salt, hash: b.hash };
1348
+ const err = P.checkPasswordRecord(rec);
1349
+ if (err) return json({ error: err }, 400);
1350
+ const setAt = A.nowIso();
1351
+ await env.DB.prepare("INSERT INTO settings (key, value) VALUES ('approval_password', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
1352
+ .bind(JSON.stringify({ ...rec, setAt, setVia: "terminal" })).run();
1353
+ log("pairing_password_set", { via: "terminal", setAt, iterations: rec.iterations });
1354
+ return json({ ok: true, mode, setAt });
1355
+ }
1356
+ if (seg[0] && (seg[1] === "approve" || seg[1] === "deny") && m === "POST" && !seg[2]) {
1357
+ if (mode === "off") return json({ error: "device-flow pairing is disabled (PAIRING_APPROVAL=off)" }, 404);
1358
+ const code = P.normUserCode(decodeURIComponent(seg[0]));
1359
+ if (!code) return json({ error: "not a valid code (it looks like WDJB-4827)" }, 400);
1360
+ const approve = seg[1] === "approve";
1361
+ if (approve && mode !== "agent")
1362
+ return json({ error: `approval mode is human: the owner approves on ${oauthUrls(env).page}?user_code=${P.formatUserCode(code)} with the approval password. \`pair approve\` works only after \`${cliCommand(env)} deploy --pairing-approval agent\`, and only if your human wants the agent to approve. \`pair deny\` works in every mode.` }, 403);
1363
+ const d = await decide(env, code, approve, approve ? "agent" : "owner");
1364
+ if (!d.ok) return json({ error: d.error }, 409);
1365
+ return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
1366
+ clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
1367
+ }
1368
+ return json({ error: "not found" }, 404);
1369
+ }
1370
+
1371
+ // ------------------------------------------------------------------ landing (GET /)
1372
+ /** A browser (Accept: text/html) gets a small HTML page; anything else (curl, agents, Accept: application/json, ?format=json) the JSON it always got. Built from the served (rewritten) card, so it shows exactly what the card shows. */
1373
+ async function landing(req: Request, env: Env, url: URL): Promise<Response> {
1374
+ const accept = req.headers.get("accept") || "";
1375
+ const html = url.searchParams.get("format") !== "json" && /\btext\/html\b/i.test(accept);
1376
+ const card: any = await agentCard(env);
1377
+ const cardUrl = env.PUBLIC_URL + "/.well-known/agent-card.json";
1378
+ if (!html) return json({ name: card.name, description: card.description, agentCard: cardUrl, a2a: env.PUBLIC_URL + "/",
1379
+ pairing: pairingOn(env) ? env.PUBLIC_URL + "/device" : undefined }, 200, { vary: "accept" });
1380
+ const versions = [...new Set<string>((card.supportedInterfaces || []).map((i: any) => String(i?.protocolVersion || "")).filter(Boolean))];
1381
+ const skills = (Array.isArray(card.skills) ? card.skills : []).filter((k: any) => k && (k.name || k.id))
1382
+ .map((k: any) => ({ name: String(k.name || k.id), description: typeof k.description === "string" ? k.description : undefined }));
1383
+ const nonce = P.randomB64();
1384
+ const page = P.landingPage({ name: String(card.name || env.AGENT_NAME || "A2A agent"), description: typeof card.description === "string" ? card.description : undefined,
1385
+ base: env.PUBLIC_URL, versions, skills, pairing: pairingOn(env), proxy: proxyMode(env) }, nonce);
1386
+ return new Response(req.method === "HEAD" ? null : page, { status: 200, headers: P.landingHeaders(nonce) });
1387
+ }
1388
+
1389
+ // ------------------------------------------------------------------ router
1390
+ async function handle(req: Request, env: Env, ectx: ExecutionContext): Promise<Response> {
1391
+ const url = new URL(req.url);
1392
+ const path = url.pathname;
1393
+ try {
1394
+ if (req.method === "GET" && path === "/.well-known/agent-card.json")
1395
+ return json(await agentCard(env), 200, { "access-control-allow-origin": "*", "cache-control": "public, max-age=300" });
1396
+ // legacy discovery path (A2A 0.2): the 0.3-shaped card that clients of that era parse
1397
+ if (req.method === "GET" && path === "/.well-known/agent.json")
1398
+ return json(agentCard03(env, await agentCard(env)), 200, { "access-control-allow-origin": "*", "cache-control": "public, max-age=300" });
1399
+ if (req.method === "GET" && path === "/health") return json({ ok: true, publicUrl: env.PUBLIC_URL, mode: proxyMode(env) ? "proxy" : "inbox" });
1400
+ if (path === "/.well-known/oauth-authorization-server" || path === "/.well-known/oauth-protected-resource" || path.startsWith("/oauth/") || path === "/device" || path === "/device/setup") {
1401
+ if (!pairingOn(env)) return json({ error: "not_found", error_description: "device-flow pairing is disabled on this inbox (PAIRING_APPROVAL=off); ask its operator for a token" }, 404);
1402
+ if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") return json(oauthMetadata(env), 200, { "access-control-allow-origin": "*" });
1403
+ if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(env), 200, { "access-control-allow-origin": "*" });
1404
+ if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, env, ectx);
1405
+ if (req.method === "POST" && path === "/oauth/token") return await tokenEndpoint(req, env);
1406
+ if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, env, url);
1407
+ if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, env, url);
1408
+ return json({ error: "not found" }, 404);
1409
+ }
1410
+ if (req.method === "POST" && (path === "/" || path === "/a2a" || path === "/a2a/v1")) return await handleRpc(req, env, ectx);
1411
+ if (req.method === "POST" && path === "/push") return await handlePush(req, env, ectx);
1412
+ if (path === "/owner" || path.startsWith("/owner/")) return await handleOwner(req, env, ectx, path, url);
1413
+ if ((req.method === "GET" || req.method === "HEAD") && path === "/") return await landing(req, env, url);
1414
+ return json({ error: "not found" }, 404);
1415
+ } catch (e: any) {
1416
+ if (e instanceof HttpError) return json({ error: e.message }, e.status);
1417
+ if (e instanceof A.InvalidParams || e instanceof SyntaxError) return json({ error: e.message }, 400);
1418
+ log("exception", { path, error: String(e?.stack || e).slice(0, 500) });
1419
+ return json({ error: "internal error" }, 500);
1420
+ }
1421
+ }
1422
+
1423
+ export default {
1424
+ async fetch(req, env: Env, ectx) {
1425
+ // PUBLIC_URL comes from the deployment (custom hostname or workers.dev). Unknown on a first workers.dev deploy,
1426
+ // or not a public https origin: use the origin the request reached the Worker on (always public).
1427
+ env = { ...env, PUBLIC_URL: A.publicOrigin(env.PUBLIC_URL, req.url) };
1428
+ const moved = A.movedResponse(req.url, env.PUBLIC_URL, env.RETIRED_HOSTNAMES);
1429
+ if (moved) return new Response(moved.body || null, { status: moved.status, headers: moved.headers });
1430
+ const res = await handle(req, env, ectx);
1431
+ // opportunistic flush of debounced wakes whose window has passed (no cron needed)
1432
+ ectx.waitUntil(flushDue(env, ectx).catch((e) => log("flush_failed", { error: String(e).slice(0, 200) })));
1433
+ return res;
1434
+ },
1435
+ // optional: enable with A2A_ENABLE_CRON=1 at deploy time (needs a workers.dev subdomain on the account)
1436
+ async scheduled(_ev, env: Env, ectx) {
1437
+ await flushDue(env, ectx);
1438
+ const now = Date.now();
1439
+ await env.DB.batch([
1440
+ env.DB.prepare("DELETE FROM rate WHERE minute < ?").bind(Math.floor(now / 60000) - 5),
1441
+ env.DB.prepare("DELETE FROM wake_budget WHERE hour < ?").bind(Math.floor(now / 3600000) - 24),
1442
+ ...pairingCleanup(env, now),
1443
+ ]);
1444
+ },
1445
+ } satisfies ExportedHandler<Env>;