@apifuse/provider-sdk 2.2.0-beta.2 → 2.2.0-beta.21

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 (291) hide show
  1. package/AUTHORING.md +487 -0
  2. package/CHANGELOG.md +90 -0
  3. package/README.md +49 -3
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +44 -59
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +27 -52
  8. package/bin/apifuse-pack-check.ts +14 -0
  9. package/bin/apifuse-pack-smoke.ts +36 -81
  10. package/bin/apifuse-pack-types.ts +305 -0
  11. package/bin/apifuse-perf.ts +45 -127
  12. package/bin/apifuse-record.ts +659 -111
  13. package/bin/apifuse-submit-check.ts +546 -48
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml-semantics.ts +204 -0
  18. package/bin/submit-check-xml.ts +134 -0
  19. package/dist/auth-turn/index.d.ts +3 -3
  20. package/dist/auth.d.ts +2 -2
  21. package/dist/auth.js +9 -18
  22. package/dist/ceremonies/index.d.ts +9 -1
  23. package/dist/ceremonies/index.js +65 -18
  24. package/dist/cli/commands.d.ts +1 -1
  25. package/dist/cli/commands.js +8 -0
  26. package/dist/cli/create.d.ts +3 -0
  27. package/dist/cli/create.js +34 -35
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/config/loader.d.ts +176 -8
  32. package/dist/config/loader.js +424 -95
  33. package/dist/contract-serialization.d.ts +2 -2
  34. package/dist/contract-serialization.js +3 -6
  35. package/dist/contract-types.d.ts +2 -2
  36. package/dist/contract.d.ts +3 -3
  37. package/dist/contract.js +4 -6
  38. package/dist/define.d.ts +9 -1
  39. package/dist/define.js +304 -119
  40. package/dist/dev.d.ts +1 -1
  41. package/dist/dev.js +1 -1
  42. package/dist/error-resolution.d.ts +2 -0
  43. package/dist/error-resolution.js +90 -0
  44. package/dist/errors.d.ts +22 -1
  45. package/dist/errors.js +88 -0
  46. package/dist/fixture-sanitization.d.ts +26 -0
  47. package/dist/fixture-sanitization.js +216 -0
  48. package/dist/i18n/catalog.d.ts +2 -2
  49. package/dist/i18n/catalog.js +4 -10
  50. package/dist/i18n/index.d.ts +2 -2
  51. package/dist/i18n/index.js +2 -2
  52. package/dist/i18n/keys.d.ts +2 -2
  53. package/dist/index.d.ts +46 -42
  54. package/dist/index.js +41 -37
  55. package/dist/lint.d.ts +1 -1
  56. package/dist/lint.js +8 -15
  57. package/dist/native-address.d.ts +43 -0
  58. package/dist/native-address.js +281 -0
  59. package/dist/native-egress-policy.d.ts +31 -0
  60. package/dist/native-egress-policy.js +288 -0
  61. package/dist/observability.d.ts +5 -2
  62. package/dist/observability.js +48 -1
  63. package/dist/provider.d.ts +12 -11
  64. package/dist/provider.js +10 -9
  65. package/dist/public-schema-field-lint.d.ts +1 -1
  66. package/dist/recipes/gov-api.js +1 -1
  67. package/dist/runtime/auth-flow.d.ts +2 -1
  68. package/dist/runtime/auth-flow.js +4 -3
  69. package/dist/runtime/browser.d.ts +1 -1
  70. package/dist/runtime/browser.js +15 -29
  71. package/dist/runtime/cache.d.ts +1 -1
  72. package/dist/runtime/cache.js +4 -8
  73. package/dist/runtime/choice.d.ts +1 -1
  74. package/dist/runtime/choice.js +31 -35
  75. package/dist/runtime/credential.d.ts +1 -1
  76. package/dist/runtime/credential.js +1 -1
  77. package/dist/runtime/env.d.ts +1 -1
  78. package/dist/runtime/executor.d.ts +1 -1
  79. package/dist/runtime/executor.js +31 -4
  80. package/dist/runtime/http.d.ts +2 -2
  81. package/dist/runtime/http.js +387 -47
  82. package/dist/runtime/insights.d.ts +1 -1
  83. package/dist/runtime/insights.js +6 -13
  84. package/dist/runtime/instrumentation.d.ts +2 -2
  85. package/dist/runtime/instrumentation.js +345 -22
  86. package/dist/runtime/keyring.js +1 -1
  87. package/dist/runtime/namespace.js +1 -1
  88. package/dist/runtime/native-network.d.ts +127 -0
  89. package/dist/runtime/native-network.js +1298 -0
  90. package/dist/runtime/otlp.d.ts +1 -1
  91. package/dist/runtime/perf.d.ts +1 -1
  92. package/dist/runtime/provider.d.ts +1 -1
  93. package/dist/runtime/provider.js +1 -2
  94. package/dist/runtime/proxy-errors.d.ts +1 -1
  95. package/dist/runtime/proxy-errors.js +9 -7
  96. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  97. package/dist/runtime/proxy-nodemaven.js +146 -0
  98. package/dist/runtime/proxy-retry-policy.d.ts +2 -2
  99. package/dist/runtime/proxy-retry-policy.js +2 -2
  100. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  101. package/dist/runtime/proxy-telemetry.js +55 -52
  102. package/dist/runtime/redirects.d.ts +29 -0
  103. package/dist/runtime/redirects.js +36 -0
  104. package/dist/runtime/redis.d.ts +1 -1
  105. package/dist/runtime/redis.js +2 -4
  106. package/dist/runtime/request-options.d.ts +68 -1
  107. package/dist/runtime/request-options.js +548 -0
  108. package/dist/runtime/secrets.d.ts +27 -0
  109. package/dist/runtime/secrets.js +51 -0
  110. package/dist/runtime/state.d.ts +2 -2
  111. package/dist/runtime/state.js +238 -26
  112. package/dist/runtime/stealth.d.ts +5 -3
  113. package/dist/runtime/stealth.js +423 -143
  114. package/dist/runtime/stt.d.ts +1 -1
  115. package/dist/runtime/stt.js +11 -15
  116. package/dist/runtime/trace.d.ts +2 -2
  117. package/dist/runtime/trace.js +2 -4
  118. package/dist/runtime/waterfall.d.ts +1 -1
  119. package/dist/schema.d.ts +1 -1
  120. package/dist/schema.js +7 -15
  121. package/dist/serve.d.ts +1 -1
  122. package/dist/serve.js +1 -1
  123. package/dist/server/index.d.ts +7 -7
  124. package/dist/server/index.js +6 -6
  125. package/dist/server/self-test-input-tokens.d.ts +2 -1
  126. package/dist/server/self-test-input-tokens.js +18 -14
  127. package/dist/server/self-test-redaction.d.ts +1 -1
  128. package/dist/server/self-test-redaction.js +1 -1
  129. package/dist/server/self-test.d.ts +104 -3
  130. package/dist/server/self-test.js +673 -115
  131. package/dist/server/serve.d.ts +116 -4
  132. package/dist/server/serve.js +799 -128
  133. package/dist/server/types.d.ts +34 -9
  134. package/dist/server/types.js +8 -1
  135. package/dist/stateful/errors.d.ts +14 -0
  136. package/dist/stateful/errors.js +14 -0
  137. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  138. package/dist/stateful/http-provider-event-emitter.js +237 -0
  139. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  140. package/dist/stateful/http-session-owner-registry.js +210 -0
  141. package/dist/stateful/index.d.ts +18 -0
  142. package/dist/stateful/index.js +18 -0
  143. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  144. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  145. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  146. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  147. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  148. package/dist/stateful/provider-event-pipeline.js +1 -0
  149. package/dist/stateful/provider-events.d.ts +101 -0
  150. package/dist/stateful/provider-events.js +289 -0
  151. package/dist/stateful/session-key.d.ts +15 -0
  152. package/dist/stateful/session-key.js +86 -0
  153. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  154. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  155. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  156. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  157. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  158. package/dist/stateful/stateful-provider-adapter.js +287 -0
  159. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  160. package/dist/stateful/stateful-provider-observability.js +161 -0
  161. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  162. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  163. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  164. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  165. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  166. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  167. package/dist/stateful/stateful-provider-session-routing.d.ts +71 -0
  168. package/dist/stateful/stateful-provider-session-routing.js +353 -0
  169. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  170. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  171. package/dist/stateful-signing.d.ts +18 -0
  172. package/dist/stateful-signing.js +27 -0
  173. package/dist/stealth/profiles.d.ts +1 -1
  174. package/dist/stealth/profiles.js +5 -14
  175. package/dist/stream-evidence.d.ts +74 -0
  176. package/dist/stream-evidence.js +785 -0
  177. package/dist/stream.d.ts +1 -1
  178. package/dist/testing/index.d.ts +2 -2
  179. package/dist/testing/index.js +2 -2
  180. package/dist/testing/run.d.ts +32 -2
  181. package/dist/testing/run.js +478 -28
  182. package/dist/types.d.ts +342 -14
  183. package/dist/types.js +1 -0
  184. package/dist/user-input.d.ts +30 -0
  185. package/dist/user-input.js +66 -0
  186. package/package.json +16 -5
  187. package/src/auth-turn/index.ts +1 -1
  188. package/src/auth.ts +28 -86
  189. package/src/ceremonies/index.ts +103 -78
  190. package/src/cli/commands.ts +10 -0
  191. package/src/cli/create.ts +42 -35
  192. package/src/cli/prompt-assets.ts +865 -0
  193. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  194. package/src/config/loader.ts +652 -204
  195. package/src/contract-serialization.ts +5 -11
  196. package/src/contract-types.ts +2 -2
  197. package/src/contract.ts +12 -28
  198. package/src/define.ts +499 -498
  199. package/src/dev.ts +4 -9
  200. package/src/error-resolution.ts +91 -0
  201. package/src/errors.ts +110 -5
  202. package/src/fixture-sanitization.ts +247 -0
  203. package/src/i18n/catalog.ts +10 -32
  204. package/src/i18n/index.ts +2 -2
  205. package/src/i18n/keys.ts +5 -11
  206. package/src/index.ts +112 -42
  207. package/src/lint.ts +88 -152
  208. package/src/native-address.ts +340 -0
  209. package/src/native-egress-policy.ts +358 -0
  210. package/src/observability.ts +51 -1
  211. package/src/provider.ts +66 -11
  212. package/src/public-schema-field-lint.ts +7 -33
  213. package/src/recipes/gov-api.ts +2 -5
  214. package/src/runtime/auth-flow.ts +7 -7
  215. package/src/runtime/browser.ts +64 -187
  216. package/src/runtime/cache.ts +20 -67
  217. package/src/runtime/choice.ts +79 -132
  218. package/src/runtime/credential.ts +2 -2
  219. package/src/runtime/env.ts +1 -1
  220. package/src/runtime/executor.ts +43 -20
  221. package/src/runtime/http.ts +494 -57
  222. package/src/runtime/insights.ts +15 -53
  223. package/src/runtime/instrumentation.ts +495 -66
  224. package/src/runtime/keyring.ts +7 -19
  225. package/src/runtime/namespace.ts +2 -7
  226. package/src/runtime/native-network.ts +1686 -0
  227. package/src/runtime/otlp.ts +12 -23
  228. package/src/runtime/perf.ts +1 -1
  229. package/src/runtime/provider.ts +4 -9
  230. package/src/runtime/proxy-errors.ts +29 -42
  231. package/src/runtime/proxy-nodemaven.ts +221 -0
  232. package/src/runtime/proxy-retry-policy.ts +3 -3
  233. package/src/runtime/proxy-telemetry.ts +79 -77
  234. package/src/runtime/redirects.ts +66 -0
  235. package/src/runtime/redis.ts +4 -12
  236. package/src/runtime/request-options.ts +679 -9
  237. package/src/runtime/secrets.ts +64 -0
  238. package/src/runtime/state.ts +353 -133
  239. package/src/runtime/stealth.ts +505 -154
  240. package/src/runtime/stt.ts +38 -94
  241. package/src/runtime/trace.ts +14 -44
  242. package/src/runtime/waterfall.ts +5 -18
  243. package/src/schema.ts +23 -84
  244. package/src/serve.ts +1 -1
  245. package/src/server/index.ts +29 -7
  246. package/src/server/self-test-input-tokens.ts +29 -14
  247. package/src/server/self-test-redaction.ts +2 -2
  248. package/src/server/self-test.ts +857 -132
  249. package/src/server/serve.ts +1151 -328
  250. package/src/server/types.ts +12 -13
  251. package/src/stateful/README.md +146 -0
  252. package/src/stateful/errors.ts +23 -0
  253. package/src/stateful/http-provider-event-emitter.ts +314 -0
  254. package/src/stateful/http-session-owner-registry.ts +306 -0
  255. package/src/stateful/index.ts +18 -0
  256. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  257. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  258. package/src/stateful/provider-event-pipeline.ts +61 -0
  259. package/src/stateful/provider-events.ts +462 -0
  260. package/src/stateful/session-key.ts +111 -0
  261. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  262. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  263. package/src/stateful/stateful-provider-adapter.ts +562 -0
  264. package/src/stateful/stateful-provider-observability.ts +261 -0
  265. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  266. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  267. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  268. package/src/stateful/stateful-provider-session-routing.ts +555 -0
  269. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  270. package/src/stateful-signing.ts +46 -0
  271. package/src/stealth/profiles.ts +10 -26
  272. package/src/stream-evidence.ts +988 -0
  273. package/src/stream.ts +8 -19
  274. package/src/testing/index.ts +10 -2
  275. package/src/testing/run.ts +653 -74
  276. package/src/types.ts +408 -28
  277. package/src/user-input.ts +118 -0
  278. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  279. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  280. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  281. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  282. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  283. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  284. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  285. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  286. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  287. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  288. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  289. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  290. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  291. /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";
