@apifuse/provider-sdk 2.2.0-beta.67 → 2.2.0-beta.69

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 (77) hide show
  1. package/AUTHORING.md +34 -2
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +18 -7
  4. package/bin/apifuse-record.ts +81 -21
  5. package/bin/apifuse-submit-check.ts +170 -28
  6. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  7. package/dist/cli/templates/provider/README.md.tpl +17 -9
  8. package/dist/config/loader.d.ts +16 -0
  9. package/dist/config/loader.js +25 -8
  10. package/dist/error-resolution.d.ts +13 -0
  11. package/dist/error-resolution.js +72 -35
  12. package/dist/fixture-sanitization.js +159 -15
  13. package/dist/lint.d.ts +2 -0
  14. package/dist/lint.js +549 -11
  15. package/dist/runtime/diagnostic-redactor.d.ts +6 -1
  16. package/dist/runtime/diagnostic-redactor.js +16 -14
  17. package/dist/runtime/executor.js +3 -0
  18. package/dist/runtime/http.d.ts +2 -0
  19. package/dist/runtime/http.js +17 -103
  20. package/dist/runtime/instrumentation.js +4 -1
  21. package/dist/runtime/provider.d.ts +7 -0
  22. package/dist/runtime/provider.js +14 -0
  23. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  24. package/dist/runtime/redirect-hop-headers.js +58 -0
  25. package/dist/runtime/redirects.d.ts +33 -1
  26. package/dist/runtime/redirects.js +113 -0
  27. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  28. package/dist/runtime/resolver-span-attributes.js +93 -0
  29. package/dist/runtime/resolver-usage.d.ts +8 -0
  30. package/dist/runtime/resolver-usage.js +5 -11
  31. package/dist/runtime/resolver-vendors/browser.js +5 -4
  32. package/dist/runtime/resolver-vendors/capsolver.js +11 -10
  33. package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
  34. package/dist/runtime/resolver.js +7 -6
  35. package/dist/runtime/stealth-owned-headers.d.ts +21 -8
  36. package/dist/runtime/stealth-owned-headers.js +28 -8
  37. package/dist/runtime/stealth-telemetry.d.ts +1 -1
  38. package/dist/runtime/stealth.js +158 -46
  39. package/dist/runtime/trace.js +2 -2
  40. package/dist/server/index.d.ts +1 -0
  41. package/dist/server/serve-implementation.d.ts +2 -1
  42. package/dist/server/serve-implementation.js +45 -30
  43. package/dist/server/state-backend-report.d.ts +70 -0
  44. package/dist/server/state-backend-report.js +113 -0
  45. package/dist/testing/run.js +14 -7
  46. package/dist/trace-sanitization.js +1 -1
  47. package/dist/types.d.ts +30 -6
  48. package/package.json +2 -2
  49. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  50. package/src/cli/templates/provider/README.md.tpl +17 -9
  51. package/src/config/loader.ts +49 -12
  52. package/src/error-resolution.ts +89 -35
  53. package/src/fixture-sanitization.ts +175 -17
  54. package/src/lint.ts +595 -10
  55. package/src/runtime/diagnostic-redactor.ts +19 -13
  56. package/src/runtime/executor.ts +3 -0
  57. package/src/runtime/http.ts +24 -112
  58. package/src/runtime/instrumentation.ts +4 -1
  59. package/src/runtime/provider.ts +15 -0
  60. package/src/runtime/redirect-hop-headers.ts +71 -0
  61. package/src/runtime/redirects.ts +150 -1
  62. package/src/runtime/resolver-span-attributes.ts +139 -0
  63. package/src/runtime/resolver-usage.ts +8 -5
  64. package/src/runtime/resolver-vendors/browser.ts +5 -4
  65. package/src/runtime/resolver-vendors/capsolver.ts +11 -10
  66. package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
  67. package/src/runtime/resolver.ts +7 -6
  68. package/src/runtime/stealth-owned-headers.ts +34 -8
  69. package/src/runtime/stealth-telemetry.ts +6 -1
  70. package/src/runtime/stealth.ts +180 -58
  71. package/src/runtime/trace.ts +6 -2
  72. package/src/server/index.ts +4 -0
  73. package/src/server/serve-implementation.ts +58 -31
  74. package/src/server/state-backend-report.ts +185 -0
  75. package/src/testing/run.ts +15 -2
  76. package/src/trace-sanitization.ts +1 -1
  77. package/src/types.ts +30 -6
@@ -5,11 +5,12 @@ import { SDKError, TransportError } from "../errors.js";
5
5
  import { DEFAULT_STEALTH_BROWSER, DEFAULT_STEALTH_OS, getStealthProfile, resolverClientProfileFamily, resolveStealthProfileSelection, } from "../stealth/profiles.js";
6
6
  import { chrome149HeaderOrder } from "./chrome149-header-order.js";
7
7
  import { registerDiagnosticValue } from "./diagnostic-env.js";
8
- import { isStealthOwnedHeaderName } from "./stealth-owned-headers.js";
8
+ import { assertCallerHeadersSupported } from "./stealth-owned-headers.js";
9
9
  import { ENGINE_CEREMONY_EGRESS_LEASE, } from "./egress-lease.js";
