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.
- package/lib/main.mjs +5 -2
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/package.json +3 -0
- package/worker/src/hosted.ts +46 -8
- package/worker/src/index.ts +92 -8
- package/worker/src/owner-rpc.ts +211 -0
- package/worker/src/tenancy.ts +3 -2
- package/worker/src/wake-secrets.ts +113 -0
- package/worker/src/wake-target.ts +124 -70
- package/worker/src/wake.ts +29 -3
- package/worker/test/alarms.test.ts +17 -0
- package/worker/test/hosted.test.ts +228 -0
- package/worker/test/owner-rpc.test.ts +107 -0
- package/worker/test/pairing.test.ts +43 -2
- package/worker/test/wake-secrets.test.ts +49 -0
- package/worker/test/wake-target.test.ts +163 -56
- package/worker/test/wake.test.ts +20 -2
|
@@ -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
|
|
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
|
|
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?:
|
|
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
|
-
/**
|
|
73
|
-
|
|
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) ? {
|
|
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 {
|
|
113
|
+
return { hostname: host, ips };
|
|
88
114
|
}
|
|
89
115
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
return
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
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
|
|
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
|
|
138
|
-
const
|
|
139
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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`
|
|
165
|
-
*
|
|
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
|
|
226
|
+
const checked = await checkedWakeAddresses(current, lookup);
|
|
185
227
|
if ("refused" in checked) return checked;
|
|
186
|
-
const
|
|
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
|
|
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")) {
|
package/worker/src/wake.ts
CHANGED
|
@@ -51,7 +51,10 @@ export function defaultDebounceSeconds(preset: string): number {
|
|
|
51
51
|
return preset === "claude-code" ? 20 : 10;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
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
|
-
|
|
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] =
|
|
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);
|