a2a-exposed 0.7.14 → 0.7.16

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,7 +191,8 @@ 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.
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.
195
196
  That page is not stored.
196
197
  FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
197
198
  Unset, there is no limit. The database rejects another insert.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
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, {
@@ -100,7 +100,7 @@ const OMIT_COLUMNS: Record<string, readonly string[]> = {
100
100
  outbound: ["push_token_hash"],
101
101
  mcp_grants: ["access_hash", "refresh_hash"],
102
102
  outbound_peers: ["token_enc"],
103
- device_requests: ["replaces_hash"],
103
+ device_requests: ["replaces_hash", "ip", "country"],
104
104
  };
105
105
 
106
106
  const SETUP_SUB = /([?&])t=[A-Za-z0-9_-]{16,}/g;
@@ -120,7 +120,7 @@ function placeholder(table: string, row: Record<string, unknown>, index: number)
120
120
 
121
121
  /**
122
122
  * A copy of `file` safe to show on the dashboard.
123
- * Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace).
123
+ * Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace) and `device_requests.ip` and `device_requests.country` (the requester's address).
124
124
  * `peers.token_hash` and `device_requests.device_hash` are NOT NULL, so they become `redacted:…` (not the hash, and not a token that authenticates).
125
125
  * `oauth_codes` and `oidc_txns` are emptied. The approval-password row and the password-setup row (its value is the hash of the live setup token) are removed.
126
126
  * Token-shaped text, including a hosted owner token (`a2aot_`), is scrubbed. The input is not changed: the CLI dump stays complete.
@@ -269,6 +269,9 @@ export class TenantStore extends DurableObject<WorkerBindings> {
269
269
  async pushConfig(body: TenantConfigPush): Promise<{ version: number; applied: boolean }> {
270
270
  return await this.ctx.blockConcurrencyWhile(async () => {
271
271
  checkPush(body);
272
+ const deleted = await (this.ctx.storage as unknown as { get(key: string): Promise<unknown> }).get("tenant_deleted");
273
+ // deleteStorage leaves this key after wiping SQL. A later push must not recreate the agent.
274
+ if (deleted != null) return { version: 0, applied: false };
272
275
  const current = this.#row ?? readRow(this.#sql);
273
276
  if (current && body.version <= current.version) {
274
277
  // A previous push may have stored this version and then failed while migrating. Catch up; don't rewrite the row.
@@ -322,6 +325,30 @@ export class TenantStore extends DurableObject<WorkerBindings> {
322
325
  return { configured: this.#row !== null, version: this.#row?.version ?? 0, tables: appTables(this.#sql) };
323
326
  }
324
327
 
328
+ /**
329
+ * Drop this object's alarm and storage. A second call on an empty object succeeds.
330
+ * Not an owner HTTP route. Self-host never calls it.
331
+ */
332
+ async deleteStorage(): Promise<{ deleted: true }> {
333
+ return await this.ctx.blockConcurrencyWhile(async () => {
334
+ const storage = this.ctx.storage as unknown as {
335
+ deleteAlarm(): Promise<void>;
336
+ deleteAll(): Promise<void>;
337
+ put(key: string, value: string): Promise<void>;
338
+ };
339
+ const tenantId = this.#row?.tenantId ?? null;
340
+ await storage.deleteAlarm();
341
+ await storage.deleteAll();
342
+ const left = readRow(this.#sql);
343
+ if (left) throw new Error("deleteStorage: data remains");
344
+ // After SQL is gone. A later pushConfig sees this and does not recreate the agent.
345
+ await storage.put("tenant_deleted", "1");
346
+ this.#row = null;
347
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "tenant_storage_deleted", tenant: tenantId }));
348
+ return { deleted: true };
349
+ });
350
+ }
351
+
325
352
  /** SQL dump of the application tables. Refuses an object that has no config row. */