10
10
  import { createProxyAuthIpDeniedError, createProxyEdgeAuthRejectedError, createProxyEdgeTlsRejectedError, createProxyPoolExhaustedError, createProxyPoolStaleError, isProxyAuthIpDeniedMessage, isProxyEdgeAuthRejectedMessage, isProxyEdgeTlsRejectedResponse, isProxyPoolRefreshableError, isProxyPoolStaleMessage, isProxyPoolStaleStatus, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_STALE_CODE, } from "./proxy-errors.js";
11
11
  import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, isProxyTransportRetryMethod, normalizeProxyTransportRetryOptions, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
12
- import { evaluateRedirectHop, isRedirectStatus, nextRedirectMethod, resolveRedirectUrl, } from "./redirects.js";
12
+ import { redirectHopHeaders } from "./redirect-hop-headers.js";
13
+ import { evaluateRedirectHop, invalidRedirectPolicy, isRedirectStatus, nextRedirectMethod, nextSameOriginRedirectHop, normalizeRedirectPolicy, resolveRedirectUrl, } from "./redirects.js";
13
14
  import { isSensitiveKey, normalizeSensitiveParams, redactSensitiveError, redactSensitiveRequestError, redactSensitiveText, redactUrlQueryParams, serializeRequestUrl, } from "./request-options.js";
14
15
  import { akamaiSbsdChallengeKey, detectAkamaiSbsdChallenge, } from "./stealth-akamai-sbsd.js";
15
16
  import { StealthCookieJar } from "./stealth-cookies.js";
@@ -37,12 +38,10 @@ function isBoundEgressTransportFailure(error) {
37
38
  (error.code !== undefined && BOUND_EGRESS_TRANSPORT_FAILURE_CODES.has(error.code)));
38
39
  }
39
40
  const MAX_STEALTH_REDIRECT_HOPS = 10;
40
- const REDIRECT_BODY_HEADERS = new Set([
41
- "content-encoding",
42
- "content-language",
43
- "content-location",
44
- "content-type",
45
- ]);
41
+ const STEALTH_REDIRECT_POLICY_TRANSPORT = {
42
+ label: "ctx.stealth",
43
+ maxHops: MAX_STEALTH_REDIRECT_HOPS,
44
+ };
46
45
  function isRecord(value) {
47
46
  return typeof value === "object" && value !== null;
48
47
  }
