a2a-exposed 0.7.15 → 0.7.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/main.mjs CHANGED
@@ -191,12 +191,13 @@ Control plane account (only when that deployment's login is on)
191
191
  page. Creating an agent shows the owner token once. The agent page rotates
192
192
  that token after a confirmation and a recent sign-in. The new token is
193
193
  shown once on that agent's page. The previous one stops working immediately.
194
- The token is shown only when the stored hash is still that token.
195
- Deleting an agent asks for its name and a recent sign-in. A self-hosted inbox has no such delete.
194
+ The token is shown only when the stored hash is still that token. A Copy button sits next to it. Without JavaScript the value still copies.
195
+ Deleting an agent asks for its name and a recent sign-in. The account page says how many slots remain. A self-hosted inbox has no such delete.
196
196
  That page is not stored.
197
197
  FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
198
198
  Unset, there is no limit. The database rejects another insert.
199
199
  SIGNUP_ALLOWLIST, when set, admits only those verified emails. Unset, sign-in stays open.
200
+ Cloudflare does not match that list. Connect it from the account page after signing in another way.
200
201
  A refusal returns to /app?error=invite and the page says Sign-in is invite-only for now.
201
202
  The control Worker logs social_sign_in_failed before the
202
203
  provider page, social_sign_in_timeout when that sign-in does not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.15",
3
+ "version": "0.7.17",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -342,6 +342,37 @@ async function verifyIdToken(token: string, jwks: { keys?: any[] }, expect: { is
342
342
  return { subject: typeof claims.sub === "string" ? claims.sub : String(claims.email) };
343
343
  }
344
344
 
345
+ type OidcCallback = { kind: OidcKind; state: string; code: string | null; error: string | null };
346
+ type OpenedOidc = { row: any; userCode: string | null } | (FinishResult & { ok: false });
347
+
348
+ /** Local callback checks. `consume` deletes the sign-in row; a probe leaves it for the real GET. */
349
+ async function openOidc(ctx: TenantContext, o: OidcCallback, consume: boolean): Promise<OpenedOidc> {
350
+ const state = o.state || "";
351
+ if (!/^[A-Za-z0-9_-]{20,128}$/.test(state)) return { ok: false, error: "This sign-in expired or was already used. Nothing was approved.", reason: "state", userCode: null };
352
+ const row: any = consume
353
+ ? await ctx.DB.prepare("DELETE FROM oidc_txns WHERE state = ? RETURNING *").bind(state).first()
354
+ : await ctx.DB.prepare("SELECT * FROM oidc_txns WHERE state = ?").bind(state).first();
355
+ if (!row || row.kind !== o.kind) return { ok: false, error: "This sign-in expired or was already used. Nothing was approved.", reason: "state", userCode: null };
356
+ const userCode = row.user_code || null;
357
+ if (Date.now() >= row.expires_ms) return { ok: false, error: "This sign-in expired. Nothing was approved.", reason: "expired", userCode };
358
+ if (o.error || !o.code) return { ok: false, error: "Sign-in was cancelled. Nothing was approved.", reason: "cancelled", userCode };
359
+ if (!approvalPolicy(ctx.gates.approvalOidc).oidc || !ctx.approvalClientSecret())
360
+ return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config", userCode };
361
+ return { row, userCode };
362
+ }
363
+
364
+ export type ReadOidcResult = (FinishResult & { ok: false }) | { ok: true; pending: true; userCode: string | null };
365
+
366
+ /**
367
+ * What a callback can know without spending it. The sign-in row stays, and the identity provider is not called,
368
+ * so a HEAD probe cannot approve or burn the one-time state.
369
+ */
370
+ export async function readOidc(ctx: TenantContext, o: OidcCallback): Promise<ReadOidcResult> {
371
+ const opened = await openOidc(ctx, o, false);
372
+ if ("ok" in opened) return opened;
373
+ return { ok: true, pending: true, userCode: opened.userCode };
374
+ }
375
+
345
376
  /**
346
377
  * Complete a sign-in. The state row is deleted on every callback, including failure, so it cannot be replayed.
347
378
  * The CSRF cookie is not read here: SameSite=Strict is dropped on the cross-site return from the identity
@@ -350,23 +381,21 @@ async function verifyIdToken(token: string, jwks: { keys?: any[] }, expect: { is
350
381
  export async function finishOidc(ctx: TenantContext, o: {
351
382
  kind: OidcKind; redirectUri: string; state: string; code: string | null; error: string | null;
352
383
  }): Promise<FinishResult> {
353
- const state = o.state || "";
354
- if (!/^[A-Za-z0-9_-]{20,128}$/.test(state)) return { ok: false, error: "This sign-in expired or was already used. Nothing was approved.", reason: "state", userCode: null };
355
- const row: any = await ctx.DB.prepare("DELETE FROM oidc_txns WHERE state = ? RETURNING *").bind(state).first();
356
- if (!row || row.kind !== o.kind) return { ok: false, error: "This sign-in expired or was already used. Nothing was approved.", reason: "state", userCode: null };
357
- const userCode = row.user_code || null;
358
- if (Date.now() >= row.expires_ms) return { ok: false, error: "This sign-in expired. Nothing was approved.", reason: "expired", userCode };
359
- if (o.error || !o.code) return { ok: false, error: "Sign-in was cancelled. Nothing was approved.", reason: "cancelled", userCode };
384
+ const opened = await openOidc(ctx, o, true);
385
+ if ("ok" in opened) return opened;
386
+ const { row, userCode } = opened;
387
+ const code = o.code;
388
+ if (!code) return { ok: false, error: "Sign-in was cancelled. Nothing was approved.", reason: "cancelled", userCode };
360
389
  const gate = ctx.gates.approvalOidc;
361
390
  const secret = ctx.approvalClientSecret();
362
- if (!approvalPolicy(gate).oidc || !secret) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config", userCode };
391
+ if (!secret) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config", userCode };
363
392
  let disc: Discovery;
364
393
  try { disc = await discover(ctx, gate.issuer); }
365
394
  catch { return { ok: false, error: UNREACHABLE, reason: "discovery", userCode }; }
366
395
  let idToken = "";
367
396
  try {
368
397
  const body = new URLSearchParams({
369
- grant_type: "authorization_code", code: o.code, redirect_uri: o.redirectUri,
398
+ grant_type: "authorization_code", code, redirect_uri: o.redirectUri,
370
399
  client_id: gate.clientId, client_secret: secret, code_verifier: row.verifier,
371
400
  });
372
401
  const res = await idpFetch(ctx, disc.token_endpoint, {
@@ -911,7 +911,7 @@ function ownerTaskView(t: Json): Json {
911
911
  };
912
912
  }
913
913
 
914
- async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionContext, path: string, url: URL, trusted = false): Promise<Response> {
914
+ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionContext, path: string, url: URL, trusted = false, head = false): Promise<Response> {
915
915
  // trusted: an in-process call. MCP tools use ownerCall (paths fixed in the tool code). The hosted
916
916
  // owner() RPC uses runOwnerRpc, which has its own allowlist. A request from the network is never trusted.
917
917
  if (!trusted && !(await isOwner(ctx, req.headers.get("authorization")))) return json({ error: "unauthorized" }, 401);
@@ -949,13 +949,16 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
949
949
  const lastUser = [...t.history].reverse().find((x: Json) => x.role === "user") || {};
950
950
  out.push({ taskId: t.id, contextId: t.contextId, from: t._peer, state: t.status.state, updated: t.status.timestamp, text: A.textOf(lastUser) });
951
951
  }
952
- // the owner has now seen these tasks: drop pending wakes that only cover them
953
- const seen = new Set(out.map((o) => o.taskId));
954
- const pend = await ctx.DB.prepare("SELECT context_id, pending_json FROM wakes WHERE pending_json IS NOT NULL").all();
955
- for (const p of pend.results as Json[]) {
956
- const ids: string[] = JSON.parse(p.pending_json).taskIds || [];
957
- if (ids.length && ids.every((i) => seen.has(i)))
958
- await ctx.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();
952
+ // the owner has now seen these tasks: drop pending wakes that only cover them.
953
+ // HEAD returns the same status and headers and does not mark the inbox seen.
954
+ if (!head) {
955
+ const seen = new Set(out.map((o) => o.taskId));
956
+ const pend = await ctx.DB.prepare("SELECT context_id, pending_json FROM wakes WHERE pending_json IS NOT NULL").all();
957
+ for (const p of pend.results as Json[]) {
958
+ const ids: string[] = JSON.parse(p.pending_json).taskIds || [];
959
+ if (ids.length && ids.every((i) => seen.has(i)))
960
+ await ctx.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();
961
+ }
959
962
  }
960
963
  return json(out);
961
964
  }
@@ -1206,6 +1209,15 @@ const pairingCleanup = (ctx: TenantContext, now: number) => [
1206
1209
  ctx.DB.prepare("DELETE FROM oidc_txns WHERE expires_ms < ?").bind(now),
1207
1210
  ];
1208
1211
 
1212
+ /**
1213
+ * True when this call is past `limit`. HEAD peeks: the counter is not incremented, and a count already at the cap
1214
+ * is past it, which is the decision one more increment would make.
1215
+ */
1216
+ async function overLimit(ctx: TenantContext, key: string, windowS: number, limit: number, head: boolean): Promise<boolean> {
1217
+ const n = await bump(ctx, key, windowS, head);
1218
+ return head ? n >= limit : n > limit;
1219
+ }
1220
+
1209
1221
  /** Fixed-window counter: increments and returns the count for `key` in the current window (peek: no increment). */
1210
1222
  async function bump(ctx: TenantContext, key: string, windowS: number, peek = false): Promise<number> {
1211
1223
  const now = Date.now(), start = Math.floor(now / (windowS * 1000));
@@ -1402,7 +1414,7 @@ function cookie(req: Request, name: string): string {
1402
1414
  }
1403
1415
 
1404
1416
  /** GET/POST /device: the owner approves or denies a request with the approval password (no scripts, strict CSP). */
1405
- async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
1417
+ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
1406
1418
  const nonce = P.randomB64(), ip = clientIp(req);
1407
1419
  const csrfName = "a2a_device_csrf";
1408
1420
  let csrf = cookie(req, csrfName);
@@ -1417,7 +1429,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
1417
1429
  };
1418
1430
  const show = async (raw: string | null, notice?: P.PageModel["notice"]) => {
1419
1431
  model.notice = notice;
1420
- if (raw && (await bump(ctx, `look:ip:${ip || "?"}`, P.LIMITS.lookupsWindowS)) > P.LIMITS.lookupsPerIp) {
1432
+ if (raw && await overLimit(ctx, `look:ip:${ip || "?"}`, P.LIMITS.lookupsWindowS, P.LIMITS.lookupsPerIp, head)) {
1421
1433
  model.askCode = true;
1422
1434
  model.notice = { kind: "error", text: "Too many code lookups from this address. Try again in a few minutes." };
1423
1435
  return;
@@ -1511,7 +1523,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
1511
1523
  }
1512
1524
 
1513
1525
  /** GET /device/oidc/callback: the identity provider returns here. State is the CSRF; the cookie is not re-checked. */
1514
- async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
1526
+ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
1515
1527
  const nonce = P.randomB64();
1516
1528
  const pw = await approvalPassword(ctx);
1517
1529
  const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
@@ -1520,10 +1532,31 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL): P
1520
1532
  h["set-cookie"] = `a2a_device_csrf=${model.csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
1521
1533
  return new Response(P.devicePage(model, nonce), { status, headers: h });
1522
1534
  };
1523
- const done = await O.finishOidc(ctx, {
1524
- kind: "device", redirectUri: `${oauthUrls(ctx).page}/oidc/callback`,
1535
+ const callback = {
1536
+ kind: "device" as const, redirectUri: `${oauthUrls(ctx).page}/oidc/callback`,
1525
1537
  state: url.searchParams.get("state") || "", code: url.searchParams.get("code"), error: url.searchParams.get("error"),
1526
- });
1538
+ };
1539
+ // HEAD must not delete the sign-in or approve. A still-open sign-in answers with the device page (200, the status a
1540
+ // completed approval uses) and does not say approved. The browser GET still finishes it.
1541
+ if (head) {
1542
+ const seen = await O.readOidc(ctx, callback);
1543
+ if (!seen.ok) {
1544
+ if (seen.userCode) {
1545
+ const pending = await pendingRequest(ctx, seen.userCode);
1546
+ if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request = pageRequest(pending);
1547
+ }
1548
+ model.notice = { kind: "error", text: seen.error };
1549
+ return send(400);
1550
+ }
1551
+ const pending = seen.userCode ? await pendingRequest(ctx, seen.userCode) : null;
1552
+ if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) {
1553
+ model.request = pageRequest(pending);
1554
+ return send(200);
1555
+ }
1556
+ model.notice = { kind: "error", text: "Nothing was approved." };
1557
+ return send(409);
1558
+ }
1559
+ const done = await O.finishOidc(ctx, callback);
1527
1560
  if (!done.ok) {
1528
1561
  log("oidc_rejected", { kind: "device", reason: done.reason });
1529
1562
  if (done.userCode) {
@@ -1559,7 +1592,7 @@ async function liveSetup(ctx: TenantContext, token: string): Promise<{ rec: Setu
1559
1592
  }
1560
1593
 
1561
1594
  /** GET/POST /device/setup: set or change the approval password from a one-time link. Same frame and CSRF as /device. The form posts only to this host, so form-action stays 'self'. */
1562
- async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
1595
+ async function setupPage(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
1563
1596
  const nonce = P.randomB64(), ip = clientIp(req);
1564
1597
  const csrfName = "a2a_device_csrf";
1565
1598
  let csrf = cookie(req, csrfName);
@@ -1577,7 +1610,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Re
1577
1610
  model.notice = { kind: "error", text };
1578
1611
  return send(status);
1579
1612
  };
1580
- if ((await bump(ctx, `setup:ip:${ip || "?"}`, P.LIMITS.setupWindowS)) > P.LIMITS.setupPerIp) {
1613
+ if (await overLimit(ctx, `setup:ip:${ip || "?"}`, P.LIMITS.setupWindowS, P.LIMITS.setupPerIp, head)) {
1581
1614
  log("password_setup_rate_limited", { ip });
1582
1615
  return invalid(429, "Too many attempts from this address. Try again in a few minutes.");
1583
1616
  }
@@ -1871,7 +1904,7 @@ async function pinnedGet(ip: string, url: string, max: number, signal: AbortSign
1871
1904
  * Document (fetched with SSRF guards: public https host other than this Worker whose A/AAAA records are all public
1872
1905
  * (DNS-over-HTTPS), connected to at that checked IP (no DNS rebinding), no redirects, no credentials, 5 s, 5 KB; cached for its
1873
1906
  * max-age; failures and invalid documents are not cached). `ip` rate-limits fetches (not cached lookups). */
1874
- async function resolveClient(ctx: TenantContext, id: string, ip?: string): Promise<{ client?: Json; error?: string }> {
1907
+ async function resolveClient(ctx: TenantContext, id: string, ip?: string, head = false): Promise<{ client?: Json; error?: string }> {
1875
1908
  if (!id) return { error: "client_id is required" };
1876
1909
  if (!/^https:\/\//i.test(id)) {
1877
1910
  const c: Json = await ctx.DB.prepare("SELECT * FROM oauth_clients WHERE client_id = ?").bind(id).first();
@@ -1882,6 +1915,12 @@ async function resolveClient(ctx: TenantContext, id: string, ip?: string): Promi
1882
1915
  const now = Date.now();
1883
1916
  const hit: Json = await ctx.DB.prepare("SELECT * FROM oauth_client_metadata WHERE client_id = ? AND expires_ms > ?").bind(id, now).first();
1884
1917
  if (hit) return { client: { ...hit, cimd: true } };
1918
+ // HEAD does not fetch the document or spend the lookup limit. A cache miss is the same refusal as a failed fetch.
1919
+ if (head) {
1920
+ if (ip !== undefined && await overLimit(ctx, `cimd:ip:${ip || "?"}`, 3600, M.CIMD.fetchesPerIpPerHour, true))
1921
+ return { error: "Too many client metadata lookups from this address. Try again later." };
1922
+ return { error: "The client's metadata document could not be fetched." };
1923
+ }
1885
1924
  if (ip !== undefined && (await bump(ctx, `cimd:ip:${ip || "?"}`, 3600)) > M.CIMD.fetchesPerIpPerHour)
1886
1925
  return { error: "Too many client metadata lookups from this address. Try again later." };
1887
1926
  const signal = AbortSignal.timeout(M.CIMD.timeoutMs);
@@ -1920,11 +1959,11 @@ async function resolveClient(ctx: TenantContext, id: string, ip?: string): Promi
1920
1959
  type AuthzCheck = { fatal?: string; error?: string; description?: string; client?: Json; redirect?: string; params: Record<string, string> };
1921
1960
 
1922
1961
  /** Validate an authorization request. fatal: show an error page (no trustworthy redirect); error: redirect back with it. */
1923
- async function checkAuthorize(ctx: TenantContext, q: Record<string, string>, ip: string): Promise<AuthzCheck> {
1962
+ async function checkAuthorize(ctx: TenantContext, q: Record<string, string>, ip: string, head = false): Promise<AuthzCheck> {
1924
1963
  const params: Record<string, string> = {};
1925
1964
  for (const k of ["response_type", "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method", "scope", "resource"])
1926
1965
  if (typeof q[k] === "string" && q[k] !== "") params[k] = q[k].slice(0, 2048);
1927
- const rc = await resolveClient(ctx, params.client_id || "", ip);
1966
+ const rc = await resolveClient(ctx, params.client_id || "", ip, head);
1928
1967
  if (!rc.client) return { fatal: rc.error!, params };
1929
1968
  const client = rc.client;
1930
1969
  const uris: string[] = JSON.parse(client.redirect_uris_json || "[]");
@@ -1948,7 +1987,7 @@ function redirectWith(ctx: TenantContext, redirect: string, q: Record<string, st
1948
1987
  }
1949
1988
 
1950
1989
  /** GET/POST /oauth/authorize: the owner approves an MCP client with the approval password (same lockout as /device). */
1951
- async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
1990
+ async function authorizePage(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
1952
1991
  const nonce = P.randomB64(), ip = clientIp(req);
1953
1992
  const csrfName = "a2a_mcp_csrf";
1954
1993
  let csrf = cookie(req, csrfName);
@@ -1959,7 +1998,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
1959
1998
  try { form = P.parseParams(await readBody(req, { ...ctx, MAX_BODY: "16384" }), "application/x-www-form-urlencoded"); } catch { /* empty */ }
1960
1999
  }
1961
2000
  const q = req.method === "GET" ? Object.fromEntries(url.searchParams) : form;
1962
- const c = await checkAuthorize(ctx, q, ip);
2001
+ const c = await checkAuthorize(ctx, q, ip, head);
1963
2002
  const pw = await approvalPassword(ctx);
1964
2003
  const page = (status: number, body: string, formOrigins: string[] = []) => {
1965
2004
  // Approve and deny 302 to the client. OpenID Connect 302s to the issuer. Chrome checks both against form-action.
@@ -2030,13 +2069,20 @@ async function issueMcpCode(ctx: TenantContext, clientName: string, redirect: st
2030
2069
  * The identity provider navigates here with a GET. The code redirect is that navigation's 302, not a form post, so form-action does not apply to the client origin.
2031
2070
  * The error page has no form.
2032
2071
  */
2033
- async function mcpOidcCallback(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
2072
+ async function mcpOidcCallback(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
2034
2073
  const nonce = P.randomB64();
2035
2074
  const fail = (status: number, text: string) => new Response(P.pageShell("Connect an MCP client", nonce, `<p class="n error">${P.esc(text)}</p>`), { status, headers: P.pageHeaders(nonce) });
2036
- const done = await O.finishOidc(ctx, {
2037
- kind: "mcp", redirectUri: `${oauthUrls(ctx).authorize}/oidc/callback`,
2075
+ const callback = {
2076
+ kind: "mcp" as const, redirectUri: `${oauthUrls(ctx).authorize}/oidc/callback`,
2038
2077
  state: url.searchParams.get("state") || "", code: url.searchParams.get("code"), error: url.searchParams.get("error"),
2039
- });
2078
+ };
2079
+ // The successful GET is a 302 that carries a newly issued code. HEAD does not issue that code or delete the sign-in,
2080
+ // so it stays on the not-approved page. The browser GET still completes the redirect.
2081
+ if (head) {
2082
+ const seen = await O.readOidc(ctx, callback);
2083
+ return fail(400, seen.ok ? "Nothing was approved." : seen.error);
2084
+ }
2085
+ const done = await O.finishOidc(ctx, callback);
2040
2086
  if (!done.ok) {
2041
2087
  log("oidc_rejected", { kind: "mcp", reason: done.reason });
2042
2088
  return fail(400, done.error);
@@ -2462,26 +2508,38 @@ async function handleMcp(req: Request, ctx: TenantContext, ectx: ExecutionContex
2462
2508
  return rpcError(id, M.ERR.METHOD_NOT_FOUND, `method not found: ${msg.method}`);
2463
2509
  }
2464
2510
 
2465
- async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext): Promise<Response> {
2511
+ /** Hosted cards disappear when the agent is deleted. There is no purge binding, so the public max-age is short. Self-host has no such delete. */
2512
+ function cardCacheControl(ctx: TenantContext): string {
2513
+ return ctx.mode === "host" ? "public, max-age=60" : "public, max-age=300";
2514
+ }
2515
+
2516
+ /** Same status and headers as the GET response, with an empty body. The body is discarded, not copied. */
2517
+ async function emptyHead(res: Response): Promise<Response> {
2518
+ try { await res.body?.cancel(); } catch { /* the status and headers still answer the HEAD */ }
2519
+ return new Response(null, { status: res.status, headers: res.headers });
2520
+ }
2521
+
2522
+ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext, head = false): Promise<Response> {
2523
+ if (req.method === "HEAD") return emptyHead(await handle(new Request(req.url, { method: "GET", headers: req.headers }), ctx, ectx, true));
2466
2524
  const url = new URL(req.url);
2467
2525
  const path = url.pathname;
2468
2526
  try {
2469
2527
  if (req.method === "GET" && path === "/.well-known/agent-card.json")
2470
- return json(await agentCard(ctx), 200, { "access-control-allow-origin": "*", "cache-control": "public, max-age=300" });
2528
+ return json(await agentCard(ctx), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
2471
2529
  // legacy discovery path (A2A 0.2): the 0.3-shaped card that clients of that era parse
2472
2530
  if (req.method === "GET" && path === "/.well-known/agent.json")
2473
- return json(agentCard03(ctx, await agentCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control": "public, max-age=300" });
2531
+ return json(agentCard03(ctx, await agentCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
2474
2532
  if (req.method === "GET" && path === "/health") return json({ ok: true, publicUrl: ctx.PUBLIC_URL, mode: proxyMode(ctx) ? "proxy" : "inbox", mcp: mcpOn(ctx) });
2475
2533
  if (path === "/mcp") return mcpOn(ctx) ? await handleMcp(req, ctx, ectx, url) : json({ error: "not_found", error_description: "the MCP server is turned off on this inbox (MCP=off)" }, 404);
2476
2534
  if (mcpOn(ctx)) {
2477
2535
  if (req.method === "GET" && path === "/.well-known/oauth-protected-resource/mcp") return json(mcpResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
2478
2536
  if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") return json(oauthMetadata(ctx), 200, { "access-control-allow-origin": "*" });
2479
2537
  if (req.method === "POST" && path === "/oauth/register") return await registerClient(req, ctx);
2480
- if (req.method === "GET" && path === "/oauth/authorize/oidc/callback") return await mcpOidcCallback(req, ctx, url);
2481
- if ((req.method === "GET" || req.method === "POST") && path === "/oauth/authorize") return await authorizePage(req, ctx, url);
2538
+ if (req.method === "GET" && path === "/oauth/authorize/oidc/callback") return await mcpOidcCallback(req, ctx, url, head);
2539
+ if ((req.method === "GET" || req.method === "POST") && path === "/oauth/authorize") return await authorizePage(req, ctx, url, head);
2482
2540
  if (req.method === "POST" && path === "/oauth/revoke") return await revokeEndpoint(req, ctx);
2483
2541
  if (req.method === "POST" && path === "/oauth/token") return await tokenEndpoint(req, ctx);
2484
- if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url);
2542
+ if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
2485
2543
  }
2486
2544
  if (path === "/.well-known/oauth-authorization-server" || path === "/.well-known/oauth-protected-resource" || path.startsWith("/oauth/") || path === "/device" || path === "/device/setup" || path === "/device/oidc/callback") {
2487
2545
  if (!pairingOn(ctx)) 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);
@@ -2489,14 +2547,14 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext):
2489
2547
  if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
2490
2548
  if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, ctx, ectx);
2491
2549
  if (req.method === "POST" && path === "/oauth/token") return await tokenEndpoint(req, ctx);
2492
- if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, url);
2493
- if (req.method === "GET" && path === "/device/oidc/callback") return await deviceOidcCallback(req, ctx, url);
2494
- if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url);
2550
+ if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, url, head);
2551
+ if (req.method === "GET" && path === "/device/oidc/callback") return await deviceOidcCallback(req, ctx, url, head);
2552
+ if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
2495
2553
  return json({ error: "not found" }, 404);
2496
2554
  }
2497
2555
  if (req.method === "POST" && (path === "/" || path === "/a2a" || path === "/a2a/v1")) return await handleRpc(req, ctx, ectx);
2498
2556
  if (req.method === "POST" && path === "/push") return await handlePush(req, ctx, ectx);
2499
- if (path === "/owner" || path.startsWith("/owner/")) return await handleOwner(req, ctx, ectx, path, url);
2557
+ if (path === "/owner" || path.startsWith("/owner/")) return await handleOwner(req, ctx, ectx, path, url, false, head);
2500
2558
  if ((req.method === "GET" || req.method === "HEAD") && path === "/") return await landing(req, ctx, url);
2501
2559
  return json({ error: "not found" }, 404);
2502
2560
  } catch (e: any) {
@@ -369,6 +369,37 @@ test("email allowlist matches and a hosted tenant uses its own approval, not the
369
369
  assert.equal(s.tokenBodies.at(-1)!.client_secret, "tenant-secret");
370
370
  });
371
371
 
372
+ test("HEAD on an OpenID Connect callback leaves the sign-in unused", async (t) => {
373
+ const s = setup(oidcEnv); t.after(s.restore);
374
+ const d = await s.start();
375
+ const { post } = await s.postOidc(d.data.user_code);
376
+ assert.equal(post.status, 302, post.text);
377
+ const loc = new URL(post.headers.get("location")!);
378
+ const state = loc.searchParams.get("state")!;
379
+ s.setClaims(() => claims({ nonce: loc.searchParams.get("nonce")! }));
380
+ const head = await s.call("HEAD", `/device/oidc/callback?code=auth-code&state=${state}`);
381
+ assert.equal(head.status, 200);
382
+ assert.equal(head.text, "");
383
+ assert.match(head.headers.get("content-type") || "", /^text\/html/);
384
+ assert.equal(s.tokenBodies.length, 0);
385
+ assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oidc_txns WHERE state = ?").get(state)!.n, 1);
386
+ const back = await s.call("GET", `/device/oidc/callback?code=auth-code&state=${state}`);
387
+ assert.equal(back.status, 200, back.text);
388
+ assert.match(back.text, /Approved/);
389
+ assert.equal(s.tokenBodies.length, 1);
390
+
391
+ const again = await s.start();
392
+ const started = await s.postOidc(again.data.user_code);
393
+ const state2 = new URL(started.post.headers.get("location")!).searchParams.get("state")!;
394
+ const denied = await s.call("HEAD", `/device/oidc/callback?error=access_denied&state=${state2}`);
395
+ assert.equal(denied.status, 400);
396
+ assert.equal(denied.text, "");
397
+ assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oidc_txns WHERE state = ?").get(state2)!.n, 1);
398
+ const still = await s.call("GET", `/device/oidc/callback?error=access_denied&state=${state2}`);
399
+ assert.equal(still.status, 400);
400
+ assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oidc_txns WHERE state = ?").get(state2)!.n, 0);
401
+ });
402
+
372
403
  test("MCP consent offers Continue with the issuer and the callback issues the authorization code", async (t) => {
373
404
  const s = setup(oidcEnv); t.after(s.restore);
374
405
  const reg = await s.call("POST", "/oauth/register", { json: { client_name: "Claude", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"] } });
@@ -386,7 +417,15 @@ test("MCP consent offers Continue with the issuer and the callback issues the au
386
417
  const loc = new URL(post.headers.get("location")!);
387
418
  assert.equal(loc.search.includes("st-1"), false, "the MCP state is not the identity provider's state");
388
419
  s.setClaims(() => claims({ nonce: loc.searchParams.get("nonce")! }));
389
- const back = await s.call("GET", `/oauth/authorize/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
420
+ const state = loc.searchParams.get("state")!;
421
+ const head = await s.call("HEAD", `/oauth/authorize/oidc/callback?code=auth-code&state=${state}`);
422
+ assert.equal(head.status, 400);
423
+ assert.equal(head.text, "");
424
+ assert.equal(head.headers.get("location"), null);
425
+ assert.equal(s.tokenBodies.length, 0);
426
+ assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oidc_txns WHERE state = ?").get(state)!.n, 1);
427
+ assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oauth_codes").get()!.n, 0);
428
+ const back = await s.call("GET", `/oauth/authorize/oidc/callback?code=auth-code&state=${state}`);
390
429
  assert.equal(back.status, 302, back.text);
391
430
  const callbackError = await s.call("GET", "/oauth/authorize/oidc/callback?error=access_denied");
392
431
  assert.equal(callbackError.status, 400);
@@ -181,6 +181,17 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
181
181
  await kv.put("tenant:bobby", JSON.stringify({ id: "id-bobby", status: "active", region: "", version: 1 }));
182
182
 
183
183
  const card = async (name: string, headers?: HeadersInit) => (await mf.dispatchFetch(`https://${name}.example.com/.well-known/agent-card.json`, { headers })).json() as Promise<{ name: string }>;
184
+ const aliceCard = await mf.dispatchFetch("https://alice.example.com/.well-known/agent-card.json");
185
+ assert.equal(aliceCard.headers.get("cache-control"), "public, max-age=60");
186
+ assert.equal((await aliceCard.json() as { name: string }).name, "Alice");
187
+ const aliceHead = await mf.dispatchFetch("https://alice.example.com/.well-known/agent-card.json", { method: "HEAD" });
188
+ assert.equal(aliceHead.status, 200);
189
+ assert.equal(await aliceHead.text(), "");
190
+ assert.equal(aliceHead.headers.get("content-type"), "application/json");
191
+ assert.equal(aliceHead.headers.get("cache-control"), "public, max-age=60");
192
+ const aliceLegacy = await mf.dispatchFetch("https://alice.example.com/.well-known/agent.json", { method: "HEAD" });
193
+ assert.equal(aliceLegacy.status, 200);
194
+ assert.equal(await aliceLegacy.text(), "");
184
195
  assert.equal((await card("alice")).name, "Alice");
185
196
  assert.equal((await card("bobby")).name, "Bob");
186
197
  assert.equal((await card("alice", { "x-a2a-tenant": JSON.stringify({ name: "bob", config: { AGENT_NAME: "Bob" } }) })).name, "Alice");
@@ -578,11 +578,23 @@ test("metadata advertises CIMD and RFC 9207 iss; DCR accepts application_type; n
578
578
  test("Client ID Metadata Documents: fetched, validated, cached; consent shows the client_id host; tokens and refresh work", async (t) => {
579
579
  const s = setup(); t.after(s.restore);
580
580
  s.cimd["/oauth/metadata.json"] = { body: cimdDoc(), headers: { "cache-control": "max-age=600" } };
581
+ const challenge = await M.s256(b64url(crypto.getRandomValues(new Uint8Array(32))));
582
+ const probe = new URLSearchParams({ response_type: "code", client_id: CIMD_ID, redirect_uri: CLAUDE_CB, state: "s1", code_challenge: challenge, code_challenge_method: "S256" });
583
+ const head = await s.call("HEAD", `/oauth/authorize?${probe}`);
584
+ assert.equal(head.status, 400);
585
+ assert.equal(head.text, "");
586
+ assert.equal(s.cimdFetches.length, 0, "HEAD does not fetch client metadata");
587
+ assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oauth_client_metadata").get()!.n, 0);
588
+ assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM pairing_rate WHERE key LIKE 'cimd:%'").get()!.n, 0);
581
589
  const r = await authorizeWith(s, CIMD_ID, { resource: `${BASE}/mcp` });
582
590
  assert.equal(r.page.status, 200, r.page.text);
583
591
  assert.match(r.page.text, /CIMD Client/);
584
592
  assert.match(r.page.text, /Client identity published at<\/th><td>client\.example\.org/);
585
593
  assert.equal(s.cimdFetches.length, 1);
594
+ const cached = await s.call("HEAD", `/oauth/authorize?${new URLSearchParams(r.q)}`);
595
+ assert.equal(cached.status, 200);
596
+ assert.equal(cached.text, "");
597
+ assert.equal(s.cimdFetches.length, 1, "a cached document is not fetched again for HEAD");
586
598
  assert.equal(s.cimdFetches[0].ip, "93.184.216.34", "connected to the DNS-checked address (pinned: no rebinding)");
587
599
  assert.equal(s.cimdFetches[0].sni, "client.example.org", "TLS verified for the client_id host");
588
600
  assert.equal(s.cimdFetches[0].port, 443);
@@ -62,6 +62,65 @@ function setup(over: Record<string, string> = {}) {
62
62
  return { env, DB, wakes, call, owner, start, poll, decidePage, restore: () => { globalThis.fetch = origFetch; } };
63
63
  }
64
64
 
65
+ test("HEAD answers like GET with an empty body, and a self-hosted card stays cached for 5 minutes", async (t) => {
66
+ const s = setup();
67
+ t.after(s.restore);
68
+ const get = await s.call("GET", "/.well-known/agent-card.json");
69
+ assert.equal(get.status, 200);
70
+ assert.equal(get.headers.get("cache-control"), "public, max-age=300");
71
+ const head = await s.call("HEAD", "/.well-known/agent-card.json");
72
+ assert.equal(head.status, 200);
73
+ assert.equal(head.text, "");
74
+ assert.equal(head.headers.get("cache-control"), "public, max-age=300");
75
+ assert.match(head.headers.get("content-type") ?? "", /^application\/json/);
76
+ const legacy = await s.call("HEAD", "/.well-known/agent.json");
77
+ assert.equal(legacy.status, 200);
78
+ assert.equal(legacy.text, "");
79
+ assert.equal(legacy.headers.get("cache-control"), "public, max-age=300");
80
+ const health = await s.call("HEAD", "/health");
81
+ assert.equal(health.status, 200);
82
+ assert.equal(health.text, "");
83
+ assert.match(health.headers.get("content-type") ?? "", /^application\/json/);
84
+ const missing = await s.call("HEAD", "/no-such-route");
85
+ assert.equal(missing.status, 404);
86
+ assert.equal(missing.text, "");
87
+ const owner = await s.call("HEAD", "/owner/peers");
88
+ assert.equal(owner.status, 401);
89
+ assert.equal(owner.text, "");
90
+ });
91
+
92
+ test("HEAD does not spend setup or pairing lookup limits", async (t) => {
93
+ const s = setup();
94
+ t.after(s.restore);
95
+ const link = (await s.owner("POST", "/owner/pairing/password-link", {})).data;
96
+ const path = new URL(link.url).pathname + new URL(link.url).search;
97
+ const ip = "192.0.2.20";
98
+ for (let i = 0; i < P.LIMITS.setupPerIp; i++) {
99
+ const h = await s.call("HEAD", path, { ip });
100
+ assert.equal(h.status, 200, h.text);
101
+ assert.equal(h.text, "");
102
+ }
103
+ assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM pairing_rate WHERE key = ?").get(`setup:ip:${ip}`)!.n, 0);
104
+ for (let i = 0; i < P.LIMITS.setupPerIp; i++) assert.equal((await s.call("GET", path, { ip })).status, 200);
105
+ const blocked = await s.call("HEAD", path, { ip });
106
+ assert.equal(blocked.status, 429);
107
+ assert.equal(blocked.text, "");
108
+ assert.equal((s.DB.db.prepare("SELECT count FROM pairing_rate WHERE key = ?").get(`setup:ip:${ip}`) as { count: number }).count, P.LIMITS.setupPerIp);
109
+ assert.equal((await s.call("GET", path, { ip })).status, 429);
110
+
111
+ const d = await s.start(undefined, "192.0.2.21");
112
+ const look = `/device?user_code=${d.data.user_code}`;
113
+ for (let i = 0; i < P.LIMITS.lookupsPerIp; i++) {
114
+ const h = await s.call("HEAD", look, { ip: "192.0.2.21" });
115
+ assert.equal(h.status, 200);
116
+ assert.equal(h.text, "");
117
+ }
118
+ const page = await s.call("GET", look, { ip: "192.0.2.21" });
119
+ assert.equal(page.status, 200);
120
+ assert.match(page.text, /Barry Bot/);
121
+ assert.equal(page.text.includes("Too many code lookups"), false);
122
+ });
123
+
65
124
  test("pairing helpers: user codes, labels, requester text, params, password hashing", async () => {
66
125
  for (let i = 0; i < 200; i++) assert.match(P.newUserCode(), /^[BCDFGHJKLMNPQRSTVWXZ]{4}[2-9]{4}$/);
67
126
  assert.equal(P.formatUserCode("WDJB4827"), "WDJB-4827");
@@ -753,4 +812,10 @@ test("owner log lists history without data_json and does not clear a pending wak
753
812
  const inbox = await s.owner("GET", "/owner/inbox");
754
813
  assert.equal(inbox.status, 200);
755
814
  assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, null);
815
+ const again = JSON.stringify({ taskIds: [taskId] });
816
+ s.DB.db.prepare("UPDATE wakes SET pending_json = ?, pending_since_ms = ? WHERE context_id = ?").run(again, Date.now(), "ctx-keep");
817
+ const head = await s.owner("HEAD", "/owner/inbox");
818
+ assert.equal(head.status, 200);
819
+ assert.equal(head.text, "");
820
+ assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, again);
756
821
  });