@friggframework/core 2.0.0-next.111 → 2.0.0-next.113

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 (74) hide show
  1. package/.eslintrc.json +18 -1
  2. package/CLAUDE.md +101 -13
  3. package/README.md +19 -22
  4. package/core/CLAUDE.md +28 -13
  5. package/core/Worker.js +32 -37
  6. package/core/create-handler.js +97 -197
  7. package/core/index.js +8 -1
  8. package/core/invocation-scope.js +225 -0
  9. package/core/secrets-to-env.js +7 -6
  10. package/database/config.js +0 -7
  11. package/database/documentdb-encryption-service.js +6 -2
  12. package/database/documentdb-utils.js +2 -1
  13. package/database/encryption/README.md +25 -1
  14. package/database/encryption/encryption-schema-registry.js +3 -0
  15. package/database/encryption/integration-mapping-encryption.js +97 -0
  16. package/database/encryption/logger.js +5 -2
  17. package/database/encryption/prisma-encryption-extension.js +20 -9
  18. package/errors/fetch-error.js +48 -65
  19. package/handlers/app-definition-loader.js +38 -2
  20. package/handlers/app-handler-helpers.js +18 -4
  21. package/handlers/backend-utils.js +10 -13
  22. package/handlers/database-migration-handler.js +10 -1
  23. package/handlers/integration-event-dispatcher.js +2 -2
  24. package/handlers/routers/integration-webhook-routers.js +13 -6
  25. package/handlers/routers/websocket.js +27 -7
  26. package/handlers/workers/db-migration.js +12 -1
  27. package/handlers/workers/dlq-processor.js +64 -38
  28. package/index.js +19 -1
  29. package/integrations/integration-base.js +56 -38
  30. package/integrations/integration-router.js +21 -20
  31. package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
  32. package/integrations/repositories/integration-mapping-query.js +193 -0
  33. package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
  34. package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
  35. package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
  36. package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
  37. package/integrations/use-cases/delete-integration-for-user.js +24 -13
  38. package/jest.config.js +2 -0
  39. package/logs/.eslintrc.json +26 -1
  40. package/logs/__fixtures__/events.js +82 -0
  41. package/logs/__fixtures__/matchers.js +45 -0
  42. package/logs/__fixtures__/secrets.js +59 -0
  43. package/logs/__fixtures__/vectors.js +329 -0
  44. package/logs/__fixtures__/with-env.js +45 -0
  45. package/logs/context.js +109 -0
  46. package/logs/contract-keys.js +67 -0
  47. package/logs/debug-shims.js +46 -0
  48. package/logs/denied-keys.js +21 -0
  49. package/logs/index.js +14 -3
  50. package/logs/jest-logger-setup.js +25 -0
  51. package/logs/levels.js +71 -0
  52. package/logs/logger-runtime.js +137 -0
  53. package/logs/logger.js +134 -42
  54. package/logs/record.js +276 -0
  55. package/logs/redact.js +328 -0
  56. package/logs/serialize.js +329 -0
  57. package/logs/sinks.js +93 -0
  58. package/logs/summarize-event.js +163 -0
  59. package/modules/module.js +32 -19
  60. package/modules/requester/oauth-2.js +28 -22
  61. package/modules/requester/requester.js +69 -6
  62. package/package.json +5 -6
  63. package/queues/queue-delivery.js +41 -0
  64. package/queues/queuer-util.js +1 -15
  65. package/telemetry/bind-telemetry-context.js +4 -0
  66. package/telemetry/instrument-handler.js +19 -2
  67. package/telemetry/no-op-telemetry.js +4 -0
  68. package/telemetry/otel-telemetry.js +25 -6
  69. package/telemetry/telemetry-context.js +19 -8
  70. package/telemetry/telemetry-runtime.js +17 -0
  71. package/telemetry/telemetry-service.js +1 -0
  72. package/types/errors/index.d.ts +24 -10
  73. package/types/logs/index.d.ts +61 -2
  74. package/user/repositories/user-repository-documentdb.js +10 -42