326
353
  async exportSql(): Promise<{ sql: string; exportedAt: string }> {
327
354
  return await this.ctx.blockConcurrencyWhile(async () => {
@@ -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);
@@ -88,7 +88,7 @@ test("redactExport drops credential material and leaves the full dump unchanged"
88
88
  await DB.prepare("INSERT INTO settings (key, value) VALUES ('approval_password', ?)").bind(JSON.stringify({ hash: "x".repeat(40) })).run();
89
89
  await DB.prepare("INSERT INTO settings (key, value) VALUES ('password_setup', ?)").bind(JSON.stringify({ hash: setupHash, createdMs: 1, expiresMs: 2, failures: 0 })).run();
90
90
  await DB.prepare("INSERT INTO settings (key, value) VALUES ('theme', 'kept')").run();
91
- await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada', ?)").bind(replaces).run();
91
+ await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash, ip, country) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada', ?, '203.0.113.50', 'XQ')").bind(replaces).run();
92
92
  await DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES ('code-hash', 'client', 'https://client.example/cb', 'challenge-secret-value', 'mcp', 'https://agent.example/mcp', 1, 2)").run();
93
93
  await DB.prepare("INSERT INTO mcp_grants (label, client_id, scope, access_hash, refresh_hash, created_at) VALUES ('mcp-claude', 'client', 'mcp', 'access-hash-secret', 'refresh-hash-secret', ?)").bind(now).run();
94
94
  await DB.prepare("INSERT INTO outbound_peers (alias, url, token_enc, created_at, updated_at) VALUES ('ada', 'https://ada.example', 'ciphertext-secret-value', ?, ?)").bind(now, now).run();
@@ -97,12 +97,16 @@ test("redactExport drops credential material and leaves the full dump unchanged"
97
97
  const redacted = redactExport(file);
98
98
  assert.equal(file.tables.peers?.[0]?.token_hash, hash, "the CLI dump still has the hash");
99
99
  assert.equal(file.tables.device_requests?.[0]?.replaces_hash, replaces);
100
+ assert.equal(file.tables.device_requests?.[0]?.ip, "203.0.113.50");
101
+ assert.equal(file.tables.device_requests?.[0]?.country, "XQ");
100
102
  assert.equal(file.tables.settings?.some((row) => row.key === "password_setup"), true);
101
103
  assert.equal(file.tables.oidc_txns?.[0]?.verifier, "verifier-secret-value");
102
104
  assert.equal(redacted.tables.peers?.[0]?.token_hash, "redacted:ada");
103
105
  assert.equal(redacted.tables.peers?.[0]?.label, "ada");
104
106
  assert.equal(redacted.tables.device_requests?.[0]?.device_hash, "redacted:WDJB4827");
105
107
  assert.equal(redacted.tables.device_requests?.[0]?.replaces_hash, undefined);
108
+ assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "ip"), false);
109
+ assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "country"), false);
106
110
  assert.equal(redacted.tables.device_requests?.[0]?.replaces_label, "ada");
107
111
  assert.equal(redacted.tables.device_requests?.[0]?.user_code, "WDJB4827");
108
112
  assert.equal(redacted.tables.tasks?.[0]?.push_configs_json, undefined);
@@ -130,6 +134,8 @@ test("redactExport drops credential material and leaves the full dump unchanged"
130
134
  assert.equal(sql.includes("oauth_codes"), false);
131
135
  assert.equal(sql.includes("oidc_txns"), false);
132
136
  assert.equal(sql.includes("replaces_hash"), false);
137
+ assert.equal(sql.includes("203.0.113.50"), false);
138
+ assert.equal(sql.includes("XQ"), false);
133
139
  assert.equal(sql.includes("password_setup"), false);
134
140
  assert.equal(ownerResponseLeaks({ sql }), false);
135
141
  for (const secret of [token, owner, hash, replaces, setupHash]) {
@@ -138,6 +144,8 @@ test("redactExport drops credential material and leaves the full dump unchanged"
138
144
  assert.match(toSql(file), /verifier-secret-value/);
139
145
  assert.match(toSql(file), new RegExp(hash));
140
146
  assert.match(toSql(file), /replaces_hash/);
147
+ assert.match(toSql(file), /203\.0\.113\.50/);
148
+ assert.match(toSql(file), /'XQ'/);
141
149
  });
142
150
 
143
151
  test("scrubSecretText redacts a hosted owner token on the log and export paths", () => {
@@ -173,19 +181,36 @@ test("redactExport removes device_requests.replaces_hash", () => {
173
181
  status: "pending",
174
182
  replaces_label: "ada",
175
183
  replaces_hash: replaces,
184
+ ip: "203.0.113.50",
185
+ country: "XQ",
176
186
  }],
177
187
  },
178
188
  });
