@outputai/core 0.10.1-next.fc0a41f.0 → 0.11.0
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/package.json +12 -7
- package/src/consts.js +5 -2
- package/src/errors.d.ts +12 -2
- package/src/errors.js +17 -3
- package/src/errors.spec.js +19 -0
- package/src/helpers/activity_options.js +21 -0
- package/src/helpers/activity_options.spec.js +55 -0
- package/src/helpers/error_serializer.js +236 -0
- package/src/helpers/error_serializer.spec.js +413 -0
- package/src/helpers/errors.js +55 -13
- package/src/helpers/errors.spec.js +166 -66
- package/src/helpers/fetch.js +1 -38
- package/src/helpers/fetch.spec.js +1 -39
- package/src/helpers/function.js +13 -0
- package/src/helpers/function.spec.js +23 -1
- package/src/helpers/redact.js +51 -0
- package/src/helpers/redact.spec.js +66 -0
- package/src/helpers/string.js +10 -0
- package/src/helpers/string.spec.js +36 -1
- package/src/hooks/index.d.ts +35 -44
- package/src/hooks/index.js +2 -1
- package/src/hooks/index.spec.js +15 -7
- package/src/interface/evaluator.d.ts +10 -9
- package/src/interface/evaluator.js +1 -6
- package/src/interface/evaluator.spec.js +10 -10
- package/src/interface/step.d.ts +16 -15
- package/src/interface/step.js +1 -6
- package/src/interface/step.spec.js +10 -10
- package/src/interface/validations/index.js +33 -53
- package/src/interface/validations/index.spec.js +25 -17
- package/src/interface/webhook.js +1 -2
- package/src/interface/webhook.spec.js +25 -19
- package/src/interface/workflow.d.ts +25 -18
- package/src/interface/workflow.js +24 -52
- package/src/interface/workflow.spec.js +109 -108
- package/src/interface/workflow_activity_options.js +2 -5
- package/src/temporal/client.js +47 -0
- package/src/temporal/client.spec.js +124 -0
- package/src/temporal/index.d.ts +32 -0
- package/src/temporal/index.js +1 -0
- package/src/tracing/trace_engine.js +9 -11
- package/src/tracing/trace_engine.spec.js +67 -28
- package/src/worker/catalog_workflow/catalog_job.js +3 -3
- package/src/worker/catalog_workflow/catalog_job.spec.js +1 -1
- package/src/worker/index.js +10 -1
- package/src/worker/index.spec.js +23 -8
- package/src/worker/interceptors/activity.js +32 -19
- package/src/worker/interceptors/activity.spec.js +71 -166
- package/src/worker/interceptors/workflow.js +53 -11
- package/src/worker/interceptors/workflow.spec.js +176 -49
- package/src/worker/loader/workflows.js +16 -3
- package/src/worker/loader/workflows.spec.js +37 -5
- package/src/worker/log_hooks.js +13 -10
- package/src/worker/log_hooks.spec.js +12 -4
- package/src/worker/sinks.js +3 -1
- package/src/worker/sinks.spec.js +21 -4
- package/src/worker/telemetry.js +2 -1
- package/src/worker/temporal_logger.js +33 -0
- package/src/worker/temporal_logger.spec.js +90 -0
- package/src/helpers/aggregations.js +0 -44
- package/src/helpers/aggregations.spec.js +0 -92
- package/src/tracing/tools/utils.js +0 -42
- package/src/tracing/tools/utils.spec.js +0 -83
|
@@ -1,89 +1,189 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
import { buildApplicationFailureWithDetails } from './errors.js';
|
|
2
|
+
import { inheritsFromAnyNamedType, rehydrateError } from './errors.js';
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
describe( 'inheritsFromAnyNamedType', () => {
|
|
5
|
+
class BaseFailure extends Error {}
|
|
6
|
+
class DomainFailure extends BaseFailure {}
|
|
7
|
+
class RequestFailure extends DomainFailure {}
|
|
8
|
+
class UnrelatedFailure extends Error {}
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const error = new Error( 'step failed' );
|
|
10
|
-
const info = { aggregations: { cost: { total: 1 } } };
|
|
10
|
+
it( 'matches exact, parent, and grandparent constructor names', () => {
|
|
11
|
+
const error = new RequestFailure( 'request failed' );
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
expect( inheritsFromAnyNamedType( error, [ 'RequestFailure' ] ) ).toBe( true );
|
|
14
|
+
expect( inheritsFromAnyNamedType( error, [ 'DomainFailure' ] ) ).toBe( true );
|
|
15
|
+
expect( inheritsFromAnyNamedType( error, [ 'BaseFailure' ] ) ).toBe( true );
|
|
16
|
+
expect( inheritsFromAnyNamedType( error, [ 'OtherFailure', 'BaseFailure' ] ) ).toBe( true );
|
|
17
|
+
} );
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
expect(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
nonRetryable: false,
|
|
19
|
-
details: [ info ],
|
|
20
|
-
cause: error
|
|
21
|
-
} );
|
|
19
|
+
it( 'does not match unrelated or descendant constructor names', () => {
|
|
20
|
+
expect( inheritsFromAnyNamedType( new RequestFailure(), [ 'UnrelatedFailure' ] ) ).toBe( false );
|
|
21
|
+
expect( inheritsFromAnyNamedType( new BaseFailure(), [ 'RequestFailure' ] ) ).toBe( false );
|
|
22
|
+
expect( inheritsFromAnyNamedType( new UnrelatedFailure(), [ 'BaseFailure' ] ) ).toBe( false );
|
|
22
23
|
} );
|
|
23
24
|
|
|
24
|
-
it( '
|
|
25
|
-
const error = new
|
|
26
|
-
|
|
25
|
+
it( 'ignores a spoofed own constructor property', () => {
|
|
26
|
+
const error = new RequestFailure();
|
|
27
|
+
Object.defineProperty( error, 'constructor', {
|
|
28
|
+
value: { name: 'SpoofedFailure' }
|
|
29
|
+
} );
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
expect( inheritsFromAnyNamedType( error, [ 'SpoofedFailure' ] ) ).toBe( false );
|
|
32
|
+
expect( inheritsFromAnyNamedType( error, [ 'RequestFailure' ] ) ).toBe( true );
|
|
33
|
+
} );
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
it( 'rejects empty names and invalid values without false positives', () => {
|
|
36
|
+
const unnamedConstructor = function NamedConstructor() {};
|
|
37
|
+
Object.defineProperty( unnamedConstructor, 'name', { value: '' } );
|
|
38
|
+
const value = Object.create( { constructor: unnamedConstructor } );
|
|
39
|
+
|
|
40
|
+
expect( inheritsFromAnyNamedType( value, [ '', undefined ] ) ).toBe( false );
|
|
41
|
+
expect( inheritsFromAnyNamedType( value, [] ) ).toBe( false );
|
|
42
|
+
expect( inheritsFromAnyNamedType( null, [ 'BaseFailure' ] ) ).toBe( false );
|
|
43
|
+
expect( inheritsFromAnyNamedType( 'failure', [ 'String' ] ) ).toBe( false );
|
|
36
44
|
} );
|
|
45
|
+
} );
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const info = { aggregations: { httpRequests: { total: 1 } } };
|
|
47
|
+
describe( 'rehydrateError', () => {
|
|
48
|
+
it( 'returns the same Error instance when given an Error', () => {
|
|
49
|
+
const original = new Error( 'already an error' );
|
|
50
|
+
original.name = 'FatalError';
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
expect( rehydrateError( original ) ).toBe( original );
|
|
53
|
+
} );
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
it( 'rebuilds a plain serialized error with name and message', () => {
|
|
56
|
+
const result = rehydrateError( {
|
|
57
|
+
name: 'FatalError',
|
|
58
|
+
message: 'workflow failed'
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
expect( result ).toBeInstanceOf( Error );
|
|
62
|
+
expect( result.name ).toBe( 'FatalError' );
|
|
63
|
+
expect( result.message ).toBe( 'workflow failed' );
|
|
64
|
+
expect( result.stack ).toBeUndefined();
|
|
52
65
|
} );
|
|
53
66
|
|
|
54
|
-
it( '
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
it( 'restores stack when present on the serialized object', () => {
|
|
68
|
+
const result = rehydrateError( {
|
|
69
|
+
name: 'Error',
|
|
70
|
+
message: 'boom',
|
|
71
|
+
stack: 'Error: boom\n at workflow.js:1:1'
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
expect( result.stack ).toBe( 'Error: boom\n at workflow.js:1:1' );
|
|
75
|
+
} );
|
|
58
76
|
|
|
59
|
-
|
|
77
|
+
it( 'copies additional enumerable properties', () => {
|
|
78
|
+
const result = rehydrateError( {
|
|
79
|
+
name: 'HTTPError',
|
|
80
|
+
message: 'bad request',
|
|
81
|
+
status: 400,
|
|
82
|
+
code: 'BAD_REQUEST'
|
|
83
|
+
} );
|
|
60
84
|
|
|
61
|
-
expect(
|
|
85
|
+
expect( result.status ).toBe( 400 );
|
|
86
|
+
expect( result.code ).toBe( 'BAD_REQUEST' );
|
|
62
87
|
} );
|
|
63
88
|
|
|
64
|
-
it( '
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
it( 'recursively rehydrates a plain-object cause', () => {
|
|
90
|
+
const result = rehydrateError( {
|
|
91
|
+
name: 'ActivityFailure',
|
|
92
|
+
message: 'Activity task failed',
|
|
93
|
+
cause: {
|
|
94
|
+
name: 'ApplicationFailure',
|
|
95
|
+
message: 'root failure',
|
|
96
|
+
cause: {
|
|
97
|
+
name: 'ValidationError',
|
|
98
|
+
message: 'invalid input'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
70
101
|
} );
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
expect(
|
|
76
|
-
expect(
|
|
77
|
-
expect(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
info
|
|
86
|
-
]
|
|
102
|
+
|
|
103
|
+
expect( result.cause ).toBeInstanceOf( Error );
|
|
104
|
+
expect( result.cause.name ).toBe( 'ApplicationFailure' );
|
|
105
|
+
expect( result.cause.message ).toBe( 'root failure' );
|
|
106
|
+
expect( result.cause.cause ).toBeInstanceOf( Error );
|
|
107
|
+
expect( result.cause.cause.name ).toBe( 'ValidationError' );
|
|
108
|
+
expect( result.cause.cause.message ).toBe( 'invalid input' );
|
|
109
|
+
} );
|
|
110
|
+
|
|
111
|
+
it( 'leaves a non-object cause unchanged', () => {
|
|
112
|
+
const result = rehydrateError( {
|
|
113
|
+
name: 'Error',
|
|
114
|
+
message: 'outer',
|
|
115
|
+
cause: 'string cause'
|
|
87
116
|
} );
|
|
117
|
+
|
|
118
|
+
expect( result.cause ).toBe( 'string cause' );
|
|
119
|
+
} );
|
|
120
|
+
|
|
121
|
+
it( 'returns an empty Error for null and undefined without throwing', () => {
|
|
122
|
+
const fromNull = rehydrateError( null );
|
|
123
|
+
const fromUndefined = rehydrateError( undefined );
|
|
124
|
+
|
|
125
|
+
expect( fromNull ).toBeInstanceOf( Error );
|
|
126
|
+
expect( fromNull.message ).toBe( '' );
|
|
127
|
+
expect( fromNull.stack ).toBeUndefined();
|
|
128
|
+
|
|
129
|
+
expect( fromUndefined ).toBeInstanceOf( Error );
|
|
130
|
+
expect( fromUndefined.message ).toBe( '' );
|
|
131
|
+
expect( fromUndefined.stack ).toBeUndefined();
|
|
132
|
+
} );
|
|
133
|
+
|
|
134
|
+
it.each( [
|
|
135
|
+
[ 'string', 'boom', 'boom' ],
|
|
136
|
+
[ 'number', 42, '42' ],
|
|
137
|
+
[ 'boolean', true, 'true' ],
|
|
138
|
+
[ 'bigint', 1n, '1' ],
|
|
139
|
+
[ 'symbol', Symbol( 'x' ), 'Symbol(x)' ],
|
|
140
|
+
[ 'named function', function named() {}, 'function named() {}' ],
|
|
141
|
+
[ 'arrow function', () => {}, '() => {}' ]
|
|
142
|
+
] )( 'stringifies a %s into Error.message', ( _label, input, message ) => {
|
|
143
|
+
const result = rehydrateError( input );
|
|
144
|
+
|
|
145
|
+
expect( result ).toBeInstanceOf( Error );
|
|
146
|
+
expect( result.message ).toBe( message );
|
|
147
|
+
expect( result.stack ).toBeUndefined();
|
|
148
|
+
} );
|
|
149
|
+
|
|
150
|
+
it( 'copies enumerable array indexes onto an empty Error', () => {
|
|
151
|
+
const result = rehydrateError( [ 'a', 'b' ] );
|
|
152
|
+
|
|
153
|
+
expect( result ).toBeInstanceOf( Error );
|
|
154
|
+
expect( result.message ).toBe( '' );
|
|
155
|
+
expect( result.stack ).toBeUndefined();
|
|
156
|
+
expect( result[0] ).toBe( 'a' );
|
|
157
|
+
expect( result[1] ).toBe( 'b' );
|
|
158
|
+
} );
|
|
159
|
+
|
|
160
|
+
it( 'returns an empty Error for RegExp and Date (no enumerable own props)', () => {
|
|
161
|
+
const fromRegexp = rehydrateError( /foo/gi );
|
|
162
|
+
const fromDate = rehydrateError( new Date( '2020-01-01T00:00:00.000Z' ) );
|
|
163
|
+
|
|
164
|
+
expect( fromRegexp ).toBeInstanceOf( Error );
|
|
165
|
+
expect( fromRegexp.message ).toBe( '' );
|
|
166
|
+
expect( fromRegexp.stack ).toBeUndefined();
|
|
167
|
+
|
|
168
|
+
expect( fromDate ).toBeInstanceOf( Error );
|
|
169
|
+
expect( fromDate.message ).toBe( '' );
|
|
170
|
+
expect( fromDate.stack ).toBeUndefined();
|
|
171
|
+
} );
|
|
172
|
+
|
|
173
|
+
it( 'copies enumerable own props from a custom class instance', () => {
|
|
174
|
+
class Widget {
|
|
175
|
+
constructor() {
|
|
176
|
+
this.x = 1;
|
|
177
|
+
this.name = 'WidgetError';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const result = rehydrateError( new Widget() );
|
|
182
|
+
|
|
183
|
+
expect( result ).toBeInstanceOf( Error );
|
|
184
|
+
expect( result.message ).toBe( '' );
|
|
185
|
+
expect( result.stack ).toBeUndefined();
|
|
186
|
+
expect( result.x ).toBe( 1 );
|
|
187
|
+
expect( result.name ).toBe( 'WidgetError' );
|
|
88
188
|
} );
|
|
89
189
|
} );
|
package/src/helpers/fetch.js
CHANGED
|
@@ -1,42 +1,5 @@
|
|
|
1
1
|
import { FatalError } from '#errors';
|
|
2
|
-
|
|
3
|
-
/** Matches red in "hot-red-pie", but not in "redact" */
|
|
4
|
-
const wordMatcher = term => new RegExp( `(?<![a-z\\d])${term}(?![a-z\\d])`, 'i' );
|
|
5
|
-
|
|
6
|
-
/** Matches red in "acquired", but not in "redact" */
|
|
7
|
-
const wordEndMatcher = term => new RegExp( `${term}(?![a-z\\d])`, 'i' );
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Redacts sensitive headers
|
|
11
|
-
* @param {object} headers
|
|
12
|
-
* @returns {object} The redacted headers
|
|
13
|
-
*/
|
|
14
|
-
export const redactHeaders = headers => {
|
|
15
|
-
/** Header names that look sensitive by substring rules but are not secret material. */
|
|
16
|
-
const ignoreHeaders = new Set( [
|
|
17
|
-
'x-csrf-token',
|
|
18
|
-
'public-key-pins'
|
|
19
|
-
] );
|
|
20
|
-
|
|
21
|
-
/** * Sensitive header patterns for redaction (case-insensitive). */
|
|
22
|
-
const sensitiveHeadersPatterns = [
|
|
23
|
-
// matches headers that contain these exact words
|
|
24
|
-
wordMatcher( 'authorization' ),
|
|
25
|
-
wordMatcher( 'token' ),
|
|
26
|
-
wordMatcher( 'secret' ),
|
|
27
|
-
wordMatcher( 'password' ),
|
|
28
|
-
wordMatcher( 'pwd' ),
|
|
29
|
-
wordMatcher( 'cookie' ),
|
|
30
|
-
// matches header that contain words ending with these sequences
|
|
31
|
-
wordEndMatcher( 'key' )
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
return Object.entries( headers ).reduce( ( redacted, [ key, value ] ) => {
|
|
35
|
-
const lowKey = key.toLowerCase();
|
|
36
|
-
const isSensitive = !ignoreHeaders.has( lowKey ) && sensitiveHeadersPatterns.some( rx => rx.test( lowKey ) );
|
|
37
|
-
return Object.assign( redacted, { [key]: isSensitive ? '[REDACTED]' : value } );
|
|
38
|
-
}, {} );
|
|
39
|
-
};
|
|
2
|
+
import { redactHeaders } from './redact.js';
|
|
40
3
|
|
|
41
4
|
/**
|
|
42
5
|
* Consume the body of a Response according it its content-type and returns it
|
|
@@ -1,44 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
2
2
|
import { Readable } from 'node:stream';
|
|
3
|
-
import { hydrateHeaders,
|
|
4
|
-
|
|
5
|
-
describe( 'redactHeaders', () => {
|
|
6
|
-
it( 'redacts sensitive header names', () => {
|
|
7
|
-
const result = redactHeaders( {
|
|
8
|
-
Authorization: 'Bearer token',
|
|
9
|
-
'X-Api-Key': 'api-key',
|
|
10
|
-
Cookie: 'session=id',
|
|
11
|
-
'x-client-secret': 'secret'
|
|
12
|
-
} );
|
|
13
|
-
|
|
14
|
-
expect( result ).toEqual( {
|
|
15
|
-
Authorization: '[REDACTED]',
|
|
16
|
-
'X-Api-Key': '[REDACTED]',
|
|
17
|
-
Cookie: '[REDACTED]',
|
|
18
|
-
'x-client-secret': '[REDACTED]'
|
|
19
|
-
} );
|
|
20
|
-
} );
|
|
21
|
-
|
|
22
|
-
it( 'preserves non-sensitive header names and ignored false positives', () => {
|
|
23
|
-
const result = redactHeaders( {
|
|
24
|
-
Accept: 'application/json',
|
|
25
|
-
'Content-Type': 'application/json',
|
|
26
|
-
'x-csrf-token': 'csrf-token',
|
|
27
|
-
'public-key-pins': 'pin'
|
|
28
|
-
} );
|
|
29
|
-
|
|
30
|
-
expect( result ).toEqual( {
|
|
31
|
-
Accept: 'application/json',
|
|
32
|
-
'Content-Type': 'application/json',
|
|
33
|
-
'x-csrf-token': 'csrf-token',
|
|
34
|
-
'public-key-pins': 'pin'
|
|
35
|
-
} );
|
|
36
|
-
} );
|
|
37
|
-
|
|
38
|
-
it( 'handles empty headers', () => {
|
|
39
|
-
expect( redactHeaders( {} ) ).toEqual( {} );
|
|
40
|
-
} );
|
|
41
|
-
} );
|
|
3
|
+
import { hydrateHeaders, serializeBodyAndInferContentType, serializeResponse } from './fetch.js';
|
|
42
4
|
|
|
43
5
|
describe( 'hydrateHeaders', () => {
|
|
44
6
|
afterEach( () => {
|
package/src/helpers/function.js
CHANGED
|
@@ -13,3 +13,16 @@ export const runOnce = fn => {
|
|
|
13
13
|
return state.result;
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Calls a given function and in case it throws errors, returns `undefined`
|
|
19
|
+
* @param {Function} fn
|
|
20
|
+
* @returns {unknown|undefined}
|
|
21
|
+
*/
|
|
22
|
+
export const tryOrUndefined = fn => {
|
|
23
|
+
try {
|
|
24
|
+
return fn();
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined; // eslint-disable-line consistent-return
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { runOnce } from './function.js';
|
|
2
|
+
import { runOnce, tryOrUndefined } from './function.js';
|
|
3
3
|
|
|
4
4
|
describe( 'runOnce', () => {
|
|
5
5
|
it( 'calls the wrapped function only once', () => {
|
|
@@ -46,3 +46,25 @@ describe( 'runOnce', () => {
|
|
|
46
46
|
expect( fn ).toHaveBeenCalledOnce();
|
|
47
47
|
} );
|
|
48
48
|
} );
|
|
49
|
+
|
|
50
|
+
describe( 'tryOrUndefined', () => {
|
|
51
|
+
it( 'returns the invoked function result', () => {
|
|
52
|
+
const fn = vi.fn( () => 'result' );
|
|
53
|
+
|
|
54
|
+
expect( tryOrUndefined( fn ) ).toBe( 'result' );
|
|
55
|
+
expect( fn ).toHaveBeenCalledOnce();
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
it( 'returns undefined when the invoked function throws', () => {
|
|
59
|
+
const fn = vi.fn( () => {
|
|
60
|
+
throw new Error( 'unavailable' );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
expect( tryOrUndefined( fn ) ).toBeUndefined();
|
|
64
|
+
expect( fn ).toHaveBeenCalledOnce();
|
|
65
|
+
} );
|
|
66
|
+
|
|
67
|
+
it( 'preserves an explicit undefined result', () => {
|
|
68
|
+
expect( tryOrUndefined( () => undefined ) ).toBeUndefined();
|
|
69
|
+
} );
|
|
70
|
+
} );
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redacts a given URL object by setting each search param, hash, username and password to '***'
|
|
3
|
+
* @param {URL|string} target
|
|
4
|
+
* @returns {string} Redacted URL as a string
|
|
5
|
+
*/
|
|
6
|
+
export const redactUrl = target => {
|
|
7
|
+
const url = new URL( target );
|
|
8
|
+
url.search = new URLSearchParams( url.searchParams.entries().map( ( [ p ] ) => [ p, '***' ] ) ).toString();
|
|
9
|
+
url.username = url.username.length > 0 ? '***' : '';
|
|
10
|
+
url.password = url.password.length > 0 ? '***' : '';
|
|
11
|
+
url.hash = url.hash ? '#***' : '';
|
|
12
|
+
return url.toString();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Matches red in "hot-red-pie", but not in "redact" */
|
|
16
|
+
const wordMatcher = term => new RegExp( `(?<![a-z\\d])${term}(?![a-z\\d])`, 'i' );
|
|
17
|
+
|
|
18
|
+
/** Matches red in "acquired", but not in "redact" */
|
|
19
|
+
const wordEndMatcher = term => new RegExp( `${term}(?![a-z\\d])`, 'i' );
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Redacts sensitive headers
|
|
23
|
+
* @param {object} headers
|
|
24
|
+
* @returns {object} The redacted headers
|
|
25
|
+
*/
|
|
26
|
+
export const redactHeaders = headers => {
|
|
27
|
+
/** Header names that look sensitive by substring rules but are not secret material. */
|
|
28
|
+
const ignoreHeaders = new Set( [
|
|
29
|
+
'x-csrf-token',
|
|
30
|
+
'public-key-pins'
|
|
31
|
+
] );
|
|
32
|
+
|
|
33
|
+
/** * Sensitive header patterns for redaction (case-insensitive). */
|
|
34
|
+
const sensitiveHeadersPatterns = [
|
|
35
|
+
// matches headers that contain these exact words
|
|
36
|
+
wordMatcher( 'authorization' ),
|
|
37
|
+
wordMatcher( 'token' ),
|
|
38
|
+
wordMatcher( 'secret' ),
|
|
39
|
+
wordMatcher( 'password' ),
|
|
40
|
+
wordMatcher( 'pwd' ),
|
|
41
|
+
wordMatcher( 'cookie' ),
|
|
42
|
+
// matches header that contain words ending with these sequences
|
|
43
|
+
wordEndMatcher( 'key' )
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
return Object.entries( headers ).reduce( ( redacted, [ key, value ] ) => {
|
|
47
|
+
const lowKey = key.toLowerCase();
|
|
48
|
+
const isSensitive = !ignoreHeaders.has( lowKey ) && sensitiveHeadersPatterns.some( rx => rx.test( lowKey ) );
|
|
49
|
+
return Object.assign( redacted, { [key]: isSensitive ? '[REDACTED]' : value } );
|
|
50
|
+
}, {} );
|
|
51
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { redactHeaders, redactUrl } from './redact.js';
|
|
3
|
+
|
|
4
|
+
describe( 'redactUrl', () => {
|
|
5
|
+
it( 'redacts credentials, query values, and the fragment while preserving URL structure', () => {
|
|
6
|
+
const result = redactUrl( 'https://user:password@example.com/models?model=gpt-5&token=secret#debug' );
|
|
7
|
+
|
|
8
|
+
expect( result ).toBe( 'https://***:***@example.com/models?model=***&token=***#***' );
|
|
9
|
+
} );
|
|
10
|
+
|
|
11
|
+
it( 'preserves duplicate query parameters and their order', () => {
|
|
12
|
+
const result = redactUrl( 'https://example.com/search?tag=first&tag=second&empty=#results' );
|
|
13
|
+
|
|
14
|
+
expect( result ).toBe( 'https://example.com/search?tag=***&tag=***&empty=***#***' );
|
|
15
|
+
} );
|
|
16
|
+
|
|
17
|
+
it( 'returns URLs without redacted components unchanged', () => {
|
|
18
|
+
expect( redactUrl( 'https://example.com/path' ) ).toBe( 'https://example.com/path' );
|
|
19
|
+
} );
|
|
20
|
+
|
|
21
|
+
it( 'does not mutate URL instances', () => {
|
|
22
|
+
const originalValue = 'ftp://user:password@example.com/file.txt?download=true#details';
|
|
23
|
+
const url = new URL( originalValue );
|
|
24
|
+
|
|
25
|
+
expect( redactUrl( url ) ).toBe( 'ftp://***:***@example.com/file.txt?download=***#***' );
|
|
26
|
+
expect( url.toString() ).toBe( originalValue );
|
|
27
|
+
} );
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
describe( 'redactHeaders', () => {
|
|
31
|
+
it( 'redacts sensitive header names', () => {
|
|
32
|
+
const result = redactHeaders( {
|
|
33
|
+
Authorization: 'Bearer token',
|
|
34
|
+
'X-Api-Key': 'api-key',
|
|
35
|
+
Cookie: 'session=id',
|
|
36
|
+
'x-client-secret': 'secret'
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
expect( result ).toEqual( {
|
|
40
|
+
Authorization: '[REDACTED]',
|
|
41
|
+
'X-Api-Key': '[REDACTED]',
|
|
42
|
+
Cookie: '[REDACTED]',
|
|
43
|
+
'x-client-secret': '[REDACTED]'
|
|
44
|
+
} );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
it( 'preserves non-sensitive header names and ignored false positives', () => {
|
|
48
|
+
const result = redactHeaders( {
|
|
49
|
+
Accept: 'application/json',
|
|
50
|
+
'Content-Type': 'application/json',
|
|
51
|
+
'x-csrf-token': 'csrf-token',
|
|
52
|
+
'public-key-pins': 'pin'
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
expect( result ).toEqual( {
|
|
56
|
+
Accept: 'application/json',
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
'x-csrf-token': 'csrf-token',
|
|
59
|
+
'public-key-pins': 'pin'
|
|
60
|
+
} );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
it( 'handles empty headers', () => {
|
|
64
|
+
expect( redactHeaders( {} ) ).toEqual( {} );
|
|
65
|
+
} );
|
|
66
|
+
} );
|
package/src/helpers/string.js
CHANGED
|
@@ -28,3 +28,13 @@ export const toUrlSafeBase64 = uuid => {
|
|
|
28
28
|
* @returns
|
|
29
29
|
*/
|
|
30
30
|
export const rxEscape = v => v.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
|
|
31
|
+
|
|
32
|
+
const URL_SCHEME_RX = /^[a-z][a-z\d+.-]*:\/\//i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Detects if string is an URL starting with `<protocol>://`
|
|
36
|
+
*
|
|
37
|
+
* @param {string} s
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export const isUrl = s => typeof s === 'string' && URL_SCHEME_RX.test( s.trim() ) && URL.canParse( s );
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { toUrlSafeBase64, rxEscape } from './string.js';
|
|
2
|
+
import { isUrl, toUrlSafeBase64, rxEscape } from './string.js';
|
|
3
3
|
|
|
4
4
|
describe( 'toUrlSafeBase64', () => {
|
|
5
5
|
const urlSafeAlphabet = /^[A-Za-z0-9_-]+$/;
|
|
@@ -62,3 +62,38 @@ describe( 'rxEscape', () => {
|
|
|
62
62
|
expect( rx.test( String.raw`C:\foo\bar\baz` ) ).toBe( false );
|
|
63
63
|
} );
|
|
64
64
|
} );
|
|
65
|
+
|
|
66
|
+
describe( 'isUrl', () => {
|
|
67
|
+
it.each( [
|
|
68
|
+
'https://example.com/path?token=secret#section',
|
|
69
|
+
'ftp://user:password@example.com/file.txt',
|
|
70
|
+
'ftps://example.com/file.txt',
|
|
71
|
+
'git+ssh://example.com/repository',
|
|
72
|
+
'custom.protocol-v2://example.com/resource'
|
|
73
|
+
] )( 'accepts a valid URL starting with a protocol: %s', value => {
|
|
74
|
+
expect( isUrl( value ) ).toBe( true );
|
|
75
|
+
} );
|
|
76
|
+
|
|
77
|
+
it.each( [
|
|
78
|
+
'Error: fetch failed',
|
|
79
|
+
'TypeError: invalid input',
|
|
80
|
+
'mailto:user@example.com',
|
|
81
|
+
'urn:isbn:978014',
|
|
82
|
+
'data:text/plain,hello'
|
|
83
|
+
] )( 'rejects non-authority URI-like strings: %s', value => {
|
|
84
|
+
expect( isUrl( value ) ).toBe( false );
|
|
85
|
+
} );
|
|
86
|
+
|
|
87
|
+
it.each( [
|
|
88
|
+
'//example.com/path',
|
|
89
|
+
'/relative/path',
|
|
90
|
+
'https://',
|
|
91
|
+
'not a URL'
|
|
92
|
+
] )( 'rejects malformed or relative values: %s', value => {
|
|
93
|
+
expect( isUrl( value ) ).toBe( false );
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
it.each( [ undefined, null, 42, new URL( 'https://example.com' ) ] )( 'rejects non-string values', value => {
|
|
97
|
+
expect( isUrl( value ) ).toBe( false );
|
|
98
|
+
} );
|
|
99
|
+
} );
|