@specific.dev/spectest 0.115.0 → 0.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ // Bidirectional forwarding with bounded opening buffers. Recording is an optional observer.
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import type WS from "ws";
5
+ import type { FakeWebSocket, FakeWebSocketHandlers } from "./fake-websocket.js";
6
+ import { FakeWebSocketLifetime, bindWebSocketLifetime } from "./harness/fake-websocket-lifetime.js";
7
+
8
+ // Bun's ws shim does not implement the external DNS lookup option.
9
+ const require = createRequire(import.meta.url);
10
+ const { WebSocket } = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
11
+ export const MAX_BUFFER = 16 * 1024 * 1024;
12
+ const MAX_PENDING = 1024;
13
+
14
+ export function closeSocket(socket: FakeWebSocket | WS, code: number, reason: string): void {
15
+ // Reserved status codes describe transport closure and cannot go on the wire.
16
+ if (code === 1006 && socket.terminate) socket.terminate();
17
+ else if (code === 1005 || code === 1006) socket.close();
18
+ else socket.close(code, reason);
19
+ }
20
+
21
+ export interface WebSocketSession {
22
+ cancel(): void;
23
+ }
24
+
25
+ export async function forwardWebSocket(args: {
26
+ url: string;
27
+ ip: string;
28
+ host: string;
29
+ headers: Record<string, string>;
30
+ protocols: string[];
31
+ label: string;
32
+ lifetime?: FakeWebSocketLifetime;
33
+ finished(session: WebSocketSession, error?: Error): void;
34
+ observe?: {
35
+ open(protocol: string): void;
36
+ input(message: string | Uint8Array): void;
37
+ output(message: string | Uint8Array): void;
38
+ close(code: number, reason: string, initiator: "client" | "server"): void;
39
+ };
40
+ }): Promise<{ handlers: FakeWebSocketHandlers; session: WebSocketSession }> {
41
+ const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
42
+ if (lifetime.ended) throw new Error("WebSocket handshake aborted");
43
+ let detach = () => {};
44
+ const upstream = new WebSocket(args.url, args.protocols, {
45
+ headers: args.headers, maxPayload: MAX_BUFFER,
46
+ handshakeTimeout: 10_000,
47
+ lookup: ((_hostname: string, options: { all?: boolean }, callback: Function) => {
48
+ if (options.all) callback(null, [{ address: args.ip, family: 4 }]);
49
+ else callback(null, args.ip, 4);
50
+ }) as any,
51
+ });
52
+ let socket: FakeWebSocket | undefined;
53
+ let stopped = false;
54
+ let clientClosed = false;
55
+ let upstreamClosure: { code: number; reason: string } | undefined;
56
+ let queue: (string | Uint8Array)[] = [];
57
+ let queuedBytes = 0;
58
+ let openingTimer: ReturnType<typeof setTimeout> | undefined;
59
+ let opened = false;
60
+ let rejectOpen: (error: Error) => void = () => {};
61
+ const finish = (error?: Error) => {
62
+ if (stopped) return;
63
+ stopped = true;
64
+ clearTimeout(openingTimer);
65
+ detach();
66
+ queue = [];
67
+ upstream.terminate();
68
+ lifetime.end(error ? { kind: "failed", error } : { kind: "aborted" });
69
+ args.finished(session, error);
70
+ };
71
+ const fail = (error: unknown) => {
72
+ if (stopped) return;
73
+ const failure = new Error(`${args.label}: ${(error as Error).message}`);
74
+ rejectOpen(failure);
75
+ finish(failure);
76
+ socket?.close(1011, "WebSocket forwarding failed");
77
+ };
78
+ const session: WebSocketSession = {
79
+ cancel() { rejectOpen(new Error("WebSocket handshake aborted")); finish(); socket?.close(1001, "forwarding cleanup"); },
80
+ };
81
+ upstream.on("error", fail);
82
+ upstream.on("message", (data, binary) => {
83
+ if (stopped || clientClosed) return;
84
+ try {
85
+ const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
86
+ const message = binary ? new Uint8Array(bytes) : bytes.toString();
87
+ args.observe?.output(message);
88
+ if (socket) socket.send(message);
89
+ else {
90
+ queuedBytes += bytes.length;
91
+ if (queue.length >= MAX_PENDING || queuedBytes > MAX_BUFFER) throw new Error("WebSocket opening buffer exceeded");
92
+ queue.push(message);
93
+ }
94
+ } catch (error) { fail(error); }
95
+ });
96
+ upstream.on("close", (code, reason) => {
97
+ if (stopped) return;
98
+ if (!opened) { fail(new Error("upstream closed before handshake completed")); return; }
99
+ try {
100
+ if (!clientClosed) {
101
+ upstreamClosure = { code, reason: reason.toString() };
102
+ args.observe?.close(code, reason.toString(), "server");
103
+ }
104
+ if (socket) { closeSocket(socket, code, reason.toString()); finish(); }
105
+ // If the upstream closes before downstream open, open() flushes the queue.
106
+ } catch (error) { fail(error); }
107
+ });
108
+ const opening = new Promise<void>((resolve, reject) => {
109
+ rejectOpen = reject;
110
+ upstream.once("open", () => {
111
+ try {
112
+ opened = true;
113
+ args.observe?.open(upstream.protocol);
114
+ openingTimer = setTimeout(() => fail(new Error("downstream did not open")), 10_000);
115
+ resolve();
116
+ } catch (error) { fail(error); }
117
+ });
118
+ });
119
+ const handlers: FakeWebSocketHandlers = {
120
+ open(ws) {
121
+ socket = ws;
122
+ clearTimeout(openingTimer);
123
+ if (stopped) { ws.close(1011, "WebSocket forwarding failed"); return; }
124
+ try {
125
+ for (const message of queue) ws.send(message);
126
+ queue = []; queuedBytes = 0;
127
+ if (upstreamClosure) { closeSocket(ws, upstreamClosure.code, upstreamClosure.reason); finish(); }
128
+ } catch (error) { fail(error); }
129
+ },
130
+ message(_ws, message) {
131
+ if (stopped) return;
132
+ try {
133
+ args.observe?.input(message);
134
+ const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
135
+ if (size + upstream.bufferedAmount > MAX_BUFFER) throw new Error("WebSocket upstream buffer exceeded");
136
+ if (upstream.readyState !== WebSocket.OPEN) throw new Error("upstream is not open");
137
+ upstream.send(message);
138
+ } catch (error) { fail(error); throw error; }
139
+ },
140
+ close(_ws, code, reason) {
141
+ if (stopped || clientClosed) return;
142
+ try {
143
+ clientClosed = true;
144
+ args.observe?.close(code, reason, "client");
145
+ closeSocket(upstream, code, reason);
146
+ openingTimer = setTimeout(() => finish(), 1_000);
147
+ } catch (error) { fail(error); }
148
+ },
149
+ };
150
+ detach = lifetime.onEnd(event => {
151
+ if (event.kind === "closed" && socket) handlers.close!(socket, event.code, event.reason);
152
+ else session.cancel();
153
+ });
154
+ await opening;
155
+ handlers.protocol = upstream.protocol || undefined;
156
+ return { session, handlers: bindWebSocketLifetime(handlers, lifetime) };
157
+ }
@@ -0,0 +1,465 @@
1
+ // Shared upstream transport and credential brokering. No cassette state lives here.
2
+ import { createSocket } from "node:dgram";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
5
+ import * as dnsPacket from "dns-packet";
6
+ import { getRecordSecret } from "./record-secrets.js";
7
+ import { inferSigV4Scope, signAwsV4Request, type SigV4Credentials } from "./aws-sigv4.js";
8
+
9
+ /** Match one request dimension. A bare string is an exact match; the
10
+ * object forms cover prefix and regex matching. */
11
+ export type StringMatch =
12
+ | string
13
+ | { exact: string }
14
+ | { startsWith: string }
15
+ | { regex: string };
16
+
17
+ /** Request matchers for a credential-injection rule. A rule applies only
18
+ * when EVERY specified dimension matches; omit `match` to always apply. */
19
+ export interface InjectMatch {
20
+ /** Match the request path (no query). */
21
+ path?: StringMatch;
22
+ /** HTTP method(s) — any one matching satisfies it. */
23
+ method?: string | string[];
24
+ /** Query-param matchers (ANDed); each key's value(s) must match. */
25
+ query?: Record<string, StringMatch>;
26
+ /** Header matchers (ANDed; header names case-insensitive). */
27
+ headers?: Record<string, StringMatch>;
28
+ }
29
+
30
+ /** One credential-brokering rule. When it matches a request, these headers
31
+ * are SET on the egress forward — overwriting whatever the app sent (so app
32
+ * code can't smuggle a different value past the broker). Header VALUES may
33
+ * embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
34
+ * project's Secrets store and supplied for the environment lifetime. Values
35
+ * never enter project files, the warm-cache hash, or a cassette (redaction
36
+ * fails closed). */
37
+ export interface InjectRule {
38
+ match?: InjectMatch;
39
+ headers: Record<string, string>;
40
+ }
41
+
42
+ /** SigV4 request-signing broker. Unlike `inject` (which SETs a static header
43
+ * value), this RE-SIGNS the outbound request: the app signs with throwaway
44
+ * dummy credentials, we strip that signature and re-sign the exact outbound
45
+ * request with the real credentials — the only way to broker AWS auth, whose
46
+ * signature is a keyed HMAC over the whole request, not a static token.
47
+ *
48
+ * The three fields are **secret refs, resolved directly** from the project's
49
+ * Secrets store (NOT `{{secret:}}` templates), with the same lifetime and
50
+ * fail-closed redaction as `inject`. Region + service are inferred from the
51
+ * incoming request (its credential scope, else the host); the app never
52
+ * configures them. */
53
+ export interface AwsSigV4Sign {
54
+ type: "awsSigv4";
55
+ /** Secret ref for the AWS access key id. */
56
+ accessKeyId: string;
57
+ /** Secret ref for the AWS secret access key. */
58
+ secretAccessKey: string;
59
+ /** Optional secret ref for a session token (temporary credentials). */
60
+ sessionToken?: string;
61
+ }
62
+
63
+ export type SignConfig = AwsSigV4Sign;
64
+
65
+ /** One resolved secret value and the ref it came from (for the redaction
66
+ * placeholder). */
67
+ export interface ResolvedSecret {
68
+ ref: string;
69
+ value: string;
70
+ }
71
+
72
+ /** Hop-by-hop + body-framing headers never forwarded / never replayed
73
+ * (we decompress on the way through, so the encoding/length no longer
74
+ * describe the bytes the app receives). */
75
+ export const STRIP_FORWARD_HEADERS = new Set([
76
+ "host",
77
+ "connection",
78
+ "keep-alive",
79
+ "proxy-authenticate",
80
+ "proxy-authorization",
81
+ "te",
82
+ "trailers",
83
+ "transfer-encoding",
84
+ "upgrade",
85
+ "content-length",
86
+ "content-encoding",
87
+ // We force `accept-encoding: identity` on the forward and decompress any
88
+ // residual encoding, so the app never sees a stale accept-encoding.
89
+ "accept-encoding",
90
+ ]);
91
+
92
+ /** External resolver used to find the REAL upstream IP, bypassing the in-VM
93
+ * resolver (which points the faked host back at the daemon). */
94
+ const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
95
+ const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
96
+
97
+ /** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
98
+ * in the query for presigned URLs — they change every call and would poison
99
+ * query matching, so they're dropped from both the match key and the forward
100
+ * when signing is active. */
101
+ export function isAmzParam(key: string): boolean {
102
+ return key.toLowerCase().startsWith("x-amz-");
103
+ }
104
+
105
+ function stripAmzParams(search: URLSearchParams): URLSearchParams {
106
+ const out = new URLSearchParams();
107
+ for (const [k, v] of search) if (!isAmzParam(k)) out.append(k, v);
108
+ return out;
109
+ }
110
+
111
+ /** `{{secret:REF}}` template token embedded in an injected header value. */
112
+ const SECRET_TOKEN = /\{\{secret:([A-Za-z0-9_]+)\}\}/g;
113
+
114
+ /** Every secret ref referenced by any inject rule's header templates plus the
115
+ * SigV4 signing credentials (direct refs, not templates). */
116
+ export function collectSecretRefs(rules: InjectRule[], sign: SignConfig | undefined): string[] {
117
+ const refs = new Set<string>();
118
+ for (const rule of rules) {
119
+ for (const value of Object.values(rule.headers)) {
120
+ for (const m of value.matchAll(SECRET_TOKEN)) refs.add(m[1]);
121
+ }
122
+ }
123
+ if (sign?.type === "awsSigv4") {
124
+ refs.add(sign.accessKeyId);
125
+ refs.add(sign.secretAccessKey);
126
+ if (sign.sessionToken) refs.add(sign.sessionToken);
127
+ }
128
+ return [...refs];
129
+ }
130
+
131
+ function matchString(m: StringMatch, value: string): boolean {
132
+ if (typeof m === "string") return value === m;
133
+ if ("exact" in m) return value === m.exact;
134
+ if ("startsWith" in m) return value.startsWith(m.startsWith);
135
+ if ("regex" in m) return new RegExp(m.regex).test(value);
136
+ return false;
137
+ }
138
+
139
+ interface IncomingRequest {
140
+ method: string;
141
+ path: string;
142
+ query: URLSearchParams;
143
+ headers: Headers;
144
+ }
145
+
146
+ /** True if every dimension the matcher specifies is satisfied (AND across
147
+ * dimensions). No `match` → always true. */
148
+ export function ruleMatches(match: InjectMatch | undefined, req: IncomingRequest): boolean {
149
+ if (!match) return true;
150
+ if (match.path !== undefined && !matchString(match.path, req.path)) return false;
151
+ if (match.method !== undefined) {
152
+ const methods = (Array.isArray(match.method) ? match.method : [match.method]).map((s) =>
153
+ s.toUpperCase(),
154
+ );
155
+ if (!methods.includes(req.method.toUpperCase())) return false;
156
+ }
157
+ if (match.query) {
158
+ for (const [k, m] of Object.entries(match.query)) {
159
+ if (!req.query.getAll(k).some((v) => matchString(m, v))) return false;
160
+ }
161
+ }
162
+ if (match.headers) {
163
+ for (const [k, m] of Object.entries(match.headers)) {
164
+ const v = req.headers.get(k.toLowerCase());
165
+ if (v === null || !matchString(m, v)) return false;
166
+ }
167
+ }
168
+ return true;
169
+ }
170
+
171
+ interface BrokeredHeaders {
172
+ /** Lowercased header → resolved value, to SET on the forward. */
173
+ headers: Record<string, string>;
174
+ /** Resolved secrets (for redaction). */
175
+ secrets: ResolvedSecret[];
176
+ /** Refs referenced but not supplied by the control plane. */
177
+ missing: string[];
178
+ }
179
+
180
+ /** Resolve a rule's header templates, substituting every `{{secret:REF}}`
181
+ * with the environment credential. Collects resolved secrets (for redaction)
182
+ * and any refs the control plane didn't supply (fail loud). */
183
+ export function brokerHeaders(rule: InjectRule): BrokeredHeaders {
184
+ const headers: Record<string, string> = {};
185
+ const secrets: ResolvedSecret[] = [];
186
+ const missing: string[] = [];
187
+ const seen = new Set<string>();
188
+ for (const [name, template] of Object.entries(rule.headers)) {
189
+ headers[name.toLowerCase()] = template.replace(SECRET_TOKEN, (_full, ref: string) => {
190
+ const value = getRecordSecret(ref);
191
+ if (value === undefined) {
192
+ missing.push(ref);
193
+ return "";
194
+ }
195
+ if (!seen.has(ref)) {
196
+ seen.add(ref);
197
+ secrets.push({ ref, value });
198
+ }
199
+ return value;
200
+ });
201
+ }
202
+ return { headers, secrets, missing };
203
+ }
204
+
205
+ /** Resolve a host's A records against an EXTERNAL DNS server, bypassing the
206
+ * in-VM resolver (which would answer with the daemon's own gateway for a
207
+ * faked host). Mirrors spectest-resolver's own upstream-forward transport
208
+ * (dns-packet over UDP) so it works identically in-VM. */
209
+ export function resolveExternalA(host: string): Promise<string[]> {
210
+ const query = dnsPacket.encode({
211
+ type: "query",
212
+ id: 1,
213
+ flags: dnsPacket.RECURSION_DESIRED,
214
+ questions: [{ type: "A", name: host }],
215
+ });
216
+ return new Promise((resolve) => {
217
+ const sock = createSocket("udp4");
218
+ let done = false;
219
+ const finish = (ips: string[]): void => {
220
+ if (done) return;
221
+ done = true;
222
+ try {
223
+ sock.close();
224
+ } catch {
225
+ // ignore
226
+ }
227
+ resolve(ips);
228
+ };
229
+ sock.on("message", (msg) => {
230
+ try {
231
+ const res = dnsPacket.decode(msg);
232
+ const ips = (res.answers ?? []).flatMap((a) =>
233
+ a.type === "A" && typeof a.data === "string" && a.data.length > 0 ? [a.data] : [],
234
+ );
235
+ finish(ips);
236
+ } catch {
237
+ finish([]);
238
+ }
239
+ });
240
+ sock.on("error", () => finish([]));
241
+ sock.send(query, EXTERNAL_DNS_PORT, EXTERNAL_DNS, (err) => {
242
+ if (err) finish([]);
243
+ });
244
+ setTimeout(() => finish([]), 3_000);
245
+ });
246
+ }
247
+
248
+ interface UpstreamResponse {
249
+ status: number;
250
+ headers: Record<string, string>;
251
+ /** Keep cookie boundaries when returning an unrecorded response. */
252
+ cookies?: string[];
253
+ /** Decompressed body bytes. */
254
+ bytes: Uint8Array;
255
+ }
256
+
257
+ /** Open a real HTTPS connection to `ip` but with SNI + `Host` = `host`, so
258
+ * the public certificate validates against the real hostname (the in-VM CA
259
+ * is not involved on this leg). Decompresses the response so the stored and
260
+ * returned bytes are the plain payload. */
261
+ function forwardToRealUpstream(args: {
262
+ ip: string;
263
+ host: string;
264
+ method: string;
265
+ pathWithQuery: string;
266
+ headers: Record<string, string>;
267
+ body: Uint8Array;
268
+ signal?: AbortSignal;
269
+ }): Promise<UpstreamResponse> {
270
+ return new Promise((resolve, reject) => {
271
+ const req = httpsRequest(
272
+ {
273
+ host: args.ip,
274
+ port: 443,
275
+ method: args.method,
276
+ signal: args.signal,
277
+ path: args.pathWithQuery,
278
+ servername: args.host,
279
+ headers: { ...args.headers, host: args.host, "accept-encoding": "identity" },
280
+ },
281
+ (res) => {
282
+ const chunks: Buffer[] = [];
283
+ res.on("data", (c: Buffer) => chunks.push(c));
284
+ res.on("end", () => {
285
+ let bytes: Uint8Array = Buffer.concat(chunks);
286
+ const enc = String(res.headers["content-encoding"] ?? "").toLowerCase();
287
+ try {
288
+ if (enc.includes("br")) bytes = brotliDecompressSync(bytes);
289
+ else if (enc.includes("gzip")) bytes = gunzipSync(bytes);
290
+ else if (enc.includes("deflate")) bytes = inflateSync(bytes);
291
+ } catch {
292
+ // Leave the bytes as-is if decompression fails.
293
+ }
294
+ const headers: Record<string, string> = {};
295
+ for (const [k, v] of Object.entries(res.headers)) {
296
+ if (typeof v === "string") headers[k.toLowerCase()] = v;
297
+ else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(", ");
298
+ }
299
+ resolve({ status: res.statusCode ?? 0, headers, bytes, cookies: res.headers["set-cookie"] });
300
+ });
301
+ res.on("error", reject);
302
+ },
303
+ );
304
+ req.on("error", reject);
305
+ if (args.body.length > 0) req.write(Buffer.from(args.body));
306
+ req.end();
307
+ });
308
+ }
309
+
310
+ /** Forward without capturing. Callers may separately record the result. */
311
+ export async function forwardHttp(
312
+ req: Request,
313
+ host: string,
314
+ opts: { inject?: InjectRule[]; sign?: SignConfig },
315
+ bodyBytes: Uint8Array,
316
+ label: string,
317
+ ): Promise<Response | { upstream: UpstreamResponse; secrets: ResolvedSecret[] }> {
318
+ const url = new URL(req.url);
319
+ const reqLike = { method: req.method.toUpperCase(), path: url.pathname };
320
+ // Credential brokering (static header inject): first matching rule wins.
321
+ // Resolve its `{{secret:REF}}` tokens from the environment secret store.
322
+ const rule = (opts.inject ?? []).find((r) =>
323
+ ruleMatches(r.match, {
324
+ method: reqLike.method,
325
+ path: reqLike.path,
326
+ query: url.searchParams,
327
+ headers: req.headers,
328
+ }),
329
+ );
330
+ const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
331
+
332
+ // SigV4 signing: resolve the credential secret refs (direct refs, not
333
+ // `{{secret:}}` templates). Missing required refs fail loud alongside any
334
+ // missing inject refs.
335
+ let signCreds: SigV4Credentials | undefined;
336
+ const signMissing: string[] = [];
337
+ if (opts.sign?.type === "awsSigv4") {
338
+ const s = opts.sign;
339
+ const accessKeyId = getRecordSecret(s.accessKeyId);
340
+ const secretAccessKey = getRecordSecret(s.secretAccessKey);
341
+ const sessionToken = s.sessionToken ? getRecordSecret(s.sessionToken) : undefined;
342
+ if (accessKeyId === undefined) signMissing.push(s.accessKeyId);
343
+ if (secretAccessKey === undefined) signMissing.push(s.secretAccessKey);
344
+ if (s.sessionToken && sessionToken === undefined) signMissing.push(s.sessionToken);
345
+ if (accessKeyId !== undefined && secretAccessKey !== undefined) {
346
+ signCreds = { accessKeyId, secretAccessKey, sessionToken };
347
+ }
348
+ }
349
+
350
+ const missing = [...brokered.missing, ...signMissing];
351
+ if (missing.length > 0) {
352
+ const refs = [...new Set(missing)];
353
+ return new Response(
354
+ `${label}: secret(s) ${JSON.stringify(refs)} were not supplied ` +
355
+ `(configure them on the project's Secrets page, then start a new environment or fork).\n`,
356
+ { status: 599, headers: { "content-type": "text/plain" } },
357
+ );
358
+ }
359
+
360
+ // Resolve the REAL host's IP via an external resolver so we don't loop
361
+ // back into the daemon (the in-VM resolver answers our own gateway for
362
+ // this faked name). See the module header.
363
+ const ips = await resolveExternalA(host);
364
+ if (ips.length === 0) {
365
+ return new Response(
366
+ `${label}: could not resolve real upstream ${JSON.stringify(host)} ` +
367
+ `via external DNS ${EXTERNAL_DNS} (is the VM online?).\n`,
368
+ { status: 599, headers: { "content-type": "text/plain" } },
369
+ );
370
+ }
371
+
372
+ // Forward headers: drop hop-by-hop/framing, then SET the brokered
373
+ // headers — overwriting whatever the app sent (so the credential can't
374
+ // be smuggled or spoofed by app code). The real values only ever appear
375
+ // on this outbound wire.
376
+ const fwdHeaders: Record<string, string> = {};
377
+ req.headers.forEach((v, k) => {
378
+ if (k.toLowerCase() === "content-encoding" || !STRIP_FORWARD_HEADERS.has(k.toLowerCase())) fwdHeaders[k.toLowerCase()] = v;
379
+ });
380
+ for (const [k, v] of Object.entries(brokered.headers)) fwdHeaders[k] = v;
381
+
382
+ // SigV4 re-signing: strip the app's dummy signature and re-sign the exact
383
+ // outbound request (method/path/query/signed-headers/body-hash) with the
384
+ // brokered credentials, so `authorization`/`x-amz-date`/`x-amz-content-sha256`
385
+ // are internally consistent.
386
+ const signSecrets: ResolvedSecret[] = [];
387
+ let wireQuery = url.search.slice(1);
388
+ if (opts.sign?.type === "awsSigv4" && signCreds) {
389
+ const scope = inferSigV4Scope({
390
+ authorization: req.headers.get("authorization"),
391
+ credentialParam: url.searchParams.get("X-Amz-Credential"),
392
+ host,
393
+ });
394
+ if (!scope) {
395
+ return new Response(
396
+ `${label}: could not infer the AWS region/service to sign ` +
397
+ `${reqLike.method} ${reqLike.path} (no SigV4 Credential on the request, and host ` +
398
+ `${JSON.stringify(host)} is not a <service>.<region>.amazonaws.com name).\n`,
399
+ { status: 599, headers: { "content-type": "text/plain" } },
400
+ );
401
+ }
402
+ // Drop the app's own (dummy-signed) signing headers before re-signing.
403
+ for (const h of ["authorization", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]) {
404
+ delete fwdHeaders[h];
405
+ }
406
+ // Fold the app's remaining `x-amz-*`/content-type headers (e.g.
407
+ // `x-amz-target`) into the signature; they stay on the outbound wire.
408
+ const additionalHeaders: Record<string, string> = {};
409
+ for (const [k, v] of Object.entries(fwdHeaders)) {
410
+ if (
411
+ k === "content-type" ||
412
+ (k.startsWith("x-amz-") &&
413
+ k !== "x-amz-date" &&
414
+ k !== "x-amz-content-sha256" &&
415
+ k !== "x-amz-security-token")
416
+ ) {
417
+ additionalHeaders[k] = v;
418
+ }
419
+ }
420
+ const forwardParams = stripAmzParams(url.searchParams);
421
+ const signed = signAwsV4Request({
422
+ method: reqLike.method,
423
+ path: reqLike.path,
424
+ query: forwardParams,
425
+ host,
426
+ body: bodyBytes,
427
+ credentials: signCreds,
428
+ scope,
429
+ additionalHeaders,
430
+ });
431
+ for (const [k, v] of Object.entries(signed.headers)) fwdHeaders[k] = v;
432
+ // Send the canonical query we signed (URLSearchParams' `+`-for-space
433
+ // encoding would diverge from the signed `%20`).
434
+ wireQuery = signed.canonicalQuery;
435
+ signSecrets.push(
436
+ { ref: opts.sign.accessKeyId, value: signCreds.accessKeyId },
437
+ { ref: opts.sign.secretAccessKey, value: signCreds.secretAccessKey },
438
+ );
439
+ if (signCreds.sessionToken && opts.sign.sessionToken) {
440
+ signSecrets.push({ ref: opts.sign.sessionToken, value: signCreds.sessionToken });
441
+ }
442
+ }
443
+
444
+ const pathWithQuery = reqLike.path + (wireQuery ? `?${wireQuery}` : "");
445
+
446
+ let upstream: UpstreamResponse;
447
+ try {
448
+ upstream = await forwardToRealUpstream({
449
+ ip: ips[0],
450
+ host,
451
+ method: reqLike.method,
452
+ pathWithQuery,
453
+ headers: fwdHeaders,
454
+ body: bodyBytes,
455
+ signal: req.signal,
456
+ });
457
+ } catch (err) {
458
+ return new Response(
459
+ `${label}: forward to real ${host} (${ips[0]}) failed: ${(err as Error).message}\n`,
460
+ { status: 599, headers: { "content-type": "text/plain" } },
461
+ );
462
+ }
463
+
464
+ return { upstream, secrets: [...brokered.secrets, ...signSecrets] };
465
+ }
@@ -0,0 +1,20 @@
1
+ import { expect, test } from "bun:test";
2
+ import { harnessMethods } from "../daemon";
3
+
4
+ test("health reports pending work without acquiring its execution slot", async () => {
5
+ const state = {
6
+ inFlightTest: null as Promise<unknown> | null,
7
+ inFlightBootstrap: null,
8
+ inFlightProjectSetup: null,
9
+ };
10
+ const methods = harnessMethods(state);
11
+ expect((await methods.health({}) as any).inFlightTest).toBe(false);
12
+ let finish!: () => void;
13
+ state.inFlightTest = new Promise<void>((resolve) => { finish = resolve; });
14
+ expect((await methods.health({}) as any).inFlightTest).toBe(true);
15
+ finish();
16
+ await state.inFlightTest;
17
+ // Completion is acknowledged by the execution-slot owner, not just a request timeout.
18
+ state.inFlightTest = null;
19
+ expect((await methods.health({}) as any).inFlightTest).toBe(false);
20
+ });
@@ -1,3 +1,5 @@
1
+ import { isInteractiveEnvironment } from "../replay-mode.js";
2
+ import { liveFakeHandlers } from "./live-fake.js";
1
3
  import type { FakeWebSocketHandlers } from "../fake-websocket.js";