179
189
  const row = redacted.tables.device_requests?.[0] ?? {};
180
190
  assert.equal(Object.hasOwn(row, "replaces_hash"), false);
191
+ assert.equal(Object.hasOwn(row, "ip"), false);
192
+ assert.equal(Object.hasOwn(row, "country"), false);
181
193
  assert.equal(row.replaces_label, "ada");
182
194
  assert.equal(row.device_hash, "redacted:WDJB4827");
183
195
  const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
184
196
  assert.equal(sql.includes("replaces_hash"), false);
197
+ assert.equal(sql.includes("203.0.113.50"), false);
185
198
  assert.equal(sql.includes(replaces), false);
186
199
  assert.equal(JSON.stringify(redacted).includes(replaces), false);
187
200
  });
188
201
 
202
+ test("self-host owner HTTP has no delete route", async () => {
203
+ const DB = db();
204
+ const env = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret" };
205
+ const res = await worker.fetch(new Request(BASE + "/owner/delete", {
206
+ method: "POST",
207
+ headers: { authorization: "Bearer owner-secret" },
208
+ }) as never, env as never, ectx as never);
209
+ assert.equal(res.status, 404);
210
+ const tables = await DB.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'peers'").all<{ name: string }>();
211
+ assert.equal(tables.results.length, 1);
212
+ });
213
+
189
214
  test("exportRows reads every application table in one batch", async () => {
190
215
  const DB = db();
191
216
  await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', 'h', 't')").run();
@@ -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");
@@ -750,3 +761,58 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
750
761
  assert.equal(await saw('"reason":"account"'), true);
751
762
  assert.equal(lines.some((l) => l.includes("../acct")), false);
752
763
  });
764
+
765
+ test("TenantStore.deleteStorage wipes the object and a second call succeeds", async (t) => {
766
+ const mf = await start({
767
+ bindings: {
768
+ TENANCY: "host",
769
+ TENANT_DOMAIN: "example.com",
770
+ OWNER_TOKEN: "platform-owner",
771
+ },
772
+ kvNamespaces: { TENANT_DIRECTORY: "tenants-delete" },
773
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
774
+ });
775
+ t.after(() => mf.dispose());
776
+ await mf.ready;
777
+ const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
778
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO");
779
+ const hash = await sha256("owner-delta");
780
+ type Stub = {
781
+ pushConfig(body: Record<string, unknown>): Promise<{ version: number; applied: boolean }>;
782
+ deleteStorage(): Promise<{ deleted: boolean }>;
783
+ storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
784
+ };
785
+ const stub = ns.get(ns.idFromName("id-delta")) as unknown as Stub;
786
+ await stub.pushConfig({
787
+ version: 1, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
788
+ config: { AGENT_NAME: "delta" },
789
+ });
790
+ await kv.put("tenant:delta", JSON.stringify({ id: "id-delta", status: "active", region: "default", version: 1 }));
791
+ const before = await stub.storageStatus();
792
+ assert.equal(before.configured, true);
793
+ assert.ok(before.tables.includes("tenant_config"));
794
+ const http = await mf.dispatchFetch("https://delta.example.com/owner/delete", {
795
+ method: "POST",
796
+ headers: { authorization: "Bearer owner-delta" },
797
+ });
798
+ assert.equal(http.status, 404);
799
+ assert.equal((await stub.storageStatus()).configured, true, "the owner HTTP API does not wipe storage");
800
+ const wiped = await stub.deleteStorage();
801
+ assert.equal(wiped.deleted, true);
802
+ const after = await stub.storageStatus();
803
+ assert.equal(after.configured, false);
804
+ assert.deepEqual(after.tables, []);
805
+ const again = await stub.deleteStorage();
806
+ assert.equal(again.deleted, true);
807
+ assert.equal((await stub.storageStatus()).configured, false);
808
+ assert.deepEqual((await stub.storageStatus()).tables, []);
809
+ const refused = await stub.pushConfig({
810
+ version: 2, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
811
+ config: { AGENT_NAME: "delta" },
812
+ });
813
+ assert.equal(refused.applied, false);
814
+ assert.equal(refused.version, 0);
815
+ const stayed = await stub.storageStatus();
816
+ assert.equal(stayed.configured, false);
817
+ assert.deepEqual(stayed.tables, []);
818
+ });
@@ -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
  });