@apifuse/provider-sdk 2.2.0-beta.11 → 2.2.0-beta.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.
Files changed (59) hide show
  1. package/AUTHORING.md +238 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +44 -2
  4. package/bin/apifuse-pack-smoke.ts +14 -0
  5. package/bin/apifuse-pack-types.ts +40 -1
  6. package/bin/apifuse-record.ts +622 -57
  7. package/bin/apifuse-submit-check.ts +43 -10
  8. package/dist/config/loader.d.ts +9 -1
  9. package/dist/config/loader.js +9 -0
  10. package/dist/define.d.ts +2 -1
  11. package/dist/define.js +61 -3
  12. package/dist/errors.d.ts +5 -0
  13. package/dist/errors.js +15 -0
  14. package/dist/fixture-sanitization.d.ts +26 -0
  15. package/dist/fixture-sanitization.js +216 -0
  16. package/dist/index.d.ts +4 -3
  17. package/dist/index.js +2 -1
  18. package/dist/provider.d.ts +2 -1
  19. package/dist/provider.js +1 -0
  20. package/dist/runtime/http.js +86 -32
  21. package/dist/runtime/instrumentation.js +295 -9
  22. package/dist/runtime/native-network.d.ts +53 -0
  23. package/dist/runtime/native-network.js +477 -0
  24. package/dist/runtime/proxy-nodemaven.d.ts +14 -0
  25. package/dist/runtime/proxy-nodemaven.js +20 -2
  26. package/dist/runtime/request-options.d.ts +68 -1
  27. package/dist/runtime/request-options.js +548 -0
  28. package/dist/runtime/stealth.d.ts +3 -1
  29. package/dist/runtime/stealth.js +352 -86
  30. package/dist/server/index.d.ts +1 -1
  31. package/dist/server/index.js +1 -1
  32. package/dist/server/self-test-input-tokens.d.ts +2 -1
  33. package/dist/server/self-test-input-tokens.js +18 -14
  34. package/dist/stream-evidence.d.ts +74 -0
  35. package/dist/stream-evidence.js +785 -0
  36. package/dist/testing/index.d.ts +1 -1
  37. package/dist/testing/index.js +1 -1
  38. package/dist/testing/run.d.ts +32 -2
  39. package/dist/testing/run.js +451 -19
  40. package/dist/types.d.ts +201 -7
  41. package/package.json +3 -1
  42. package/src/config/loader.ts +22 -1
  43. package/src/define.ts +81 -3
  44. package/src/errors.ts +15 -0
  45. package/src/fixture-sanitization.ts +247 -0
  46. package/src/index.ts +45 -1
  47. package/src/provider.ts +37 -0
  48. package/src/runtime/http.ts +144 -38
  49. package/src/runtime/instrumentation.ts +424 -8
  50. package/src/runtime/native-network.ts +600 -0
  51. package/src/runtime/proxy-nodemaven.ts +37 -2
  52. package/src/runtime/request-options.ts +680 -1
  53. package/src/runtime/stealth.ts +420 -88
  54. package/src/server/index.ts +4 -1
  55. package/src/server/self-test-input-tokens.ts +29 -14
  56. package/src/stream-evidence.ts +988 -0
  57. package/src/testing/index.ts +9 -1
  58. package/src/testing/run.ts +608 -12
  59. package/src/types.ts +235 -7
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env bun
2
- // @ts-nocheck
3
2
 
4
3
  import { existsSync, readFileSync, statSync } from "node:fs";
5
4
  import { mkdir, writeFile } from "node:fs/promises";
@@ -14,14 +13,49 @@ import {
14
13
  createSttClientFromEnv,
15
14
  executeOperation,
16
15
  type HttpClient,
16
+ type HttpResponse,
17
+ type HttpStreamResponse,
17
18
  type ProviderContext,
18
19
  type ProviderDefinition,
19
20
  ProviderError,
21
+ type RequestOptions,
20
22
  type StealthClient,
21
23
  TransportError,
22
24
  ValidationError,
23
25
  } from "../src/index.js";
26
+ import type { JsonValue } from "../src/contract-json.js";
27
+ import {
28
+ isSensitiveFixtureKey,
29
+ requestPathForFixture,
30
+ sanitizeDiagnosticText,
31
+ sanitizeFixtureString,
32
+ } from "../src/fixture-sanitization.js";
24
33
  import { createMemoryProviderRuntimeState } from "../src/runtime/state.js";
34
+ import {
35
+ REDACTED_QUERY_VALUE,
36
+ isSensitiveKey,
37
+ normalizeSensitiveParams,
38
+ parseHttpRequestInvocation,
39
+ redactSensitiveError,
40
+ redactSensitiveText,
41
+ redactUrlQueryParams,
42
+ replaceRequestOptionsInHttpInvocation,
43
+ requestOptionsFromHttpInvocation,
44
+ serializeRequestUrl,
45
+ } from "../src/runtime/request-options.js";
46
+ import { parseSchema } from "../src/schema.js";
47
+ import {
48
+ captureStreamEvidence,
49
+ createStreamCaptureEnvelope,
50
+ findStreamCaptureGroup,
51
+ findStreamEvidenceRecords,
52
+ hasStreamEvidenceMarker,
53
+ parseStreamEvidenceRecord,
54
+ STREAM_PREVIEW_BYTES,
55
+ type StreamCaptureGroupItem,
56
+ type StreamEvidenceCapture,
57
+ type StreamEvidenceRequest,
58
+ } from "../src/stream-evidence.js";
25
59
 
