@slates/provider 1.0.0-rc.28 → 1.0.0-rc.30

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.
@@ -1,5 +1,8 @@
1
1
  import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
2
- import { AuthConfigSecretRedactor } from '../auth/redact';
2
+ import {
3
+ AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX,
4
+ AuthConfigSecretRedactor
5
+ } from '../auth/redact';
3
6
  import type { SlatePublicContext } from '../context';
4
7
 
5
8
  export interface SlateHttpTraceTextBody {
@@ -84,6 +87,9 @@ let CORE_SECRET_KEY_ATOMS = [
84
87
  'app[-_.]?key',
85
88
  'x[-_.]?api[-_.]?key',
86
89
  'x[-_.]?app[-_.]?key',
90
+ // HubSpot legacy API key query param
91
+ 'hapikey',
92
+ 'access[-_.]?key',
87
93
 
88
94
  // OAuth client credentials
89
95
  'client[-_.]?secret',
@@ -149,6 +155,9 @@ let CORE_SECRET_KEY_ATOMS = [
149
155
  'session[-_.]?id',
150
156
  'sessionid',
151
157
  'sid',
158
+ 'sessid',
159
+ 'jsessionid',
160
+ 'phpsessid',
152
161
  'cookie',
153
162
  'cookies',
154
163
  'set[-_.]?cookie',
@@ -255,18 +264,119 @@ let NON_SECRET_KEY_EXACT = new Set<string>([
255
264
  'kid'
256
265
  ]);
257
266
 
258
- let isSecretKeyName = (name: string): boolean => {
267
+ export let isSecretKeyName = (name: string): boolean => {
259
268
  let normalized = name.toLowerCase();
260
269
  if (NON_SECRET_KEY_EXACT.has(normalized)) return false;
261
270
  return SECRET_KEY_PATTERN.test(normalized);
262
271
  };
263
272
 
273
+ // Whole query/form names that carry a credential only in URLs: Google's `?key=AIza...`.
274
+ // Matched exactly so compound names such as `sort_key` or `partition_key` stay visible.
275
+ let URL_FORM_EXACT_SECRET_KEYS = new Set(['key']);
276
+
264
277
  let isUrlFormSecretKeyName = (name: string): boolean => {
265
278
  let normalized = name.toLowerCase();
266
279
  if (NON_SECRET_KEY_EXACT.has(normalized)) return false;
280
+ if (URL_FORM_EXACT_SECRET_KEYS.has(normalized)) return true;
267
281
  return URL_FORM_SECRET_KEY_PATTERN.test(normalized);
268
282
  };
269
283
 
284
+ let splitCamelCase = (key: string) => key.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
285
+
286
+ // `githubToken` -> ['github', 'token'], `ASP.NET_SessionId` -> ['asp', 'net', 'session', 'id'].
287
+ export let splitKeyParts = (key: string) =>
288
+ splitCamelCase(key)
289
+ .toLowerCase()
290
+ .split(/[^a-z0-9]+/)
291
+ .filter(Boolean);
292
+
293
+ // A compound key ending in one of these describes a secret (its count, expiry, URL,
294
+ // type, error, ...) rather than holding it: `tokenCount`, `sessionTimeout`,
295
+ // `authError`, `signatureValid`, `authorizationUrl`, `token_type`, `tokenExpiresAt`.
296
+ let NON_SECRET_KEY_SUFFIXES = new Set([
297
+ 'count',
298
+ 'limit',
299
+ 'timeout',
300
+ 'expired',
301
+ 'expires',
302
+ 'expiry',
303
+ 'at',
304
+ 'valid',
305
+ 'url',
306
+ 'uri',
307
+ 'error',
308
+ 'type',
309
+ 'id',
310
+ 'ids',
311
+ 'name'
312
+ ]);
313
+
314
+ // Pagination cursors, not credentials.
315
+ let NON_SECRET_FIELD_KEYS = new Set([
316
+ 'page_token',
317
+ 'next_page_token',
318
+ 'prev_page_token',
319
+ 'previous_page_token',
320
+ 'next_token',
321
+ 'continuation_token'
322
+ ]);
323
+
324
+ // `<qualifier>_id` keys that are credentials despite the `id` suffix: session IDs
325
+ // (`sessionId`, `user_session_ids`, `ASP.NET_SessionId`) and `secret_id`.
326
+ let SECRET_ID_QUALIFIERS = new Set(['session', 'sess', 'secret']);
327
+
328
+ /**
329
+ * Split-camelCase-aware secret field classifier shared by error baggage, auth-config
330
+ * path checks, and the prose `key: value` scan. Matches camelCase keys
331
+ * (`githubToken`, `apiSecret`, `accessKey`) and bare `session` keys, but not keys that
332
+ * describe a secret (`tokenCount`, `token_type`) or pagination cursors (`pageToken`).
333
+ */
334
+ export let isSecretFieldName = (key: string): boolean => {
335
+ let parts = splitKeyParts(key);
336
+ if (parts.length === 0) return false;
337
+ let normalized = parts.join('_');
338
+ if (NON_SECRET_FIELD_KEYS.has(normalized)) return false;
339
+
340
+ let last = parts[parts.length - 1] ?? '';
341
+ if (parts.length > 1 && NON_SECRET_KEY_SUFFIXES.has(last)) {
342
+ return (
343
+ (last === 'id' || last === 'ids') &&
344
+ SECRET_ID_QUALIFIERS.has(parts[parts.length - 2] ?? '')
345
+ );
346
+ }
347
+
348
+ return isSecretKeyName(key) || isSecretKeyName(normalized) || parts.includes('session');
349
+ };
350
+
351
+ // Auth-config fields named for the credential's role rather than its kind:
352
+ // `subscriptionKey`, `writeKey`, `integrationKey`, `provisioningHash`.
353
+ let AUTH_CONFIG_CREDENTIAL_SUFFIXES = new Set(['key', 'keys', 'hash']);
354
+
355
+ // `tokenHeader: 'authorization'` names a header; it does not hold a credential.
356
+ let HEADER_NAME_VALUE_PATTERN = /^[A-Za-z]+(?:-[A-Za-z]+)*$/;
357
+
358
+ /**
359
+ * Whether an auth-config value at `path` (e.g. `oauth.accessToken`, `apiKeys.0`) is a
360
+ * credential worth replacing in diagnostics. Only secret-named fields qualify: auth
361
+ * configs also hold account IDs, emails, subdomains, scopes, and token types, and
362
+ * replacing those wherever they appear destroys useful context. Field names are chosen
363
+ * by integration authors and describe credentials consistently (`token`, `apiKey`,
364
+ * `clientSecret`, `password`, `*Key`), while an entropy fallback would also hit
365
+ * high-entropy non-secrets such as `clientId`, `tenantId`, or `cloudId`.
366
+ */
367
+ export let isAuthConfigSecretValue = (secret: string, path: string): boolean => {
368
+ let segment = path
369
+ .split('.')
370
+ .reverse()
371
+ .find(part => !/^\d+$/.test(part));
372
+ if (!segment) return false;
373
+
374
+ let parts = splitKeyParts(segment);
375
+ let last = parts[parts.length - 1] ?? '';
376
+ if (last === 'header' && HEADER_NAME_VALUE_PATTERN.test(secret)) return false;
377
+ return isSecretFieldName(segment) || AUTH_CONFIG_CREDENTIAL_SUFFIXES.has(last);
378
+ };
379
+
270
380
  // Patterns that identify well-known secret values regardless of the surrounding key.
271
381
  // Keeps low false-positive risk by matching formats that are unlikely to occur otherwise.
272
382
  let KNOWN_SECRET_VALUE_PATTERNS: RegExp[] = [
@@ -360,6 +470,14 @@ let isTextContentType = (contentType?: string) =>
360
470
  !contentType ||
361
471
  /(^text\/)|json|xml|html|javascript|graphql|x-www-form-urlencoded|svg/.test(contentType);
362
472
 
473
+ let redactKnownSecretValues = (text: string) => {
474
+ let result = text;
475
+ for (let pattern of KNOWN_SECRET_VALUE_PATTERNS) {
476
+ result = result.replace(pattern, REDACTED_VALUE);
477
+ }
478
+ return result;
479
+ };
480
+
363
481
  let isSafeHeaderName = (name: string) => {
364
482
  let normalized = name.toLowerCase();
365
483
  if (isSecretKeyName(normalized)) return false;
@@ -383,16 +501,14 @@ let sanitizeFreeText = (text: string, redactor?: AuthConfigSecretRedactor) => {
383
501
 
384
502
  // Literal auth-config secret values are replaced first -- this catches secrets sitting
385
503
  // under a key name the heuristics below don't recognize as secret-like.
386
- let result = redactor ? redactor.redact(text) : text;
504
+ let result = redactor ? redactor.redact(text, isAuthConfigSecretValue) : text;
387
505
 
388
506
  result = result.replace(
389
507
  HTTP_AUTH_SCHEME_PATTERN,
390
508
  (_match, scheme: string) => `${scheme} ${REDACTED_VALUE}`
391
509
  );
392
510
 
393
- for (let pattern of KNOWN_SECRET_VALUE_PATTERNS) {
394
- result = result.replace(pattern, REDACTED_VALUE);
395
- }
511
+ result = redactKnownSecretValues(result);
396
512
 
397
513
  result = result.replace(
398
514
  GENERIC_KEY_VALUE_PATTERN,
@@ -409,6 +525,185 @@ let sanitizeFreeText = (text: string, redactor?: AuthConfigSecretRedactor) => {
409
525
  return result;
410
526
  };
411
527
 
528
+ // A credential after an auth scheme word, so messages like "Invalid token provided"
529
+ // or "Basic plan required" are left intact (see looksLikeCredential).
530
+ let PROSE_SAFE_AUTH_SCHEME_PATTERN =
531
+ /\b(bearer|basic|digest|token|negotiate|apikey)(\s+)([A-Za-z0-9_\-.~+/=]{8,})/gi;
532
+
533
+ // A value counts as a credential when it carries a digit or token punctuation, or mixes
534
+ // case past its first character (base64 such as `dXNlcjpwYXNz`). Plain lowercase or
535
+ // Title-case words are prose. `key=value` fragments are left to the key/value scan so
536
+ // "token code=404" is not mistaken for a credential.
537
+ let looksLikeCredential = (value: string) => {
538
+ if (/^[A-Za-z][A-Za-z0-9_\-.]*=/.test(value)) return false;
539
+ return /[0-9_\-.~+/=]/.test(value) || (/[a-z]/.test(value) && /[A-Z]/.test(value.slice(1)));
540
+ };
541
+
542
+ // Credential header names in free text. The value runs to the closing quote when it is
543
+ // quoted, otherwise to the end of the line or the next `&`; redactCredentialHeaderValue
544
+ // decides how much of it is the credential.
545
+ let CREDENTIAL_HEADER_VALUE_PATTERN =
546
+ /\b(proxy-authorization|authorization|x-api-key|api[-_]?key|set-cookie|cookie)(\s*[:=]\s*)(?:(["'])((?:\\.|(?!\3)[^\\\r\n])*)|([^\r\n&]*))/gi;
547
+
548
+ let AUTH_SCHEME_WORD_PATTERN = /^(?:bearer|basic|digest|token|negotiate|apikey)$/i;
549
+
550
+ let COOKIE_HEADER_NAME_PATTERN = /^(?:set-)?cookie$/i;
551
+ let AUTHORIZATION_HEADER_NAME_PATTERN = /^(?:proxy-)?authorization$/i;
552
+
553
+ // `sid=1; theme=dark`: a cookie pair followed by any chained pairs.
554
+ let COOKIE_PAIRS_PATTERN = /^[^\s;=&"',]+=[^\s;&"',]*(?:;\s*[^\s;=&"',]+=[^\s;&"',]*)*/;
555
+ let HEADER_TOKEN_PATTERN = /^[^\s&"',;]+/;
556
+ // A scheme word (`Bearer`, `OAuth`, `Bot`, `SSWS`, `Zoho-oauthtoken`, `Klaviyo-API-Key`)
557
+ // followed by its credential.
558
+ let HEADER_SCHEME_PATTERN = /^([A-Za-z][\w-]*)(\s+)([\s\S]*)$/;
559
+ // `Digest username="x", response="..."`, `AWS4-HMAC-SHA256 Credential=..., Signature=...`.
560
+ let HEADER_AUTH_PARAMS_PATTERN = /^[A-Za-z][\w-]*\s*=/;
561
+
562
+ // Header values are redacted only when they hold a credential, so prose such as
563
+ // `Authorization: missing or invalid` or `Missing cookie: please log in` stays readable.
564
+ let redactCredentialHeaderValue = (name: string, value: string) => {
565
+ if (COOKIE_HEADER_NAME_PATTERN.test(name)) {
566
+ let pairs = COOKIE_PAIRS_PATTERN.exec(value)?.[0];
567
+ if (pairs) return `${REDACTED_VALUE}${value.slice(pairs.length)}`;
568
+ }
569
+
570
+ let scheme = HEADER_SCHEME_PATTERN.exec(value);
571
+ if (scheme) {
572
+ let [, word = '', space = '', rest = ''] = scheme;
573
+ let schemePrefix = `${word}${space}`;
574
+ if (
575
+ AUTHORIZATION_HEADER_NAME_PATTERN.test(name) &&
576
+ HEADER_AUTH_PARAMS_PATTERN.test(rest)
577
+ ) {
578
+ // Auth-param lists carry the credential in any parameter, so all of them go.
579
+ return `${schemePrefix}${REDACTED_VALUE}`;
580
+ }
581
+
582
+ let credential = HEADER_TOKEN_PATTERN.exec(rest)?.[0];
583
+ if (credential === REDACTED_VALUE) return value;
584
+ if (
585
+ credential &&
586
+ (AUTH_SCHEME_WORD_PATTERN.test(word) || looksLikeCredential(credential))
587
+ ) {
588
+ return `${schemePrefix}${REDACTED_VALUE}${rest.slice(credential.length)}`;
589
+ }
590
+ }
591
+
592
+ // No scheme: the first token is the credential itself (`X-API-Key: k1`).
593
+ let token = HEADER_TOKEN_PATTERN.exec(value)?.[0];
594
+ if (token && token !== REDACTED_VALUE && looksLikeCredential(token)) {
595
+ return `${REDACTED_VALUE}${value.slice(token.length)}`;
596
+ }
597
+ return value;
598
+ };
599
+
600
+ // Generic `'key': 'value'` scan (Python reprs, single-quoted logs); the key is filtered
601
+ // via isSecretKeyName at replace time.
602
+ let GENERIC_SINGLE_QUOTED_KEY_VALUE_PATTERN =
603
+ /'([^'\\]*(?:\\.[^'\\]*)*)'(\s*:\s*)'([^'\\]*(?:\\.[^'\\]*)*)'/g;
604
+
605
+ // Unquoted `key: value` in prose and JS-style object dumps: `password: Hunter2`,
606
+ // `Invalid token: ya29...`, `{ access_token: 'abc' }`. The key is the word right before
607
+ // the colon; the value is one token, optionally quoted.
608
+ let PROSE_KEY_VALUE_PATTERN =
609
+ /(?<![A-Za-z0-9_\-.])([A-Za-z][A-Za-z0-9_\-.]*)([ \t]*:[ \t]*)(["'`]?)([^\s"'`,;&)\]}]+)/g;
610
+
611
+ // Words that describe a credential's state rather than being one (`token: expired`).
612
+ let PROSE_STATE_WORD_PATTERN =
613
+ /^(?:required|missing|invalid|expired|revoked|empty|blank|null|undefined|none|not|is|was|has|must|should|cannot|incorrect|wrong|malformed|provided|present|absent|unknown|denied|rejected|unauthorized|forbidden|true|false|too|the|a|an)$/i;
614
+
615
+ // Lowercase snake_case words are error codes (`token: invalid_grant`), not credentials.
616
+ let PROSE_ERROR_CODE_PATTERN = /^[a-z]+(?:_[a-z]+)+$/;
617
+
618
+ let redactProseKeyValue = (
619
+ match: string,
620
+ key: string,
621
+ separator: string,
622
+ quote: string,
623
+ value: string
624
+ ) => {
625
+ if (AUTHORIZATION_HEADER_NAME_PATTERN.test(key) || COOKIE_HEADER_NAME_PATTERN.test(key)) {
626
+ return match;
627
+ }
628
+ if (value.startsWith('[') || value.startsWith(AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX)) {
629
+ return match;
630
+ }
631
+ if (!isSecretFieldName(key)) return match;
632
+
633
+ // Sentence punctuation after the value (`token: expired.`) is not part of it.
634
+ let credential = value.replace(/[.!?:]+$/, '');
635
+ if (!credential || PROSE_ERROR_CODE_PATTERN.test(credential)) return match;
636
+
637
+ // A single-word key (`token`, `password`) needs a credential-shaped value; a compound
638
+ // key (`access_token`, `sessionToken`) is specific enough that any non-state word is
639
+ // treated as its value.
640
+ let compoundKey = splitKeyParts(key).length > 1;
641
+ if (
642
+ looksLikeCredential(credential) ||
643
+ (compoundKey && !PROSE_STATE_WORD_PATTERN.test(credential))
644
+ ) {
645
+ return `${key}${separator}${quote}${REDACTED_VALUE}${value.slice(credential.length)}`;
646
+ }
647
+ return match;
648
+ };
649
+
650
+ // Narrower than sanitizeFreeText for human-readable error text: redacts known secret
651
+ // formats, scheme credentials, credential header values, and secret-named
652
+ // `key=value` / `key: value` / `"key": "value"` / `'key': 'value'` pairs, but never a
653
+ // bare `code=` value or a prose word that merely follows "token"/"basic".
654
+ export let sanitizeSecretValuesInText = (text: string) => {
655
+ if (!text) return text;
656
+
657
+ let result = text.replace(
658
+ PROSE_SAFE_AUTH_SCHEME_PATTERN,
659
+ (match, scheme: string, space: string, value: string) =>
660
+ looksLikeCredential(value) ? `${scheme}${space}${REDACTED_VALUE}` : match
661
+ );
662
+
663
+ result = result.replace(
664
+ CREDENTIAL_HEADER_VALUE_PATTERN,
665
+ (
666
+ _match,
667
+ name: string,
668
+ separator: string,
669
+ quote: string | undefined,
670
+ quotedValue: string | undefined,
671
+ plainValue: string | undefined
672
+ ) =>
673
+ `${name}${separator}${quote ?? ''}${redactCredentialHeaderValue(
674
+ name,
675
+ quote ? (quotedValue ?? '') : (plainValue ?? '')
676
+ )}`
677
+ );
678
+
679
+ result = redactKnownSecretValues(result);
680
+
681
+ result = result.replace(
682
+ GENERIC_KEY_VALUE_PATTERN,
683
+ (match, key: string, equals: string, value: string, offset: number, whole: string) => {
684
+ if (!isSecretKeyName(key)) return match;
685
+ // `Authorization=Basic [redacted]` was already handled by the scheme patterns.
686
+ let rest = whole.slice(offset + match.length);
687
+ if (AUTH_SCHEME_WORD_PATTERN.test(value) && /^\s+\[redacted\]/.test(rest)) return match;
688
+ return `${key}${equals}${REDACTED_VALUE}`;
689
+ }
690
+ );
691
+
692
+ result = result.replace(PROSE_KEY_VALUE_PATTERN, redactProseKeyValue);
693
+
694
+ result = result.replace(
695
+ GENERIC_SINGLE_QUOTED_KEY_VALUE_PATTERN,
696
+ (match, key: string, colon: string, _value: string) =>
697
+ isSecretKeyName(key) ? `'${key}'${colon}'${REDACTED_VALUE}'` : match
698
+ );
699
+
700
+ return result.replace(
701
+ GENERIC_JSON_KEY_VALUE_PATTERN,
702
+ (match, key: string, colon: string, _value: string) =>
703
+ isSecretKeyName(key) ? `"${key}"${colon}"${REDACTED_VALUE}"` : match
704
+ );
705
+ };
706
+
412
707
  let sanitizeScalar = (value: string, redactor?: AuthConfigSecretRedactor) =>
413
708
  truncateText(sanitizeFreeText(value, redactor));
414
709
 
@@ -549,6 +844,13 @@ let decodeRedactedMarker = (value: string) =>
549
844
  ? value
550
845
  : value.split(REDACTED_ENCODED).join(REDACTED_VALUE);
551
846
 
847
+ // A query/fragment value under a non-secret name: a literal secret-named auth-config
848
+ // value or a known credential format (`?q=sk_live_...`) is still replaced.
849
+ let redactQueryValue = (rawValue: string, redactor?: AuthConfigSecretRedactor) =>
850
+ redactKnownSecretValues(
851
+ redactor ? redactor.redact(rawValue, isAuthConfigSecretValue) : rawValue
852
+ );
853
+
552
854
  let redactHashFragment = (hash: string, redactor?: AuthConfigSecretRedactor) => {
553
855
  if (!hash?.includes('=')) return hash;
554
856
  let withoutHash = hash.startsWith('#') ? hash.slice(1) : hash;
@@ -556,8 +858,8 @@ let redactHashFragment = (hash: string, redactor?: AuthConfigSecretRedactor) =>
556
858
  /(?<![A-Za-z0-9])([A-Za-z0-9][A-Za-z0-9_\-.]*)=([^&]*)/g,
557
859
  (match, key: string, rawValue: string) => {
558
860
  if (isUrlFormSecretKeyName(key)) return `${key}=${REDACTED_VALUE}`;
559
- let literalRedacted = redactor ? redactor.redact(rawValue) : rawValue;
560
- return literalRedacted !== rawValue ? `${key}=${literalRedacted}` : match;
861
+ let redactedValue = redactQueryValue(rawValue, redactor);
862
+ return redactedValue !== rawValue ? `${key}=${redactedValue}` : match;
561
863
  }
562
864
  );
563
865
  return redacted ? `#${redacted}` : '';
@@ -565,9 +867,16 @@ let redactHashFragment = (hash: string, redactor?: AuthConfigSecretRedactor) =>
565
867
 
566
868
  // Redacts secret-keyed auth values embedded in URL paths (e.g. Telegram's `/bot<token>/...`).
567
869
  let isEmbeddedPathSecret = (secret: string, path: string) =>
568
- secret.length >= 8 && isSecretKeyName(path.split('.').pop() ?? '');
870
+ secret.length >= 8 && isAuthConfigSecretValue(secret, path);
871
+
872
+ // Replaces secret-named auth-config values in free text: whole-value matches, plus
873
+ // values of 8+ chars embedded in longer text such as URL paths or error messages.
874
+ export let redactAuthConfigSecretsInText = (
875
+ text: string,
876
+ redactor: AuthConfigSecretRedactor
877
+ ): string => redactor.redactEmbedded(text, isEmbeddedPathSecret, isAuthConfigSecretValue);
569
878
 
570
- let sanitizeUrl = (value: string, redactor?: AuthConfigSecretRedactor) => {
879
+ export let sanitizeUrl = (value: string, redactor?: AuthConfigSecretRedactor) => {
571
880
  if (!value) return value;
572
881
 
573
882
  try {
@@ -578,7 +887,7 @@ let sanitizeUrl = (value: string, redactor?: AuthConfigSecretRedactor) => {
578
887
  url.password = '';
579
888
  }
580
889
 
581
- if (redactor) url.pathname = redactor.redactEmbedded(url.pathname, isEmbeddedPathSecret);
890
+ if (redactor) url.pathname = redactAuthConfigSecretsInText(url.pathname, redactor);
582
891
 
583
892
  for (let key of Array.from(url.searchParams.keys())) {
584
893
  if (isUrlFormSecretKeyName(key)) {
@@ -586,21 +895,24 @@ let sanitizeUrl = (value: string, redactor?: AuthConfigSecretRedactor) => {
586
895
  continue;
587
896
  }
588
897
 
589
- // Not a secret-looking key -- still catch a literal auth-config secret value.
898
+ // Not a secret-looking key -- still catch a literal auth-config secret value or a
899
+ // known credential format.
590
900
  let rawValue = url.searchParams.get(key) ?? '';
591
- let literalRedacted = redactor ? redactor.redact(rawValue) : rawValue;
592
- if (literalRedacted !== rawValue) url.searchParams.set(key, literalRedacted);
901
+ let redactedValue = redactQueryValue(rawValue, redactor);
902
+ if (redactedValue !== rawValue) url.searchParams.set(key, redactedValue);
593
903
  }
594
904
 
595
905
  url.hash = redactHashFragment(url.hash, redactor);
596
906
 
597
907
  return decodeRedactedMarker(url.toString());
598
908
  } catch {
599
- let text = redactor ? redactor.redactEmbedded(value, isEmbeddedPathSecret) : value;
909
+ let text = redactor ? redactAuthConfigSecretsInText(value, redactor) : value;
600
910
  return text.replace(
601
911
  /([?&]([^=&#]+)=)([^&#]+)/g,
602
912
  (_match, prefix: string, key: string, rawValue: string) =>
603
- isUrlFormSecretKeyName(key) ? `${prefix}${REDACTED_VALUE}` : `${prefix}${rawValue}`
913
+ isUrlFormSecretKeyName(key)
914
+ ? `${prefix}${REDACTED_VALUE}`
915
+ : `${prefix}${redactKnownSecretValues(rawValue)}`
604
916
  );
605
917
  }
606
918
  };
@@ -802,6 +1114,8 @@ export let __traceInternals = {
802
1114
  sanitizeHeaders,
803
1115
  isSafeHeaderName,
804
1116
  isSecretKeyName,
1117
+ isSecretFieldName,
1118
+ isAuthConfigSecretValue,
805
1119
  isUrlFormSecretKeyName,
806
1120
  redactStructuredValue,
807
1121
  SECRET_KEY_PATTERN,
@@ -1,8 +1,30 @@
1
1
  import { ServiceError } from '@lowerdeck/error';
2
+ import { AxiosError, AxiosHeaders } from 'axios';
2
3
  import { describe, expect, it } from 'vitest';
3
- import { buildApiServiceError, collectApiErrorDetails, createApiServiceError } from './api';
4
+ import {
5
+ buildApiServiceError,
6
+ collectApiErrorDetails,
7
+ createApiServiceError,
8
+ decodeApiErrorBody,
9
+ getApiErrorResponseWithBody,
10
+ looksLikeHtmlErrorBody,
11
+ pushBoundedApiErrorDetail,
12
+ readApiErrorText,
13
+ truncateApiErrorMessage
14
+ } from './api';
4
15
  import { SlateError } from './base';
5
16
 
17
+ let axiosFailure = (status: number, data: unknown) => {
18
+ let config = { url: '/widgets', method: 'get', headers: new AxiosHeaders() };
19
+ return new AxiosError('Request failed', 'ERR_BAD_REQUEST', config, undefined, {
20
+ status,
21
+ statusText: 'Error',
22
+ data,
23
+ headers: {},
24
+ config
25
+ });
26
+ };
27
+
6
28
  describe('API service error helpers', () => {
7
29
  it('collects unique nested API error details', () => {
8
30
  let details: string[] = [];
@@ -111,4 +133,104 @@ describe('API service error helpers', () => {
111
133
  })
112
134
  ).toBe(existing);
113
135
  });
136
+
137
+ it('recovers the upstream body from SlateError baggage', () => {
138
+ let parent = SlateError.fromAxios(
139
+ axiosFailure(422, { errors: [{ message: 'Name is required' }] })
140
+ );
141
+
142
+ expect(parent.name).toBe('SlateError');
143
+ expect((parent as any).response).toBeUndefined();
144
+ expect(getApiErrorResponseWithBody(parent)).toEqual({
145
+ status: 422,
146
+ data: { errors: [{ message: 'Name is required' }] }
147
+ });
148
+
149
+ let error = buildApiServiceError(parent, {
150
+ providerLabel: 'Example',
151
+ reason: 'example_api_error',
152
+ extractResponse: (input, helpers) => helpers.getResponseWithBody(input)
153
+ });
154
+
155
+ expect(error.data.upstreamStatus).toBe(422);
156
+ expect(error.data.message).toBe('Example API request failed: HTTP 422: Name is required');
157
+ });
158
+
159
+ it('prefers an existing response body and ignores non-SlateErrors without one', () => {
160
+ let response = { status: 400, data: { message: 'Direct' } };
161
+
162
+ expect(getApiErrorResponseWithBody({ response })).toEqual({
163
+ status: 400,
164
+ statusText: undefined,
165
+ data: { message: 'Direct' }
166
+ });
167
+ expect(
168
+ getApiErrorResponseWithBody({ name: 'Error', data: { baggage: { response: 'x' } } })
169
+ ).toBeUndefined();
170
+ });
171
+
172
+ it('detects HTML error pages without dropping plain text that starts with <', () => {
173
+ expect(looksLikeHtmlErrorBody('<!DOCTYPE html><html><body>Bad</body></html>')).toBe(true);
174
+ expect(looksLikeHtmlErrorBody(' <html lang="en">')).toBe(true);
175
+ expect(looksLikeHtmlErrorBody('<h1>502 Bad Gateway</h1>')).toBe(true);
176
+ expect(looksLikeHtmlErrorBody('Error page: <body>oops</body>')).toBe(true);
177
+ expect(looksLikeHtmlErrorBody('<id> is required')).toBe(false);
178
+ expect(looksLikeHtmlErrorBody('<body> is required')).toBe(false);
179
+ expect(looksLikeHtmlErrorBody('Invalid <body> parameter')).toBe(false);
180
+ expect(looksLikeHtmlErrorBody('<title> is required')).toBe(false);
181
+ expect(looksLikeHtmlErrorBody('<p> must be a string')).toBe(false);
182
+ expect(looksLikeHtmlErrorBody('<= 100 items allowed')).toBe(false);
183
+ expect(looksLikeHtmlErrorBody('Field must be a number')).toBe(false);
184
+ });
185
+
186
+ it('reads displayable error text and keeps detail lists bounded and unique', () => {
187
+ expect(readApiErrorText(' Bad input ')).toBe('Bad input');
188
+ expect(readApiErrorText(42)).toBe('42');
189
+ expect(readApiErrorText(42, { includeNumbers: false })).toBeUndefined();
190
+ expect(readApiErrorText('[truncated]')).toBeUndefined();
191
+ expect(readApiErrorText(' [redacted] ')).toBeUndefined();
192
+ expect(readApiErrorText('[unserializable]')).toBeUndefined();
193
+ expect(readApiErrorText('Token [redacted] is invalid')).toBe(
194
+ 'Token [redacted] is invalid'
195
+ );
196
+ expect(readApiErrorText('<html></html>')).toBeUndefined();
197
+ expect(readApiErrorText({ message: 'x' })).toBeUndefined();
198
+
199
+ let details: string[] = [];
200
+ for (let value of ['a', 'a', '', '[truncated]', 'b', 'c', 'd', 'e', 'f']) {
201
+ pushBoundedApiErrorDetail(details, value);
202
+ }
203
+ expect(details).toEqual(['a', 'b', 'c', 'd', 'e']);
204
+
205
+ let fieldDetails: string[] = [];
206
+ pushBoundedApiErrorDetail(fieldDetails, 'is required', { field: 'name' });
207
+ pushBoundedApiErrorDetail(fieldDetails, 'is required', { field: 'name' });
208
+ pushBoundedApiErrorDetail(fieldDetails, 'second', { maxDetails: 1 });
209
+ expect(fieldDetails).toEqual(['name: is required']);
210
+ });
211
+
212
+ it('truncates long error messages', () => {
213
+ expect(truncateApiErrorMessage('abc', 5)).toBe('abc');
214
+ expect(truncateApiErrorMessage('abcdef', 3)).toBe('abc...');
215
+ expect(truncateApiErrorMessage('x'.repeat(1_001))).toBe(`${'x'.repeat(1_000)}...`);
216
+ });
217
+
218
+ it('decodes text and binary error bodies', () => {
219
+ let json = '{"error":{"message":"Not found"}}';
220
+ let bytes = new TextEncoder().encode(json);
221
+
222
+ expect(decodeApiErrorBody(json)).toEqual({ error: { message: 'Not found' } });
223
+ expect(decodeApiErrorBody(bytes.buffer)).toEqual({ error: { message: 'Not found' } });
224
+ expect(decodeApiErrorBody(bytes)).toEqual({ error: { message: 'Not found' } });
225
+ expect(decodeApiErrorBody(Buffer.from(` ${json} `))).toEqual({
226
+ error: { message: 'Not found' }
227
+ });
228
+ expect(decodeApiErrorBody(' plain text ')).toBe('plain text');
229
+ expect(decodeApiErrorBody('{not json')).toBe('{not json');
230
+ expect(decodeApiErrorBody(new TextEncoder().encode('abcdef'), { maxBytes: 3 })).toBe(
231
+ 'abc'
232
+ );
233
+ expect(decodeApiErrorBody({ already: 'parsed' })).toEqual({ already: 'parsed' });
234
+ expect(decodeApiErrorBody(undefined)).toBeUndefined();
235
+ });
114
236
  });