@@ -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
- } from "../src";
24
- import { createMemoryProviderRuntimeState } from "../src/runtime/state";
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";
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,72 +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
- --append append to an existing array fixture
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
- console.log(
68
- `[apifuse record] Calling ${operationName} on ${provider.id}...`,
69
- );
70
-
71
- const result = await executeOperation(
72
- provider,
73
- operationName,
74
- capture.ctx,
75
- parsedParams,
76
- );
77
- const captured = capture.getCapturedRaw();
110
+ console.log(`[apifuse record] Calling ${operationName} on ${provider.id}...`);
111
+
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();
78
135
 
79
136
  if (captured === undefined) {
80
- throw new Error(
81
- `No upstream response was captured for ${provider.id}.${operationName}.`,
82
- );
137
+ throw new Error(`No upstream response was captured for ${provider.id}.${operationName}.`);
83
138
  }
84
139
 
85
- const rawPayload = args.sanitize ? sanitizeFixture(captured) : captured;
140
+ const sensitiveParams = capture.getCapturedSensitiveParams();
141
+ const rawPayload = jsonFixtureValue(captured);
86
142
  const fixturePath = resolve(location.rootDir, "__fixtures__", "raw.json");
87
- const nextPayload = await prepareFixturePayload(
88
- fixturePath,
89
- rawPayload,
90
- args.append,
91
- );
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);
92
151
 
