autotel-edge 3.16.0 → 3.16.2

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.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Redaction utility for structured log output
3
+ *
4
+ * Inspired by pino's redaction — zero-dependency, edge-compatible.
5
+ * Supports deep paths, wildcards, and custom censor values.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const redactor = createRedactor({
10
+ * paths: ['password', 'user.email', 'headers.authorization'],
11
+ * })
12
+ * const safe = redactor({ password: 'secret', user: { email: 'a@b.com' } })
13
+ * // { password: '[Redacted]', user: { email: '[Redacted]' } }
14
+ * ```
15
+ */
16
+
17
+ const DEFAULT_CENSOR = '[Redacted]';
18
+
19
+ /**
20
+ * Validate and parse a dot/bracket path string into segments.
21
+ *
22
+ * Valid: "user.email", "users[*].name", "headers.*", "[0].secret"
23
+ * Invalid: "user..email", "user[", "foo.]bar", ""
24
+ */
25
+ function parsePath(path: string): string[] {
26
+ if (typeof path !== 'string' || path.length === 0) {
27
+ throw new Error(
28
+ `redact path must be a non-empty string, got: ${JSON.stringify(path)}`,
29
+ );
30
+ }
31
+
32
+ // Reject obviously malformed paths
33
+ if (path.includes('..')) {
34
+ throw new Error(
35
+ `redact path contains empty segment: ${JSON.stringify(path)}`,
36
+ );
37
+ }
38
+ // Unmatched brackets: "[" with no "]" or vice versa
39
+ const openBracket = path.indexOf('[');
40
+ const closeBracket = path.indexOf(']');
41
+ if (
42
+ (openBracket !== -1 && closeBracket === -1) ||
43
+ (closeBracket !== -1 && openBracket === -1)
44
+ ) {
45
+ throw new Error(
46
+ `redact path has unclosed bracket: ${JSON.stringify(path)}`,
47
+ );
48
+ }
49
+ if (openBracket !== -1 && closeBracket < openBracket) {
50
+ throw new Error(
51
+ `redact path has bracket close before open: ${JSON.stringify(path)}`,
52
+ );
53
+ }
54
+ if (/\[\s*\]$/.test(path)) {
55
+ throw new Error(
56
+ `redact path has unclosed bracket: ${JSON.stringify(path)}`,
57
+ );
58
+ }
59
+ if (/\][^.[\]]/.test(path) && !/\]\[/.test(path)) {
60
+ // e.g. "foo.]bar" — closing bracket not followed by dot, bracket, or end
61
+ const afterClose = path.match(/\]([^.[\]]+)/);
62
+ if (afterClose) {
63
+ throw new Error(
64
+ `redact path has invalid characters after ']': ${JSON.stringify(path)}`,
65
+ );
66
+ }
67
+ }
68
+
69
+ const segments: string[] = [];
70
+ const rx = /[^.[\]]+|\[(\d+|\*)\]/g;
71
+ let match: RegExpExecArray | null;
72
+ let lastIndex = 0;
73
+ while ((match = rx.exec(path)) !== null) {
74
+ segments.push(match[1] ?? match[0]);
75
+ lastIndex = rx.lastIndex;
76
+ }
77
+
78
+ // If the regex didn't consume the whole string, something is malformed
79
+ if (lastIndex < path.length) {
80
+ throw new Error(
81
+ `redact path has unexpected trailing content: ${JSON.stringify(path)}`,
82
+ );
83
+ }
84
+
85
+ if (segments.length === 0) {
86
+ throw new Error(
87
+ `redact path produced no segments: ${JSON.stringify(path)}`,
88
+ );
89
+ }
90
+
91
+ return segments;
92
+ }
93
+
94
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
95
+ if (value === null || typeof value !== 'object') return false;
96
+ const proto = Object.getPrototypeOf(value);
97
+ return proto === Object.prototype || proto === null;
98
+ }
99
+
100
+ /**
101
+ * Deep clone plain objects and arrays only.
102
+ * Non-plain objects (Date, Map, Set, class instances) are returned by reference.
103
+ */
104
+ function deepClone<T>(value: T): T {
105
+ if (value === null || typeof value !== 'object') return value;
106
+ if (Array.isArray(value)) return value.map((v) => deepClone(v)) as T;
107
+ if (!isPlainObject(value)) return value;
108
+ const result: Record<string, unknown> = {};
109
+ for (const key of Object.keys(value)) {
110
+ result[key] = deepClone((value as Record<string, unknown>)[key]);
111
+ }
112
+ return result as T;
113
+ }
114
+
115
+ /**
116
+ * A node in the compiled path tree.
117
+ *
118
+ * Unlike a simple PathTree | null representation, a node can be both
119
+ * a redact target AND a parent for deeper paths. This correctly handles
120
+ * overlapping paths like ['user', 'user.email'].
121
+ */
122
+ interface PathNode {
123
+ redact?: boolean;
124
+ children?: Record<string, PathNode>;
125
+ }
126
+
127
+ /**
128
+ * Insert a parsed path into the tree.
129
+ *
130
+ * buildPathTree([['user'], ['user', 'email']]) produces:
131
+ * { user: { redact: true, children: { email: { redact: true } } } }
132
+ */
133
+ function buildPathTree(paths: string[][]): PathNode {
134
+ const root: PathNode = {};
135
+ for (const segments of paths) {
136
+ let node = root;
137
+ for (let i = 0; i < segments.length; i++) {
138
+ const seg = segments[i];
139
+ const isLast = i === segments.length - 1;
140
+
141
+ if (!node.children) node.children = {};
142
+ if (!node.children[seg]) node.children[seg] = {};
143
+
144
+ if (isLast) {
145
+ node.children[seg].redact = true;
146
+ }
147
+ node = node.children[seg];
148
+ }
149
+ }
150
+ return root;
151
+ }
152
+
153
+ /**
154
+ * Walk a compiled path tree and redact matching values in `obj`.
155
+ * Mutates `obj` in place (caller should clone before calling).
156
+ */
157
+ function redactWithTree(
158
+ obj: Record<string, any>,
159
+ node: PathNode,
160
+ censor: Censor,
161
+ remove: boolean,
162
+ path: string[] = [],
163
+ ): void {
164
+ if (!node.children) return;
165
+
166
+ const applyCensor = (target: Record<string, any>, k: string, currentPath: string[]) => {
167
+ if (remove) {
168
+ delete target[k];
169
+ } else {
170
+ target[k] = typeof censor === 'function' ? censor(target[k], currentPath) : censor;
171
+ }
172
+ };
173
+
174
+ for (const [key, childNode] of Object.entries(node.children)) {
175
+ if (key === '*') {
176
+ for (const k of Object.keys(obj)) {
177
+ const wildcardPath = [...path, k];
178
+ if (childNode.redact && !childNode.children) {
179
+ applyCensor(obj, k, wildcardPath);
180
+ } else if (childNode.redact && childNode.children) {
181
+ const child = obj[k];
182
+ if (
183
+ child != null &&
184
+ typeof child === 'object' &&
185
+ !Array.isArray(child)
186
+ ) {
187
+ redactWithTree(child, childNode, censor, remove, wildcardPath);
188
+ } else {
189
+ applyCensor(obj, k, wildcardPath);
190
+ }
191
+ } else if (childNode.children) {
192
+ const child = obj[k];
193
+ if (child != null && typeof child === 'object') {
194
+ redactWithTree(child, childNode, censor, remove, wildcardPath);
195
+ }
196
+ }
197
+ }
198
+ continue;
199
+ }
200
+
201
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
202
+
203
+ const val = obj[key];
204
+ const currentPath = [...path, key];
205
+
206
+ if (childNode.redact && !childNode.children) {
207
+ applyCensor(obj, key, currentPath);
208
+ } else if (childNode.redact && childNode.children) {
209
+ if (val != null && typeof val === 'object') {
210
+ redactWithTree(val, childNode, censor, remove, currentPath);
211
+ }
212
+ } else if (childNode.children && val != null && typeof val === 'object') {
213
+ redactWithTree(val, childNode, censor, remove, currentPath);
214
+ }
215
+ }
216
+ }
217
+
218
+ export type Censor = string | ((value: unknown, path: string[]) => unknown);
219
+
220
+ export interface RedactorOptions {
221
+ paths: string[];
222
+ censor?: Censor;
223
+ remove?: boolean;
224
+ }
225
+
226
+ /**
227
+ * Preset name or explicit options
228
+ */
229
+ export type RedactorConfig = RedactorOptions | RedactorPreset;
230
+
231
+ export type RedactorPreset = 'default' | 'strict' | 'pci-dss';
232
+
233
+ /**
234
+ * Paths that match the main autotel package's `sensitiveKey` pattern:
235
+ * `/^(password|passwd|pwd|secret|token|api[_-]?key|auth|credential|private[_-]?key|authorization)$/i`
236
+ *
237
+ * Copied here so the edge logger stays zero-dependency.
238
+ */
239
+ const SENSITIVE_KEY_PATHS = [
240
+ 'password',
241
+ 'passwd',
242
+ 'pwd',
243
+ 'secret',
244
+ 'token',
245
+ 'apiKey',
246
+ 'api_key',
247
+ 'auth',
248
+ 'credential',
249
+ 'privateKey',
250
+ 'private_key',
251
+ 'authorization',
252
+ ];
253
+
254
+ /**
255
+ * Common request header paths that often carry secrets.
256
+ */
257
+ const SENSITIVE_HEADER_PATHS = [
258
+ 'req.headers.authorization',
259
+ 'req.headers.cookie',
260
+ 'headers.authorization',
261
+ 'headers.cookie',
262
+ 'request.headers.authorization',
263
+ 'request.headers.cookie',
264
+ ];
265
+
266
+ /**
267
+ * Built-in redactor presets.
268
+ *
269
+ * - `default` — sensitive keys + request headers (passwords, tokens, secrets, auth headers)
270
+ * - `strict` — default + bearer/jwt/api-key patterns nested at any depth
271
+ * - `pci-dss` — credit card and payment-related fields
272
+ */
273
+ export const REDACT_PRESETS: Record<RedactorPreset, RedactorOptions> = {
274
+ /**
275
+ * Default: covers the same sensitive-key names as the main autotel package's
276
+ * `sensitiveKey` pattern, plus common request header paths.
277
+ *
278
+ * Redacted keys: password, passwd, pwd, secret, token, apiKey, auth,
279
+ * credential, privateKey, authorization
280
+ * Redacted headers: req.headers.authorization, req.headers.cookie, etc.
281
+ */
282
+ default: {
283
+ paths: [...SENSITIVE_KEY_PATHS, ...SENSITIVE_HEADER_PATHS],
284
+ },
285
+
286
+ /**
287
+ * Strict: everything in default, plus nested paths for bearer tokens,
288
+ * JWT fields, and API key fields at common depths.
289
+ */
290
+ strict: {
291
+ paths: [
292
+ ...SENSITIVE_KEY_PATHS,
293
+ ...SENSITIVE_HEADER_PATHS,
294
+ // Common nested auth shapes
295
+ 'bearer',
296
+ 'jwt',
297
+ 'apiSecret',
298
+ 'api_secret',
299
+ 'accessToken',
300
+ 'access_token',
301
+ 'refreshToken',
302
+ 'refresh_token',
303
+ 'clientSecret',
304
+ 'client_secret',
305
+ ],
306
+ },
307
+
308
+ /**
309
+ * PCI-DSS: credit card and payment-related fields.
310
+ */
311
+ 'pci-dss': {
312
+ paths: [
313
+ 'card',
314
+ 'cardNumber',
315
+ 'card_number',
316
+ 'ccn',
317
+ 'pan',
318
+ 'cvv',
319
+ 'cvc',
320
+ 'expirationDate',
321
+ 'expiration_date',
322
+ 'exp',
323
+ 'payment.card',
324
+ 'payment.cardNumber',
325
+ 'payment.cvv',
326
+ ],
327
+ },
328
+ };
329
+
330
+ /**
331
+ * Resolve a preset name or explicit options into RedactorOptions.
332
+ */
333
+ function resolveConfig(config: RedactorConfig): RedactorOptions {
334
+ if (typeof config === 'string') {
335
+ const preset = REDACT_PRESETS[config];
336
+ if (!preset) {
337
+ throw new Error(
338
+ `Unknown redactor preset: "${config}". Available: ${Object.keys(REDACT_PRESETS).join(', ')}`,
339
+ );
340
+ }
341
+ return preset;
342
+ }
343
+ return config;
344
+ }
345
+
346
+ /**
347
+ * Create a redactor function from a list of paths.
348
+ *
349
+ * Paths use dot notation for nested fields and `[*]` or `*` for wildcards:
350
+ * - `"password"` — top-level key
351
+ * - `"user.email"` — nested field
352
+ * - `"users[*].password"` or `"users.*.password"` — all items in array/object
353
+ * - `"headers.authorization"` — nested header
354
+ * - `"[*].secret"` — wildcard at root
355
+ *
356
+ * Overlapping paths are supported — e.g. `['user', 'user.email']` will
357
+ * redact `user` as a whole while still recursing into `user.email`.
358
+ *
359
+ * The returned function clones plain objects/arrays before mutating,
360
+ * so the original is never modified. Non-plain objects (Date, Map, etc.)
361
+ * are passed through by reference.
362
+ *
363
+ * @example
364
+ * ```typescript
365
+ * // Using a preset
366
+ * const redactor = createRedactor('default')
367
+ *
368
+ * // Using explicit paths
369
+ * const redactor = createRedactor({
370
+ * paths: ['password', 'token', 'user.email', 'users[*].ssn'],
371
+ * censor: '[Filtered]', // optional, default '[Redacted]'
372
+ * })
373
+ *
374
+ * redactor({ password: 's3cret', user: { email: 'a@b.com' } })
375
+ * // → { password: '[Filtered]', user: { email: '[Filtered]' } }
376
+ *
377
+ * // Custom censor function:
378
+ * const mask = createRedactor({
379
+ * paths: ['ccn'],
380
+ * censor: (val) => '****' + String(val).slice(-4),
381
+ * })
382
+ * ```
383
+ */
384
+ export function createRedactor(config: RedactorConfig): <T>(obj: T) => T {
385
+ const { paths, censor = DEFAULT_CENSOR, remove = false } = resolveConfig(config);
386
+
387
+ if (paths.length === 0) {
388
+ return <T>(obj: T): T => obj;
389
+ }
390
+
391
+ const parsed = paths.map((p) => parsePath(p));
392
+ const tree = buildPathTree(parsed);
393
+
394
+ return function redactor<T>(obj: T): T {
395
+ if (obj == null || typeof obj !== 'object') return obj;
396
+
397
+ // Clone so we don't mutate the original log entry
398
+ const clone = deepClone(obj) as Record<string, any>;
399
+ redactWithTree(clone, tree, censor, remove);
400
+ return clone as T;
401
+ };
402
+ }
package/src/logger.ts CHANGED
@@ -9,5 +9,18 @@ export {
9
9
  runWithLogLevel,
10
10
  getActiveLogLevel,
11
11
  type EdgeLogger,
12
+ type EdgeLoggerOptions,
12
13
  type LogLevel,
14
+ type LogEvent,
15
+ type WriteFn,
16
+ type TimeFn,
13
17
  } from './api/logger';
18
+
19
+ export {
20
+ createRedactor,
21
+ REDACT_PRESETS,
22
+ type Censor,
23
+ type RedactorOptions,
24
+ type RedactorConfig,
25
+ type RedactorPreset,
26
+ } from './api/redact';