@global-torque/invest-core 0.3.0 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 candidate
4
+
5
+ - Retain user, business, numeric, URL, and diagnostic analytics values while
6
+ redacting exact known credential fields across objects, JSON, FormData, and
7
+ URLSearchParams.
8
+ - Preserve duplicate form values, circular/binary markers, and method-specific
9
+ request-body normalization without mutating caller data.
10
+
3
11
  ## 0.3.0 candidate
4
12
 
5
13
  - Require Node `^24.21.0`; Node 22 is no longer supported.
package/README.md CHANGED
@@ -27,7 +27,9 @@ TypeScript browser and type targets and additionally provide exact generated
27
27
  candidate packer emits only these three Node files; all other exports remain
28
28
  source based.
29
29
 
30
- - `analytics/analyticsBody`: analytics request body normalization and redaction.
30
+ - `analytics/analyticsBody`: analytics request body normalization with exact
31
+ credential-field redaction. User, business, numeric, URL, and diagnostic
32
+ values are retained unless assigned to a known credential field.
31
33
  - `decimal/canonicalDecimal`: numeric(38,18) canonical-string validation, exact scaled-integer arithmetic, comparison, and display formatting.
32
34
  - `evm/walletInfo`: EVM wallet status-only response normalization and deposit-address extraction.
33
35
  - `filer/publicImage`: pure public filer image URL and `srcset` builders with injected `filerUrl`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@global-torque/invest-core",
3
3
  "private": false,
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "description": "Pure investment policy, calculations, formatters, and schema helpers.",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@types/lodash": "^4.17.24",
70
- "@global-torque/domain-types": "0.3.0",
70
+ "@global-torque/domain-types": "0.3.1",
71
71
  "ajv": "^8.20.0",
72
72
  "ajv-errors": "^3.0.0",
73
73
  "ajv-formats": "^3.0.1",
