a2a-exposed 0.7.11 → 0.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ // Wake secrets sealed for one hosted tenant. The control plane writes `secrets_enc`; the tenant
2
+ // object decrypts it when it builds the tenant context. Self-host does not use this.
3
+ // Keep control/src/tenants/wake-secrets.ts in step with this file.
4
+
5
+ const INFO = "tenant-wake-secrets v1";
6
+ const SALT = "a2a-exposed";
7
+
8
+ /** Secret wake fields. Anything else in the blob is ignored. */
9
+ export const WAKE_SECRET_KEYS = [
10
+ "WAKE_WEBHOOK_KEY",
11
+ "WAKE_HMAC_SECRET",
12
+ "WAKE_ACCESS_CLIENT_ID",
13
+ "WAKE_ACCESS_CLIENT_SECRET",
14
+ ] as const;
15
+
16
+ export type WakeSecretKey = (typeof WAKE_SECRET_KEYS)[number];
17
+ export type WakeSecrets = Partial<Record<WakeSecretKey, string>>;
18
+
19
+ /** One secret. The form uses the same cap. */
20
+ export const MAX_WAKE_VALUE = 4096;
21
+
22
+ /**
23
+ * Largest JSON payload `sealWakeSecrets` will write. A character can become `\u0000` (six characters),
24
+ * and every known field may be present. `openWakeSecrets` accepts this and nothing larger.
25
+ */
26
+ export const MAX_WAKE_PLAIN = (() => {
27
+ let n = 2;
28
+ for (let i = 0; i < WAKE_SECRET_KEYS.length; i++) {
29
+ if (i) n += 1;
30
+ n += WAKE_SECRET_KEYS[i].length + 5 + MAX_WAKE_VALUE * 6;
31
+ }
32
+ return n;
33
+ })();
34
+
35
+ /** `v1.` + base64(12-byte iv) + `.` + base64(utf-8 plaintext + 16-byte tag). */
36
+ export const MAX_WAKE_SEALED = 3 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
37
+
38
+ function b64(bytes: Uint8Array): string {
39
+ let raw = "";
40
+ for (let i = 0; i < bytes.length; i += 0x8000) raw += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
41
+ return btoa(raw);
42
+ }
43
+ const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
44
+
45
+ async function derive(ikm: string, tenantId: string): Promise<CryptoKey | null> {
46
+ if (!ikm || !tenantId) return null;
47
+ const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(ikm), "HKDF", false, ["deriveKey"]);
48
+ return crypto.subtle.deriveKey(
49
+ { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(SALT), info: new TextEncoder().encode(`${INFO}\0${tenantId}`) },
50
+ base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
51
+ );
52
+ }
53
+
54
+ function payloadOf(secrets: WakeSecrets): Record<string, string> | null {
55
+ const payload: Record<string, string> = {};
56
+ for (const key of WAKE_SECRET_KEYS) {
57
+ const value = secrets[key];
58
+ if (value == null || value === "") continue;
59
+ if (typeof value !== "string" || value.length > MAX_WAKE_VALUE) return null;
60
+ payload[key] = value;
61
+ }
62
+ return payload;
63
+ }
64
+
65
+ /** `v1.<iv>.<ciphertext>`, or null when the key or a value is unusable. */
66
+ export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: WakeSecrets): Promise<string | null> {
67
+ const key = await derive(ikm, tenantId);
68
+ const payload = payloadOf(secrets);
69
+ if (!key || !payload) return null;
70
+ const json = JSON.stringify(payload);
71
+ const encoded = new TextEncoder().encode(json);
72
+ if (json.length > MAX_WAKE_PLAIN || encoded.length > MAX_WAKE_PLAIN * 3) return null;
73
+ const iv = crypto.getRandomValues(new Uint8Array(12));
74
+ const ct = new Uint8Array(await crypto.subtle.encrypt(
75
+ { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(tenantId) },
76
+ key,
77
+ encoded,
78
+ ));
79
+ const sealed = `v1.${b64(iv)}.${b64(ct)}`;
80
+ return sealed.length <= MAX_WAKE_SEALED ? sealed : null;
81
+ }
82
+
83
+ /**
84
+ * The sealed fields, `{}` when there is nothing to open, or null when the blob cannot be read.
85
+ * A null result must not be treated as "no secrets": the caller keeps the previous ciphertext.
86
+ */
87
+ export async function openWakeSecrets(ikm: string, tenantId: string, sealed: string | null | undefined): Promise<WakeSecrets | null> {
88
+ if (sealed == null || sealed === "") return {};
89
+ if (sealed.length > MAX_WAKE_SEALED) return null;
90
+ const key = await derive(ikm, tenantId);
91
+ if (!key) return null;
92
+ const [version, iv, ct, extra] = sealed.split(".");
93
+ if (version !== "v1" || !iv || !ct || extra !== undefined) return null;
94
+ try {
95
+ const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
96
+ { name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(tenantId) },
97
+ key,
98
+ unb64(ct),
99
+ ));
100
+ if (plain.length > MAX_WAKE_PLAIN) return null;
101
+ const parsed = JSON.parse(plain) as unknown;
102
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
103
+ const out: WakeSecrets = {};
104
+ for (const name of WAKE_SECRET_KEYS) {
105
+ const value = (parsed as Record<string, unknown>)[name];
106
+ if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
107
+ out[name] = value;
108
+ }
109
+ return out;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
@@ -1,7 +1,14 @@
1
1
  // Wake URL checks for WAKE_TARGET_POLICY=public-https. Unset stays the previous fetch
