@apifuse/provider-sdk 2.2.0-beta.58 → 2.2.0-beta.60

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 (150) hide show
  1. package/AUTHORING.md +25 -0
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +18 -2
  4. package/bin/apifuse-dev.ts +2 -1
  5. package/bin/apifuse-pack-types.ts +49 -0
  6. package/bin/apifuse-perf.ts +41 -4
  7. package/bin/apifuse-record.ts +9 -2
  8. package/dist/cli/templates/provider/README.md.tpl +15 -3
  9. package/dist/config/loader.d.ts +7 -0
  10. package/dist/config/loader.js +43 -18
  11. package/dist/declaration-validation.d.ts +2 -1
  12. package/dist/declaration-validation.js +39 -0
  13. package/dist/define.d.ts +4 -4
  14. package/dist/define.js +25 -1
  15. package/dist/engine.d.ts +16 -0
  16. package/dist/engine.js +59 -3
  17. package/dist/error-resolution.js +13 -0
  18. package/dist/index.d.ts +7 -4
  19. package/dist/index.js +4 -2
  20. package/dist/lint.js +38 -17
  21. package/dist/provider.d.ts +3 -3
  22. package/dist/provider.js +2 -2
  23. package/dist/runtime/bounded-expiring-map.d.ts +21 -0
  24. package/dist/runtime/bounded-expiring-map.js +64 -0
  25. package/dist/runtime/browser.js +2 -1
  26. package/dist/runtime/cache.d.ts +2 -0
  27. package/dist/runtime/cache.js +11 -3
  28. package/dist/runtime/diagnostic-env-values.d.ts +5 -0
  29. package/dist/runtime/diagnostic-env-values.js +57 -0
  30. package/dist/runtime/diagnostic-env.d.ts +11 -0
  31. package/dist/runtime/diagnostic-env.js +35 -0
  32. package/dist/runtime/diagnostic-redactor.d.ts +49 -0
  33. package/dist/runtime/diagnostic-redactor.js +569 -0
  34. package/dist/runtime/egress-lease.d.ts +65 -0
  35. package/dist/runtime/egress-lease.js +236 -0
  36. package/dist/runtime/env.js +2 -1
  37. package/dist/runtime/http.js +1 -0
  38. package/dist/runtime/instrumentation.js +3 -0
  39. package/dist/runtime/ocr.js +2 -1
  40. package/dist/runtime/otlp.js +5 -4
  41. package/dist/runtime/proxy-nodemaven.js +3 -2
  42. package/dist/runtime/proxy-retry-policy.d.ts +4 -1
  43. package/dist/runtime/proxy-retry-policy.js +11 -2
  44. package/dist/runtime/request-options.d.ts +7 -0
  45. package/dist/runtime/request-options.js +62 -22
  46. package/dist/runtime/request-telemetry.d.ts +7 -9
  47. package/dist/runtime/resolver-config.d.ts +7 -0
  48. package/dist/runtime/resolver-config.js +8 -0
  49. package/dist/runtime/resolver-public.d.ts +3 -1
  50. package/dist/runtime/resolver-public.js +1 -1
  51. package/dist/runtime/resolver-shared.d.ts +9 -0
  52. package/dist/runtime/resolver-shared.js +57 -0
  53. package/dist/runtime/resolver-telemetry.d.ts +134 -0
  54. package/dist/runtime/resolver-telemetry.js +204 -0
  55. package/dist/runtime/resolver-usage.d.ts +24 -0
  56. package/dist/runtime/resolver-usage.js +60 -0
  57. package/dist/runtime/resolver-vendors/bindings.d.ts +5 -0
  58. package/dist/runtime/resolver-vendors/bindings.js +5 -0
  59. package/dist/runtime/resolver-vendors/browser.js +0 -1
  60. package/dist/runtime/resolver-vendors/capsolver.d.ts +2 -2
  61. package/dist/runtime/resolver-vendors/capsolver.js +21 -9
  62. package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
  63. package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
  64. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -2
  65. package/dist/runtime/resolver-vendors/twocaptcha.js +26 -10
  66. package/dist/runtime/resolver-vendors/types.d.ts +35 -5
  67. package/dist/runtime/resolver-vendors/types.js +20 -10
  68. package/dist/runtime/resolver.d.ts +5 -1
  69. package/dist/runtime/resolver.js +400 -111
  70. package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
  71. package/dist/runtime/stealth-akamai-sbsd.js +142 -0
  72. package/dist/runtime/stealth.js +547 -53
  73. package/dist/runtime/stt.js +3 -2
  74. package/dist/runtime/trace.d.ts +5 -0
  75. package/dist/runtime/trace.js +115 -20
  76. package/dist/schema.d.ts +20 -0
  77. package/dist/schema.js +72 -2
  78. package/dist/server/index.d.ts +2 -0
  79. package/dist/server/index.js +1 -0
  80. package/dist/server/self-test-redaction.js +8 -33
  81. package/dist/server/self-test.d.ts +4 -0
  82. package/dist/server/self-test.js +10 -1
  83. package/dist/server/sensitive-values.d.ts +26 -0
  84. package/dist/server/sensitive-values.js +131 -0
  85. package/dist/server/serve-implementation.d.ts +7 -1
  86. package/dist/server/serve-implementation.js +394 -125
  87. package/dist/server/trace-output.d.ts +2 -1
  88. package/dist/server/trace-output.js +46 -14
  89. package/dist/server/types.d.ts +6 -0
  90. package/dist/server/types.js +14 -0
  91. package/dist/stealth/profiles.d.ts +7 -1
  92. package/dist/stealth/profiles.js +9 -0
  93. package/dist/testing/run.js +6 -0
  94. package/dist/trace-sanitization.d.ts +4 -3
  95. package/dist/trace-sanitization.js +45 -22
  96. package/dist/types.d.ts +106 -10
  97. package/package.json +2 -1
  98. package/src/cli/templates/provider/README.md.tpl +15 -3
  99. package/src/config/loader.ts +47 -18
  100. package/src/declaration-validation.ts +47 -1
  101. package/src/define.ts +45 -3
  102. package/src/engine.ts +78 -3
  103. package/src/error-resolution.ts +13 -0
  104. package/src/index.ts +34 -1
  105. package/src/lint.ts +44 -18
  106. package/src/provider.ts +10 -0
  107. package/src/runtime/bounded-expiring-map.ts +67 -0
  108. package/src/runtime/browser.ts +2 -1
  109. package/src/runtime/cache.ts +13 -3
  110. package/src/runtime/diagnostic-env-values.ts +56 -0
  111. package/src/runtime/diagnostic-env.ts +55 -0
  112. package/src/runtime/diagnostic-redactor.ts +662 -0
  113. package/src/runtime/egress-lease.ts +341 -0
  114. package/src/runtime/env.ts +2 -1
  115. package/src/runtime/http.ts +1 -0
  116. package/src/runtime/instrumentation.ts +3 -0
  117. package/src/runtime/ocr.ts +2 -1
  118. package/src/runtime/otlp.ts +5 -4
  119. package/src/runtime/proxy-nodemaven.ts +3 -2
  120. package/src/runtime/proxy-retry-policy.ts +12 -1
  121. package/src/runtime/request-options.ts +76 -28
  122. package/src/runtime/request-telemetry.ts +5 -7
  123. package/src/runtime/resolver-config.ts +12 -0
  124. package/src/runtime/resolver-public.ts +21 -0
  125. package/src/runtime/resolver-shared.ts +75 -0
  126. package/src/runtime/resolver-telemetry.ts +362 -0
  127. package/src/runtime/resolver-usage.ts +96 -0
  128. package/src/runtime/resolver-vendors/bindings.ts +5 -0
  129. package/src/runtime/resolver-vendors/browser.ts +0 -1
  130. package/src/runtime/resolver-vendors/capsolver.ts +22 -3
  131. package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
  132. package/src/runtime/resolver-vendors/twocaptcha.ts +26 -12
  133. package/src/runtime/resolver-vendors/types.ts +60 -12
  134. package/src/runtime/resolver.ts +468 -126
  135. package/src/runtime/stealth-akamai-sbsd.ts +207 -0
  136. package/src/runtime/stealth.ts +727 -95
  137. package/src/runtime/stt.ts +3 -2
  138. package/src/runtime/trace.ts +168 -26
  139. package/src/schema.ts +80 -2
  140. package/src/server/index.ts +21 -0
  141. package/src/server/self-test-redaction.ts +10 -40
  142. package/src/server/self-test.ts +13 -0
  143. package/src/server/sensitive-values.ts +200 -0
  144. package/src/server/serve-implementation.ts +561 -136
  145. package/src/server/trace-output.ts +77 -17
  146. package/src/server/types.ts +14 -0
  147. package/src/stealth/profiles.ts +10 -0
  148. package/src/testing/run.ts +6 -0
  149. package/src/trace-sanitization.ts +70 -23
  150. package/src/types.ts +124 -19