93
152
  await mkdir(dirname(fixturePath), { recursive: true });
94
153
  await writeFile(fixturePath, `${JSON.stringify(nextPayload, null, 2)}\n`);
95
154
 
96
155
  console.log(
97
156
  `[apifuse record] Captured response (${formatBytes(
98
- Buffer.byteLength(JSON.stringify(rawPayload)),
157
+ Buffer.byteLength(JSON.stringify(redactedCapture)),
99
158
  )})`,
100
159
  );
101
- console.log(
102
- `[apifuse record] Saved to ${relative(process.cwd(), fixturePath)}`,
103
- );
160
+ console.log(`[apifuse record] Saved to ${relative(process.cwd(), fixturePath)}`);
104
161
 
105
162
  void result;
106
163
  } catch (error) {
107
- handleCliError(error);
164
+ handleCliError(error, capture?.getCapturedSensitiveParams().values);
108
165
  }
109
166
  }
110
167
 
@@ -189,13 +246,29 @@ function parseArgs(argv: string[]): CliArgs {
189
246
  return { append, providerPath, operation, params, sanitize };
190
247
  }
191
248
 
192
- function handleCliError(error: unknown): never {
193
- const message = formatCliError(error);
249
+ function handleCliError(error: unknown, sensitiveValues: readonly string[] = []): never {
250
+ const message = redactSensitiveText(formatCliError(error), sensitiveValues);
194
251
  console.error(`[apifuse record] ${message}`);
195
252
  process.exit(1);
196
253
  }
197
254
 
198
- 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
+ }
199
272
  if (error instanceof TransportError) {
200
273
  return [
201
274
  error.message,
@@ -210,22 +283,38 @@ function formatCliError(error: unknown): string {
210
283
  }
211
284
 
212
285
  if (error instanceof ProviderError || error instanceof ValidationError) {
213
- return [
214
- error.message,
215
- error.code ? `code=${error.code}` : undefined,
216
- error.fix,
217
- ]
286
+ return [error.message, error.code ? `code=${error.code}` : undefined, error.fix]
218
287
  .filter(Boolean)
219
288
  .join(" ");
220
289
  }
221
290
 
222
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
+ }
223
295
  return error.message;
224
296
  }
