@specific.dev/spectest 0.6.0 → 0.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -27,8 +27,11 @@ export {
27
27
  replayFake,
28
28
  type ReplayFakeOptions,
29
29
  type ReplayMatch,
30
- type ReplaySecretInjection,
30
+ type InjectRule,
31
+ type InjectMatch,
32
+ type StringMatch,
31
33
  type ReplayHelpers,
34
+ type ReplaySummary,
32
35
  type Cassette,
33
36
  type CassetteInteraction,
34
37
  type CassetteRequest,
@@ -6,42 +6,58 @@
6
6
  // and registers the hostname, dispatch routes by `Host` header to the
7
7
  // generated `handler`, and `state` forks per test like any fake.
8
8
  //
9
- // The only new behaviour lives inside the generated `handler`/`state`:
9
+ // You fake the REAL host directly — `host: "api.stripe.com"`, not a
10
+ // stand-in. The fake answers for that name on both `http://` (:80) and
11
+ // `https://` (:443, in-VM-CA leaf cert), and the SAME name is what gets
12
+ // forwarded to the real upstream in record mode. The two modes:
10
13
  //
11
- // - REPLAY (hermetic): the request is matched against a committed
12
- // cassette and the recorded response is returned. No network. This is
13
- // what runs under `spectest test`.
14
- // - RECORD: the request is forwarded to the real upstream, the
14
+ // - REPLAY (hermetic): the request is matched against the cassette the
15
+ // test EXPLICITLY loaded (`ctx.fakes.<name>.replay(file)`) and the
16
+ // recorded response is returned. No network. Nothing is auto-loaded:
17
+ // a test that loads no cassette gets a fail-loud 599 on every request.
18
+ // This is what runs under `spectest test`.
19
+ // - RECORD (MITM): the request is forwarded to the REAL host and the
15
20
  // request/response pair is captured (decoded, redacted), and the real
16
21
  // response is returned to the app so a manual session behaves like
17
22
  // production. This runs under `spectest_eval` / a manual env.
18
23
  //
24
+ // Because the fake's hostname IS the real host, the in-VM resolver points
25
+ // that name at the daemon — so a naive `fetch("https://api.stripe.com")`
26
+ // from the record-mode forwarder would loop straight back into us. The
27
+ // forwarder therefore resolves the real IP itself against an EXTERNAL DNS
28
+ // server (default 1.1.1.1, `SPECTEST_UPSTREAM_DNS`), then opens a real TLS
29
+ // connection to that IP with the correct SNI/`Host` (validating the real
30
+ // public cert). This is a transparent MITM: the app's leg is terminated
31
+ // with our in-VM-CA leaf; the upstream leg is a genuine public-cert TLS
32
+ // handshake to the real host.
33
+ //
19
34
  // Mode is chosen by `isRecording()`: it is true only inside an active
20
35
  // recorder (a `spectest test` case), false in eval/manual. So `auto`
21
36
  // (the default) replays under test and records under eval — no new
22
37
  // control-plane mode flag. `mode: "record" | "replay"` overrides it.
23
38
  //
24
- // Secrets are injected at the egress boundary (the forwarder): the app
25
- // only ever holds a placeholder; the real value — pushed eval-scoped from
26
- // the control plane via {@link getRecordSecret} — is swapped in on the
27
- // forward to upstream and never persists into a cassette (redacted,
28
- // fail-closed) or any snapshot a hermetic run could fork.
29
-
39
+ // Credential brokering (per-fake): `inject` rules set
40
+ // headers on the egress forward — overwriting whatever the app sent — so
41
+ // app code never holds the credential. Header values embed `{{secret:REF}}`
42
+ // tokens; each REF is resolved server-side from the platform Secrets store
43
+ // and pushed eval-scoped from the control plane (via {@link getRecordSecret}).
44
+ // The real value lives only on the outbound wire to the real upstream and
45
+ // is redacted from the cassette (fail-closed) — it never enters project
46
+ // code, the tarball, the warm-cache hash, or any snapshot a hermetic run
47
+ // could fork.
48
+
49
+ import { createSocket } from "node:dgram";
30
50
  import { createHash } from "node:crypto";
31
51
  import { existsSync, readFileSync } from "node:fs";
52
+ import { request as httpsRequest } from "node:https";
32
53
  import path from "node:path";
54
+ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
55
+ import * as dnsPacket from "dns-packet";
33
56
 
34
57
  import type { FakeContext, FakeDefinition } from "../index.js";
35
58
  import { isRecording } from "../recorder.js";
36
59
  import { getRecordSecret } from "../record-secrets.js";
37
60
 
38
- // Pristine `fetch`, captured at module load (project import time, before
39
- // any per-test / per-eval fetch wrapper monkey-patches `globalThis.fetch`).
40
- // The forwarder uses this so its outbound call isn't intercepted by the
41
- // recorder — same reason the daemon's reverse-proxy keeps its own
42
- // `NATIVE_FETCH`.
43
- const NATIVE_FETCH: typeof fetch = globalThis.fetch.bind(globalThis);
44
-
45
61
  // ────────────────────────────────────────────────────────────────────────
46
62
  // Options + cassette format
47
63
  // ────────────────────────────────────────────────────────────────────────
@@ -61,39 +77,59 @@ export interface ReplayMatch {
61
77
  headers?: string[];
62
78
  }
63
79
 
64
- export interface ReplaySecretInjection {
65
- /** Platform secret reference, e.g. `"STRIPE_API_KEY"`. The control plane
66
- * resolves it (default: `SPECTEST_SECRET_STRIPE_API_KEY` in the server
67
- * env) and pushes the value on the eval path only. */
68
- ref: string;
69
- /** Build the headers to set on the forward to upstream from the real
70
- * secret value, *replacing* whatever placeholder the app sent. Default:
71
- * `{ authorization: "Bearer <value>" }`. */
72
- inject?: (req: Request, value: string) => Record<string, string>;
73
- /** Extra substrings/patterns to scrub from stored request/response
74
- * bodies and queries (the secret value itself is always scrubbed). */
75
- redactPatterns?: (string | RegExp)[];
80
+ /** Match one request dimension. A bare string is an exact match; the
81
+ * object forms cover prefix and regex matching. */
82
+ export type StringMatch =
83
+ | string
84
+ | { exact: string }
85
+ | { startsWith: string }
86
+ | { regex: string };
87
+
88
+ /** Request matchers for a credential-injection rule. A rule applies only
89
+ * when EVERY specified dimension matches; omit `match` to always apply. */
90
+ export interface InjectMatch {
91
+ /** Match the request path (no query). */
92
+ path?: StringMatch;
93
+ /** HTTP method(s) — any one matching satisfies it. */
94
+ method?: string | string[];
95
+ /** Query-param matchers (ANDed); each key's value(s) must match. */
96
+ query?: Record<string, StringMatch>;
97
+ /** Header matchers (ANDed; header names case-insensitive). */
98
+ headers?: Record<string, StringMatch>;
99
+ }
100
+
101
+ /** One credential-brokering rule. When it matches a request, these headers
102
+ * are SET on the egress forward — overwriting whatever the app sent (so app
103
+ * code can't smuggle a different value past the broker). Header VALUES may
104
+ * embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
105
+ * project's Secrets store and pushed eval-scoped — the real value never
106
+ * enters project code, the
107
+ * tarball, the warm-cache hash, or a cassette (it's redacted, fail-closed). */
108
+ export interface InjectRule {
109
+ match?: InjectMatch;
110
+ headers: Record<string, string>;
76
111
  }
77
112
 
78
113
  export interface ReplayFakeOptions {
79
114
  /** Stable name — the `ctx.fakes` key. */
80
115
  name: string;
81
- /** Hostnames the fake answers to (e.g. `["api.stripe.test"]`). Keep
82
- * these distinct from the real `upstream` host so the record-mode
83
- * forward doesn't resolve back into the daemon (see the loopback
84
- * guard). */
85
- hostnames: string[];
86
- /** Real upstream base URL to forward to in record mode (e.g.
87
- * `"https://api.stripe.com"`). */
88
- upstream: string;
89
- /** TCP port the fake listens on. Default `80`. */
90
- port?: number;
91
- /** Cassette filename under `spectest/recordings/`. Default `<name>.json`. */
92
- cassette?: string;
116
+ /** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
117
+ * this name on both `http://` (:80) and `https://` (:443), and the same
118
+ * name is forwarded to the real upstream in record mode (over HTTPS,
119
+ * resolved via an external DNS server — see the module header). Your
120
+ * app points at this real host directly; no separate stand-in. */
121
+ host: string;
93
122
  /** What defines a request match. Default `{ method, path, query }`. */
94
123
  match?: ReplayMatch;
95
- /** Record-time secret injection at the egress boundary. */
96
- secret?: ReplaySecretInjection;
124
+ /** Per-fake credential brokering. Rules are evaluated in
125
+ * order; the first whose `match` matches wins (a rule without `match`
126
+ * matches everything and shadows later rules). Applied at the
127
+ * record-mode egress forward; resolved secret values are redacted from
128
+ * the cassette (fail-closed). */
129
+ inject?: InjectRule[];
130
+ /** Extra substrings/patterns to scrub from stored request/response
131
+ * bodies and queries (every injected secret value is always scrubbed). */
132
+ redactPatterns?: (string | RegExp)[];
97
133
  /** `"auto"` (default) records under eval and replays under test;
98
134
  * `"record"` / `"replay"` force a mode. */
99
135
  mode?: "auto" | "record" | "replay";
@@ -127,7 +163,8 @@ export interface CassetteInteraction {
127
163
  export interface Cassette {
128
164
  version: 1;
129
165
  fake: string;
130
- upstream: string;
166
+ /** The real host this cassette mirrors (e.g. `api.stripe.com`). */
167
+ host: string;
131
168
  interactions: CassetteInteraction[];
132
169
  }
133
170
 
@@ -141,8 +178,28 @@ export interface CassetteState {
141
178
  dirty: boolean;
142
179
  }
143
180
 
181
+ /** What `replay(...)` returns — a small summary that surfaces in the test
182
+ * timeline (recorded as a `fake` event) so the run shows exactly which
183
+ * cassette a test loaded and how many interactions it carried. */
184
+ export interface ReplaySummary {
185
+ /** Filename loaded (under `spectest/recordings/`) or `"(inline)"`. */
186
+ cassette: string;
187
+ /** The real host the cassette mirrors. */
188
+ host: string;
189
+ /** Number of interactions now available for replay. */
190
+ interactions: number;
191
+ }
192
+
144
193
  export interface ReplayHelpers extends Record<string, unknown> {
145
- /** Full cassette JSON (committed interactions + newly recorded, redacted)
194
+ /** Load a cassette into THIS fork's replay set and replay from it,
195
+ * replacing whatever was loaded before and resetting the replay cursor.
196
+ * Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
197
+ * path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
198
+ * must stay under `spectest/tests/`) or a cassette object; a missing file
199
+ * throws. The returned summary is recorded as a step, so the UI timeline
200
+ * shows what was loaded. */
201
+ replay(file: string | Cassette): ReplaySummary;
202
+ /** Full cassette JSON (loaded interactions + newly recorded, redacted)
146
203
  * — the MVP delivery channel: `export default ctx.fakes.<name>.dump()`,
147
204
  * then write the result to `spectest/recordings/<name>.json`. */
148
205
  dump(): Cassette;
@@ -166,8 +223,8 @@ const SENSITIVE_HEADERS = new Set([
166
223
  ]);
167
224
 
168
225
  /** Hop-by-hop + body-framing headers never forwarded / never replayed
169
- * (Bun's fetch already decompresses, so the encoding/length no longer
170
- * describe the bytes). */
226
+ * (we decompress on the way through, so the encoding/length no longer
227
+ * describe the bytes the app receives). */
171
228
  const STRIP_FORWARD_HEADERS = new Set([
172
229
  "host",
173
230
  "connection",
@@ -180,8 +237,16 @@ const STRIP_FORWARD_HEADERS = new Set([
180
237
  "upgrade",
181
238
  "content-length",
182
239
  "content-encoding",
240
+ // We force `accept-encoding: identity` on the forward and decompress any
241
+ // residual encoding, so the app never sees a stale accept-encoding.
242
+ "accept-encoding",
183
243
  ]);
184
244
 
245
+ /** External resolver used to find the REAL upstream IP, bypassing the in-VM
246
+ * resolver (which points the faked host back at the daemon). */
247
+ const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
248
+ const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
249
+
185
250
  function defaultMatch(m: ReplayMatch | undefined): Required<Omit<ReplayMatch, "body" | "headers">> &
186
251
  Pick<ReplayMatch, "body" | "headers"> {
187
252
  return {
@@ -237,41 +302,64 @@ function signature(req: CassetteRequest, match: ReturnType<typeof defaultMatch>)
237
302
  return JSON.stringify(sig);
238
303
  }
239
304
 
240
- function recordingsDir(): string {
241
- const dir =
305
+ /** The project's `spectest/tests/` dir — cassettes live alongside the
306
+ * tests, and a `replay(path)` argument is a path relative to here. Keeping
307
+ * them under `tests/` means re-recording is picked up on the next run with
308
+ * no environment rebuild (that subtree is re-imported with the tests). */
309
+ function cassetteRoot(): string {
310
+ const spectestDir =
242
311
  process.env.SPECTEST_PROJECT_DIR ??
243
312
  path.join(process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app", "spectest");
244
- return path.join(dir, "recordings");
313
+ return path.join(spectestDir, "tests");
245
314
  }
246
315
 
247
- function emptyCassette(name: string, upstream: string): Cassette {
248
- return { version: 1, fake: name, upstream, interactions: [] };
316
+ function emptyCassette(name: string, host: string): Cassette {
317
+ return { version: 1, fake: name, host, interactions: [] };
249
318
  }
250
319
 
251
- function loadCassette(name: string, file: string, upstream: string): Cassette {
252
- const p = path.join(recordingsDir(), file);
253
- if (!existsSync(p)) return emptyCassette(name, upstream);
320
+ /** Validate + normalize a parsed cassette (from a file or passed inline). */
321
+ function coerceCassette(parsed: Cassette, name: string, host: string): Cassette {
322
+ if (!parsed || !Array.isArray(parsed.interactions)) {
323
+ throw new Error(`replayFake(${name}): cassette has no \`interactions\` array`);
324
+ }
325
+ return {
326
+ version: 1,
327
+ fake: parsed.fake ?? name,
328
+ host: parsed.host ?? host,
329
+ interactions: parsed.interactions,
330
+ };
331
+ }
332
+
333
+ /** Read a cassette by its path relative to `spectest/tests/` (e.g.
334
+ * `"recordings/stripe.json"`). The path must stay under `spectest/tests/`,
335
+ * and explicit loads must point at a real file — a missing one throws (fail
336
+ * loud) rather than silently replaying nothing. */
337
+ function readCassetteFile(name: string, file: string, host: string): Cassette {
338
+ const base = cassetteRoot();
339
+ const p = path.resolve(base, file);
340
+ if (p !== base && !p.startsWith(base + path.sep)) {
341
+ throw new Error(
342
+ `replayFake(${name}): cassette path ${JSON.stringify(file)} must be under spectest/tests/`,
343
+ );
344
+ }
345
+ if (!existsSync(p)) {
346
+ throw new Error(
347
+ `replayFake(${name}): cassette not found at ${p} — record one first, or pass a cassette object.`,
348
+ );
349
+ }
350
+ let parsed: Cassette;
254
351
  try {
255
- const parsed = JSON.parse(readFileSync(p, "utf8")) as Cassette;
256
- if (!Array.isArray(parsed.interactions)) {
257
- throw new Error("cassette has no `interactions` array");
258
- }
259
- return {
260
- version: 1,
261
- fake: parsed.fake ?? name,
262
- upstream: parsed.upstream ?? upstream,
263
- interactions: parsed.interactions,
264
- };
352
+ parsed = JSON.parse(readFileSync(p, "utf8")) as Cassette;
265
353
  } catch (err) {
266
354
  throw new Error(
267
355
  `replayFake(${name}): failed to read cassette ${p}: ${(err as Error).message}`,
268
356
  );
269
357
  }
358
+ return coerceCassette(parsed, name, host);
270
359
  }
271
360
 
272
361
  /** Decode response bytes to a string, falling back to base64 for binary. */
273
- function decodeBody(buf: ArrayBuffer): { body: string; bodyEncoding: "utf8" | "base64" } {
274
- const bytes = new Uint8Array(buf);
362
+ function decodeBody(bytes: Uint8Array): { body: string; bodyEncoding: "utf8" | "base64" } {
275
363
  try {
276
364
  const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
277
365
  return { body: text, bodyEncoding: "utf8" };
@@ -280,15 +368,21 @@ function decodeBody(buf: ArrayBuffer): { body: string; bodyEncoding: "utf8" | "b
280
368
  }
281
369
  }
282
370
 
371
+ /** One resolved secret value and the ref it came from (for the redaction
372
+ * placeholder). */
373
+ interface ResolvedSecret {
374
+ ref: string;
375
+ value: string;
376
+ }
377
+
283
378
  function makeRedactor(
284
- secretValue: string | undefined,
379
+ secrets: ResolvedSecret[],
285
380
  patterns: (string | RegExp)[] | undefined,
286
- ref: string | undefined,
287
381
  ): (s: string) => string {
288
382
  return (s: string): string => {
289
383
  let out = s;
290
- if (secretValue && secretValue.length > 0) {
291
- out = out.split(secretValue).join(`<REDACTED:${ref ?? "SECRET"}>`);
384
+ for (const { ref, value } of secrets) {
385
+ if (value.length > 0) out = out.split(value).join(`<REDACTED:${ref}>`);
292
386
  }
293
387
  for (const p of patterns ?? []) {
294
388
  if (typeof p === "string") {
@@ -302,6 +396,204 @@ function makeRedactor(
302
396
  };
303
397
  }
304
398
 
399
+ // ────────────────────────────────────────────────────────────────────────
400
+ // Credential brokering: matchers + secret-token resolution
401
+ // ────────────────────────────────────────────────────────────────────────
402
+
403
+ /** `{{secret:REF}}` template token embedded in an injected header value. */
404
+ const SECRET_TOKEN = /\{\{secret:([A-Za-z0-9_]+)\}\}/g;
405
+
406
+ /** Every secret ref referenced by any inject rule's header templates. */
407
+ function collectSecretRefs(rules: InjectRule[]): string[] {
408
+ const refs = new Set<string>();
409
+ for (const rule of rules) {
410
+ for (const value of Object.values(rule.headers)) {
411
+ for (const m of value.matchAll(SECRET_TOKEN)) refs.add(m[1]);
412
+ }
413
+ }
414
+ return [...refs];
415
+ }
416
+
417
+ function matchString(m: StringMatch, value: string): boolean {
418
+ if (typeof m === "string") return value === m;
419
+ if ("exact" in m) return value === m.exact;
420
+ if ("startsWith" in m) return value.startsWith(m.startsWith);
421
+ if ("regex" in m) return new RegExp(m.regex).test(value);
422
+ return false;
423
+ }
424
+
425
+ interface IncomingRequest {
426
+ method: string;
427
+ path: string;
428
+ query: URLSearchParams;
429
+ headers: Headers;
430
+ }
431
+
432
+ /** True if every dimension the matcher specifies is satisfied (AND across
433
+ * dimensions). No `match` → always true. */
434
+ function ruleMatches(match: InjectMatch | undefined, req: IncomingRequest): boolean {
435
+ if (!match) return true;
436
+ if (match.path !== undefined && !matchString(match.path, req.path)) return false;
437
+ if (match.method !== undefined) {
438
+ const methods = (Array.isArray(match.method) ? match.method : [match.method]).map((s) =>
439
+ s.toUpperCase(),
440
+ );
441
+ if (!methods.includes(req.method.toUpperCase())) return false;
442
+ }
443
+ if (match.query) {
444
+ for (const [k, m] of Object.entries(match.query)) {
445
+ if (!req.query.getAll(k).some((v) => matchString(m, v))) return false;
446
+ }
447
+ }
448
+ if (match.headers) {
449
+ for (const [k, m] of Object.entries(match.headers)) {
450
+ const v = req.headers.get(k.toLowerCase());
451
+ if (v === null || !matchString(m, v)) return false;
452
+ }
453
+ }
454
+ return true;
455
+ }
456
+
457
+ interface BrokeredHeaders {
458
+ /** Lowercased header → resolved value, to SET on the forward. */
459
+ headers: Record<string, string>;
460
+ /** Resolved secrets (for redaction). */
461
+ secrets: ResolvedSecret[];
462
+ /** Refs referenced but not supplied by the control plane. */
463
+ missing: string[];
464
+ }
465
+
466
+ /** Resolve a rule's header templates, substituting every `{{secret:REF}}`
467
+ * with the eval-scoped value. Collects resolved secrets (for redaction)
468
+ * and any refs the control plane didn't supply (fail loud). */
469
+ function brokerHeaders(rule: InjectRule): BrokeredHeaders {
470
+ const headers: Record<string, string> = {};
471
+ const secrets: ResolvedSecret[] = [];
472
+ const missing: string[] = [];
473
+ const seen = new Set<string>();
474
+ for (const [name, template] of Object.entries(rule.headers)) {
475
+ headers[name.toLowerCase()] = template.replace(SECRET_TOKEN, (_full, ref: string) => {
476
+ const value = getRecordSecret(ref);
477
+ if (value === undefined) {
478
+ missing.push(ref);
479
+ return "";
480
+ }
481
+ if (!seen.has(ref)) {
482
+ seen.add(ref);
483
+ secrets.push({ ref, value });
484
+ }
485
+ return value;
486
+ });
487
+ }
488
+ return { headers, secrets, missing };
489
+ }
490
+
491
+ // ────────────────────────────────────────────────────────────────────────
492
+ // Record-mode egress: resolve the real host externally, then MITM-forward
493
+ // ────────────────────────────────────────────────────────────────────────
494
+
495
+ /** Resolve a host's A records against an EXTERNAL DNS server, bypassing the
496
+ * in-VM resolver (which would answer with the daemon's own gateway for a
497
+ * faked host). Mirrors spectest-resolver's own upstream-forward transport
498
+ * (dns-packet over UDP) so it works identically in-VM. */
499
+ function resolveExternalA(host: string): Promise<string[]> {
500
+ const query = dnsPacket.encode({
501
+ type: "query",
502
+ id: 1,
503
+ flags: dnsPacket.RECURSION_DESIRED,
504
+ questions: [{ type: "A", name: host }],
505
+ });
506
+ return new Promise((resolve) => {
507
+ const sock = createSocket("udp4");
508
+ let done = false;
509
+ const finish = (ips: string[]): void => {
510
+ if (done) return;
511
+ done = true;
512
+ try {
513
+ sock.close();
514
+ } catch {
515
+ // ignore
516
+ }
517
+ resolve(ips);
518
+ };
519
+ sock.on("message", (msg) => {
520
+ try {
521
+ const res = dnsPacket.decode(msg);
522
+ const ips = (res.answers ?? [])
523
+ .filter((a) => a.type === "A")
524
+ .map((a) => a.data as string)
525
+ .filter((d) => typeof d === "string" && d.length > 0);
526
+ finish(ips);
527
+ } catch {
528
+ finish([]);
529
+ }
530
+ });
531
+ sock.on("error", () => finish([]));
532
+ sock.send(query, EXTERNAL_DNS_PORT, EXTERNAL_DNS, (err) => {
533
+ if (err) finish([]);
534
+ });
535
+ setTimeout(() => finish([]), 3_000);
536
+ });
537
+ }
538
+
539
+ interface UpstreamResponse {
540
+ status: number;
541
+ headers: Record<string, string>;
542
+ /** Decompressed body bytes. */
543
+ bytes: Uint8Array;
544
+ }
545
+
546
+ /** Open a real HTTPS connection to `ip` but with SNI + `Host` = `host`, so
547
+ * the public certificate validates against the real hostname (the in-VM CA
548
+ * is not involved on this leg). Decompresses the response so the stored and
549
+ * returned bytes are the plain payload. */
550
+ function forwardToRealUpstream(args: {
551
+ ip: string;
552
+ host: string;
553
+ method: string;
554
+ pathWithQuery: string;
555
+ headers: Record<string, string>;
556
+ body: string;
557
+ }): Promise<UpstreamResponse> {
558
+ return new Promise((resolve, reject) => {
559
+ const req = httpsRequest(
560
+ {
561
+ host: args.ip,
562
+ port: 443,
563
+ method: args.method,
564
+ path: args.pathWithQuery,
565
+ servername: args.host,
566
+ headers: { ...args.headers, host: args.host, "accept-encoding": "identity" },
567
+ },
568
+ (res) => {
569
+ const chunks: Buffer[] = [];
570
+ res.on("data", (c: Buffer) => chunks.push(c));
571
+ res.on("end", () => {
572
+ let bytes: Uint8Array = Buffer.concat(chunks);
573
+ const enc = String(res.headers["content-encoding"] ?? "").toLowerCase();
574
+ try {
575
+ if (enc.includes("br")) bytes = brotliDecompressSync(bytes);
576
+ else if (enc.includes("gzip")) bytes = gunzipSync(bytes);
577
+ else if (enc.includes("deflate")) bytes = inflateSync(bytes);
578
+ } catch {
579
+ // Leave the bytes as-is if decompression fails.
580
+ }
581
+ const headers: Record<string, string> = {};
582
+ for (const [k, v] of Object.entries(res.headers)) {
583
+ if (typeof v === "string") headers[k.toLowerCase()] = v;
584
+ else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(", ");
585
+ }
586
+ resolve({ status: res.statusCode ?? 0, headers, bytes });
587
+ });
588
+ res.on("error", reject);
589
+ },
590
+ );
591
+ req.on("error", reject);
592
+ if (args.body.length > 0) req.write(args.body);
593
+ req.end();
594
+ });
595
+ }
596
+
305
597
  // ────────────────────────────────────────────────────────────────────────
306
598
  // replayFake
307
599
  // ────────────────────────────────────────────────────────────────────────
@@ -310,21 +602,16 @@ export function replayFake(
310
602
  opts: ReplayFakeOptions,
311
603
  ): FakeDefinition<CassetteState, ReplayHelpers> {
312
604
  if (!opts.name) throw new Error("replayFake: `name` is required");
313
- if (!Array.isArray(opts.hostnames) || opts.hostnames.length === 0) {
314
- throw new Error(`replayFake(${opts.name}): at least one hostname is required`);
315
- }
316
- if (!opts.upstream) {
317
- throw new Error(`replayFake(${opts.name}): "upstream" is required`);
605
+ if (!opts.host || typeof opts.host !== "string") {
606
+ throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
318
607
  }
319
- const cassetteFile = opts.cassette ?? `${opts.name}.json`;
320
- const upstream = opts.upstream.replace(/\/+$/, "");
608
+ const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
321
609
  const match = defaultMatch(opts.match);
322
- const fakeHosts = new Set(opts.hostnames.map((h) => h.toLowerCase()));
323
- const inject =
324
- opts.secret?.inject ??
325
- ((_req: Request, value: string): Record<string, string> => ({
326
- authorization: `Bearer ${value}`,
327
- }));
610
+ const injectRules = opts.inject ?? [];
611
+ // Refs every header template references — the control plane resolves these
612
+ // server-side and pushes them eval-scoped (see the daemon's
613
+ // /record-secret-refs endpoint, which reads `def.secretRefs`).
614
+ const secretRefs = collectSecretRefs(injectRules);
328
615
 
329
616
  const resolveMode = (): "record" | "replay" => {
330
617
  if (opts.mode === "record") return "record";
@@ -360,67 +647,86 @@ export function replayFake(
360
647
  return new Response(payload, { status: hit.response.status, headers });
361
648
  }
362
649
 
363
- // ── RECORD ──────────────────────────────────────────────────────────
650
+ // ── RECORD (MITM) ───────────────────────────────────────────────────
364
651
  async function record(
365
652
  req: Request,
653
+ url: URL,
366
654
  reqLike: CassetteRequest,
367
655
  rawBody: string,
368
656
  state: CassetteState,
369
657
  ): Promise<Response> {
370
- const secretValue = opts.secret ? getRecordSecret(opts.secret.ref) : undefined;
371
- if (opts.secret && secretValue === undefined) {
658
+ // Credential brokering: first matching inject rule wins. Resolve its
659
+ // `{{secret:REF}}` tokens from the eval-scoped store; a
660
+ // referenced-but-unsupplied ref fails loud.
661
+ const rule = injectRules.find((r) =>
662
+ ruleMatches(r.match, {
663
+ method: reqLike.method,
664
+ path: reqLike.path,
665
+ query: url.searchParams,
666
+ headers: req.headers,
667
+ }),
668
+ );
669
+ const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
670
+ if (brokered.missing.length > 0) {
671
+ const refs = [...new Set(brokered.missing)];
372
672
  return new Response(
373
- `replayFake(${opts.name}): secret ${JSON.stringify(opts.secret.ref)} was not supplied ` +
374
- `(set SPECTEST_SECRET_${opts.secret.ref} in the server env and record via spectest_eval).\n`,
673
+ `replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
674
+ `(configure them on the project's Secrets page, and record via spectest_eval).\n`,
375
675
  { status: 599, headers: { "content-type": "text/plain" } },
376
676
  );
377
677
  }
378
678
 
379
- // Build the upstream URL and guard against forwarding back into the
380
- // daemon (which would happen if the upstream host is itself one of
381
- // this fake's hostnames — the in-VM resolver points those at us).
382
- const target = new URL(reqLike.path, upstream + "/");
383
- const incomingUrl = new URL(req.url);
384
- incomingUrl.searchParams.forEach((v, k) => target.searchParams.append(k, v));
385
- if (fakeHosts.has(target.hostname.toLowerCase())) {
386
- throw new Error(
387
- `replayFake(${opts.name}): upstream host ${target.hostname} is also a fake hostname — ` +
388
- `forwarding would loop back into the daemon. Use a distinct fake hostname ` +
389
- `(e.g. "api.${opts.name}.test") pointed at upstream ${upstream}.`,
679
+ // Resolve the REAL host's IP via an external resolver so we don't loop
680
+ // back into the daemon (the in-VM resolver answers our own gateway for
681
+ // this faked name). See the module header.
682
+ const ips = await resolveExternalA(host);
683
+ if (ips.length === 0) {
684
+ return new Response(
685
+ `replayFake(${opts.name}): could not resolve real upstream ${JSON.stringify(host)} ` +
686
+ `via external DNS ${EXTERNAL_DNS} (is the VM online?).\n`,
687
+ { status: 599, headers: { "content-type": "text/plain" } },
390
688
  );
391
689
  }
392
690
 
393
- // Forward headers: drop hop-by-hop, then apply secret injection
394
- // (replacing whatever placeholder the app sent — the real key only
395
- // ever appears on this outbound wire).
396
- const fwdHeaders = new Headers();
691
+ // Forward headers: drop hop-by-hop/encoding, then SET the brokered
692
+ // headers — overwriting whatever the app sent (so the credential can't
693
+ // be smuggled or spoofed by app code). The real values only ever appear
694
+ // on this outbound wire.
695
+ const fwdHeaders: Record<string, string> = {};
397
696
  req.headers.forEach((v, k) => {
398
- if (!STRIP_FORWARD_HEADERS.has(k.toLowerCase())) fwdHeaders.set(k, v);
697
+ if (!STRIP_FORWARD_HEADERS.has(k.toLowerCase())) fwdHeaders[k.toLowerCase()] = v;
399
698
  });
400
- if (opts.secret && secretValue !== undefined) {
401
- for (const [k, v] of Object.entries(inject(req, secretValue))) {
402
- fwdHeaders.set(k, v);
403
- }
404
- }
699
+ for (const [k, v] of Object.entries(brokered.headers)) fwdHeaders[k] = v;
405
700
 
406
- const upstreamRes = await NATIVE_FETCH(target.toString(), {
407
- method: reqLike.method,
408
- headers: fwdHeaders,
409
- body: rawBody.length > 0 ? rawBody : undefined,
410
- redirect: "manual",
411
- });
412
- const resBuf = await upstreamRes.arrayBuffer();
701
+ const search = url.searchParams.toString();
702
+ const pathWithQuery = reqLike.path + (search ? `?${search}` : "");
703
+
704
+ let upstream: UpstreamResponse;
705
+ try {
706
+ upstream = await forwardToRealUpstream({
707
+ ip: ips[0],
708
+ host,
709
+ method: reqLike.method,
710
+ pathWithQuery,
711
+ headers: fwdHeaders,
712
+ body: rawBody,
713
+ });
714
+ } catch (err) {
715
+ return new Response(
716
+ `replayFake(${opts.name}): forward to real ${host} (${ips[0]}) failed: ${(err as Error).message}\n`,
717
+ { status: 599, headers: { "content-type": "text/plain" } },
718
+ );
719
+ }
413
720
 
414
721
  // Decode + redact for storage; return the real (un-redacted) bytes to
415
722
  // the app so the manual session behaves like production.
416
- const redact = makeRedactor(secretValue, opts.secret?.redactPatterns, opts.secret?.ref);
417
- const decoded = decodeBody(resBuf);
723
+ const redact = makeRedactor(brokered.secrets, opts.redactPatterns);
724
+ const decoded = decodeBody(upstream.bytes);
418
725
  const respHeaders: Record<string, string> = {};
419
- upstreamRes.headers.forEach((v, k) => {
420
- const lower = k.toLowerCase();
421
- if (SENSITIVE_HEADERS.has(lower) || STRIP_FORWARD_HEADERS.has(lower)) return;
422
- respHeaders[lower] = v;
423
- });
726
+ for (const [k, v] of Object.entries(upstream.headers)) {
727
+ if (SENSITIVE_HEADERS.has(k) || STRIP_FORWARD_HEADERS.has(k)) continue;
728
+ respHeaders[k] = v;
729
+ }
424
730
 
425
731
  const redactedQuery: Record<string, string[]> = {};
426
732
  for (const [k, vs] of Object.entries(reqLike.query)) {
@@ -437,20 +743,22 @@ export function replayFake(
437
743
  : {}),
438
744
  },
439
745
  response: {
440
- status: upstreamRes.status,
746
+ status: upstream.status,
441
747
  headers: respHeaders,
442
748
  body: decoded.bodyEncoding === "utf8" ? redact(decoded.body) : decoded.body,
443
749
  bodyEncoding: decoded.bodyEncoding,
444
750
  },
445
751
  };
446
752
 
447
- // Fail-closed: never persist a cassette that still carries the raw
448
- // secret (a missed redaction is a leak, not a warning).
449
- if (secretValue && secretValue.length > 0) {
450
- if (JSON.stringify(interaction).includes(secretValue)) {
753
+ // Fail-closed: never persist a cassette that still carries any raw
754
+ // secret value (a missed redaction is a leak, not a warning).
755
+ const serialized = JSON.stringify(interaction);
756
+ for (const { ref, value } of brokered.secrets) {
757
+ if (value.length > 0 && serialized.includes(value)) {
451
758
  throw new Error(
452
- `replayFake(${opts.name}): refusing to record — the secret value survived redaction ` +
453
- `into the interaction for ${reqLike.method} ${reqLike.path}. Add a redactPatterns entry.`,
759
+ `replayFake(${opts.name}): refusing to record — the value for secret ${JSON.stringify(ref)} ` +
760
+ `survived redaction into the interaction for ${reqLike.method} ${reqLike.path}. ` +
761
+ `Add a redactPatterns entry.`,
454
762
  );
455
763
  }
456
764
  }
@@ -458,8 +766,10 @@ export function replayFake(
458
766
  state.recorded.push(interaction);
459
767
  state.dirty = true;
460
768
 
461
- const outHeaders = new Headers(respHeaders);
462
- return new Response(resBuf, { status: upstreamRes.status, headers: outHeaders });
769
+ return new Response(upstream.bytes, {
770
+ status: upstream.status,
771
+ headers: new Headers(respHeaders),
772
+ });
463
773
  }
464
774
 
465
775
  // ── handler ─────────────────────────────────────────────────────────
@@ -479,27 +789,51 @@ export function replayFake(
479
789
  };
480
790
 
481
791
  if (resolveMode() === "replay") return replay(reqLike, state);
482
- return record(req, reqLike, rawBody, state);
792
+ return record(req, url, reqLike, rawBody, state);
483
793
  };
484
794
 
485
795
  const def: FakeDefinition<CassetteState, ReplayHelpers> = {
486
796
  name: opts.name,
487
- hostnames: opts.hostnames,
488
- port: opts.port,
489
- secretRefs: opts.secret ? [opts.secret.ref] : [],
797
+ // Fake = real: we answer for the real host on :80 and :443.
798
+ hostnames: [host],
799
+ secretRefs,
800
+ // Cassettes are NOT auto-loaded — a fork starts with an empty replay
801
+ // set, so a test that loads nothing fails loud (599). Tests call
802
+ // `replay(...)` to populate it.
490
803
  state: (): CassetteState => ({
491
- cassette: loadCassette(opts.name, cassetteFile, upstream),
804
+ cassette: emptyCassette(opts.name, host),
492
805
  cursor: new Map(),
493
806
  recorded: [],
494
807
  dirty: false,
495
808
  }),
496
809
  handler,
497
810
  helpers: ({ state }): ReplayHelpers => ({
811
+ replay(file: string | Cassette): ReplaySummary {
812
+ let loaded: Cassette;
813
+ let label: string;
814
+ if (file && typeof file === "object") {
815
+ loaded = coerceCassette(file, opts.name, host);
816
+ label = file.fake || "(inline)";
817
+ } else if (typeof file === "string" && file.length > 0) {
818
+ label = file;
819
+ loaded = readCassetteFile(opts.name, file, host);
820
+ } else {
821
+ throw new Error(
822
+ `replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
823
+ `(e.g. "recordings/${opts.name}.json").`,
824
+ );
825
+ }
826
+ // Mutate the live state object (the handler reads the same
827
+ // reference); reset the cursor so replay starts from the top.
828
+ state.cassette = loaded;
829
+ state.cursor = new Map();
830
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
831
+ },
498
832
  dump(): Cassette {
499
833
  return {
500
834
  version: 1,
501
835
  fake: opts.name,
502
- upstream,
836
+ host,
503
837
  interactions: [...state.cassette.interactions, ...state.recorded],
504
838
  };
505
839
  },
package/src/daemon.ts CHANGED
@@ -24,7 +24,15 @@ import net from "node:net";
24
24
  import path from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
26
 
27
- import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard } from "./index.js";
27
+ import {
28
+ assert,
29
+ expect,
30
+ expectRaw,
31
+ lowerIngress,
32
+ dnsName as makeDnsDecl,
33
+ isWildcard,
34
+ proxy as makeProxyDecl,
35
+ } from "./index.js";
28
36
  import type { DnsTarget, LoweredIngress } from "./index.js";
29
37
  import { openBrowser } from "./browser.js";
30
38
  import { openMobile, isMobileApp } from "./mobile.js";
@@ -1222,6 +1230,24 @@ const INGRESS_HTTP_SERVERS = new Map<number, any>();
1222
1230
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1223
1231
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1224
1232
  const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1233
+ /**
1234
+ * Live per-port route tables, keyed by listen port (80 / 443 / fake ports).
1235
+ * Each listener's `fetch` closure captures *this* Map object, so adding an
1236
+ * entry takes effect immediately with no rebind — that's what lets a runtime
1237
+ * `tls` (a `ctx.startService({ tls })`) bind a new ingress route after boot.
1238
+ * Held at module scope so it's part of the live daemon process and forks
1239
+ * with the snapshot, exactly like fake state / the names REGISTRY. Rebuilt on
1240
+ * /load (cleared by {@link stopIngressServers}).
1241
+ */
1242
+ const INGRESS_ROUTES_BY_PORT = new Map<number, Map<string, Route>>();
1243
+ /**
1244
+ * The :443 SNI cert table: serverName → leaf. Unlike the route table, Bun's
1245
+ * TLS config is fixed at `Bun.serve` time (reload won't add an SNI entry), so
1246
+ * minting a cert for a *new* hostname requires rebinding the :443 listener
1247
+ * (cheap, ~1ms — see {@link rebindHttpsListener}). A hostname already covered
1248
+ * by an existing exact or wildcard cert needs no rebind, just a route entry.
1249
+ */
1250
+ const HTTPS_CERT_BY_HOST = new Map<string, { cert: string; key: string }>();
1225
1251
 
1226
1252
  /**
1227
1253
  * Tear down listener servers between /load calls so the new project's
@@ -1246,6 +1272,8 @@ function stopIngressServers(): void {
1246
1272
  }
1247
1273
  }
1248
1274
  INGRESS_HTTPS_SERVERS.clear();
1275
+ INGRESS_ROUTES_BY_PORT.clear();
1276
+ HTTPS_CERT_BY_HOST.clear();
1249
1277
  }
1250
1278
 
1251
1279
  function buildIngress(project: Project): void {
@@ -1441,12 +1469,11 @@ async function startIngress(): Promise<void> {
1441
1469
  // before binding so the HTTPS listener has certs ready and a startup
1442
1470
  // failure aborts /bootstrap cleanly.
1443
1471
  const caPresent = existsSync(CA_PATH) && existsSync(CA_KEY_PATH);
1444
- const certByHost = new Map<string, { cert: string; key: string }>();
1445
1472
  if (caPresent) {
1446
1473
  for (const group of LOWERED.certificates) {
1447
1474
  if (group.hostnames.length === 0) continue;
1448
1475
  const leaf = await generateHostCert(group.hostnames[0], group.hostnames);
1449
- for (const h of group.hostnames) certByHost.set(h, leaf);
1476
+ for (const h of group.hostnames) HTTPS_CERT_BY_HOST.set(h, leaf);
1450
1477
  }
1451
1478
  } else if (LOWERED.certificates.length > 0) {
1452
1479
  // eslint-disable-next-line no-console
@@ -1455,73 +1482,49 @@ async function startIngress(): Promise<void> {
1455
1482
  );
1456
1483
  }
1457
1484
 
1458
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1459
- const Bun = (globalThis as any).Bun;
1460
- if (!Bun?.serve) {
1461
- throw new Error(
1462
- "ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)",
1463
- );
1464
- }
1465
-
1466
- // Resolve every ingress hostname to its handler. Fakes run an in-daemon
1467
- // handler; proxies reverse-proxy to a service:port. This single table
1468
- // drives both the HTTP and HTTPS listeners.
1469
- const routeByHost = new Map<string, Route>();
1470
- for (const fake of FAKES.values()) {
1471
- for (const h of fake.hostnames) routeByHost.set(h, { kind: "fake", fake });
1472
- }
1473
- for (const p of LOWERED.proxies) {
1474
- routeByHost.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1475
- }
1485
+ const Bun = requireBun();
1476
1486
 
1477
- // ── HTTP listeners: group routes by port, dispatch per-request by Host.
1478
- // Proxies bind :80 (HTTPS, if any, is on :443); fakes use their
1479
- // declared port. Skip :443 in the HTTP map — HTTPS wins.
1480
- const httpRoutesByPort = new Map<number, Map<string, Route>>();
1487
+ // Resolve every ingress hostname to its handler into the live per-port
1488
+ // route tables (module scope, so runtime `tls` can extend them later).
1489
+ // Fakes run an in-daemon handler on their declared port; proxies
1490
+ // reverse-proxy to a service:port and bind :80 (HTTPS, if any, is :443).
1481
1491
  const ensurePort = (port: number): Map<string, Route> => {
1482
- const m = httpRoutesByPort.get(port) ?? new Map<string, Route>();
1483
- httpRoutesByPort.set(port, m);
1492
+ const m = INGRESS_ROUTES_BY_PORT.get(port) ?? new Map<string, Route>();
1493
+ INGRESS_ROUTES_BY_PORT.set(port, m);
1484
1494
  return m;
1485
1495
  };
1486
1496
  for (const fake of FAKES.values()) {
1487
1497
  if (fake.port === INGRESS_HTTPS_PORT) continue;
1488
1498
  const routes = ensurePort(fake.port);
1489
- for (const h of fake.hostnames) routes.set(h, routeByHost.get(h)!);
1499
+ for (const h of fake.hostnames) routes.set(h, { kind: "fake", fake });
1490
1500
  }
1491
1501
  if (LOWERED.proxies.length > 0) {
1492
1502
  const routes = ensurePort(INGRESS_HTTP_PORT);
1493
- for (const p of LOWERED.proxies) routes.set(p.hostname, routeByHost.get(p.hostname)!);
1503
+ for (const p of LOWERED.proxies) {
1504
+ routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1505
+ }
1494
1506
  }
1495
- for (const [port, byHost] of httpRoutesByPort) {
1496
- const label = `port ${port}`;
1497
- INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, label));
1498
- const hosts = [...byHost.keys()].join(", ");
1499
- // eslint-disable-next-line no-console
1500
- console.log(`[ingress] http :${port} for ${hosts}`);
1507
+ // The :443 route table mirrors every certificated hostname's handler.
1508
+ if (HTTPS_CERT_BY_HOST.size > 0) {
1509
+ const httpsRoutes = ensurePort(INGRESS_HTTPS_PORT);
1510
+ for (const h of HTTPS_CERT_BY_HOST.keys()) {
1511
+ for (const fake of FAKES.values()) {
1512
+ if (fake.hostnames.includes(h)) httpsRoutes.set(h, { kind: "fake", fake });
1513
+ }
1514
+ const proxy = LOWERED.proxies.find((p) => p.hostname === h);
1515
+ if (proxy) httpsRoutes.set(h, { kind: "proxy", service: proxy.service, port: proxy.port });
1516
+ }
1501
1517
  }
1502
1518
 
1503
- // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1504
- if (certByHost.size > 0) {
1505
- const tlsEntries: Array<{ cert: string; key: string; serverName: string }> = [];
1506
- const byHostHttps = new Map<string, Route>();
1507
- for (const [h, leaf] of certByHost) {
1508
- tlsEntries.push({ cert: leaf.cert, key: leaf.key, serverName: h });
1509
- const route = routeByHost.get(h);
1510
- if (route) byHostHttps.set(h, route);
1511
- }
1512
- const label = `https :${INGRESS_HTTPS_PORT}`;
1513
- const server = bindIngressServer(
1514
- Bun,
1515
- INGRESS_HTTPS_PORT,
1516
- byHostHttps,
1517
- label,
1518
- tlsEntries,
1519
- );
1520
- INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
1521
- const hosts = [...byHostHttps.keys()].join(", ");
1519
+ // ── HTTP listeners (one per non-443 port).
1520
+ for (const [port, byHost] of INGRESS_ROUTES_BY_PORT) {
1521
+ if (port === INGRESS_HTTPS_PORT) continue;
1522
+ INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, `port ${port}`));
1522
1523
  // eslint-disable-next-line no-console
1523
- console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${hosts}`);
1524
+ console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
1524
1525
  }
1526
+ // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1527
+ if (HTTPS_CERT_BY_HOST.size > 0) rebindHttpsListener(Bun);
1525
1528
 
1526
1529
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
1527
1530
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
@@ -1530,6 +1533,148 @@ async function startIngress(): Promise<void> {
1530
1533
  await seedNamesRegistry({ servicesUp: false });
1531
1534
  }
1532
1535
 
1536
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1537
+ function requireBun(): any {
1538
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1539
+ const Bun = (globalThis as any).Bun;
1540
+ if (!Bun?.serve) {
1541
+ throw new Error(
1542
+ "ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)",
1543
+ );
1544
+ }
1545
+ return Bun;
1546
+ }
1547
+
1548
+ /** Flatten {@link HTTPS_CERT_BY_HOST} into Bun's TLS-entry SNI array. */
1549
+ function tlsEntriesFromCerts(): Array<{ cert: string; key: string; serverName: string }> {
1550
+ return [...HTTPS_CERT_BY_HOST].map(([serverName, leaf]) => ({
1551
+ cert: leaf.cert,
1552
+ key: leaf.key,
1553
+ serverName,
1554
+ }));
1555
+ }
1556
+
1557
+ /**
1558
+ * (Re)bind the :443 listener from the current cert table + route map. Bun's
1559
+ * TLS config is immutable per `Bun.serve`, so adding an SNI cert means
1560
+ * stopping the old listener and serving a fresh one — cheap (~1ms) and the
1561
+ * window is sub-millisecond. The route Map is the persistent module object,
1562
+ * so the new listener closes over the same table (later route additions need
1563
+ * no rebind). No-ops to a plain rebind when only routes changed.
1564
+ */
1565
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1566
+ function rebindHttpsListener(Bun: any): void {
1567
+ const routes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map<string, Route>();
1568
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, routes);
1569
+ const old = INGRESS_HTTPS_SERVERS.get(INGRESS_HTTPS_PORT);
1570
+ if (old) {
1571
+ try {
1572
+ old.stop(true);
1573
+ } catch (err) {
1574
+ // eslint-disable-next-line no-console
1575
+ console.warn("[ingress] failed to stop https listener for rebind:", err);
1576
+ }
1577
+ }
1578
+ const server = bindIngressServer(
1579
+ Bun,
1580
+ INGRESS_HTTPS_PORT,
1581
+ routes,
1582
+ `https :${INGRESS_HTTPS_PORT}`,
1583
+ tlsEntriesFromCerts(),
1584
+ );
1585
+ INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
1586
+ // eslint-disable-next-line no-console
1587
+ console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
1588
+ }
1589
+
1590
+ /** True if an exact or wildcard cert already covers `hostname` for SNI. */
1591
+ function certCovers(hostname: string): boolean {
1592
+ if (HTTPS_CERT_BY_HOST.has(hostname)) return true;
1593
+ for (const serverName of HTTPS_CERT_BY_HOST.keys()) {
1594
+ if (isWildcard(serverName) && hostname.endsWith(wildcardSuffix(serverName))) {
1595
+ return true;
1596
+ }
1597
+ }
1598
+ return false;
1599
+ }
1600
+
1601
+ /**
1602
+ * Bind a runtime ingress route for one `tls: [{ hostname, port }]` entry on a
1603
+ * {@link RuntimeServiceSpec} — the runtime twin of a boot service's `tls`.
1604
+ * Mints a leaf cert (unless one already covers the hostname), stands up a
1605
+ * TLS-terminating reverse proxy at `https://<hostname>/` → `service:port`,
1606
+ * also serves plain `http://<hostname>/`, and points the hostname at the
1607
+ * daemon gateway in the resolver registry. Idempotent per hostname.
1608
+ *
1609
+ * Everything it mutates (the live route tables, the :443 cert table, the
1610
+ * names REGISTRY) is daemon-process state, so the binding forks with the
1611
+ * per-test snapshot exactly like fake state — a `dependsOn` child inherits
1612
+ * it, siblings forked from an earlier snapshot never see it.
1613
+ */
1614
+ async function bindRuntimeTls(hostname: string, service: string, port: number): Promise<void> {
1615
+ // Reuse the boot primitives for validation + lowercasing; throws on a
1616
+ // malformed hostname / upstream just like a boot `tls` would at load.
1617
+ const decl = makeProxyDecl(hostname, { service, port });
1618
+ const host = decl.hostname;
1619
+ if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
1620
+ throw new Error(
1621
+ `runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`,
1622
+ );
1623
+ }
1624
+ const Bun = requireBun();
1625
+ const route: Route = { kind: "proxy", service, port };
1626
+
1627
+ // Plain HTTP on :80 (parity with boot `tls`, which serves both schemes).
1628
+ let httpRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT);
1629
+ if (!httpRoutes) {
1630
+ httpRoutes = new Map<string, Route>();
1631
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTP_PORT, httpRoutes);
1632
+ }
1633
+ httpRoutes.set(host, route);
1634
+ if (!INGRESS_HTTP_SERVERS.has(INGRESS_HTTP_PORT)) {
1635
+ INGRESS_HTTP_SERVERS.set(
1636
+ INGRESS_HTTP_PORT,
1637
+ bindIngressServer(Bun, INGRESS_HTTP_PORT, httpRoutes, `port ${INGRESS_HTTP_PORT}`),
1638
+ );
1639
+ }
1640
+
1641
+ // HTTPS on :443. A new cert forces a listener rebind; an already-covered
1642
+ // hostname (exact dup or a boot wildcard) just needs the route entry.
1643
+ const needCert = !certCovers(host);
1644
+ if (needCert) {
1645
+ HTTPS_CERT_BY_HOST.set(host, await generateHostCert(host, [host]));
1646
+ }
1647
+ const httpsRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map<string, Route>();
1648
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, httpsRoutes);
1649
+ httpsRoutes.set(host, route);
1650
+ if (needCert || !INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) {
1651
+ rebindHttpsListener(Bun);
1652
+ }
1653
+
1654
+ // Resolve the hostname to the daemon gateway (where :443/:80 listen).
1655
+ const gw = await bridgeGatewayIp();
1656
+ REGISTRY.hosts[host] = gw;
1657
+ await writeRegistry();
1658
+ // eslint-disable-next-line no-console
1659
+ console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
1660
+ }
1661
+
1662
+ /**
1663
+ * Undo {@link bindRuntimeTls} for one hostname when its runtime service is
1664
+ * stopped: drop the route (so it 404s) and the registry entry. The cert is
1665
+ * left in the SNI table — harmless without a route, and removing it would
1666
+ * mean an avoidable :443 rebind.
1667
+ */
1668
+ async function unbindRuntimeTls(hostname: string): Promise<void> {
1669
+ const host = hostname.toLowerCase();
1670
+ INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT)?.delete(host);
1671
+ INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT)?.delete(host);
1672
+ if (host in REGISTRY.hosts) {
1673
+ delete REGISTRY.hosts[host];
1674
+ await writeRegistry();
1675
+ }
1676
+ }
1677
+
1533
1678
  /**
1534
1679
  * Spin up one Bun.serve listener bound to (port, optional TLS) that
1535
1680
  * dispatches every request to the matching Route by Host header.
@@ -2020,6 +2165,11 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
2020
2165
  await runContainer(svc, tag, flags, aliases);
2021
2166
  await waitForReady(svc);
2022
2167
  const ip = (await serviceContainerIp(svc.name)) ?? "";
2168
+ // `tls` is the runtime twin of a boot service's: stand up a
2169
+ // TLS-terminating reverse proxy at https://<hostname>/ → this container.
2170
+ for (const entry of svc.tls ?? []) {
2171
+ await bindRuntimeTls(entry.hostname, svc.name, entry.port);
2172
+ }
2023
2173
  RUNTIME_SERVICES.set(svc.name, svc);
2024
2174
  recordEnv({
2025
2175
  op: "startService",
@@ -2046,8 +2196,10 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
2046
2196
  async function stopRuntimeService(name: string): Promise<void> {
2047
2197
  const t0 = Date.now();
2048
2198
  const resv = reserveEvent();
2199
+ const svc = RUNTIME_SERVICES.get(name);
2049
2200
  await docker(["rm", "-f", name], 30_000);
2050
2201
  RUNTIME_SERVICES.delete(name);
2202
+ for (const entry of svc?.tls ?? []) await unbindRuntimeTls(entry.hostname);
2051
2203
  recordEnv({ op: "stopService", service: name, durationMs: Date.now() - t0 }, resv);
2052
2204
  }
2053
2205
 
package/src/index.ts CHANGED
@@ -348,22 +348,28 @@ export type ReadyCheck =
348
348
  /**
349
349
  * Spec for a service started at runtime via {@link TestContext.startService}
350
350
  * (or the `ctx` handed to a fake). It's a normal {@link ServiceConfig} plus a
351
- * required `name`, minus the two fields that only make sense at boot:
352
- *
353
- * - `tls` — runtime services are reached *directly* by their own IP on
354
- * `spectest-net` (like a real machine on a network), not through the
355
- * daemon's HTTP ingress, so there's no proxy/cert to configure. Map a
356
- * friendly DNS name onto one with {@link TestContext.dnsName}.
357
- * - `dependsOn` — there is no boot DAG at runtime; the caller orders
358
- * `startService` calls itself with `await`.
351
+ * required `name`, minus only `dependsOn` (there is no boot DAG at runtime;
352
+ * the caller orders `startService` calls itself with `await`).
359
353
  *
360
354
  * The container joins `spectest-net` with its own IP and is resolvable by
361
355
  * `name` (single-label, via the resolver / docker embedded DNS) and by any
362
356
  * `hostnames` (extra `--network-alias`es). Like everything else in the VM it
363
357
  * is captured by the per-test post-state snapshot, so a `dependsOn` child
364
358
  * inherits the live container while siblings never see it.
359
+ *
360
+ * `tls: [{ hostname, port }]` works exactly as it does for a boot service:
361
+ * the daemon mints a leaf cert from the in-VM root CA and stands up a
362
+ * TLS-terminating reverse proxy at `https://<hostname>/` (and plain
363
+ * `http://<hostname>/`) → the container's `port`, binding it onto the live
364
+ * `:443`/`:80` ingress listeners the moment the container is ready. The
365
+ * hostname resolves to the daemon gateway for tests, `ctx.browser()`, and
366
+ * peer containers. Because the route lives in daemon memory (and the
367
+ * resolver registry file), it forks with the per-test snapshot like fake
368
+ * state, and is torn down when the service is stopped. This lets a runtime
369
+ * provider mint a CA-trusted HTTPS endpoint on demand — e.g. a per-DB proxy
370
+ * the Neon serverless driver reaches at its *default* `https://<host>/sql`.
365
371
  */
366
- export interface RuntimeServiceSpec extends Omit<ServiceConfig, "tls" | "dependsOn"> {
372
+ export interface RuntimeServiceSpec extends Omit<ServiceConfig, "dependsOn"> {
367
373
  /**
368
374
  * Container name and primary DNS name on `spectest-net`. Must be unique
369
375
  * within the current fork — generate a fresh one per provisioned instance