@@ -0,0 +1,341 @@
1
+ import { readDiagnosticEnv } from "./diagnostic-env.js";
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
3
+
4
+ import type { ProxyVendorName } from "../config/loader.js";
5
+ import { SDKError } from "../errors.js";
6
+
7
+ /**
8
+ * AES-256-GCM key for the engine ceremony egress lease handle (ADR-0010 v1.1).
9
+ * Engine host only: the `APIFUSE__ENGINE__` family is rejected from provider
10
+ * secrets and filtered from provider env projections. Supply at least 32 random
11
+ * bytes (for example `openssl rand -base64 32`). Rotating the key fails
12
+ * verification of every outstanding handle (`EGRESS_LEASE_INVALID`, 409): the
13
+ * affected ceremonies restart; they do not expire.
14
+ */
15
+ export const APIFUSE__ENGINE__CEREMONY_LEASE_KEY = "APIFUSE__ENGINE__CEREMONY_LEASE_KEY";
16
+
17
+ /** Internal option key shared only by the server assembler and stealth runtime. */
18
+ export const ENGINE_CEREMONY_EGRESS_LEASE = Symbol.for(
19
+ "@apifuse/provider-sdk/engine-ceremony-egress-lease",
20
+ );
21
+
22
+ const HANDLE_VERSION = 1;
23
+ const HANDLE_MAX_BYTES = 16_384;
24
+ const AEAD_NONCE_BYTES = 12;
25
+ const AEAD_TAG_BYTES = 16;
26
+ const AEAD_ALGORITHM = "aes-256-gcm";
27
+ const AEAD_KEY_DOMAIN = "apifuse:ceremony-egress-lease:v1\0";
28
+ const LEASE_KEY_MIN_BYTES = 32;
29
+
30
+ export type CeremonyEgressBinding = {
31
+ readonly vendor: ProxyVendorName;
32
+ readonly proxyUrl: string;
33
+ readonly poolIndex: number;
34
+ readonly affinityKey: string;
35
+ readonly refreshEpoch: number;
36
+ /**
37
+ * Vendor session lifetime the endpoint was allocated for (`proxy.session.lifetimeMinutes`,
38
+ * Smartproxy `life`, NodeMaven SID window). It is the upper bound of the lease, counted
39
+ * from the mint (the first successful attempt), so a Smartproxy lease can outlive the
40
+ * vendor session by the extraction-cache reuse window; expiry then drops and rebinds.
41
+ * The bound is not a vendor guarantee (ADR-0010: a raw Smartproxy endpoint is not a hard
42
+ * lease): the stealth runtime releases the binding as soon as the endpoint fails at the
43
+ * transport, so a dead endpoint is left before the lifetime elapses.
44
+ */
45
+ readonly lifetimeMinutes: number;
46
+ };
47
+
48
+ type CeremonyEgressLeasePayloadV1 = CeremonyEgressBinding & {
49
+ readonly version: typeof HANDLE_VERSION;
50
+ readonly tenantId: string;
51
+ readonly providerId: string;
52
+ readonly flowId: string;
53
+ readonly mintedAtMs: number;
54
+ readonly expiresAtMs: number;
55
+ };
56
+
57
+ type CeremonyEgressLeaseScopeV1 = Pick<
58
+ CeremonyEgressLeasePayloadV1,
59
+ "tenantId" | "providerId" | "flowId" | "affinityKey"
60
+ >;
61
+
62
+ export type CeremonyEgressLeaseRuntime = {
63
+ /** Exact egress bound to this ceremony; undefined until the first successful attempt binds one. */
64
+ readonly binding: CeremonyEgressBinding | undefined;
65
+ /**
66
+ * Drops the binding once its vendor session lifetime has elapsed so the next attempt
67
+ * selects and binds a fresh endpoint. Returns true when a binding was dropped: the
68
+ * caller must also discard challenge state that was only valid for that endpoint.
69
+ */
70
+ dropExpiredBinding(): boolean;
71
+ /**
72
+ * Drops the binding unconditionally; the stealth runtime calls this when the bound
73
+ * endpoint fails at the transport. Same return and caller obligation as
74
+ * `dropExpiredBinding`.
75
+ */
76
+ dropBinding(): boolean;
77
+ /**
78
+ * Number of bindings dropped so far (expiry or release). Every stealth session created
79
+ * from one client shares the lease; a session compares this to what it last observed to
80
+ * retire endpoint-bound challenge state and replay records after another session moved
81
+ * the ceremony.
82
+ */
83
+ readonly generation: number;
84
+ bind(binding: CeremonyEgressBinding): void;
85
+ handle(): string | undefined;
86
+ };
87
+
88
+ /** The caller presented a handle this engine cannot verify for this ceremony (409). */
89
+ function invalidLease(): never {
90
+ throw new SDKError("The engine ceremony egress lease is invalid", {
91
+ code: "EGRESS_LEASE_INVALID",
92
+ fix: "Restart the authentication ceremony to obtain a new engine-owned egress lease.",
93
+ });
94
+ }
95
+
96
+ /** The engine violated its own binding invariant (500); never attributable to the caller. */
97
+ function bindingFault(message: string): never {
98
+ throw new SDKError(message, {
99
+ code: "EGRESS_LEASE_BINDING_INVALID",
100
+ fix: "This is an SDK or provider-runtime fault; the ceremony lease cannot bind the selected egress.",
101
+ });
102
+ }
103
+
104
+ function encryptionKey(key: string): Buffer {
105
+ return createHash("sha256").update(AEAD_KEY_DOMAIN, "utf8").update(key, "utf8").digest();
106
+ }
107
+
108
+ /** Fixed field order and JSON.stringify's compact form are the canonical AAD encoding. */
109
+ function scopeAad(scope: CeremonyEgressLeaseScopeV1): Buffer {
110
+ return Buffer.from(
111
+ JSON.stringify({
112
+ version: HANDLE_VERSION,
113
+ tenantId: scope.tenantId,
114
+ providerId: scope.providerId,
115
+ flowId: scope.flowId,
116
+ affinityKey: scope.affinityKey,
117
+ }),
118
+ "utf8",
119
+ );
120
+ }
121
+
122
+ function encodeHandle(
123
+ payload: CeremonyEgressLeasePayloadV1,
124
+ key: string,
125
+ scope: CeremonyEgressLeaseScopeV1,
126
+ ): string {
127
+ const nonce = randomBytes(AEAD_NONCE_BYTES);
128
+ const cipher = createCipheriv(AEAD_ALGORITHM, encryptionKey(key), nonce, {
129
+ authTagLength: AEAD_TAG_BYTES,
130
+ });
131
+ cipher.setAAD(scopeAad(scope));
132
+ const ciphertext = Buffer.concat([
133
+ cipher.update(JSON.stringify(payload), "utf8"),
134
+ cipher.final(),
135
+ ]);
136
+ return [
137
+ `v${HANDLE_VERSION}`,
138
+ nonce.toString("base64url"),
139
+ ciphertext.toString("base64url"),
140
+ cipher.getAuthTag().toString("base64url"),
141
+ ].join(".");
142
+ }
143
+
144
+ function isFiniteInteger(value: unknown): value is number {
145
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
146
+ }
147
+
148
+ function isPositiveFinite(value: unknown): value is number {
149
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
150
+ }
151
+
152
+ function decodeHandle(
153
+ handle: string,
154
+ key: string,
155
+ scope: CeremonyEgressLeaseScopeV1,
156
+ ): CeremonyEgressLeasePayloadV1 {
157
+ if (Buffer.byteLength(handle, "utf8") > HANDLE_MAX_BYTES) return invalidLease();
158
+ const parts = handle.split(".");
159
+ if (
160
+ parts.length !== 4 ||
161
+ parts[0] !== `v${HANDLE_VERSION}` ||
162
+ parts.slice(1).some((part) => !/^[A-Za-z0-9_-]+$/u.test(part))
163
+ ) {
164
+ return invalidLease();
165
+ }
166
+ let parsed: unknown;
167
+ try {
168
+ const nonce = Buffer.from(parts[1]!, "base64url");
169
+ const ciphertext = Buffer.from(parts[2]!, "base64url");
170
+ const tag = Buffer.from(parts[3]!, "base64url");
171
+ if (
172
+ nonce.byteLength !== AEAD_NONCE_BYTES ||
173
+ tag.byteLength !== AEAD_TAG_BYTES ||
174
+ ciphertext.byteLength === 0
175
+ ) {
176
+ return invalidLease();
177
+ }
178
+ const decipher = createDecipheriv(AEAD_ALGORITHM, encryptionKey(key), nonce, {
179
+ authTagLength: AEAD_TAG_BYTES,
180
+ });
181
+ decipher.setAAD(scopeAad(scope));
182
+ decipher.setAuthTag(tag);
183
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
184
+ parsed = JSON.parse(plaintext.toString("utf8"));
185
+ } catch {
186
+ return invalidLease();
187
+ }
188
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return invalidLease();
189
+ const candidate = parsed as Partial<CeremonyEgressLeasePayloadV1>;
190
+ if (
191
+ candidate.version !== HANDLE_VERSION ||
192
+ (candidate.vendor !== "smartproxy" && candidate.vendor !== "nodemaven") ||
193
+ typeof candidate.proxyUrl !== "string" ||
194
+ !candidate.proxyUrl ||
195
+ !isFiniteInteger(candidate.poolIndex) ||
196
+ typeof candidate.affinityKey !== "string" ||
197
+ !candidate.affinityKey ||
198
+ !isFiniteInteger(candidate.refreshEpoch) ||
199
+ typeof candidate.tenantId !== "string" ||
200
+ !candidate.tenantId ||
201
+ typeof candidate.providerId !== "string" ||
202
+ !candidate.providerId ||
203
+ typeof candidate.flowId !== "string" ||
204
+ !candidate.flowId ||
205
+ !isFiniteInteger(candidate.mintedAtMs) ||
206
+ !isFiniteInteger(candidate.expiresAtMs) ||
207
+ candidate.expiresAtMs <= candidate.mintedAtMs ||
208
+ !isPositiveFinite(candidate.lifetimeMinutes)
209
+ ) {
210
+ return invalidLease();
211
+ }
212
+ try {
213
+ const proxy = new URL(candidate.proxyUrl);
214
+ if (proxy.protocol !== "http:" && proxy.protocol !== "socks5:") return invalidLease();
215
+ } catch {
216
+ return invalidLease();
217
+ }
218
+ return candidate as CeremonyEgressLeasePayloadV1;
219
+ }
220
+
221
+ function bindingOf(payload: CeremonyEgressLeasePayloadV1): CeremonyEgressBinding {
222
+ return {
223
+ vendor: payload.vendor,
224
+ proxyUrl: payload.proxyUrl,
225
+ poolIndex: payload.poolIndex,
226
+ affinityKey: payload.affinityKey,
227
+ refreshEpoch: payload.refreshEpoch,
228
+ lifetimeMinutes: payload.lifetimeMinutes,
229
+ };
230
+ }
231
+
232
+ export function createCeremonyEgressLeaseRuntime(options: {
233
+ readonly tenantId?: string;
234
+ readonly providerId: string;
235
+ readonly flowId: string;
236
+ readonly affinityKey: string;
237
+ readonly handle?: string;
238
+ /** Server-only registration before a handle can reach request diagnostics. */
239
+ readonly onHandle?: (handle: string) => void;
240
+ readonly environment?: Readonly<Record<string, string | undefined>>;
241
+ readonly now?: () => number;
242
+ }): CeremonyEgressLeaseRuntime {
243
+ const environment = options.environment ?? process.env;
244
+ const now = options.now ?? Date.now;
245
+ if (typeof options.tenantId !== "string" || !options.tenantId.trim()) {
246
+ throw new SDKError("A tenant is required for an engine ceremony egress lease", {
247
+ code: "EGRESS_LEASE_INVALID",
248
+ fix: "Restart the authentication ceremony with its tenant identity.",
249
+ });
250
+ }
251
+ const scope: CeremonyEgressLeaseScopeV1 = {
252
+ tenantId: options.tenantId,
253
+ providerId: options.providerId,
254
+ flowId: options.flowId,
255
+ affinityKey: options.affinityKey,
256
+ };
257
+ const key = readDiagnosticEnv(APIFUSE__ENGINE__CEREMONY_LEASE_KEY, environment)?.trim();
258
+ if (!key) {
259
+ throw new SDKError("The engine ceremony egress lease key is not configured", {
260
+ code: "EGRESS_LEASE_KEY_MISSING",
261
+ fix: `Configure ${APIFUSE__ENGINE__CEREMONY_LEASE_KEY} in the engine host.`,
262
+ });
263
+ }
264
+ // The key is the whole authenticity boundary of a handle (a verified handle's proxyUrl
265
+ // becomes the ceremony egress), so the documented minimum is enforced, not advisory.
266
+ if (Buffer.byteLength(key, "utf8") < LEASE_KEY_MIN_BYTES) {
267
+ throw new SDKError(
268
+ `The engine ceremony egress lease key is shorter than ${LEASE_KEY_MIN_BYTES} bytes`,
269
+ {
270
+ code: "EGRESS_LEASE_KEY_WEAK",
271
+ fix: `Set ${APIFUSE__ENGINE__CEREMONY_LEASE_KEY} to at least ${LEASE_KEY_MIN_BYTES} random bytes (for example \`openssl rand -base64 32\`).`,
272
+ },
273
+ );
274
+ }
275
+ let payload: CeremonyEgressLeasePayloadV1 | undefined;
276
+ let currentHandle: string | undefined;
277
+ let generation = 0;
278
+
279
+ const dropBinding = (): boolean => {
280
+ if (!payload) return false;
281
+ payload = undefined;
282
+ currentHandle = undefined;
283
+ generation += 1;
284
+ return true;
285
+ };
286
+ const dropExpiredBinding = (): boolean =>
287
+ payload !== undefined && now() >= payload.expiresAtMs ? dropBinding() : false;
288
+
289
+ if (options.handle !== undefined) {
290
+ options.onHandle?.(options.handle);
291
+ // Scope mismatch fails GCM authentication because scope is AAD; there is
292
+ // deliberately no post-decrypt scope comparison fallback.
293
+ payload = decodeHandle(options.handle, key, scope);
294
+ currentHandle = options.handle;
295
+ // An expired but authentic handle is not a fault: the ceremony continues on a
296
+ // freshly selected endpoint and the next turn receives the new handle.
297
+ dropExpiredBinding();
298
+ }
299
+
300
+ return {
301
+ get binding() {
302
+ return payload ? bindingOf(payload) : undefined;
303
+ },
304
+ dropExpiredBinding,
305
+ dropBinding,
306
+ get generation() {
307
+ return generation;
308
+ },
309
+ bind(binding) {
310
+ if (payload) {
311
+ // The stealth runtime reuses the bound endpoint while a binding exists and
312
+ // never rebinds it; a second bind is an engine invariant break.
313
+ return bindingFault("The ceremony egress lease is already bound");
314
+ }
315
+ if (
316
+ binding.affinityKey !== options.affinityKey ||
317
+ !binding.proxyUrl ||
318
+ (binding.vendor !== "smartproxy" && binding.vendor !== "nodemaven") ||
319
+ !isFiniteInteger(binding.poolIndex) ||
320
+ !isPositiveFinite(binding.lifetimeMinutes)
321
+ ) {
322
+ return bindingFault("The selected egress cannot be bound to the ceremony lease");
323
+ }
324
+ const mintedAtMs = now();
325
+ payload = {
326
+ version: HANDLE_VERSION,
327
+ tenantId: scope.tenantId,
328
+ providerId: options.providerId,
329
+ flowId: options.flowId,
330
+ ...binding,
331
+ mintedAtMs,
332
+ expiresAtMs: mintedAtMs + binding.lifetimeMinutes * 60_000,
333
+ };
334
+ currentHandle = encodeHandle(payload, key, scope);
335
+ options.onHandle?.(currentHandle);
336
+ },
337
+ handle() {
338
+ return currentHandle;
339
+ },
340
+ };
341
+ }
@@ -1,3 +1,4 @@
1
+ import { readDiagnosticEnv } from "./diagnostic-env.js";
1
2
  import { isEngineOwnedEnvName } from "../engine.js";