225
297
 
226
298
  return String(error);
227
299
  }
228
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
+
229
318
  function resolveProviderLocation(inputPath?: string) {
230
319
  const originalInput = inputPath ?? process.cwd();
231
320
  const resolvedInput = resolve(process.cwd(), originalInput);
@@ -269,8 +358,7 @@ function findProviderRoot(startDirectory: string): string | undefined {
269
358
 
270
359
  function looksLikeProviderRoot(directory: string): boolean {
271
360
  return (
272
- existsSync(resolve(directory, "index.ts")) &&
273
- existsSync(resolve(directory, "package.json"))
361
+ existsSync(resolve(directory, "index.ts")) && existsSync(resolve(directory, "package.json"))
274
362
  );
275
363
  }
276
364
 
@@ -287,15 +375,10 @@ async function loadProvider(rootDir: string): Promise<ProviderRuntime> {
287
375
  return module.default;
288
376
  }
289
377
 
290
- function resolveOperationName(
291
- provider: ProviderRuntime,
292
- operationName?: string,
293
- ): string {
378
+ function resolveOperationName(provider: ProviderRuntime, operationName?: string): string {
294
379
  if (operationName) {
295
380
  if (!(operationName in provider.operations)) {
296
- throw new Error(
297
- `Unknown operation "${operationName}" for provider "${provider.id}".`,
298
- );
381
+ throw new Error(`Unknown operation "${operationName}" for provider "${provider.id}".`);
299
382
  }
300
383
 
301
384
  return operationName;
@@ -309,10 +392,10 @@ function resolveOperationName(
309
392
  return firstOperation;
310
393
  }
311
394
 
312
- function parseParams(
395
+ async function parseParams(
313
396
  operation: ProviderRuntime["operations"][string],
314
397
  value: string,
315
- ): unknown {
398
+ ): Promise<unknown> {
316
399
  let parsed: unknown;
317
400
 
318
401
  try {
@@ -323,13 +406,10 @@ function parseParams(
323
406
  );
324
407
  }
325
408
 
326
- return operation.input ? operation.input.parse(parsed) : parsed;
409
+ return operation.input ? parseSchema(operation.input, parsed, "record.params") : parsed;
327
410
  }
328
411
 
329
- function resolveOperationBaseUrl(
330
- provider: ProviderRuntime,
331
- operationName: string,
332
- ): string {
412
+ function resolveOperationBaseUrl(provider: ProviderRuntime, operationName: string): string {
333
413
  const baseUrl = provider.operations[operationName]?.upstream?.baseUrl;
334
414
  if (!baseUrl) {
335
415
  throw new Error(
@@ -340,17 +420,77 @@ function resolveOperationBaseUrl(
340
420
  return baseUrl;
341
421
  }
342
422
 
343
- function createCaptureContext(provider: ProviderRuntime, baseUrl: string) {
344
- let capturedRaw: unknown;
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],
442
+ });
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
+ };
345
452
 
346
- const http = proxyHttpClient(createHttpClient(baseUrl), (response) => {
347
- capturedRaw = response.data;
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
+ },
348
488
  });
349
489
  const stealth = proxyStealthClient(
350
490
  createStealthClient(baseUrl),
351
- (response) => {
352
- capturedRaw = normalizeCapturedStealthResponse(response);
353
- },
491
+ captureSensitiveParams,
492
+ (order, response) => retainRawCapture(order, normalizeCapturedStealthResponse(response)),
493
+ reserveCaptureOrder,
354
494
  );
355
495
 
356
496
  const env = {
@@ -408,65 +548,263 @@ function createCaptureContext(provider: ProviderRuntime, baseUrl: string) {
408
548
 
409
549
  return {
410
550
  ctx,
411
- 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,
412
594
  };
413
595
  }
414
596
 
415
- function proxyHttpClient(
416
- client: HttpClient,
417
- onResponse: (response: Awaited<ReturnType<HttpClient["get"]>>) => void,
418
- ): HttpClient {
419
- return new Proxy(client, {
420
- get(target, prop, receiver) {
421
- const value = Reflect.get(target, prop, receiver);
597
+ type CapturedSensitiveParams = {
598
+ names: readonly string[];
599
+ values: readonly string[];
600
+ };
422
601
 
423
- if (typeof value !== "function") {
424
- return value;
425
- }
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
+ }
426
613
 
427
- return async (...args: unknown[]) => {
428
- const response = await value.apply(target, args);
429
- onResponse(response);
430
- return response;
431
- };
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));
432
709
  },
433
- }) 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
+ };
434
738
  }
435
739
 
436
740
  type StealthSession = ReturnType<StealthClient["createSession"]>;
437
741
 
438
742
  function proxyStealthClient(
439
743
  client: StealthClient,
440
- 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,
441
747
  ): StealthClient {
442
748
  return {
443
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]);
444
753
  const response = await client.fetch(...args);
445
- onResponse(response);
754
+ if (response.url) onSensitiveParams(response.url, args[1]);
755
+ onResponse(order, response);
446
756
  return response;
447
757
  },
448
758
  createSession: (...args: Parameters<StealthClient["createSession"]>) =>
449
- proxyStealthSession(client.createSession(...args), onResponse),
759
+ proxyStealthSession(
760
+ client.createSession(...args),
761
+ onSensitiveParams,
762
+ onResponse,
763
+ reserveOrder,
764
+ ),
450
765
  };
451
766
  }
