@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.40

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