2
3
  import type { EnvContext } from "../types.js";
3
4
 
@@ -20,7 +21,7 @@ export function createEnvContext(allowedKeys?: string[]): EnvContext {
20
21
  return undefined;
21
22
  }
22
23
 
23
- return process.env[key];
24
+ return readDiagnosticEnv(key);
24
25
  },
25
26
  };
26
27
  }
@@ -654,6 +654,7 @@ async function resolveNativeProxy(
654
654
  // supported here, so a socks5 policy fails loudly rather than downgrading.
655
655
  transportProtocols: ["http"],
656
656
  telemetry: clientOptions.telemetry,
657
+ engineCredentials: clientOptions.engineCredentials,
657
658
  });
658
659
  if (resolvedProxy.shouldWarn) {
659
660
  warn(
@@ -1,3 +1,4 @@
1
+ import { registerDiagnosticSensitiveValues } from "./diagnostic-redactor.js";
1
2
  import type {
2
3
  HttpStreamResponse,
3
4
  ProviderContext,
@@ -570,6 +571,7 @@ function wrapStealthRedirects(
570
571
  run(...args: Parameters<StealthSession["redirects"]["run"]>) {
571
572
  const diagnosticArgs = [args[0].url, args[0]];
572
573
  const diagnostics = snapshotRequestDiagnostics("stealth", "fetch", diagnosticArgs);
574
+ registerDiagnosticSensitiveValues(trace, diagnostics.sensitiveValues);
573
575
  let result: ReturnType<StealthSession["redirects"]["run"]>;
574
576
  try {
575
577
  result = redirects.run(...args);
@@ -750,6 +752,7 @@ function wrapNamespace<T extends object>(
750
752
  return Reflect.apply(value, namespaceTarget, args);
751
753
  }
752
754
  const requestDiagnostics = snapshotRequestDiagnostics(namespace, methodName, args);
755
+ registerDiagnosticSensitiveValues(trace, requestDiagnostics.sensitiveValues);
753
756
  // Invoke first and decide by the RETURN VALUE. `runSpan` always
754
757
  // returns a Promise, so unconditionally span-wrapping every member
755
758
  // silently rewrote synchronous contracts: `ctx.state.namespace()`
@@ -1,3 +1,4 @@
1
+ import { readDiagnosticEnv } from "./diagnostic-env.js";
1
2
  import { ProviderError, TransportError } from "../errors.js";
2
3
  import type {
3
4
  OcrCaptchaCandidate,
@@ -96,7 +97,7 @@ export function createUnsupportedOcrClient(reason?: string): OcrContext {
96
97
  }
97
98
 
98
99
  function normalizedEnvValue(env: EnvLike, key: string): string | undefined {
99
- const value = env[key]?.trim();
100
+ const value = readDiagnosticEnv(key, env)?.trim();
100
101
  return value ? value : undefined;
101
102
  }
102
103
 
@@ -1,3 +1,4 @@
1
+ import { readDiagnosticEnv } from "./diagnostic-env.js";
1
2
  import { AsyncResource } from "node:async_hooks";
2
3
 
3
4
  import type { TraceSpan } from "../types.js";
@@ -153,12 +154,12 @@ export function resolveOTLPExportOptions(
153
154
  { source: EXPLICIT_ENDPOINT_SOURCE, value: explicit.endpoint, appendPath: false },
154
155
  {
155
156
  source: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
156
- value: env[OTEL_EXPORTER_OTLP_TRACES_ENDPOINT],
157
+ value: readDiagnosticEnv(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, env),
157
158
  appendPath: false,
158
159
  },
159
160
  {
160
161
  source: OTEL_EXPORTER_OTLP_ENDPOINT,
161
- value: env[OTEL_EXPORTER_OTLP_ENDPOINT],
162
+ value: readDiagnosticEnv(OTEL_EXPORTER_OTLP_ENDPOINT, env),
162
163
  appendPath: true,
163
164
  },
164
165
  ];
@@ -173,11 +174,11 @@ export function resolveOTLPExportOptions(
173
174
  }
174
175
  const endpoint = candidate.appendPath ? appendTracesPath(parsed.url) : candidate.value;
175
176
 
176
- const tracesHeaders = presentValue(env[OTEL_EXPORTER_OTLP_TRACES_HEADERS]);
177
+ const tracesHeaders = presentValue(readDiagnosticEnv(OTEL_EXPORTER_OTLP_TRACES_HEADERS, env));
177
178
  const headersSource =
178
179
  tracesHeaders !== undefined ? OTEL_EXPORTER_OTLP_TRACES_HEADERS : OTEL_EXPORTER_OTLP_HEADERS;
179
180
  const envHeaders = parseHeaderList(
180
- tracesHeaders ?? presentValue(env[OTEL_EXPORTER_OTLP_HEADERS]),
181
+ tracesHeaders ?? presentValue(readDiagnosticEnv(OTEL_EXPORTER_OTLP_HEADERS, env)),
181
182
  );
182
183
  if (!headersAreSendable(envHeaders)) {
183
184
  return { status: "invalid", source: headersSource, reason: INVALID_HEADERS };
@@ -1,3 +1,4 @@
1
+ import { readDiagnosticEnv } from "./diagnostic-env.js";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
 
3
4
  import type { ProviderProxyPolicy } from "../types.js";
@@ -43,11 +44,11 @@ export function hasNodemavenCredentials(): boolean {
43
44
  }
44
45
 
45
46
  function readNodemavenUsername(): string | undefined {
46
- return process.env[NODEMAVEN_USERNAME_ENV]?.trim() || undefined;
47
+ return readDiagnosticEnv(NODEMAVEN_USERNAME_ENV)?.trim() || undefined;
47
48
  }
48
49
 
49
50
  function readNodemavenPassword(): string | undefined {
50
- return process.env[NODEMAVEN_PASSWORD_ENV]?.trim() || undefined;
51
+ return readDiagnosticEnv(NODEMAVEN_PASSWORD_ENV)?.trim() || undefined;
51
52
  }
52
53
 
53
54
  function resolveNodemavenFilter(value: string | undefined): string {
@@ -7,6 +7,7 @@ import {
7
7
  HttpRetryPreset,
8
8
  HttpRetryUnsafeMethodPolicy,
9
9
  } from "../types.js";
10
+ import { isSensitiveKey } from "./request-options.js";
10
11
 
11
12
  export type NormalizedProxyTransportRetryOptions = Required<
12
13
  Pick<
@@ -364,10 +365,20 @@ export function validateUnsafeProxyTransportRetryMethods(
364
365
  export function isProxyTransportRetryMethod(
365
366
  method: HttpMethod | string,
366
367
  options: NormalizedProxyTransportRetryOptions,
368
+ request?: {
369
+ readonly body?: unknown;
370
+ readonly headers?: Readonly<Record<string, string | readonly string[] | undefined>>;
371
+ },
367
372
  ): boolean {
368
- return options.methods
373
+ const methodAllowed = options.methods
369
374
  .map((allowedMethod) => allowedMethod.toUpperCase())
370
375
  .includes(method.toUpperCase());
376
+ if (!methodAllowed || request === undefined) return methodAllowed;
377
+ if (request.body !== undefined) return false;
378
+ return !Object.keys(request.headers ?? {}).some((name) => {
379
+ const normalized = name.toLowerCase();
380
+ return normalized === "cookie" || normalized === "proxy-authorization" || isSensitiveKey(name);
381
+ });
371
382
  }
372
383
 
373
384
  export function shouldRetryProxyTransportError(
@@ -1,3 +1,4 @@
1
+ import { isProviderError } from "../errors.js";
1
2
  import type {
2
3
  HttpClient,
3
4
  RequestOptions,
@@ -5,7 +6,6 @@ import type {
5
6
  RequestParams,
6
7
  RequestParamValue,
7
8
  } from "../types.js";
8
- import { isProviderError } from "../errors.js";
9
9
 
10
10
  export const REDACTED_QUERY_VALUE = "[REDACTED]";
11
11
 
@@ -161,15 +161,34 @@ export function serializeRequestUrl(
161
161
  };
162
162
  }
163
163
 
164
- const MIN_UNSCOPED_SENSITIVE_VALUE_LENGTH = 4;
164
+ const MIN_UNSCOPED_SENSITIVE_VALUE_LENGTH = 8;
165
165
 
166
- function normalizePercentEscapes(value: string): string {
166
+ export function normalizePercentEscapes(value: string): string {
167
167
  return value.replace(/%[\da-f]{2}/gi, (percentEscape) => percentEscape.toUpperCase());
168
168
  }
169
169
 
170
170
  function sensitiveValueVariants(value: string): string[] {
171
171
  if (!value || value === REDACTED_QUERY_VALUE) return [];
172
172
 
173
+ // Owner-visible policy: short credentials are literal, case-sensitive tokens only.
174
+ // Encoded short needles collide with benign cursors and ordinary diagnostic text.
175
+ if (value.length < 8) return [value];
176
+ // These ASCII characters are identical under NFC, JSON, URI-component and
177
+ // form encoding. Avoid constructing those identical variants for common tokens.
178
+ if (/^[A-Za-z0-9._*-]+$/.test(value)) {
179
+ const base64 = Buffer.from(value, "utf8").toString("base64");
180
+ const base64Url = base64.replaceAll("+", "-").replaceAll("/", "_");
181
+ return [
182
+ ...new Set([
183
+ value,
184
+ base64,
185
+ base64.replace(/=+$/, ""),
186
+ base64Url,
187
+ base64Url.replace(/=+$/, ""),
188
+ ]),
189
+ ];
190
+ }
191
+ const normalized = value.normalize("NFC");
173
192
  const formEncoded = new URLSearchParams({ value }).toString().slice("value=".length);
174
193
  let componentEncoded: string;
175
194
  try {
@@ -179,15 +198,42 @@ function sensitiveValueVariants(value: string): string[] {
179
198
  // still safe to scrub, and URLSearchParams supplies its well-formed form.
180
199
  componentEncoded = value;
181
200
  }
182
- return [...new Set([value, componentEncoded, formEncoded].map(normalizePercentEscapes))];
183
- }
184
-
185
- type SensitiveValueVariant = { value: string; requiresTokenBoundary: boolean };
201
+ const base64 = Buffer.from(value, "utf8").toString("base64");
202
+ const base64Url = base64.replaceAll("+", "-").replaceAll("/", "_");
203
+ const jsonEscaped = JSON.stringify(value).slice(1, -1);
204
+ return [
205
+ ...new Set(
206
+ [
207
+ value,
208
+ normalized,
209
+ componentEncoded,
210
+ formEncoded,
211
+ jsonEscaped,
212
+ base64,
213
+ base64.replace(/=+$/, ""),
214
+ base64Url,
215
+ base64Url.replace(/=+$/, ""),
216
+ ].map(normalizePercentEscapes),
217
+ ),
218
+ ];
219
+ }
220
+
221
+ export type SensitiveValueVariant = {
222
+ value: string;
223
+ requiresTokenBoundary: boolean;
224
+ caseInsensitive: boolean;
225
+ };
186
226
 
187
- function allSensitiveValueVariants(sensitiveValues: readonly string[]): SensitiveValueVariant[] {
227
+ export function allSensitiveValueVariants(
228
+ sensitiveValues: readonly string[],
229
+ ): SensitiveValueVariant[] {
188
230
  const variants = new Map<string, SensitiveValueVariant>();
189
231
  for (const sensitiveValue of sensitiveValues) {
190
232
  const requiresTokenBoundary = sensitiveValue.length < MIN_UNSCOPED_SENSITIVE_VALUE_LENGTH;
233
+ // Long values are unlikely to be ordinary prose, so case folding is worth
234
+ // the extra over-redaction protection. Values of 4-7 characters remain
235
+ // case-sensitive because they commonly occur as words and identifiers.
236
+ const caseInsensitive = sensitiveValue.length >= 8;
191
237
  for (const value of sensitiveValueVariants(sensitiveValue)) {
192
238
  const existing = variants.get(value);
193
239
  variants.set(value, {
@@ -195,6 +241,7 @@ function allSensitiveValueVariants(sensitiveValues: readonly string[]): Sensitiv
195
241
  requiresTokenBoundary: existing
196
242
  ? existing.requiresTokenBoundary && requiresTokenBoundary
197
243
  : requiresTokenBoundary,
244
+ caseInsensitive: existing ? existing.caseInsensitive || caseInsensitive : caseInsensitive,
198
245
  });
199
246
  }
200
247
  }
@@ -209,28 +256,29 @@ function isTokenCharacter(value: string | undefined): boolean {
209
256
  }
210
257
 
211
258
  function replaceSensitiveVariant(text: string, variant: SensitiveValueVariant): string {
212
- const pattern = variant.value.replace(/%([\da-f]{2})|[.*+?^${}()|[\]\\]/gi, (match, hex) => {
213
- if (hex) {
214
- return `%${[...String(hex)]
215
- .map((character) =>
216
- /[a-f]/i.test(character)
217
- ? `[${character.toLowerCase()}${character.toUpperCase()}]`
218
- : character,
219
- )
220
- .join("")}`;
221
- }
222
- return `\\${match}`;
223
- });
224
- return text.replace(new RegExp(pattern, "gu"), (match, offset: number, source: string) => {
225
- const end = offset + match.length;
259
+ const normalized = normalizePercentEscapes(text);
260
+ const comparable = variant.caseInsensitive ? normalized.toLowerCase() : text;
261
+ const needle = variant.caseInsensitive ? variant.value.toLowerCase() : variant.value;
262
+ let cursor = 0;
263
+ let output = "";
264
+ for (
265
+ let index = comparable.indexOf(needle);
266
+ index !== -1;
267
+ index = comparable.indexOf(needle, index + 1)
268
+ ) {
269
+ const end = index + needle.length;
226
270
  if (
227
271
  variant.requiresTokenBoundary &&
228
- (isTokenCharacter(source[offset - 1]) || isTokenCharacter(source[end]))
229
- ) {
230
- return match;
231
- }
232
- return REDACTED_QUERY_VALUE;
233
- });
272
+ (isTokenCharacter(comparable[index - 1]) || isTokenCharacter(comparable[end]))
273
+ )
274
+ continue;
275
+ // Unicode folding can change offsets; suppress the field instead of slicing a secret.
276
+ if (comparable.length !== text.length) return REDACTED_QUERY_VALUE;
277
+ output += text.slice(cursor, index) + REDACTED_QUERY_VALUE;
278
+ cursor = end;
279
+ index = end - 1;
280
+ }
281
+ return cursor ? output + text.slice(cursor) : text;
234
282
  }
235
283
 
236
284
  /** Scrubs raw and URL-encoded secret values from diagnostic text. */
@@ -1,6 +1,7 @@
1
1
  import { PROVIDER_OBSERVABILITY_TAXONOMY_VERSION } from "../observability.js";
2
2
  import type { ProxyTelemetrySink } from "../config/loader.js";
3
3
  import type { ProxyTelemetryLogPayload } from "./proxy-telemetry.js";
4
+ import type { ResolverTelemetryLogPayload } from "./resolver-telemetry.js";
4
5
  import type { Span, TraceContext } from "./trace.js";
5
6
 
6
7
  export const PROVIDER_TELEMETRY_HEADER = "X-ApiFuse-Provider-Telemetry";
@@ -57,9 +58,7 @@ export type GatewayIngestible<T> = 0 extends 1 & T
57
58
  ? never
58
59
  : unknown extends U
59
60
  ? never
60
- : [U] extends [
61
- number | boolean | ClosedEnum<string> | GatewayIngestible<U>,
62
- ]
61
+ : [U] extends [number | boolean | ClosedEnum<string> | GatewayIngestible<U>]
63
62
  ? T[K]
64
63
  : never
65
64
  : [NonNullable<T[K]>] extends [object]
@@ -86,9 +85,7 @@ export type TenantNeutral<T> = 0 extends 1 & T
86
85
  ? never
87
86
  : unknown extends T[K]
88
87
  ? never
89
- : [NonNullable<T[K]>] extends [
90
- string[] & { readonly __tenantOpaqueCacheKeys: true },
91
- ]
88
+ : [NonNullable<T[K]>] extends [string[] & { readonly __tenantOpaqueCacheKeys: true }]
92
89
  ? K extends "keys"
93
90
  ? T[K]
94
91
  : never
@@ -123,7 +120,8 @@ export interface TelemetryContributor<Log extends object, Header extends object>
123
120
 
124
121
  export type RequestTelemetryLogPayload = {
125
122
  proxy?: ProxyTelemetryLogPayload;
126
- } & Partial<Record<Exclude<TelemetryKey, "proxy">, object>>;
123
+ resolver?: ResolverTelemetryLogPayload;
124
+ } & Partial<Record<Exclude<TelemetryKey, "proxy" | "resolver">, object>>;
127
125
 
128
126
  type RegisteredTelemetryContributor = {
129
127
  readonly key: TelemetryKey;