452
767
 
453
768
  function proxyStealthSession(
454
769
  session: StealthSession,
455
- 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,
456
773
  ): StealthSession {
457
774
  return {
458
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]);
459
779
  const response = await session.fetch(...args);
460
- onResponse(response);
780
+ if (response.url) onSensitiveParams(response.url, args[1]);
781
+ onResponse(order, response);
461
782
  return response;
462
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
+ },
463
803
  close: () => session.close(),
464
804
  };
465
805
  }
466
806
 
467
- function normalizeCapturedStealthResponse(
468
- response: Awaited<ReturnType<StealthClient["fetch"]>>,
469
- ) {
807
+ function normalizeCapturedStealthResponse(response: Awaited<ReturnType<StealthClient["fetch"]>>) {
470
808
  try {
471
809
  return JSON.parse(response.body);
472
810
  } catch {
@@ -474,33 +812,193 @@ function normalizeCapturedStealthResponse(
474
812
  }
475
813
  }
476
814
 
477
- 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
+ }
478
827
  if (Array.isArray(value)) {
479
- return value.map((item) => sanitizeFixture(item));
828
+ return value.map((item) => sanitizeStreamFixture(item, sensitiveParams));
480
829
  }
481
-
482
830
  if (!value || typeof value !== "object") {
483
- return value;
831
+ return redactFixture(value, sensitiveParams, false);
484
832
  }
485
833
 
486
- const entries = Object.entries(value as MutableRecord).map(
487
- ([key, entryValue]) => {
488
- if (isSensitiveKey(key)) {
489
- return [key, "[REDACTED]"] as const;
490
- }
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
+ }
491
847
 
492
- return [key, sanitizeFixture(entryValue)] as const;
493
- },
494
- );
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 };
495
866
 