2
2
  // (any URL the operator configured, redirects followed by the platform).
3
+ //
4
+ // The TCP connection is to an address from the DNS check. TLS is not the runtime's
5
+ // startTls({ expectedServerHostname }): on the Workers edge that sends no SNI when the
6
+ // socket was opened to an IP (cloudflare/workerd#6903), so the peer aborts before HTTP.
7
+ // The handshake runs in-process, with the URL hostname as SNI, over that pinned socket.
8
+ // A socket still cannot open a Cloudflare address, so a *.workers.dev target does not connect.
9
+ import { CertificateError, Client, TlsError } from "tunnelfetch";
3
10
  import { isPrivateHost } from "./a2a.ts";
4
- import { isPublicIp, net, parseHttpResponse, type PinnedSocket } from "./mcp.ts";
11
+ import { isPublicIp } from "./mcp.ts";
5
12
 
6
13
  /** DNS-over-HTTPS resolver. Same endpoint the client-metadata fetch uses. */
7
14
  export const DOH_URL = "https://cloudflare-dns.com/dns-query";
@@ -9,14 +16,33 @@ export const DOH_URL = "https://cloudflare-dns.com/dns-query";
9
16
  /** Original request plus this many redirects. Each hop is checked before it is requested. */
10
17
  const MAX_REDIRECTS = 3;
11
18
 
19
+ const REQUEST_TIMEOUT_MS = 15_000;
20
+
12
21
  /** Host to addresses. `null` means the lookup failed and the caller must fail closed. */
13
22
  export type AddressLookup = (host: string) => Promise<string[] | null>;
14
23
 
15
- type Connect = NonNullable<typeof net.connect>;
24
+ type WakeSocket = {
25
+ readable: ReadableStream<Uint8Array>;
26
+ writable: WritableStream<Uint8Array>;
27
+ opened?: Promise<{ remoteAddress?: string | null }>;
28
+ close?: () => Promise<void>;
29
+ };
30
+
31
+ /** Raw TCP. Production uses `cloudflare:sockets`. Tests replace it. */
32
+ export type WakeConnect = (address: { hostname: string; port: number }, options?: { secureTransport?: "off" | "on" | "starttls"; allowHalfOpen?: boolean }) => WakeSocket;
33
+
34
+ /** One checked hop. `ip` is the address the socket must use. `hostname` is the TLS name. */
35
+ export type CheckedDeliver = (url: string, init: RequestInit, target: { ip: string; hostname: string }) => Promise<Response>;
36
+
37
+ export const wakeNet: { connect: null | WakeConnect; deliver: null | CheckedDeliver } = { connect: null, deliver: null };
16
38
 
17
39
  export type WakeDeps = {
18
40
  lookup?: AddressLookup;
19
- connect?: Connect;
41
+ connect?: WakeConnect;
42
+ deliver?: CheckedDeliver;
43
+ /** Deliver a URL on this host in-process. Used for a wake aimed at this inbox. */
44
+ localHost?: string;
45
+ local?: (url: string, init: RequestInit) => Promise<Response>;
20
46
  };
21
47
 