26
60
  type CliArgs = {
27
61
  append: boolean;
@@ -39,57 +73,95 @@ const HELP_TEXT = `Usage: apifuse record [path] --operation <operation> --params
39
73
 
40
74
  Calls a real upstream-backed operation through ctx.http or ctx.stealth and writes __fixtures__/raw.json.
41
75
 
76
+ Streaming responses are recorded as evidence (status, selected headers, full-body SHA-256 and byte
77
+ count, plus a ${STREAM_PREVIEW_BYTES}-byte base64 preview). Test replay is evidence-only: ctx.http.stream exposes the
78
+ preview as its body and the original body_sha256/body_bytes as response metadata.
79
+ When an operation opens multiple streams, all evidence records are saved in stream call order.
80
+ Mixed JSON/stream operations save a tagged call-ordered envelope so snapshot replay can route each response.
81
+ ctx.http.sse() recording is unsupported and fails explicitly.
82
+
42
83
  Options:
43
84
  --operation, -o <name> operation to call
44
85
  --params, -p <json> JSON input passed to the operation (default: {})
45
86
  --append preserve the existing fixture and append this capture
46
87
  --sanitize redact common token/header fields (default)
47
- --no-sanitize write the captured upstream payload as-is
88
+ --no-sanitize disable common-field redaction (sensitiveParams are always redacted)
48
89
  --help, -h show this help
49
90
 
50
91
  Example:
51
92
  apifuse record providers/korea-air-quality --operation realtime --params '{"stationName":"jongno"}'`;
52
93
 
53
94
  export async function main() {
95
+ let capture: ReturnType<typeof createCaptureContext> | undefined;
54
96
  try {
55
97
  const args = parseArgs(normalizeArgs(process.argv.slice(2)));
56
98
  const location = resolveProviderLocation(args.providerPath);
57
99
  const provider = await loadProvider(location.rootDir);
58
100
  const operationName = resolveOperationName(provider, args.operation);
59
101
  const operation = provider.operations[operationName];
60
- const parsedParams = parseParams(operation, args.params);
102
+ const parsedParams = await parseParams(operation, args.params);
61
103
 
62
- const capture = createCaptureContext(
104
+ capture = createCaptureContext(
63
105
  provider,
64
106
  resolveOperationBaseUrl(provider, operationName),
107
+ args.sanitize,
65
108
  );
66
109
 
67
110
  console.log(`[apifuse record] Calling ${operationName} on ${provider.id}...`);
68
111
 
69
- const result = await executeOperation(provider, operationName, capture.ctx, parsedParams);
70
- const captured = capture.getCapturedRaw();
112
+ let result: unknown;
113
+ try {
114
+ result = await executeOperation(provider, operationName, capture.ctx, parsedParams);
115
+ } catch (operationError) {
116
+ let partial: unknown;
117
+ try {
118
+ partial = await capture.getCapturedRaw();
119
+ } catch (finalizationError) {
120
+ throw new StreamRecorderError("Operation and stream finalization both failed.", [
121
+ operationError,
122
+ finalizationError,
123
+ ]);
124
+ }
125
+ const streamCount = findStreamEvidenceRecords(partial).length;
126
+ if (streamCount > 0) {
127
+ throw new StreamRecorderError(
128
+ `Operation failed after finalizing ${streamCount} stream capture${streamCount === 1 ? "" : "s"}.`,
129
+ [operationError],
130
+ );
131
+ }
132
+ throw operationError;
133
+ }
134
+ const captured = await capture.getCapturedRaw();
71
135
 
72
136
  if (captured === undefined) {
73
137
  throw new Error(`No upstream response was captured for ${provider.id}.${operationName}.`);
74
138
  }
75
139
 
76
- const rawPayload = args.sanitize ? sanitizeFixture(captured) : captured;
140
+ const sensitiveParams = capture.getCapturedSensitiveParams();
141
+ const rawPayload = jsonFixtureValue(captured);
77
142
  const fixturePath = resolve(location.rootDir, "__fixtures__", "raw.json");
78
- const nextPayload = await prepareFixturePayload(fixturePath, rawPayload, args.append);
143
+ const redactedCapture = redactFixture(rawPayload, sensitiveParams, args.sanitize);
144
+ const mergedPayload = await prepareFixturePayload(fixturePath, redactedCapture, args.append);
145
+ // Mandatory query-secret redaction applies to the merged history, including
146
+ // values discovered in older declared-key URL positions. Optional common-
147
+ // field sanitization applies only to this run's new capture so --append does
148
+ // not rewrite deliberately preserved historical fields.
149
+ const historicalSensitiveParams = discoverSensitiveQueryValues(mergedPayload, sensitiveParams);
150
+ const nextPayload = redactFixture(mergedPayload, historicalSensitiveParams, false);
79
151
 
80
152
  await mkdir(dirname(fixturePath), { recursive: true });
81
153
  await writeFile(fixturePath, `${JSON.stringify(nextPayload, null, 2)}\n`);
82
154
 
83
155
  console.log(
84
156
  `[apifuse record] Captured response (${formatBytes(
85
- Buffer.byteLength(JSON.stringify(rawPayload)),
157
+ Buffer.byteLength(JSON.stringify(redactedCapture)),
86
158
  )})`,
87
159
  );
88
160
  console.log(`[apifuse record] Saved to ${relative(process.cwd(), fixturePath)}`);
89
161
 
90
162
  void result;
91
163
  } catch (error) {
92
- handleCliError(error);
164
+ handleCliError(error, capture?.getCapturedSensitiveParams().values);
93
165
  }
94
166
  }
95
167
 
@@ -174,13 +246,29 @@ function parseArgs(argv: string[]): CliArgs {
174
246
  return { append, providerPath, operation, params, sanitize };
175
247
  }
176
248
 
177
- function handleCliError(error: unknown): never {
178
- const message = formatCliError(error);
249
+ function handleCliError(error: unknown, sensitiveValues: readonly string[] = []): never {
250
+ const message = redactSensitiveText(formatCliError(error), sensitiveValues);
179
251
  console.error(`[apifuse record] ${message}`);
180
252
  process.exit(1);
181
253
  }
182
254
 
183
- function formatCliError(error: unknown): string {
255
+ class StreamRecorderError extends Error {
256
+ readonly diagnosticCauses: readonly unknown[];
257
+
258
+ constructor(message: string, diagnosticCauses: readonly unknown[]) {
259
+ super(message, { cause: diagnosticCauses[0] });
260
+ this.name = "StreamRecorderError";
261
+ this.diagnosticCauses = diagnosticCauses;
262
+ }
263
+ }
264
+
265
+ export function formatCliError(error: unknown): string {
266
+ if (error instanceof StreamRecorderError) {
267
+ return [
268
+ sanitizeDiagnosticText(error.message),
269
+ ...error.diagnosticCauses.map((cause) => `cause=${formatDiagnosticCause(cause)}`),
270
+ ].join(" ");
271
+ }
184
272
  if (error instanceof TransportError) {
185
273
  return [
186
274
  error.message,
@@ -201,12 +289,32 @@ function formatCliError(error: unknown): string {
201
289
  }
202
290
 
203
291
  if (error instanceof Error) {
292
+ if (/^Stream capture\b/.test(error.message) && error.cause !== undefined) {
293
+ return `${sanitizeDiagnosticText(error.message)} cause=${formatDiagnosticCause(error.cause)}`;
294
+ }
204
295
  return error.message;
205
296
  }
206
297
 
207
298
  return String(error);
208
299
  }
209
300
 
301
+ function formatDiagnosticCause(cause: unknown): string {
302
+ if (cause instanceof StreamRecorderError) {
303
+ return [
304
+ sanitizeDiagnosticText(cause.message),
305
+ ...cause.diagnosticCauses.map((nested) => `cause=${formatDiagnosticCause(nested)}`),
306
+ ].join(" ");
307
+ }
308
+ if (!(cause instanceof Error)) return sanitizeDiagnosticText(String(cause));
309
+ const code =
310
+ "code" in cause && typeof cause.code === "string"
311
+ ? ` code=${sanitizeDiagnosticText(cause.code)}`
312
+ : "";
313
+ const nestedCause =
314
+ cause.cause === undefined ? "" : ` cause=${formatDiagnosticCause(cause.cause)}`;
315
+ return `${sanitizeDiagnosticText(cause.message)}${code}${nestedCause}`;
316
+ }
317
+
210
318
  function resolveProviderLocation(inputPath?: string) {
211
319
  const originalInput = inputPath ?? process.cwd();
212
320
  const resolvedInput = resolve(process.cwd(), originalInput);
@@ -284,7 +392,10 @@ function resolveOperationName(provider: ProviderRuntime, operationName?: string)
284
392
  return firstOperation;
285
393
  }
286
394
 
287
- function parseParams(operation: ProviderRuntime["operations"][string], value: string): unknown {
395
+ async function parseParams(
396
+ operation: ProviderRuntime["operations"][string],
397
+ value: string,
398
+ ): Promise<unknown> {
288
399
  let parsed: unknown;
289
400
 
290
401
  try {
@@ -295,7 +406,7 @@ function parseParams(operation: ProviderRuntime["operations"][string], value: st
295
406
  );
296
407
  }
297
408
 
298
- return operation.input ? operation.input.parse(parsed) : parsed;
409
+ return operation.input ? parseSchema(operation.input, parsed, "record.params") : parsed;
299
410
  }
300
411
 
301
412
  function resolveOperationBaseUrl(provider: ProviderRuntime, operationName: string): string {
@@ -309,15 +420,78 @@ function resolveOperationBaseUrl(provider: ProviderRuntime, operationName: strin
309
420
  return baseUrl;
310
421
  }
311
422
 
312
- function createCaptureContext(provider: ProviderRuntime, baseUrl: string) {
313
- let capturedRaw: unknown;
314
-
315
- const http = proxyHttpClient(createHttpClient(baseUrl), (response) => {
316
- capturedRaw = response.data;
423
+ function createCaptureContext(provider: ProviderRuntime, baseUrl: string, sanitize: boolean) {
424
+ let nextCaptureOrder = 0;
425
+ let nextStreamOrdinal = 0;
426
+ let capturedRaw: JsonValue | undefined;
427
+ const rawCaptures: Array<{ order: number; value: JsonValue }> = [];
428
+ const streamCaptures: Array<{
429
+ order: number;
430
+ request: StreamEvidenceRequest;
431
+ capture: StreamEvidenceCapture;
432
+ }> = [];
433
+ let capturedSse: { order: number; method: string; path: string } | undefined;
434
+ const sensitiveParamNames = new Set<string>();
435
+ const sensitiveParamValues = new Set<string>();
436
+ const captureSensitiveParams = (url: string, options?: RequestOptions) => {
437
+ captureSensitiveRequestValues(url, options, sensitiveParamNames, sensitiveParamValues);
438
+ };
439
+ const getCapturedSensitiveParams = (): CapturedSensitiveParams => ({
440
+ names: [...sensitiveParamNames],
441
+ values: [...sensitiveParamValues],
317
442
  });
318
- const stealth = proxyStealthClient(createStealthClient(baseUrl), (response) => {
319
- capturedRaw = normalizeCapturedStealthResponse(response);
443
+ const reserveCaptureOrder = () => {
444
+ nextCaptureOrder += 1;
445
+ return nextCaptureOrder;
446
+ };
447
+ const retainRawCapture = (order: number, value: unknown) => {
448
+ const json = jsonFixtureValue(value);
449
+ capturedRaw = json;
450
+ rawCaptures.push({ order, value: json });
451
+ };
452
+
453
+ const http = captureHttpClient(createHttpClient(baseUrl), {
454
+ reserveOrder: reserveCaptureOrder,
455
+ reserveStreamOrdinal: () => {
456
+ nextStreamOrdinal += 1;
457
+ return nextStreamOrdinal;
458
+ },
459
+ onSensitiveParams: captureSensitiveParams,
460
+ onResponse: (order, response) => retainRawCapture(order, response.data),
461
+ onStreamResponse: (order, ordinal, requestUrl, method, response) => {
462
+ const resolvedRequestUrl = new URL(requestUrl, baseUrl).toString();
463
+ const request = {
464
+ ordinal,
465
+ method,
466
+ path: requestPathForFixture(resolvedRequestUrl),
467
+ };
468
+ const capture = captureStreamEvidence(response, {
469
+ requestUrl: resolvedRequestUrl,
470
+ request,
471
+ ...(sanitize
472
+ ? {
473
+ sanitizeFixture: (value: JsonValue) =>
474
+ jsonFixtureValue(sanitizeStreamFixture(value, getCapturedSensitiveParams())),
475
+ }
476
+ : {}),
477
+ });
478
+ streamCaptures.push({ order, request, capture });
479
+ return capture.response;
480
+ },
481
+ onSseResponse: (order, requestUrl, method) => {
482
+ capturedSse = {
483
+ order,
484
+ method,
485
+ path: requestPathForFixture(new URL(requestUrl, baseUrl).toString()),
486
+ };
487
+ },
320
488
  });
489
+ const stealth = proxyStealthClient(
490
+ createStealthClient(baseUrl),
491
+ captureSensitiveParams,
492
+ (order, response) => retainRawCapture(order, normalizeCapturedStealthResponse(response)),
493
+ reserveCaptureOrder,
494
+ );
321
495
 
322
496
  const env = {
323
497
  get: (key: string) => process.env[key],
@@ -374,58 +548,258 @@ function createCaptureContext(provider: ProviderRuntime, baseUrl: string) {
374
548
 
375
549
  return {
376
550
  ctx,
377
- getCapturedRaw: () => capturedRaw,
551
+ getCapturedRaw: async () => {
552
+ if (streamCaptures.length === 0) {
553
+ if (capturedSse) throw unsupportedSseCaptureError(capturedSse);
554
+ return capturedRaw;
555
+ }
556
+
557
+ const settled = await Promise.allSettled(
558
+ streamCaptures.map(async ({ order, request, capture }) => ({
559
+ order,
560
+ request,
561
+ value: redactStreamEvidence(await capture.getEvidence(), getCapturedSensitiveParams()),
562
+ })),
563
+ );
564
+ const failures = settled.flatMap((result, index) =>
565
+ result.status === "rejected"
566
+ ? [
567
+ new StreamRecorderError(
568
+ `Stream finalization failed: method=${streamCaptures[index]!.request.method} path=${streamCaptures[index]!.request.path} ordinal=${streamCaptures[index]!.request.ordinal}.`,
569
+ [result.reason],
570
+ ),
571
+ ]
572
+ : [],
573
+ );
574
+ if (failures.length > 0) {
575
+ throw new StreamRecorderError(
576
+ `${failures.length} stream capture${failures.length === 1 ? "" : "s"} failed to finalize.`,
577
+ failures,
578
+ );
579
+ }
580
+ const evidence = settled.flatMap((result) =>
581
+ result.status === "fulfilled" ? [result.value] : [],
582
+ );
583
+ if (capturedSse) throw unsupportedSseCaptureError(capturedSse);
584
+ const timeline: StreamCaptureGroupItem[] = [...rawCaptures, ...evidence]
585
+ .sort((left, right) => left.order - right.order)
586
+ .map((item) =>
587
+ "request" in item
588
+ ? { kind: "stream" as const, evidence: item.value }
589
+ : { kind: "response" as const, value: item.value },
590
+ );
591
+ return createStreamCaptureEnvelope(timeline);
592
+ },
593
+ getCapturedSensitiveParams,
378
594
  };
379
595
  }
380
596
 
381
- function proxyHttpClient(
382
- client: HttpClient,
383
- onResponse: (response: Awaited<ReturnType<HttpClient["get"]>>) => void,
384
- ): HttpClient {
385
- return new Proxy(client, {
386
- get(target, prop, receiver) {
387
- const value = Reflect.get(target, prop, receiver);
597
+ type CapturedSensitiveParams = {
598
+ names: readonly string[];
599
+ values: readonly string[];
600
+ };
388
601
 
389
- if (typeof value !== "function") {
390
- return value;
391
- }
602
+ function captureSensitiveRequestValues(
603
+ url: string,
604
+ options: RequestOptions | undefined,
605
+ names: Set<string>,
606
+ values: Set<string>,
607
+ ): void {
608
+ const sensitiveParams = normalizeSensitiveParams(options?.sensitiveParams);
609
+ if (sensitiveParams === undefined) return;
610
+ if (!sensitiveParams || typeof sensitiveParams !== "object" || Array.isArray(sensitiveParams)) {
611
+ throw new TypeError("sensitiveParams must be an object whose values are strings.");
612
+ }
392
613
 
393
- return async (...args: unknown[]) => {
394
- const response = await value.apply(target, args);
395
- onResponse(response);
396
- return response;
397
- };
614
+ const entries = Object.entries(sensitiveParams);
615
+ for (const [key, value] of entries) {
616
+ if (typeof value !== "string") {
617
+ throw new TypeError(`sensitiveParams.${key} must be a string.`);
618
+ }
619
+ names.add(key);
620
+ if (value !== "") values.add(value);
621
+ }
622
+
623
+ let serializedUrl: ReturnType<typeof serializeRequestUrl>;
624
+ try {
625
+ const absoluteUrl = new URL(String(url), "http://apifuse.invalid").toString();
626
+ serializedUrl = serializeRequestUrl(absoluteUrl, options?.params, sensitiveParams);
627
+ } catch (error) {
628
+ const structural = redactUrlQueryParams(String(url), [...names]);
629
+ const safeUrl = redactSensitiveText(structural.redactedUrl, [
630
+ ...values,
631
+ ...structural.sensitiveValues,
632
+ ]);
633
+ const causeKind = error instanceof Error ? error.name : typeof error;
634
+ throw new TypeError(`Cannot securely record sensitiveParams for "${safeUrl}" (${causeKind}).`, {
635
+ cause: redactSensitiveError(
636
+ error,
637
+ [...values, ...structural.sensitiveValues],
638
+ String(url),
639
+ structural.redactedUrl,
640
+ ),
641
+ });
642
+ }
643
+
644
+ for (const value of serializedUrl.sensitiveValues) {
645
+ if (value !== "") values.add(value);
646
+ }
647
+ }
648
+
649
+ function snapshotRequestOptions<T extends RequestOptions>(options: T): T {
650
+ return {
651
+ ...options,
652
+ ...(options.params
653
+ ? {
654
+ params: Object.fromEntries(
655
+ Object.entries(options.params).map(([key, value]) => [
656
+ key,
657
+ Array.isArray(value) ? [...value] : value,
658
+ ]),
659
+ ),
660
+ }
661
+ : {}),
662
+ ...(normalizeSensitiveParams(options.sensitiveParams)
663
+ ? { sensitiveParams: { ...options.sensitiveParams } }
664
+ : {}),
665
+ };
666
+ }
667
+
668
+ type HttpCaptureCallbacks = {
669
+ reserveOrder(): number;
670
+ reserveStreamOrdinal(): number;
671
+ onSensitiveParams(url: string, options?: RequestOptions): void;
672
+ onResponse(order: number, response: HttpResponse): void;
673
+ onStreamResponse(
674
+ order: number,
675
+ ordinal: number,
676
+ requestUrl: string,
677
+ method: string,
678
+ response: HttpStreamResponse,
679
+ ): HttpStreamResponse;
680
+ onSseResponse(order: number, requestUrl: string, method: string): void;
681
+ };
682
+
683
+ function captureHttpClient(client: HttpClient, callbacks: HttpCaptureCallbacks): HttpClient {
684
+ const captureResponse = async (
685
+ order: number,
686
+ responsePromise: Promise<HttpResponse>,
687
+ ): Promise<HttpResponse> => {
688
+ const response = await responsePromise;
689
+ callbacks.onResponse(order, response);
690
+ return response;
691
+ };
692
+ const captureRequestOptions = (method: PropertyKey, args: unknown[]) => {
693
+ const invocation = parseHttpRequestInvocation(method, args);
694
+ const options = invocation ? requestOptionsFromHttpInvocation(invocation) : undefined;
695
+ if (!invocation || !options) return;
696
+ const snapshot = snapshotRequestOptions(options);
697
+ callbacks.onSensitiveParams(String(args[0]), snapshot);
698
+ replaceRequestOptionsInHttpInvocation(invocation, snapshot);
699
+ };
700
+
701
+ return {
702
+ request: (...args: Parameters<HttpClient["request"]>) => {
703
+ captureRequestOptions("request", args);
704
+ return captureResponse(callbacks.reserveOrder(), client.request(...args));
705
+ },
706
+ get: (...args: Parameters<HttpClient["get"]>) => {
707
+ captureRequestOptions("get", args);
708
+ return captureResponse(callbacks.reserveOrder(), client.get(...args));
398
709
  },
399
- }) as HttpClient;
710
+ post: (...args: Parameters<HttpClient["post"]>) => {
711
+ captureRequestOptions("post", args);
712
+ return captureResponse(callbacks.reserveOrder(), client.post(...args));
713
+ },
714
+ put: (...args: Parameters<HttpClient["put"]>) => {
715
+ captureRequestOptions("put", args);
716
+ return captureResponse(callbacks.reserveOrder(), client.put(...args));
717
+ },
718
+ delete: (...args: Parameters<HttpClient["delete"]>) => {
719
+ captureRequestOptions("delete", args);
720
+ return captureResponse(callbacks.reserveOrder(), client.delete(...args));
721
+ },
722
+ stream: async (...args: Parameters<HttpClient["stream"]>) => {
723
+ captureRequestOptions("stream", args);
724
+ const order = callbacks.reserveOrder();
725
+ const ordinal = callbacks.reserveStreamOrdinal();
726
+ const method = (args[1]?.method ?? "GET").toUpperCase();
727
+ const response = await client.stream(...args);
728
+ return callbacks.onStreamResponse(order, ordinal, args[0], method, response);
729
+ },
730
+ sse: async (...args: Parameters<HttpClient["sse"]>) => {
731
+ captureRequestOptions("sse", args);
732
+ const order = callbacks.reserveOrder();
733
+ const response = await client.sse(...args);
734
+ callbacks.onSseResponse(order, args[0], (args[1]?.method ?? "GET").toUpperCase());
735
+ return response;
736
+ },
737
+ };
400
738
  }
401
739
 
402
740
  type StealthSession = ReturnType<StealthClient["createSession"]>;
403
741
 
404
742
  function proxyStealthClient(
405
743
  client: StealthClient,
406
- onResponse: (response: Awaited<ReturnType<StealthClient["fetch"]>>) => void,
744
+ onSensitiveParams: (url: string, options?: RequestOptions) => void,
745
+ onResponse: (order: number, response: Awaited<ReturnType<StealthClient["fetch"]>>) => void,
746
+ reserveOrder: () => number,
407
747
  ): StealthClient {
408
748
  return {
409
749
  fetch: async (...args: Parameters<StealthClient["fetch"]>) => {
750
+ const order = reserveOrder();
751
+ if (args[1]) args[1] = snapshotRequestOptions(args[1]);
752
+ onSensitiveParams(args[0], args[1]);
410
753
  const response = await client.fetch(...args);
411
- onResponse(response);
754
+ if (response.url) onSensitiveParams(response.url, args[1]);
755
+ onResponse(order, response);
412
756
  return response;
413
757
  },
414
758
  createSession: (...args: Parameters<StealthClient["createSession"]>) =>
415
- proxyStealthSession(client.createSession(...args), onResponse),
759
+ proxyStealthSession(
760
+ client.createSession(...args),
761
+ onSensitiveParams,
762
+ onResponse,
763
+ reserveOrder,
764
+ ),
416
765
  };
417
766
  }
418
767
 
419
768
  function proxyStealthSession(
420
769
  session: StealthSession,
421
- onResponse: (response: Awaited<ReturnType<StealthClient["fetch"]>>) => void,
770
+ onSensitiveParams: (url: string, options?: RequestOptions) => void,
771
+ onResponse: (order: number, response: Awaited<ReturnType<StealthClient["fetch"]>>) => void,
772
+ reserveOrder: () => number,
422
773
  ): StealthSession {
423
774
  return {
424
775
  fetch: async (...args: Parameters<StealthSession["fetch"]>) => {
776
+ const order = reserveOrder();
777
+ if (args[1]) args[1] = snapshotRequestOptions(args[1]);
778
+ onSensitiveParams(args[0], args[1]);
425
779
  const response = await session.fetch(...args);
426
- onResponse(response);
780
+ if (response.url) onSensitiveParams(response.url, args[1]);
781
+ onResponse(order, response);
427
782
  return response;
428
783
  },
784
+ cookies: session.cookies,
785
+ redirects: {
786
+ run: async (...args: Parameters<StealthSession["redirects"]["run"]>) => {
787
+ const order = reserveOrder();
788
+ args[0] = snapshotRequestOptions(args[0]);
789
+ onSensitiveParams(args[0].url, args[0]);
790
+ const callerStopWhen = args[0].stopWhen;
791
+ args[0].stopWhen = async (hop) => {
792
+ for (const url of [hop.url, hop.location, hop.nextUrl]) {
793
+ if (url) onSensitiveParams(url, args[0]);
794
+ }
795
+ return callerStopWhen ? await callerStopWhen(hop) : false;
796
+ };
797
+ const result = await session.redirects.run(...args);
798
+ if (result.final.url) onSensitiveParams(result.final.url, args[0]);
799
+ onResponse(order, result.final);
800
+ return result;
801
+ },
802
+ },
429
803
  close: () => session.close(),
430
804
  };
431
805
  }
@@ -438,30 +812,192 @@ function normalizeCapturedStealthResponse(response: Awaited<ReturnType<StealthCl
438
812
  }
439
813
  }
440
814
 
441
- function sanitizeFixture(value: unknown): unknown {
815
+ function redactStreamEvidence(
816
+ evidence: Awaited<ReturnType<StreamEvidenceCapture["getEvidence"]>>,
817
+ sensitiveParams: CapturedSensitiveParams,
818
+ ): Awaited<ReturnType<StreamEvidenceCapture["getEvidence"]>> {
819
+ return parseStreamEvidenceRecord(redactFixture(evidence, sensitiveParams, false));
820
+ }
821
+
822
+ function sanitizeStreamFixture(value: unknown, sensitiveParams: CapturedSensitiveParams): unknown {
823
+ if (typeof value === "string") {
824
+ if (value !== "" && sensitiveParams.values.includes(value)) return REDACTED_QUERY_VALUE;
825
+ return sanitizeFixtureString(redactFixtureText(value, sensitiveParams));
826
+ }
442
827
  if (Array.isArray(value)) {
443
- return value.map((item) => sanitizeFixture(item));
828
+ return value.map((item) => sanitizeStreamFixture(item, sensitiveParams));
444
829
  }
445
-
446
830
  if (!value || typeof value !== "object") {
447
- return value;
831
+ return redactFixture(value, sensitiveParams, false);
448
832
  }
449
833
 
450
- const entries = Object.entries(value as MutableRecord).map(([key, entryValue]) => {
451
- if (isSensitiveKey(key)) {
452
- return [key, "[REDACTED]"] as const;
453
- }
834
+ const result: MutableRecord = Object.create(null) as MutableRecord;
835
+ for (const [key, entryValue] of Object.entries(value as MutableRecord)) {
836
+ const redactedKey = sensitiveParams.values.includes(key)
837
+ ? REDACTED_QUERY_VALUE
838
+ : redactFixtureText(key, sensitiveParams);
839
+ const uniqueKey = collisionSafeKey(result, redactedKey);
840
+ result[uniqueKey] =
841
+ isSensitiveFixtureKey(key) && !sensitiveParams.names.includes(key)
842
+ ? REDACTED_QUERY_VALUE
843
+ : sanitizeStreamFixture(entryValue, sensitiveParams);
844
+ }
845
+ return result;
846
+ }
454
847
 
455
- return [key, sanitizeFixture(entryValue)] as const;
456
- });
848
+ function redactStreamPreview(
849
+ bodyPreviewBase64: string,
850
+ sensitiveParams: CapturedSensitiveParams,
851
+ ): { bodyPreviewBase64: string; changed: boolean } {
852
+ const preview = Buffer.from(bodyPreviewBase64, "base64");
853
+ if (preview.byteLength === 0) return { bodyPreviewBase64, changed: false };
854
+
855
+ const text = new TextDecoder().decode(preview);
856
+ let redactedText = redactFixtureText(text, sensitiveParams);
857
+ const trimmed = text.trimEnd();
858
+ try {
859
+ const parsed = JSON.parse(trimmed) as unknown;
860
+ const structurallyRedacted = JSON.stringify(redactFixture(parsed, sensitiveParams, false));
861
+ if (structurallyRedacted !== JSON.stringify(parsed)) redactedText = structurallyRedacted;
862
+ } catch {
863
+ // Non-JSON previews still use the shared free-text sensitive-value policy.
864
+ }
865
+ if (redactedText === text) return { bodyPreviewBase64, changed: false };
457
866
 
458
- return Object.fromEntries(entries);
867
+ const fitted = Buffer.alloc(preview.byteLength, 0x20);
868
+ let redactedBytes = Buffer.from(redactedText);
869
+ if (redactedBytes.byteLength > fitted.byteLength) {
870
+ redactedBytes = Buffer.from(REDACTED_QUERY_VALUE);
871
+ }
872
+ redactedBytes.copy(fitted, 0, 0, Math.min(redactedBytes.byteLength, fitted.byteLength));
873
+ return { bodyPreviewBase64: fitted.toString("base64"), changed: true };
459
874
  }
460
875
 
461
- function isSensitiveKey(key: string): boolean {
462
- return /authorization|token|api[-_]?key/i.test(key);
876
+ function redactFixture(
877
+ value: unknown,
878
+ sensitiveParams: CapturedSensitiveParams,
879
+ sanitizeCommonFields: boolean,
880
+ ): unknown {
881
+ if (typeof value === "string") {
882
+ if (value !== "" && sensitiveParams.values.includes(value)) return REDACTED_QUERY_VALUE;
883
+ return redactFixtureText(value, sensitiveParams);
884
+ }
885
+
886
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
887
+ return sensitiveParams.values.includes(String(value)) ? REDACTED_QUERY_VALUE : value;
888
+ }
889
+
890
+ if (Array.isArray(value)) {
891
+ return value.map((item) => redactFixture(item, sensitiveParams, sanitizeCommonFields));
892
+ }
893
+
894
+ if (!value || typeof value !== "object") return value;
895
+
896
+ const result: MutableRecord = Object.create(null) as MutableRecord;
897
+ for (const [key, entryValue] of Object.entries(value as MutableRecord)) {
898
+ const redactedKey = sensitiveParams.values.includes(key)
899
+ ? REDACTED_QUERY_VALUE
900
+ : redactFixtureText(key, sensitiveParams);
901
+ const uniqueKey = collisionSafeKey(result, redactedKey);
902
+ result[uniqueKey] =
903
+ sanitizeCommonFields &&
904
+ (isSensitiveKey(key) || isSensitiveFixtureKey(key)) &&
905
+ !sensitiveParams.names.includes(key)
906
+ ? REDACTED_QUERY_VALUE
907
+ : redactFixture(entryValue, sensitiveParams, sanitizeCommonFields);
908
+ }
909
+ const record = value as MutableRecord;
910
+ if (record.__apifuse_stream__ === true && typeof record.body_preview_base64 === "string") {
911
+ const preview = redactStreamPreview(record.body_preview_base64, sensitiveParams);
912
+ result.body_preview_base64 = preview.bodyPreviewBase64;
913
+ if (preview.changed) result.preview_sanitized = true;
914
+ }
915
+ return result;
916
+ }
917
+
918
+ function redactFixtureText(text: string, sensitiveParams: CapturedSensitiveParams): string {
919
+ // Shared free-text policy: long values are unconditional substrings; values
920
+ // shorter than four characters require token boundaries. Exact scalar echoes
921
+ // and declared query-key positions are structurally redacted for every length.
922
+ let redacted = redactSensitiveText(text, sensitiveParams.values);
923
+ for (const name of sensitiveParams.names) {
924
+ let componentEncodedName = name;
925
+ try {
926
+ componentEncodedName = encodeURIComponent(name);
927
+ } catch {
928
+ // Lone surrogates remain covered by the raw and form-encoded variants.
929
+ }
930
+ const keyVariants = new Set([
931
+ name,
932
+ componentEncodedName,
933
+ new URLSearchParams({ [name]: "" }).toString().slice(0, -1),
934
+ ]);
935
+ for (const key of keyVariants) {
936
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
937
+ const queryValue = new RegExp(`(^|[?&])(${escapedKey}=)[^&#\\s"]*`, "g");
938
+ redacted = redacted.replace(queryValue, (_match, prefix, assignment) => {
939
+ return `${prefix}${assignment}${REDACTED_QUERY_VALUE}`;
940
+ });
941
+ }
942
+ }
943
+ return redacted;
463
944
  }
464
945
 
946
+ function discoverSensitiveQueryValues(
947
+ value: unknown,
948
+ sensitiveParams: CapturedSensitiveParams,
949
+ ): CapturedSensitiveParams {
950
+ const values = new Set(sensitiveParams.values);
951
+ const absoluteUrl = /\b[A-Za-z][A-Za-z\d+.-]*:\/\/[^\s<>"']+/g;
952
+ const discoverFromUrl = (url: string) => {
953
+ for (const discovered of redactUrlQueryParams(url, sensitiveParams.names).sensitiveValues) {
954
+ if (discovered !== "" && discovered !== REDACTED_QUERY_VALUE) values.add(discovered);
955
+ }
956
+ };
957
+ const visitText = (text: string) => {
958
+ if (!/\s/.test(text)) {
959
+ try {
960
+ new URL(text, "http://apifuse.invalid");
961
+ discoverFromUrl(text);
962
+ return;
963
+ } catch {
964
+ // Fall through to extracting absolute URL spans from prose.
965
+ }
966
+ }
967
+ for (const match of text.matchAll(absoluteUrl)) {
968
+ try {
969
+ new URL(match[0]);
970
+ discoverFromUrl(match[0]);
971
+ } catch {
972
+ // Ignore URI-like prose that is not a parseable URL.
973
+ }
974
+ }
975
+ };
976
+ const visit = (current: unknown): void => {
977
+ if (typeof current === "string") {
978
+ visitText(current);
979
+ return;
980
+ }
981
+ if (Array.isArray(current)) {
982
+ for (const item of current) visit(item);
983
+ return;
984
+ }
985
+ if (!current || typeof current !== "object") return;
986
+ for (const [key, entryValue] of Object.entries(current as MutableRecord)) {
987
+ visitText(key);
988
+ visit(entryValue);
989
+ }
990
+ };
991
+ visit(value);
992
+ return { names: sensitiveParams.names, values: [...values] };
993
+ }
994
+
995
+ function collisionSafeKey(record: MutableRecord, preferredKey: string): string {
996
+ if (!(preferredKey in record)) return preferredKey;
997
+ let suffix = 2;
998
+ while (`${preferredKey}#${suffix}` in record) suffix += 1;
999
+ return `${preferredKey}#${suffix}`;
1000
+ }
465
1001
  export async function prepareFixturePayload(
466
1002
  fixturePath: string,
467
1003
  payload: unknown,
@@ -493,7 +1029,18 @@ export async function prepareFixturePayload(
493
1029
  );
494
1030
  }
495
1031
 
1032
+ if (hasStreamEvidenceMarker(existing)) {
1033
+ const evidence = parseStreamEvidenceRecord(existing);
1034
+ return [createStreamCaptureEnvelope([{ kind: "stream", evidence }]), payload];
1035
+ }
496
1036
  if (Array.isArray(existing)) {
1037
+ if (existing.some((item) => hasStreamEvidenceMarker(item))) {
1038
+ const legacyGroup = findStreamCaptureGroup(existing);
1039
+ if (legacyGroup) {
1040
+ const prefix = existing.slice(0, -legacyGroup.items.length);
1041
+ return [...prefix, createStreamCaptureEnvelope(legacyGroup.items), payload];
1042
+ }
1043
+ }
497
1044
  return [...existing, payload];
498
1045
  }
499
1046
  if (existing !== null) {
@@ -510,6 +1057,24 @@ function formatBytes(bytes: number): string {
510
1057
  return `${(bytes / 1024).toFixed(1)} KB`;
511
1058
  }
512
1059
 
1060
+ function jsonFixtureValue(value: unknown): JsonValue {
1061
+ const serialized = JSON.stringify(value);
1062
+ if (serialized === undefined) {
1063
+ throw new Error("Captured upstream response is not JSON-serializable.");
1064
+ }
1065
+ return JSON.parse(serialized) as JsonValue;
1066
+ }
1067
+
1068
+ function unsupportedSseCaptureError(request: {
1069
+ order: number;
1070
+ method: string;
1071
+ path: string;
1072
+ }): Error {
1073
+ return new Error(
1074
+ `apifuse record does not support ctx.http.sse(): method=${request.method} path=${request.path} call=${request.order}.`,
1075
+ );
1076
+ }
1077
+
513
1078
  if (import.meta.main) {
514
1079
  await main();
515
1080
  }