496
- 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 };
497
874
  }
498
875
 
499
- function isSensitiveKey(key: string): boolean {
500
- 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;
501
944
  }
502
945
 
503
- async function prepareFixturePayload(
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
+ }
1001
+ export async function prepareFixturePayload(
504
1002
  fixturePath: string,
505
1003
  payload: unknown,
506
1004
  append: boolean,
@@ -509,15 +1007,45 @@ async function prepareFixturePayload(
509
1007
  return payload;
510
1008
  }
511
1009
 
1010
+ let fixtureSource: string;
512
1011
  try {
513
- const existing = JSON.parse(readFileSync(fixturePath, "utf8")) as unknown;
514
- if (Array.isArray(existing)) {
515
- return [...existing, payload];
516
- }
517
- } catch {
518
- // Fall through to overwrite with the new payload.
1012
+ fixtureSource = readFileSync(fixturePath, "utf8");
1013
+ } catch (error) {
1014
+ throw new Error(
1015
+ `Cannot append to existing fixture "${fixturePath}" because it could not be read: ${
1016
+ error instanceof Error ? error.message : String(error)
1017
+ }. Fix its permissions or delete it, then run apifuse record --append again.`,
1018
+ );
519
1019
  }
520
1020
 
1021
+ let existing: unknown;
1022
+ try {
1023
+ existing = JSON.parse(fixtureSource) as unknown;
1024
+ } catch (error) {
1025
+ throw new Error(
1026
+ `Cannot append to corrupt fixture "${fixturePath}" because it is not valid JSON: ${
1027
+ error instanceof Error ? error.message : String(error)
1028
+ }. Fix or delete the fixture, then run apifuse record --append again.`,
1029
+ );
1030
+ }
1031
+
1032
+ if (hasStreamEvidenceMarker(existing)) {
1033
+ const evidence = parseStreamEvidenceRecord(existing);
1034
+ return [createStreamCaptureEnvelope([{ kind: "stream", evidence }]), payload];
1035
+ }
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
+ }
1044
+ return [...existing, payload];
1045
+ }
1046
+ if (existing !== null) {
1047
+ return [existing, payload];
1048
+ }
521
1049
  return payload;
522
1050
  }
523
1051
 
@@ -529,4 +1057,24 @@ function formatBytes(bytes: number): string {
529
1057
  return `${(bytes / 1024).toFixed(1)} KB`;
530
1058
  }
531
1059
 
532
- await main();
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
+
1078
+ if (import.meta.main) {
1079
+ await main();
1080
+ }