22
48
  function bareHost(host: string): string {
@@ -69,8 +95,8 @@ export async function publicWakeProblem(url: string, lookup: AddressLookup = loo
69
95
  return "refused" in checked ? checked.refused : "";
70
96
  }
71
97
 
72
- /** The address the next request must use. A name is resolved once; the returned IP is from that answer. */
73
- export async function checkedWakeAddress(url: string, lookup: AddressLookup = lookupAddresses): Promise<{ ip: string } | { refused: string }> {
98
+ /** Every public address from one lookup. The socket uses the first. The TLS name is the URL host. */
99
+ async function checkedWakeAddresses(url: string, lookup: AddressLookup): Promise<{ hostname: string; ips: string[] } | { refused: string }> {
74
100
  let parsed: URL;
75
101
  try { parsed = new URL(url); } catch { return { refused: "not a URL" }; }
76
102
  if (parsed.protocol !== "https:") return { refused: "must be https" };
@@ -78,79 +104,99 @@ export async function checkedWakeAddress(url: string, lookup: AddressLookup = lo
78
104
  const host = parsed.hostname;
79
105
  if (!host || isPrivateHost(host)) return { refused: "private or internal host" };
80
106
  const bare = bareHost(host);
81
- if (isIpLiteral(host)) return isPublicIp(bare) ? { ip: bare } : { refused: "private or reserved address" };
107
+ if (isIpLiteral(host)) return isPublicIp(bare) ? { hostname: bare, ips: [bare] } : { refused: "private or reserved address" };
82
108
  let ips: string[] | null;
83
109
  try { ips = await lookup(host); } catch { return { refused: "its address could not be checked" }; }
84
110
  if (ips == null) return { refused: "its address could not be checked" };
85
111
  if (!ips.length) return { refused: "the host has no address" };
86
112
  if (!ips.every((ip) => isPublicIp(ip))) return { refused: "resolves to a private or reserved address" };
87
- return { ip: ips[0] };
113
+ return { hostname: host, ips };
88
114
  }
89
115
 
90
- function headerEntries(headers: HeadersInit | undefined): [string, string][] {
91
- const out: [string, string][] = [];
92
- const list = new Headers(headers);
93
- list.forEach((value, key) => out.push([key, value]));
94
- return out;
116
+ /** The address the next request must use. A name is resolved once; the returned IP is from that answer. */
117
+ export async function checkedWakeAddress(url: string, lookup: AddressLookup = lookupAddresses): Promise<{ ip: string } | { refused: string }> {
118
+ const checked = await checkedWakeAddresses(url, lookup);
119
+ if ("refused" in checked) return checked;
120
+ return { ip: checked.ips[0]! };
95
121
  }
96
122
 
97
- function requestBytes(method: string, url: URL, headers: HeadersInit | undefined, body: Uint8Array): Uint8Array {
98
- const lines = [`${method} ${url.pathname}${url.search} HTTP/1.1`, `host: ${url.host}`];
99
- for (const [key, value] of headerEntries(headers)) {
100
- if (key === "host" || key === "content-length" || key === "connection" || key === "transfer-encoding") continue;
101
- if (/[\r\n]/.test(key) || /[\r\n]/.test(value)) throw new Error("invalid header");
102
- lines.push(`${key}: ${value}`);
103
- }
104
- if (body.byteLength) lines.push(`content-length: ${body.byteLength}`);
105
- lines.push("connection: close");
106
- const head = new TextEncoder().encode(lines.join("\r\n") + "\r\n\r\n");
107
- const raw = new Uint8Array(head.byteLength + body.byteLength);
108
- raw.set(head);
109
- raw.set(body, head.byteLength);
110
- return raw;
123
+ /**
124
+ * Dial `ip`, ignoring the hostname the TLS stack passes in. The handshake names that hostname
125
+ * itself; this function only chooses the TCP address.
126
+ */
127
+ export function dialChecked(ip: string, connect: WakeConnect): WakeConnect {
128
+ return (addr) => connect({ hostname: ip, port: addr.port }, { secureTransport: "off", allowHalfOpen: false });
111
129
  }
112
130
 
113
- async function readSocket(sock: PinnedSocket, cap: number): Promise<Uint8Array> {
114
- const reader = sock.readable.getReader();
115
- const chunks: Uint8Array[] = [];
116
- let n = 0;
117
- for (;;) {
118
- const { done, value } = await reader.read();
119
- if (done) break;
120
- n += value.byteLength;
121
- if (n > cap) break;
122
- chunks.push(value);
123
- }
124
- const raw = new Uint8Array(Math.min(n, cap));
125
- let o = 0;
126
- for (const chunk of chunks) {
127
- const take = Math.min(chunk.byteLength, raw.byteLength - o);
128
- raw.set(chunk.subarray(0, take), o);
129
- o += take;
130
- }
131
- return raw;
131
+ async function runtimeConnect(): Promise<WakeConnect> {
132
+ if (wakeNet.connect) return wakeNet.connect;
133
+ const sockets = await import("cloudflare:sockets");
134
+ return sockets.connect as unknown as WakeConnect;
132
135
  }
133
136
 
137
+ /** Enough of an error body to recognise a Cloudflare status page. A success body is not kept. */
138
+ const HINT_BYTES = 4096;
139
+
134
140
  /**
135
- * HTTPS to `ip`, with TLS checked for the URL's hostname. The platform does not resolve the name again.
141
+ * HTTPS to the checked IP, with SNI and certificate verification for the URL host.
142
+ * `forceTunnel` keeps the platform from fetching the hostname again.
143
+ * The body is copied before the client closes, because closing drops the socket.
136
144
  */
137
- async function pinnedRequest(ip: string, url: string, method: string, headers: HeadersInit | undefined, body: Uint8Array, connect: Connect): Promise<Response> {
138
- const parsed = new URL(url);
139
- const sock = connect({ hostname: ip, port: Number(parsed.port || 443) }, { secureTransport: "starttls" }).startTls({ expectedServerHostname: parsed.hostname });
145
+ async function deliverPinned(url: string, init: RequestInit, target: { ip: string; hostname: string }, connect: WakeConnect): Promise<Response> {
146
+ const client = new Client({
147
+ connect: dialChecked(target.ip, connect),
148
+ forceTunnel: true,
149
+ maxRedirects: 0,
150
+ maxBodyBytes: Infinity,
151
+ http2: false,
152
+ keepAlive: false,
153
+ timeouts: { totalMs: REQUEST_TIMEOUT_MS },
154
+ });
140
155
  try {
141
- const writer = sock.writable.getWriter();
142
- await writer.write(requestBytes(method, parsed, headers, body));
143
- writer.releaseLock();
144
- const raw = await readSocket(sock, 65536);
145
- const message = parseHttpResponse(raw);
146
- if (typeof message === "string") throw new Error(message);
147
- const responseHeaders = new Headers();
148
- for (const [key, value] of Object.entries(message.headers)) responseHeaders.set(key, value);
149
- const payload = message.status === 204 || message.status === 304 ? null : message.body;
150
- return new Response(payload, { status: message.status, headers: responseHeaders });
156
+ const res = await client.fetch(url, {
157
+ method: init.method,
158
+ headers: init.headers,
159
+ body: init.body,
160
+ redirect: "manual",
161
+ });
162
+ return await responseForCaller(res);
163
+ } catch (e) {
164
+ if (e instanceof TlsError || e instanceof CertificateError) throw new Error("TLS Handshake Failed.");
165
+ throw e;
151
166
  } finally {
152
- await sock.close().catch(() => {});
167
+ await client.close().catch(() => {});
168
+ }
169
+ }
170
+
171
+ /** Status and headers, plus the start of an error body. The live socket is not returned. */
172
+ async function responseForCaller(res: Response): Promise<Response> {
173
+ const headers = new Headers(res.headers);
174
+ if (res.ok || !res.body) {
175
+ await res.body?.cancel().catch(() => {});
176
+ return new Response(null, { status: res.status, headers });
177
+ }
178
+ const reader = res.body.getReader();
179
+ const chunks: Uint8Array[] = [];
180
+ let total = 0;
181
+ try {
182
+ while (total < HINT_BYTES) {
183
+ const next = await reader.read();
184
+ if (next.done || !next.value) break;
185
+ chunks.push(next.value);
186
+ total += next.value.byteLength;
187
+ }
188
+ } finally {
189
+ await reader.cancel().catch(() => {});
190
+ }
191
+ const merged = new Uint8Array(Math.min(total, HINT_BYTES));
192
+ let offset = 0;
193
+ for (const chunk of chunks) {
194
+ const take = Math.min(chunk.byteLength, merged.byteLength - offset);
195
+ if (take <= 0) break;
196
+ merged.set(chunk.subarray(0, take), offset);
197
+ offset += take;
153
198
  }
199
+ return new Response(merged, { status: res.status, headers });
154
200
  }
155
201
 
156
202
  function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
@@ -161,8 +207,9 @@ function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
161
207
  }
162
208
 
163
209
  /**
164
- * POST (or whatever `init` says) to `url` on the address that passed the public-https check.
165
- * A redirect is followed only when it stays on the same origin, and that hop is checked and pinned again.
210
+ * POST (or whatever `init` says) to `url` after the public-https check.
211
+ * The socket opens to an address from that check. TLS uses the hostname.
212
+ * A redirect is followed only when it stays on the same origin, and that hop is checked again.
166
213
  * A cross-origin redirect is refused, so wake credentials are not sent to another host.
167
214
  * 301, 302, and 303 drop the body. 307 and 308 keep it.
168
215
  */
@@ -172,24 +219,31 @@ export async function fetchCheckedWake(
172
219
  deps: WakeDeps = {},
173
220
  ): Promise<{ response: Response } | { refused: string }> {
174
221
  const lookup = deps.lookup ?? lookupAddresses;
175
- const connect = async (): Promise<Connect> => {
176
- if (deps.connect) return deps.connect;
177
- if (net.connect) return net.connect;
178
- return (await import("cloudflare:sockets")).connect as unknown as Connect;
179
- };
180
222
  let current = url;
181
223
  let method = (init.method || "GET").toUpperCase();
182
224
  let body = bodyBytes(method === "GET" || method === "HEAD" ? undefined : init.body);
183
225
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
184
- const checked = await checkedWakeAddress(current, lookup);
226
+ const checked = await checkedWakeAddresses(current, lookup);
185
227
  if ("refused" in checked) return checked;
186
- const res = await pinnedRequest(checked.ip, current, method, init.headers, method === "GET" || method === "HEAD" ? new Uint8Array() : body, await connect());
228
+ const nextInit: RequestInit = {
229
+ method,
230
+ headers: init.headers,
231
+ body: method === "GET" || method === "HEAD" || body.byteLength === 0 ? undefined : body,
232
+ redirect: "manual",
233
+ };
234
+ const target = { ip: checked.ips[0]!, hostname: checked.hostname };
235
+ let res: Response;
236
+ if (deps.local && deps.localHost && new URL(current).host === deps.localHost) res = await deps.local(current, nextInit);
237
+ else if (deps.deliver) res = await deps.deliver(current, nextInit, target);
238
+ else if (wakeNet.deliver) res = await wakeNet.deliver(current, nextInit, target);
239
+ else res = await deliverPinned(current, nextInit, target, deps.connect ?? wakeNet.connect ?? await runtimeConnect());
187
240
  const location = res.headers.get("location");
188
241
  if (res.status < 300 || res.status >= 400 || !location) return { response: res };
242
+ await res.body?.cancel().catch(() => {});
189
243
  if (hop === MAX_REDIRECTS) return { refused: "too many redirects" };
190
244
  let next: URL;
191
245
  try { next = new URL(location, current); } catch { return { refused: "not a URL" }; }
192
- const ahead = await checkedWakeAddress(next.href, lookup);
246
+ const ahead = await checkedWakeAddresses(next.href, lookup);
193
247
  if ("refused" in ahead) return ahead;
194
248
  if (next.origin !== new URL(current).origin) return { refused: "cross-origin redirect" };
195
249
  if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== "GET" && method !== "HEAD")) {
@@ -51,7 +51,10 @@ export function defaultDebounceSeconds(preset: string): number {
51
51
  return preset === "claude-code" ? 20 : 10;
52
52
  }
53
53
 
54
- /** Default hourly wake cap per preset (0 = unlimited). Claude Code routines allow 30 fires/hour. */
54
+ /**
55
+ * Default hourly wake cap per preset. 0 means unlimited. Claude Code routines allow 30 fires/hour,
56
+ * so that preset defaults to 25 only when WAKE_MAX_PER_HOUR is unset. An explicit 0 stays unlimited.
57
+ */
55
58
  export function defaultMaxPerHour(preset: string): number {
56
59
  return preset === "claude-code" ? 25 : 0;
57
60
  }
@@ -203,7 +206,11 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
203
206
  break;
204
207
  case "generic":
205
208
  default: {
206
- if (cfg.key) headers[(cfg.keyHeader || "authorization").toLowerCase()] = (cfg.keyPrefix ?? "Bearer ") + cfg.key;
209
+ // A header the preview shows in full must not carry the key.
210
+ if (cfg.key) {
211
+ const requested = (cfg.keyHeader || "authorization").toLowerCase();
212
+ headers[previewHeaderPlain(requested) ? "authorization" : requested] = (cfg.keyPrefix ?? "Bearer ") + cfg.key;
213
+ }
207
214
  headers["x-request-id"] = requestId;
208
215
  body = cfg.bodyTemplate ? renderTemplate(cfg.bodyTemplate, ev, cli) : JSON.stringify(wakePayload(ev, cli));
209
216
  if (cfg.hmacSecret) await hermesSign(headers, cfg.hmacSecret, body, nowMs);
@@ -243,11 +250,30 @@ export function cloudflareErrorHint(status: number, body: string): string {
243
250
  return "";
244
251
  }
245
252
 
253
+ /** Headers a wake preview may show in full. Every other header carries a credential, whatever its name. */
254
+ export const PREVIEW_PLAIN_HEADERS = new Set([
255
+ "content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp",
256
+ ]);
257
+
258
+ export function previewHeaderPlain(name: string): boolean {
259
+ return PREVIEW_PLAIN_HEADERS.has(name.toLowerCase());
260
+ }
261
+
262
+ /** A credential header safe to show. No character of the secret is kept. */
263
+ export function maskedCredential(value: string): string {
264
+ return /^bearer\s/i.test(value) ? "Bearer ••••" : "••••";
265
+ }
266
+
267
+ /** True only for the exact value `maskedCredential` returns. A prefix plus an ellipsis is still a secret. */
268
+ export function credentialIsMasked(value: string): boolean {
269
+ return value === "••••" || value === "Bearer ••••";
270
+ }
271
+
246
272
  /** Copy of a rendered request with credentials masked (for previews/logs). */
247
273
  export function redact(req: WakeRequest): WakeRequest {
248
274
  const h: Record<string, string> = {};
249
275
  for (const [k, v] of Object.entries(req.headers))
250
- h[k] = /authorization|key|token|secret|signature|access-client-id/i.test(k) && !/^idempotency-key$/i.test(k) ? v.replace(/(Bearer\s+)?(.{0,4}).*/s, "$1$2…") : v;
276
+ h[k] = previewHeaderPlain(k) ? v : maskedCredential(v);
251
277
  let url = req.url;
252
278
  try { const u = new URL(req.url); url = `${u.protocol}//${u.host}${u.pathname.replace(/\/[^/]{16,}/g, "/…")}`; } catch { /* keep */ }
253
279
  return { ...req, url, headers: h };
@@ -41,6 +41,23 @@ test("nextCronAt is the earliest wake, rate row, or expiry", async () => {
41
41
  assert.equal(await nextCronAt(tenant, now), now + 801);
42
42
  });
43
43
 
44
+ test("zero and an unset generic cap stay unlimited; an unset Claude cap is 25", async () => {
45
+ const now = 1_700_000_000_000;
46
+ const hour = Math.floor(now / 3600000);
47
+ const due = now - 60_000;
48
+ const open = due + 10_000;
49
+ async function arm(over: Record<string, string>, count: number) {
50
+ const { DB, tenant } = ctx(over);
51
+ await DB.prepare("INSERT INTO wake_budget (hour, count) VALUES (?, ?)").bind(hour, count).run();
52
+ await DB.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES ('c', ?, '{}', ?)").bind(due, now).run();
53
+ return nextCronAt(tenant, now);
54
+ }
55
+ assert.equal(await arm({ WAKE_MAX_PER_HOUR: "0" }, 100), open);
56
+ assert.equal(await arm({ WAKE_MAX_PER_HOUR: "" }, 100), open);
57
+ assert.equal(await arm({ WAKE_PRESET: "claude-code", WAKE_MAX_PER_HOUR: "0" }, 100), open);
58
+ assert.equal(await arm({ WAKE_PRESET: "claude-code", WAKE_MAX_PER_HOUR: "" }, 25), (hour + 1) * 3600000);
59
+ });
60
+
44
61
  test("an hourly cap holds an already-due wake until the next hour", async () => {
45
62
  const now = 1_700_000_000_000;
46
63
  const hour = Math.floor(now / 3600000);