@@ -6,45 +6,180 @@ import {
6
6
  import {
7
7
  normalizeAnalyticsBody,
8
8
  normalizeAnalyticsBodyForMethod,
9
+ sanitizeAnalyticsText,
10
+ sanitizeAnalyticsUrl,
9
11
  } from '../analyticsBody';
10
12
 
11
13
  describe('analyticsBody', () => {
12
- it('redacts sensitive fields in object payloads', () => {
13
- expect(normalizeAnalyticsBody({
14
+ it('removes exact credential fields while preserving user and business data', () => {
15
+ const input = {
14
16
  email: 'user@example.com',
17
+ firstName: 'Jamie',
18
+ address: '42 Main Street',
19
+ longId: '12345678901234567890',
20
+ token_symbol: 'USDC',
21
+ token_count: 12,
22
+ tokenNote: 'token is a business concept here',
15
23
  password: 'secret',
16
- code: '123456',
17
24
  nested: {
18
- routing_number: '021000021',
25
+ jwtToken: 'jwt-secret',
26
+ auth_code: 'auth-secret',
27
+ status_code: 200,
19
28
  note: 'Contact user@example.com before submitting',
20
- tokenNote: 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwMTIzNDU2Nzg5MCJ9.signaturepart',
21
- keep: true,
22
29
  },
23
- })).toEqual({
24
- email: '[redacted]',
30
+ };
31
+
32
+ expect(normalizeAnalyticsBody(input)).toEqual({
33
+ email: 'user@example.com',
34
+ firstName: 'Jamie',
35
+ address: '42 Main Street',
36
+ longId: '12345678901234567890',
37
+ token_symbol: 'USDC',
38
+ token_count: 12,
39
+ tokenNote: 'token is a business concept here',
25
40
  password: '[redacted]',
26
- code: '[redacted]',
27
41
  nested: {
28
- routing_number: '[redacted]',
29
- note: 'Contact [redacted] before submitting',
30
- tokenNote: '[redacted]',
31
- keep: true,
42
+ jwtToken: '[redacted]',
43
+ auth_code: '[redacted]',
44
+ status_code: 200,
45
+ note: 'Contact user@example.com before submitting',
32
46
  },
33
47
  });
48
+ expect(input.password).toBe('secret');
49
+ expect(input.nested.jwtToken).toBe('jwt-secret');
34
50
  });
35
51
 
36
- it('normalizes JSON strings and ignores invalid string bodies', () => {
37
- expect(normalizeAnalyticsBody('{"first_name":"Jamie","keep":1}')).toEqual({
38
- first_name: '[redacted]',
52
+ it('normalizes JSON strings and retains GET bodies', () => {
53
+ expect(normalizeAnalyticsBody('{"email":"user@example.com","token":"secret","keep":1}')).toEqual({
54
+ email: 'user@example.com',
55
+ token: '[redacted]',
39
56
  keep: 1,
40
57
  });
41
58
  expect(normalizeAnalyticsBody('not-json')).toEqual({});
59
+ expect(normalizeAnalyticsBodyForMethod('GET', { keep: true })).toEqual({ keep: true });
60
+ expect(normalizeAnalyticsBodyForMethod('POST', { keep: true })).toEqual({ keep: true });
61
+ expect(normalizeAnalyticsBodyForMethod('POST', undefined)).toEqual({});
42
62
  });
43
63
 
44
- it('only includes bodies for mutation methods', () => {
45
- expect(normalizeAnalyticsBodyForMethod('GET', { keep: true })).toEqual({});
46
- expect(normalizeAnalyticsBodyForMethod('POST', { keep: true })).toEqual({ redacted: true });
47
- expect(normalizeAnalyticsBodyForMethod('POST', undefined)).toEqual({});
48
- expect(normalizeAnalyticsBodyForMethod('POST', '')).toEqual({});
64
+ it('redacts code only for the known csrf authentication record shape', () => {
65
+ expect(normalizeAnalyticsBody({ method: 'code', csrf_token: 'csrf', code: 'one', state: 'two' })).toEqual({
66
+ method: 'code',
67
+ csrf_token: '[redacted]',
68
+ code: '[redacted]',
69
+ state: 'two',
70
+ });
71
+ expect(normalizeAnalyticsBody({ method: 'code', code: 'keep', state: 'two' })).toEqual({
72
+ method: 'code',
73
+ code: 'keep',
74
+ state: 'two',
75
+ });
76
+ expect(normalizeAnalyticsBody({ method: 'code', csrf_token: 'csrf', status_code: 200 })).toEqual({
77
+ method: 'code',
78
+ csrf_token: '[redacted]',
79
+ status_code: 200,
80
+ });
81
+
82
+ expect(normalizeAnalyticsBody('{"method":"code","csrfToken":"csrf","code":"one","state":"two"}')).toEqual({
83
+ method: 'code',
84
+ csrfToken: '[redacted]',
85
+ code: '[redacted]',
86
+ state: 'two',
87
+ });
88
+ });
89
+
90
+ it('preserves duplicate FormData and URLSearchParams values while masking credentials', () => {
91
+ const formData = new FormData();
92
+ formData.append('email', 'user@example.com');
93
+ formData.append('email', 'second@example.com');
94
+ formData.append('method', 'code');
95
+ formData.append('csrf_token', 'csrf');
96
+ formData.append('code', 'one');
97
+ formData.append('code', 'two');
98
+ expect(normalizeAnalyticsBody(formData)).toMatchObject({
99
+ email: ['user@example.com', 'second@example.com'],
100
+ method: 'code',
101
+ csrf_token: '[redacted]',
102
+ code: ['[redacted]', '[redacted]'],
103
+ });
104
+
105
+ const params = new URLSearchParams();
106
+ params.append('email', 'user@example.com');
107
+ params.append('email', 'second@example.com');
108
+ params.append('token_symbol', 'USDC');
109
+ params.append('token', 'secret');
110
+ expect(normalizeAnalyticsBody(params)).toEqual({
111
+ email: ['user@example.com', 'second@example.com'],
112
+ token_symbol: 'USDC',
113
+ token: '[redacted]',
114
+ });
115
+
116
+ const authParams = new URLSearchParams('method=code&csrf_token=csrf&code=one&code=two&state=state');
117
+ expect(normalizeAnalyticsBody(authParams)).toEqual({
118
+ method: 'code',
119
+ csrf_token: '[redacted]',
120
+ code: ['[redacted]', '[redacted]'],
121
+ state: 'state',
122
+ });
123
+ });
124
+
125
+ it('handles cycles before arrays and preserves binary markers', () => {
126
+ const value: Record<string, unknown> = { name: 'user@example.com' };
127
+ const array: unknown[] = [];
128
+ array.push(array);
129
+ value.array = array;
130
+ value.file = new Blob(['contents']);
131
+ expect(normalizeAnalyticsBody(value)).toEqual({
132
+ name: 'user@example.com',
133
+ array: ['[circular]'],
134
+ file: '[binary]',
135
+ });
136
+ });
137
+
138
+ it('selectively preserves URL context and masks credential locations', () => {
139
+ const url = 'https://user:pass@example.test/path/token/CANARY?%74oken=CANARY&email=user@example.test&tag=one&tag=two#section';
140
+ const sanitized = sanitizeAnalyticsUrl(url);
141
+ expect(sanitized).toContain('https://example.test/path/token/[redacted]');
142
+ expect(sanitized).toContain('email=user%40example.test');
143
+ expect(sanitized).toContain('tag=one&tag=two');
144
+ expect(sanitized).toContain('#section');
145
+ expect(sanitized).not.toContain('CANARY');
146
+ });
147
+
148
+ it('sanitizes credential assignments and bearer values without broad PII matching', () => {
149
+ const text = 'user@example.com amount=123456789012 tokenNote=keep token=CANARY url=https://example.test/path?token=CANARY&email=user@example.com';
150
+ const sanitized = sanitizeAnalyticsText(text);
151
+ expect(sanitized).toContain('user@example.com');
152
+ expect(sanitized).toContain('123456789012');
153
+ expect(sanitized).toContain('tokenNote=keep');
154
+ expect(sanitized).not.toContain('token=CANARY');
155
+ expect(sanitized).not.toContain('?token=CANARY');
156
+ expect(sanitizeAnalyticsText('Failed /offers?%74oken=CANARY&email=user@example.test'))
157
+ .toBe('Failed /offers?token=%5Bredacted%5D&email=user%40example.test');
158
+ expect(sanitizeAnalyticsText('Open ../offers?email=user@example.test'))
159
+ .toBe('Open ../offers?email=user%40example.test');
160
+ expect(normalizeAnalyticsBody({
161
+ redirect_url: '/auth?%74oken=CANARY&email=user@example.test',
162
+ })).toEqual({
163
+ redirect_url: '/auth?token=%5Bredacted%5D&email=user%40example.test',
164
+ });
165
+ });
166
+
167
+ it('sanitizes nested text assignments and exact credential headers only', () => {
168
+ expect(sanitizeAnalyticsText('{"nested":{"password":"TEST_SECRET"},"email":"user@example.com"}'))
169
+ .toBe('{"nested":{"password":"[redacted]"},"email":"user@example.com"}');
170
+ expect(sanitizeAnalyticsText('https://host/path#%74oken=TEST_SECRET&tab=activity'))
171
+ .toBe('https://host/path#token=%5Bredacted%5D&tab=activity');
172
+ expect(sanitizeAnalyticsText('Cookie: session=TEST_SECRET; next=SECOND_SECRET'))
173
+ .toBe('Cookie: [redacted]');
174
+ expect(sanitizeAnalyticsText('preferred-cookie: chocolate email=user@example.com'))
175
+ .toBe('preferred-cookie: chocolate email=user@example.com');
176
+ expect(sanitizeAnalyticsText('message="failed password=CANARY for a@example.test"'))
177
+ .toBe('message="failed password=[redacted] for a@example.test"');
178
+ expect(sanitizeAnalyticsText('{"authorization":"Bearer CANARY","password":"SECOND_SECRET","email":"user@example.com"}'))
179
+ .toBe('{"authorization":"Bearer [redacted]","password":"[redacted]","email":"user@example.com"}');
180
+ expect(sanitizeAnalyticsText('{"proxy_authorization":"Basic CANARY","token":"SECOND_SECRET","email":"user@example.com"}'))
181
+ .toBe('{"proxy_authorization":"Basic [redacted]","token":"[redacted]","email":"user@example.com"}');
182
+ expect(sanitizeAnalyticsText('password="prefix[redacted]SECRET" token=abc%5Bredacted%5DSECRET'))
183
+ .toBe('password="[redacted]" token=[redacted]');
49
184
  });
50
185
  });
@@ -1,224 +1,314 @@
1
1
  import type { AnalyticsBody } from '@global-torque/domain-types/analyticsTypes';
2
2
 
3
- const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
4
3
  const REDACTED_VALUE = '[redacted]';
5
- const REDACTED_BODY: AnalyticsBody = { redacted: true };
6
- const SENSITIVE_EXACT_KEYS = new Set([
7
- 'address',
8
- 'address1',
9
- 'address2',
10
- 'city',
11
- 'country',
12
- 'account_holder_name',
13
- 'code',
14
- 'dob',
15
- 'email',
16
- 'first_name',
17
- 'full_name',
18
- 'ip_address',
19
- 'last_name',
20
- 'middle_name',
21
- 'postal_code',
22
- 'state',
23
- 'wallet',
24
- 'user_browser',
25
- 'zip',
26
- 'zip_code',
27
- ]);
28
4
  const MAX_STRING_LENGTH = 500;
29
- const SENSITIVE_KEY_SUBSTRINGS = [
30
- 'account_number',
31
- 'csrf',
32
- 'passcode',
5
+
6
+ /**
7
+ * Credential names are matched exactly after converting camel, kebab and
8
+ * spaced names to snake case. Business fields such as `token_symbol`,
9
+ * `token_count`, and `tokenNote` remain available.
10
+ */
11
+ const CREDENTIAL_KEYS = new Set([
33
12
  'password',
34
- 'routing_number',
13
+ 'create_password',
14
+ 'repeat_password',
15
+ 'current_password',
16
+ 'new_password',
17
+ 'confirm_password',
18
+ 'token',
19
+ 'jwt_token',
20
+ 'access_token',
21
+ 'refresh_token',
22
+ 'id_token',
23
+ 'auth_token',
24
+ 'session_token',
25
+ 'session_token_exchange_code',
26
+ 'csrf',
27
+ 'csrf_token',
28
+ 'xsrf_token',
29
+ 'authorization',
30
+ 'proxy_authorization',
31
+ 'cookie',
32
+ 'set_cookie',
33
+ 'api_key',
34
+ 'x_api_key',
35
35
  'secret',
36
- 'social_security',
37
- 'ssn',
38
- 'tax_id',
36
+ 'client_secret',
37
+ 'private_key',
38
+ 'seed_phrase',
39
+ 'mnemonic',
40
+ 'passcode',
41
+ 'otp',
39
42
  'totp',
40
- ] as const;
41
- const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
42
- const LONG_DIGIT_PATTERN = /\d{10,}/g;
43
- const JWT_LIKE_PATTERN = /\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b/g;
44
- const BEARER_TOKEN_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi;
45
- const SECRET_ASSIGNMENT_PATTERN = /\b(access[_-]?token|refresh[_-]?token|id[_-]?token|auth[_-]?token|csrf[_-]?token|session[_-]?token|token|secret|client[_-]?secret|api[_-]?key|authorization|auth[_-]?code|verification[_-]?code|recovery[_-]?code|password|passcode|otp|totp|pin)\b\s*[:=]\s*("[^"]*"|'[^']*'|(?:Bearer|Basic)\s+[^\s,;&]+|[^\s,;&]+)/gi;
43
+ 'pin',
44
+ 'auth_code',
45
+ 'verification_code',
46
+ 'recovery_code',
47
+ 'totp_code',
48
+ 'link_token',
49
+ 'challenge_signature',
50
+ 'owner_signature',
51
+ ]);
52
+ const COMPACT_CREDENTIAL_KEYS = new Set(
53
+ [...CREDENTIAL_KEYS].map((key) => key.replaceAll('_', '')),
54
+ );
55
+
56
+ const BEARER_TOKEN_PATTERN = /\b(Bearer|Basic)\s+[^\s,;&"'<>]+/giu;
57
+ const URL_PATTERN = /https?:\/\/[^\s"'<>]+/giu;
58
+ const RELATIVE_URL_PATTERN = /(^|[\s("'=])((?:\/{1,2}|\.\.?\/)[^\s"'<>]+)/gmu;
59
+ const CREDENTIAL_HEADER_PATTERN = /(^|[^"'A-Za-z0-9_-])(cookie|set-cookie|authorization|proxy-authorization)\s*:\s*[^\r\n]*/gimu;
60
+
61
+ /** Convert supported key spellings to one exact comparison form. */
62
+ export const normalizeAnalyticsKey = (key: string): string => key
63
+ .trim()
64
+ .replace(/([a-z\d])([A-Z])/g, '$1_$2')
65
+ .replace(/[^A-Za-z\d]+/g, '_')
66
+ .replace(/^_+|_+$/g, '')
67
+ .toLowerCase();
68
+
69
+ export const isCredentialKey = (key: string): boolean =>
70
+ CREDENTIAL_KEYS.has(normalizeAnalyticsKey(key))
71
+ || COMPACT_CREDENTIAL_KEYS.has(normalizeAnalyticsKey(key).replaceAll('_', ''));
72
+
73
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
74
+ const CREDENTIAL_KEY_SPELLINGS = new Set(
75
+ [...CREDENTIAL_KEYS].flatMap((key) => [
76
+ key,
77
+ key.replace(/_([a-z])/gu, (_, letter: string) => letter.toUpperCase()),
78
+ key.replaceAll('_', '-'),
79
+ key.replaceAll('_', ''),
80
+ ]),
81
+ );
82
+ const CREDENTIAL_ASSIGNMENT_PATTERN = new RegExp(
83
+ `(?<![A-Za-z0-9_-])(["']?)(${[...CREDENTIAL_KEY_SPELLINGS].map(escapeRegExp).join('|')})\\1(\\s*[:=]\\s*)(?:(['"])((?:\\\\.|(?!\\4)[\\s\\S])*)\\4|([^\\s,;&}{"']+))`,
84
+ 'giu',
85
+ );
86
+
87
+ /** Mask a credential assignment while retaining its label and surrounding text. */
88
+ const maskCredentialAssignments = (value: string): string => value.replace(
89
+ CREDENTIAL_ASSIGNMENT_PATTERN,
90
+ (
91
+ match,
92
+ quote: string,
93
+ key: string,
94
+ separator: string,
95
+ valueQuote?: string,
96
+ quotedValue?: string,
97
+ unquotedValue?: string,
98
+ ) => {
99
+ const rawValue = quotedValue ?? unquotedValue ?? '';
100
+ if (
101
+ rawValue === REDACTED_VALUE
102
+ || /^%5bredacted%5d$/iu.test(rawValue)
103
+ || /^(?:Bearer|Basic)\s+\[redacted\]$/iu.test(rawValue)
104
+ ) return match;
105
+ return `${quote}${key}${quote}${separator}${valueQuote ? `${valueQuote}${REDACTED_VALUE}${valueQuote}` : REDACTED_VALUE}`;
106
+ },
107
+ );
108
+
109
+ const maskCredentialHeaders = (value: string): string => value.replace(
110
+ CREDENTIAL_HEADER_PATTERN,
111
+ (match, prefix: string, key: string) => `${prefix}${key}: [redacted]`,
112
+ );
113
+
114
+ const hasAbsoluteScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value);
115
+ const hasProtocolRelativePrefix = (value: string): boolean => value.startsWith('//');
116
+
117
+ const redactPathCredentialValues = (pathname: string): string => {
118
+ const segments = pathname.split('/');
119
+ return segments.map((segment, index) => {
120
+ const previous = segments[index - 1] ?? '';
121
+ let decodedPrevious = previous;
122
+ try {
123
+ decodedPrevious = decodeURIComponent(previous);
124
+ } catch {
125
+ // Keep the encoded segment when it is not valid URI encoding.
126
+ }
127
+ return isCredentialKey(decodedPrevious) ? '[redacted]' : segment;
128
+ }).join('/');
129
+ };
46
130
 
131
+ const maskCredentialFragment = (fragment: string): string => {
132
+ if (!fragment) return fragment;
133
+ const rawFragment = fragment.slice(1);
134
+ if (rawFragment.includes('=')) {
135
+ const params = new URLSearchParams(rawFragment);
136
+ const entries = [...params.entries()];
137
+ if (entries.some(([key]) => isCredentialKey(key))) {
138
+ const next = new URLSearchParams();
139
+ entries.forEach(([key, value]) => next.append(key, isCredentialKey(key) ? REDACTED_VALUE : value));
140
+ return `#${next.toString()}`;
141
+ }
142
+ }
143
+ return maskCredentialAssignments(fragment);
144
+ };
145
+
146
+ /** Preserve useful URL data while removing only known credential locations. */
147
+ export const sanitizeAnalyticsUrl = (raw: string): string => {
148
+ if (!raw) return '';
149
+
150
+ const absolute = hasAbsoluteScheme(raw);
151
+ const protocolRelative = hasProtocolRelativePrefix(raw);
152
+ const base = 'https://analytics.local';
153
+
154
+ try {
155
+ const url = new URL(raw, base);
156
+ url.username = '';
157
+ url.password = '';
158
+ url.pathname = redactPathCredentialValues(url.pathname);
159
+
160
+ const entries = [...url.searchParams.entries()];
161
+ url.search = '';
162
+ entries.forEach(([key, value]) => {
163
+ url.searchParams.append(key, isCredentialKey(key) ? '[redacted]' : value);
164
+ });
165
+ url.hash = maskCredentialFragment(url.hash);
166
+
167
+ const serialized = url.toString();
168
+ if (absolute) return serialized;
169
+ if (protocolRelative) return `//${url.host}${url.pathname}${url.search}${url.hash}`;
170
+
171
+ const originalHadPath = raw.startsWith('/') || (!raw.startsWith('?') && !raw.startsWith('#'));
172
+ const relativePrefix = raw.match(/^(?:\.\.?\/)+/u)?.[0] ?? '';
173
+ const relativePath = originalHadPath ? url.pathname.replace(/^\//u, '') : '';
174
+ return `${raw.startsWith('/') ? url.pathname : `${relativePrefix}${relativePath}`}${url.search}${url.hash}`;
175
+ } catch {
176
+ return maskCredentialAssignments(raw);
177
+ }
178
+ };
179
+
180
+ /**
181
+ * Sanitize free-form diagnostics without treating ordinary user/business data
182
+ * as sensitive. Embedded URLs are processed first so useful URL context stays.
183
+ */
47
184
  export const sanitizeAnalyticsText = (
48
185
  raw: unknown,
49
186
  maxLength = MAX_STRING_LENGTH,
50
187
  ): string => {
51
- if (raw == null) {
52
- return '';
53
- }
188
+ if (raw == null) return '';
54
189
 
55
190
  let sanitized = String(raw)
56
- .replace(SECRET_ASSIGNMENT_PATTERN, '$1=[redacted]')
57
- .replace(BEARER_TOKEN_PATTERN, '$1 [redacted]')
58
- .replace(JWT_LIKE_PATTERN, REDACTED_VALUE)
59
- .replace(EMAIL_PATTERN, REDACTED_VALUE)
60
- .replace(LONG_DIGIT_PATTERN, REDACTED_VALUE);
191
+ .replace(URL_PATTERN, (url) => sanitizeAnalyticsUrl(url))
192
+ .replace(RELATIVE_URL_PATTERN, (match, prefix: string, url: string) => (
193
+ `${prefix}${sanitizeAnalyticsUrl(url)}`
194
+ ))
195
+ .replace(BEARER_TOKEN_PATTERN, '$1 [redacted]');
196
+ sanitized = maskCredentialHeaders(sanitized);
197
+ sanitized = maskCredentialAssignments(sanitized);
61
198
 
62
199
  if (sanitized.length > maxLength) {
63
- sanitized = `${sanitized.slice(0, maxLength - 3)}...`;
200
+ sanitized = `${sanitized.slice(0, Math.max(0, maxLength - 3))}...`;
64
201
  }
65
202
 
66
203
  return sanitized;
67
204
  };
68
205
 
69
- const isSensitiveKey = (key: string): boolean => {
70
- const normalized = key.trim().toLowerCase();
71
-
72
- if (!normalized) {
73
- return false;
74
- }
75
-
76
- if (SENSITIVE_EXACT_KEYS.has(normalized)) {
77
- return true;
78
- }
79
-
80
- if (SENSITIVE_KEY_SUBSTRINGS.some((fragment) => normalized.includes(fragment))) {
81
- return true;
82
- }
83
-
84
- if (
85
- normalized === 'token'
86
- || normalized.endsWith('token')
87
- || normalized.startsWith('token')
88
- || normalized.endsWith('_token')
89
- || normalized.startsWith('token_')
90
- ) {
91
- return true;
92
- }
93
-
94
- return (
95
- normalized === 'otp'
96
- || normalized === 'pin'
97
- || normalized.endsWith('_otp')
98
- || normalized.endsWith('_pin')
99
- || normalized.endsWith('otp')
100
- || normalized.endsWith('pin')
101
- || (
102
- (normalized.endsWith('_code') || normalized.endsWith('code'))
103
- && (
104
- normalized.includes('otp')
105
- || normalized.includes('totp')
106
- || normalized.includes('pin')
107
- || normalized.includes('verification')
108
- || normalized.includes('recovery')
109
- )
110
- )
111
- );
112
- };
113
-
114
206
  const isBlobSupported = () => typeof Blob !== 'undefined';
115
207
  const isFormDataSupported = () => typeof FormData !== 'undefined';
208
+ const isUrlSearchParamsSupported = () => typeof URLSearchParams !== 'undefined';
116
209
  const isFileSupported = () => typeof File !== 'undefined';
117
210
 
118
211
  const normalizeBinaryValue = (value: Blob): string => {
119
- if (isFileSupported() && value instanceof File && value.name.trim()) {
120
- return '[binary]';
121
- }
122
-
212
+ if (isFileSupported() && value instanceof File && value.name.trim()) return '[binary]';
123
213
  return '[binary]';
124
214
  };
125
215
 
126
- const normalizeObjectValue = (
127
- value: Record<string, unknown>,
128
- seen: WeakSet<object>,
129
- ): AnalyticsBody => {
130
- seen.add(value);
131
-
132
- const normalized = Object.entries(value).reduce<AnalyticsBody>((result, [key, item]) => {
133
- result[key] = isSensitiveKey(key)
134
- ? REDACTED_VALUE
135
- : normalizeBodyValue(item, seen);
136
- return result;
137
- }, {});
138
-
139
- seen.delete(value);
140
- return normalized;
216
+ const isAuthCodeRecord = (value: Record<string, unknown>): boolean => {
217
+ const hasMethodCode = Object.entries(value).some(([key, item]) =>
218
+ normalizeAnalyticsKey(key) === 'method' && item === 'code');
219
+ const hasCsrfToken = Object.keys(value).some((key) =>
220
+ normalizeAnalyticsKey(key) === 'csrf_token');
221
+ return hasMethodCode && hasCsrfToken;
141
222
  };
142
223
 
143
- const appendFormDataEntry = (
144
- target: AnalyticsBody,
145
- key: string,
146
- value: unknown,
147
- ) => {
224
+ const appendRepeatedValue = (target: AnalyticsBody, key: string, value: unknown): void => {
148
225
  const currentValue = target[key];
149
-
150
226
  if (typeof currentValue === 'undefined') {
151
227
  target[key] = value;
152
- return;
228
+ } else {
229
+ target[key] = Array.isArray(currentValue)
230
+ ? [...currentValue, value]
231
+ : [currentValue, value];
153
232
  }
154
-
155
- target[key] = Array.isArray(currentValue)
156
- ? [...currentValue, value]
157
- : [currentValue, value];
158
233
  };
159
234
 
160
- const normalizeFormData = (
161
- formData: FormData,
162
- seen: WeakSet<object>,
163
- ): AnalyticsBody => {
235
+ const normalizeFormData = (formData: FormData, seen: WeakSet<object>): AnalyticsBody => {
236
+ seen.add(formData);
237
+ const entries = [...formData.entries()];
238
+ const hasMethodCode = entries.some(([key, value]) => normalizeAnalyticsKey(key) === 'method' && value === 'code');
239
+ const hasCsrfToken = entries.some(([key]) => normalizeAnalyticsKey(key) === 'csrf_token');
164
240
  const normalized: AnalyticsBody = {};
165
241
 
166
- for (const [key, value] of formData.entries()) {
167
- appendFormDataEntry(
242
+ entries.forEach(([key, value]) => {
243
+ const redactCode = normalizeAnalyticsKey(key) === 'code' && hasMethodCode && hasCsrfToken;
244
+ appendRepeatedValue(
168
245
  normalized,
169
246
  key,
170
- isSensitiveKey(key) ? REDACTED_VALUE : normalizeBodyValue(value, seen),
247
+ isCredentialKey(key) || redactCode ? '[redacted]' : normalizeBodyValue(value, seen),
171
248
  );
172
- }
249
+ });
173
250
 
251
+ seen.delete(formData);
174
252
  return normalized;
175
253
  };
176
254
 
177
- const normalizeBodyValue = (
178
- value: unknown,
179
- seen: WeakSet<object>,
180
- ): unknown => {
181
- if (value == null) {
182
- return null;
183
- }
184
-
185
- if (typeof value === 'string') {
186
- return sanitizeAnalyticsText(value);
187
- }
188
-
189
- if (typeof value === 'boolean') {
190
- return value;
191
- }
192
-
193
- if (typeof value === 'number') {
194
- return Number.isFinite(value) ? value : null;
195
- }
196
-
197
- if (typeof value === 'bigint') {
198
- return value.toString();
199
- }
255
+ const normalizeUrlSearchParams = (params: URLSearchParams, seen: WeakSet<object>): AnalyticsBody => {
256
+ seen.add(params);
257
+ const entries = [...params.entries()];
258
+ const hasMethodCode = entries.some(([key, value]) => normalizeAnalyticsKey(key) === 'method' && value === 'code');
259
+ const hasCsrfToken = entries.some(([key]) => normalizeAnalyticsKey(key) === 'csrf_token');
260
+ const normalized: AnalyticsBody = {};
200
261
 
201
- if (value instanceof Date) {
202
- return value.toISOString();
203
- }
262
+ entries.forEach(([key, value]) => {
263
+ const redactCode = normalizeAnalyticsKey(key) === 'code' && hasMethodCode && hasCsrfToken;
264
+ appendRepeatedValue(
265
+ normalized,
266
+ key,
267
+ isCredentialKey(key) || redactCode ? '[redacted]' : normalizeBodyValue(value, seen),
268
+ );
269
+ });
204
270
 
205
- if (isBlobSupported() && value instanceof Blob) {
206
- return normalizeBinaryValue(value);
207
- }
271
+ seen.delete(params);
272
+ return normalized;
273
+ };
208
274
 
209
- if (Array.isArray(value)) {
210
- return value.map((item) => normalizeBodyValue(item, seen));
211
- }
275
+ const normalizeObjectValue = (
276
+ value: Record<string, unknown>,
277
+ seen: WeakSet<object>,
278
+ ): AnalyticsBody => {
279
+ seen.add(value);
280
+ const redactCode = isAuthCodeRecord(value);
281
+ const normalized = Object.entries(value).reduce<AnalyticsBody>((result, [key, item]) => {
282
+ result[key] = isCredentialKey(key) || (redactCode && normalizeAnalyticsKey(key) === 'code')
283
+ ? '[redacted]'
284
+ : normalizeBodyValue(item, seen);
285
+ return result;
286
+ }, {});
287
+ seen.delete(value);
288
+ return normalized;
289
+ };
212
290
 
213
- if (isFormDataSupported() && value instanceof FormData) {
214
- return normalizeFormData(value, seen);
215
- }
291
+ const normalizeBodyValue = (value: unknown, seen: WeakSet<object>): unknown => {
292
+ if (value == null) return null;
293
+ if (typeof value === 'string') return sanitizeAnalyticsText(value);
294
+ if (typeof value === 'boolean') return value;
295
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
296
+ if (typeof value === 'bigint') return value.toString();
297
+ if (value instanceof Date) return value.toISOString();
298
+ if (isBlobSupported() && value instanceof Blob) return normalizeBinaryValue(value);
216
299
 
217
300
  if (typeof value === 'object') {
218
- if (seen.has(value as object)) {
219
- return '[circular]';
301
+ if (seen.has(value as object)) return '[circular]';
302
+ if (Array.isArray(value)) {
303
+ seen.add(value);
304
+ const normalized = value.map((item) => normalizeBodyValue(item, seen));
305
+ seen.delete(value);
306
+ return normalized;
307
+ }
308
+ if (isFormDataSupported() && value instanceof FormData) return normalizeFormData(value, seen);
309
+ if (isUrlSearchParamsSupported() && value instanceof URLSearchParams) {
310
+ return normalizeUrlSearchParams(value, seen);
220
311
  }
221
-
222
312
  return normalizeObjectValue(value as Record<string, unknown>, seen);
223
313
  }
224
314
 
@@ -226,16 +316,11 @@ const normalizeBodyValue = (
226
316
  };
227
317
 
228
318
  export const normalizeAnalyticsBody = (value: unknown): AnalyticsBody => {
229
- if (value == null) {
230
- return {};
231
- }
319
+ if (value == null) return {};
232
320
 
233
321
  if (typeof value === 'string') {
234
322
  const trimmedValue = value.trim();
235
- if (!trimmedValue) {
236
- return {};
237
- }
238
-
323
+ if (!trimmedValue) return {};
239
324
  try {
240
325
  return normalizeAnalyticsBody(JSON.parse(trimmedValue));
241
326
  } catch {
@@ -243,28 +328,17 @@ export const normalizeAnalyticsBody = (value: unknown): AnalyticsBody => {
243
328
  }
244
329
  }
245
330
 
246
- if (isFormDataSupported() && value instanceof FormData) {
247
- return normalizeFormData(value, new WeakSet<object>());
331
+ const seen = new WeakSet<object>();
332
+ if (isFormDataSupported() && value instanceof FormData) return normalizeFormData(value, seen);
333
+ if (isUrlSearchParamsSupported() && value instanceof URLSearchParams) {
334
+ return normalizeUrlSearchParams(value, seen);
248
335
  }
249
-
250
- if (typeof value !== 'object' || Array.isArray(value)) {
251
- return {};
252
- }
253
-
254
- return normalizeObjectValue(value as Record<string, unknown>, new WeakSet<object>());
336
+ if (typeof value !== 'object' || Array.isArray(value)) return {};
337
+ return normalizeObjectValue(value as Record<string, unknown>, seen);
255
338
  };
256
339
 
340
+ /** Normalize every method consistently; method remains part of the public API. */
257
341
  export const normalizeAnalyticsBodyForMethod = (
258
- method: string | undefined,
342
+ _method: string | undefined,
259
343
  value: unknown,
260
- ): AnalyticsBody => {
261
- if (!method || !MUTATION_METHODS.has(method.toUpperCase())) {
262
- return {};
263
- }
264
-
265
- if (value == null || (typeof value === 'string' && value.trim() === '')) {
266
- return {};
267
- }
268
-
269
- return { ...REDACTED_BODY };
270
- };
344
+ ): AnalyticsBody => normalizeAnalyticsBody(value);