package/logs/record.js ADDED
@@ -0,0 +1,276 @@
1
+ const { LEVELS } = require('./levels');
2
+ const { serializeValue, serializeError, isError } = require('./serialize');
3
+ const { isPlainObject } = require('./context');
4
+ const {
5
+ RESOURCE_KEYS,
6
+ DROPPED_KEYS_FIELD,
7
+ OWNED_KEYS,
8
+ RESERVED_KEYS,
9
+ PAYLOAD_KEYS,
10
+ } = require('./contract-keys');
11
+
12
+ const MAX_RECORD_BYTES = 16 * 1024;
13
+ const DEFAULT_LOGGER_NAME = 'frigg.unknown';
14
+
15
+ function deepFreeze(value) {
16
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
17
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
18
+ Object.freeze(value);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ function readFields(source) {
24
+ const result = {};
25
+ if (!source || typeof source !== 'object') return result;
26
+ let keys;
27
+ try {
28
+ keys = Object.keys(source);
29
+ } catch {
30
+ return result;
31
+ }
32
+ for (const key of keys) {
33
+ try {
34
+ result[key] = source[key];
35
+ } catch {
36
+ result[key] = '[Getter threw]';
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+
42
+ // Returns the message text. An Error message is serialized once; when the
43
+ // call site has no `error`, that result becomes the record's `error`.
44
+ function buildMessage(message, callSite) {
45
+ if (typeof message === 'string') {
46
+ return { text: serializeValue(message) };
47
+ }
48
+ if (message === undefined || message === null) return { text: '' };
49
+ if (isError(message)) {
50
+ const error = serializeError(message);
51
+ const text = typeof error?.message === 'string' ? error.message : '';
52
+ if (callSite.error !== undefined) return { text };
53
+ callSite.error = message;
54
+ return { text, error };
55
+ }
56
+ if (callSite.value === undefined) callSite.value = message;
57
+ return { text: '[non-string message]' };
58
+ }
59
+
60
+ const INVOCATION_KEY = 'invocation';
61
+
62
+ // A boundary adds request detail to the scope's invocation; scope keys win.
63
+ function extendInvocation(scoped, detail) {
64
+ const merged = { ...scoped };
65
+ for (const key of Object.keys(detail)) {
66
+ if (!(key in merged)) merged[key] = detail[key];
67
+ }
68
+ return merged;
69
+ }
70
+
71
+ function traceFields(spanContext) {
72
+ if (
73
+ !spanContext ||
74
+ typeof spanContext.traceId !== 'string' ||
75
+ typeof spanContext.spanId !== 'string'
76
+ ) {
77
+ return {};
78
+ }
79
+ const flags = Number(spanContext.traceFlags) || 0;
80
+ return {
81
+ trace_id: spanContext.traceId,
82
+ span_id: spanContext.spanId,
83
+ trace_flags: (flags & 0xff).toString(16).padStart(2, '0'),
84
+ };
85
+ }
86
+
87
+ function sameValue(a, b) {
88
+ if (a === b) return true;
89
+ return (
90
+ typeof a === 'object' &&
91
+ typeof b === 'object' &&
92
+ JSON.stringify(a) === JSON.stringify(b)
93
+ );
94
+ }
95
+
96
+ function byteLength(record) {
97
+ return Buffer.byteLength(JSON.stringify(record));
98
+ }
99
+
100
+ function isDroppedKey(key, dropPayloads) {
101
+ return (
102
+ OWNED_KEYS.has(key) ||
103
+ RESERVED_KEYS.has(key) ||
104
+ (dropPayloads && PAYLOAD_KEYS.has(key))
105
+ );
106
+ }
107
+
108
+ // Filters and serializes one source. `error` is serialized on its own, once
109
+ // (or taken from `preSerializedError`), and never walked by serializeValue.
110
+ function prepareSource(source, dropPayloads, preSerializedError) {
111
+ const accepted = {};
112
+ const keys = [];
113
+ const dropped = [];
114
+ let rawError;
115
+ for (const [key, value] of Object.entries(source)) {
116
+ if (value === undefined || value === null) continue;
117
+ if (isDroppedKey(key, dropPayloads)) {
118
+ dropped.push(key);
119
+ continue;
120
+ }
121
+ keys.push(key);
122
+ if (key === 'error') rawError = value;
123
+ else accepted[key] = value;
124
+ }
125
+ // One pass over the object, so key rules (denied keys, digests,
126
+ // headers, OAuth shapes) apply to top-level fields too.
127
+ const safe = serializeValue(accepted);
128
+ const entries = [];
129
+ for (const key of keys) {
130
+ const value =
131
+ key === 'error'
132
+ ? preSerializedError ?? serializeError(rawError)
133
+ : safe?.[key];
134
+ if (value !== undefined && value !== null) entries.push([key, value]);
135
+ }
136
+ return { entries, dropped };
137
+ }
138
+
139
+ // The scope object is frozen and shared by every record of one scope.
140
+ const preparedScopes = new WeakMap();
141
+
142
+ function prepareScope(scope, dropPayloads) {
143
+ if (!scope || typeof scope !== 'object') return { entries: [], dropped: [] };
144
+ let byLevel = preparedScopes.get(scope);
145
+ if (!byLevel) {
146
+ byLevel = {};
147
+ preparedScopes.set(scope, byLevel);
148
+ }
149
+ const slot = dropPayloads ? 'info' : 'debug';
150
+ if (!byLevel[slot]) byLevel[slot] = prepareSource(scope, dropPayloads);
151
+ return byLevel[slot];
152
+ }
153
+
154
+ function buildRecord({
155
+ level,
156
+ logger,
157
+ message,
158
+ fields,
159
+ bindings,
160
+ scope,
161
+ spanContext,
162
+ resource = {},
163
+ now = new Date(),
164
+ } = {}) {
165
+ const dropPayloads = LEVELS[level] >= LEVELS.INFO;
166
+ // log.error('msg', err): own props of an Error (axios config, request) must not become fields.
167
+ const callSite = readFields(isError(fields) ? { error: fields } : fields);
168
+ const built = buildMessage(message, callSite);
169
+ const record = {
170
+ timestamp: now.toISOString(),
171
+ level,
172
+ message: built.text,
173
+ logger: typeof logger === 'string' && logger ? logger : DEFAULT_LOGGER_NAME,
174
+ };
175
+ for (const key of RESOURCE_KEYS) {
176
+ if (typeof resource[key] === 'string' && resource[key]) {
177
+ record[key] = resource[key];
178
+ }
179
+ }
180
+ Object.assign(record, traceFields(spanContext));
181
+
182
+ const dropped = new Set();
183
+ const callSiteKeys = [];
184
+ // Call-site keys merged into the scope invocation; the size cap must reach them too.
185
+ const invocationDetailKeys = [];
186
+ const place = ({ entries, dropped: droppedHere }, isCallSite) => {
187
+ for (const key of droppedHere) dropped.add(key);
188
+ for (const [key, value] of entries) {
189
+ if (Object.prototype.hasOwnProperty.call(record, key)) {
190
+ if (
191
+ key === INVOCATION_KEY &&
192
+ isPlainObject(record[key]) &&
193
+ isPlainObject(value)
194
+ ) {
195
+ const merged = extendInvocation(record[key], value);
196
+ if (isCallSite) {
197
+ for (const detailKey of Object.keys(value)) {
198
+ if (!(detailKey in record[key])) invocationDetailKeys.push(detailKey);
199
+ }
200
+ }
201
+ record[key] = merged;
202
+ } else if (!sameValue(record[key], value)) {
203
+ // A repeat of the winning value is no conflict.
204
+ dropped.add(key);
205
+ }
206
+ continue;
207
+ }
208
+ record[key] = value;
209
+ if (isCallSite) callSiteKeys.push(key);
210
+ }
211
+ };
212
+ place(prepareScope(scope, dropPayloads), false);
213
+ place(prepareSource(bindings || {}, dropPayloads), false);
214
+ place(prepareSource(callSite, dropPayloads, built.error), true);
215
+
216
+ const setDroppedKeys = () => {
217
+ delete record[DROPPED_KEYS_FIELD];
218
+ if (dropped.size) record[DROPPED_KEYS_FIELD] = [...dropped];
219
+ };
220
+ setDroppedKeys();
221
+ let size = byteLength(record);
222
+
223
+ if (size > MAX_RECORD_BYTES) {
224
+ const candidates = [
225
+ ...callSiteKeys
226
+ .filter((key) => key !== 'error')
227
+ .map((key) => ({
228
+ name: key,
229
+ value: record[key],
230
+ remove: () => delete record[key],
231
+ })),
232
+ ...invocationDetailKeys.map((key) => ({
233
+ name: `${INVOCATION_KEY}.${key}`,
234
+ value: record[INVOCATION_KEY][key],
235
+ remove: () => {
236
+ const rest = { ...record[INVOCATION_KEY] };
237
+ delete rest[key];
238
+ record[INVOCATION_KEY] = rest;
239
+ },
240
+ })),
241
+ ];
242
+ const bySize = candidates
243
+ .map((candidate) => [candidate, Buffer.byteLength(JSON.stringify(candidate.value))])
244
+ .sort((a, b) => b[1] - a[1]);
245
+ for (const [candidate] of bySize) {
246
+ candidate.remove();
247
+ dropped.add(candidate.name);
248
+ setDroppedKeys();
249
+ size = byteLength(record);
250
+ if (size <= MAX_RECORD_BYTES) break;
251
+ }
252
+ }
253
+ for (const part of ['stack', 'cause']) {
254
+ if (
255
+ size > MAX_RECORD_BYTES &&
256
+ record.error &&
257
+ typeof record.error === 'object' &&
258
+ record.error[part] !== undefined
259
+ ) {
260
+ const { [part]: _removed, ...rest } = record.error;
261
+ record.error = rest;
262
+ size = byteLength(record);
263
+ }
264
+ }
265
+
266
+ return deepFreeze(record);
267
+ }
268
+
269
+ module.exports = {
270
+ buildRecord,
271
+ deepFreeze,
272
+ MAX_RECORD_BYTES,
273
+ RESERVED_KEYS,
274
+ PAYLOAD_KEYS,
275
+ DEFAULT_LOGGER_NAME,
276
+ };
package/logs/redact.js ADDED
@@ -0,0 +1,328 @@
1
+ const { CONTRACT_KEYS } = require('./contract-keys');
2
+
3
+ const DENIED_SUFFIXES = [
4
+ 'token',
5
+ 'secret',
6
+ 'password',
7
+ 'apikey',
8
+ 'privatekey',
9
+ 'signature',
10
+ 'authorization',
11
+ 'cookie',
12
+ 'pwd',
13
+ 'passphrase',
14
+ 'credentials',
15
+ 'sessionid',
16
+ ];
17
+
18
+ // Core encryption registry leaves and exact names the suffix rule does not
19
+ // cover. `header` is Node's raw `_header` request text. `domain` is not
20
+ // seeded (too common); a module can still register it as a credential field.
21
+ const deniedKeys = new Set([
22
+ 'hashword',
23
+ 'cookies',
24
+ 'apikeyvalue',
25
+ 'mapping',
26
+ 'auth',
27
+ 'header',
28
+ ]);
29
+
30
+ // Record-contract fields. A credential field with one of these names must not
31
+ // blank the field in every record.
32
+ const PROTECTED_KEYS = new Set(CONTRACT_KEYS.map(normalizeKey));
33
+
34
+ const DENIED_SUFFIX = new RegExp(`(?:${DENIED_SUFFIXES.join('|')})$`);
35
+ const PAIR_KEYS = new Set(['code', 'codeverifier']);
36
+ const DIGEST_KEY = /(sha(1|256)|hash|digest|checksum)$/;
37
+
38
+ const MAX_SCRUB_INPUT = 65536;
39
+
40
+ const URL_IN_TEXT = /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"'<>`]+/g;
41
+ const URL_TRAILING_PUNCT = /[.,;:!?)\]}]+$/;
42
+ const ABSOLUTE_URL = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
43
+ const RAW_ABSOLUTE_PATH = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]*([^?#]*)/;
44
+ const RAW_RELATIVE_PATH = /^[^?#]*/;
45
+ const PATH_TOKEN_SEGMENT = /^[A-Za-z0-9_.~-]{20,}$/;
46
+ const PREFIXED_TOKEN =
47
+ /(?<![A-Za-z0-9_-])(?:(?:sk|rk)_(?:live|test)_|whsec_|xox[abprs]-|gh[opsu]_|github_pat_|shp(?:at|ss)_|glpat-|npm_)[A-Za-z0-9_-]{16,}|\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g;
48
+ const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g;
49
+ const AUTH_SCHEME = /\b(Bearer|bearer|BEARER|Basic|basic|BASIC|Token|token|TOKEN)(\s+)([A-Za-z0-9._~+/=-]{8,})/g;
50
+ const AUTH_SCHEME_WORD = /^(bearer|basic|token)$/i;
51
+ const COLON_PAIR = /(?<![A-Za-z0-9_-])([A-Za-z_][A-Za-z0-9_.-]{0,99})(["']?)(\s*:\s*)(["']?)([^\s"',;{}]+)/g;
52
+ const COOKIE_LINE = /(?<![A-Za-z0-9_-])((?:set-)?cookie)(\s*:\s*)([^\r\n]+)/gi;
53
+ const CODE_PAIR = /(?<![A-Za-z0-9_.-])code=/;
54
+ const JSON_PAIR = /"([^"\\]{1,100})"(\s*:\s*)"((?:[^"\\]|\\.)*)"/g;
55
+ const TEXT_PAIR = /(?<![A-Za-z0-9_.-])([A-Za-z0-9_.-]{1,100})=([^\s&"'<>,;)]+)/g;
56
+ const HEX_RUN = /(?<![A-Za-z0-9])[0-9a-fA-F]{40,}(?![A-Za-z0-9])/g;
57
+ const BASE64_RUN = /(?<![A-Za-z0-9+/=_-])[A-Za-z0-9+/_-]{40,}={0,2}(?![A-Za-z0-9+/=_-])/g;
58
+ const FORM_TEXT = /^[A-Za-z0-9_.%[\]-]+=[^&\s]*(?:&[A-Za-z0-9_.%[\]-]+=[^&\s]*)+$/;
59
+ const PATH_SEGMENT = /^[A-Za-z0-9_-]*$/;
60
+
61
+ function redacted(value) {
62
+ return `[REDACTED:${value.length}]`;
63
+ }
64
+
65
+ function normalizeKey(key) {
66
+ if (typeof key !== 'string') {
67
+ if (typeof key !== 'number') return '';
68
+ key = String(key);
69
+ }
70
+ return key.toLowerCase().replace(/[-_\s]/g, '');
71
+ }
72
+
73
+ // The *Normalized variants take a normalizeKey() result, so a walk
74
+ // normalizes each key once.
75
+ function isDeniedNormalized(normalized) {
76
+ if (!normalized || PROTECTED_KEYS.has(normalized)) return false;
77
+ return deniedKeys.has(normalized) || DENIED_SUFFIX.test(normalized);
78
+ }
79
+
80
+ function isDeniedKey(key) {
81
+ return isDeniedNormalized(normalizeKey(key));
82
+ }
83
+
84
+ function isDigestNormalized(normalized) {
85
+ return DIGEST_KEY.test(normalized);
86
+ }
87
+
88
+ function isDigestKey(key) {
89
+ return isDigestNormalized(normalizeKey(key));
90
+ }
91
+
92
+ function isPairKey(key, { withState = false } = {}) {
93
+ const normalized = normalizeKey(key);
94
+ return (
95
+ isDeniedKey(key) ||
96
+ PAIR_KEYS.has(normalized) ||
97
+ (withState && normalized === 'state')
98
+ );
99
+ }
100
+
101
+ // Returns the leaves it skipped as record-contract keys, so a caller can warn.
102
+ function addDeniedKeys(keys) {
103
+ const ignored = [];
104
+ if (!Array.isArray(keys)) return ignored;
105
+ for (const key of keys) {
106
+ if (typeof key !== 'string') continue;
107
+ const rawLeaf = key.split('.').pop();
108
+ const leaf = normalizeKey(rawLeaf);
109
+ if (!leaf) continue;
110
+ if (PROTECTED_KEYS.has(leaf)) ignored.push(rawLeaf);
111
+ else deniedKeys.add(leaf);
112
+ }
113
+ return ignored;
114
+ }
115
+
116
+ function hasMixedClasses(run) {
117
+ return /[A-Z]/.test(run) && /[a-z]/.test(run) && /[0-9]/.test(run);
118
+ }
119
+
120
+ function isPathLike(run) {
121
+ if (!run.startsWith('/')) return false;
122
+ const segments = run.split('/');
123
+ if (segments.length < 3) return false;
124
+ return segments.every(
125
+ (segment) => segment.length < 40 && PATH_SEGMENT.test(segment)
126
+ );
127
+ }
128
+
129
+ function isRedactedValue(value) {
130
+ return value === 'REDACTED' || value.startsWith('[REDACTED');
131
+ }
132
+
133
+ function scrubTokens(text, { allowHex = false } = {}) {
134
+ let out = text.replace(PREFIXED_TOKEN, redacted);
135
+ out = out.replace(JWT, redacted);
136
+ out = out.replace(AUTH_SCHEME, (match, scheme, space, credential) => {
137
+ const classes = [/[A-Z]/, /[a-z]/, /[0-9]/, /[._~+/=-]/].filter((re) =>
138
+ re.test(credential)
139
+ ).length;
140
+ return classes >= 2 ? `${scheme}${space}${redacted(credential)}` : match;
141
+ });
142
+ out = out.replace(JSON_PAIR, (match, key, colon, value) =>
143
+ isDeniedKey(key) || normalizeKey(key) === 'codeverifier'
144
+ ? `"${key}"${colon}"${redacted(value)}"`
145
+ : match
146
+ );
147
+ out = out.replace(COOKIE_LINE, (match, key, colon, value) =>
148
+ isRedactedValue(value) ? match : `${key}${colon}${redacted(value)}`
149
+ );
150
+ out = out.replace(COLON_PAIR, (match, key, close, colon, open, value) =>
151
+ isDeniedKey(key) &&
152
+ !isRedactedValue(value) &&
153
+ !AUTH_SCHEME_WORD.test(value)
154
+ ? `${key}${close}${colon}${open}${redacted(value)}`
155
+ : match
156
+ );
157
+ const withState = CODE_PAIR.test(out);
158
+ out = out.replace(TEXT_PAIR, (match, key, value) =>
159
+ isPairKey(key, { withState }) && !isRedactedValue(value)
160
+ ? `${key}=${redacted(value)}`
161
+ : match
162
+ );
163
+ if (!allowHex) out = out.replace(HEX_RUN, redacted);
164
+ out = out.replace(BASE64_RUN, (run) =>
165
+ hasMixedClasses(run) && !isPathLike(run)
166
+ ? redacted(run)
167
+ : run
168
+ );
169
+ return out;
170
+ }
171
+
172
+ function queryKeys(search) {
173
+ return search
174
+ .replace(/^[?#]/, '')
175
+ .split('&')
176
+ .map((pair) => pair.split('=')[0])
177
+ .filter(Boolean);
178
+ }
179
+
180
+ function redactParams(search, prefix) {
181
+ const keys = queryKeys(search);
182
+ if (keys.length === 0) return '';
183
+ return `${prefix}${keys.map((key) => `${key}=REDACTED`).join('&')}`;
184
+ }
185
+
186
+ function redactHash(hash) {
187
+ if (!hash) return '';
188
+ if (hash.includes('=')) return redactParams(hash, '#');
189
+ return scrubTokens(hash);
190
+ }
191
+
192
+ // A long segment mixing upper case, lower case and digits is an opaque
193
+ // token (webhook secrets live in paths); hex ids and UUIDs lack one class.
194
+ function redactPathSegment(segment) {
195
+ if (PATH_TOKEN_SEGMENT.test(segment) && hasMixedClasses(segment)) {
196
+ return redacted(segment);
197
+ }
198
+ return scrubTokens(segment);
199
+ }
200
+
201
+ // The raw path, not URL#pathname, which percent-encodes `{proxy+}`.
202
+ function redactPath(pathname) {
203
+ return pathname.split('/').map(redactPathSegment).join('/');
204
+ }
205
+
206
+ function redactUrlFallback(raw) {
207
+ const out = raw
208
+ .replace(/(:\/\/)[^/?#@\s]*@/, '$1')
209
+ .replace(/([?&#][^=&#\s]+)=[^&#\s]*/g, '$1=REDACTED');
210
+ return scrubTokens(out);
211
+ }
212
+
213
+ function redactUrl(url) {
214
+ if (url === undefined || url === null) return '';
215
+ let raw;
216
+ try {
217
+ raw = url instanceof URL ? url.href : String(url);
218
+ } catch {
219
+ return '';
220
+ }
221
+ try {
222
+ if (ABSOLUTE_URL.test(raw)) {
223
+ const u = new URL(raw);
224
+ const path = raw.match(RAW_ABSOLUTE_PATH)[1];
225
+ return `${u.protocol}//${u.host}${redactPath(path)}${redactParams(
226
+ u.search,
227
+ '?'
228
+ )}${redactHash(u.hash)}`;
229
+ }
230
+ if (raw.startsWith('/')) {
231
+ const u = new URL(raw, 'https://relative.invalid');
232
+ return `${redactPath(raw.match(RAW_RELATIVE_PATH)[0])}${redactParams(
233
+ u.search,
234
+ '?'
235
+ )}${redactHash(u.hash)}`;
236
+ }
237
+ } catch {
238
+ return redactUrlFallback(raw);
239
+ }
240
+ return redactUrlFallback(raw);
241
+ }
242
+
243
+ function looksLikeJson(trimmed) {
244
+ if (trimmed.startsWith('{')) return trimmed.endsWith('}');
245
+ return /^\[\s*[{["\]\d-]/.test(trimmed) && trimmed.endsWith(']');
246
+ }
247
+
248
+ function scrubJsonText(text, originalLength) {
249
+ try {
250
+ return JSON.stringify(redactValue(JSON.parse(text)));
251
+ } catch {
252
+ return `[unparsed:${originalLength}]`;
253
+ }
254
+ }
255
+
256
+ function scrubFormText(text) {
257
+ const pairs = text.split('&').map((pair) => {
258
+ const index = pair.indexOf('=');
259
+ return [pair.slice(0, index), pair.slice(index + 1)];
260
+ });
261
+ const decodedKeys = pairs.map(([key]) => {
262
+ try {
263
+ return decodeURIComponent(key);
264
+ } catch {
265
+ return key;
266
+ }
267
+ });
268
+ const dropAll = isOAuthCallbackShape(decodedKeys);
269
+ return pairs
270
+ .map(([key, value], i) => {
271
+ if (dropAll || isPairKey(decodedKeys[i])) {
272
+ return `${key}=${redacted(value)}`;
273
+ }
274
+ return `${key}=${scrubTokens(value)}`;
275
+ })
276
+ .join('&');
277
+ }
278
+
279
+ function scrubText(text, options) {
280
+ const withUrls = text.replace(URL_IN_TEXT, (match) => {
281
+ const trailing = match.match(URL_TRAILING_PUNCT)?.[0] ?? '';
282
+ const core = trailing ? match.slice(0, -trailing.length) : match;
283
+ return `${redactUrl(core)}${trailing}`;
284
+ });
285
+ return scrubTokens(withUrls, options);
286
+ }
287
+
288
+ function scrubString(str, { allowHex = false } = {}) {
289
+ if (typeof str !== 'string' || str.length === 0) return str;
290
+ try {
291
+ const text =
292
+ str.length > MAX_SCRUB_INPUT ? str.slice(0, MAX_SCRUB_INPUT) : str;
293
+ const trimmed = text.trim();
294
+ if (looksLikeJson(trimmed)) return scrubJsonText(trimmed, str.length);
295
+ if (FORM_TEXT.test(trimmed)) return scrubFormText(trimmed);
296
+ return scrubText(text, { allowHex });
297
+ } catch {
298
+ return redacted(str);
299
+ }
300
+ }
301
+
302
+ function isOAuthCallbackShapeNormalized(normalizedKeys) {
303
+ const keys = new Set(normalizedKeys);
304
+ return keys.has('code') && (keys.has('state') || keys.has('codeverifier'));
305
+ }
306
+
307
+ function isOAuthCallbackShape(keys) {
308
+ return isOAuthCallbackShapeNormalized(keys.map(normalizeKey));
309
+ }
310
+
311
+ function redactValue(value) {
312
+ const { serializeValue } = require('./serialize');
313
+ return serializeValue(value);
314
+ }
315
+
316
+ module.exports = {
317
+ normalizeKey,
318
+ isDeniedKey,
319
+ isDeniedNormalized,
320
+ isDigestKey,
321
+ isDigestNormalized,
322
+ isOAuthCallbackShape,
323
+ isOAuthCallbackShapeNormalized,
324
+ addDeniedKeys,
325
+ redactUrl,
326
+ scrubString,
327
+ redactValue,
328
+ };