@@ -179,6 +178,8 @@ function normalizedHeaderEntries(headers) {
179
178
  // installs Accept-Language via DevTools next to User-Agent; real Chrome only
180
179
  // receives it from //net (URLRequestHttpJob::AddExtraHeaders), after
181
180
  // Accept-Encoding and before Cookie, as al-placement-capture.json B/C show.
181
+ // chrome-form-post-capture.json was taken without that option and needs no
182
+ // adjustment.
182
183
  const CHROME_HEADER_ORDERS = {
183
184
  navigation: [
184
185
  "sec-ch-ua",
@@ -215,6 +216,27 @@ const CHROME_HEADER_ORDERS = {
215
216
  "cookie",
216
217
  "priority",
217
218
  ],
219
+ "form-post": [
220
+ "content-length",
221
+ "cache-control",
222
+ "sec-ch-ua",
223
+ "sec-ch-ua-mobile",
224
+ "sec-ch-ua-platform",
225
+ "upgrade-insecure-requests",
226
+ "content-type",
227
+ "user-agent",
228
+ "origin",
229
+ "accept",
230
+ "sec-fetch-site",
231
+ "sec-fetch-mode",
232
+ "sec-fetch-user",
233
+ "sec-fetch-dest",
234
+ "referer",
235
+ "accept-encoding",
236
+ "accept-language",
237
+ "cookie",
238
+ "priority",
239
+ ],
218
240
  };
219
241
  const CHROME_H1_HEADER_ORDERS = {
220
242
  navigation: [
@@ -271,6 +293,28 @@ const CHROME_H1_HEADER_ORDERS = {
271
293
  "accept-language",
272
294
  "cookie",
273
295
  ],
296
+ "form-post": [
297
+ "host",
298
+ "connection",
299
+ "content-length",
300
+ "cache-control",
301
+ "sec-ch-ua",
302
+ "sec-ch-ua-mobile",
303
+ "sec-ch-ua-platform",
304
+ "upgrade-insecure-requests",
305
+ "content-type",
306
+ "user-agent",
307
+ "origin",
308
+ "accept",
309
+ "sec-fetch-site",
310
+ "sec-fetch-mode",
311
+ "sec-fetch-user",
312
+ "sec-fetch-dest",
313
+ "referer",
314
+ "accept-encoding",
315
+ "accept-language",
316
+ "cookie",
317
+ ],
274
318
  };
275
319
  const CHROME_H1_HEADER_NAMES = {
276
320
  host: "Host",
@@ -293,6 +337,25 @@ const CHROME_H1_HEADER_NAMES = {
293
337
  origin: "Origin",
294
338
  range: "Range",
295
339
  };
340
+ /**
341
+ * What each request class sends besides its header order, one row per public
342
+ * `stealth.requestClass`:
343
+ *
344
+ * - `fetchMetadata: "navigate"` is a document navigation: `Sec-Fetch-Mode:
345
+ * navigate`, `Sec-Fetch-Dest: document`, `Sec-Fetch-User: ?1` unless
346
+ * `userActivation` is false, `Upgrade-Insecure-Requests: 1`, and the
347
+ * profile's `Accept` and `Priority`. `"cors"` is a script fetch: `cors` /
348
+ * `empty`, `Accept: *\/*`, `Priority: u=1, i`.
349
+ * - `bodyful` sends `Content-Length`.
350
+ * - `validateCache` sends `Cache-Control: max-age=0`, which Chrome adds to a
351
+ * form submission (chrome-form-post-capture.json).
352
+ */
353
+ const CHROME_REQUEST_CLASSES = {
354
+ navigation: { fetchMetadata: "navigate", bodyful: false, validateCache: false },
355
+ "form-post": { fetchMetadata: "navigate", bodyful: true, validateCache: true },
356
+ xhr: { fetchMetadata: "cors", bodyful: false, validateCache: false },
357
+ post: { fetchMetadata: "cors", bodyful: true, validateCache: false },
358
+ };
296
359
  function chromeHeaderOrder(requestClass, isHttp1, caller) {
297
360
  if (isHttp1)
298
361
  return CHROME_H1_HEADER_ORDERS[requestClass];
@@ -320,29 +383,11 @@ function chromeHeaderOrder(requestClass, isHttp1, caller) {
320
383
  }
321
384
  return order;
322
385
  }
323
- function normalizedCallerHeaderEntries(headers) {
324
- const entries = normalizedHeaderEntries(headers);
325
- assertCallerHeadersSupported(entries);
326
- return entries;
327
- }
328
386
  function normalizedCallerHeaderEntriesFromRecord(headers) {
329
387
  // The record has been through normalizeHeaders (lowercase, merged names); the
330
388
  // lowercase here only guards later insertions such as the cookie jar's header.
331
- const entries = Object.entries(headers).map(([name, value]) => [
332
- name.toLowerCase(),
333
- value,
334
- ]);
335
- assertCallerHeadersSupported(entries);
336
- return entries;
337
- }
338
- function assertCallerHeadersSupported(entries) {
339
- for (const [name] of entries) {
340
- if (isStealthOwnedHeaderName(name)) {
341
- throw new SDKError(`Stealth transport owns the "${name}" header; remove it from headers.`, {
342
- code: "STEALTH_HEADER_OVERRIDE_UNSUPPORTED",
343
- });
344
- }
345
- }
389
+ assertCallerHeadersSupported(headers);
390
+ return Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]);
346
391
  }
347
392
  function chromeRequestClass(method, requestedClass) {
348
393
  if (requestedClass)
@@ -374,9 +419,10 @@ function buildChromeHeaderTuples(options) {
374
419
  const caller = new Map(callerEntries);
375
420
  const emulation = new Map(Array.from(options.emulationHeaders, ([name, value]) => [name.toLowerCase(), value]));
376
421
  const requestClass = chromeRequestClass(options.method, options.requestClass);
422
+ const shape = CHROME_REQUEST_CLASSES[requestClass];
377
423
  const referer = caller.get("referer");
378
424
  const fetchSite = secFetchSite(options.requestUrl, referer);
379
- const isNavigation = requestClass === "navigation";
425
+ const isNavigation = shape.fetchMetadata === "navigate";
380
426
  const values = new Map([
381
427
  ["sec-ch-ua", requiredEmulationHeader(emulation, "sec-ch-ua")],
382
428
  ["sec-ch-ua-mobile", requiredEmulationHeader(emulation, "sec-ch-ua-mobile")],
@@ -403,6 +449,8 @@ function buildChromeHeaderTuples(options) {
403
449
  if (options.userActivation !== false)
404
450
  values.set("sec-fetch-user", "?1");
405
451
  }
452
+ if (shape.validateCache)
453
+ values.set("cache-control", "max-age=0");
406
454
  for (const [name, value] of callerEntries)
407
455
  values.set(name, value);
408
456
  for (const name of ["content-type", "origin", "referer"]) {
@@ -410,7 +458,10 @@ function buildChromeHeaderTuples(options) {
410
458
  if (value !== undefined)
411
459
  values.set(name, value);
412
460
  }
413
- if (requestClass === "post") {
461
+ // A bodyful class keeps its class across a 301/302/303 that rewrites the method
462
+ // to GET (a form submission's redirect stays a validated navigation), but the
463
+ // rewritten request has no body and Chrome sends no Content-Length for it.
464
+ if (shape.bodyful && options.method !== "GET" && options.method !== "HEAD") {
414
465
  values.set("content-length", caller.get("content-length") ?? String(Buffer.byteLength(options.body ?? "")));
415
466
  }
416
467
  const isHttp1 = new URL(options.requestUrl).protocol === "http:";
@@ -865,8 +916,25 @@ function locationHeader(headers) {
865
916
  }
866
917
  return undefined;
867
918
  }
868
- function withoutRedirectBodyHeaders(headers) {
869
- return Object.fromEntries(Object.entries(headers).filter(([name]) => !REDIRECT_BODY_HEADERS.has(name.toLowerCase())));
919
+ /**
920
+ * Snapshot a caller's `redirectPolicy` before the first send. The policy acts
921
+ * on redirects the transport follows, so it requires the default
922
+ * `redirect: "follow"`.
923
+ */
924
+ function snapshotStealthRedirectPolicy(options) {
925
+ let policy;
926
+ try {
927
+ policy = normalizeRedirectPolicy(options.redirectPolicy, STEALTH_REDIRECT_POLICY_TRANSPORT);
928
+ }
929
+ catch (error) {
930
+ if (error instanceof TransportError)
931
+ throw error;
932
+ throw invalidRedirectPolicy(STEALTH_REDIRECT_POLICY_TRANSPORT, "could not be read safely", error instanceof Error ? error : undefined);
933
+ }
934
+ if (policy && options.redirect !== undefined && options.redirect !== "follow") {
935
+ throw invalidRedirectPolicy(STEALTH_REDIRECT_POLICY_TRANSPORT, `it applies to followed redirects and cannot be combined with redirect: "${options.redirect}"`);
936
+ }
937
+ return policy;
870
938
  }
871
939
  function assertStealthRedirectUrl(url) {
872
940
  const protocol = new URL(url).protocol;
@@ -962,7 +1030,9 @@ function startStealthExchange(telemetry, method, options, signal) {
962
1030
  kind: telemetry.kind,
963
1031
  requestClass: requestClass === "navigation" && options.stealth?.userActivation === false
964
1032
  ? "script_navigation"
965
- : requestClass,
1033
+ : requestClass === "form-post"
1034
+ ? "form_post"
1035
+ : requestClass,
966
1036
  ...(status === undefined ? {} : { status }),
967
1037
  ...(errorCode ? { errorCode } : {}),
968
1038
  ...(error === undefined
@@ -988,6 +1058,10 @@ async function fetchStealthRedirectChain(transport, cookieJar, requestUrl, metho
988
1058
  let followedHops = 0;
989
1059
  let response;
990
1060
  const deadline = options.timeout ? performance.now() + options.timeout : undefined;
1061
+ // Snapshotted and validated by session.fetch before the first send.
1062
+ const redirectPolicy = options.redirectPolicy;
1063
+ const initialOrigin = new URL(requestUrl).origin;
1064
+ const visitedRequests = new Set([`${method} ${requestUrl}`]);
991
1065
  let finishExchange;
992
1066
  let status;
993
1067
  let exchangeError;
@@ -1040,23 +1114,42 @@ async function fetchStealthRedirectChain(transport, cookieJar, requestUrl, metho
1040
1114
  status: 0,
1041
1115
  });
1042
1116
  }
1043
- const nextUrl = resolveRedirectUrl(response.headers.get("location") ?? undefined, response.url ?? currentUrl);
1044
- if (!nextUrl)
1045
- break;
1046
- if (followedHops >= MAX_STEALTH_REDIRECT_HOPS) {
1117
+ let nextUrl;
1118
+ let nextMethod;
1119
+ if (redirectPolicy) {
1120
+ discardStealthRedirectBody(response);
1121
+ ({ nextUrl, nextMethod } = nextSameOriginRedirectHop({
1122
+ policy: redirectPolicy,
1123
+ initialOrigin,
1124
+ currentUrl,
1125
+ responseUrl: response.url ?? currentUrl,
1126
+ status: response.status,
1127
+ method: currentMethod,
1128
+ location: response.headers.get("location") ?? undefined,
1129
+ followedHops,
1130
+ visitedRequests,
1131
+ }));
1132
+ }
1133
+ else {
1134
+ const location = resolveRedirectUrl(response.headers.get("location") ?? undefined, response.url ?? currentUrl);
1135
+ if (!location)
1136
+ break;
1137
+ if (followedHops >= MAX_STEALTH_REDIRECT_HOPS) {
1138
+ discardStealthRedirectBody(response);
1139
+ throw new TransportError(`Stealth request exceeded the ${MAX_STEALTH_REDIRECT_HOPS}-redirect limit`, { code: "transport_network_error", status: 0 });
1140
+ }
1047
1141
  discardStealthRedirectBody(response);
1048
- throw new TransportError(`Stealth request exceeded the ${MAX_STEALTH_REDIRECT_HOPS}-redirect limit`, { code: "transport_network_error", status: 0 });
1142
+ nextUrl = location;
1143
+ nextMethod = nextRedirectMethod(response.status, currentMethod);
1049
1144
  }
1050
1145
  assertStealthRedirectUrl(nextUrl);
1051
- discardStealthRedirectBody(response);
1052
- const nextMethod = nextRedirectMethod(response.status, currentMethod);
1053
- if (nextMethod !== currentMethod) {
1146
+ currentHeaders = redirectHopHeaders({ from: currentUrl, to: nextUrl, methodChanged: nextMethod !== currentMethod }, currentHeaders);
1147
+ if (nextMethod !== currentMethod)
1054
1148
  currentBody = undefined;
1055
- currentHeaders = withoutRedirectBodyHeaders(currentHeaders);
1056
- }
1057
1149
  currentMethod = nextMethod;
1058
1150
  currentUrl = nextUrl;
1059
1151
  followedHops += 1;
1152
+ visitedRequests.add(`${currentMethod} ${currentUrl}`);
1060
1153
  emitStealthTelemetry(telemetry?.sink, (sink) => sink.recordRedirectHop());
1061
1154
  finishExchange(status);
1062
1155
  finishExchange = undefined;
@@ -1155,10 +1248,15 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1155
1248
  let entry = clients.get(cacheKey);
1156
1249
  if (!entry) {
1157
1250
  entry = {
1251
+ // The header shape only partitions sessions; it is never handed to wreq as
1252
+ // session `defaultHeaders`. wreq merges those into every request that lacks
1253
+ // one of their names, so a header the SDK left off on purpose — a credential
1254
+ // dropped on a cross-origin redirect hop, the body headers after POST -> GET,
1255
+ // a cookie for another host — would come back with the first request's value.
1256
+ // Every request already sends its complete ordered header list.
1158
1257
  session: wreq.createSession({
1159
1258
  browser,
1160
1259
  os,
1161
- ...(defaultHeaders ? { defaultHeaders } : {}),
1162
1260
  ...(proxyUrl ? { proxy: proxyUrl } : {}),
1163
1261
  ...(ignoreTlsErrors ? { insecure: true } : {}),
1164
1262
  timeout: 30_000,
@@ -1332,7 +1430,12 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1332
1430
  const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
1333
1431
  try {
1334
1432
  const method = normalizeMethod(options.method ?? "GET");
1335
- normalizedCallerHeaderEntries(options.headers ?? {});
1433
+ assertCallerHeadersSupported(options.headers ?? {});
1434
+ // Replace the caller's object with the validated copy: the walker, a
1435
+ // retry and an explicit replay all read this snapshot.
1436
+ const redirectPolicy = snapshotStealthRedirectPolicy(options);
1437
+ if (redirectPolicy)
1438
+ options.redirectPolicy = redirectPolicy;
1336
1439
  const hasExplicitRetryPolicy = options.retry !== undefined;
1337
1440
  const stealthRetryOptions = normalizeProxyTransportRetryOptions(options.retry, {
1338
1441
  extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
@@ -1900,10 +2003,11 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1900
2003
  const maxHops = options.maxHops === undefined || !Number.isFinite(options.maxHops)
1901
2004
  ? 10
1902
2005
  : Math.max(0, Math.floor(options.maxHops));
1903
- const { url: _url, maxHops: _maxHops, stopWhen, params, sensitiveParams, ...fetchOptions } = options;
2006
+ const { url: _url, maxHops: _maxHops, stopWhen, params, sensitiveParams, headers: initialHeaders, ...fetchOptions } = options;
1904
2007
  const hops = [];
1905
2008
  let method = normalizeMethod(options.method ?? "GET");
1906
2009
  let body = options.body;
2010
+ let headers = initialHeaders;
1907
2011
  let response;
1908
2012
  const visitedRequests = new Set();
1909
2013
  const initialParams = params
@@ -1956,6 +2060,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1956
2060
  try {
1957
2061
  response = await session.fetch(currentUrl, {
1958
2062
  ...fetchOptions,
2063
+ ...(headers ? { headers } : {}),
1959
2064
  body,
1960
2065
  method,
1961
2066
  ...(hopIndex === 0 && initialParams ? { params: initialParams } : {}),
@@ -2042,6 +2147,13 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
2042
2147
  cookieStore: cookieJar.serialize(),
2043
2148
  };
2044
2149
  }
2150
+ if (headers) {
2151
+ headers = redirectHopHeaders({
2152
+ from: currentUrl,
2153
+ to: decision.nextUrl,
2154
+ methodChanged: decision.nextMethod !== method,
2155
+ }, headers);
2156
+ }
2045
2157
  if (decision.nextMethod !== method) {
2046
2158
  body = undefined;
2047
2159
  }
@@ -76,7 +76,7 @@ function normalizeAttributes(attributes, redact, retainBigints = false) {
76
76
  ? redactedKey === REDACTION_FAILED
77
77
  ? REDACTION_FAILED
78
78
  : REDACTED_QUERY_VALUE
79
- : normalizeAttributeValue(value, diagnosticAttributeRedactor(key, redact), retainBigints);
79
+ : normalizeAttributeValue(value, diagnosticAttributeRedactor(key, redact, value), retainBigints);
80
80
  return [normalizedKey, normalizedValue];
81
81
  })
82
82
  .filter((entry) => entry[1] !== undefined);
@@ -103,7 +103,7 @@ function prepareResourceAttributesForExport(attributes, redact, sanitize) {
103
103
  ? redactedKey === REDACTION_FAILED
104
104
  ? REDACTION_FAILED
105
105
  : REDACTED_QUERY_VALUE
106
- : sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact)),
106
+ : sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact, value)),
107
107
  ];
108
108
  }));
109
109
  }
@@ -12,6 +12,7 @@ export type { ResolverVendorUnavailableReason } from "../runtime/resolver-vendor
12
12
  export type { Span, TraceContext } from "../runtime/trace.js";
13
13
  export type { StealthBrowser, StealthOS, StealthProfileDescriptor } from "../types.js";
14
14
  export { createServerApp, createServerAppAsync, ERROR_OBSERVABILITY_HEADER, type ErrorObservabilityDetails, type ProviderServerCloseOptions, type ProviderServerHandle, type ProviderErrorCauseFrame, type ProviderServerLogEvent, type ProviderServerLogger, type ProviderServerOperationExecutor, type ProviderServerOperationExecutorInput, type ProviderServerOptions, type ProviderServerStatefulForwardEnvelope, type ServeOptions, serve, } from "./serve.js";
15
+ export type { ProviderStateBackendLogEvent, ProviderStoreBackendReport, } from "./state-backend-report.js";
15
16
  export type { ProviderErrorObservability } from "../errors.js";
16
17
  export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, type SelfTestAppOptions, type SelfTestAuthFlowInvoke, type SelfTestAuthFlowRoute, type SelfTestCaseResult, type SelfTestCaseStatus, type SelfTestOperationInvoke, type SelfTestRequest, SelfTestRequestSchema, type SelfTestResponse, } from "./self-test.js";
17
18
  export { type InputDateTokenCalendar, resolveHealthCheckInputDateTokens, } from "./self-test-input-tokens.js";
@@ -10,6 +10,7 @@ import { type ProxyTelemetryLogPayload } from "../runtime/proxy-telemetry.js";
10
10
  import { type ResolverTelemetryLogPayload } from "../runtime/resolver-telemetry.js";
11
11
  import type { OcrContext, ProviderContext, ProviderDefinition, ProviderFilesContext, ProviderRuntimeState, ProviderRuntimeTarget, ResolverContext, SttContext } from "../types.js";
12
12
  import type { SelfTestCancellationLogEvent } from "./self-test.js";
13
+ import { type ProviderStateBackendLogEvent } from "./state-backend-report.js";
13
14
  import { type AuthFlowRequest, type OperationRequest } from "./types.js";
14
15
  /** Compact SDK-owned error classification emitted separately from the public response body. */
15
16
  export declare const ERROR_OBSERVABILITY_HEADER = "X-ApiFuse-Error-Observability";
@@ -167,7 +168,7 @@ export type ProviderServerLogEvent = (ProviderServerLogEventBase & {
167
168
  * `engine_env_ignored`. Absent when the selection was unambiguous.
168
169
  */
169
170
  warnings?: readonly string[];
170
- } | SelfTestCancellationLogEvent;
171
+ } | ProviderStateBackendLogEvent | SelfTestCancellationLogEvent;
171
172
  export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
172
173
  export type ProviderServerOptions<TContext extends Partial<ProviderContext> = ProviderContext> = {
173
174
  logger?: ProviderServerLogger;
@@ -15,7 +15,7 @@ import { createEngineForMode, isUnavailableProviderEngine, resolveProviderEngine
15
15
  import { SDK_VERSION } from "./sdk-version.js";
16
16
  import { safeProviderErrorObservability } from "../error-observability.js";
17
17
  import { providerErrorStackFrames } from "./error-stack-frames.js";
18
- import { SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
18
+ import { registeredUpstreamErrorCategory, SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
19
19
  import { AuthError, isProviderError, isSessionExpiredError, isTransportError, isValidationError, ProviderError, SDKError, } from "../errors.js";
20
20
  import { sanitizeDiagnosticText } from "../fixture-sanitization.js";
21
21
  import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog.js";
@@ -37,10 +37,11 @@ import { HttpTelemetryCollector } from "../runtime/http-telemetry.js";
37
37
  import { wrapWithInstrumentation } from "../runtime/instrumentation.js";
38
38
  import { createOcrClientFromEnv } from "../runtime/ocr.js";
39
39
  import { bindOcrTelemetry, OcrTelemetryCollector } from "../runtime/ocr-telemetry.js";
40
- import { getProviderBaseUrl } from "../runtime/provider.js";
40
+ import { getProviderBaseUrl, getProviderStealthBaseUrl } from "../runtime/provider.js";
41
41
  import { PROXY_AUTH_IP_DENIED_CODE, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_EXHAUSTED_CODE, } from "../runtime/proxy-errors.js";
42
42
  import { PROVIDER_TELEMETRY_HEADER, ProxyTelemetryCollector, } from "../runtime/proxy-telemetry.js";
43
43
  import { closedEnum, RequestTelemetry, tenantOpaqueKeys, } from "../runtime/request-telemetry.js";
44
+ import { resolverSpanAttributes } from "../runtime/resolver-span-attributes.js";
44
45
  import { bindResolverTelemetry, createUnsupportedResolverClient, inheritResolverTelemetryBinding, } from "../runtime/resolver-shared.js";
45
46
  import { ResolverTelemetryCollector, } from "../runtime/resolver-telemetry.js";
46
47
  import { createResolverRuntimeOptions, resolveNativeProxyPolicy, } from "../runtime/resolver-runtime-options.js";
@@ -62,6 +63,7 @@ import { getStealthProfile } from "../stealth/profiles.js";
62
63
  import { APIFUSE_STREAM_DONE_EVENT, APIFUSE_STREAM_ERROR_EVENT, encodeSseEvent, error as streamError, } from "../stream.js";
63
64
  import { sanitizeSpanNameForOutput, sanitizeTraceAttributes } from "../trace-sanitization.js";
64
65
  import { VALID_OPERATION_ERROR_STATUSES } from "../types.js";
66
+ import { providerStateBackendLogEvent, } from "./state-backend-report.js";
65
67
  import { resolveSelfTestMasterSecrets } from "./self-test-token.js";
66
68
  import { collectStaticDiagnosticSensitiveValues, createDiagnosticEnvObserver, } from "./sensitive-values.js";
67
69
  import { resolveServerTraceContextOptions } from "./trace-output.js";
@@ -340,14 +342,6 @@ function bindResolverSignalWithoutRuntime(resolver, defaultSignal) {
340
342
  inheritResolverTelemetryBinding(resolver, boundResolver);
341
343
  return boundResolver;
342
344
  }
343
- function getProviderStealthBaseUrl(provider) {
344
- const baseUrl = getProviderBaseUrl(provider);
345
- if (baseUrl) {
346
- return baseUrl;
347
- }
348
- const firstHost = provider.allowedHosts?.[0];
349
- return firstHost ? `https://${firstHost}` : undefined;
350
- }
351
345
  function getProviderStealthProfile(provider) {
352
346
  return provider.stealth ? getStealthProfile(provider.stealth) : undefined;
353
347
  }
@@ -392,7 +386,7 @@ function createStealthChallengeDetection(provider, resolverRuntime, resolverOver
392
386
  const solve = () => resolver.solve(challenge, solveSignal, recorder ?? undefined);
393
387
  return recorder
394
388
  ? recorder.runSpan("resolver.solve", solve, {
395
- attributes: { challenge_kind: challenge.kind },
389
+ attributes: resolverSpanAttributes({ challenge_kind: challenge.kind }),
396
390
  })
397
391
  : solve();
398
392
  },
@@ -849,8 +843,9 @@ function zodDetails(error, redact) {
849
843
  // (credential_unavailable) usually means a caller credential problem, an
850
844
  // internal stateful-routing deadline is APIFuse-owned despite its timeout
851
845
  // category, and the built-in upstream failure families keep their upstream
852
- // attribution even when the author left the category at the provider_error
853
- // default.
846
+ // attribution even under an explicit non-upstream category. An unset category
847
+ // needs no override: a registered upstream code already carries its category
848
+ // (SDK_UPSTREAM_ERROR_CODE_REGISTRY), and the source follows from it.
854
849
  function publicErrorSource(error, category) {
855
850
  if (error instanceof StatefulRoutingDeadlineError)
856
851
  return "apifuse";
@@ -1120,6 +1115,18 @@ function providerObservabilityDetails(error, declaredErrorCode) {
1120
1115
  ...(error.upstreamStatus ? { upstreamStatus: error.upstreamStatus } : {}),
1121
1116
  };
1122
1117
  }
1118
+ // The category a `ProviderError` (validation errors included) gets when its
1119
+ // author set none, ahead of the branch's own default: an operation-declared
1120
+ // rejection status (409/410/422) → upstream_rejected, then the registered
1121
+ // upstream code's category (UPSTREAM_SCHEMA_ERROR → upstream_schema_drift,
1122
+ // NOT_FOUND → upstream_http, ...). `undefined` leaves the branch default.
1123
+ function derivedErrorCategory(error, declaredStatus) {
1124
+ if (isEmittableErrorStatus(declaredStatus) &&
1125
+ categoryForStatus(declaredStatus) === "upstream_rejected") {
1126
+ return "upstream_rejected";
1127
+ }
1128
+ return registeredUpstreamErrorCategory(providerErrorCode(error));
1129
+ }
1123
1130
  function classifiedErrorObservabilityDetails(error, declaredErrorCode) {
1124
1131
  const effectiveDeclaration = sdkOwnsErrorResolution(error) ? undefined : declaredErrorCode;
1125
1132
  const providerDetails = providerObservabilityDetails(error, effectiveDeclaration);
@@ -1131,12 +1138,10 @@ function classifiedErrorObservabilityDetails(error, declaredErrorCode) {
1131
1138
  return {
1132
1139
  category: providerCategory
1133
1140
  ? providerCategory
1134
- : isEmittableErrorStatus(declaredStatus) &&
1135
- categoryForStatus(declaredStatus) === "upstream_rejected"
1136
- ? "upstream_rejected"
1137
- : isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
1141
+ : (derivedErrorCategory(error, declaredStatus) ??
1142
+ (isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
1138
1143
  ? "provider_error"
1139
- : "input_validation",
1144
+ : "input_validation")),
1140
1145
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
1141
1146
  retryable: isProviderError(error)
1142
1147
  ? (providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false)
@@ -1151,18 +1156,12 @@ function classifiedErrorObservabilityDetails(error, declaredErrorCode) {
1151
1156
  };
1152
1157
  }
1153
1158
  if (isProviderError(error)) {
1154
- // Deterministic upstream refusals default to the rejection category:
1155
- // the UPSTREAM_REJECTED family and any operation-declared rejection
1156
- // status (409/410/422) classify as upstream_rejected unless the
1157
- // author set an explicit category.
1158
- const declaredStatus = effectiveDeclaration?.status;
1159
- const rejectionDefault = providerErrorCode(error) === "UPSTREAM_REJECTED" ||
1160
- (isEmittableErrorStatus(declaredStatus) &&
1161
- categoryForStatus(declaredStatus) === "upstream_rejected")
1162
- ? "upstream_rejected"
1163
- : "provider_error";
1159
+ // Retryability does not follow the derived category: it stays
1160
+ // instance ?? declared ?? false below.
1164
1161
  return {
1165
- category: providerErrorOption(error, "category") ?? rejectionDefault,
1162
+ category: providerErrorOption(error, "category") ??
1163
+ derivedErrorCategory(error, effectiveDeclaration?.status) ??
1164
+ "provider_error",
1166
1165
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
1167
1166
  retryable: providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false,
1168
1167
  };
@@ -2560,7 +2559,13 @@ function createServerAppWithCapabilityModules(provider, serverOptions, capabilit
2560
2559
  });
2561
2560
  }
2562
2561
  app.notFound((c) => {
2563
- const error = new ProviderError("Not found", { code: "not_found", retryable: false });
2562
+ // An unknown route is not an upstream answer: the explicit category keeps
2563
+ // the registered not_found code from attributing it to an upstream.
2564
+ const error = new ProviderError("Not found", {
2565
+ code: "not_found",
2566
+ category: "provider_error",
2567
+ retryable: false,
2568
+ });
2564
2569
  const observabilityDetails = errorObservabilityDetails(error);
2565
2570
  return responseWithErrorObservability(c.json(toErrorResponse(error, undefined, observabilityDetails, undefined, errorEnvelopeLocalization({
2566
2571
  catalogs: localeCatalogs,
@@ -2968,6 +2973,16 @@ export async function serve(provider, options = {}) {
2968
2973
  // One structured line per process boot, before the listener binds: the fleet
2969
2974
  // audit (ADR-0011 migration) counts these.
2970
2975
  logger(engineModeLogEvent(provider, engineSelection.resolution, !isUnavailableProviderEngine(engineSelection.engine)));
2976
+ // Same contract for the stores: one line per process boot naming the state
2977
+ // and cache backends this pod resolved, so "which Redis is this provider's
2978
+ // state actually on" is a log grep instead of an inference from the
2979
+ // Deployment spec (apifuse#2302).
2980
+ logger(providerStateBackendLogEvent({
2981
+ providerId: provider.id,
2982
+ sdkVersion: SDK_VERSION,
2983
+ injectedState: options.state !== undefined,
2984
+ allowMemoryFallback: options.allowMemoryStateFallback === true,
2985
+ }));
2971
2986
  const servers = [];
2972
2987
  try {
2973
2988
  servers.push(bunRuntime.serve({
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What a provider store resolved to at boot.
3
+ *
4
+ * `endpoint` is deliberately host and port only: the URL can carry a password
5
+ * (`redis://:secret@host:6379`), and the diagnostic inventory already treats
6
+ * those credential components — and only those — as sensitive
7
+ * (`collectDiagnosticSensitiveValues`, `urlCredentialComponents(url, {
8
+ * includePathAndQuery: false })`). A URL that does not parse contributes no
9
+ * endpoint at all rather than a redacted-looking fragment of itself.
10
+ */
11
+ export type ProviderStoreBackendReport = {
12
+ /**
13
+ * `redis` when a URL resolved, `memory` for the in-process fallback,
14
+ * `unsupported` when state has no backend and every call fails closed,
15
+ * `injected` when the host handed the server a store object.
16
+ */
17
+ readonly backend: "redis" | "memory" | "unsupported" | "injected";
18
+ /** Env name that supplied the Redis URL. Present only for `redis`. */
19
+ readonly envName?: string;
20
+ /**
21
+ * `host:port` of the resolved Redis. Never includes credentials, path or
22
+ * query. Absent when the URL does not parse, or when its query carries an
23
+ * ioredis option that moves the endpoint (`path`, `port`, `host`, `family`)
24
+ * so the authority is not where the client connects — see
25
+ * `state_redis_url_ambiguous_endpoint`.
26
+ */
27
+ readonly endpoint?: string;
28
+ /** URL scheme, e.g. `redis` or `rediss`. Present only for `redis`. */
29
+ readonly scheme?: string;
30
+ /** True when the URL came from a shared fallback env rather than the store's own. */
31
+ readonly fallback?: boolean;
32
+ };
33
+ /**
34
+ * One line per process boot naming the stores the provider resolved. Without
35
+ * it, a provider silently riding the cache Redis for its state is
36
+ * indistinguishable from one on a durable instance, from outside the pod and
37
+ * from the pod's own logs (apifuse#2302, the reason apifuse#2144 could only be
38
+ * verified by inference from the Deployment spec).
39
+ */
40
+ export type ProviderStateBackendLogEvent = {
41
+ /** `warn` when a warning was raised, otherwise `info`. */
42
+ readonly level: "info" | "warn";
43
+ readonly event: "provider_state_backend";
44
+ readonly providerId: string;
45
+ readonly state: ProviderStoreBackendReport;
46
+ readonly cache: ProviderStoreBackendReport;
47
+ readonly sdkVersion: string;
48
+ /**
49
+ * Non-fatal findings, e.g. `state_redis_url_fallback` (the state store is
50
+ * sharing an instance provisioned for something else),
51
+ * `state_redis_url_unparsed`, or `state_redis_url_ambiguous_endpoint` (the
52
+ * URL's query carries an ioredis option that moves the endpoint, so the
53
+ * authority is not reported as the endpoint). Absent when the resolution
54
+ * was unambiguous.
55
+ */
56
+ readonly warnings?: readonly string[];
57
+ };
58
+ /**
59
+ * Build the boot event from the same env precedence the stores themselves use
60
+ * (`providerStateRedisResolutionFromEnv` / `providerCacheRedisResolutionFromEnv`),
61
+ * so the line can never describe a backend other than the one in use.
62
+ */
63
+ export declare function providerStateBackendLogEvent(input: {
64
+ readonly providerId: string;
65
+ readonly sdkVersion: string;
66
+ /** The host passed an explicit state store, so no env resolution happened. */
67
+ readonly injectedState: boolean;
68
+ /** Mirrors `createProviderRuntimeStateFromEnv({ allowMemoryFallback })`. */
69
+ readonly allowMemoryFallback: boolean;
70
+ }): ProviderStateBackendLogEvent;