autotel-web 1.1.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/LICENSE +21 -0
- package/README.md +921 -0
- package/dist/index.d.ts +310 -0
- package/dist/index.js +391 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
- package/src/functional.ts +197 -0
- package/src/index.ts +70 -0
- package/src/init.privacy.test.ts +179 -0
- package/src/init.ts +415 -0
- package/src/privacy.test.ts +404 -0
- package/src/privacy.ts +261 -0
- package/src/traceparent.test.ts +49 -0
- package/src/traceparent.ts +105 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { PrivacyManager, getDenialReason } from './privacy';
|
|
3
|
+
|
|
4
|
+
describe('PrivacyManager', () => {
|
|
5
|
+
describe('Do Not Track (DNT)', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
// Reset doNotTrack to null instead of deleting (read-only property)
|
|
8
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
9
|
+
value: null,
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should block injection when DNT is enabled and respectDoNotTrack is true', () => {
|
|
16
|
+
// Mock navigator.doNotTrack
|
|
17
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
18
|
+
value: '1',
|
|
19
|
+
configurable: true,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const manager = new PrivacyManager({
|
|
23
|
+
respectDoNotTrack: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(manager.shouldInjectTraceparent('https://api.example.com')).toBe(
|
|
27
|
+
false
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should allow injection when DNT is enabled but respectDoNotTrack is false', () => {
|
|
32
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
33
|
+
value: '1',
|
|
34
|
+
configurable: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const manager = new PrivacyManager({
|
|
38
|
+
respectDoNotTrack: false,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(manager.shouldInjectTraceparent('https://api.example.com')).toBe(
|
|
42
|
+
true
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should allow injection when DNT is disabled and respectDoNotTrack is true', () => {
|
|
47
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
48
|
+
value: '0',
|
|
49
|
+
configurable: true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const manager = new PrivacyManager({
|
|
53
|
+
respectDoNotTrack: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(manager.shouldInjectTraceparent('https://api.example.com')).toBe(
|
|
57
|
+
true
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should allow injection when DNT is not set', () => {
|
|
62
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
63
|
+
value: null,
|
|
64
|
+
configurable: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const manager = new PrivacyManager({
|
|
68
|
+
respectDoNotTrack: true,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(manager.shouldInjectTraceparent('https://api.example.com')).toBe(
|
|
72
|
+
true
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('Global Privacy Control (GPC)', () => {
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
// Reset globalPrivacyControl to undefined
|
|
80
|
+
Object.defineProperty(navigator, 'globalPrivacyControl', {
|
|
81
|
+
value: undefined,
|
|
82
|
+
configurable: true,
|
|
83
|
+
writable: true,
|
|
84
|
+
});
|
|
85
|
+
vi.restoreAllMocks();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should block injection when GPC is enabled and respectGPC is true', () => {
|
|
89
|
+
// Mock globalPrivacyControl property
|
|
90
|
+
Object.defineProperty(navigator, 'globalPrivacyControl', {
|
|
91
|
+
value: true,
|
|
92
|
+
configurable: true,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const manager = new PrivacyManager({
|
|
96
|
+
respectGPC: true,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(manager.shouldInjectTraceparent('https://api.example.com')).toBe(
|
|
100
|
+
false
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should allow injection when GPC is enabled but respectGPC is false', () => {
|
|
105
|
+
Object.defineProperty(navigator, 'globalPrivacyControl', {
|
|
106
|
+
value: true,
|
|
107
|
+
configurable: true,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const manager = new PrivacyManager({
|
|
111
|
+
respectGPC: false,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(manager.shouldInjectTraceparent('https://api.example.com')).toBe(
|
|
115
|
+
true
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should allow injection when GPC is disabled', () => {
|
|
120
|
+
Object.defineProperty(navigator, 'globalPrivacyControl', {
|
|
121
|
+
value: false,
|
|
122
|
+
configurable: true,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const manager = new PrivacyManager({
|
|
126
|
+
respectGPC: true,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(manager.shouldInjectTraceparent('https://api.example.com')).toBe(
|
|
130
|
+
true
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('Origin Blocklist', () => {
|
|
136
|
+
it('should block injection for origins in blockedOrigins', () => {
|
|
137
|
+
const manager = new PrivacyManager({
|
|
138
|
+
blockedOrigins: ['analytics.google.com', 'facebook.com'],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(
|
|
142
|
+
manager.shouldInjectTraceparent('https://analytics.google.com/collect')
|
|
143
|
+
).toBe(false);
|
|
144
|
+
expect(
|
|
145
|
+
manager.shouldInjectTraceparent('https://www.facebook.com/track')
|
|
146
|
+
).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should allow injection for origins not in blockedOrigins', () => {
|
|
150
|
+
const manager = new PrivacyManager({
|
|
151
|
+
blockedOrigins: ['analytics.google.com', 'facebook.com'],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(
|
|
155
|
+
manager.shouldInjectTraceparent('https://api.myapp.com/users')
|
|
156
|
+
).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should be case-insensitive', () => {
|
|
160
|
+
const manager = new PrivacyManager({
|
|
161
|
+
blockedOrigins: ['ANALYTICS.GOOGLE.COM'],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(
|
|
165
|
+
manager.shouldInjectTraceparent('https://analytics.google.com/collect')
|
|
166
|
+
).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should match subdomains', () => {
|
|
170
|
+
const manager = new PrivacyManager({
|
|
171
|
+
blockedOrigins: ['google.com'],
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(
|
|
175
|
+
manager.shouldInjectTraceparent('https://analytics.google.com/collect')
|
|
176
|
+
).toBe(false);
|
|
177
|
+
expect(
|
|
178
|
+
manager.shouldInjectTraceparent('https://www.google.com/search')
|
|
179
|
+
).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('Origin Allowlist', () => {
|
|
184
|
+
it('should allow injection only for origins in allowedOrigins', () => {
|
|
185
|
+
const manager = new PrivacyManager({
|
|
186
|
+
allowedOrigins: ['api.myapp.com', 'myapp.com'],
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(
|
|
190
|
+
manager.shouldInjectTraceparent('https://api.myapp.com/users')
|
|
191
|
+
).toBe(true);
|
|
192
|
+
expect(manager.shouldInjectTraceparent('https://myapp.com/api')).toBe(
|
|
193
|
+
true
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should block injection for origins not in allowedOrigins', () => {
|
|
198
|
+
const manager = new PrivacyManager({
|
|
199
|
+
allowedOrigins: ['api.myapp.com'],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(
|
|
203
|
+
manager.shouldInjectTraceparent('https://api.otherapp.com/data')
|
|
204
|
+
).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should be case-insensitive', () => {
|
|
208
|
+
const manager = new PrivacyManager({
|
|
209
|
+
allowedOrigins: ['API.MYAPP.COM'],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(
|
|
213
|
+
manager.shouldInjectTraceparent('https://api.myapp.com/users')
|
|
214
|
+
).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should match subdomains', () => {
|
|
218
|
+
const manager = new PrivacyManager({
|
|
219
|
+
allowedOrigins: ['myapp.com'],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(
|
|
223
|
+
manager.shouldInjectTraceparent('https://api.myapp.com/users')
|
|
224
|
+
).toBe(true);
|
|
225
|
+
expect(
|
|
226
|
+
manager.shouldInjectTraceparent('https://admin.myapp.com/dashboard')
|
|
227
|
+
).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('Blocklist + Allowlist Interaction', () => {
|
|
232
|
+
it('should prioritize blocklist over allowlist', () => {
|
|
233
|
+
const manager = new PrivacyManager({
|
|
234
|
+
allowedOrigins: ['myapp.com'],
|
|
235
|
+
blockedOrigins: ['analytics.myapp.com'],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Allowed domain
|
|
239
|
+
expect(
|
|
240
|
+
manager.shouldInjectTraceparent('https://api.myapp.com/users')
|
|
241
|
+
).toBe(true);
|
|
242
|
+
|
|
243
|
+
// Blocked domain (takes precedence even though myapp.com is in allowlist)
|
|
244
|
+
expect(
|
|
245
|
+
manager.shouldInjectTraceparent('https://analytics.myapp.com/track')
|
|
246
|
+
).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('Relative URLs', () => {
|
|
251
|
+
// Note: These tests use window.location which should be set by the test environment
|
|
252
|
+
|
|
253
|
+
it('should handle relative URLs by using window.location', () => {
|
|
254
|
+
if (typeof window === 'undefined' || !window.location) {
|
|
255
|
+
// Skip test if window is not available
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Get the actual origin from the test environment
|
|
260
|
+
const testOrigin = new URL('/api/users', window.location.href).origin;
|
|
261
|
+
|
|
262
|
+
const manager = new PrivacyManager({
|
|
263
|
+
allowedOrigins: [testOrigin], // Use actual test environment origin
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// In test environment, relative URLs resolve to window.location.origin
|
|
267
|
+
expect(manager.shouldInjectTraceparent('/api/users')).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should block relative URLs if origin is blocked', () => {
|
|
271
|
+
if (typeof window === 'undefined' || !window.location) {
|
|
272
|
+
// Skip test if window is not available
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Get the actual origin from the test environment
|
|
277
|
+
const testOrigin = new URL('/api/users', window.location.href).origin;
|
|
278
|
+
|
|
279
|
+
const manager = new PrivacyManager({
|
|
280
|
+
blockedOrigins: [testOrigin], // Block the actual test environment origin
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(manager.shouldInjectTraceparent('/api/users')).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('Edge Cases', () => {
|
|
288
|
+
it('should allow all origins when no privacy config provided', () => {
|
|
289
|
+
const manager = new PrivacyManager({});
|
|
290
|
+
|
|
291
|
+
expect(
|
|
292
|
+
manager.shouldInjectTraceparent('https://api.example.com')
|
|
293
|
+
).toBe(true);
|
|
294
|
+
expect(
|
|
295
|
+
manager.shouldInjectTraceparent('https://analytics.google.com')
|
|
296
|
+
).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should handle invalid URLs gracefully', () => {
|
|
300
|
+
const manager = new PrivacyManager({
|
|
301
|
+
allowedOrigins: ['myapp.com'],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Invalid URL should not throw, just return false (can't match origin)
|
|
305
|
+
expect(manager.shouldInjectTraceparent('not-a-valid-url')).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should handle empty origin lists', () => {
|
|
309
|
+
const manager = new PrivacyManager({
|
|
310
|
+
allowedOrigins: [],
|
|
311
|
+
blockedOrigins: [],
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Empty allowlist means no explicit allowlist, so default to allow
|
|
315
|
+
expect(
|
|
316
|
+
manager.shouldInjectTraceparent('https://api.example.com')
|
|
317
|
+
).toBe(true);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('getDenialReason', () => {
|
|
322
|
+
afterEach(() => {
|
|
323
|
+
// Reset properties instead of deleting (read-only properties)
|
|
324
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
325
|
+
value: null,
|
|
326
|
+
configurable: true,
|
|
327
|
+
writable: true,
|
|
328
|
+
});
|
|
329
|
+
Object.defineProperty(navigator, 'globalPrivacyControl', {
|
|
330
|
+
value: undefined,
|
|
331
|
+
configurable: true,
|
|
332
|
+
writable: true,
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should return DNT reason when DNT is enabled', () => {
|
|
337
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
338
|
+
value: '1',
|
|
339
|
+
configurable: true,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const manager = new PrivacyManager({
|
|
343
|
+
respectDoNotTrack: true,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const reason = getDenialReason(manager, 'https://api.example.com');
|
|
347
|
+
expect(reason).toBe('Do Not Track is enabled');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should return GPC reason when GPC is enabled', () => {
|
|
351
|
+
Object.defineProperty(navigator, 'globalPrivacyControl', {
|
|
352
|
+
value: true,
|
|
353
|
+
configurable: true,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const manager = new PrivacyManager({
|
|
357
|
+
respectGPC: true,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const reason = getDenialReason(manager, 'https://api.example.com');
|
|
361
|
+
expect(reason).toBe('Global Privacy Control is enabled');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should return blocklist reason when origin is blocked', () => {
|
|
365
|
+
const manager = new PrivacyManager({
|
|
366
|
+
blockedOrigins: ['analytics.google.com'],
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const reason = getDenialReason(
|
|
370
|
+
manager,
|
|
371
|
+
'https://analytics.google.com/collect'
|
|
372
|
+
);
|
|
373
|
+
expect(reason).toContain('is in blockedOrigins list');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should return allowlist reason when origin is not allowed', () => {
|
|
377
|
+
const manager = new PrivacyManager({
|
|
378
|
+
allowedOrigins: ['myapp.com'],
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const reason = getDenialReason(manager, 'https://otherapp.com/api');
|
|
382
|
+
expect(reason).toContain('is not in allowedOrigins list');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should return null when injection is allowed', () => {
|
|
386
|
+
const manager = new PrivacyManager({
|
|
387
|
+
allowedOrigins: ['myapp.com'],
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const reason = getDenialReason(manager, 'https://api.myapp.com/users');
|
|
391
|
+
expect(reason).toBeNull();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('should return invalid URL reason for invalid URLs', () => {
|
|
395
|
+
const manager = new PrivacyManager({});
|
|
396
|
+
|
|
397
|
+
const reason = getDenialReason(manager, 'not-a-url');
|
|
398
|
+
// In test environment, "not-a-url" gets resolved as a relative URL
|
|
399
|
+
// using window.location, so it doesn't fail URL construction
|
|
400
|
+
// This test verifies the function doesn't throw, even if result is null
|
|
401
|
+
expect(reason).toBeNull(); // No privacy rules violated for relative URLs without restrictions
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
});
|
package/src/privacy.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy controls for autotel-web
|
|
3
|
+
*
|
|
4
|
+
* Provides origin filtering and privacy signal respecting (DNT, GPC)
|
|
5
|
+
* to ensure compliance with GDPR, CCPA, and user privacy preferences.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PrivacyConfig {
|
|
9
|
+
/**
|
|
10
|
+
* Only inject traceparent headers on requests to these origins (whitelist)
|
|
11
|
+
*
|
|
12
|
+
* If specified, traceparent will ONLY be injected on matching origins.
|
|
13
|
+
* Origins are matched using substring matching (e.g., "example.com" matches "https://api.example.com").
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* {
|
|
18
|
+
* allowedOrigins: ['api.myapp.com', 'myapp.com']
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
allowedOrigins?: string[];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Never inject traceparent headers on requests to these origins (blacklist)
|
|
26
|
+
*
|
|
27
|
+
* Takes precedence over allowedOrigins.
|
|
28
|
+
* Origins are matched using substring matching.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* {
|
|
33
|
+
* blockedOrigins: ['analytics.google.com', 'facebook.com']
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
blockedOrigins?: string[];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Respect the Do Not Track (DNT) browser setting
|
|
41
|
+
*
|
|
42
|
+
* If true and user has DNT enabled, no traceparent headers will be injected.
|
|
43
|
+
*
|
|
44
|
+
* @default false
|
|
45
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack
|
|
46
|
+
*/
|
|
47
|
+
respectDoNotTrack?: boolean;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Respect the Global Privacy Control (GPC) browser signal
|
|
51
|
+
*
|
|
52
|
+
* If true and user has GPC enabled, no traceparent headers will be injected.
|
|
53
|
+
*
|
|
54
|
+
* @default false
|
|
55
|
+
* @see https://globalprivacycontrol.org/
|
|
56
|
+
*/
|
|
57
|
+
respectGPC?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Manages privacy controls for traceparent header injection
|
|
62
|
+
*
|
|
63
|
+
* Checks user privacy preferences (DNT, GPC) and origin filtering rules
|
|
64
|
+
* to determine if traceparent headers should be injected on a given request.
|
|
65
|
+
*/
|
|
66
|
+
export class PrivacyManager {
|
|
67
|
+
constructor(private readonly config: PrivacyConfig) {}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if traceparent header should be injected for a given URL
|
|
71
|
+
*
|
|
72
|
+
* Decision order:
|
|
73
|
+
* 1. Check Do Not Track (if enabled)
|
|
74
|
+
* 2. Check Global Privacy Control (if enabled)
|
|
75
|
+
* 3. Check blockedOrigins (explicit deny)
|
|
76
|
+
* 4. Check allowedOrigins (explicit allow, if configured)
|
|
77
|
+
* 5. Default: allow
|
|
78
|
+
*
|
|
79
|
+
* @param url - Full URL or relative path of the request
|
|
80
|
+
* @returns true if traceparent should be injected, false otherwise
|
|
81
|
+
*/
|
|
82
|
+
shouldInjectTraceparent(url: string): boolean {
|
|
83
|
+
// Check Do Not Track
|
|
84
|
+
if (this.config.respectDoNotTrack && this.isDoNotTrackEnabled()) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check Global Privacy Control
|
|
89
|
+
if (this.config.respectGPC && this.isGPCEnabled()) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Get the origin of the target URL
|
|
94
|
+
const targetOrigin = this.extractOrigin(url);
|
|
95
|
+
|
|
96
|
+
// Check blocklist first (explicit deny takes precedence)
|
|
97
|
+
if (
|
|
98
|
+
this.config.blockedOrigins &&
|
|
99
|
+
this.matchesAnyOrigin(targetOrigin, this.config.blockedOrigins)
|
|
100
|
+
) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If allowlist exists, only allow those origins
|
|
105
|
+
if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) {
|
|
106
|
+
return this.matchesAnyOrigin(targetOrigin, this.config.allowedOrigins);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Default: allow (backward compatible behavior)
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if Do Not Track is enabled in the browser
|
|
115
|
+
*/
|
|
116
|
+
private isDoNotTrackEnabled(): boolean {
|
|
117
|
+
if (typeof navigator === 'undefined') return false;
|
|
118
|
+
|
|
119
|
+
// DNT header can be "1" (enabled), "0" (disabled), or null (not set)
|
|
120
|
+
return navigator.doNotTrack === '1';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if Global Privacy Control is enabled in the browser
|
|
125
|
+
*/
|
|
126
|
+
private isGPCEnabled(): boolean {
|
|
127
|
+
if (typeof navigator === 'undefined') return false;
|
|
128
|
+
|
|
129
|
+
// GPC is a newer spec, not all browsers support it yet
|
|
130
|
+
// TypeScript doesn't have types for this yet, so we cast
|
|
131
|
+
const nav = navigator as Navigator & { globalPrivacyControl?: boolean };
|
|
132
|
+
return nav.globalPrivacyControl === true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Extract origin from a URL (handles both absolute and relative URLs)
|
|
137
|
+
*
|
|
138
|
+
* @param url - Full URL or relative path
|
|
139
|
+
* @returns Origin string (e.g., "https://api.example.com")
|
|
140
|
+
*/
|
|
141
|
+
private extractOrigin(url: string): string {
|
|
142
|
+
try {
|
|
143
|
+
// Handle absolute URLs
|
|
144
|
+
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
145
|
+
return new URL(url).origin;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Handle relative URLs - use current window location
|
|
149
|
+
if (typeof window !== 'undefined') {
|
|
150
|
+
return new URL(url, window.location.href).origin;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Fallback for SSR or unknown cases
|
|
154
|
+
return '';
|
|
155
|
+
} catch {
|
|
156
|
+
// Invalid URL - return empty string
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check if a target origin matches any of the configured origins
|
|
163
|
+
*
|
|
164
|
+
* Uses substring matching for flexibility (e.g., "example.com" matches "https://api.example.com")
|
|
165
|
+
*
|
|
166
|
+
* @param targetOrigin - Origin to check
|
|
167
|
+
* @param configuredOrigins - List of allowed or blocked origins
|
|
168
|
+
* @returns true if any origin matches
|
|
169
|
+
*/
|
|
170
|
+
private matchesAnyOrigin(
|
|
171
|
+
targetOrigin: string,
|
|
172
|
+
configuredOrigins: string[]
|
|
173
|
+
): boolean {
|
|
174
|
+
return configuredOrigins.some((configuredOrigin) => {
|
|
175
|
+
// Normalize both strings to lowercase for case-insensitive matching
|
|
176
|
+
const normalizedTarget = targetOrigin.toLowerCase();
|
|
177
|
+
const normalizedConfigured = configuredOrigin.toLowerCase();
|
|
178
|
+
|
|
179
|
+
// Check if target origin contains the configured origin
|
|
180
|
+
// This allows "example.com" to match "https://api.example.com"
|
|
181
|
+
return normalizedTarget.includes(normalizedConfigured);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get reason why traceparent injection was denied (for debugging)
|
|
188
|
+
*
|
|
189
|
+
* Returns a human-readable reason if injection would be blocked,
|
|
190
|
+
* or null if injection would be allowed.
|
|
191
|
+
*
|
|
192
|
+
* @param privacyManager - Configured PrivacyManager instance
|
|
193
|
+
* @param url - URL to check
|
|
194
|
+
* @returns Denial reason or null if allowed
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```typescript
|
|
198
|
+
* const manager = new PrivacyManager({ respectDoNotTrack: true })
|
|
199
|
+
* const reason = getDenialReason(manager, 'https://api.example.com')
|
|
200
|
+
* if (reason) {
|
|
201
|
+
* console.log('Traceparent blocked:', reason)
|
|
202
|
+
* }
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
export function getDenialReason(
|
|
206
|
+
privacyManager: PrivacyManager,
|
|
207
|
+
url: string
|
|
208
|
+
): string | null {
|
|
209
|
+
// This is a helper for debugging - it re-checks the conditions
|
|
210
|
+
// to provide a user-friendly reason string
|
|
211
|
+
const config = (privacyManager as any).config as PrivacyConfig;
|
|
212
|
+
|
|
213
|
+
// Check DNT
|
|
214
|
+
if (config.respectDoNotTrack && typeof navigator !== 'undefined') {
|
|
215
|
+
if (navigator.doNotTrack === '1') {
|
|
216
|
+
return 'Do Not Track is enabled';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check GPC
|
|
221
|
+
if (config.respectGPC && typeof navigator !== 'undefined') {
|
|
222
|
+
const nav = navigator as Navigator & { globalPrivacyControl?: boolean };
|
|
223
|
+
if (nav.globalPrivacyControl === true) {
|
|
224
|
+
return 'Global Privacy Control is enabled';
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Extract origin
|
|
229
|
+
let targetOrigin = '';
|
|
230
|
+
try {
|
|
231
|
+
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
232
|
+
targetOrigin = new URL(url).origin;
|
|
233
|
+
} else if (typeof window !== 'undefined') {
|
|
234
|
+
targetOrigin = new URL(url, window.location.href).origin;
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
return 'Invalid URL';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check blocklist
|
|
241
|
+
if (config.blockedOrigins) {
|
|
242
|
+
const blocked = config.blockedOrigins.some((origin) =>
|
|
243
|
+
targetOrigin.toLowerCase().includes(origin.toLowerCase())
|
|
244
|
+
);
|
|
245
|
+
if (blocked) {
|
|
246
|
+
return `Origin ${targetOrigin} is in blockedOrigins list`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Check allowlist
|
|
251
|
+
if (config.allowedOrigins && config.allowedOrigins.length > 0) {
|
|
252
|
+
const allowed = config.allowedOrigins.some((origin) =>
|
|
253
|
+
targetOrigin.toLowerCase().includes(origin.toLowerCase())
|
|
254
|
+
);
|
|
255
|
+
if (!allowed) {
|
|
256
|
+
return `Origin ${targetOrigin} is not in allowedOrigins list`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
generateTraceId,
|
|
4
|
+
generateSpanId,
|
|
5
|
+
createTraceparent,
|
|
6
|
+
parseTraceparent,
|
|
7
|
+
} from './traceparent';
|
|
8
|
+
|
|
9
|
+
describe('traceparent generation', () => {
|
|
10
|
+
it('should generate valid trace IDs (32 hex chars)', () => {
|
|
11
|
+
const traceId = generateTraceId();
|
|
12
|
+
expect(traceId).toHaveLength(32);
|
|
13
|
+
expect(traceId).toMatch(/^[0-9a-f]{32}$/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should generate valid span IDs (16 hex chars)', () => {
|
|
17
|
+
const spanId = generateSpanId();
|
|
18
|
+
expect(spanId).toHaveLength(16);
|
|
19
|
+
expect(spanId).toMatch(/^[0-9a-f]{16}$/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should create valid W3C traceparent header', () => {
|
|
23
|
+
const traceparent = createTraceparent();
|
|
24
|
+
expect(traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should use provided trace ID when given', () => {
|
|
28
|
+
const customTraceId = '4bf92f3577b34da6a3ce929d0e0e4736';
|
|
29
|
+
const traceparent = createTraceparent(customTraceId);
|
|
30
|
+
expect(traceparent).toContain(customTraceId);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should parse valid traceparent header', () => {
|
|
34
|
+
const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
|
|
35
|
+
const parsed = parseTraceparent(traceparent);
|
|
36
|
+
|
|
37
|
+
expect(parsed).not.toBeNull();
|
|
38
|
+
expect(parsed?.version).toBe('00');
|
|
39
|
+
expect(parsed?.traceId).toBe('4bf92f3577b34da6a3ce929d0e0e4736');
|
|
40
|
+
expect(parsed?.spanId).toBe('00f067aa0ba902b7');
|
|
41
|
+
expect(parsed?.flags).toBe('01');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return null for invalid traceparent', () => {
|
|
45
|
+
expect(parseTraceparent('invalid')).toBeNull();
|
|
46
|
+
expect(parseTraceparent('00-abc-def-01')).toBeNull();
|
|
47
|
+
expect(parseTraceparent('00-toolong-00f067aa0ba902b7-01')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|