@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.
Files changed (52) hide show
  1. package/AUTHORING.md +201 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +26 -2
  4. package/bin/apifuse-pack-types.ts +30 -1
  5. package/bin/apifuse-record.ts +622 -57
  6. package/bin/apifuse-submit-check.ts +43 -10
  7. package/dist/define.d.ts +2 -1
  8. package/dist/define.js +61 -3
  9. package/dist/fixture-sanitization.d.ts +26 -0
  10. package/dist/fixture-sanitization.js +216 -0
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +1 -0
  13. package/dist/provider.d.ts +2 -1
  14. package/dist/provider.js +1 -0
  15. package/dist/runtime/http.js +86 -32
  16. package/dist/runtime/instrumentation.js +295 -9
  17. package/dist/runtime/native-network.d.ts +53 -0
  18. package/dist/runtime/native-network.js +477 -0
  19. package/dist/runtime/proxy-nodemaven.d.ts +14 -0
  20. package/dist/runtime/proxy-nodemaven.js +20 -2
  21. package/dist/runtime/request-options.d.ts +68 -1
  22. package/dist/runtime/request-options.js +548 -0
  23. package/dist/runtime/stealth.d.ts +3 -1
  24. package/dist/runtime/stealth.js +239 -39
  25. package/dist/server/index.d.ts +1 -1
  26. package/dist/server/index.js +1 -1
  27. package/dist/server/self-test-input-tokens.d.ts +2 -1
  28. package/dist/server/self-test-input-tokens.js +18 -14
  29. package/dist/stream-evidence.d.ts +74 -0
  30. package/dist/stream-evidence.js +785 -0
  31. package/dist/testing/index.d.ts +1 -1
  32. package/dist/testing/index.js +1 -1
  33. package/dist/testing/run.d.ts +32 -2
  34. package/dist/testing/run.js +451 -19
  35. package/dist/types.d.ts +162 -0
  36. package/package.json +2 -1
  37. package/src/define.ts +81 -3
  38. package/src/fixture-sanitization.ts +247 -0
  39. package/src/index.ts +37 -0
  40. package/src/provider.ts +37 -0
  41. package/src/runtime/http.ts +144 -38
  42. package/src/runtime/instrumentation.ts +424 -8
  43. package/src/runtime/native-network.ts +600 -0
  44. package/src/runtime/proxy-nodemaven.ts +37 -2
  45. package/src/runtime/request-options.ts +680 -1
  46. package/src/runtime/stealth.ts +293 -40
  47. package/src/server/index.ts +4 -1
  48. package/src/server/self-test-input-tokens.ts +29 -14
  49. package/src/stream-evidence.ts +988 -0
  50. package/src/testing/index.ts +9 -1
  51. package/src/testing/run.ts +608 -12
  52. package/src/types.ts +194 -0
@@ -50,7 +50,15 @@ import {
50
50
  shouldRetryProxyTransportAttempt,
51
51
  validateUnsafeProxyTransportRetryMethods,
52
52
  } from "./proxy-retry-policy.js";
53
- import { appendQueryParams } from "./request-options.js";
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 = await response.arrayBuffer();
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 = normalizeMethod(options.method ?? "GET");
726
- const hasExplicitRetryPolicy = options.retry !== undefined;
727
- const stealthRetryOptions =
728
- normalizeProxyTransportRetryOptions(options.retry, {
729
- extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
730
- label: "Stealth",
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
- if (stealthRetryOptions) {
739
- validateUnsafeProxyTransportRetryMethods(stealthRetryOptions, "Stealth");
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
- const requestUrl = appendQueryParams(resolveUrl(baseUrl, url), options.params);
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 normalizedError = normalizeStealthTransportError(error);
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 && isProxyPoolRefreshableError(normalizedError)) {
1047
+ if (proxy && rotatesRegistryChain && refreshableProxyError) {
902
1048
  stalePoolError = normalizedError;
903
- if (shouldRunProxyAuthDiagnostic(normalizedError)) {
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: normalizedError,
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
- const { url: _url, maxHops: _maxHops, stopWhen, params, ...fetchOptions } = options;
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
- visitedRequests.add(`${method} ${currentUrl}`);
1015
- response = await session.fetch(currentUrl, {
1016
- ...fetchOptions,
1017
- body,
1018
- method,
1019
- ...(hopIndex === 0 && params ? { params } : {}),
1020
- redirect: "manual",
1021
- throwOnHttpError: false,
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 nextUrl = location
1036
- ? new URL(location, response.url ?? currentUrl).toString()
1037
- : undefined;
1038
- const hop: StealthRedirectHop = {
1039
- url: response.url ?? currentUrl,
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
- if (stopWhen && (await stopWhen(hop))) {
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,
@@ -35,7 +35,10 @@ export {
35
35
  SelfTestRequestSchema,
36
36
  type SelfTestResponse,
37
37
  } from "./self-test.js";
38
- export { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens.js";
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-KST date token resolution for health-check case inputs, ported from
3
- * the monorepo health-monitor (`apps/health-monitor/src/lib/health-check-input.ts`)
4
- * so provider self-tests resolve durable probe inputs identically.
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, KST).
6
+ * Supported token: `+<days>d` or `+<days>d:YYYYMMDD` (1..365 days ahead).
7
7
  */
8
- const RELATIVE_KST_DATE_TOKEN = /^\+(\d{1,3})d(?::(YYYYMMDD))?$/i;
8
+ const RELATIVE_DATE_TOKEN = /^\+(\d{1,3})d(?::(YYYYMMDD))?$/i;
9
9
 
10
- function dateFromKstDaysAhead(
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 kstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000);
18
+ const calendarNow = new Date(now.getTime() + (calendar === "KST" ? 9 * 60 * 60 * 1000 : 0));
16
19
  const date = new Date(
17
- Date.UTC(kstNow.getUTCFullYear(), kstNow.getUTCMonth(), kstNow.getUTCDate() + daysAhead),
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(value: unknown, now = new Date()): unknown {
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(RELATIVE_KST_DATE_TOKEN);
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 dateFromKstDaysAhead(daysAhead, now, format);
43
+ return dateFromDaysAhead(daysAhead, now, format, calendar);
33
44
  }
34
45
  if (Array.isArray(value)) {
35
- return value.map((entry) => resolveHealthCheckInputDateTokens(entry, now));
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
- return Object.fromEntries(
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
  }