2
4
  import type { FakeContext, FakeDefinition } from "../index.js";
3
5
  import type { IngressServer } from "./ingress-server.js";
@@ -14,13 +16,14 @@ export async function dispatchFake<S>(
14
16
  state: S,
15
17
  ctx: FakeContext,
16
18
  ): Promise<Response> {
17
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
18
- try { return await runUninstrumented(() => def.handler(req, state, ctx)); }
19
+ const implementation = def.live && isInteractiveEnvironment() ? liveFakeHandlers(def) : def;
20
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !implementation.websocket) {
21
+ try { return await runUninstrumented(() => implementation.handler(req, state, ctx)); }
19
22
  catch (err) { return fakeError(def.name, err); }
20
23
  }
21
24
  const lifetime = getWebSocketLifetime(req);
22
25
  try {
23
- const accepted = await runUninstrumented(() => def.websocket!(req, state, ctx));
26
+ const accepted = await runUninstrumented(() => implementation.websocket!(req, state, ctx));
24
27
  if (accepted instanceof Response) { lifetime.end({ kind: "aborted" }); return accepted; }
25
28
  if (!accepted || typeof accepted !== "object") throw new Error("websocket must return handlers or a Response");
26
29
  if (accepted.protocol !== undefined && !parseSubprotocols(req.headers.get("sec-websocket-protocol")).includes(accepted.protocol)) {