@apifuse/provider-sdk 2.2.0-beta.12 → 2.2.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +201 -0
- package/CHANGELOG.md +10 -0
- package/README.md +26 -2
- package/bin/apifuse-pack-types.ts +30 -1
- package/bin/apifuse-record.ts +622 -57
- package/bin/apifuse-submit-check.ts +43 -10
- package/dist/define.d.ts +2 -1
- package/dist/define.js +61 -3
- package/dist/fixture-sanitization.d.ts +26 -0
- package/dist/fixture-sanitization.js +216 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/provider.d.ts +2 -1
- package/dist/provider.js +1 -0
- package/dist/runtime/http.js +86 -32
- package/dist/runtime/instrumentation.js +295 -9
- package/dist/runtime/native-network.d.ts +53 -0
- package/dist/runtime/native-network.js +477 -0
- package/dist/runtime/proxy-nodemaven.d.ts +14 -0
- package/dist/runtime/proxy-nodemaven.js +20 -2
- package/dist/runtime/request-options.d.ts +68 -1
- package/dist/runtime/request-options.js +548 -0
- package/dist/runtime/stealth.d.ts +3 -1
- package/dist/runtime/stealth.js +239 -39
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/self-test-input-tokens.d.ts +2 -1
- package/dist/server/self-test-input-tokens.js +18 -14
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +451 -19
- package/dist/types.d.ts +162 -0
- package/package.json +2 -1
- package/src/define.ts +81 -3
- package/src/fixture-sanitization.ts +247 -0
- package/src/index.ts +37 -0
- package/src/provider.ts +37 -0
- package/src/runtime/http.ts +144 -38
- package/src/runtime/instrumentation.ts +424 -8
- package/src/runtime/native-network.ts +600 -0
- package/src/runtime/proxy-nodemaven.ts +37 -2
- package/src/runtime/request-options.ts +680 -1
- package/src/runtime/stealth.ts +293 -40
- package/src/server/index.ts +4 -1
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/stream-evidence.ts +988 -0
- package/src/testing/index.ts +9 -1
- package/src/testing/run.ts +608 -12
- package/src/types.ts +194 -0
package/src/runtime/stealth.ts
CHANGED
|
@@ -50,7 +50,15 @@ import {
|
|
|
50
50
|
shouldRetryProxyTransportAttempt,
|
|
51
51
|
validateUnsafeProxyTransportRetryMethods,
|
|
52
52
|
} from "./proxy-retry-policy.js";
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
isSensitiveKey,
|
|
55
|
+
redactSensitiveError,
|
|
56
|
+
redactSensitiveRequestError,
|
|
57
|
+
redactSensitiveText,
|
|
58
|
+
redactUrlQueryParams,
|
|
59
|
+
normalizeSensitiveParams,
|
|
60
|
+
serializeRequestUrl,
|
|
61
|
+
} from "./request-options.js";
|
|
54
62
|
|
|
55
63
|
const DEFAULT_PROFILE = "chrome-146";
|
|
56
64
|
|
|
@@ -65,6 +73,14 @@ const PROXY_AUTH_DIAGNOSTIC_URL = "http://example.com/";
|
|
|
65
73
|
const PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS = 5_000;
|
|
66
74
|
const STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES = [PROXY_CONNECT_FAILURE_CODE] as const;
|
|
67
75
|
|
|
76
|
+
function sensitiveQueryParamNames(url: string): string[] {
|
|
77
|
+
const queryStart = url.indexOf("?");
|
|
78
|
+
if (queryStart === -1) return [];
|
|
79
|
+
const fragmentStart = url.indexOf("#", queryStart);
|
|
80
|
+
const query = url.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart);
|
|
81
|
+
return [...new URLSearchParams(query).keys()].filter(isSensitiveKey);
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
export type StealthClientOptions = ProxyResolutionOptions & {
|
|
69
85
|
warn?: (message: string) => void;
|
|
70
86
|
/**
|
|
@@ -117,6 +133,8 @@ type StealthTransportResponse = Pick<
|
|
|
117
133
|
ImpitResponse,
|
|
118
134
|
"arrayBuffer" | "headers" | "json" | "ok" | "status" | "text"
|
|
119
135
|
> & {
|
|
136
|
+
body?: ReadableStream<Uint8Array>;
|
|
137
|
+
abort?: () => void;
|
|
120
138
|
url?: string;
|
|
121
139
|
redirected?: boolean;
|
|
122
140
|
};
|
|
@@ -406,13 +424,17 @@ function splitCombinedSetCookieHeader(headerValue: string): string[] {
|
|
|
406
424
|
export async function normalizeResponse(
|
|
407
425
|
response: StealthTransportResponse,
|
|
408
426
|
requestUrl?: string,
|
|
427
|
+
maxBodyBytes?: number,
|
|
409
428
|
): Promise<StealthResponse> {
|
|
410
429
|
const headers = Object.fromEntries(response.headers.entries());
|
|
411
430
|
const cookies = new CookieJarImpl(
|
|
412
431
|
setCookieHeadersFromResponse(response.headers),
|
|
413
432
|
response.url ?? requestUrl,
|
|
414
433
|
);
|
|
415
|
-
const bodyBytes =
|
|
434
|
+
const bodyBytes =
|
|
435
|
+
maxBodyBytes === undefined
|
|
436
|
+
? await response.arrayBuffer()
|
|
437
|
+
: await readResponseBodyWithLimit(response, maxBodyBytes);
|
|
416
438
|
const body = new TextDecoder().decode(bodyBytes);
|
|
417
439
|
|
|
418
440
|
return {
|
|
@@ -440,6 +462,83 @@ export async function normalizeResponse(
|
|
|
440
462
|
};
|
|
441
463
|
}
|
|
442
464
|
|
|
465
|
+
function responseTooLargeError(maxBodyBytes: number, observedBytes: number): TransportError {
|
|
466
|
+
return new TransportError(
|
|
467
|
+
`Response body exceeded maxBodyBytes limit of ${maxBodyBytes} bytes (observed ${observedBytes} bytes)`,
|
|
468
|
+
{
|
|
469
|
+
code: "response_too_large",
|
|
470
|
+
category: "upstream_http",
|
|
471
|
+
retryable: false,
|
|
472
|
+
status: 0,
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function declaredContentLength(headers: Headers): number | undefined {
|
|
478
|
+
const contentLength = headers.get("content-length")?.trim();
|
|
479
|
+
if (!contentLength || !/^\d+$/.test(contentLength)) return undefined;
|
|
480
|
+
const parsed = Number(contentLength);
|
|
481
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function abortTransportResponse(response: StealthTransportResponse): boolean {
|
|
485
|
+
if (!response.abort) return false;
|
|
486
|
+
try {
|
|
487
|
+
response.abort();
|
|
488
|
+
} catch {
|
|
489
|
+
// The size error remains the primary failure if impit has already closed the response.
|
|
490
|
+
}
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function readResponseBodyWithLimit(
|
|
495
|
+
response: StealthTransportResponse,
|
|
496
|
+
maxBodyBytes: number,
|
|
497
|
+
): Promise<ArrayBuffer> {
|
|
498
|
+
const contentLength = declaredContentLength(response.headers);
|
|
499
|
+
if (contentLength !== undefined && contentLength > maxBodyBytes) {
|
|
500
|
+
if (!abortTransportResponse(response)) {
|
|
501
|
+
await response.body?.cancel().catch(() => undefined);
|
|
502
|
+
}
|
|
503
|
+
throw responseTooLargeError(maxBodyBytes, contentLength);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!response.body) {
|
|
507
|
+
throw new TransportError("Response body stream is unavailable", {
|
|
508
|
+
code: "transport_stream_unavailable",
|
|
509
|
+
category: "upstream_http",
|
|
510
|
+
status: 0,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const reader = response.body.getReader();
|
|
515
|
+
const chunks: Uint8Array[] = [];
|
|
516
|
+
let receivedBytes = 0;
|
|
517
|
+
try {
|
|
518
|
+
while (true) {
|
|
519
|
+
const { done, value } = await reader.read();
|
|
520
|
+
if (done) break;
|
|
521
|
+
receivedBytes += value.byteLength;
|
|
522
|
+
if (receivedBytes > maxBodyBytes) {
|
|
523
|
+
await reader.cancel().catch(() => undefined);
|
|
524
|
+
abortTransportResponse(response);
|
|
525
|
+
throw responseTooLargeError(maxBodyBytes, receivedBytes);
|
|
526
|
+
}
|
|
527
|
+
chunks.push(value);
|
|
528
|
+
}
|
|
529
|
+
} finally {
|
|
530
|
+
reader.releaseLock();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const bodyBytes = new Uint8Array(receivedBytes);
|
|
534
|
+
let offset = 0;
|
|
535
|
+
for (const chunk of chunks) {
|
|
536
|
+
bodyBytes.set(chunk, offset);
|
|
537
|
+
offset += chunk.byteLength;
|
|
538
|
+
}
|
|
539
|
+
return bodyBytes.buffer;
|
|
540
|
+
}
|
|
541
|
+
|
|
443
542
|
function normalizeBody(body: StealthFetchOptions["body"]): string {
|
|
444
543
|
if (body === undefined) {
|
|
445
544
|
return "";
|
|
@@ -722,22 +821,29 @@ function createSessionFetcher(
|
|
|
722
821
|
|
|
723
822
|
const session: StealthSession = {
|
|
724
823
|
async fetch(url, options: StealthFetchOptions = {}) {
|
|
725
|
-
const method =
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}) ??
|
|
732
|
-
(hasExplicitRetryPolicy
|
|
733
|
-
? undefined
|
|
734
|
-
: createDefaultProxyTransportRetryOptions({
|
|
824
|
+
const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
|
|
825
|
+
try {
|
|
826
|
+
const method = normalizeMethod(options.method ?? "GET");
|
|
827
|
+
const hasExplicitRetryPolicy = options.retry !== undefined;
|
|
828
|
+
const stealthRetryOptions =
|
|
829
|
+
normalizeProxyTransportRetryOptions(options.retry, {
|
|
735
830
|
extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
|
|
736
831
|
label: "Stealth",
|
|
737
|
-
})
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
832
|
+
}) ??
|
|
833
|
+
(hasExplicitRetryPolicy
|
|
834
|
+
? undefined
|
|
835
|
+
: createDefaultProxyTransportRetryOptions({
|
|
836
|
+
extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
|
|
837
|
+
label: "Stealth",
|
|
838
|
+
}));
|
|
839
|
+
if (stealthRetryOptions) {
|
|
840
|
+
validateUnsafeProxyTransportRetryMethods(stealthRetryOptions, "Stealth");
|
|
841
|
+
}
|
|
842
|
+
return { hasExplicitRetryPolicy, method, stealthRetryOptions };
|
|
843
|
+
} catch (error) {
|
|
844
|
+
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
845
|
+
}
|
|
846
|
+
})();
|
|
741
847
|
const hasPolicyProxy = isPolicyManagedProxy(clientOptions);
|
|
742
848
|
const usesPolicyAllocator = hasPolicyProxy && !options.proxy && !clientOptions.proxy;
|
|
743
849
|
const retryAttemptCap = Math.max(1, stealthRetryOptions?.attempts ?? 1);
|
|
@@ -782,6 +888,11 @@ function createSessionFetcher(
|
|
|
782
888
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
783
889
|
let proxy: string | undefined;
|
|
784
890
|
let attemptProxy: ResolvedAttemptProxy | undefined;
|
|
891
|
+
// Reuse the exact serialization used by this outbound attempt in its catch path.
|
|
892
|
+
let serializedUrl: ReturnType<typeof serializeRequestUrl> | undefined;
|
|
893
|
+
let fallbackSensitiveValues: readonly string[] = [];
|
|
894
|
+
let fallbackRequestUrl: string | undefined;
|
|
895
|
+
let fallbackRedactedUrl: string | undefined;
|
|
785
896
|
const attemptStartedAt = Date.now();
|
|
786
897
|
let attemptRecorded = false;
|
|
787
898
|
const recordProxyAttempt = (
|
|
@@ -805,6 +916,16 @@ function createSessionFetcher(
|
|
|
805
916
|
});
|
|
806
917
|
};
|
|
807
918
|
try {
|
|
919
|
+
const sensitiveParams = normalizeSensitiveParams(options.sensitiveParams);
|
|
920
|
+
const structural = redactUrlQueryParams(url, Object.keys(sensitiveParams ?? {}));
|
|
921
|
+
fallbackSensitiveValues = [
|
|
922
|
+
...new Set([
|
|
923
|
+
...Object.values(sensitiveParams ?? {}).map(String),
|
|
924
|
+
...structural.sensitiveValues,
|
|
925
|
+
]),
|
|
926
|
+
].filter((value) => value !== "");
|
|
927
|
+
fallbackRequestUrl = url;
|
|
928
|
+
fallbackRedactedUrl = structural.redactedUrl;
|
|
808
929
|
assertNoUnsupportedFingerprintOverrides(options);
|
|
809
930
|
attemptProxy = await resolveRequestProxy(options, attempt, refreshAttempt);
|
|
810
931
|
proxy = attemptProxy.url;
|
|
@@ -824,7 +945,12 @@ function createSessionFetcher(
|
|
|
824
945
|
(!hasPolicyProxy && proxy && clientOptions.proxyStealth?.insecureSkipVerify),
|
|
825
946
|
);
|
|
826
947
|
const profileName = options.profile ?? defaultProfile;
|
|
827
|
-
|
|
948
|
+
serializedUrl = serializeRequestUrl(
|
|
949
|
+
resolveUrl(baseUrl, url),
|
|
950
|
+
options.params,
|
|
951
|
+
sensitiveParams,
|
|
952
|
+
);
|
|
953
|
+
const { requestUrl } = serializedUrl;
|
|
828
954
|
const headers = { ...(options.headers ?? {}) };
|
|
829
955
|
if (!hasHeader(headers, "Cookie")) {
|
|
830
956
|
const cookieHeader = cookieJar.toHeader(requestUrl);
|
|
@@ -843,7 +969,7 @@ function createSessionFetcher(
|
|
|
843
969
|
requestUrl,
|
|
844
970
|
requestInit,
|
|
845
971
|
);
|
|
846
|
-
const normalized = await normalizeResponse(response, requestUrl);
|
|
972
|
+
const normalized = await normalizeResponse(response, requestUrl, options.maxBodyBytes);
|
|
847
973
|
cookieJar.setFromCookieStrings(
|
|
848
974
|
setCookieHeadersFromResponse(response.headers),
|
|
849
975
|
response.url ?? requestUrl,
|
|
@@ -891,16 +1017,36 @@ function createSessionFetcher(
|
|
|
891
1017
|
recordProxyAttempt("ok", undefined, response.status);
|
|
892
1018
|
return normalized;
|
|
893
1019
|
} catch (error) {
|
|
894
|
-
const
|
|
1020
|
+
const sensitiveValues = serializedUrl?.sensitiveValues ?? fallbackSensitiveValues;
|
|
1021
|
+
let normalizedError: TransportError;
|
|
1022
|
+
try {
|
|
1023
|
+
normalizedError = normalizeStealthTransportError(error);
|
|
1024
|
+
} catch (normalizationError) {
|
|
1025
|
+
throw redactSensitiveError(
|
|
1026
|
+
normalizationError,
|
|
1027
|
+
sensitiveValues,
|
|
1028
|
+
serializedUrl?.requestUrl ?? fallbackRequestUrl,
|
|
1029
|
+
serializedUrl?.redactedUrl ?? fallbackRedactedUrl,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
const retryErrorCode = proxyAttemptErrorCode(normalizedError);
|
|
1033
|
+
const refreshableProxyError = isProxyPoolRefreshableError(normalizedError);
|
|
1034
|
+
const runProxyAuthDiagnostic = shouldRunProxyAuthDiagnostic(normalizedError);
|
|
1035
|
+
normalizedError = redactSensitiveError(
|
|
1036
|
+
normalizedError,
|
|
1037
|
+
sensitiveValues,
|
|
1038
|
+
serializedUrl?.requestUrl ?? fallbackRequestUrl,
|
|
1039
|
+
serializedUrl?.redactedUrl ?? fallbackRedactedUrl,
|
|
1040
|
+
);
|
|
895
1041
|
recordProxyAttempt(
|
|
896
1042
|
"error",
|
|
897
1043
|
proxyAttemptErrorCode(normalizedError),
|
|
898
1044
|
proxyAttemptStatus(normalizedError),
|
|
899
1045
|
);
|
|
900
1046
|
lastError = normalizedError;
|
|
901
|
-
if (proxy && rotatesRegistryChain &&
|
|
1047
|
+
if (proxy && rotatesRegistryChain && refreshableProxyError) {
|
|
902
1048
|
stalePoolError = normalizedError;
|
|
903
|
-
if (
|
|
1049
|
+
if (runProxyAuthDiagnostic) {
|
|
904
1050
|
stalePoolDiagnosticProxy = proxy;
|
|
905
1051
|
}
|
|
906
1052
|
if (attempt + 1 < maxAttempts) {
|
|
@@ -931,7 +1077,7 @@ function createSessionFetcher(
|
|
|
931
1077
|
if (
|
|
932
1078
|
attempt + 1 < transportRetryCap &&
|
|
933
1079
|
shouldRetryProxyTransportAttempt({
|
|
934
|
-
error:
|
|
1080
|
+
error: { code: retryErrorCode },
|
|
935
1081
|
explicitRetry: hasExplicitRetryPolicy,
|
|
936
1082
|
method,
|
|
937
1083
|
options: stealthRetryOptions,
|
|
@@ -1001,25 +1147,99 @@ function createSessionFetcher(
|
|
|
1001
1147
|
options.maxHops === undefined || !Number.isFinite(options.maxHops)
|
|
1002
1148
|
? 10
|
|
1003
1149
|
: Math.max(0, Math.floor(options.maxHops));
|
|
1150
|
+
const {
|
|
1151
|
+
url: _url,
|
|
1152
|
+
maxHops: _maxHops,
|
|
1153
|
+
stopWhen,
|
|
1154
|
+
params,
|
|
1155
|
+
sensitiveParams,
|
|
1156
|
+
...fetchOptions
|
|
1157
|
+
} = options;
|
|
1004
1158
|
const hops: StealthRedirectHop[] = [];
|
|
1005
|
-
let currentUrl = resolveUrl(baseUrl, options.url);
|
|
1006
1159
|
let method = normalizeMethod(options.method ?? "GET");
|
|
1007
1160
|
let body = options.body;
|
|
1008
1161
|
let response: StealthResponse | undefined;
|
|
1009
1162
|
const visitedRequests = new Set<string>();
|
|
1010
|
-
|
|
1011
|
-
|
|
1163
|
+
const initialParams = params
|
|
1164
|
+
? Object.fromEntries(
|
|
1165
|
+
Object.entries(params).map(([key, value]) => [
|
|
1166
|
+
key,
|
|
1167
|
+
Array.isArray(value) ? [...value] : value,
|
|
1168
|
+
]),
|
|
1169
|
+
)
|
|
1170
|
+
: undefined;
|
|
1171
|
+
const normalizedSensitiveParams = normalizeSensitiveParams(sensitiveParams);
|
|
1172
|
+
const initialSensitiveParams = normalizedSensitiveParams
|
|
1173
|
+
? { ...normalizedSensitiveParams }
|
|
1174
|
+
: undefined;
|
|
1175
|
+
const sensitiveParamNames = initialSensitiveParams
|
|
1176
|
+
? Object.keys(initialSensitiveParams)
|
|
1177
|
+
: [];
|
|
1178
|
+
const callerStructural = redactUrlQueryParams(options.url, sensitiveParamNames);
|
|
1179
|
+
const sensitiveValues = new Set(
|
|
1180
|
+
[
|
|
1181
|
+
...Object.values(initialSensitiveParams ?? {}),
|
|
1182
|
+
...callerStructural.sensitiveValues,
|
|
1183
|
+
].filter((value) => value !== ""),
|
|
1184
|
+
);
|
|
1185
|
+
const redactRedirectUrl = (value: string): string => {
|
|
1186
|
+
const structural = redactUrlQueryParams(value, [
|
|
1187
|
+
...new Set([...sensitiveParamNames, ...sensitiveQueryParamNames(value)]),
|
|
1188
|
+
]);
|
|
1189
|
+
for (const sensitiveValue of structural.sensitiveValues) {
|
|
1190
|
+
sensitiveValues.add(sensitiveValue);
|
|
1191
|
+
}
|
|
1192
|
+
return redactSensitiveText(structural.redactedUrl, [...sensitiveValues]);
|
|
1193
|
+
};
|
|
1194
|
+
let currentUrl: string;
|
|
1195
|
+
let initialUrl: ReturnType<typeof serializeRequestUrl>;
|
|
1196
|
+
try {
|
|
1197
|
+
currentUrl = resolveUrl(baseUrl, options.url);
|
|
1198
|
+
redactRedirectUrl(currentUrl);
|
|
1199
|
+
initialUrl = serializeRequestUrl(currentUrl, initialParams, initialSensitiveParams);
|
|
1200
|
+
for (const value of initialUrl.sensitiveValues) {
|
|
1201
|
+
if (value !== "") sensitiveValues.add(value);
|
|
1202
|
+
}
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
throw redactSensitiveError(
|
|
1205
|
+
error,
|
|
1206
|
+
[...sensitiveValues],
|
|
1207
|
+
options.url,
|
|
1208
|
+
redactRedirectUrl(options.url),
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1012
1211
|
|
|
1013
1212
|
for (let hopIndex = 0; hopIndex <= maxHops; hopIndex += 1) {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1213
|
+
const outboundUrl =
|
|
1214
|
+
hopIndex === 0 ? initialUrl.requestUrl : serializeRequestUrl(currentUrl).requestUrl;
|
|
1215
|
+
// Preserve params-only loop bookkeeping from before sensitiveParams:
|
|
1216
|
+
// the first visited key is the caller's resolved URL, not its expanded query.
|
|
1217
|
+
const visitedUrl = hopIndex === 0 && !initialSensitiveParams ? currentUrl : outboundUrl;
|
|
1218
|
+
visitedRequests.add(`${method} ${visitedUrl}`);
|
|
1219
|
+
try {
|
|
1220
|
+
response = await session.fetch(currentUrl, {
|
|
1221
|
+
...fetchOptions,
|
|
1222
|
+
body,
|
|
1223
|
+
method,
|
|
1224
|
+
...(hopIndex === 0 && initialParams ? { params: initialParams } : {}),
|
|
1225
|
+
...(hopIndex === 0 && initialSensitiveParams
|
|
1226
|
+
? { sensitiveParams: initialSensitiveParams }
|
|
1227
|
+
: {}),
|
|
1228
|
+
redirect: "manual",
|
|
1229
|
+
throwOnHttpError: false,
|
|
1230
|
+
});
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
throw redactSensitiveError(
|
|
1233
|
+
error,
|
|
1234
|
+
[...sensitiveValues],
|
|
1235
|
+
outboundUrl,
|
|
1236
|
+
redactRedirectUrl(outboundUrl),
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
// StealthResponse.url is programmatic metadata and remains raw. Only the
|
|
1240
|
+
// redirect hop emitted below is a diagnostic surface.
|
|
1241
|
+
const responseUrl =
|
|
1242
|
+
response.url ?? (hopIndex === 0 && initialSensitiveParams ? outboundUrl : currentUrl);
|
|
1023
1243
|
|
|
1024
1244
|
if (!isRedirectStatus(response.status)) {
|
|
1025
1245
|
return {
|
|
@@ -1032,19 +1252,52 @@ function createSessionFetcher(
|
|
|
1032
1252
|
}
|
|
1033
1253
|
|
|
1034
1254
|
const location = locationHeader(response.headers);
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1255
|
+
const redactedResponseUrl = redactRedirectUrl(responseUrl);
|
|
1256
|
+
const redactedLocation = location ? redactRedirectUrl(location) : undefined;
|
|
1257
|
+
let nextUrl: string | undefined;
|
|
1258
|
+
try {
|
|
1259
|
+
nextUrl = location ? new URL(location, responseUrl).toString() : undefined;
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
throw redactSensitiveError(error, [...sensitiveValues], location, redactedLocation);
|
|
1262
|
+
}
|
|
1263
|
+
const realHop: StealthRedirectHop = {
|
|
1264
|
+
url: responseUrl,
|
|
1040
1265
|
status: response.status,
|
|
1041
1266
|
method,
|
|
1042
1267
|
...(location ? { location } : {}),
|
|
1043
1268
|
...(nextUrl ? { nextUrl } : {}),
|
|
1044
1269
|
};
|
|
1270
|
+
const hop: StealthRedirectHop = {
|
|
1271
|
+
...realHop,
|
|
1272
|
+
url: redactedResponseUrl,
|
|
1273
|
+
...(redactedLocation ? { location: redactedLocation } : {}),
|
|
1274
|
+
...(nextUrl ? { nextUrl: redactRedirectUrl(nextUrl) } : {}),
|
|
1275
|
+
};
|
|
1045
1276
|
hops.push(hop);
|
|
1046
1277
|
|
|
1047
|
-
|
|
1278
|
+
let shouldStop = false;
|
|
1279
|
+
if (stopWhen) {
|
|
1280
|
+
try {
|
|
1281
|
+
shouldStop = await stopWhen(realHop);
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
let sanitizedError: unknown = error;
|
|
1284
|
+
for (const [rawUrl, safeUrl] of [
|
|
1285
|
+
[responseUrl, redactedResponseUrl],
|
|
1286
|
+
[location, redactedLocation],
|
|
1287
|
+
[nextUrl, nextUrl ? redactRedirectUrl(nextUrl) : undefined],
|
|
1288
|
+
] as const) {
|
|
1289
|
+
if (!rawUrl || !safeUrl) continue;
|
|
1290
|
+
sanitizedError = redactSensitiveError(
|
|
1291
|
+
sanitizedError,
|
|
1292
|
+
[...sensitiveValues],
|
|
1293
|
+
rawUrl,
|
|
1294
|
+
safeUrl,
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
throw sanitizedError;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (shouldStop) {
|
|
1048
1301
|
return {
|
|
1049
1302
|
final: response,
|
|
1050
1303
|
hops,
|
package/src/server/index.ts
CHANGED
|
@@ -35,7 +35,10 @@ export {
|
|
|
35
35
|
SelfTestRequestSchema,
|
|
36
36
|
type SelfTestResponse,
|
|
37
37
|
} from "./self-test.js";
|
|
38
|
-
export {
|
|
38
|
+
export {
|
|
39
|
+
type InputDateTokenCalendar,
|
|
40
|
+
resolveHealthCheckInputDateTokens,
|
|
41
|
+
} from "./self-test-input-tokens.js";
|
|
39
42
|
export {
|
|
40
43
|
collectSelfTestSensitiveValues,
|
|
41
44
|
redactSelfTestText,
|
|
@@ -1,46 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relative
|
|
3
|
-
*
|
|
4
|
-
* so
|
|
2
|
+
* Relative date-token resolution for health-check case inputs and fixture
|
|
3
|
+
* requests. The calendar defaults to KST; callers that need UTC must opt in
|
|
4
|
+
* explicitly so UTC and KST do not silently disagree from 15:00–23:59 UTC.
|
|
5
5
|
*
|
|
6
|
-
* Supported token: `+<days>d` or `+<days>d:YYYYMMDD` (1..365 days ahead
|
|
6
|
+
* Supported token: `+<days>d` or `+<days>d:YYYYMMDD` (1..365 days ahead).
|
|
7
7
|
*/
|
|
8
|
-
const
|
|
8
|
+
const RELATIVE_DATE_TOKEN = /^\+(\d{1,3})d(?::(YYYYMMDD))?$/i;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
export type InputDateTokenCalendar = "KST" | "UTC";
|
|
11
|
+
|
|
12
|
+
function dateFromDaysAhead(
|
|
11
13
|
daysAhead: number,
|
|
12
14
|
now = new Date(),
|
|
13
15
|
format: "YYYY-MM-DD" | "YYYYMMDD" = "YYYY-MM-DD",
|
|
16
|
+
calendar: InputDateTokenCalendar = "KST",
|
|
14
17
|
): string {
|
|
15
|
-
const
|
|
18
|
+
const calendarNow = new Date(now.getTime() + (calendar === "KST" ? 9 * 60 * 60 * 1000 : 0));
|
|
16
19
|
const date = new Date(
|
|
17
|
-
Date.UTC(
|
|
20
|
+
Date.UTC(
|
|
21
|
+
calendarNow.getUTCFullYear(),
|
|
22
|
+
calendarNow.getUTCMonth(),
|
|
23
|
+
calendarNow.getUTCDate() + daysAhead,
|
|
24
|
+
),
|
|
18
25
|
);
|
|
19
26
|
const isoDate = date.toISOString().slice(0, 10);
|
|
20
27
|
return format === "YYYYMMDD" ? isoDate.replace(/-/g, "") : isoDate;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
export function resolveHealthCheckInputDateTokens(
|
|
30
|
+
export function resolveHealthCheckInputDateTokens(
|
|
31
|
+
value: unknown,
|
|
32
|
+
now = new Date(),
|
|
33
|
+
calendar: InputDateTokenCalendar = "KST",
|
|
34
|
+
): unknown {
|
|
24
35
|
if (typeof value === "string") {
|
|
25
|
-
const relative = value.match(
|
|
36
|
+
const relative = value.match(RELATIVE_DATE_TOKEN);
|
|
26
37
|
if (!relative) return value;
|
|
27
38
|
const daysAhead = Number(relative[1]);
|
|
28
39
|
if (!Number.isInteger(daysAhead) || daysAhead < 1 || daysAhead > 365) {
|
|
29
40
|
return value;
|
|
30
41
|
}
|
|
31
42
|
const format = relative[2]?.toUpperCase() === "YYYYMMDD" ? "YYYYMMDD" : "YYYY-MM-DD";
|
|
32
|
-
return
|
|
43
|
+
return dateFromDaysAhead(daysAhead, now, format, calendar);
|
|
33
44
|
}
|
|
34
45
|
if (Array.isArray(value)) {
|
|
35
|
-
|
|
46
|
+
const resolved = value.map((entry) => resolveHealthCheckInputDateTokens(entry, now, calendar));
|
|
47
|
+
return resolved.some((entry, index) => entry !== value[index]) ? resolved : value;
|
|
36
48
|
}
|
|
37
49
|
if (value && typeof value === "object") {
|
|
38
|
-
|
|
50
|
+
const resolved = Object.fromEntries(
|
|
39
51
|
Object.entries(value).map(([key, entry]) => [
|
|
40
52
|
key,
|
|
41
|
-
resolveHealthCheckInputDateTokens(entry, now),
|
|
53
|
+
resolveHealthCheckInputDateTokens(entry, now, calendar),
|
|
42
54
|
]),
|
|
43
55
|
);
|
|
56
|
+
return Object.entries(resolved).some(([key, entry]) => entry !== Reflect.get(value, key))
|
|
57
|
+
? resolved
|
|
58
|
+
: value;
|
|
44
59
|
}
|
|
45
60
|
return value;
|
|
46
61
|
}
|