@consentera/consent-sdk 2.0.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/LICENSE +21 -0
  3. package/README.md +489 -0
  4. package/dist/consentera-consent.cjs +4919 -0
  5. package/dist/consentera-consent.cjs.map +1 -0
  6. package/dist/consentera-consent.min.js +2 -0
  7. package/dist/consentera-consent.min.js.map +1 -0
  8. package/dist/consentera-consent.mjs +4864 -0
  9. package/dist/consentera-consent.mjs.map +1 -0
  10. package/dist/react/index.cjs +2731 -0
  11. package/dist/react/index.cjs.map +1 -0
  12. package/dist/react/index.mjs +2724 -0
  13. package/dist/react/index.mjs.map +1 -0
  14. package/dist/types/consent/CallbackHandler.d.ts +246 -0
  15. package/dist/types/consent/ConsentManager.d.ts +128 -0
  16. package/dist/types/consent/ConsentSession.d.ts +127 -0
  17. package/dist/types/consent/ConsentValidator.d.ts +63 -0
  18. package/dist/types/consent/artifactRead.d.ts +48 -0
  19. package/dist/types/consent/consentPopup.d.ts +115 -0
  20. package/dist/types/core/ConsentEraClient.d.ts +106 -0
  21. package/dist/types/core/ConsenteraConsent.d.ts +163 -0
  22. package/dist/types/core/errors.d.ts +108 -0
  23. package/dist/types/core/http.d.ts +176 -0
  24. package/dist/types/core/version.d.ts +36 -0
  25. package/dist/types/df/DFConfigClient.d.ts +59 -0
  26. package/dist/types/gcm/ConsentModeBridge.d.ts +54 -0
  27. package/dist/types/gpp/GPPManager.d.ts +62 -0
  28. package/dist/types/index.d.mts +5 -0
  29. package/dist/types/index.d.ts +28 -0
  30. package/dist/types/principal/PrincipalClient.d.ts +34 -0
  31. package/dist/types/react/ConsentEraProvider.d.ts +58 -0
  32. package/dist/types/react/ConsentGate.d.ts +40 -0
  33. package/dist/types/react/index.d.mts +4 -0
  34. package/dist/types/react/index.d.ts +10 -0
  35. package/dist/types/react/useConsentEra.d.ts +65 -0
  36. package/dist/types/react/useConsentValidation.d.ts +23 -0
  37. package/dist/types/storage/ConsentStorage.d.ts +39 -0
  38. package/dist/types/tcf/TCFManager.d.ts +46 -0
  39. package/dist/types/types/consent-lifecycle.d.ts +804 -0
  40. package/dist/types/types/index.d.ts +311 -0
  41. package/dist/types/ui/ConsentBanner.d.ts +22 -0
  42. package/dist/types/ui/PreferenceCenter.d.ts +24 -0
  43. package/dist/types/utils/EventEmitter.d.ts +32 -0
  44. package/dist/types/utils/Logger.d.ts +16 -0
  45. package/dist/types/utils/browserStorage.d.ts +35 -0
  46. package/dist/types/utils/context.d.ts +81 -0
  47. package/dist/types/utils/helpers.d.ts +48 -0
  48. package/package.json +132 -0
@@ -0,0 +1,4864 @@
1
+ /**
2
+ * Consentera Consent SDK — guarded browser storage.
3
+ *
4
+ * EVERY ACCESS IS WRAPPED, AND THAT IS NOT DEFENSIVENESS. `localStorage` and
5
+ * `sessionStorage` THROW rather than return null in ordinary, common
6
+ * configurations: Safari's private browsing on old versions, a site with
7
+ * cookies blocked, a sandboxed iframe without `allow-same-origin`, a quota that
8
+ * is full. Before 2.0.0 `ConsentStorage.save()` called `localStorage.setItem`
9
+ * bare, so a visitor with storage blocked did not get a degraded banner — they
10
+ * got an exception out of the middle of a consent write.
11
+ *
12
+ * ONE implementation, so the "did we remember to try/catch this one" question
13
+ * is asked once. `ConsenteraConsent.getOrCreateDeviceId` already had the right
14
+ * shape; it was the only place that did.
15
+ *
16
+ * When storage is unavailable the SDK degrades to a per-page memory map: the
17
+ * flow still works within the page, it just does not survive a reload. That is
18
+ * the correct trade for a consent handshake, which is short-lived.
19
+ */
20
+ const memory = {
21
+ local: new Map(),
22
+ session: new Map(),
23
+ };
24
+ /** The one place key names are spelled, so a reader and a writer cannot drift. */
25
+ const storageKeys = {
26
+ session: (id) => `consentera_session_${id}`,
27
+ callback: (id) => `consentera_callback_${id}`,
28
+ pendingSync: 'consentera_pending_sync',
29
+ deviceId: 'consentera_device_id',
30
+ };
31
+ function backing(kind) {
32
+ try {
33
+ if (typeof window === 'undefined')
34
+ return null;
35
+ const s = kind === 'local' ? window.localStorage : window.sessionStorage;
36
+ // Touching the object is itself what throws in a blocked context, so the
37
+ // probe has to be a real operation rather than a truthiness check.
38
+ const probe = '__consentera_probe__';
39
+ s.setItem(probe, '1');
40
+ s.removeItem(probe);
41
+ return s;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** True when the real browser store is usable; false when we are on memory. */
48
+ function storageAvailable(kind) {
49
+ return backing(kind) !== null;
50
+ }
51
+ function readStored(kind, key) {
52
+ const s = backing(kind);
53
+ if (!s)
54
+ return memory[kind].get(key) ?? null;
55
+ try {
56
+ return s.getItem(key);
57
+ }
58
+ catch {
59
+ return memory[kind].get(key) ?? null;
60
+ }
61
+ }
62
+ /** Returns false when the value could not be persisted anywhere durable. */
63
+ function writeStored(kind, key, value) {
64
+ const s = backing(kind);
65
+ if (s) {
66
+ try {
67
+ s.setItem(key, value);
68
+ return true;
69
+ }
70
+ catch {
71
+ /* quota or policy — fall through to memory */
72
+ }
73
+ }
74
+ memory[kind].set(key, value);
75
+ return false;
76
+ }
77
+ function removeStored(kind, key) {
78
+ const s = backing(kind);
79
+ if (s) {
80
+ try {
81
+ s.removeItem(key);
82
+ }
83
+ catch {
84
+ /* ignore */
85
+ }
86
+ }
87
+ memory[kind].delete(key);
88
+ }
89
+
90
+ /**
91
+ * Consent Storage Module
92
+ * Handles persistence of consent data
93
+ */
94
+ /**
95
+ * A cookie over this many bytes is silently dropped by every browser (the
96
+ * per-cookie limit is 4096 bytes including the name and attributes). Dropped
97
+ * silently means `exists()` stays false forever and the banner returns on every
98
+ * page view, with nothing anywhere saying why.
99
+ */
100
+ const COOKIE_BYTE_LIMIT = 4096;
101
+ class ConsentStorage {
102
+ config;
103
+ key;
104
+ constructor(config) {
105
+ this.config = {
106
+ type: 'cookie',
107
+ cookieName: 'consentera_consent',
108
+ cookieExpiry: 365,
109
+ cookiePath: '/',
110
+ // `secure` BY PROTOCOL, not unconditionally. A `; secure` cookie on an
111
+ // http:// origin is dropped by every browser WITHOUT AN ERROR, so a
112
+ // hardcoded true meant the consent cookie never stored on a developer's
113
+ // http://localhost — exists() stayed false, the banner returned on every
114
+ // page view, and nothing said why. https stays secure, which is the case
115
+ // that matters.
116
+ secure: typeof location !== 'undefined' ? location.protocol === 'https:' : true,
117
+ sameSite: 'Lax',
118
+ ...config,
119
+ };
120
+ this.key = this.config.cookieName;
121
+ }
122
+ /**
123
+ * Save consent data
124
+ */
125
+ save(consent) {
126
+ const data = JSON.stringify(consent);
127
+ switch (this.config.type) {
128
+ case 'cookie':
129
+ return this.setCookie(data);
130
+ case 'localStorage':
131
+ return writeStored('local', this.key, data);
132
+ case 'sessionStorage':
133
+ return writeStored('session', this.key, data);
134
+ default:
135
+ return false;
136
+ }
137
+ }
138
+ /**
139
+ * Get consent data
140
+ */
141
+ get() {
142
+ let data = null;
143
+ switch (this.config.type) {
144
+ case 'cookie':
145
+ data = this.getCookie();
146
+ break;
147
+ case 'localStorage':
148
+ data = readStored('local', this.key);
149
+ break;
150
+ case 'sessionStorage':
151
+ data = readStored('session', this.key);
152
+ break;
153
+ }
154
+ if (!data)
155
+ return null;
156
+ try {
157
+ return JSON.parse(data);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ /**
164
+ * Check if consent exists
165
+ */
166
+ exists() {
167
+ return this.get() !== null;
168
+ }
169
+ /**
170
+ * Clear consent data
171
+ */
172
+ clear() {
173
+ switch (this.config.type) {
174
+ case 'cookie':
175
+ this.deleteCookie();
176
+ break;
177
+ case 'localStorage':
178
+ removeStored('local', this.key);
179
+ break;
180
+ case 'sessionStorage':
181
+ removeStored('session', this.key);
182
+ break;
183
+ }
184
+ }
185
+ /**
186
+ * Set cookie
187
+ */
188
+ /** @returns false when the value could not be stored (and says why). */
189
+ setCookie(value) {
190
+ const { cookieExpiry, cookiePath, cookieDomain, secure, sameSite } = this.config;
191
+ let cookie = `${encodeURIComponent(this.key)}=${encodeURIComponent(value)}`;
192
+ if (cookieExpiry) {
193
+ const date = new Date();
194
+ date.setTime(date.getTime() + cookieExpiry * 24 * 60 * 60 * 1000);
195
+ cookie += `; expires=${date.toUTCString()}`;
196
+ }
197
+ if (cookiePath) {
198
+ cookie += `; path=${cookiePath}`;
199
+ }
200
+ if (cookieDomain) {
201
+ cookie += `; domain=${cookieDomain}`;
202
+ }
203
+ if (secure) {
204
+ cookie += '; secure';
205
+ }
206
+ if (sameSite) {
207
+ cookie += `; samesite=${sameSite}`;
208
+ }
209
+ // OVER THE LIMIT THE BROWSER DROPS IT WITHOUT A WORD. Refusing here, loudly,
210
+ // beats a consent record that appears to be saved and is not.
211
+ const size = new Blob([cookie]).size;
212
+ if (size > COOKIE_BYTE_LIMIT) {
213
+ // eslint-disable-next-line no-console
214
+ console.error(`[Consentera] the consent cookie is ${size} bytes, over the ${COOKIE_BYTE_LIMIT}-byte ` +
215
+ 'browser limit, and would be dropped silently. Use storage.type "localStorage", or ' +
216
+ 'reduce the number of purposes stored on the device.');
217
+ return false;
218
+ }
219
+ if (typeof document === 'undefined')
220
+ return false;
221
+ try {
222
+ document.cookie = cookie;
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ return this.getCookie() !== null;
228
+ }
229
+ /**
230
+ * Get cookie value
231
+ */
232
+ getCookie() {
233
+ if (typeof document === 'undefined')
234
+ return null;
235
+ const name = encodeURIComponent(this.key) + '=';
236
+ const cookies = document.cookie.split(';');
237
+ for (let cookie of cookies) {
238
+ cookie = cookie.trim();
239
+ if (cookie.indexOf(name) === 0) {
240
+ return decodeURIComponent(cookie.substring(name.length));
241
+ }
242
+ }
243
+ return null;
244
+ }
245
+ /**
246
+ * Delete cookie
247
+ */
248
+ deleteCookie() {
249
+ if (typeof document === 'undefined')
250
+ return;
251
+ const { cookiePath, cookieDomain } = this.config;
252
+ let cookie = `${encodeURIComponent(this.key)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
253
+ if (cookiePath) {
254
+ cookie += `; path=${cookiePath}`;
255
+ }
256
+ if (cookieDomain) {
257
+ cookie += `; domain=${cookieDomain}`;
258
+ }
259
+ document.cookie = cookie;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Consent Banner UI Component
265
+ */
266
+ // SEC-H10 FIX: Escape HTML entities in server content before innerHTML assignment.
267
+ function escapeHtml$1(str) {
268
+ const div = document.createElement('div');
269
+ div.textContent = str;
270
+ return div.innerHTML;
271
+ }
272
+ // Sanitize a URL for use in href attributes (allow only http/https/mailto).
273
+ function sanitizeUrl$1(url) {
274
+ try {
275
+ const parsed = new URL(url, window.location.origin);
276
+ if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
277
+ return parsed.href;
278
+ }
279
+ }
280
+ catch {
281
+ // invalid URL
282
+ }
283
+ return '#';
284
+ }
285
+ class ConsentBanner {
286
+ config;
287
+ theme;
288
+ sdk;
289
+ container = null;
290
+ isVisible = false;
291
+ constructor(config, theme, sdk) {
292
+ this.config = config;
293
+ this.theme = theme;
294
+ this.sdk = sdk;
295
+ }
296
+ show() {
297
+ if (this.isVisible)
298
+ return;
299
+ this.createBanner();
300
+ this.isVisible = true;
301
+ }
302
+ hide() {
303
+ if (!this.isVisible || !this.container)
304
+ return;
305
+ this.container.classList.add('consentera-banner--closing');
306
+ setTimeout(() => {
307
+ this.container?.remove();
308
+ this.container = null;
309
+ this.isVisible = false;
310
+ }, 300);
311
+ }
312
+ createBanner() {
313
+ // Create container
314
+ this.container = document.createElement('div');
315
+ this.container.id = 'consentera-consent-banner';
316
+ this.container.className = `consentera-banner consentera-banner--${this.theme.position || 'bottom'}`;
317
+ this.container.setAttribute('role', 'dialog');
318
+ this.container.setAttribute('aria-modal', 'true');
319
+ this.container.setAttribute('aria-labelledby', 'consentera-banner-title');
320
+ // Inject styles
321
+ this.injectStyles();
322
+ // Create content
323
+ this.container.innerHTML = this.getBannerHTML();
324
+ // Add event listeners
325
+ this.attachEventListeners();
326
+ // Append to body
327
+ document.body.appendChild(this.container);
328
+ // Trigger animation
329
+ requestAnimationFrame(() => {
330
+ this.container?.classList.add('consentera-banner--visible');
331
+ });
332
+ }
333
+ getBannerHTML() {
334
+ const { banner } = this.config;
335
+ return `
336
+ <div class="consentera-banner__content">
337
+ ${this.theme.showLogo ? `
338
+ <div class="consentera-banner__logo">
339
+ ${this.theme.logoUrl
340
+ ? `<img src="${sanitizeUrl$1(this.theme.logoUrl)}" alt="Logo" />`
341
+ : this.getDefaultLogo()}
342
+ </div>
343
+ ` : ''}
344
+
345
+ <div class="consentera-banner__text">
346
+ <h2 id="consentera-banner-title" class="consentera-banner__title">
347
+ ${escapeHtml$1(banner.title || 'We value your privacy')}
348
+ </h2>
349
+ <p class="consentera-banner__description">
350
+ ${escapeHtml$1(banner.description || 'We use cookies to enhance your experience.')}
351
+ </p>
352
+ ${banner.showPrivacyPolicy && banner.privacyPolicyUrl ? `
353
+ <a href="${sanitizeUrl$1(banner.privacyPolicyUrl)}" target="_blank" rel="noopener" class="consentera-banner__link">
354
+ Privacy Policy
355
+ </a>
356
+ ` : ''}
357
+ </div>
358
+
359
+ <div class="consentera-banner__actions">
360
+ ${banner.showCustomize ? `
361
+ <button type="button" class="consentera-btn consentera-btn--secondary" data-action="customize">
362
+ ${escapeHtml$1(banner.customizeText || 'Customize')}
363
+ </button>
364
+ ` : ''}
365
+ ${banner.showRejectAll ? `
366
+ <button type="button" class="consentera-btn consentera-btn--secondary" data-action="reject">
367
+ ${escapeHtml$1(banner.rejectAllText || 'Reject All')}
368
+ </button>
369
+ ` : ''}
370
+ <button type="button" class="consentera-btn consentera-btn--primary" data-action="accept">
371
+ ${escapeHtml$1(banner.acceptAllText || 'Accept All')}
372
+ </button>
373
+ </div>
374
+ </div>
375
+ `;
376
+ }
377
+ getDefaultLogo() {
378
+ return `
379
+ <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
380
+ <rect width="32" height="32" rx="8" fill="${this.theme.primaryColor || '#2563eb'}"/>
381
+ <path d="M16 6L24 10V16C24 21.52 20.6 26.74 16 28C11.4 26.74 8 21.52 8 16V10L16 6Z"
382
+ stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
383
+ <path d="M12 16L14.5 18.5L20 13"
384
+ stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
385
+ </svg>
386
+ `;
387
+ }
388
+ attachEventListeners() {
389
+ if (!this.container)
390
+ return;
391
+ this.container.addEventListener('click', (e) => {
392
+ const target = e.target;
393
+ const action = target.dataset.action;
394
+ if (!action)
395
+ return;
396
+ switch (action) {
397
+ case 'accept':
398
+ this.sdk.acceptAll();
399
+ break;
400
+ case 'reject':
401
+ this.sdk.rejectAll();
402
+ break;
403
+ case 'customize':
404
+ this.hide();
405
+ this.sdk.showPreferences();
406
+ break;
407
+ }
408
+ });
409
+ // Handle escape key
410
+ document.addEventListener('keydown', this.handleKeyDown.bind(this));
411
+ }
412
+ handleKeyDown(e) {
413
+ if (e.key === 'Escape' && this.isVisible) ;
414
+ }
415
+ injectStyles() {
416
+ if (document.getElementById('consentera-consent-styles'))
417
+ return;
418
+ const styleElement = document.createElement('style');
419
+ styleElement.id = 'consentera-consent-styles';
420
+ styleElement.textContent = this.getStyles();
421
+ document.head.appendChild(styleElement);
422
+ }
423
+ getStyles() {
424
+ const { primaryColor, backgroundColor, textColor, borderRadius, fontFamily } = this.theme;
425
+ return `
426
+ .consentera-banner {
427
+ position: fixed;
428
+ left: 0;
429
+ right: 0;
430
+ z-index: 999999;
431
+ font-family: ${fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'};
432
+ opacity: 0;
433
+ transform: translateY(100%);
434
+ transition: opacity 0.3s ease, transform 0.3s ease;
435
+ }
436
+
437
+ .consentera-banner--bottom {
438
+ bottom: 0;
439
+ }
440
+
441
+ .consentera-banner--top {
442
+ top: 0;
443
+ transform: translateY(-100%);
444
+ }
445
+
446
+ .consentera-banner--visible {
447
+ opacity: 1;
448
+ transform: translateY(0);
449
+ }
450
+
451
+ .consentera-banner--closing {
452
+ opacity: 0;
453
+ transform: translateY(100%);
454
+ }
455
+
456
+ .consentera-banner--top.consentera-banner--closing {
457
+ transform: translateY(-100%);
458
+ }
459
+
460
+ .consentera-banner__content {
461
+ display: flex;
462
+ align-items: center;
463
+ gap: 24px;
464
+ padding: 20px 24px;
465
+ background: ${backgroundColor || '#ffffff'};
466
+ color: ${textColor || '#1f2937'};
467
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
468
+ border-radius: ${borderRadius || '0'} ${borderRadius || '0'} 0 0;
469
+ }
470
+
471
+ .consentera-banner--top .consentera-banner__content {
472
+ border-radius: 0 0 ${borderRadius || '0'} ${borderRadius || '0'};
473
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
474
+ }
475
+
476
+ .consentera-banner__logo {
477
+ flex-shrink: 0;
478
+ }
479
+
480
+ .consentera-banner__logo img,
481
+ .consentera-banner__logo svg {
482
+ width: 48px;
483
+ height: 48px;
484
+ object-fit: contain;
485
+ }
486
+
487
+ .consentera-banner__text {
488
+ flex: 1;
489
+ min-width: 0;
490
+ }
491
+
492
+ .consentera-banner__title {
493
+ margin: 0 0 4px 0;
494
+ font-size: 16px;
495
+ font-weight: 600;
496
+ color: ${textColor || '#1f2937'};
497
+ }
498
+
499
+ .consentera-banner__description {
500
+ margin: 0;
501
+ font-size: 14px;
502
+ color: ${textColor || '#6b7280'};
503
+ line-height: 1.5;
504
+ }
505
+
506
+ .consentera-banner__link {
507
+ display: inline-block;
508
+ margin-top: 8px;
509
+ font-size: 13px;
510
+ color: ${primaryColor || '#2563eb'};
511
+ text-decoration: underline;
512
+ }
513
+
514
+ .consentera-banner__actions {
515
+ display: flex;
516
+ gap: 12px;
517
+ flex-shrink: 0;
518
+ }
519
+
520
+ .consentera-btn {
521
+ padding: 10px 20px;
522
+ border: none;
523
+ border-radius: 6px;
524
+ font-size: 14px;
525
+ font-weight: 500;
526
+ cursor: pointer;
527
+ transition: all 0.2s ease;
528
+ white-space: nowrap;
529
+ }
530
+
531
+ .consentera-btn--primary {
532
+ background: ${primaryColor || '#2563eb'};
533
+ color: #ffffff;
534
+ }
535
+
536
+ .consentera-btn--primary:hover {
537
+ opacity: 0.9;
538
+ }
539
+
540
+ .consentera-btn--secondary {
541
+ background: transparent;
542
+ color: ${textColor || '#4b5563'};
543
+ border: 1px solid ${textColor || '#d1d5db'};
544
+ }
545
+
546
+ .consentera-btn--secondary:hover {
547
+ background: rgba(0, 0, 0, 0.05);
548
+ }
549
+
550
+ @media (max-width: 768px) {
551
+ .consentera-banner__content {
552
+ flex-direction: column;
553
+ text-align: center;
554
+ padding: 16px;
555
+ gap: 16px;
556
+ }
557
+
558
+ .consentera-banner__actions {
559
+ width: 100%;
560
+ flex-direction: column;
561
+ }
562
+
563
+ .consentera-btn {
564
+ width: 100%;
565
+ }
566
+ }
567
+
568
+ /* Accessibility */
569
+ .consentera-btn:focus {
570
+ outline: 2px solid ${primaryColor || '#2563eb'};
571
+ outline-offset: 2px;
572
+ }
573
+
574
+ @media (prefers-reduced-motion: reduce) {
575
+ .consentera-banner {
576
+ transition: none;
577
+ }
578
+ }
579
+ `;
580
+ }
581
+ destroy() {
582
+ document.removeEventListener('keydown', this.handleKeyDown.bind(this));
583
+ this.container?.remove();
584
+ this.container = null;
585
+ this.isVisible = false;
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Preference Center UI Component
591
+ */
592
+ // SEC-H10 FIX: Escape HTML entities in server content before innerHTML assignment.
593
+ function escapeHtml(str) {
594
+ const div = document.createElement('div');
595
+ div.textContent = str;
596
+ return div.innerHTML;
597
+ }
598
+ function sanitizeUrl(url) {
599
+ try {
600
+ const parsed = new URL(url, window.location.origin);
601
+ if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
602
+ return parsed.href;
603
+ }
604
+ }
605
+ catch {
606
+ // invalid URL
607
+ }
608
+ return '#';
609
+ }
610
+ class PreferenceCenter {
611
+ config;
612
+ theme;
613
+ sdk;
614
+ container = null;
615
+ overlay = null;
616
+ isVisible = false;
617
+ preferences;
618
+ constructor(config, theme, sdk) {
619
+ this.config = config;
620
+ this.theme = theme;
621
+ this.sdk = sdk;
622
+ this.preferences = sdk.getPreferences();
623
+ }
624
+ show() {
625
+ if (this.isVisible)
626
+ return;
627
+ this.preferences = this.sdk.getPreferences();
628
+ this.createPreferenceCenter();
629
+ this.isVisible = true;
630
+ }
631
+ hide() {
632
+ if (!this.isVisible)
633
+ return;
634
+ this.container?.classList.add('consentera-preferences--closing');
635
+ this.overlay?.classList.add('consentera-overlay--closing');
636
+ setTimeout(() => {
637
+ this.container?.remove();
638
+ this.overlay?.remove();
639
+ this.container = null;
640
+ this.overlay = null;
641
+ this.isVisible = false;
642
+ }, 300);
643
+ }
644
+ createPreferenceCenter() {
645
+ // Create overlay
646
+ this.overlay = document.createElement('div');
647
+ this.overlay.className = 'consentera-overlay';
648
+ this.overlay.addEventListener('click', () => this.hide());
649
+ // Create container
650
+ this.container = document.createElement('div');
651
+ this.container.id = 'consentera-preference-center';
652
+ this.container.className = 'consentera-preferences';
653
+ this.container.setAttribute('role', 'dialog');
654
+ this.container.setAttribute('aria-modal', 'true');
655
+ this.container.setAttribute('aria-labelledby', 'consentera-preferences-title');
656
+ // Inject styles
657
+ this.injectStyles();
658
+ // Create content
659
+ this.container.innerHTML = this.getPreferenceCenterHTML();
660
+ // Add event listeners
661
+ this.attachEventListeners();
662
+ // Append to body
663
+ document.body.appendChild(this.overlay);
664
+ document.body.appendChild(this.container);
665
+ // Trigger animation
666
+ requestAnimationFrame(() => {
667
+ this.overlay?.classList.add('consentera-overlay--visible');
668
+ this.container?.classList.add('consentera-preferences--visible');
669
+ });
670
+ // Focus trap
671
+ this.container.querySelector('.consentera-preferences__close')?.focus();
672
+ }
673
+ getPreferenceCenterHTML() {
674
+ const { banner, purposes } = this.config;
675
+ return `
676
+ <div class="consentera-preferences__header">
677
+ <h2 id="consentera-preferences-title" class="consentera-preferences__title">
678
+ Cookie Preferences
679
+ </h2>
680
+ <button type="button" class="consentera-preferences__close" data-action="close" aria-label="Close">
681
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
682
+ <path d="M18 6L6 18M6 6l12 12"/>
683
+ </svg>
684
+ </button>
685
+ </div>
686
+
687
+ <div class="consentera-preferences__body">
688
+ <p class="consentera-preferences__description">
689
+ We use cookies and similar technologies to personalize content, analyze traffic, and improve your experience.
690
+ You can choose which categories of cookies you allow below.
691
+ </p>
692
+
693
+ <div class="consentera-preferences__purposes">
694
+ ${purposes.map((purpose) => `
695
+ <div class="consentera-purpose">
696
+ <div class="consentera-purpose__header">
697
+ <div class="consentera-purpose__info">
698
+ <h3 class="consentera-purpose__name">${escapeHtml(purpose.name)}</h3>
699
+ ${purpose.required ? '<span class="consentera-purpose__required">Always Active</span>' : ''}
700
+ </div>
701
+ <label class="consentera-toggle">
702
+ <input type="checkbox"
703
+ id="purpose-${purpose.id}"
704
+ data-purpose="${purpose.id}"
705
+ ${purpose.required ? 'checked disabled' : ''}
706
+ ${this.preferences[purpose.id] ? 'checked' : ''}
707
+ />
708
+ <span class="consentera-toggle__slider"></span>
709
+ </label>
710
+ </div>
711
+ <p class="consentera-purpose__description">${escapeHtml(purpose.description)}</p>
712
+ ${purpose.cookies && purpose.cookies.length > 0 ? `
713
+ <details class="consentera-purpose__cookies">
714
+ <summary>View cookies (${purpose.cookies.length})</summary>
715
+ <table class="consentera-cookies-table">
716
+ <thead>
717
+ <tr>
718
+ <th>Cookie</th>
719
+ <th>Provider</th>
720
+ <th>Purpose</th>
721
+ <th>Expiry</th>
722
+ </tr>
723
+ </thead>
724
+ <tbody>
725
+ ${purpose.cookies.map((cookie) => `
726
+ <tr>
727
+ <td>${escapeHtml(cookie.name)}</td>
728
+ <td>${escapeHtml(cookie.provider)}</td>
729
+ <td>${escapeHtml(cookie.purpose)}</td>
730
+ <td>${escapeHtml(cookie.expiry)}</td>
731
+ </tr>
732
+ `).join('')}
733
+ </tbody>
734
+ </table>
735
+ </details>
736
+ ` : ''}
737
+ </div>
738
+ `).join('')}
739
+ </div>
740
+ </div>
741
+
742
+ <div class="consentera-preferences__footer">
743
+ <div class="consentera-preferences__links">
744
+ ${banner.privacyPolicyUrl ? `
745
+ <a href="${sanitizeUrl(banner.privacyPolicyUrl)}" target="_blank" rel="noopener">Privacy Policy</a>
746
+ ` : ''}
747
+ ${banner.cookiePolicyUrl ? `
748
+ <a href="${sanitizeUrl(banner.cookiePolicyUrl)}" target="_blank" rel="noopener">Cookie Policy</a>
749
+ ` : ''}
750
+ </div>
751
+ <div class="consentera-preferences__actions">
752
+ <button type="button" class="consentera-btn consentera-btn--secondary" data-action="reject">
753
+ ${banner.rejectAllText || 'Reject All'}
754
+ </button>
755
+ <button type="button" class="consentera-btn consentera-btn--secondary" data-action="accept">
756
+ ${banner.acceptAllText || 'Accept All'}
757
+ </button>
758
+ <button type="button" class="consentera-btn consentera-btn--primary" data-action="save">
759
+ ${banner.saveText || 'Save Preferences'}
760
+ </button>
761
+ </div>
762
+ </div>
763
+ `;
764
+ }
765
+ attachEventListeners() {
766
+ if (!this.container)
767
+ return;
768
+ // Button actions
769
+ this.container.addEventListener('click', (e) => {
770
+ const target = e.target;
771
+ const action = target.dataset.action || target.closest('[data-action]')?.getAttribute('data-action');
772
+ if (!action)
773
+ return;
774
+ switch (action) {
775
+ case 'close':
776
+ this.hide();
777
+ break;
778
+ case 'accept':
779
+ this.sdk.acceptAll();
780
+ this.hide();
781
+ break;
782
+ case 'reject':
783
+ this.sdk.rejectAll();
784
+ this.hide();
785
+ break;
786
+ case 'save':
787
+ this.savePreferences();
788
+ break;
789
+ }
790
+ });
791
+ // Toggle changes
792
+ this.container.querySelectorAll('input[data-purpose]').forEach((input) => {
793
+ input.addEventListener('change', (e) => {
794
+ const target = e.target;
795
+ const purpose = target.dataset.purpose;
796
+ this.preferences[purpose] = target.checked;
797
+ });
798
+ });
799
+ // Escape key
800
+ document.addEventListener('keydown', this.handleKeyDown.bind(this));
801
+ }
802
+ handleKeyDown(e) {
803
+ if (e.key === 'Escape' && this.isVisible) {
804
+ this.hide();
805
+ }
806
+ }
807
+ savePreferences() {
808
+ this.sdk.savePreferences(this.preferences);
809
+ this.hide();
810
+ }
811
+ injectStyles() {
812
+ if (document.getElementById('consentera-preferences-styles'))
813
+ return;
814
+ const styleElement = document.createElement('style');
815
+ styleElement.id = 'consentera-preferences-styles';
816
+ styleElement.textContent = this.getStyles();
817
+ document.head.appendChild(styleElement);
818
+ }
819
+ getStyles() {
820
+ const { primaryColor, backgroundColor, textColor, borderRadius } = this.theme;
821
+ return `
822
+ .consentera-overlay {
823
+ position: fixed;
824
+ inset: 0;
825
+ background: rgba(0, 0, 0, 0.5);
826
+ z-index: 999998;
827
+ opacity: 0;
828
+ transition: opacity 0.3s ease;
829
+ }
830
+
831
+ .consentera-overlay--visible {
832
+ opacity: 1;
833
+ }
834
+
835
+ .consentera-overlay--closing {
836
+ opacity: 0;
837
+ }
838
+
839
+ .consentera-preferences {
840
+ position: fixed;
841
+ top: 50%;
842
+ left: 50%;
843
+ transform: translate(-50%, -50%) scale(0.95);
844
+ width: 90%;
845
+ max-width: 640px;
846
+ max-height: 85vh;
847
+ background: ${backgroundColor || '#ffffff'};
848
+ border-radius: ${borderRadius || '12px'};
849
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
850
+ z-index: 999999;
851
+ display: flex;
852
+ flex-direction: column;
853
+ opacity: 0;
854
+ transition: opacity 0.3s ease, transform 0.3s ease;
855
+ }
856
+
857
+ .consentera-preferences--visible {
858
+ opacity: 1;
859
+ transform: translate(-50%, -50%) scale(1);
860
+ }
861
+
862
+ .consentera-preferences--closing {
863
+ opacity: 0;
864
+ transform: translate(-50%, -50%) scale(0.95);
865
+ }
866
+
867
+ .consentera-preferences__header {
868
+ display: flex;
869
+ align-items: center;
870
+ justify-content: space-between;
871
+ padding: 20px 24px;
872
+ border-bottom: 1px solid #e5e7eb;
873
+ }
874
+
875
+ .consentera-preferences__title {
876
+ margin: 0;
877
+ font-size: 18px;
878
+ font-weight: 600;
879
+ color: ${textColor || '#1f2937'};
880
+ }
881
+
882
+ .consentera-preferences__close {
883
+ background: none;
884
+ border: none;
885
+ padding: 4px;
886
+ cursor: pointer;
887
+ color: ${textColor || '#6b7280'};
888
+ border-radius: 4px;
889
+ }
890
+
891
+ .consentera-preferences__close:hover {
892
+ background: rgba(0, 0, 0, 0.05);
893
+ }
894
+
895
+ .consentera-preferences__body {
896
+ flex: 1;
897
+ overflow-y: auto;
898
+ padding: 24px;
899
+ }
900
+
901
+ .consentera-preferences__description {
902
+ margin: 0 0 24px 0;
903
+ font-size: 14px;
904
+ color: ${textColor || '#6b7280'};
905
+ line-height: 1.5;
906
+ }
907
+
908
+ .consentera-preferences__purposes {
909
+ display: flex;
910
+ flex-direction: column;
911
+ gap: 16px;
912
+ }
913
+
914
+ .consentera-purpose {
915
+ padding: 16px;
916
+ background: #f9fafb;
917
+ border-radius: 8px;
918
+ }
919
+
920
+ .consentera-purpose__header {
921
+ display: flex;
922
+ align-items: center;
923
+ justify-content: space-between;
924
+ margin-bottom: 8px;
925
+ }
926
+
927
+ .consentera-purpose__info {
928
+ display: flex;
929
+ align-items: center;
930
+ gap: 8px;
931
+ }
932
+
933
+ .consentera-purpose__name {
934
+ margin: 0;
935
+ font-size: 15px;
936
+ font-weight: 600;
937
+ color: ${textColor || '#1f2937'};
938
+ }
939
+
940
+ .consentera-purpose__required {
941
+ font-size: 11px;
942
+ padding: 2px 6px;
943
+ background: ${primaryColor || '#2563eb'};
944
+ color: white;
945
+ border-radius: 4px;
946
+ font-weight: 500;
947
+ }
948
+
949
+ .consentera-purpose__description {
950
+ margin: 0;
951
+ font-size: 13px;
952
+ color: ${textColor || '#6b7280'};
953
+ line-height: 1.5;
954
+ }
955
+
956
+ .consentera-purpose__cookies {
957
+ margin-top: 12px;
958
+ }
959
+
960
+ .consentera-purpose__cookies summary {
961
+ font-size: 12px;
962
+ color: ${primaryColor || '#2563eb'};
963
+ cursor: pointer;
964
+ }
965
+
966
+ .consentera-cookies-table {
967
+ width: 100%;
968
+ margin-top: 8px;
969
+ font-size: 12px;
970
+ border-collapse: collapse;
971
+ }
972
+
973
+ .consentera-cookies-table th,
974
+ .consentera-cookies-table td {
975
+ padding: 8px;
976
+ text-align: left;
977
+ border-bottom: 1px solid #e5e7eb;
978
+ }
979
+
980
+ .consentera-cookies-table th {
981
+ font-weight: 600;
982
+ color: ${textColor || '#374151'};
983
+ }
984
+
985
+ .consentera-cookies-table td {
986
+ color: ${textColor || '#6b7280'};
987
+ }
988
+
989
+ .consentera-toggle {
990
+ position: relative;
991
+ display: inline-block;
992
+ width: 48px;
993
+ height: 26px;
994
+ }
995
+
996
+ .consentera-toggle input {
997
+ opacity: 0;
998
+ width: 0;
999
+ height: 0;
1000
+ }
1001
+
1002
+ .consentera-toggle__slider {
1003
+ position: absolute;
1004
+ cursor: pointer;
1005
+ inset: 0;
1006
+ background: #d1d5db;
1007
+ border-radius: 26px;
1008
+ transition: 0.3s;
1009
+ }
1010
+
1011
+ .consentera-toggle__slider::before {
1012
+ position: absolute;
1013
+ content: "";
1014
+ height: 20px;
1015
+ width: 20px;
1016
+ left: 3px;
1017
+ bottom: 3px;
1018
+ background: white;
1019
+ border-radius: 50%;
1020
+ transition: 0.3s;
1021
+ }
1022
+
1023
+ .consentera-toggle input:checked + .consentera-toggle__slider {
1024
+ background: ${primaryColor || '#2563eb'};
1025
+ }
1026
+
1027
+ .consentera-toggle input:checked + .consentera-toggle__slider::before {
1028
+ transform: translateX(22px);
1029
+ }
1030
+
1031
+ .consentera-toggle input:disabled + .consentera-toggle__slider {
1032
+ opacity: 0.7;
1033
+ cursor: not-allowed;
1034
+ }
1035
+
1036
+ .consentera-preferences__footer {
1037
+ display: flex;
1038
+ align-items: center;
1039
+ justify-content: space-between;
1040
+ padding: 16px 24px;
1041
+ border-top: 1px solid #e5e7eb;
1042
+ gap: 16px;
1043
+ }
1044
+
1045
+ .consentera-preferences__links {
1046
+ display: flex;
1047
+ gap: 16px;
1048
+ }
1049
+
1050
+ .consentera-preferences__links a {
1051
+ font-size: 13px;
1052
+ color: ${primaryColor || '#2563eb'};
1053
+ text-decoration: underline;
1054
+ }
1055
+
1056
+ .consentera-preferences__actions {
1057
+ display: flex;
1058
+ gap: 8px;
1059
+ }
1060
+
1061
+ @media (max-width: 640px) {
1062
+ .consentera-preferences {
1063
+ width: 100%;
1064
+ height: 100%;
1065
+ max-height: 100%;
1066
+ border-radius: 0;
1067
+ }
1068
+
1069
+ .consentera-preferences__footer {
1070
+ flex-direction: column;
1071
+ }
1072
+
1073
+ .consentera-preferences__actions {
1074
+ width: 100%;
1075
+ flex-direction: column;
1076
+ }
1077
+
1078
+ .consentera-btn {
1079
+ width: 100%;
1080
+ }
1081
+ }
1082
+ `;
1083
+ }
1084
+ destroy() {
1085
+ document.removeEventListener('keydown', this.handleKeyDown.bind(this));
1086
+ this.container?.remove();
1087
+ this.overlay?.remove();
1088
+ this.container = null;
1089
+ this.overlay = null;
1090
+ this.isVisible = false;
1091
+ }
1092
+ }
1093
+
1094
+ /**
1095
+ * TCF 2.2 Manager
1096
+ * IAB Transparency & Consent Framework implementation
1097
+ */
1098
+ class TCFManager {
1099
+ config;
1100
+ tcfData = null;
1101
+ constructor(config) {
1102
+ this.config = {
1103
+ cmpId: 123, // Replace with actual CMP ID
1104
+ cmpVersion: 1,
1105
+ publisherCountryCode: 'IN',
1106
+ gdprApplies: true,
1107
+ ...config,
1108
+ };
1109
+ }
1110
+ /**
1111
+ * Initialize TCF API
1112
+ */
1113
+ async init() {
1114
+ // Create TCF API stub
1115
+ this.createTCFApiStub();
1116
+ // Initialize TCF data
1117
+ this.tcfData = this.getDefaultTCFData();
1118
+ }
1119
+ /**
1120
+ * Create __tcfapi stub
1121
+ */
1122
+ createTCFApiStub() {
1123
+ if (typeof window === 'undefined')
1124
+ return;
1125
+ // Create stub
1126
+ window.__tcfapi = (command, version, callback, parameter) => {
1127
+ if (command === 'ping') {
1128
+ callback({
1129
+ gdprApplies: this.config.gdprApplies,
1130
+ cmpLoaded: true,
1131
+ cmpStatus: 'loaded',
1132
+ displayStatus: 'visible',
1133
+ apiVersion: '2.2',
1134
+ cmpVersion: this.config.cmpVersion,
1135
+ cmpId: this.config.cmpId,
1136
+ gvlVersion: 100,
1137
+ tcfPolicyVersion: 4,
1138
+ });
1139
+ }
1140
+ else if (command === 'getTCData') {
1141
+ callback(this.tcfData, true);
1142
+ }
1143
+ else if (command === 'addEventListener') {
1144
+ // Handle event listener
1145
+ callback({
1146
+ ...this.tcfData,
1147
+ eventStatus: 'tcloaded',
1148
+ listenerId: Math.random(),
1149
+ }, true);
1150
+ }
1151
+ else if (command === 'removeEventListener') {
1152
+ callback(true);
1153
+ }
1154
+ else ;
1155
+ };
1156
+ // Mark as CMP
1157
+ window.__tcfapi.gdprApplies = this.config.gdprApplies;
1158
+ window.__tcfapi.cmpLoaded = true;
1159
+ }
1160
+ /**
1161
+ * Get default TCF data
1162
+ */
1163
+ getDefaultTCFData() {
1164
+ return {
1165
+ cmpId: this.config.cmpId,
1166
+ cmpVersion: this.config.cmpVersion,
1167
+ gdprApplies: this.config.gdprApplies,
1168
+ tcString: '',
1169
+ tcfPolicyVersion: 4,
1170
+ isServiceSpecific: true,
1171
+ useNonStandardStacks: false,
1172
+ purposeOneTreatment: false,
1173
+ publisherCC: this.config.publisherCountryCode,
1174
+ vendorConsents: {},
1175
+ vendorLegitimateInterests: {},
1176
+ purposeConsents: {},
1177
+ purposeLegitimateInterests: {},
1178
+ specialFeatureOptins: {},
1179
+ publisherRestrictions: [],
1180
+ };
1181
+ }
1182
+ /**
1183
+ * Update consent in TCF format
1184
+ */
1185
+ async updateConsent(preferences) {
1186
+ if (!this.tcfData)
1187
+ return;
1188
+ // Map preferences to TCF purposes
1189
+ // TCF Purposes:
1190
+ // 1: Store and/or access information on a device
1191
+ // 2: Select basic ads
1192
+ // 3: Create a personalised ads profile
1193
+ // 4: Select personalised ads
1194
+ // 5: Create a personalised content profile
1195
+ // 6: Select personalised content
1196
+ // 7: Measure ad performance
1197
+ // 8: Measure content performance
1198
+ // 9: Apply market research to generate audience insights
1199
+ // 10: Develop and improve products
1200
+ this.tcfData.purposeConsents = {
1201
+ 1: preferences.essential,
1202
+ 2: preferences.advertising,
1203
+ 3: preferences.personalization,
1204
+ 4: preferences.advertising,
1205
+ 5: preferences.personalization,
1206
+ 6: preferences.personalization,
1207
+ 7: preferences.analytics,
1208
+ 8: preferences.analytics,
1209
+ 9: preferences.analytics,
1210
+ 10: preferences.functional,
1211
+ };
1212
+ // Generate TC String
1213
+ this.tcfData.tcString = this.generateTCString();
1214
+ // Dispatch event
1215
+ this.dispatchTCFEvent('useractioncomplete');
1216
+ }
1217
+ /**
1218
+ * Generate TC String (simplified version)
1219
+ */
1220
+ generateTCString() {
1221
+ // In production, use proper TC String encoding
1222
+ // This is a placeholder that would be replaced with actual IAB TCF encoding
1223
+ const data = {
1224
+ version: 2,
1225
+ created: Date.now(),
1226
+ lastUpdated: Date.now(),
1227
+ cmpId: this.config.cmpId,
1228
+ cmpVersion: this.config.cmpVersion,
1229
+ purposeConsents: this.tcfData?.purposeConsents || {},
1230
+ };
1231
+ return btoa(JSON.stringify(data));
1232
+ }
1233
+ /**
1234
+ * Dispatch TCF event
1235
+ */
1236
+ dispatchTCFEvent(eventStatus) {
1237
+ if (typeof window === 'undefined')
1238
+ return;
1239
+ const event = new CustomEvent('tcfapi', {
1240
+ detail: {
1241
+ ...this.tcfData,
1242
+ eventStatus,
1243
+ },
1244
+ });
1245
+ window.dispatchEvent(event);
1246
+ }
1247
+ /**
1248
+ * Get current TCF data
1249
+ */
1250
+ getTCFData() {
1251
+ return this.tcfData;
1252
+ }
1253
+ /**
1254
+ * Get TC String
1255
+ */
1256
+ getTCString() {
1257
+ return this.tcfData?.tcString || '';
1258
+ }
1259
+ /**
1260
+ * Destroy TCF manager
1261
+ */
1262
+ destroy() {
1263
+ if (typeof window !== 'undefined') {
1264
+ delete window.__tcfapi;
1265
+ }
1266
+ this.tcfData = null;
1267
+ }
1268
+ }
1269
+
1270
+ /**
1271
+ * GPP Manager
1272
+ * IAB Global Privacy Platform implementation
1273
+ */
1274
+ class GPPManager {
1275
+ config;
1276
+ gppData = null;
1277
+ constructor(config) {
1278
+ this.config = {
1279
+ sections: ['tcfeuv2', 'uspv1'],
1280
+ ...config,
1281
+ };
1282
+ }
1283
+ /**
1284
+ * Initialize GPP API
1285
+ */
1286
+ async init() {
1287
+ // Create GPP API stub
1288
+ this.createGPPApiStub();
1289
+ // Initialize GPP data
1290
+ this.gppData = this.getDefaultGPPData();
1291
+ }
1292
+ /**
1293
+ * Create __gpp stub
1294
+ */
1295
+ createGPPApiStub() {
1296
+ if (typeof window === 'undefined')
1297
+ return;
1298
+ const eventQueue = [];
1299
+ const cmpStatus = 'loaded';
1300
+ const cmpDisplayStatus = 'visible';
1301
+ window.__gpp = (command, callback, parameter) => {
1302
+ switch (command) {
1303
+ case 'ping':
1304
+ callback({
1305
+ gppVersion: '1.1',
1306
+ cmpStatus,
1307
+ cmpDisplayStatus,
1308
+ supportedAPIs: this.config.sections,
1309
+ cmpId: 123, // Replace with actual CMP ID
1310
+ sectionList: this.gppData?.sectionIds || [],
1311
+ applicableSections: this.gppData?.applicableSections || [],
1312
+ gppString: this.gppData?.gppString || '',
1313
+ });
1314
+ break;
1315
+ case 'getSection':
1316
+ if (this.gppData) {
1317
+ callback({
1318
+ sectionId: parameter,
1319
+ sectionData: this.getSectionData(parameter),
1320
+ });
1321
+ }
1322
+ break;
1323
+ case 'getField':
1324
+ callback(this.getFieldValue(parameter));
1325
+ break;
1326
+ case 'hasSection':
1327
+ callback(this.gppData?.sectionIds.includes(parameter) || false);
1328
+ break;
1329
+ case 'addEventListener':
1330
+ eventQueue.push({ callback, listenerId: Math.random() });
1331
+ callback({
1332
+ eventName: 'listenerRegistered',
1333
+ listenerId: eventQueue.length - 1,
1334
+ data: this.gppData?.gppString || '',
1335
+ pingData: {
1336
+ gppVersion: '1.1',
1337
+ cmpStatus,
1338
+ cmpDisplayStatus,
1339
+ },
1340
+ });
1341
+ break;
1342
+ case 'removeEventListener': {
1343
+ const index = eventQueue.findIndex((e) => e.listenerId === parameter);
1344
+ if (index > -1) {
1345
+ eventQueue.splice(index, 1);
1346
+ callback(true);
1347
+ }
1348
+ else {
1349
+ callback(false);
1350
+ }
1351
+ break;
1352
+ }
1353
+ default:
1354
+ callback(null);
1355
+ }
1356
+ };
1357
+ // Set GPP API version
1358
+ window.__gpp.version = '1.1';
1359
+ }
1360
+ /**
1361
+ * Get default GPP data
1362
+ */
1363
+ getDefaultGPPData() {
1364
+ return {
1365
+ gppString: '',
1366
+ sectionIds: [],
1367
+ applicableSections: [],
1368
+ };
1369
+ }
1370
+ /**
1371
+ * Get section data
1372
+ */
1373
+ getSectionData(sectionId) {
1374
+ // Return section-specific data
1375
+ switch (sectionId) {
1376
+ case 2: // TCF EU v2
1377
+ return {
1378
+ Version: 2,
1379
+ Created: Math.floor(Date.now() / 100),
1380
+ LastUpdated: Math.floor(Date.now() / 100),
1381
+ CmpId: 123,
1382
+ CmpVersion: 1,
1383
+ ConsentScreen: 1,
1384
+ ConsentLanguage: 'EN',
1385
+ };
1386
+ case 6: // USP v1
1387
+ return {
1388
+ Version: 1,
1389
+ Notice: 'Y',
1390
+ OptOutSale: 'N',
1391
+ LspaCovered: 'Y',
1392
+ };
1393
+ default:
1394
+ return null;
1395
+ }
1396
+ }
1397
+ /**
1398
+ * Get field value
1399
+ */
1400
+ getFieldValue(_field) {
1401
+ // STUB: the GPP `getField` command is accepted and answers null for every
1402
+ // field. Named with a leading underscore so it reads as deliberately
1403
+ // unused rather than as a parameter someone forgot to wire.
1404
+ return null;
1405
+ }
1406
+ /**
1407
+ * Update consent in GPP format
1408
+ */
1409
+ async updateConsent(preferences) {
1410
+ if (!this.gppData)
1411
+ return;
1412
+ // Build GPP string based on preferences
1413
+ this.gppData.gppString = this.buildGPPString(preferences);
1414
+ // Update section IDs based on applicable regulations
1415
+ this.gppData.sectionIds = this.determineSections(preferences);
1416
+ this.gppData.applicableSections = this.gppData.sectionIds;
1417
+ // Dispatch event
1418
+ this.dispatchGPPEvent('signalStatus');
1419
+ }
1420
+ /**
1421
+ * Build GPP string
1422
+ */
1423
+ buildGPPString(preferences) {
1424
+ // In production, use proper GPP string encoding
1425
+ // This is a simplified version
1426
+ const sections = [];
1427
+ // TCF EU v2 section (section ID 2)
1428
+ if (this.config.sections?.includes('tcfeuv2')) {
1429
+ sections.push({
1430
+ id: 2,
1431
+ purposes: {
1432
+ 1: preferences.essential,
1433
+ 2: preferences.advertising,
1434
+ 3: preferences.personalization,
1435
+ 4: preferences.advertising,
1436
+ 7: preferences.analytics,
1437
+ 10: preferences.functional,
1438
+ },
1439
+ });
1440
+ }
1441
+ // USP v1 section (section ID 6)
1442
+ if (this.config.sections?.includes('uspv1')) {
1443
+ sections.push({
1444
+ id: 6,
1445
+ notice: true,
1446
+ optOut: !preferences.thirdParty,
1447
+ lspaCovered: true,
1448
+ });
1449
+ }
1450
+ // Generate encoded string
1451
+ return this.encodeGPPString(sections);
1452
+ }
1453
+ /**
1454
+ * Encode GPP string
1455
+ */
1456
+ encodeGPPString(sections) {
1457
+ // Simplified encoding - in production use proper GPP encoding
1458
+ const header = {
1459
+ version: 1,
1460
+ sectionIds: sections.map((s) => s.id),
1461
+ };
1462
+ return btoa(JSON.stringify({ header, sections }));
1463
+ }
1464
+ /**
1465
+ * Determine applicable sections
1466
+ */
1467
+ determineSections(_preferences) {
1468
+ const sections = [];
1469
+ // Add TCF EU v2 (2) if GDPR applicable
1470
+ if (this.config.sections?.includes('tcfeuv2')) {
1471
+ sections.push(2);
1472
+ }
1473
+ // Add USP v1 (6) if CCPA applicable
1474
+ if (this.config.sections?.includes('uspv1')) {
1475
+ sections.push(6);
1476
+ }
1477
+ // Add other sections as needed
1478
+ return sections;
1479
+ }
1480
+ /**
1481
+ * Dispatch GPP event
1482
+ */
1483
+ dispatchGPPEvent(eventName) {
1484
+ if (typeof window === 'undefined')
1485
+ return;
1486
+ const event = new CustomEvent('gpp', {
1487
+ detail: {
1488
+ eventName,
1489
+ data: this.gppData?.gppString || '',
1490
+ pingData: {
1491
+ gppVersion: '1.1',
1492
+ cmpStatus: 'loaded',
1493
+ applicableSections: this.gppData?.applicableSections || [],
1494
+ },
1495
+ },
1496
+ });
1497
+ window.dispatchEvent(event);
1498
+ }
1499
+ /**
1500
+ * Get current GPP data
1501
+ */
1502
+ getGPPData() {
1503
+ return this.gppData;
1504
+ }
1505
+ /**
1506
+ * Get GPP string
1507
+ */
1508
+ getGPPString() {
1509
+ return this.gppData?.gppString || '';
1510
+ }
1511
+ /**
1512
+ * Destroy GPP manager
1513
+ */
1514
+ destroy() {
1515
+ if (typeof window !== 'undefined') {
1516
+ delete window.__gpp;
1517
+ }
1518
+ this.gppData = null;
1519
+ }
1520
+ }
1521
+
1522
+ /**
1523
+ * Simple Event Emitter
1524
+ */
1525
+ class EventEmitter {
1526
+ events = new Map();
1527
+ /**
1528
+ * Register event listener
1529
+ */
1530
+ on(event, callback) {
1531
+ if (!this.events.has(event)) {
1532
+ this.events.set(event, new Set());
1533
+ }
1534
+ this.events.get(event).add(callback);
1535
+ }
1536
+ /**
1537
+ * Remove event listener
1538
+ */
1539
+ off(event, callback) {
1540
+ const callbacks = this.events.get(event);
1541
+ if (callbacks) {
1542
+ callbacks.delete(callback);
1543
+ }
1544
+ }
1545
+ /**
1546
+ * Emit event
1547
+ */
1548
+ emit(event, ...args) {
1549
+ const callbacks = this.events.get(event);
1550
+ if (callbacks) {
1551
+ callbacks.forEach((callback) => {
1552
+ try {
1553
+ callback(...args);
1554
+ }
1555
+ catch (error) {
1556
+ console.error(`Error in event handler for ${event}:`, error);
1557
+ }
1558
+ });
1559
+ }
1560
+ }
1561
+ /**
1562
+ * Register one-time event listener
1563
+ */
1564
+ once(event, callback) {
1565
+ const onceWrapper = (...args) => {
1566
+ this.off(event, onceWrapper);
1567
+ callback(...args);
1568
+ };
1569
+ this.on(event, onceWrapper);
1570
+ }
1571
+ /**
1572
+ * Remove all listeners
1573
+ */
1574
+ removeAllListeners(event) {
1575
+ if (event) {
1576
+ this.events.delete(event);
1577
+ }
1578
+ else {
1579
+ this.events.clear();
1580
+ }
1581
+ }
1582
+ }
1583
+
1584
+ /* eslint-disable no-console -- THIS FILE IS THE CONSOLE WRITE. The `no-console`
1585
+ rule exists so that no OTHER file writes to the host's console directly; the
1586
+ whole point of routing through here is that a host can set the level or
1587
+ replace the sink. Disabling it anywhere else is the thing the rule is for. */
1588
+ class Logger {
1589
+ level;
1590
+ prefix = '[ConsentEra]';
1591
+ constructor(level = 'info') {
1592
+ this.level = level;
1593
+ }
1594
+ shouldLog(level) {
1595
+ const levels = ['debug', 'info', 'warn', 'error'];
1596
+ return levels.indexOf(level) >= levels.indexOf(this.level);
1597
+ }
1598
+ debug(...args) {
1599
+ if (this.shouldLog('debug')) {
1600
+ console.debug(this.prefix, ...args);
1601
+ }
1602
+ }
1603
+ info(...args) {
1604
+ if (this.shouldLog('info')) {
1605
+ console.info(this.prefix, ...args);
1606
+ }
1607
+ }
1608
+ warn(...args) {
1609
+ if (this.shouldLog('warn')) {
1610
+ console.warn(this.prefix, ...args);
1611
+ }
1612
+ }
1613
+ error(...args) {
1614
+ if (this.shouldLog('error')) {
1615
+ console.error(this.prefix, ...args);
1616
+ }
1617
+ }
1618
+ setLevel(level) {
1619
+ this.level = level;
1620
+ }
1621
+ }
1622
+
1623
+ /**
1624
+ * Utility helper functions
1625
+ */
1626
+ /**
1627
+ * Generate a unique consent ID
1628
+ */
1629
+ function generateConsentId() {
1630
+ const timestamp = Date.now().toString(36);
1631
+ const randomPart = Math.random().toString(36).substring(2, 15);
1632
+ return `consent_${timestamp}_${randomPart}`;
1633
+ }
1634
+ /**
1635
+ * Get device information
1636
+ */
1637
+ function getDeviceInfo() {
1638
+ const ua = navigator.userAgent.toLowerCase();
1639
+ let type = 'desktop';
1640
+ if (/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i.test(ua)) {
1641
+ type = 'mobile';
1642
+ }
1643
+ else if (/tablet|ipad|playbook|silk/i.test(ua)) {
1644
+ type = 'tablet';
1645
+ }
1646
+ let os;
1647
+ if (/windows/i.test(ua))
1648
+ os = 'Windows';
1649
+ else if (/macintosh|mac os x/i.test(ua))
1650
+ os = 'macOS';
1651
+ else if (/linux/i.test(ua))
1652
+ os = 'Linux';
1653
+ else if (/android/i.test(ua))
1654
+ os = 'Android';
1655
+ else if (/iphone|ipad|ipod/i.test(ua))
1656
+ os = 'iOS';
1657
+ let browser;
1658
+ if (/chrome/i.test(ua) && !/edge|edg/i.test(ua))
1659
+ browser = 'Chrome';
1660
+ else if (/firefox/i.test(ua))
1661
+ browser = 'Firefox';
1662
+ else if (/safari/i.test(ua) && !/chrome/i.test(ua))
1663
+ browser = 'Safari';
1664
+ else if (/edge|edg/i.test(ua))
1665
+ browser = 'Edge';
1666
+ else if (/msie|trident/i.test(ua))
1667
+ browser = 'IE';
1668
+ return { type, os, browser };
1669
+ }
1670
+
1671
+ /**
1672
+ * Consentera Consent SDK — the error taxonomy.
1673
+ *
1674
+ * ONE class with a discriminant, plus subclasses for `instanceof`. Before 2.0.0
1675
+ * there was a single `ConsentEraApiError` carrying `statusCode` and the raw
1676
+ * `responseBody`, which meant a caller who wanted to know *what went wrong* had
1677
+ * to dig a string out of an untyped body — and the two things support asks for
1678
+ * first, the canonical code and the request id, were both discarded. The API
1679
+ * has carried them the whole time: the envelope is `{code, message}`
1680
+ * (core/apierrors/errors.go:33-38) over ~141 codes of which 22 are canonical
1681
+ * (core/apierrors/canonical_codes.go), and `X-Request-ID` is set by
1682
+ * chi middleware and CORS-exposed so a browser can read it
1683
+ * (cmd/api/main.go:1266, :1272).
1684
+ *
1685
+ * `kind` is the discriminant to switch on. The CODE is the platform's word and
1686
+ * may be one of many; the KIND is this SDK's classification of it and is a
1687
+ * closed set, so `switch (err.kind)` stays exhaustive when the platform adds a
1688
+ * code. Both are on the error — never infer the kind from the code yourself.
1689
+ */
1690
+ /**
1691
+ * Canonical code → kind. Codes taken from the platform's registry; anything not
1692
+ * listed falls back to the HTTP status, and a code we have never seen is
1693
+ * therefore still classified rather than dropped into `unknown`.
1694
+ */
1695
+ const CODE_KIND = {
1696
+ // 400 — the request itself
1697
+ VALIDATION_ERROR: 'validation',
1698
+ INVALID_REQUEST_BODY: 'validation',
1699
+ INVALID_JSON: 'validation',
1700
+ INVALID_UUID: 'validation',
1701
+ BAD_REQUEST: 'validation',
1702
+ MISSING_REQUIRED_FIELD: 'validation',
1703
+ MISSING_TENANT_ID: 'validation',
1704
+ INVALID_TENANT_ID: 'validation',
1705
+ INVALID_DATE_OF_BIRTH: 'validation',
1706
+ INVALID_IDENTIFIER_FORMAT: 'validation',
1707
+ // identity — the U58 / lifecycle identity family, which is what an
1708
+ // integrator gets wrong most often and most expensively
1709
+ DATA_PRINCIPAL_REF_REFUSED: 'identity',
1710
+ UNKNOWN_IDENTIFIER_FIELD: 'identity',
1711
+ IDENTIFIER_REQUIRED: 'identity',
1712
+ IDENTITY_MISMATCH: 'identity',
1713
+ // guardian / age
1714
+ GUARDIAN_REQUIRED: 'guardian',
1715
+ GUARDIAN_CONSENT_REQUIRED: 'guardian',
1716
+ GUARDIAN_TOKEN_REQUIRED: 'guardian',
1717
+ GUARDIAN_EVIDENCE_MISSING: 'guardian',
1718
+ GUARDIAN_IDENTITY_NOT_VERIFIED: 'guardian',
1719
+ GUARDIAN_ADULT_NOT_VERIFIED: 'guardian',
1720
+ GUARDIAN_VERIFICATION_METHOD_INVALID: 'guardian',
1721
+ AGE_REQUIRED: 'guardian',
1722
+ // 401
1723
+ UNAUTHORIZED: 'auth',
1724
+ INVALID_CREDENTIALS: 'auth',
1725
+ TOKEN_MISSING: 'auth',
1726
+ TOKEN_INVALID: 'auth',
1727
+ TOKEN_EXPIRED: 'auth',
1728
+ TOKEN_MALFORMED: 'auth',
1729
+ TOKEN_REVOKED: 'auth',
1730
+ SESSION_NOT_FOUND: 'auth',
1731
+ SESSION_EXPIRED: 'auth',
1732
+ SESSION_REVOKED: 'auth',
1733
+ // 403
1734
+ FORBIDDEN: 'permission',
1735
+ TENANT_MISMATCH: 'permission',
1736
+ TENANT_SUSPENDED: 'permission',
1737
+ ACCOUNT_DISABLED: 'permission',
1738
+ PRIVILEGE_ESCALATION: 'permission',
1739
+ SCHEME_NOT_CONFIGURED: 'permission',
1740
+ CSRF_TOKEN_MISSING: 'permission',
1741
+ CSRF_TOKEN_INVALID: 'permission',
1742
+ // 404 / 409 / 429 / 5xx
1743
+ NOT_FOUND: 'not_found',
1744
+ USER_NOT_FOUND: 'not_found',
1745
+ CONFLICT: 'conflict',
1746
+ IDEMPOTENCY_KEY_REUSE: 'conflict',
1747
+ RATE_LIMIT_EXCEEDED: 'rate_limit',
1748
+ INTERNAL_ERROR: 'server',
1749
+ INTERNAL_SERVER_ERROR: 'server',
1750
+ DATABASE_ERROR: 'server',
1751
+ };
1752
+ function kindForStatus(status) {
1753
+ if (status === 401)
1754
+ return 'auth';
1755
+ if (status === 403)
1756
+ return 'permission';
1757
+ if (status === 404)
1758
+ return 'not_found';
1759
+ if (status === 409)
1760
+ return 'conflict';
1761
+ if (status === 429)
1762
+ return 'rate_limit';
1763
+ if (status >= 500)
1764
+ return 'server';
1765
+ if (status >= 400)
1766
+ return 'validation';
1767
+ return 'unknown';
1768
+ }
1769
+ /**
1770
+ * Parse `Retry-After`, which RFC 9110 §10.2.3 allows to be either a count of
1771
+ * seconds or an HTTP-date. Returns milliseconds, or undefined when the header
1772
+ * is absent or unparseable — never NaN, because a NaN delay becomes an
1773
+ * immediate retry and turns a 429 into a hot loop.
1774
+ */
1775
+ function parseRetryAfterMs(header, now = Date.now()) {
1776
+ if (!header)
1777
+ return undefined;
1778
+ const trimmed = header.trim();
1779
+ if (/^\d+$/.test(trimmed)) {
1780
+ const seconds = Number(trimmed);
1781
+ return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : undefined;
1782
+ }
1783
+ const at = Date.parse(trimmed);
1784
+ if (Number.isNaN(at))
1785
+ return undefined;
1786
+ return Math.max(0, at - now);
1787
+ }
1788
+ /** The base error every road in this SDK throws. */
1789
+ class ConsenteraError extends Error {
1790
+ /** This SDK's classification. A closed set — safe to switch on. */
1791
+ kind;
1792
+ /** The platform's canonical error code, when the response carried one. */
1793
+ code;
1794
+ /** HTTP status; 0 for a transport failure that never reached a status. */
1795
+ status;
1796
+ /** `X-Request-ID` off the response. Quote this in a support ticket. */
1797
+ requestId;
1798
+ /** The parsed response body, or the raw text when it was not JSON. */
1799
+ responseBody;
1800
+ /** Whether THIS SDK would retry it. Already applied internally. */
1801
+ retryable;
1802
+ /** Server-asked wait, from `Retry-After`, in ms. */
1803
+ retryAfterMs;
1804
+ constructor(init) {
1805
+ super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
1806
+ this.name = new.target.name;
1807
+ this.kind = init.kind;
1808
+ this.code = init.code;
1809
+ this.status = init.status ?? 0;
1810
+ this.requestId = init.requestId;
1811
+ this.responseBody = init.responseBody;
1812
+ this.retryable = init.retryable ?? false;
1813
+ this.retryAfterMs = init.retryAfterMs;
1814
+ // Extending Error across the ES5 target rollup emits breaks instanceof
1815
+ // without this; new.target is the actual subclass.
1816
+ Object.setPrototypeOf(this, new.target.prototype);
1817
+ }
1818
+ /** @deprecated 2.0.0 — use `status`. Kept so 1.x `err.statusCode` still reads. */
1819
+ get statusCode() {
1820
+ return this.status;
1821
+ }
1822
+ /** A one-line form safe to log: no body, no identifiers. */
1823
+ toString() {
1824
+ const bits = [this.name, this.code ?? this.kind];
1825
+ if (this.status)
1826
+ bits.push(String(this.status));
1827
+ if (this.requestId)
1828
+ bits.push(`req=${this.requestId}`);
1829
+ return `${bits.join(' ')}: ${this.message}`;
1830
+ }
1831
+ }
1832
+ class ConsenteraConfigError extends ConsenteraError {
1833
+ }
1834
+ class ConsenteraAuthError extends ConsenteraError {
1835
+ }
1836
+ class ConsenteraPermissionError extends ConsenteraError {
1837
+ }
1838
+ class ConsenteraValidationError extends ConsenteraError {
1839
+ }
1840
+ class ConsenteraIdentityError extends ConsenteraError {
1841
+ }
1842
+ class ConsenteraGuardianError extends ConsenteraError {
1843
+ }
1844
+ class ConsenteraNotFoundError extends ConsenteraError {
1845
+ }
1846
+ class ConsenteraConflictError extends ConsenteraError {
1847
+ }
1848
+ class ConsenteraRateLimitError extends ConsenteraError {
1849
+ }
1850
+ class ConsenteraServerError extends ConsenteraError {
1851
+ }
1852
+ class ConsenteraNetworkError extends ConsenteraError {
1853
+ }
1854
+ class ConsenteraTimeoutError extends ConsenteraError {
1855
+ }
1856
+ const KIND_CLASS = {
1857
+ config: ConsenteraConfigError,
1858
+ auth: ConsenteraAuthError,
1859
+ permission: ConsenteraPermissionError,
1860
+ validation: ConsenteraValidationError,
1861
+ identity: ConsenteraIdentityError,
1862
+ guardian: ConsenteraGuardianError,
1863
+ not_found: ConsenteraNotFoundError,
1864
+ conflict: ConsenteraConflictError,
1865
+ rate_limit: ConsenteraRateLimitError,
1866
+ server: ConsenteraServerError,
1867
+ network: ConsenteraNetworkError,
1868
+ timeout: ConsenteraTimeoutError,
1869
+ cancelled: ConsenteraError,
1870
+ unknown: ConsenteraError,
1871
+ };
1872
+ function newOfKind(init) {
1873
+ return new KIND_CLASS[init.kind](init);
1874
+ }
1875
+ /** Pull `{code, message}` off a parsed body, tolerating the `{error:{...}}` wrapper. */
1876
+ function readEnvelope(body) {
1877
+ if (!body || typeof body !== 'object')
1878
+ return {};
1879
+ const o = body;
1880
+ const inner = o.error && typeof o.error === 'object' ? o.error : o;
1881
+ const code = typeof inner.code === 'string' ? inner.code : undefined;
1882
+ const message = typeof inner.message === 'string' ? inner.message : undefined;
1883
+ return { code, message };
1884
+ }
1885
+ /** Build the error for a non-2xx response. */
1886
+ function errorFromResponse(args) {
1887
+ const { code, message } = readEnvelope(args.body);
1888
+ const kind = (code && CODE_KIND[code]) || kindForStatus(args.status);
1889
+ const retryable = args.status === 429 || args.status >= 500;
1890
+ return newOfKind({
1891
+ kind,
1892
+ code,
1893
+ status: args.status,
1894
+ requestId: args.requestId,
1895
+ responseBody: args.body,
1896
+ retryable,
1897
+ retryAfterMs: args.retryAfterMs,
1898
+ message: message ||
1899
+ `${args.method} ${args.path} failed: ${args.status}${args.statusText ? ` ${args.statusText}` : ''}`,
1900
+ });
1901
+ }
1902
+ /** A request that never reached a status: DNS, TLS, offline, CORS. */
1903
+ function networkError(message, cause) {
1904
+ return new ConsenteraNetworkError({ kind: 'network', message, status: 0, retryable: true, cause });
1905
+ }
1906
+ /** The SDK's own deadline fired. */
1907
+ function timeoutError(message, cause) {
1908
+ return new ConsenteraTimeoutError({ kind: 'timeout', message, status: 0, retryable: true, cause });
1909
+ }
1910
+ /** The caller's AbortSignal fired. NEVER retryable: the caller asked to stop. */
1911
+ function cancelledError(message, cause) {
1912
+ return new ConsenteraError({ kind: 'cancelled', message, status: 0, retryable: false, cause });
1913
+ }
1914
+ /** Misconfiguration found before anything went on the wire. */
1915
+ function configError(message, code) {
1916
+ return new ConsenteraConfigError({ kind: 'config', message, status: 0, code, retryable: false });
1917
+ }
1918
+ /**
1919
+ * @deprecated 2.0.0 — `ConsentEraApiError` is now an alias of {@link ConsenteraError}.
1920
+ * `instanceof` and `.statusCode` still work; `.code`, `.kind` and `.requestId` are new.
1921
+ */
1922
+ const ConsentEraApiError = ConsenteraError;
1923
+
1924
+ /**
1925
+ * The SDK's own version, sent on every request as `X-Consentera-SDK`.
1926
+ *
1927
+ * KEPT IN SYNC BY A TEST, NOT BY A BUILD STEP. `src/__tests__/core/version.test.ts`
1928
+ * reads package.json and fails when the two disagree, so a release that forgets
1929
+ * this file cannot go green. A build-time codegen would have been the other
1930
+ * option; it was rejected because the constant then does not exist when a host
1931
+ * builds from src, and because a generated file in the tree is one command from
1932
+ * being overwritten with the wrong value and nothing noticing.
1933
+ */
1934
+ const SDK_NAME = 'consent-sdk-js';
1935
+ const SDK_VERSION = '2.0.0';
1936
+ const SDK_PLATFORM = 'web';
1937
+ /** The value of the `X-Consentera-SDK` header: `<surface>/<version>`. */
1938
+ const SDK_HEADER_VALUE = `js/${SDK_VERSION}`;
1939
+ /**
1940
+ * The `User-Agent` half of the pair, agreed across all six surfaces
1941
+ * (coordinator ruling 2026-09-22):
1942
+ *
1943
+ * User-Agent: ConsenteraSDK/2.0.0 (<platform>; <runtime>)
1944
+ * X-Consentera-SDK: <surface>/2.0.0
1945
+ *
1946
+ * `ConsenteraSDK/<version>` is the form the PLATFORM ALREADY PARSES:
1947
+ * `internal/core/audit/user_agent_coarsening_test.go:39-40` asserts that
1948
+ * `ConsenteraSDK/2.3.1 (Android 14; SM-G991B; build 4471)` coarsens to
1949
+ * `ConsenteraSDK/2`, so this is the shape its audit pipeline expects rather
1950
+ * than a new one. (A second spelling, `consentera-sdk/1.2`, appears in
1951
+ * `validation_envelope_test.go:233`; the pair above is the agreed one.)
1952
+ *
1953
+ * IN A BROWSER THIS HEADER CANNOT BE SENT. `User-Agent` is a forbidden header
1954
+ * name (fetch spec §forbidden-request-header), so a browser silently drops any
1955
+ * attempt to set it — the SDK does not try, and the browser build identifies
1956
+ * itself with `X-Consentera-SDK` alone. The Node build (and the CLI) send both,
1957
+ * because there the header is ours to set.
1958
+ */
1959
+ function userAgentValue(runtime) {
1960
+ const rt = (typeof process !== 'undefined' && process.versions?.node ? `node ${process.versions.node}` : 'unknown');
1961
+ return `ConsenteraSDK/${SDK_VERSION} (${SDK_PLATFORM}; ${rt})`;
1962
+ }
1963
+
1964
+ /**
1965
+ * Consentera Consent SDK — the transport.
1966
+ *
1967
+ * Everything that talks to the platform goes through `HttpTransport.request`.
1968
+ * One implementation, because the four things below are the ones that are
1969
+ * always missing when each caller writes its own `fetch`:
1970
+ *
1971
+ * 1. A DEADLINE. `fetch` has none. Before 2.0.0 a hung connection hung the
1972
+ * caller's promise forever, which on a consent gate means a page that never
1973
+ * renders. Every request now carries an AbortController, linked to the
1974
+ * caller's own signal so `AbortSignal` cancellation still works.
1975
+ * 2. RETRIES that are safe. Exponential backoff with full jitter on network
1976
+ * failure, 5xx and 429, honouring `Retry-After` when the server sends one.
1977
+ * 3. ONE IDEMPOTENCY KEY PER LOGICAL OPERATION. Before 2.0.0 the key was
1978
+ * minted inside the request function from `Date.now()` + `Math.random()`,
1979
+ * so it changed on every attempt and the caller could not supply one — a
1980
+ * dedupe mechanism that was present and could not dedupe anything. The key
1981
+ * is now minted ONCE per logical operation, reused on every retry of it,
1982
+ * and `options.idempotencyKey` overrides it.
1983
+ * 4. THE REQUEST ID. The platform sets `X-Request-ID` and CORS-exposes it
1984
+ * (cmd/api/main.go:1266, :1272). It is now on every error.
1985
+ *
1986
+ * WHAT IS DELIBERATELY NOT HERE: no request or response BODY is ever logged.
1987
+ * The body of a session create is the Data Principal's identifiers.
1988
+ */
1989
+ const DEFAULT_RETRY = { attempts: 3, baseDelayMs: 250, maxDelayMs: 4000 };
1990
+ const DEFAULT_TIMEOUT_MS = 10_000;
1991
+ /** `typeof window !== 'undefined'` in one place, so a test can reason about it. */
1992
+ function isBrowser() {
1993
+ return typeof window !== 'undefined';
1994
+ }
1995
+ /** A secret DF key: the platform's prefixes for the two secret classes. */
1996
+ function isSecretKey(key) {
1997
+ return !!key && (key.startsWith('tiq_live_') || key.startsWith('tiq_test_'));
1998
+ }
1999
+ /** A public site key. */
2000
+ function isSiteKey(key) {
2001
+ return !!key && key.startsWith('tiq_pub_');
2002
+ }
2003
+ /**
2004
+ * THE ONE REFUSAL, in one place — used by BOTH entry points (ConsentEraClient
2005
+ * and ConsenteraConsent), because a rule enforced at only one door is not a
2006
+ * rule. A secret key in a browser is not a warning: the bundle is public, so by
2007
+ * the time it runs the key is already published to every visitor. Refusing at
2008
+ * construction is the only point at which the integrator still has the option
2009
+ * of not shipping it.
2010
+ *
2011
+ * `fix` is the entry-point-specific remedy (a proxy endpoint for the lifecycle
2012
+ * client, a public site key for the cookie SDK); everything else — the reason,
2013
+ * the prefix echo, the escape hatch — is identical, which is exactly why it
2014
+ * lives here rather than being copied and left to drift.
2015
+ */
2016
+ function assertNoSecretKeyInBrowser(opts) {
2017
+ if (!isBrowser() || !opts.apiKey || opts.unsafeAllowSecretKeyInBrowser)
2018
+ return;
2019
+ throw configError('Consentera: `apiKey` is a SECRET and this SDK is running in a browser, where every ' +
2020
+ 'visitor can read the bundle. ' +
2021
+ opts.fix +
2022
+ ' ' +
2023
+ (isSecretKey(opts.apiKey)
2024
+ ? `The key supplied starts with "${opts.apiKey.slice(0, 9)}" — rotate it, it is now in a bundle. `
2025
+ : '') +
2026
+ 'If you are certain this code never reaches a browser (a test harness with a jsdom window, ' +
2027
+ 'a server-side render that only looks like one), set `unsafeAllowSecretKeyInBrowser: true` ' +
2028
+ 'and every request will warn.', 'SECRET_KEY_IN_BROWSER');
2029
+ }
2030
+ /**
2031
+ * Crypto-strong id. `Math.random()` was what minted idempotency keys before
2032
+ * 2.0.0; it is neither unpredictable nor collision-safe at 6 characters.
2033
+ */
2034
+ function newRequestId() {
2035
+ const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined;
2036
+ if (c && typeof c.randomUUID === 'function')
2037
+ return c.randomUUID();
2038
+ if (c && typeof c.getRandomValues === 'function') {
2039
+ const b = c.getRandomValues(new Uint8Array(16));
2040
+ return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
2041
+ }
2042
+ // Last resort for an ancient browser. Recorded rather than silent: a caller
2043
+ // on such a browser should supply its own idempotencyKey.
2044
+ return `nc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
2045
+ }
2046
+ function sleep(ms, signal) {
2047
+ return new Promise((resolve, reject) => {
2048
+ if (signal?.aborted) {
2049
+ reject(cancelledError('request cancelled'));
2050
+ return;
2051
+ }
2052
+ const t = setTimeout(() => {
2053
+ signal?.removeEventListener('abort', onAbort);
2054
+ resolve();
2055
+ }, ms);
2056
+ const onAbort = () => {
2057
+ clearTimeout(t);
2058
+ reject(cancelledError('request cancelled'));
2059
+ };
2060
+ signal?.addEventListener('abort', onAbort, { once: true });
2061
+ });
2062
+ }
2063
+ /** Full-jitter backoff (AWS's "Exponential Backoff and Jitter"): random in [0, cap]. */
2064
+ function backoffMs(attempt, policy, random = Math.random) {
2065
+ const exp = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt);
2066
+ return Math.floor(random() * exp);
2067
+ }
2068
+ class HttpTransport {
2069
+ config;
2070
+ logger;
2071
+ constructor(config, logger) {
2072
+ this.config = config;
2073
+ this.logger = logger;
2074
+ }
2075
+ /** Swap config after construction (the client re-reads customHeaders each call). */
2076
+ updateConfig(patch) {
2077
+ this.config = { ...this.config, ...patch };
2078
+ }
2079
+ /**
2080
+ * The credential decision for one road, in one place so the policy can be
2081
+ * read rather than reconstructed from call sites.
2082
+ *
2083
+ * Returns the auth headers to send, or throws a config error naming the fix.
2084
+ */
2085
+ authHeadersFor(road, viaProxy) {
2086
+ const { apiKey, siteKey, unsafeAllowSecretKeyInBrowser } = this.config;
2087
+ const browser = isBrowser();
2088
+ // In proxy mode the DF's own server holds the credential and adds it. The
2089
+ // SDK sends none — a key sent here would be a key in the bundle.
2090
+ if (viaProxy)
2091
+ return {};
2092
+ if (road === 'session')
2093
+ return {};
2094
+ if (road === 'public') {
2095
+ if (siteKey)
2096
+ return { 'X-API-Key': siteKey };
2097
+ // A public road with no site key is legitimate: /notices/current takes a
2098
+ // tenant_code in the query.
2099
+ return {};
2100
+ }
2101
+ // road === 'df'
2102
+ if (browser && !unsafeAllowSecretKeyInBrowser) {
2103
+ throw configError('Consentera: this call needs a Data Fiduciary credential, which is a SECRET and must ' +
2104
+ 'never be in a browser bundle. Set `proxyEndpoint` to your own server route (it holds ' +
2105
+ 'the tiq_live_/tiq_test_ key and forwards the call), or run this call server-side. ' +
2106
+ 'A public site key (tiq_pub_) cannot authorise it: the lifecycle roads are ' +
2107
+ 'server-to-server. A site key opens only the public read roads and the ' +
2108
+ 'session-scoped render/submit, and only for the origins in its allowed_domains.', 'SECRET_KEY_IN_BROWSER');
2109
+ }
2110
+ if (apiKey) {
2111
+ if (browser && unsafeAllowSecretKeyInBrowser) {
2112
+ this.logger.warn('Consentera: SENDING A SECRET KEY FROM A BROWSER because ' +
2113
+ 'unsafeAllowSecretKeyInBrowser is set. Every visitor to this page can read it. ' +
2114
+ 'Rotate the key and move to proxyEndpoint.');
2115
+ }
2116
+ return { 'X-API-Key': apiKey };
2117
+ }
2118
+ if (isSiteKey(siteKey)) {
2119
+ throw configError('Consentera: a site key (tiq_pub_) was supplied for a Data Fiduciary lifecycle road. ' +
2120
+ 'Those roads are server-to-server; a site key opens only the public read roads and the ' +
2121
+ 'session-scoped render/submit, so this call would be refused 403. Use `proxyEndpoint`, ' +
2122
+ 'or a secret key server-side.', 'SITE_KEY_ON_DF_ROAD');
2123
+ }
2124
+ throw configError('Consentera: no credential for a Data Fiduciary road. Set `proxyEndpoint` (browser) or ' +
2125
+ '`apiKey` (server).', 'NO_CREDENTIAL');
2126
+ }
2127
+ urlFor(path, query) {
2128
+ const { proxyEndpoint, apiEndpoint } = this.config;
2129
+ // A session/public road is reachable without the DF's proxy, but when a
2130
+ // proxy is configured everything goes through it: one origin to allow-list.
2131
+ const viaProxy = !!proxyEndpoint;
2132
+ const base = viaProxy ? proxyEndpoint : apiEndpoint;
2133
+ const prefix = this.config.apiPathPrefix ?? '/api/v1/public';
2134
+ if (prefix !== '/api/v1/public' && prefix !== '/api/v1/cookie-consent') {
2135
+ throw configError('Consentera: unsupported transport API plane.');
2136
+ }
2137
+ let url = viaProxy ? `${base}${path}` : `${base}${prefix}${path}`;
2138
+ if (query) {
2139
+ const params = new URLSearchParams();
2140
+ for (const [k, v] of Object.entries(query)) {
2141
+ if (v !== undefined && v !== null)
2142
+ params.set(k, String(v));
2143
+ }
2144
+ const qs = params.toString();
2145
+ if (qs)
2146
+ url += `?${qs}`;
2147
+ }
2148
+ return { url, viaProxy };
2149
+ }
2150
+ async request(method, path, body, options = {}) {
2151
+ const road = options.road ?? 'df';
2152
+ const { url, viaProxy } = this.urlFor(path, options.query);
2153
+ const policy = { ...DEFAULT_RETRY, ...this.config.retry, ...options.retry };
2154
+ const timeoutMs = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2155
+ const doFetch = this.config.fetchImpl ?? globalThis.fetch;
2156
+ if (typeof doFetch !== 'function') {
2157
+ throw configError('Consentera: no fetch implementation available in this environment.');
2158
+ }
2159
+ const headers = {
2160
+ Accept: 'application/json',
2161
+ // THE PAIR, and why only one half of it is here.
2162
+ //
2163
+ // All six surfaces identify themselves as
2164
+ // User-Agent: ConsenteraSDK/<version> (<platform>; <runtime>)
2165
+ // X-Consentera-SDK: <surface>/<version>
2166
+ // the first being the form the platform's audit pipeline already parses
2167
+ // (core/audit/user_agent_coarsening_test.go:39-40 coarsens
2168
+ // `ConsenteraSDK/2.3.1 (…)` to `ConsenteraSDK/2`).
2169
+ //
2170
+ // `User-Agent` is a FORBIDDEN HEADER NAME in a browser: fetch drops any
2171
+ // attempt to set it, silently, so setting it here would be a line that
2172
+ // looks like identification and is not. It is therefore added only when
2173
+ // there is no `window` — the Node build — and the browser identifies
2174
+ // itself with X-Consentera-SDK alone.
2175
+ 'X-Consentera-SDK': SDK_HEADER_VALUE,
2176
+ ...(isBrowser() ? {} : { 'User-Agent': userAgentValue() }),
2177
+ ...this.authHeadersFor(road, viaProxy),
2178
+ };
2179
+ if (!viaProxy && road !== 'session' && this.config.tenantId)
2180
+ headers['X-Tenant-Id'] = this.config.tenantId;
2181
+ if (body !== undefined)
2182
+ headers['Content-Type'] = 'application/json';
2183
+ // ONE key for this logical operation, reused on every attempt below.
2184
+ if (method !== 'GET')
2185
+ headers['Idempotency-Key'] = options.idempotencyKey ?? newRequestId();
2186
+ const custom = typeof this.config.customHeaders === 'function' ? this.config.customHeaders() : this.config.customHeaders;
2187
+ Object.assign(headers, custom, options.headers);
2188
+ let outgoing = body;
2189
+ if (body !== undefined && this.config.beforeSend) {
2190
+ const kept = this.config.beforeSend({ method, path, body });
2191
+ if (kept === null || kept === undefined) {
2192
+ throw configError(`Consentera: beforeSend refused ${method} ${path}. Nothing was sent.`, 'BEFORE_SEND_REFUSED');
2193
+ }
2194
+ outgoing = kept;
2195
+ }
2196
+ const payload = outgoing === undefined ? undefined : JSON.stringify(outgoing);
2197
+ let lastError;
2198
+ for (let attempt = 0; attempt < policy.attempts; attempt++) {
2199
+ if (options.signal?.aborted)
2200
+ throw cancelledError('request cancelled before attempt');
2201
+ const started = Date.now();
2202
+ let timedOut = false;
2203
+ const controller = new AbortController();
2204
+ const timer = setTimeout(() => {
2205
+ timedOut = true;
2206
+ controller.abort();
2207
+ }, timeoutMs);
2208
+ const onCallerAbort = () => controller.abort();
2209
+ options.signal?.addEventListener('abort', onCallerAbort, { once: true });
2210
+ try {
2211
+ const response = await doFetch(url, {
2212
+ method,
2213
+ headers,
2214
+ body: payload,
2215
+ signal: controller.signal,
2216
+ });
2217
+ const elapsed = Date.now() - started;
2218
+ const requestId = response.headers?.get?.('X-Request-Id') ?? undefined;
2219
+ if (response.ok) {
2220
+ // NEVER the body — see the header of this file.
2221
+ this.logger.debug(`${method} ${path} -> ${response.status} (${elapsed}ms)`, { requestId });
2222
+ const wrap = (parsed) => (options.raw
2223
+ ? {
2224
+ status: response.status,
2225
+ body: parsed,
2226
+ requestId,
2227
+ retryAfterMs: parseRetryAfterMs(response.headers?.get?.('Retry-After')),
2228
+ }
2229
+ : parsed);
2230
+ if (response.status === 204)
2231
+ return wrap(undefined);
2232
+ const text = await response.text();
2233
+ if (!text)
2234
+ return wrap(undefined);
2235
+ try {
2236
+ return wrap(JSON.parse(text));
2237
+ }
2238
+ catch {
2239
+ // A 200 whose body is not JSON is a SERVER problem. Reporting it as
2240
+ // a network error (which 1.x did) sends the integrator to look at
2241
+ // their connection.
2242
+ throw errorFromResponse({
2243
+ status: response.status,
2244
+ body: text,
2245
+ requestId,
2246
+ method,
2247
+ path,
2248
+ statusText: 'response body is not JSON',
2249
+ });
2250
+ }
2251
+ }
2252
+ const rawText = await response.text();
2253
+ let parsed = rawText;
2254
+ try {
2255
+ parsed = JSON.parse(rawText);
2256
+ }
2257
+ catch {
2258
+ /* keep the text */
2259
+ }
2260
+ const retryAfterMs = parseRetryAfterMs(response.headers?.get?.('Retry-After'));
2261
+ lastError = errorFromResponse({
2262
+ status: response.status,
2263
+ statusText: response.statusText,
2264
+ body: parsed,
2265
+ requestId,
2266
+ retryAfterMs,
2267
+ method,
2268
+ path,
2269
+ });
2270
+ this.logger.debug(`${method} ${path} -> ${response.status} ${lastError.code ?? ''} (${elapsed}ms)`, { requestId });
2271
+ }
2272
+ catch (err) {
2273
+ if (err instanceof ConsenteraError) {
2274
+ if (!err.retryable)
2275
+ throw err;
2276
+ lastError = err;
2277
+ }
2278
+ else if (timedOut) {
2279
+ lastError = timeoutError(`${method} ${path} timed out after ${timeoutMs}ms`, err);
2280
+ }
2281
+ else if (options.signal?.aborted) {
2282
+ throw cancelledError(`${method} ${path} cancelled`, err);
2283
+ }
2284
+ else {
2285
+ lastError = networkError(`${method} ${path}: ${err?.message ?? 'network error'}`, err);
2286
+ }
2287
+ }
2288
+ finally {
2289
+ clearTimeout(timer);
2290
+ options.signal?.removeEventListener('abort', onCallerAbort);
2291
+ }
2292
+ if (!lastError.retryable || attempt === policy.attempts - 1)
2293
+ throw lastError;
2294
+ if (policy.maxRetryAfterMs !== undefined && lastError.retryAfterMs !== undefined &&
2295
+ lastError.retryAfterMs > policy.maxRetryAfterMs)
2296
+ throw lastError;
2297
+ const wait = lastError.retryAfterMs ?? backoffMs(attempt, policy);
2298
+ this.logger.debug(`${method} ${path} retry ${attempt + 1}/${policy.attempts - 1} in ${wait}ms (${lastError.code ?? lastError.kind})`);
2299
+ await sleep(wait, options.signal);
2300
+ }
2301
+ /* istanbul ignore next — the loop always throws on its last attempt. */
2302
+ throw lastError ?? networkError(`${method} ${path} failed`);
2303
+ }
2304
+ }
2305
+
2306
+ /**
2307
+ * ConsentEra Consent SDK Core Module
2308
+ * Main entry point for consent management
2309
+ */
2310
+ class ConsentEraConsent extends EventEmitter {
2311
+ config;
2312
+ storage;
2313
+ banner = null;
2314
+ preferenceCenter = null;
2315
+ tcfManager = null;
2316
+ gppManager = null;
2317
+ logger;
2318
+ initialized = false;
2319
+ // Memoizes concurrent init() calls (autoShow's constructor-triggered init()
2320
+ // racing an integrator's own explicit init() call is a realistic pattern,
2321
+ // not just a React StrictMode artifact) so the async fetch + UI-component
2322
+ // construction only ever happens once.
2323
+ initPromise = null;
2324
+ // Set by destroy(). init()'s fetchConfig() await is a suspend point — a
2325
+ // caller that destroys the SDK while a first init() is still in flight
2326
+ // (React StrictMode's mount->cleanup->mount double-invoke does exactly
2327
+ // this) must not have that stale in-flight call resurrect a banner after
2328
+ // teardown.
2329
+ destroyed = false;
2330
+ remoteConfig = null;
2331
+ // Set from the real config response's policy_version — sent back on every
2332
+ // preferences submission so the server can tell which notice/category
2333
+ // snapshot the visitor's decision was recorded against.
2334
+ remotePolicyVersion = '';
2335
+ ageGateEnabled = false;
2336
+ ageGatePrompt;
2337
+ // Populated only if the integrator calls setAgeDeclaration() before saving.
2338
+ // Left unset by default — the server already fails closed (force-denies
2339
+ // tracking categories) for an unknown-age visitor, so omitting this is
2340
+ // safe, not a compliance gap; it's an enhancement for integrators who
2341
+ // collect their own age signal.
2342
+ pendingIsAdult = null;
2343
+ // A visitor-stable identifier persisted in localStorage (separate from the
2344
+ // consent record itself) — the API's integrity-hash chain and expiry are
2345
+ // scoped "per device", so this must be stable across visits, unlike the
2346
+ // per-action IDs generateConsentId() produces elsewhere in this class.
2347
+ deviceId;
2348
+ constructor(config) {
2349
+ super();
2350
+ // THE P0 THIS SDK USED TO HAVE. ConsenteraConsent is the package DEFAULT
2351
+ // export and the ConsentEraClient refusal did not reach it. authHeaders()
2352
+ // below builds `siteKey || apiKey` into X-API-Key, so a tiq_live_ SECRET
2353
+ // handed to it in a browser shipped straight to /api/v1/cookie-consent/*
2354
+ // — and that plane IGNORES the key, so nothing refused it and nothing
2355
+ // failed; the leak was silent. Same fail-closed rule as the lifecycle
2356
+ // client, through the same shared guard, so the two doors cannot drift.
2357
+ assertNoSecretKeyInBrowser({
2358
+ apiKey: config.apiKey,
2359
+ unsafeAllowSecretKeyInBrowser: config.unsafeAllowSecretKeyInBrowser,
2360
+ fix: 'The cookie-consent plane needs no secret at all — use a public site key ' +
2361
+ '(`siteKey: "tiq_pub_…"`), which is what it reads. Keep the tiq_live_/tiq_test_ key ' +
2362
+ 'on your own server.',
2363
+ });
2364
+ // NO DEFAULT SERVER. Until 2.0.0 an omitted `apiEndpoint` fell back to a
2365
+ // built-in host on the `.io` domain, which the company does not own: it
2366
+ // did not resolve, so every such embed failed, and it was one DNS record
2367
+ // away from handing the visitor's consent payload and the site key to
2368
+ // whoever holds that domain. It then moved to `api.consentera.in`, which
2369
+ // does not exist either (NXDOMAIN, 2026-09-22). There is no host this SDK
2370
+ // could guess correctly — the tenant's API host is deployment-specific —
2371
+ // so it is required, and refused by name here, the same code and the same
2372
+ // rule ConsentEraClient applies (ENDPOINT_REQUIRED).
2373
+ if (typeof config.apiEndpoint !== 'string' || config.apiEndpoint.trim() === '') {
2374
+ throw configError('Consentera: `apiEndpoint` is required — there is no default server. Set it to your ' +
2375
+ "Consentera API origin (the SDK calls `${apiEndpoint}/api/v1/cookie-consent/*`); with " +
2376
+ 'the script-tag build, set `data-api-endpoint`.', 'ENDPOINT_REQUIRED');
2377
+ }
2378
+ this.config = this.mergeDefaults(config);
2379
+ this.logger = new Logger(config.apiKey ? 'info' : 'debug');
2380
+ this.storage = new ConsentStorage(this.config.storage);
2381
+ this.deviceId = this.getOrCreateDeviceId();
2382
+ if (this.config.autoShow) {
2383
+ this.init()
2384
+ .then(() => {
2385
+ if (!this.hasConsent()) {
2386
+ this.showBanner();
2387
+ }
2388
+ })
2389
+ .catch((error) => {
2390
+ // init() now REJECTS when the tenant configuration cannot be loaded
2391
+ // (it used to fabricate a generic notice and carry on). An unhandled
2392
+ // rejection in a constructor is invisible, so it is caught here and
2393
+ // announced: no banner is shown, and the host is told why.
2394
+ this.logger.error('Consentera: auto-init failed; no banner will be shown.', error);
2395
+ this.emit('error', error);
2396
+ this.config.callbacks?.onError?.(error);
2397
+ });
2398
+ }
2399
+ }
2400
+ mergeDefaults(config) {
2401
+ return {
2402
+ // `apiEndpoint` HAS NO DEFAULT, and none belongs here. It is a bare
2403
+ // origin — real routes are mounted at /api/v1/cookie-consent/... (built
2404
+ // into the request URLs below), not under a versioned apiEndpoint prefix,
2405
+ // matching ConsentEraClient's convention — and the constructor has
2406
+ // already refused a config without one.
2407
+ //
2408
+ // The history, because the next person will be tempted to "helpfully"
2409
+ // restore a default: until 2.0.0 it was a host on the `.io` domain, which
2410
+ // is not the company's. That host did not resolve, so every integrator
2411
+ // who omitted `apiEndpoint` got a DNS failure, which 1.x swallowed into a
2412
+ // fabricated banner and a silently-unrecorded consent; three tests
2413
+ // asserted it, so the wrong default was pinned by the suite rather than
2414
+ // caught by it. And the `.io` apex DOES resolve, to someone else's
2415
+ // servers, so it was one DNS record away from receiving consent payloads
2416
+ // and the site key. The first fix moved it to `api.consentera.in`, which
2417
+ // does not exist either. A default only moves the problem; the
2418
+ // integrator's own API host is the only right answer.
2419
+ autoShow: true,
2420
+ language: 'en',
2421
+ region: 'IN',
2422
+ regulations: ['DPDP'],
2423
+ theme: {
2424
+ position: 'bottom',
2425
+ type: 'banner',
2426
+ primaryColor: '#2563eb',
2427
+ backgroundColor: '#ffffff',
2428
+ textColor: '#1f2937',
2429
+ borderRadius: '8px',
2430
+ showLogo: true,
2431
+ },
2432
+ storage: {
2433
+ type: 'cookie',
2434
+ cookieName: 'consentera_consent',
2435
+ cookieExpiry: 365,
2436
+ cookiePath: '/',
2437
+ // `secure` IS DELIBERATELY ABSENT so ConsentStorage's protocol-aware
2438
+ // default applies. A SECOND copy of the storage defaults lived here
2439
+ // with `secure: true` hardcoded, which overrode ConsentStorage's own
2440
+ // default — so on an http:// origin the browser dropped the consent
2441
+ // cookie silently, exists() stayed false and the banner returned on
2442
+ // every page view. One implementation of a default, in the class that
2443
+ // owns it.
2444
+ sameSite: 'Lax',
2445
+ },
2446
+ ...config,
2447
+ };
2448
+ }
2449
+ getOrCreateDeviceId() {
2450
+ const KEY = 'consentera_device_id';
2451
+ try {
2452
+ let id = window.localStorage.getItem(KEY);
2453
+ if (!id) {
2454
+ id = generateConsentId();
2455
+ window.localStorage.setItem(KEY, id);
2456
+ }
2457
+ return id;
2458
+ }
2459
+ catch {
2460
+ // localStorage unavailable (privacy mode, SSR) — fall back to a
2461
+ // session-only id; consent will still work, just won't survive reload.
2462
+ return generateConsentId();
2463
+ }
2464
+ }
2465
+ /**
2466
+ * Supply a self-declared adult/minor signal (DPDP §9(3)) before calling
2467
+ * acceptAll()/rejectAll()/savePreferences(). Optional — if never called,
2468
+ * the server treats the visitor as unknown-age and force-denies tracking
2469
+ * categories regardless of what the visitor otherwise selects.
2470
+ */
2471
+ setAgeDeclaration(isAdult) {
2472
+ this.pendingIsAdult = isAdult;
2473
+ }
2474
+ /**
2475
+ * Whether the tenant has DPDP §9(3) age assurance turned on for this
2476
+ * banner. Populated from the real config once init() resolves; false
2477
+ * before that.
2478
+ */
2479
+ isAgeGateEnabled() {
2480
+ return this.ageGateEnabled;
2481
+ }
2482
+ /**
2483
+ * The tenant-configured age-gate prompt text, if any — for an integrator
2484
+ * building their own age-check UI ahead of calling setAgeDeclaration().
2485
+ */
2486
+ getAgeGatePrompt() {
2487
+ return this.ageGatePrompt;
2488
+ }
2489
+ /**
2490
+ * Initialize the SDK
2491
+ */
2492
+ init() {
2493
+ if (this.initialized) {
2494
+ this.logger.warn('ConsentEra Consent SDK already initialized');
2495
+ return Promise.resolve();
2496
+ }
2497
+ // A second concurrent call (e.g. autoShow's constructor-triggered init()
2498
+ // racing an integrator's own explicit init() call) joins the same
2499
+ // in-flight promise instead of re-fetching config and constructing a
2500
+ // second set of UI components.
2501
+ if (this.initPromise) {
2502
+ return this.initPromise;
2503
+ }
2504
+ this.initPromise = this.doInit().finally(() => {
2505
+ this.initPromise = null;
2506
+ });
2507
+ return this.initPromise;
2508
+ }
2509
+ async doInit() {
2510
+ try {
2511
+ this.logger.info('Initializing ConsentEra Consent SDK...');
2512
+ // Fetch remote configuration
2513
+ const remoteConfig = await this.fetchConfig();
2514
+ // destroy() may have run while fetchConfig() was in flight (e.g. React
2515
+ // StrictMode's dev-mode mount->cleanup->mount double-invoke destroys
2516
+ // the first instance before its init() resolves) — a torn-down
2517
+ // instance must not resurrect a banner/preference center.
2518
+ if (this.destroyed) {
2519
+ return;
2520
+ }
2521
+ this.remoteConfig = remoteConfig;
2522
+ // Initialize TCF if enabled
2523
+ if (this.config.tcf?.enabled) {
2524
+ this.tcfManager = new TCFManager(this.config.tcf);
2525
+ await this.tcfManager.init();
2526
+ }
2527
+ // Initialize GPP if enabled
2528
+ if (this.config.gpp?.enabled) {
2529
+ this.gppManager = new GPPManager(this.config.gpp);
2530
+ await this.gppManager.init();
2531
+ }
2532
+ if (this.destroyed) {
2533
+ return;
2534
+ }
2535
+ // Create UI components
2536
+ this.banner = new ConsentBanner(this.remoteConfig, this.config.theme, this);
2537
+ this.preferenceCenter = new PreferenceCenter(this.remoteConfig, this.config.theme, this);
2538
+ this.initialized = true;
2539
+ this.emit('ready');
2540
+ this.config.callbacks?.onReady?.();
2541
+ this.logger.info('ConsentEra Consent SDK initialized successfully');
2542
+ }
2543
+ catch (error) {
2544
+ this.logger.error('Failed to initialize SDK:', error);
2545
+ this.config.callbacks?.onError?.(error);
2546
+ throw error;
2547
+ }
2548
+ }
2549
+ authHeaders() {
2550
+ const headers = { 'X-Tenant-Id': this.config.tenantId };
2551
+ // Site key FIRST — it is the credential this plane is meant to carry. A
2552
+ // secret apiKey only reaches here in a browser through
2553
+ // unsafeAllowSecretKeyInBrowser (the constructor refuses it otherwise), and
2554
+ // then every request says so, the way the transport does.
2555
+ const key = this.config.siteKey || this.config.apiKey;
2556
+ if (key) {
2557
+ if (isBrowser() && !this.config.siteKey && isSecretKey(this.config.apiKey)) {
2558
+ this.logger.warn('Consentera: SENDING A SECRET KEY FROM A BROWSER because ' +
2559
+ 'unsafeAllowSecretKeyInBrowser is set. Every visitor to this page can read it. ' +
2560
+ 'The cookie-consent plane needs only a public site key — rotate this one.');
2561
+ }
2562
+ headers['X-API-Key'] = key;
2563
+ }
2564
+ return headers;
2565
+ }
2566
+ /**
2567
+ * Fetch configuration from server — the real, public, unauthenticated
2568
+ * (beyond X-Tenant-Id) /api/v1/cookie-consent/config surface. Previously
2569
+ * this called {apiEndpoint}/consent/config/{tenantId}, a route that does
2570
+ * not exist anywhere in the API, so every real embed silently fell back to
2571
+ * getDefaultConfig() and never showed the tenant's actual categories.
2572
+ */
2573
+ async fetchConfig() {
2574
+ try {
2575
+ const response = await fetch(`${this.config.apiEndpoint}/api/v1/cookie-consent/config`, {
2576
+ headers: {
2577
+ ...this.authHeaders(),
2578
+ 'Accept-Language': this.config.language || 'en',
2579
+ },
2580
+ });
2581
+ if (!response.ok) {
2582
+ throw new Error(`Failed to fetch config: ${response.statusText}`);
2583
+ }
2584
+ const real = await response.json();
2585
+ this.remotePolicyVersion = real.policy_version;
2586
+ this.ageGateEnabled = real.age_gate_enabled;
2587
+ this.ageGatePrompt = real.age_gate_prompt;
2588
+ return mapCookieConfigToSDKConfig(real, this.config);
2589
+ }
2590
+ catch (error) {
2591
+ // NO FABRICATED NOTICE. Until 2.0.0 this fell back to getDefaultConfig():
2592
+ // a hardcoded "We value your privacy" banner with invented purpose ids
2593
+ // (essential / analytics / marketing) that are not the tenant's, and a
2594
+ // policy_version of ''. A decision taken against a notice the controller
2595
+ // never published is not a consent, so there is nothing safe to render
2596
+ // here — the banner stays away and the host is told.
2597
+ this.logger.error('Consentera: the tenant consent configuration could not be loaded.', error);
2598
+ throw error instanceof Error ? error : new Error(String(error));
2599
+ }
2600
+ }
2601
+ // getDefaultConfig() WAS HERE AND IS DELETED (2.0.0). It returned a generic
2602
+ // banner with fabricated purposes whenever the tenant's real configuration
2603
+ // could not be fetched, and consent was then collected against it. See
2604
+ // fetchConfig above for why there is no safe fallback.
2605
+ /**
2606
+ * Show consent banner
2607
+ */
2608
+ showBanner() {
2609
+ if (!this.initialized) {
2610
+ this.logger.warn('SDK not initialized. Call init() first.');
2611
+ return;
2612
+ }
2613
+ this.banner?.show();
2614
+ this.emit('banner_shown');
2615
+ this.config.callbacks?.onBannerShown?.();
2616
+ }
2617
+ /**
2618
+ * Hide consent banner
2619
+ */
2620
+ hideBanner() {
2621
+ this.banner?.hide();
2622
+ this.emit('banner_closed');
2623
+ this.config.callbacks?.onBannerClosed?.();
2624
+ }
2625
+ /**
2626
+ * Show preference center
2627
+ */
2628
+ showPreferences() {
2629
+ if (!this.initialized) {
2630
+ this.logger.warn('SDK not initialized. Call init() first.');
2631
+ return;
2632
+ }
2633
+ this.preferenceCenter?.show();
2634
+ this.emit('preferences_opened');
2635
+ }
2636
+ /**
2637
+ * Hide preference center
2638
+ */
2639
+ hidePreferences() {
2640
+ this.preferenceCenter?.hide();
2641
+ }
2642
+ /**
2643
+ * Accept all consent purposes
2644
+ */
2645
+ async acceptAll() {
2646
+ const preferences = {
2647
+ essential: true,
2648
+ functional: true,
2649
+ analytics: true,
2650
+ marketing: true,
2651
+ personalization: true,
2652
+ advertising: true,
2653
+ thirdParty: true,
2654
+ };
2655
+ this.emit('accept_all');
2656
+ return this.saveConsent(preferences);
2657
+ }
2658
+ /**
2659
+ * Reject all non-essential purposes
2660
+ */
2661
+ async rejectAll() {
2662
+ const preferences = {
2663
+ essential: true,
2664
+ functional: false,
2665
+ analytics: false,
2666
+ marketing: false,
2667
+ personalization: false,
2668
+ advertising: false,
2669
+ thirdParty: false,
2670
+ };
2671
+ this.emit('reject_all');
2672
+ return this.saveConsent(preferences);
2673
+ }
2674
+ /**
2675
+ * Save consent preferences
2676
+ */
2677
+ async savePreferences(preferences) {
2678
+ this.emit('preferences_saved', preferences);
2679
+ return this.saveConsent(preferences);
2680
+ }
2681
+ async saveConsent(preferences) {
2682
+ try {
2683
+ const selections = this.buildSelections(preferences);
2684
+ // syncConsent THROWS when the platform did not record the decision, so
2685
+ // nothing below this line runs on a failure: no local cookie, no hidden
2686
+ // banner, no consent_given. That is the point — see syncConsent.
2687
+ const submitResult = await this.syncConsent(selections);
2688
+ // DPDP §9(3): effective_selections is the server-authoritative result
2689
+ // AFTER the minor force-deny gate. Building the stored/emitted consent
2690
+ // from the client's raw `preferences` instead — as this used to do —
2691
+ // would let a minor's tracking-category consent read as granted
2692
+ // locally even when the server denied it.
2693
+ const effective = submitResult.effective_selections ?? selections;
2694
+ const consent = this.buildConsentData(effective);
2695
+ this.storage.save(consent);
2696
+ if (this.tcfManager) {
2697
+ await this.tcfManager.updateConsent(this.selectionsToPreferences(effective));
2698
+ }
2699
+ if (this.gppManager) {
2700
+ await this.gppManager.updateConsent(this.selectionsToPreferences(effective));
2701
+ }
2702
+ this.hideBanner();
2703
+ this.hidePreferences();
2704
+ this.emit('consent_given', consent);
2705
+ this.config.callbacks?.onConsentGiven?.(consent);
2706
+ // onPreferencesUpdated is part of the public ConsentCallbacks type but was
2707
+ // never invoked — an integrator who wired it got silence forever. Fire it
2708
+ // from the SERVER-AUTHORITATIVE effective selections (post §9(3) force-deny),
2709
+ // not the client's raw request, so a listener that re-gates scripts on this
2710
+ // payload inherits the same protection as isConsentGiven().
2711
+ this.config.callbacks?.onPreferencesUpdated?.(this.selectionsToPreferences(effective));
2712
+ return { success: true, consentId: consent.consentId };
2713
+ }
2714
+ catch (error) {
2715
+ this.logger.error('Failed to save consent:', error);
2716
+ return { success: false, error: error.message };
2717
+ }
2718
+ }
2719
+ /** Client-requested selections, keyed by category/purpose id — required
2720
+ * (mandatory) categories are always true regardless of the input. This is
2721
+ * what gets POSTed; the server may still override individual entries in
2722
+ * its response (see effective_selections above). */
2723
+ buildSelections(preferences) {
2724
+ const selections = {};
2725
+ for (const purpose of this.remoteConfig?.purposes || []) {
2726
+ selections[purpose.id] = purpose.required || !!preferences[purpose.id];
2727
+ }
2728
+ return selections;
2729
+ }
2730
+ selectionsToPreferences(selections) {
2731
+ const prefs = {
2732
+ essential: true,
2733
+ functional: false,
2734
+ analytics: false,
2735
+ marketing: false,
2736
+ personalization: false,
2737
+ advertising: false,
2738
+ thirdParty: false,
2739
+ };
2740
+ for (const [id, granted] of Object.entries(selections)) {
2741
+ prefs[id] = granted;
2742
+ }
2743
+ return prefs;
2744
+ }
2745
+ buildConsentData(selections) {
2746
+ const purposes = (this.remoteConfig?.purposes || []).map((p) => ({
2747
+ id: p.id,
2748
+ name: p.name,
2749
+ description: p.description,
2750
+ category: p.category,
2751
+ required: p.required,
2752
+ consented: p.required || !!selections[p.id],
2753
+ legalBasis: p.required ? 'legitimate_interest' : 'consent',
2754
+ }));
2755
+ return {
2756
+ consentId: generateConsentId(),
2757
+ timestamp: new Date().toISOString(),
2758
+ version: this.remotePolicyVersion || '1.0',
2759
+ purposes,
2760
+ legalBasis: 'consent',
2761
+ regulation: this.config.regulations?.[0] || 'DPDP',
2762
+ metadata: {
2763
+ userAgent: navigator.userAgent,
2764
+ pageUrl: window.location.href,
2765
+ referrer: document.referrer,
2766
+ device: getDeviceInfo(),
2767
+ },
2768
+ };
2769
+ }
2770
+ /**
2771
+ * POST /api/v1/cookie-consent/preferences — the real submit endpoint.
2772
+ * Previously this posted to {apiEndpoint}/consent, a route that does not
2773
+ * exist anywhere in the API, so no real embed ever actually recorded a
2774
+ * consent decision server-side (it silently failed and kept only the
2775
+ * local copy).
2776
+ *
2777
+ * ─── IT THROWS. IT USED TO SWALLOW, AND THAT WAS THE WORST DEFECT HERE ────
2778
+ *
2779
+ * Until 2.0.0 every failure — offline, 403, a wrong host, a 500 — was caught,
2780
+ * logged at warn, and turned into `return null`. The caller then wrote the
2781
+ * local cookie anyway, hid the banner, fired `consent_given` and reported
2782
+ * success. The visitor had clicked Reject All; the platform had no record of
2783
+ * it; and because the cookie existed the banner never came back to ask again.
2784
+ * A withdrawal was lost the same way. The SDK's own log line was the only
2785
+ * trace, at a level nobody reads in production.
2786
+ *
2787
+ * Now the failure reaches the caller, the local copy is NOT written, the
2788
+ * banner stays up, and the host hears about it on `sync_failed` and
2789
+ * `callbacks.onSyncFailed`.
2790
+ */
2791
+ async syncConsent(selections) {
2792
+ try {
2793
+ const body = {
2794
+ device_id: this.deviceId,
2795
+ policy_version: this.remotePolicyVersion,
2796
+ selections,
2797
+ affirmative_action: {
2798
+ type: 'button',
2799
+ ui_event_id: generateConsentId(),
2800
+ captured_at: new Date().toISOString(),
2801
+ },
2802
+ };
2803
+ if (this.ageGateEnabled && this.pendingIsAdult !== null) {
2804
+ body.age_declaration = { is_adult: this.pendingIsAdult };
2805
+ }
2806
+ const transport = new HttpTransport({
2807
+ apiEndpoint: this.config.apiEndpoint,
2808
+ apiPathPrefix: '/api/v1/cookie-consent',
2809
+ retry: { maxRetryAfterMs: 5000 },
2810
+ }, this.logger);
2811
+ return await transport.request('POST', '/preferences', body, { road: 'public', headers: this.authHeaders() });
2812
+ }
2813
+ catch (error) {
2814
+ this.logger.error('Consentera: consent sync failed — the decision was not recorded.', {
2815
+ message: error?.message,
2816
+ });
2817
+ this.emit('sync_failed', error);
2818
+ this.config.callbacks?.onSyncFailed?.(error);
2819
+ throw error;
2820
+ }
2821
+ }
2822
+ /**
2823
+ * Get current consent data
2824
+ */
2825
+ getConsent() {
2826
+ return this.storage.get();
2827
+ }
2828
+ /**
2829
+ * Check if consent has been given
2830
+ */
2831
+ hasConsent() {
2832
+ return this.storage.exists();
2833
+ }
2834
+ /**
2835
+ * Withdraw consent for specified purposes (or all optional purposes if
2836
+ * none given). There is no separate public withdraw endpoint for the
2837
+ * cookie widget — the real API expresses withdrawal the same way a
2838
+ * changed-mind re-visit is expressed: resubmitting selections via
2839
+ * POST /api/v1/cookie-consent/preferences with the withdrawn categories
2840
+ * set to false. Previously this called {apiEndpoint}/consent/withdraw,
2841
+ * a route that does not exist anywhere in the API.
2842
+ */
2843
+ async withdrawConsent(purposes) {
2844
+ try {
2845
+ const currentConsent = this.getConsent();
2846
+ if (!currentConsent) {
2847
+ return { success: false, error: 'No consent found' };
2848
+ }
2849
+ const selections = {};
2850
+ for (const purpose of this.remoteConfig?.purposes || []) {
2851
+ if (purpose.required) {
2852
+ selections[purpose.id] = true;
2853
+ continue;
2854
+ }
2855
+ const isWithdrawn = purposes ? purposes.includes(purpose.id) : true;
2856
+ if (isWithdrawn) {
2857
+ selections[purpose.id] = false;
2858
+ }
2859
+ else {
2860
+ const wasConsented = currentConsent.purposes.find((p) => p.id === purpose.id)?.consented ?? false;
2861
+ selections[purpose.id] = wasConsented;
2862
+ }
2863
+ }
2864
+ const submitResult = await this.syncConsent(selections);
2865
+ const effective = submitResult.effective_selections ?? selections;
2866
+ const consent = this.buildConsentData(effective);
2867
+ this.storage.save(consent);
2868
+ if (this.tcfManager) {
2869
+ await this.tcfManager.updateConsent(this.selectionsToPreferences(effective));
2870
+ }
2871
+ if (this.gppManager) {
2872
+ await this.gppManager.updateConsent(this.selectionsToPreferences(effective));
2873
+ }
2874
+ this.emit('consent_withdrawn', purposes || []);
2875
+ this.config.callbacks?.onConsentWithdrawn?.(purposes || []);
2876
+ return { success: true, consentId: consent.consentId };
2877
+ }
2878
+ catch (error) {
2879
+ return { success: false, error: error.message };
2880
+ }
2881
+ }
2882
+ /**
2883
+ * Check if consent is given for a specific purpose
2884
+ */
2885
+ isConsentGiven(purpose) {
2886
+ const consent = this.getConsent();
2887
+ if (!consent)
2888
+ return false;
2889
+ const purposeData = consent.purposes.find((p) => p.id === purpose);
2890
+ return purposeData?.consented || false;
2891
+ }
2892
+ /**
2893
+ * Get current preferences
2894
+ */
2895
+ getPreferences() {
2896
+ const consent = this.getConsent();
2897
+ if (!consent) {
2898
+ return {
2899
+ essential: true,
2900
+ functional: false,
2901
+ analytics: false,
2902
+ marketing: false,
2903
+ personalization: false,
2904
+ advertising: false,
2905
+ thirdParty: false,
2906
+ };
2907
+ }
2908
+ const preferences = { essential: true };
2909
+ consent.purposes.forEach((p) => {
2910
+ preferences[p.id] = p.consented;
2911
+ });
2912
+ return preferences;
2913
+ }
2914
+ /**
2915
+ * Set language
2916
+ */
2917
+ setLanguage(language) {
2918
+ this.config.language = language;
2919
+ // Refetch config with new language if needed
2920
+ }
2921
+ /**
2922
+ * Destroy SDK instance
2923
+ */
2924
+ destroy() {
2925
+ this.destroyed = true;
2926
+ this.banner?.destroy();
2927
+ this.preferenceCenter?.destroy();
2928
+ this.tcfManager?.destroy();
2929
+ this.gppManager?.destroy();
2930
+ this.removeAllListeners();
2931
+ this.initialized = false;
2932
+ }
2933
+ }
2934
+ /**
2935
+ * Translates the real /api/v1/cookie-consent/config response into the SDK's
2936
+ * own internal ConfigResponse/BannerConfig/PurposeConfig shape that
2937
+ * ConsentBanner and PreferenceCenter render from. Isolated here (rather than
2938
+ * rewriting those two renderers around the real field names) so the
2939
+ * rendering layer's contract stays stable while only the network layer
2940
+ * changes — the real backend's vocabulary (cookie "categories" with a
2941
+ * mandatory/sort_order flag) is a different shape than the SDK's generic
2942
+ * "purposes", not just a rename.
2943
+ */
2944
+ function mapCookieConfigToSDKConfig(real, config) {
2945
+ const purposes = [...real.categories]
2946
+ .sort((a, b) => a.sort_order - b.sort_order)
2947
+ .map((c) => ({
2948
+ id: c.key,
2949
+ name: c.name,
2950
+ description: c.description,
2951
+ required: c.mandatory,
2952
+ defaultEnabled: c.mandatory,
2953
+ category: mapCategoryKeyToPurposeCategory(c.key),
2954
+ }));
2955
+ // Messaging keys mirror what the admin Banners tab actually writes
2956
+ // (page.tsx's bannerForm.messaging: title/description/accept_text/
2957
+ // reject_text/customize_text) — this is a free-form map on the backend,
2958
+ // not a fixed schema, but these are the keys real banners contain.
2959
+ const msg = real.banner?.messaging || {};
2960
+ const banner = {
2961
+ title: msg.title,
2962
+ description: msg.description,
2963
+ acceptAllText: msg.accept_text,
2964
+ rejectAllText: msg.reject_text,
2965
+ customizeText: msg.customize_text,
2966
+ saveText: msg.save_text,
2967
+ showRejectAll: real.ui_options?.reject_all ?? true,
2968
+ showCustomize: real.ui_options?.customize ?? true,
2969
+ showPrivacyPolicy: true,
2970
+ purposes,
2971
+ };
2972
+ return {
2973
+ tenantId: real.tenant_id,
2974
+ banner,
2975
+ purposes,
2976
+ theme: config.theme,
2977
+ regulations: config.regulations || ['DPDP'],
2978
+ languages: [real.language_code],
2979
+ };
2980
+ }
2981
+ function mapCategoryKeyToPurposeCategory(key) {
2982
+ const k = key.toLowerCase();
2983
+ if (k.includes('essential') || k.includes('necess') || k.includes('strict') || k.includes('security')) {
2984
+ return 'essential';
2985
+ }
2986
+ if (k.includes('analyt'))
2987
+ return 'analytics';
2988
+ if (k.includes('market') || k.includes('advertis'))
2989
+ return 'marketing';
2990
+ if (k.includes('function') || k.includes('prefer'))
2991
+ return 'functional';
2992
+ if (k.includes('personal'))
2993
+ return 'personalization';
2994
+ return 'third_party';
2995
+ }
2996
+
2997
+ /**
2998
+ * Consentera Consent SDK — THE artifact read, in one place.
2999
+ *
3000
+ * `ConsentSession.getArtifact` and the callback handler's confirmation both
3001
+ * read GET /consent/artifacts/{artifact_id}, and before this file each spelled
3002
+ * the request itself. Both spelled it WITHOUT `?session_id=`, which on the
3003
+ * platform that ships is the difference between two answers:
3004
+ *
3005
+ * without session_id a consent whose artifact is still being written is a
3006
+ * 404 ARTIFACT_NOT_FOUND — the same word a guessed id
3007
+ * gets. The callback handler therefore had to read
3008
+ * every 404 as "maybe pending" and poll it, so a FORGED
3009
+ * artifact_id came back `pending` instead of refused.
3010
+ * with session_id 202 + Retry-After while the projection is owed; 404
3011
+ * only when the id was never issued for that session or
3012
+ * will never be written. (GetArtifactHandler,
3013
+ * consent/collection.go:4409-4520 at 4fda7e3d05; walk
3014
+ * finding F018; SDK register WEB-033.)
3015
+ *
3016
+ * Measured on setup.consentera.in (4fda7e3d05): an unknown artifact read with a
3017
+ * real session is 404; the walk's real artifact with its session is 200. See
3018
+ * fixtures/platform-wire/artifact-read.*.json at the repo root.
3019
+ *
3020
+ * WHAT THE session_id DOES NOT DO (walk finding F077, measured): once the
3021
+ * artifact row exists the 200 path reads by tenant + artifact_id ALONE. The
3022
+ * same artifact read with a random session_id is still 200. So a 200 does not
3023
+ * prove the artifact belongs to your session; compare its `data_principal_id`
3024
+ * with the one your session create returned (the callback handler does).
3025
+ */
3026
+ /** Retry-After when the 202 carried neither the header nor `retry_after`. */
3027
+ const DEFAULT_PENDING_RETRY_MS = 1000;
3028
+ function artifactPath(artifactId) {
3029
+ // encodeURIComponent: an id with a URL-special character (or a caller passing
3030
+ // a path fragment) would otherwise reshape the request.
3031
+ return `/consent/artifacts/${encodeURIComponent(artifactId)}`;
3032
+ }
3033
+ /**
3034
+ * Read one artifact. Resolves `recorded` (200) or `pending` (202); a 404, a
3035
+ * 403 and everything else is the transport's typed ConsenteraError.
3036
+ */
3037
+ async function readArtifact(request, artifactId, options = {}) {
3038
+ const res = await request('GET', artifactPath(artifactId), undefined, { session_id: options.sessionId || undefined }, { raw: true, signal: options.signal, timeoutMs: options.timeoutMs });
3039
+ if (res.status === 200 && res.body && typeof res.body === 'object') {
3040
+ return { state: 'recorded', artifact: res.body, requestId: res.requestId };
3041
+ }
3042
+ if (res.status === 202 && res.body && typeof res.body === 'object') {
3043
+ const pending = res.body;
3044
+ const fromBody = typeof pending.retry_after === 'number' && pending.retry_after >= 0 ? pending.retry_after * 1000 : undefined;
3045
+ return {
3046
+ state: 'pending',
3047
+ pending,
3048
+ retryAfterMs: res.retryAfterMs ?? fromBody ?? DEFAULT_PENDING_RETRY_MS,
3049
+ requestId: res.requestId,
3050
+ };
3051
+ }
3052
+ // Any other 2xx (a 204, a 200 with no body) is not an answer this road
3053
+ // gives. Reporting it as an artifact would be inventing one.
3054
+ throw errorFromResponse({
3055
+ status: res.status,
3056
+ statusText: 'not an artifact read answer (expected 200 or 202 with a JSON body)',
3057
+ body: res.body,
3058
+ requestId: res.requestId,
3059
+ method: 'GET',
3060
+ path: artifactPath(artifactId),
3061
+ });
3062
+ }
3063
+
3064
+ /**
3065
+ * Consentera Consent SDK — the consent popup, and the ONE message it listens for.
3066
+ *
3067
+ * ─── WHAT /collect ACTUALLY POSTS (SDK register WEB-035) ────────────────────
3068
+ *
3069
+ * After a decision, an EMBEDDED /collect page (window.parent !== window) posts
3070
+ * exactly one message to its parent. That is decisionMessage() in
3071
+ * consentera-ui/src/app/collect/[sessionId]/decision-message.ts, posted at
3072
+ * page.tsx:1535-1545 (grant) and :1670-1680 (decline) on the served commit
3073
+ * 4fda7e3d05:
3074
+ *
3075
+ * { type: 'consentera:submitted' | 'consentera:declined',
3076
+ * session_id, artifact_id, status: 'granted'|'partial'|'denied', pending,
3077
+ * sessionId, redirectUrl, choices } // the three legacy fields
3078
+ *
3079
+ * `choices` rides only on a submit. The TARGET ORIGIN is the origin of the
3080
+ * session's callback_url (redirect-trust.ts parentOriginFor), so the page that
3081
+ * frames /collect must be on the callback's origin or the browser drops the
3082
+ * message without a word.
3083
+ *
3084
+ * Before this file the SDK opened the page with window.open and listened for
3085
+ * `consentera:consent-result`, a type the platform has never sent. That was
3086
+ * wrong twice over, because a window.open popup is a TOP-LEVEL page. It posts
3087
+ * nothing: it navigates to the callback instead. The storage fallback polled
3088
+ * sessionStorage, which a separate window does not share. So the promise could
3089
+ * end only when the window closed, as `unverified`, or at the 10-minute
3090
+ * timeout.
3091
+ *
3092
+ * So the popup is now what the platform's own website popup is: a dialog on
3093
+ * THIS page with /collect in an iframe (consentera-ui/src/lib/onboarding/
3094
+ * website-consent.ts, createDialog). It is the presentation in which /collect
3095
+ * reports the decision.
3096
+ *
3097
+ * ─── WHAT THE RESULT IS, AND WHAT IT IS NOT ────────────────────────────────
3098
+ *
3099
+ * The message comes from the platform's origin and from the frame this SDK
3100
+ * opened (both are checked), so it is the platform page's report of the
3101
+ * decision. It is not the consent record. The record is the artefact: confirm
3102
+ * it with getArtifact(artifact_id, { sessionId }) through your proxy, or on
3103
+ * your server, before you act on a grant.
3104
+ */
3105
+ const TYPES = new Set(['consentera:submitted', 'consentera:declined']);
3106
+ const STATUSES = new Set(['granted', 'partial', 'denied']);
3107
+ function queryOf(url) {
3108
+ if (typeof url !== 'string' || !url)
3109
+ return null;
3110
+ try {
3111
+ return new URL(url, 'https://collect.invalid').searchParams;
3112
+ }
3113
+ catch {
3114
+ return null;
3115
+ }
3116
+ }
3117
+ /**
3118
+ * The platform's own rule for a status the message did not carry
3119
+ * (decision-message.ts decisionStatus): the redirect URL's status when it has
3120
+ * one, else from the choices — none granted → denied, some denied → partial,
3121
+ * else granted.
3122
+ */
3123
+ function derivedStatus(q, choices) {
3124
+ const fromUrl = q?.get('status');
3125
+ if (fromUrl && STATUSES.has(fromUrl))
3126
+ return fromUrl;
3127
+ const values = Object.values(choices ?? {});
3128
+ const granted = values.filter(Boolean).length;
3129
+ if (granted === 0)
3130
+ return 'denied';
3131
+ if (granted < values.length)
3132
+ return 'partial';
3133
+ return 'granted';
3134
+ }
3135
+ /**
3136
+ * Read a MessageEvent as /collect's decision — or null when it is not one, or
3137
+ * not from where it must come from.
3138
+ *
3139
+ * Exported for a Data Fiduciary that frames consent_url itself: the same
3140
+ * checks, one implementation.
3141
+ *
3142
+ * origin must equal the /collect origin (the consent_url's origin)
3143
+ * source when given, must be the frame that was opened
3144
+ * type consentera:submitted | consentera:declined
3145
+ * session `session_id` (or the legacy `sessionId`) must be this session;
3146
+ * when both are present they must agree
3147
+ */
3148
+ function readDecisionMessage(event, expected) {
3149
+ if (!expected.origin || event.origin !== expected.origin)
3150
+ return null;
3151
+ if (expected.source !== undefined && event.source !== expected.source)
3152
+ return null;
3153
+ const data = event.data;
3154
+ if (!data || typeof data !== 'object' || typeof data.type !== 'string' || !TYPES.has(data.type))
3155
+ return null;
3156
+ const snake = typeof data.session_id === 'string' && data.session_id ? data.session_id : undefined;
3157
+ const legacy = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : undefined;
3158
+ if (snake && legacy && snake !== legacy)
3159
+ return null;
3160
+ const sessionId = snake ?? legacy;
3161
+ if (!sessionId || sessionId !== expected.sessionId)
3162
+ return null;
3163
+ const redirectUrl = typeof data.redirectUrl === 'string' && data.redirectUrl ? data.redirectUrl : undefined;
3164
+ const q = queryOf(redirectUrl);
3165
+ const choices = data.choices && typeof data.choices === 'object' ? data.choices : undefined;
3166
+ const type = data.type;
3167
+ // The platform's own value when it sent one; otherwise its own derivation.
3168
+ // A decline carries no choices, so it derives `denied` unless the redirect
3169
+ // URL says otherwise.
3170
+ const status = typeof data.status === 'string' && STATUSES.has(data.status)
3171
+ ? data.status
3172
+ : derivedStatus(q, type === 'consentera:declined' ? undefined : choices);
3173
+ const artifactId = typeof data.artifact_id === 'string' && data.artifact_id ? data.artifact_id : (q?.get('artifact_id') ?? '');
3174
+ return {
3175
+ outcome: 'decided',
3176
+ type,
3177
+ session_id: sessionId,
3178
+ artifact_id: artifactId,
3179
+ status,
3180
+ pending: data.pending === true || q?.get('pending') === '1',
3181
+ ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
3182
+ ...(choices ? { choices } : {}),
3183
+ };
3184
+ }
3185
+ /**
3186
+ * Refuse a callback that cannot deliver the decision to this page.
3187
+ * Returns the refusal, or null when the callback is fine or unknown.
3188
+ */
3189
+ function callbackOriginProblem(callbackUrl, pageOrigin) {
3190
+ if (!callbackUrl)
3191
+ return null;
3192
+ let cb;
3193
+ try {
3194
+ cb = new URL(callbackUrl, pageOrigin);
3195
+ }
3196
+ catch {
3197
+ return `the session's callback_url (${callbackUrl}) is not a URL`;
3198
+ }
3199
+ if (cb.protocol !== 'https:' && cb.protocol !== 'http:') {
3200
+ return (`the session's callback_url is an app link (${cb.protocol}//…). /collect then posts its decision to its ` +
3201
+ 'own origin, which this page is not, so the popup would never hear it. Use redirectToConsent, or ' +
3202
+ 'create the session with a callback_url on this page’s origin.');
3203
+ }
3204
+ if (cb.origin !== pageOrigin) {
3205
+ return (`/collect posts the decision to the callback's origin (${cb.origin}), and this page is ${pageOrigin}. ` +
3206
+ 'The browser drops a message addressed to another origin, so the popup would never hear it. ' +
3207
+ 'Create the session with a callback_url on this page’s origin.');
3208
+ }
3209
+ return null;
3210
+ }
3211
+ /**
3212
+ * Open consent_url in a dialog on this page and resolve with the decision
3213
+ * /collect posts, or `dismissed` when the person closes it.
3214
+ */
3215
+ function openConsentDialog(session, options = {}) {
3216
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
3217
+ throw new Error('openConsentPopup can only be used in browser environments');
3218
+ }
3219
+ const problem = callbackOriginProblem(options.callbackUrl, window.location.origin);
3220
+ if (problem) {
3221
+ return Promise.reject(configError(`Consentera: ${problem}`, 'CALLBACK_ORIGIN_MISMATCH'));
3222
+ }
3223
+ let consentOrigin;
3224
+ try {
3225
+ consentOrigin = new URL(session.consent_url, window.location.href).origin;
3226
+ }
3227
+ catch {
3228
+ return Promise.reject(configError('Consentera: consent_url is not a URL.', 'CONSENT_URL_INVALID'));
3229
+ }
3230
+ const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
3231
+ const title = options.title ?? 'Your consent choices';
3232
+ const host = options.container ?? document.body;
3233
+ return new Promise((resolve, reject) => {
3234
+ const previouslyFocused = document.activeElement;
3235
+ const previousOverflow = document.documentElement.style.overflow;
3236
+ const overlay = document.createElement('div');
3237
+ overlay.dataset.consenteraPopup = 'true';
3238
+ overlay.style.cssText =
3239
+ 'position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;' +
3240
+ 'padding:12px;background:rgba(15,23,42,.6)';
3241
+ const box = document.createElement('div');
3242
+ box.setAttribute('role', 'dialog');
3243
+ box.setAttribute('aria-modal', 'true');
3244
+ box.setAttribute('aria-label', title);
3245
+ box.style.cssText =
3246
+ 'display:flex;flex-direction:column;width:100%;max-width:680px;height:min(780px,100%);background:#fff;' +
3247
+ 'border-radius:14px;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.35)';
3248
+ const head = document.createElement('div');
3249
+ head.style.cssText = 'display:flex;justify-content:flex-end;padding:8px;border-bottom:1px solid #e5e7eb';
3250
+ const close = document.createElement('button');
3251
+ close.type = 'button';
3252
+ close.textContent = 'Close';
3253
+ close.setAttribute('aria-label', `Close — ${title}`);
3254
+ close.style.cssText =
3255
+ 'font:inherit;font-size:14px;padding:8px 14px;min-height:40px;border:1px solid #d1d5db;border-radius:8px;' +
3256
+ 'background:#fff;color:#111827;cursor:pointer';
3257
+ const frame = document.createElement('iframe');
3258
+ frame.title = title;
3259
+ frame.src = session.consent_url;
3260
+ frame.style.cssText = 'flex:1;width:100%;border:0;background:#fff';
3261
+ head.append(close);
3262
+ box.append(head, frame);
3263
+ overlay.append(box);
3264
+ host.append(overlay);
3265
+ document.documentElement.style.overflow = 'hidden';
3266
+ close.focus();
3267
+ let settled = false;
3268
+ const finish = (fn) => {
3269
+ if (settled)
3270
+ return;
3271
+ settled = true;
3272
+ clearTimeout(deadline);
3273
+ window.removeEventListener('message', onMessage);
3274
+ document.removeEventListener('keydown', onKey, true);
3275
+ overlay.remove();
3276
+ document.documentElement.style.overflow = previousOverflow;
3277
+ if (previouslyFocused && typeof previouslyFocused.focus === 'function')
3278
+ previouslyFocused.focus();
3279
+ fn();
3280
+ };
3281
+ const dismiss = () => finish(() => resolve({ outcome: 'dismissed', session_id: session.consent_session_id }));
3282
+ const onMessage = (event) => {
3283
+ const decision = readDecisionMessage(event, {
3284
+ origin: consentOrigin,
3285
+ sessionId: session.consent_session_id,
3286
+ source: frame.contentWindow,
3287
+ });
3288
+ if (decision)
3289
+ finish(() => resolve(decision));
3290
+ };
3291
+ const onKey = (event) => {
3292
+ if (event.key === 'Escape') {
3293
+ event.preventDefault();
3294
+ dismiss();
3295
+ }
3296
+ };
3297
+ const deadline = setTimeout(() => finish(() => reject(new Error(`Consentera: the consent dialog had no decision within ${timeoutMs}ms. The session is still ` +
3298
+ 'valid — verify it server-side, or reopen it.'))), timeoutMs);
3299
+ close.addEventListener('click', dismiss);
3300
+ window.addEventListener('message', onMessage);
3301
+ document.addEventListener('keydown', onKey, true);
3302
+ });
3303
+ }
3304
+
3305
+ /**
3306
+ * ConsentEra Consent SDK — Session Management
3307
+ * Create consent sessions, retrieve artifacts, handle redirect flows
3308
+ */
3309
+ class ConsentSession {
3310
+ request;
3311
+ config;
3312
+ logger;
3313
+ /**
3314
+ * The callback_url each session was created with, in this page. The popup
3315
+ * needs it: /collect posts its decision to that URL's origin.
3316
+ */
3317
+ callbackUrls = new Map();
3318
+ constructor(request, config, logger) {
3319
+ this.request = request;
3320
+ this.config = config;
3321
+ this.logger = logger;
3322
+ }
3323
+ /**
3324
+ * Create a new consent session for consent collection.
3325
+ * Returns a session with consent_url for redirect/popup flow.
3326
+ *
3327
+ * createSession({
3328
+ * data_principal: { email: 'riya@example.in' },
3329
+ * notice_internal_name: 'bnb_consent_v2',
3330
+ * age: { date_of_birth: '1998-04-12' },
3331
+ * })
3332
+ *
3333
+ * Identity is `data_principal` — the identifiers, keyed by THIS
3334
+ * ORGANISATION'S locked integration key — or `data_principal_id`; the type of
3335
+ * {@link CreateSessionRequest} requires at least one of the two, because the
3336
+ * API refuses a request with neither.
3337
+ *
3338
+ * ─── THIS IS THE U58 WIRE, AND THERE IS NO OVERLAP WINDOW ────────────────
3339
+ *
3340
+ * `data_principal_ref`, `data_principal_ref_type`, `data_principal_details`,
3341
+ * `locale_pref`, `notice_language`, `template_language` and `purpose_ids` are
3342
+ * DELETED from the API's request struct. This SDK version talks to an API
3343
+ * carrying U58 and to no other, in both directions:
3344
+ *
3345
+ * old SDK -> new API the identifiers are dropped, then 400
3346
+ * new SDK -> old API the identifiers are dropped, then 400
3347
+ *
3348
+ * EVERY FIELD THIS BUILDS IS A FIELD THE API DECODES. The handler decodes
3349
+ * with a plain `json.Decoder` and NO `DisallowUnknownFields` — on BOTH wires
3350
+ * — so a key it does not know is dropped in silence rather than refused. That
3351
+ * is why the two wires cannot be mixed and why the failure above is a 400
3352
+ * about a MISSING identifier rather than about the field you sent. Check a
3353
+ * field against the request struct before adding it.
3354
+ */
3355
+ async createSession(params, options) {
3356
+ // THE CALLBACK STATE, minted here and nowhere else — before the call,
3357
+ // because callback_url is a REQUEST field. See withCallbackState.
3358
+ const requestedCallback = params.callback_url || this.config.callbackUrl;
3359
+ const state = requestedCallback ? newCallbackState() : undefined;
3360
+ const body = {
3361
+ ui_mode: params.ui_mode || 'redirect',
3362
+ callback_url: requestedCallback && state ? withCallbackState(requestedCallback, state) : undefined,
3363
+ };
3364
+ // The identifiers, by the tenant's own field names. Sent as-is: this SDK
3365
+ // does not know the key and must not guess at it — a field outside the key
3366
+ // is the API's 400 UNKNOWN_IDENTIFIER_FIELD, which can name the allowed
3367
+ // fields, and a guess here could only turn that into silence.
3368
+ if (params.data_principal && Object.keys(params.data_principal).length > 0) {
3369
+ body.data_principal = params.data_principal;
3370
+ }
3371
+ if (params.data_principal_id)
3372
+ body.data_principal_id = params.data_principal_id;
3373
+ if (params.age && params.age.date_of_birth) {
3374
+ body.age = { date_of_birth: params.age.date_of_birth };
3375
+ }
3376
+ // ONE language field. Only sent when a language was actually asked for: it
3377
+ // goes to the FRONT of the server's locale fallback chain, AHEAD of the
3378
+ // tenant's own default, so a hard-coded fallback here would override that
3379
+ // default for every caller who never set one.
3380
+ const language = params.language || this.config.language;
3381
+ if (language)
3382
+ body.language = language;
3383
+ if (params.session_ref)
3384
+ body.session_ref = params.session_ref;
3385
+ if (params.notice_internal_name)
3386
+ body.notice_internal_name = params.notice_internal_name;
3387
+ if (params.notice_version_number !== undefined) {
3388
+ body.notice_version_number = params.notice_version_number;
3389
+ }
3390
+ if (params.customer_token)
3391
+ body.customer_token = params.customer_token;
3392
+ // The guardian channel — top-level, and never inside data_principal, which
3393
+ // holds the identifiers of the person the consent is ABOUT. Either, not
3394
+ // both: one invitation goes to one address.
3395
+ if (params.guardian_email)
3396
+ body.guardian_email = params.guardian_email;
3397
+ if (params.guardian_phone)
3398
+ body.guardian_phone = params.guardian_phone;
3399
+ if (params.guardian_relationship) {
3400
+ body.guardian_relationship = params.guardian_relationship;
3401
+ }
3402
+ const response = await this.request('POST', '/consent/sessions', body, undefined, {
3403
+ // ONE key for this logical session creation, reused across the
3404
+ // transport's own retries so a retried create cannot mint two sessions
3405
+ // for one person.
3406
+ idempotencyKey: options?.idempotencyKey ?? newRequestId(),
3407
+ signal: options?.signal,
3408
+ timeoutMs: options?.timeoutMs,
3409
+ });
3410
+ // THE STORED HALF OF THE CALLBACK HANDSHAKE. CallbackHandler.parseCallback
3411
+ // reads this record back, compares the session id and the STATE this SDK
3412
+ // put on callback_url, and refuses anything that does not match. It is not
3413
+ // the server's challengeNonce: that is the hosted page's credential, it
3414
+ // rides consent_url, and the platform's return never carries it — storing
3415
+ // it here is what made every genuine web redirect come back `unverified`.
3416
+ const persisted = writeStored('session', storageKeys.session(response.consent_session_id), JSON.stringify({
3417
+ session_id: response.consent_session_id,
3418
+ ...(state ? { state } : {}),
3419
+ notice_hash: response.notice_hash,
3420
+ // The person this session is ABOUT. The callback handler compares it
3421
+ // with the artifact's data_principal_id, because the platform's 200
3422
+ // artifact read does not check the session (walk finding F077).
3423
+ data_principal_id: response.data_principal_id,
3424
+ created_at: new Date().toISOString(),
3425
+ }));
3426
+ if (!persisted) {
3427
+ // Storage is blocked; the handshake now lives in memory and will not
3428
+ // survive the redirect. Say so, because the callback will come back
3429
+ // `unverified` and the integrator needs to know why.
3430
+ this.logger.warn('Consentera: browser storage is unavailable, so the callback handshake cannot survive a ' +
3431
+ 'page navigation. Use ui_mode "popup", or verify the artifact server-side.');
3432
+ }
3433
+ if (typeof body.callback_url === 'string' && body.callback_url) {
3434
+ this.callbackUrls.set(response.consent_session_id, body.callback_url);
3435
+ }
3436
+ this.logger.info('Consent session created', {
3437
+ session_id: response.consent_session_id,
3438
+ });
3439
+ return response;
3440
+ }
3441
+ /**
3442
+ * Redirect the user to the ConsentEra consent collection widget.
3443
+ * Only works in browser environments.
3444
+ */
3445
+ redirectToConsent(session) {
3446
+ if (typeof window === 'undefined') {
3447
+ throw new Error('redirectToConsent can only be used in browser environments');
3448
+ }
3449
+ window.location.href = session.consent_url;
3450
+ }
3451
+ /**
3452
+ * Open the consent page in a dialog ON THIS PAGE and resolve with the
3453
+ * decision it reports, or `{ outcome: 'dismissed' }` when the person closes
3454
+ * it. Rejects after `timeoutMs` (default 10 minutes).
3455
+ *
3456
+ * const r = await consent.openConsentPopup(session);
3457
+ * if (r.outcome === 'decided') confirmOnServer(r.session_id, r.artifact_id);
3458
+ *
3459
+ * It listens for the ONE message /collect posts, `consentera:submitted` /
3460
+ * `consentera:declined`, from the /collect origin and the frame it opened.
3461
+ * See consent/consentPopup.ts for the wire and for why this is a dialog and
3462
+ * not window.open (a top-level /collect posts nothing) (SDK register WEB-035).
3463
+ *
3464
+ * THIS PAGE MUST BE ON THE CALLBACK'S ORIGIN: /collect addresses the message
3465
+ * to the session's callback_url origin. A session created by this SDK is
3466
+ * checked before anything opens (`CALLBACK_ORIGIN_MISMATCH`); pass
3467
+ * `callbackUrl` when your server set a different one. The page must also be
3468
+ * in your integration client's Allowed Domains, or the platform refuses to
3469
+ * be framed (frame-ancestors).
3470
+ *
3471
+ * The result is what the platform page reported, not the consent record:
3472
+ * confirm the artefact before acting on a grant.
3473
+ */
3474
+ openConsentPopup(session, options) {
3475
+ return openConsentDialog(session, {
3476
+ ...options,
3477
+ callbackUrl: options?.callbackUrl ?? this.callbackUrls.get(session.consent_session_id) ?? this.config.callbackUrl,
3478
+ });
3479
+ }
3480
+ /**
3481
+ * Read a consent artifact.
3482
+ *
3483
+ * const r = await consent.getArtifact(artifactId, { sessionId });
3484
+ * if (r.state === 'pending') retryIn(r.retryAfterMs); // 202: recorded, still being written
3485
+ * else use(r.artifact); // 200
3486
+ *
3487
+ * PASS THE SESSION ID the callback gave you. The platform answers 202 for a
3488
+ * consent whose artifact is still being written only when it can see which
3489
+ * session is asking; without it that consent is a 404, the same answer a
3490
+ * guessed id gets. A 404 WITH a session id means the artifact was never
3491
+ * issued for that session: it throws a ConsenteraNotFoundError.
3492
+ *
3493
+ * A 200 is not proof the artifact belongs to your session — the platform
3494
+ * checks the session only while the artifact is missing (walk finding F077).
3495
+ * Compare `artifact.data_principal_id` with your session's.
3496
+ */
3497
+ async getArtifact(artifactId, options) {
3498
+ return readArtifact(this.request, artifactId, options);
3499
+ }
3500
+ }
3501
+ /**
3502
+ * A fresh per-attempt callback state: 32 random bytes, hex. From the platform
3503
+ * CSPRNG only — there is no Math.random fallback, because a guessable state is
3504
+ * a forgeable callback. (`newRequestId` may fall back; this must not.)
3505
+ */
3506
+ function newCallbackState() {
3507
+ const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined;
3508
+ if (!c || typeof c.getRandomValues !== 'function') {
3509
+ throw configError('Consentera: no crypto.getRandomValues here, so no unguessable callback state can be made. ' +
3510
+ 'This SDK will not fall back to Math.random for it.', 'NO_SECURE_RANDOM');
3511
+ }
3512
+ return Array.from(c.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, '0')).join('');
3513
+ }
3514
+ /**
3515
+ * callback_url with this SDK's `state` added to its query.
3516
+ *
3517
+ * WHY IT SURVIVES THE ROUND TRIP — a platform fact, read on pre-main
3518
+ * 35cd853ac7: addRedirectParams appends session_id/artifact_id to callback_url
3519
+ * with `&` when it already has a `?` (redirect_params.go:17), and
3520
+ * setRedirectParam then parses, SETS status / pending / sig and re-encodes —
3521
+ * every other key, `state` included, is kept. The mobile SDKs have bound their
3522
+ * callbacks this way since 2.0.0.
3523
+ *
3524
+ * `state` IS RESERVED. A callback_url that already carries one is refused
3525
+ * rather than overwritten: overwriting would silently break the Data
3526
+ * Fiduciary's own use of it, and keeping theirs would bind nothing.
3527
+ */
3528
+ function withCallbackState(callbackUrl, state) {
3529
+ let u;
3530
+ try {
3531
+ u = new URL(callbackUrl);
3532
+ }
3533
+ catch {
3534
+ throw configError(`Consentera: callback_url must be an absolute URL (the platform refuses anything else); got ${JSON.stringify(callbackUrl.slice(0, 80))}`, 'INVALID_CALLBACK_URL');
3535
+ }
3536
+ if (u.searchParams.has('state')) {
3537
+ throw configError('Consentera: callback_url already carries a `state` parameter. This SDK puts its own callback ' +
3538
+ 'state there to bind the return to this browser; use a different parameter name for yours.', 'CALLBACK_STATE_RESERVED');
3539
+ }
3540
+ u.searchParams.set('state', state);
3541
+ return u.toString();
3542
+ }
3543
+
3544
+ /**
3545
+ * ConsentEra Consent SDK — Context Helpers
3546
+ * Build client context and affirmative action payloads
3547
+ */
3548
+ /** Strip the query and fragment: the parts that carry the host's own data. */
3549
+ function pagePath(href) {
3550
+ try {
3551
+ const u = new URL(href);
3552
+ return `${u.origin}${u.pathname}`;
3553
+ }
3554
+ catch {
3555
+ return undefined;
3556
+ }
3557
+ }
3558
+ /**
3559
+ * Build client context from the current browser environment.
3560
+ *
3561
+ * @param sessionId optional consent session to correlate against
3562
+ * @param mode how much to collect; see {@link ContextMode}. Default `minimal`.
3563
+ */
3564
+ function buildClientContext(sessionId, mode = 'minimal') {
3565
+ if (mode === 'none')
3566
+ return undefined;
3567
+ if (typeof window === 'undefined') {
3568
+ return {
3569
+ user_agent: 'Consentera SDK (SSR)',
3570
+ platform: 'web',
3571
+ };
3572
+ }
3573
+ const ua = navigator.userAgent;
3574
+ let browserName = 'Unknown';
3575
+ let browserVersion = '';
3576
+ let osName = 'Unknown';
3577
+ let osVersion = '';
3578
+ let deviceType = 'desktop';
3579
+ // Browser detection
3580
+ if (ua.includes('Chrome') && !ua.includes('Edg')) {
3581
+ browserName = 'Chrome';
3582
+ browserVersion = ua.match(/Chrome\/([\d.]+)/)?.[1] || '';
3583
+ }
3584
+ else if (ua.includes('Safari') && !ua.includes('Chrome')) {
3585
+ browserName = 'Safari';
3586
+ browserVersion = ua.match(/Version\/([\d.]+)/)?.[1] || '';
3587
+ }
3588
+ else if (ua.includes('Firefox')) {
3589
+ browserName = 'Firefox';
3590
+ browserVersion = ua.match(/Firefox\/([\d.]+)/)?.[1] || '';
3591
+ }
3592
+ else if (ua.includes('Edg')) {
3593
+ browserName = 'Edge';
3594
+ browserVersion = ua.match(/Edg\/([\d.]+)/)?.[1] || '';
3595
+ }
3596
+ // OS detection
3597
+ if (ua.includes('Windows')) {
3598
+ osName = 'Windows';
3599
+ osVersion = ua.match(/Windows NT ([\d.]+)/)?.[1] || '';
3600
+ }
3601
+ else if (ua.includes('Mac OS X')) {
3602
+ osName = 'macOS';
3603
+ osVersion = ua.match(/Mac OS X ([\d_.]+)/)?.[1]?.replace(/_/g, '.') || '';
3604
+ }
3605
+ else if (ua.includes('Android')) {
3606
+ osName = 'Android';
3607
+ osVersion = ua.match(/Android ([\d.]+)/)?.[1] || '';
3608
+ }
3609
+ else if (ua.includes('iPhone') || ua.includes('iPad')) {
3610
+ osName = 'iOS';
3611
+ osVersion = ua.match(/OS ([\d_]+)/)?.[1]?.replace(/_/g, '.') || '';
3612
+ }
3613
+ else if (ua.includes('Linux')) {
3614
+ osName = 'Linux';
3615
+ }
3616
+ // Device type
3617
+ if (/Mobi|Android.*Mobile|iPhone/.test(ua)) {
3618
+ deviceType = 'mobile';
3619
+ }
3620
+ else if (/iPad|Android(?!.*Mobile)|Tablet/.test(ua)) {
3621
+ deviceType = 'tablet';
3622
+ }
3623
+ const minimal = {
3624
+ platform: 'web',
3625
+ device_type: deviceType,
3626
+ browser_name: browserName,
3627
+ os_name: osName,
3628
+ session_id: sessionId,
3629
+ };
3630
+ if (mode === 'minimal')
3631
+ return minimal;
3632
+ return {
3633
+ ...minimal,
3634
+ user_agent: ua,
3635
+ screen_resolution: `${window.screen.width}x${window.screen.height}`,
3636
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
3637
+ browser_version: browserVersion,
3638
+ os_version: osVersion,
3639
+ page_url: pagePath(window.location.href),
3640
+ referrer: document.referrer ? pagePath(document.referrer) : undefined,
3641
+ };
3642
+ }
3643
+ /**
3644
+ * Build an affirmative action payload.
3645
+ */
3646
+ function buildAffirmativeAction(uiEventId, type = 'button') {
3647
+ return {
3648
+ type,
3649
+ ui_event_id: uiEventId,
3650
+ captured_at: new Date().toISOString(),
3651
+ };
3652
+ }
3653
+ /**
3654
+ * Turn a {@link PrincipalRef} into the request-body fields that name the
3655
+ * person. Sends exactly ONE of the two keys, never both and never an empty
3656
+ * object.
3657
+ *
3658
+ * NEVER BOTH, and that is not tidiness: the API refuses a request carrying
3659
+ * `data_principal_ref` even when `data_principal_id` is also present, for the
3660
+ * stated reason that two fields naming a person can disagree and the caller
3661
+ * would never learn which one the answer was about. Sending one key keeps this
3662
+ * SDK on the right side of that rule by construction.
3663
+ */
3664
+ function principalBody(who) {
3665
+ if ('data_principal_id' in who && who.data_principal_id) {
3666
+ return { data_principal_id: who.data_principal_id };
3667
+ }
3668
+ const ids = who.data_principal_identifiers;
3669
+ if (!ids || Object.keys(ids).length === 0) {
3670
+ // The identifier FIELDS are the tenant's own locked integration key, which
3671
+ // this SDK cannot know and does not enumerate (F015): a message listing a
3672
+ // fixed five would name fields this organisation may not use and omit ones
3673
+ // it does. The SERVER lists the key's actual fields in its
3674
+ // UNKNOWN_IDENTIFIER_FIELD refusal; here we only say WHERE the fields come
3675
+ // from.
3676
+ throw new Error('Consentera: name the Data Principal with data_principal_id, or with ' +
3677
+ 'data_principal_identifiers carrying the identifier fields of this ' +
3678
+ 'organisation\'s integration key. data_principal_ref is refused by the API.');
3679
+ }
3680
+ return { data_principal_identifiers: ids };
3681
+ }
3682
+
3683
+ /**
3684
+ * ConsentEra Consent SDK — Consent Validation
3685
+ * Validate consent status for single or multiple purposes
3686
+ */
3687
+ class ConsentValidator {
3688
+ request;
3689
+ logger;
3690
+ constructor(request, logger) {
3691
+ this.request = request;
3692
+ this.logger = logger;
3693
+ }
3694
+ /**
3695
+ * Validate consent for a single purpose.
3696
+ * Returns ALLOW or DENY with reason code.
3697
+ *
3698
+ * check({ data_principal_identifiers: { email: 'riya@example.in' } },
3699
+ * 'product_analytics')
3700
+ * check({ data_principal_id: '…' }, 'product_analytics')
3701
+ *
3702
+ * ─── data_principal_ref IS REFUSED OUTRIGHT ────────────────────────────
3703
+ *
3704
+ * Owner ruling 2026-09-21, no transition period
3705
+ * (consent/lifecycle_identity.go:8-11). A request carrying it is answered
3706
+ * 400 VALIDATION_ERROR whose message begins `DATA_PRINCIPAL_REF_REFUSED`,
3707
+ * and the refusal fires even when `data_principal_id` is also present.
3708
+ *
3709
+ * NAME PEOPLE BY THE ORGANISATION'S LOCKED KEY FIELDS (F015): the mobile atom
3710
+ * is `mobile` on this road AND on create — `phone` is refused by name. See
3711
+ * {@link DataPrincipalIdentifiers}.
3712
+ */
3713
+ async check(who, purposeCode, options) {
3714
+ const body = { ...principalBody(who), purpose_code: purposeCode };
3715
+ const response = await this.request('POST', '/consent/validate', body, undefined, { signal: options?.signal, timeoutMs: options?.timeoutMs });
3716
+ // Never log the identifiers themselves — they are raw PII, and this line
3717
+ // used to carry the opaque handle verbatim. Log WHICH way the person was
3718
+ // named, which is what a support question actually needs.
3719
+ this.logger.debug('Consent validated', {
3720
+ namedBy: 'data_principal_id' in who ? 'data_principal_id' : 'data_principal_identifiers',
3721
+ purpose: purposeCode,
3722
+ decision: response.decision,
3723
+ });
3724
+ return response;
3725
+ }
3726
+ /**
3727
+ * Validate consent for multiple purposes at once.
3728
+ * Returns results map indexed by purpose code.
3729
+ *
3730
+ * `data_principal_ref` AND `data_principal_refs` are both refused here
3731
+ * (consent/validate.go:249,257).
3732
+ */
3733
+ async checkBulk(who, purposeCodes, options) {
3734
+ const body = {
3735
+ ...principalBody(who),
3736
+ purpose_codes: purposeCodes,
3737
+ };
3738
+ const response = await this.request('POST', '/consent/validate/bulk', body, undefined, { signal: options?.signal, timeoutMs: options?.timeoutMs });
3739
+ this.logger.debug('Bulk consent validated', {
3740
+ namedBy: 'data_principal_id' in who ? 'data_principal_id' : 'data_principal_identifiers',
3741
+ purposes: purposeCodes.length,
3742
+ results: response.results?.map((r) => `${r.purpose_code}:${r.decision}`),
3743
+ });
3744
+ return response;
3745
+ }
3746
+ /**
3747
+ * Quick boolean check — is this purpose allowed?
3748
+ *
3749
+ * isAllowed({ data_principal_identifiers: { email: 'riya@example.in' } },
3750
+ * 'product_analytics')
3751
+ */
3752
+ async isAllowed(who, purposeCode, options) {
3753
+ const result = await this.check(who, purposeCode, options);
3754
+ return result.decision === 'ALLOW';
3755
+ }
3756
+ /**
3757
+ * Check multiple purposes and return a map of purpose → boolean.
3758
+ */
3759
+ async areAllowed(who, purposeCodes) {
3760
+ const response = await this.checkBulk(who, purposeCodes);
3761
+ const map = new Map();
3762
+ for (const result of response.results || []) {
3763
+ // purpose_code is omitempty on the wire; a result without one is keyed
3764
+ // by its purpose_id rather than dropped under an `undefined` key.
3765
+ map.set(result.purpose_code ?? result.purpose_id, result.decision === 'ALLOW');
3766
+ }
3767
+ return map;
3768
+ }
3769
+ }
3770
+
3771
+ /**
3772
+ * ConsentEra Consent SDK — Consent Lifecycle Manager
3773
+ * Update, withdraw, and renew consent decisions
3774
+ */
3775
+ class ConsentManager {
3776
+ request;
3777
+ logger;
3778
+ /**
3779
+ * Called after every successful consent mutation with
3780
+ * {dataPrincipalId, changes:[{purpose_id,status}], source}. The client wires this
3781
+ * to its EventEmitter as the public `consent.changed` event — downstream
3782
+ * code (tracker gates, Consent Mode bridges) subscribes instead of polling.
3783
+ */
3784
+ onChanged;
3785
+ contextMode;
3786
+ constructor(request, logger, options) {
3787
+ this.request = request;
3788
+ this.logger = logger;
3789
+ this.contextMode = options?.contextMode ?? 'minimal';
3790
+ }
3791
+ /** The client context for a mutation, at whatever detail the host allowed. */
3792
+ context() {
3793
+ return buildClientContext(undefined, this.contextMode);
3794
+ }
3795
+ /** One key per logical operation, reused by the transport across its retries. */
3796
+ mutation(options) {
3797
+ return {
3798
+ idempotencyKey: options?.idempotencyKey ?? newRequestId(),
3799
+ signal: options?.signal,
3800
+ timeoutMs: options?.timeoutMs,
3801
+ };
3802
+ }
3803
+ emitChanged(dataPrincipalId, changes, source) {
3804
+ try {
3805
+ this.onChanged?.({ dataPrincipalId, changes, source });
3806
+ }
3807
+ catch (e) {
3808
+ this.logger.warn('consent.changed listener threw', { error: String(e) });
3809
+ }
3810
+ }
3811
+ // =========================================================================
3812
+ // Consent Update
3813
+ // =========================================================================
3814
+ /**
3815
+ * Get the current consent context for a data principal.
3816
+ * Returns all consents with their current status.
3817
+ *
3818
+ * ─── THIS ROAD TAKES THE ID AND NOTHING ELSE ──────────────────────────
3819
+ *
3820
+ * It is a GET, so the only place an identifier could go is the query string,
3821
+ * and the platform refuses to put one there: a query string is written
3822
+ * VERBATIM into the access log of every hop that sees the request line, kept
3823
+ * in browser history, and sent onward in the Referer header
3824
+ * (consent/lifecycle_identity.go:120-133). So this road was RESTRICTED to
3825
+ * `data_principal_id`, not converted to identifiers the way the POST roads
3826
+ * were — `?data_principal_ref=` is refused `DATA_PRINCIPAL_REF_REFUSED`
3827
+ * even when `data_principal_id` is also present.
3828
+ *
3829
+ * IF YOU HOLD AN IDENTIFIER AND NOT THE ID: resolve it once with
3830
+ * `validate.check({ data_principal_identifiers: … }, purpose)` and read
3831
+ * `data_principal_id` off that response, then call this with the id.
3832
+ *
3833
+ * The parameter used to be `dataPrincipalIdOrRef` and guessed by UUID shape:
3834
+ * anything not UUID-shaped went out as `data_principal_ref`, which this road
3835
+ * now refuses. The guess is gone — a non-UUID is rejected here, by name.
3836
+ */
3837
+ async getUpdateContext(dataPrincipalId, queryParams) {
3838
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(dataPrincipalId)) {
3839
+ throw new Error('Consentera: getUpdateContext takes a data_principal_id (a uuid), not an identifier. ' +
3840
+ 'This road deliberately accepts no identifiers — a query string is written verbatim ' +
3841
+ 'into access logs, browser history and Referer headers. Resolve the id once with ' +
3842
+ 'validate.check({ data_principal_identifiers: … }, purpose) and pass the ' +
3843
+ 'data_principal_id from its response.');
3844
+ }
3845
+ const params = { data_principal_id: dataPrincipalId };
3846
+ if (queryParams?.language_code)
3847
+ params.language_code = queryParams.language_code;
3848
+ return this.request('GET', '/consent/update/context', undefined, params);
3849
+ }
3850
+ /**
3851
+ * Update consent decisions for a data principal.
3852
+ * Pass an array of purpose updates (grant or deny).
3853
+ */
3854
+ async update(dataPrincipalId, updates, context, uiEventId = 'btn_save_preferences', options) {
3855
+ const body = {
3856
+ data_principal_id: dataPrincipalId,
3857
+ notice_version_id: context.noticeVersionId,
3858
+ notice_hash: context.noticeHash,
3859
+ language_code: context.languageCode || 'en',
3860
+ updates,
3861
+ affirmative_action: buildAffirmativeAction(uiEventId),
3862
+ client_context: this.context(),
3863
+ };
3864
+ await this.request('POST', '/consent/update', body, undefined, this.mutation(options));
3865
+ this.logger.info('Consent updated', {
3866
+ dataPrincipal: dataPrincipalId,
3867
+ updates: updates.map((u) => `${u.purpose_id}:${u.new_status}`),
3868
+ });
3869
+ this.emitChanged(dataPrincipalId, updates.map((u) => ({ purpose_id: u.purpose_id, status: u.new_status })), 'update');
3870
+ }
3871
+ /**
3872
+ * Convenience: Grant specific purposes.
3873
+ */
3874
+ async grant(dataPrincipalId, purposeIds, context, uiEventId = 'btn_grant_consent', options) {
3875
+ const updates = purposeIds.map((id) => ({
3876
+ purpose_id: id,
3877
+ new_status: 'granted',
3878
+ }));
3879
+ return this.update(dataPrincipalId, updates, context, uiEventId, options);
3880
+ }
3881
+ /**
3882
+ * Convenience: Deny specific purposes.
3883
+ */
3884
+ async deny(dataPrincipalId, purposeIds, context, uiEventId = 'btn_deny_consent', options) {
3885
+ const updates = purposeIds.map((id) => ({
3886
+ purpose_id: id,
3887
+ new_status: 'denied',
3888
+ }));
3889
+ return this.update(dataPrincipalId, updates, context, uiEventId, options);
3890
+ }
3891
+ // =========================================================================
3892
+ // Consent Withdrawal
3893
+ // =========================================================================
3894
+ /**
3895
+ * Get the withdrawal context — which consents can be withdrawn.
3896
+ */
3897
+ async getWithdrawalContext(dataPrincipalId) {
3898
+ return this.request('GET', '/consent/withdrawal/context', undefined, { data_principal_id: dataPrincipalId });
3899
+ }
3900
+ /**
3901
+ * Withdraw consent for specific purposes.
3902
+ */
3903
+ async withdraw(dataPrincipalId, purposeIds, reason, uiEventId = 'btn_withdraw_consent', options) {
3904
+ const body = {
3905
+ data_principal_id: dataPrincipalId,
3906
+ purposes: purposeIds,
3907
+ reason,
3908
+ affirmative_action: buildAffirmativeAction(uiEventId),
3909
+ client_context: this.context(),
3910
+ };
3911
+ await this.request('POST', '/consent/withdraw', body, undefined, this.mutation(options));
3912
+ this.logger.info('Consent withdrawn', {
3913
+ dataPrincipal: dataPrincipalId,
3914
+ purposes: purposeIds,
3915
+ });
3916
+ this.emitChanged(dataPrincipalId, purposeIds.map((p) => ({ purpose_id: p, status: 'withdrawn' })), 'withdraw');
3917
+ }
3918
+ /**
3919
+ * Bulk withdraw consent for multiple purposes.
3920
+ */
3921
+ async withdrawBulk(dataPrincipalId, purposeIds, reason, uiEventId = 'btn_withdraw_all', options) {
3922
+ const body = {
3923
+ data_principal_id: dataPrincipalId,
3924
+ purposes: purposeIds,
3925
+ reason,
3926
+ affirmative_action: buildAffirmativeAction(uiEventId),
3927
+ client_context: this.context(),
3928
+ };
3929
+ await this.request('POST', '/consent/withdraw/bulk', body, undefined, this.mutation(options));
3930
+ this.logger.info('Bulk consent withdrawn', {
3931
+ dataPrincipal: dataPrincipalId,
3932
+ purposes: purposeIds.length,
3933
+ });
3934
+ this.emitChanged(dataPrincipalId, purposeIds.map((p) => ({ purpose_id: p, status: 'withdrawn' })), 'withdraw_bulk');
3935
+ }
3936
+ /**
3937
+ * Get withdrawal analytics (reasons, trends).
3938
+ */
3939
+ async getWithdrawalAnalytics() {
3940
+ return this.request('GET', '/consent/withdrawal/analytics');
3941
+ }
3942
+ // =========================================================================
3943
+ // Consent Renewal
3944
+ // =========================================================================
3945
+ /**
3946
+ * Get the renewal context — which consents are expiring and need renewal.
3947
+ */
3948
+ async getRenewalContext(dataPrincipalId) {
3949
+ return this.request('GET', '/consent/renewal/context', undefined, { data_principal_id: dataPrincipalId });
3950
+ }
3951
+ /**
3952
+ * Renew consent for specific purposes.
3953
+ */
3954
+ async renew(dataPrincipalId, purposeIds, noticeHash, uiEventId = 'btn_renew_consent', options) {
3955
+ const body = {
3956
+ data_principal_id: dataPrincipalId,
3957
+ purpose_ids: purposeIds,
3958
+ notice_hash: noticeHash,
3959
+ captured_at: buildAffirmativeAction(uiEventId).captured_at,
3960
+ client_context: this.context(),
3961
+ };
3962
+ const response = await this.request('POST', '/consent/renew', body, undefined, { ...this.mutation(options), raw: true });
3963
+ this.logger.info('Consent renewed', {
3964
+ dataPrincipal: dataPrincipalId,
3965
+ purposes: purposeIds,
3966
+ });
3967
+ this.emitChanged(dataPrincipalId, response.body.renewed_consents.map((p) => ({ purpose_id: p.purpose_id, status: 'granted' })), 'renew');
3968
+ return response;
3969
+ }
3970
+ /**
3971
+ * Bulk renew all expiring consents.
3972
+ */
3973
+ async renewBulk(dataPrincipalId, purposeIds, noticeHash, uiEventId = 'btn_renew_all', options) {
3974
+ const body = {
3975
+ data_principal_id: dataPrincipalId,
3976
+ renewals: purposeIds.map(purpose_id => ({ purpose_id })),
3977
+ notice_hash: noticeHash,
3978
+ captured_at: buildAffirmativeAction(uiEventId).captured_at,
3979
+ client_context: this.context(),
3980
+ };
3981
+ const response = await this.request('POST', '/consent/renew/bulk', body, undefined, { ...this.mutation(options), raw: true });
3982
+ this.logger.info('Bulk consent renewed', {
3983
+ dataPrincipal: dataPrincipalId,
3984
+ purposes: response.body.success_count,
3985
+ });
3986
+ if (response.body.renewed_consents.length > 0) {
3987
+ this.emitChanged(dataPrincipalId, response.body.renewed_consents.map((p) => ({
3988
+ purpose_id: p.purpose_id, status: 'granted',
3989
+ })), 'renew');
3990
+ }
3991
+ return response;
3992
+ }
3993
+ }
3994
+
3995
+ /**
3996
+ * Consentera Consent SDK — Callback Handler
3997
+ *
3998
+ * ─── THIS ROAD FAILS CLOSED, AND BEFORE 2.0.0 IT DID NOT ───────────────────
3999
+ *
4000
+ * The 1.x handler read `session_id`, `artifact_id` and `status` out of
4001
+ * `window.location.search`, looked up the stored session, logged
4002
+ * "Callback verified against stored session" — and then verified NOTHING. It
4003
+ * never parsed the record it had stored, never compared the nonce, never
4004
+ * compared the notice hash, and returned `status: 'completed'` for any URL
4005
+ * carrying an `artifact_id` (the `normalizeStatus` default). A link to
4006
+ *
4007
+ * https://your-site.example/consent/done?session_id=x&artifact_id=y
4008
+ *
4009
+ * made `consent.handleCallback()` answer "completed" with no consent given.
4010
+ * The log line said "verified" the whole time, which is why it survived review.
4011
+ *
4012
+ * What replaces it:
4013
+ *
4014
+ * 1. The stored record is PARSED. Its session id must equal the one in the
4015
+ * URL, and the `state` THIS SDK minted and put on callback_url at create
4016
+ * must come back on the return. A mismatch, an absence, or an
4017
+ * unparseable record is `unverified`.
4018
+ *
4019
+ * IT IS THE SDK'S OWN STATE, NOT THE SERVER'S challengeNonce. Until the
4020
+ * callback-contract fix this compared the create response's
4021
+ * `challengeNonce` with a `nonce` on the return — and the platform's
4022
+ * return never carries one (collection.go:4225-4288 adds session_id,
4023
+ * artifact_id, status, pending and, when signed, sig). challengeNonce is
4024
+ * the hosted page's own credential: it rides consent_url
4025
+ * (`/collect/<id>?nonce=…`, :2010) and comes back on the page's submit
4026
+ * (:3509-3543). So every genuine web redirect came back `unverified`.
4027
+ * The state survives because the platform builds the return ON TOP of
4028
+ * callback_url's existing query — the mobile SDKs' binding, the same way.
4029
+ * 2. `completed` is only ever reached by CONFIRMING THE ARTIFACT with the
4030
+ * platform — `GET /consent/artifacts/{id}` through the same road the rest
4031
+ * of the SDK uses, which in a browser is the Data Fiduciary's proxy. The
4032
+ * URL is a claim; the artifact is the evidence.
4033
+ * 3. Everything else is `unverified`. There is no path from a query string to
4034
+ * `completed`.
4035
+ *
4036
+ * `parseCallback` is therefore ASYNC now. The synchronous
4037
+ * {@link CallbackHandler.readCallbackParams} is kept for a caller that only
4038
+ * wants to know what the URL says — it is named so that using it as a decision
4039
+ * is visibly the wrong thing.
4040
+ */
4041
+ const PLATFORM_CALLBACK_STATUSES = new Set(['granted', 'partial', 'denied']);
4042
+ /** Map the raw `status` query value onto the platform's vocabulary. Exact match: the server writes lowercase. */
4043
+ function claimedCallbackStatus(raw) {
4044
+ return raw && PLATFORM_CALLBACK_STATUSES.has(raw) ? raw : 'unknown';
4045
+ }
4046
+ /**
4047
+ * Compute and compare the platform's callback signature. SERVER-SIDE ONLY —
4048
+ * the secret must never reach a browser.
4049
+ *
4050
+ * The canonical string is `session_id + "|" + artifact_id + "|" + status`, in
4051
+ * that order, with the raw values as they appear on the URL.
4052
+ */
4053
+ async function verifyCallbackSignature(secret, params) {
4054
+ if (!secret || !params.sig)
4055
+ return false;
4056
+ const subtle = subtleCrypto();
4057
+ if (!subtle) {
4058
+ // THROWING, NOT RETURNING FALSE. A false here is indistinguishable from
4059
+ // "the signature is wrong", which would make every callback look forged on
4060
+ // a runtime that simply has no SubtleCrypto — a wrong answer that reads
4061
+ // like a security finding. The handler turns this into `unverifiable`.
4062
+ throw configError('Consentera: no SubtleCrypto available, so the callback signature cannot be verified here. ' +
4063
+ 'Verify it on a runtime that has WebCrypto (Node 20+, any modern browser) — but note the ' +
4064
+ 'signing key is your callback_signing_secret and must not be in a browser.', 'NO_SUBTLE_CRYPTO');
4065
+ }
4066
+ const enc = new TextEncoder();
4067
+ const key = await subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
4068
+ const canonical = `${params.sessionId}|${params.artifactId}|${params.status}`;
4069
+ const mac = await subtle.sign('HMAC', key, enc.encode(canonical));
4070
+ const expected = Array.from(new Uint8Array(mac), (b) => b.toString(16).padStart(2, '0')).join('');
4071
+ return timingSafeEqual(expected, params.sig.toLowerCase());
4072
+ }
4073
+ class CallbackHandler {
4074
+ logger;
4075
+ request;
4076
+ options;
4077
+ constructor(logger, request, options = {}) {
4078
+ this.logger = logger;
4079
+ this.request = request;
4080
+ this.options = options;
4081
+ }
4082
+ /**
4083
+ * Read the callback URL's claims. THIS IS NOT A DECISION — the values come
4084
+ * from the address bar and anyone can type them. Use {@link parseCallback}.
4085
+ */
4086
+ readCallbackParams(searchParams) {
4087
+ let params;
4088
+ if (!searchParams) {
4089
+ if (typeof window === 'undefined') {
4090
+ throw new Error('readCallbackParams requires searchParams in non-browser environments');
4091
+ }
4092
+ params = new URLSearchParams(window.location.search);
4093
+ }
4094
+ else if (typeof searchParams === 'string') {
4095
+ params = new URLSearchParams(searchParams);
4096
+ }
4097
+ else {
4098
+ params = searchParams;
4099
+ }
4100
+ return {
4101
+ session_id: params.get('session_id') || params.get('consent_session_id') || '',
4102
+ artifact_id: params.get('artifact_id') || params.get('consent_artifact_id') || undefined,
4103
+ raw_status: params.get('status') || '',
4104
+ claimed_status: claimedCallbackStatus(params.get('status')),
4105
+ pending: params.get('pending') === '1',
4106
+ state: params.get('state') || undefined,
4107
+ // The platform's HMAC over session|artifact|status; present only when the
4108
+ // DF registered a callback_signing_secret (collection.go:4280-4288).
4109
+ sig: params.get('sig') || undefined,
4110
+ error: params.get('error') || undefined,
4111
+ };
4112
+ }
4113
+ /**
4114
+ * Verify a consent callback. Resolves `completed` ONLY when the stored
4115
+ * session matches and the artifact was confirmed with the platform.
4116
+ */
4117
+ async parseCallback(searchParams) {
4118
+ const claim = this.readCallbackParams(searchParams);
4119
+ const base = {
4120
+ session_id: claim.session_id,
4121
+ artifact_id: claim.artifact_id,
4122
+ claimed_status: claim.claimed_status,
4123
+ claimed_pending: claim.pending,
4124
+ status: 'unverified',
4125
+ };
4126
+ if (claim.error) {
4127
+ return this.finish({ ...base, status: 'error', error: claim.error, reason: 'the platform returned an error' });
4128
+ }
4129
+ if (!claim.session_id) {
4130
+ return this.finish({ ...base, status: 'unverified', reason: 'the callback names no session' });
4131
+ }
4132
+ // 1. The stored side of the handshake.
4133
+ const raw = readStored('session', storageKeys.session(claim.session_id));
4134
+ if (!raw) {
4135
+ return this.finish({
4136
+ ...base,
4137
+ reason: 'no session was stored in this browser for that id — the session was created elsewhere, ' +
4138
+ 'the tab was replaced, or the callback was not produced by this flow',
4139
+ });
4140
+ }
4141
+ let stored;
4142
+ try {
4143
+ stored = JSON.parse(raw);
4144
+ }
4145
+ catch {
4146
+ removeStored('session', storageKeys.session(claim.session_id));
4147
+ return this.finish({ ...base, reason: 'the stored session record could not be parsed' });
4148
+ }
4149
+ removeStored('session', storageKeys.session(claim.session_id));
4150
+ if (stored.session_id && stored.session_id !== claim.session_id) {
4151
+ return this.finish({ ...base, reason: 'the stored session id does not match the callback' });
4152
+ }
4153
+ // The state is the anti-forgery binding: this SDK minted it at create, put
4154
+ // it on callback_url, and only a real return trip to THIS browser carries
4155
+ // it back. It is REQUIRED — a record without one cannot bind anything.
4156
+ if (!stored.state) {
4157
+ return this.finish({
4158
+ ...base,
4159
+ reason: 'the stored session has no callback state: it was created without a callback_url, or by an ' +
4160
+ 'SDK version that bound the callback to the server challengeNonce (which the platform never ' +
4161
+ 'returns). Read the consent back through your backend instead.',
4162
+ });
4163
+ }
4164
+ if (!claim.state) {
4165
+ return this.finish({ ...base, reason: 'the callback carries no state, and the session was created with one' });
4166
+ }
4167
+ if (!timingSafeEqual(stored.state, claim.state)) {
4168
+ return this.finish({ ...base, reason: 'the callback state does not match the stored session' });
4169
+ }
4170
+ // 2. THE PLATFORM'S SIGNATURE, when it sent one.
4171
+ //
4172
+ // A `sig` on the URL means the DF registered a callback_signing_secret and
4173
+ // the platform signed status + artifact with it. Ignoring it would make the
4174
+ // signature decorative, so a sig that cannot be checked is `unverified` —
4175
+ // the SDK does not fall back to "well, the state matched".
4176
+ let signature = 'absent';
4177
+ if (claim.sig) {
4178
+ if (!this.options.verifySignature) {
4179
+ return this.finish({
4180
+ ...base,
4181
+ signature: 'unverifiable',
4182
+ reason: 'the callback is signed and no verifier is configured. The signing key is your ' +
4183
+ 'callback_signing_secret, which must not be in a browser: set ' +
4184
+ '`verifyCallbackSignature` to call your own server, which uses ' +
4185
+ 'verifyCallbackSignature(secret, …) from this package.',
4186
+ });
4187
+ }
4188
+ let ok = false;
4189
+ try {
4190
+ ok = await this.options.verifySignature({
4191
+ sessionId: claim.session_id,
4192
+ artifactId: claim.artifact_id ?? '',
4193
+ status: claim.raw_status,
4194
+ sig: claim.sig,
4195
+ });
4196
+ }
4197
+ catch (err) {
4198
+ return this.finish({
4199
+ ...base,
4200
+ signature: 'unverifiable',
4201
+ reason: `the signature verifier threw: ${String(err)}`,
4202
+ });
4203
+ }
4204
+ if (!ok) {
4205
+ return this.finish({ ...base, signature: 'invalid', reason: 'the callback signature did not verify' });
4206
+ }
4207
+ signature = 'verified';
4208
+ }
4209
+ // 3. The platform's vocabulary, exactly: granted | partial | denied
4210
+ // (collection.go:4094-4098). A denial is a real, verified outcome — no
4211
+ // artifact exists to confirm, and the state above proved the round trip.
4212
+ // Anything else did not come from the platform and goes nowhere near the
4213
+ // success road: before this, `completed`, `success`, a missing status or
4214
+ // any other value fell through to the artifact read and could come back
4215
+ // `completed`, while `expired`/`rejected`/`timeout` — which the platform
4216
+ // never sends — had branches of their own.
4217
+ if (claim.claimed_status === 'denied') {
4218
+ return this.finish({ ...base, status: 'denied', signature });
4219
+ }
4220
+ if (claim.claimed_status === 'unknown') {
4221
+ return this.finish({
4222
+ ...base,
4223
+ signature,
4224
+ reason: `the callback status ${JSON.stringify(claim.raw_status)} is not a status the platform issues ` +
4225
+ '(granted | partial | denied). Read the consent back through your backend.',
4226
+ });
4227
+ }
4228
+ // 4. granted | partial: `completed` requires the artifact, from the
4229
+ // platform, not the URL. pending=1 (always set on the capture road)
4230
+ // means the first read may be a 202; confirmArtifact waits for it.
4231
+ if (!claim.artifact_id) {
4232
+ return this.finish({ ...base, signature, reason: 'the callback claims success but names no artifact' });
4233
+ }
4234
+ if (!this.request) {
4235
+ return this.finish({
4236
+ ...base,
4237
+ signature,
4238
+ reason: 'no transport available to confirm the artifact. Construct the handler through ' +
4239
+ 'ConsentEraClient so it can call GET /consent/artifacts/{id}.',
4240
+ });
4241
+ }
4242
+ return this.confirmArtifact(base, claim.artifact_id, claim.session_id, stored.data_principal_id, signature);
4243
+ }
4244
+ /**
4245
+ * Read the artifact back FOR THIS SESSION, waiting while the platform says
4246
+ * it is still being written.
4247
+ *
4248
+ * THE READ CARRIES ?session_id=, AND THAT DECIDES WHAT A 404 MEANS (WEB-033).
4249
+ * With it the platform answers 202 + Retry-After while the projection is
4250
+ * owed and 404 only when the id was never issued for this session (or will
4251
+ * never be written). Until WEB-033 the read went out without it, so every
4252
+ * absence was a 404, and this method polled every 404 as "maybe pending" — a
4253
+ * forged artifact_id came back `pending` instead of refused.
4254
+ *
4255
+ * 200 the artifact. Then it must name the person this browser's session
4256
+ * was created for (below). Then `completed`.
4257
+ * 202 recorded, not readable yet: wait Retry-After inside the budget, else
4258
+ * `pending`.
4259
+ * 404 `unverified`, at once. Not polled: it is an answer, not a delay.
4260
+ * else `unverified` (403, network, timeout — unproven is unproven).
4261
+ *
4262
+ * THE BINDING IS THE PERSON, NOT A RESPONSE FIELD (WEB-034). This method used
4263
+ * to compare `consent_session_id` on the artifact with the callback's
4264
+ * session. The platform's artifact carries no such field (measured on
4265
+ * setup.consentera.in, fixtures/platform-wire/artifact-read.200.*.json), so
4266
+ * the check was skipped on every real response and bound nothing. What the
4267
+ * artifact does carry is `data_principal_id`, and the session create
4268
+ * returned the same field for the person the session is about. They must be
4269
+ * equal. This matters because the platform's 200 path does not check the
4270
+ * session (walk finding F077, measured: the same artifact read with a random
4271
+ * session_id is still 200), so without it a callback carrying someone
4272
+ * else's artifact_id would confirm.
4273
+ */
4274
+ async confirmArtifact(base, artifactId, sessionId, expectedPrincipal, signature) {
4275
+ const budgetMs = this.options.artifactWaitMs ?? 15_000;
4276
+ const deadline = Date.now() + budgetMs;
4277
+ for (;;) {
4278
+ let read;
4279
+ try {
4280
+ read = await readArtifact(this.request, artifactId, { sessionId });
4281
+ }
4282
+ catch (err) {
4283
+ const detail = err instanceof ConsenteraError ? err.toString() : String(err);
4284
+ if (err instanceof ConsenteraError && (err.status === 404 || err.kind === 'not_found')) {
4285
+ return this.finish({
4286
+ ...base,
4287
+ signature,
4288
+ reason: `the platform has no artifact ${artifactId} for session ${sessionId}: it was never ` +
4289
+ 'issued for this session, or will never be written. A forged or mismatched ' +
4290
+ `artifact_id reads exactly like this. (${detail})`,
4291
+ });
4292
+ }
4293
+ // A 403, a network failure, anything else: unproven, so unverified.
4294
+ return this.finish({ ...base, signature, reason: `the artifact could not be confirmed: ${detail}` });
4295
+ }
4296
+ if (read.state === 'recorded') {
4297
+ const artifact = read.artifact;
4298
+ if (artifact.artifact_id && artifact.artifact_id !== artifactId) {
4299
+ return this.finish({ ...base, signature, reason: 'the platform returned a different artifact than the one read' });
4300
+ }
4301
+ if (!expectedPrincipal) {
4302
+ return this.finish({
4303
+ ...base,
4304
+ signature,
4305
+ reason: 'the stored session names no data_principal_id, so the artifact cannot be tied to ' +
4306
+ 'the person this session was created for. Create the session with this SDK version, ' +
4307
+ 'or confirm the artifact server-side.',
4308
+ });
4309
+ }
4310
+ if (artifact.data_principal_id !== expectedPrincipal) {
4311
+ return this.finish({
4312
+ ...base,
4313
+ signature,
4314
+ reason: "the artifact names a different Data Principal from this browser's session — it is " +
4315
+ 'not this consent',
4316
+ });
4317
+ }
4318
+ return this.finish({ ...base, status: 'completed', artifact, signature });
4319
+ }
4320
+ // 202: recorded, still being written.
4321
+ const remaining = deadline - Date.now();
4322
+ if (remaining <= 0 || read.retryAfterMs > remaining) {
4323
+ return this.finish({
4324
+ ...base,
4325
+ status: 'pending',
4326
+ signature,
4327
+ retryAfterMs: read.retryAfterMs,
4328
+ reason: `the platform says the consent was recorded and its artifact is still being written ` +
4329
+ `(${read.pending.reason || 'pending'}); read it again in ${read.retryAfterMs}ms. ` +
4330
+ 'This is NOT a failure and NOT a consent that did not happen.',
4331
+ });
4332
+ }
4333
+ await new Promise((r) => setTimeout(r, read.retryAfterMs));
4334
+ }
4335
+ }
4336
+ /** Is this page a consent callback? Says nothing about whether it is genuine. */
4337
+ isCallback(searchParams) {
4338
+ const params = new URLSearchParams(searchParams || (typeof window !== 'undefined' ? window.location.search : ''));
4339
+ return params.has('session_id') || params.has('consent_session_id');
4340
+ }
4341
+ /** Record the outcome for the popup flow to collect, and log it. */
4342
+ finish(result) {
4343
+ if (result.session_id) {
4344
+ writeStored('session', storageKeys.callback(result.session_id), JSON.stringify(result));
4345
+ }
4346
+ if (result.status === 'completed') {
4347
+ this.logger.info('Consent callback verified', { session_id: result.session_id, status: result.status });
4348
+ }
4349
+ else if (result.status === 'pending') {
4350
+ this.logger.info('Consent callback verified; artifact not readable yet', {
4351
+ session_id: result.session_id,
4352
+ status: result.status,
4353
+ });
4354
+ }
4355
+ else {
4356
+ this.logger.warn('Consent callback NOT verified', {
4357
+ session_id: result.session_id,
4358
+ status: result.status,
4359
+ reason: result.reason,
4360
+ });
4361
+ }
4362
+ return result;
4363
+ }
4364
+ }
4365
+ /**
4366
+ * SubtleCrypto, wherever this is running. `globalThis.crypto.subtle` is the
4367
+ * standard spelling and is present in browsers and in Node 20+; jsdom, which is
4368
+ * where this package's tests run, exposes `crypto` WITHOUT `subtle`, so the
4369
+ * node:crypto webcrypto instance is the fallback. This function is server-side
4370
+ * by design, so reaching for node here is not a layering break.
4371
+ */
4372
+ function subtleCrypto() {
4373
+ const fromGlobal = globalThis.crypto?.subtle;
4374
+ if (fromGlobal)
4375
+ return fromGlobal;
4376
+ try {
4377
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
4378
+ const nodeCrypto = require('node:crypto');
4379
+ return nodeCrypto?.webcrypto?.subtle;
4380
+ }
4381
+ catch {
4382
+ return undefined;
4383
+ }
4384
+ }
4385
+ /** Constant-time string compare, so a state cannot be guessed byte by byte. */
4386
+ function timingSafeEqual(a, b) {
4387
+ if (a.length !== b.length)
4388
+ return false;
4389
+ let diff = 0;
4390
+ for (let i = 0; i < a.length; i++)
4391
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
4392
+ return diff === 0;
4393
+ }
4394
+
4395
+ /**
4396
+ * ConsentEra Consent SDK — DF Configuration Client
4397
+ * Fetch Data Fiduciary configuration, purposes, and notice details
4398
+ */
4399
+ class DFConfigClient {
4400
+ request;
4401
+ logger;
4402
+ cachedConfig = null;
4403
+ constructor(request, logger) {
4404
+ this.request = request;
4405
+ this.logger = logger;
4406
+ }
4407
+ /**
4408
+ * Get the full DF configuration (tenant info, purposes, notices, branding).
4409
+ * Results are cached for the lifetime of the client instance.
4410
+ */
4411
+ async getConfig(language) {
4412
+ if (this.cachedConfig)
4413
+ return this.cachedConfig;
4414
+ const params = {};
4415
+ if (language)
4416
+ params.language_code = language;
4417
+ const config = await this.request('GET', '/df/config', undefined, params,
4418
+ // The DF read roads are openable by a public site key (route membership,
4419
+ // not the four legacy permission names — see README). Marking the road
4420
+ // 'public' is what stops the transport demanding a secret / proxy for a
4421
+ // browser call that legitimately carries only the site key.
4422
+ { road: 'public' });
4423
+ this.cachedConfig = config;
4424
+ this.logger.info('DF config loaded', {
4425
+ tenant: config.tenant_name,
4426
+ purposes: config.purposes?.length,
4427
+ });
4428
+ return config;
4429
+ }
4430
+ /**
4431
+ * Get all purposes configured for this DF.
4432
+ */
4433
+ async getPurposes() {
4434
+ const response = await this.request('GET', '/df/purposes', undefined, undefined, { road: 'public' });
4435
+ return Array.isArray(response) ? response : response.purposes || [];
4436
+ }
4437
+ /**
4438
+ * Get purposes associated with a specific notice.
4439
+ * This returns the ordered list of purposes as they appear in the notice.
4440
+ */
4441
+ async getNoticePurposes(noticeInternalName, language) {
4442
+ const params = {};
4443
+ if (noticeInternalName)
4444
+ params.notice_internal_name = noticeInternalName;
4445
+ if (language)
4446
+ params.language_code = language;
4447
+ return this.request('GET', '/df/notice/purposes', undefined, params, { road: 'public' });
4448
+ }
4449
+ /**
4450
+ * Get the pre-rendered HTML+CSS snapshot for a notice, including purpose snapshot.
4451
+ * Use this instead of getConfig() for bootstrapping notice-based consent UIs.
4452
+ *
4453
+ * @param noticeName - internal_name of the notice (required)
4454
+ * @param noticeLanguage - language of the notice content (default: tenant primary language)
4455
+ * @param templateLanguage - language for widget UI labels (optional)
4456
+ */
4457
+ async getNoticeTemplate(noticeName, noticeLanguage, templateLanguage) {
4458
+ const params = { notice: noticeName };
4459
+ if (noticeLanguage)
4460
+ params.notice_language = noticeLanguage;
4461
+ if (templateLanguage)
4462
+ params.template_language = templateLanguage;
4463
+ const response = await this.request('GET', '/df/notice/template', undefined, params, { road: 'public' });
4464
+ this.logger.info('Notice template loaded', { notice: noticeName, language: noticeLanguage });
4465
+ // The endpoint wraps in { data: ... }
4466
+ return response.data ?? response;
4467
+ }
4468
+ /**
4469
+ * Get the widget template for a consent SESSION's collection UI.
4470
+ *
4471
+ * THE ROUTE CHANGED IN 2.0.0, because the old one did not exist. This called
4472
+ * GET /df/widget-template, which is a 404 — there is no such platform route.
4473
+ * The real widget template is session-scoped: GET
4474
+ * /consent/sessions/{id}/widget-template (routes_consent.go:125), which sits
4475
+ * in the pre-auth throttle group and takes NO DF credential — the session id
4476
+ * and its nonce are the capability. So this needs a session id, and rides the
4477
+ * `session` road (no key, no proxy required).
4478
+ *
4479
+ * For the notice-authoring template keyed by internal_name, use
4480
+ * {@link getNoticeTemplate}, which is the site-key-openable /df/notice/template.
4481
+ *
4482
+ * @param sessionId the consent_session_id from createSession()
4483
+ */
4484
+ async getSessionWidgetTemplate(sessionId) {
4485
+ return this.request('GET', `/consent/sessions/${encodeURIComponent(sessionId)}/widget-template`, undefined, undefined, { road: 'session' });
4486
+ }
4487
+ /**
4488
+ * Clear the cached config (useful after config changes).
4489
+ */
4490
+ clearCache() {
4491
+ this.cachedConfig = null;
4492
+ }
4493
+ }
4494
+
4495
+ /**
4496
+ * ConsentEra Consent SDK — Principal Client
4497
+ * Data principal rights: Portal SSO, data export, deletion requests
4498
+ */
4499
+ class PrincipalClient {
4500
+ request;
4501
+ logger;
4502
+ // `config` is accepted and ignored: the portal road takes nothing from it,
4503
+ // and the argument stays so the client's construction call reads the same as
4504
+ // every other sub-client's.
4505
+ constructor(request, _config, logger) {
4506
+ this.request = request;
4507
+ this.logger = logger;
4508
+ }
4509
+ /**
4510
+ * Generate a principal portal SSO token.
4511
+ * Returns a URL the data principal can visit to manage their consents.
4512
+ *
4513
+ * THIS ROAD STILL TAKES data_principal_ref, AND THAT IS CORRECT. It is
4514
+ * identity/dfclient's (handlers.go:1418-1428), not the consent module's, so
4515
+ * the 2026-09-21 ruling that refuses the field across the consent surface
4516
+ * does not reach it. A sweep that converted this call would break a working
4517
+ * endpoint.
4518
+ */
4519
+ async getPortalToken(dataPrincipalRef, redirectPath) {
4520
+ const body = {
4521
+ data_principal_ref: dataPrincipalRef,
4522
+ redirect_path: redirectPath,
4523
+ };
4524
+ const response = await this.request('POST', '/df/principal-token', body);
4525
+ this.logger.info('Principal portal token generated', {
4526
+ dataPrincipal: dataPrincipalRef,
4527
+ expires_in: response.expires_in,
4528
+ });
4529
+ return response;
4530
+ }
4531
+ /**
4532
+ * Get the full portal URL for a data principal.
4533
+ * Convenience method that returns just the redirect URL string.
4534
+ */
4535
+ async getPortalUrl(dataPrincipalRef, redirectPath) {
4536
+ const token = await this.getPortalToken(dataPrincipalRef, redirectPath);
4537
+ return token.redirect_url;
4538
+ }
4539
+ /**
4540
+ * Open the principal portal in a new browser window/tab.
4541
+ */
4542
+ async openPortal(dataPrincipalRef, redirectPath) {
4543
+ if (typeof window === 'undefined') {
4544
+ throw new Error('openPortal can only be used in browser environments');
4545
+ }
4546
+ const url = await this.getPortalUrl(dataPrincipalRef, redirectPath);
4547
+ window.open(url, '_blank', 'noopener,noreferrer');
4548
+ this.logger.info('Principal portal opened', { dataPrincipal: dataPrincipalRef });
4549
+ }
4550
+ }
4551
+
4552
+ /**
4553
+ * Consentera Consent SDK — the client.
4554
+ *
4555
+ * ─── THE CREDENTIAL RULE, AND IT IS ENFORCED HERE AT CONSTRUCTION ──────────
4556
+ *
4557
+ * A BROWSER BUNDLE NEVER CARRIES A SECRET (owner ruling 2026-09-22). Before
4558
+ * 2.0.0 this file said the opposite in a comment — "Prefer site key (public,
4559
+ * safe for frontend) over API key (secret, server-side only)" — and then sent
4560
+ * whichever of the two it was given, from wherever it was running. Both halves
4561
+ * of that sentence were wrong in a way that only showed up in production:
4562
+ *
4563
+ * the site key does not work. The platform replaces a site key's permissions
4564
+ * with exactly {consent.render, widget.render, session.submit, session.render}
4565
+ * (core/auth/df/api_usage.go:105-110, :1115) and NO ROUTE REQUIRES ANY OF THEM
4566
+ * — a grep for RequireDFPermission of those four names over the whole API
4567
+ * returns nothing. Every consent lifecycle road requires
4568
+ * engagement.consent.{collect,read,validate,update,withdraw,renew}
4569
+ * (cmd/api/routes_consent.go:377-569). The intersection is empty, so a browser
4570
+ * configured the recommended way got 403 on every call.
4571
+ *
4572
+ * the secret key does work — which is worse. It is the only credential that
4573
+ * authorises those roads, so "make it work" meant putting a tiq_live_ key in
4574
+ * a public bundle.
4575
+ *
4576
+ * So the rule is now structural rather than advisory:
4577
+ *
4578
+ * in a browser a Data Fiduciary road MUST go through `proxyEndpoint` — your
4579
+ * own server route, which holds the secret and forwards. An
4580
+ * `apiKey` in a browser is REFUSED AT CONSTRUCTION.
4581
+ * on a server `apiKey` directly, as always.
4582
+ * public roads may carry `siteKey`; they carry no secret.
4583
+ * session roads carry no key at all: the session id and its nonce are the
4584
+ * capability.
4585
+ *
4586
+ * WHAT THE PLATFORM MUST STILL DO for the public half to be usable from a
4587
+ * browser without a proxy is a separate unit and is written down in
4588
+ * README.md → "What the platform must guarantee".
4589
+ */
4590
+ class ConsentEraClient extends EventEmitter {
4591
+ config;
4592
+ logger;
4593
+ transport;
4594
+ /** Consent session creation and artifact retrieval */
4595
+ session;
4596
+ /** Consent validation (single + bulk) */
4597
+ validate;
4598
+ /** Consent update, withdrawal, and renewal */
4599
+ manage;
4600
+ /** Callback handler for redirect flows */
4601
+ callback;
4602
+ /** DF configuration and notice purposes */
4603
+ df;
4604
+ /** Data principal rights and portal SSO */
4605
+ principal;
4606
+ /**
4607
+ * Unified consent namespace — aggregates session, validate, manage, callback.
4608
+ */
4609
+ consent;
4610
+ constructor(config) {
4611
+ super();
4612
+ if (!config.apiEndpoint && !config.proxyEndpoint) {
4613
+ throw configError('Consentera: set `proxyEndpoint` (your own server route — required in a browser for ' +
4614
+ 'consent lifecycle roads) or `apiEndpoint` (server-side use).', 'ENDPOINT_REQUIRED');
4615
+ }
4616
+ // A tenant id is needed only where THIS SDK names the tenant: direct mode,
4617
+ // which sends X-Tenant-Id. Behind `proxyEndpoint` your server holds the key
4618
+ // and the key names the tenant, so the SDK sends no tenant header, and
4619
+ // demanding the value anyway made every integrator invent one (WEB-036).
4620
+ if (!config.tenantId && !config.proxyEndpoint) {
4621
+ throw configError('Consentera: tenantId is required with `apiEndpoint`. (Behind `proxyEndpoint` it is optional: ' +
4622
+ 'your server supplies the tenant.)', 'TENANT_ID_REQUIRED');
4623
+ }
4624
+ // THE ONE REFUSAL — the shared guard, so this client and the ConsenteraConsent
4625
+ // cookie SDK enforce the identical rule at their two doors.
4626
+ assertNoSecretKeyInBrowser({
4627
+ apiKey: config.apiKey,
4628
+ unsafeAllowSecretKeyInBrowser: config.unsafeAllowSecretKeyInBrowser,
4629
+ fix: 'Move the key to your own server and point the SDK at it with ' +
4630
+ '`proxyEndpoint: "/api/consentera"`; the browser then sends no credential at all.',
4631
+ });
4632
+ this.config = config;
4633
+ this.logger = new Logger(config.debug ? 'debug' : 'info');
4634
+ this.transport = new HttpTransport({
4635
+ tenantId: config.tenantId,
4636
+ apiEndpoint: config.apiEndpoint,
4637
+ proxyEndpoint: config.proxyEndpoint,
4638
+ apiKey: config.apiKey,
4639
+ siteKey: config.siteKey,
4640
+ customHeaders: config.customHeaders,
4641
+ unsafeAllowSecretKeyInBrowser: config.unsafeAllowSecretKeyInBrowser,
4642
+ timeoutMs: config.timeoutMs,
4643
+ retry: config.retry,
4644
+ beforeSend: config.beforeSend,
4645
+ fetchImpl: config.fetchImpl,
4646
+ }, this.logger);
4647
+ const requestFn = this.request.bind(this);
4648
+ this.session = new ConsentSession(requestFn, config, this.logger);
4649
+ this.validate = new ConsentValidator(requestFn, this.logger);
4650
+ this.manage = new ConsentManager(requestFn, this.logger, {
4651
+ contextMode: config.collectContext ?? 'minimal',
4652
+ });
4653
+ // Public event bus: every successful consent mutation (update/grant/deny/
4654
+ // withdraw/renew) re-emits as `consent.changed` — subscribe with
4655
+ // client.on('consent.changed', cb) instead of polling validate.
4656
+ this.manage.onChanged = (payload) => this.emit('consent.changed', payload);
4657
+ this.callback = new CallbackHandler(this.logger, requestFn, {
4658
+ verifySignature: config.verifyCallbackSignature,
4659
+ artifactWaitMs: config.artifactWaitMs,
4660
+ });
4661
+ this.df = new DFConfigClient(requestFn, this.logger);
4662
+ this.principal = new PrincipalClient(requestFn, config, this.logger);
4663
+ this.consent = {
4664
+ createSession: this.session.createSession.bind(this.session),
4665
+ redirectToConsent: this.session.redirectToConsent.bind(this.session),
4666
+ openConsentPopup: this.session.openConsentPopup.bind(this.session),
4667
+ getArtifact: this.session.getArtifact.bind(this.session),
4668
+ validate: this.validate.check.bind(this.validate),
4669
+ validateBulk: this.validate.checkBulk.bind(this.validate),
4670
+ isAllowed: this.validate.isAllowed.bind(this.validate),
4671
+ areAllowed: this.validate.areAllowed.bind(this.validate),
4672
+ handleCallback: this.callback.parseCallback.bind(this.callback),
4673
+ isCallback: this.callback.isCallback.bind(this.callback),
4674
+ getContext: this.manage.getUpdateContext.bind(this.manage),
4675
+ update: this.manage.update.bind(this.manage),
4676
+ grant: this.manage.grant.bind(this.manage),
4677
+ deny: this.manage.deny.bind(this.manage),
4678
+ getWithdrawalContext: this.manage.getWithdrawalContext.bind(this.manage),
4679
+ withdraw: this.manage.withdraw.bind(this.manage),
4680
+ withdrawBulk: this.manage.withdrawBulk.bind(this.manage),
4681
+ getRenewalContext: this.manage.getRenewalContext.bind(this.manage),
4682
+ renew: this.manage.renew.bind(this.manage),
4683
+ renewBulk: this.manage.renewBulk.bind(this.manage),
4684
+ };
4685
+ this.logger.info('Consentera client initialised', {
4686
+ mode: config.proxyEndpoint ? 'proxy' : 'direct',
4687
+ tenantId: config.tenantId,
4688
+ });
4689
+ }
4690
+ /**
4691
+ * Central HTTP request method. Delegates to {@link HttpTransport}, which owns
4692
+ * the deadline, the retry policy, the idempotency key and the credential rule.
4693
+ *
4694
+ * @param queryParams query string values (the legacy 4th-argument position)
4695
+ * @param options per-request road, idempotency key, signal, timeout, retry
4696
+ */
4697
+ async request(method, path, body, queryParams, options = {}) {
4698
+ try {
4699
+ return await this.transport.request(method, path, body, {
4700
+ ...options,
4701
+ query: { ...queryParams, ...options.query },
4702
+ });
4703
+ }
4704
+ catch (err) {
4705
+ if (err instanceof ConsenteraError)
4706
+ this.emit('error', err);
4707
+ throw err;
4708
+ }
4709
+ }
4710
+ /** Build client context — delegates to shared helper */
4711
+ static buildClientContext = buildClientContext;
4712
+ /** Build affirmative action — delegates to shared helper */
4713
+ static buildAffirmativeAction = buildAffirmativeAction;
4714
+ /** Get the current configuration. The credentials are redacted. */
4715
+ getConfig() {
4716
+ const copy = { ...this.config };
4717
+ if (copy.apiKey)
4718
+ copy.apiKey = `${copy.apiKey.slice(0, 9)}…`;
4719
+ return copy;
4720
+ }
4721
+ }
4722
+
4723
+ /**
4724
+ * Google Consent Mode v2 bridge.
4725
+ *
4726
+ * Maps Consentera purpose decisions onto the four Consent Mode v2 signals
4727
+ * (analytics_storage, ad_storage, ad_user_data, ad_personalization) and
4728
+ * pushes them through gtag's dataLayer — `default` denied at install (before
4729
+ * any Google tag fires), `update` on every consent.changed event.
4730
+ *
4731
+ * Usage:
4732
+ * const bridge = installConsentModeBridge(client, {
4733
+ * analytics_storage: ['product_analytics'],
4734
+ * ad_storage: ['marketing_communications'],
4735
+ * ad_user_data: ['marketing_communications'],
4736
+ * ad_personalization: ['marketing_communications'],
4737
+ * });
4738
+ * // after reading current state (e.g. update-context or validate):
4739
+ * bridge.apply({ product_analytics: true, marketing_communications: false });
4740
+ *
4741
+ * The bridge never loads Google code itself — it only writes the standard
4742
+ * dataLayer entries, so it is inert until/unless a Google tag is present.
4743
+ */
4744
+ const ALL_SIGNALS = [
4745
+ 'analytics_storage',
4746
+ 'ad_storage',
4747
+ 'ad_user_data',
4748
+ 'ad_personalization',
4749
+ ];
4750
+ function gtag() {
4751
+ const w = globalThis;
4752
+ if (typeof w.gtag === 'function')
4753
+ return w.gtag;
4754
+ w.dataLayer = w.dataLayer || [];
4755
+ return function (...args) {
4756
+ w.dataLayer.push(args);
4757
+ };
4758
+ }
4759
+ class ConsentModeBridge {
4760
+ mapping;
4761
+ /** purpose id/code → granted? — the bridge's view of current state. */
4762
+ state = {};
4763
+ constructor(mapping) {
4764
+ this.mapping = mapping;
4765
+ }
4766
+ /** Send the Consent Mode v2 `default` (everything mapped → denied). */
4767
+ sendDefault(waitForUpdateMs = 500) {
4768
+ const defaults = { wait_for_update: waitForUpdateMs };
4769
+ for (const signal of ALL_SIGNALS) {
4770
+ if (this.mapping[signal]?.length)
4771
+ defaults[signal] = 'denied';
4772
+ }
4773
+ gtag()('consent', 'default', defaults);
4774
+ }
4775
+ /** Replace the bridge's purpose state wholesale and push an update. */
4776
+ apply(purposeStates) {
4777
+ this.state = { ...purposeStates };
4778
+ this.push();
4779
+ }
4780
+ /** Fold a consent.changed event into state and push an update. */
4781
+ onChanged(payload) {
4782
+ for (const c of payload.changes) {
4783
+ this.state[c.purpose_id] = c.status === 'granted';
4784
+ }
4785
+ this.push();
4786
+ }
4787
+ push() {
4788
+ const update = {};
4789
+ for (const signal of ALL_SIGNALS) {
4790
+ const purposes = this.mapping[signal];
4791
+ if (!purposes?.length)
4792
+ continue;
4793
+ // A signal is granted only when EVERY mapped purpose is granted —
4794
+ // the conservative reading a regulator would expect.
4795
+ update[signal] = purposes.every((p) => this.state[p] === true) ? 'granted' : 'denied';
4796
+ }
4797
+ gtag()('consent', 'update', update);
4798
+ }
4799
+ }
4800
+ /**
4801
+ * Install the bridge: sends the denied `default` immediately and subscribes
4802
+ * to the client's `consent.changed` bus. Returns the bridge so the caller can
4803
+ * `apply()` the current state once known.
4804
+ */
4805
+ function installConsentModeBridge(bus, mapping) {
4806
+ const bridge = new ConsentModeBridge(mapping);
4807
+ bridge.sendDefault();
4808
+ bus.on('consent.changed', (payload) => bridge.onChanged(payload));
4809
+ return bridge;
4810
+ }
4811
+
4812
+ /**
4813
+ * ConsentEra Consent SDK
4814
+ * DPDP, GDPR, TCF 2.2 compliant consent management for web applications
4815
+ */
4816
+ // =============================================================================
4817
+ // Cookie Banner SDK (existing)
4818
+ // =============================================================================
4819
+ // =============================================================================
4820
+ // Auto-initialization (script tag usage)
4821
+ // =============================================================================
4822
+ // Auto-initialize if script tag has data-auto-init
4823
+ if (typeof window !== 'undefined') {
4824
+ const script = document.currentScript;
4825
+ if (script?.dataset?.autoInit === 'true') {
4826
+ const siteKey = script.dataset.siteKey;
4827
+ const apiKey = script.dataset.apiKey; // Legacy support
4828
+ const tenantId = script.dataset.tenantId;
4829
+ // REQUIRED: there is no default server. Before 2.0.0 this block had no
4830
+ // way to name the API at all, so every script-tag embed talked to the
4831
+ // SDK's built-in default — a host on a domain the company does not own.
4832
+ const apiEndpoint = script.dataset.apiEndpoint ?? '';
4833
+ const authKey = siteKey || apiKey;
4834
+ if (authKey && tenantId) {
4835
+ // A refusal here must not take the bundle down with it: this runs at
4836
+ // module evaluation, and an uncaught throw would also skip the global
4837
+ // exposure below. So the constructor's own error is caught and SAID,
4838
+ // naming the attribute, and nothing is initialised.
4839
+ try {
4840
+ window.ConsentEra = new ConsentEraConsent({
4841
+ ...(siteKey ? { siteKey } : { apiKey: apiKey }),
4842
+ tenantId,
4843
+ apiEndpoint,
4844
+ autoShow: true,
4845
+ });
4846
+ }
4847
+ catch (error) {
4848
+ console.error('Consentera: data-auto-init did not initialise. ' +
4849
+ (apiEndpoint ? '' : 'Set data-api-endpoint="https://<your Consentera API host>" on the script tag. '), error);
4850
+ }
4851
+ }
4852
+ }
4853
+ }
4854
+ // Expose to window for script tag usage
4855
+ if (typeof window !== 'undefined') {
4856
+ // The UMD/CDN global. Both spellings are set: `Consentera` is the name the
4857
+ // UMD build registers from 2.0.0, `ConsentEraConsent` is the 1.x name a
4858
+ // script-tag integrator will already have in their page.
4859
+ window.Consentera = ConsentEraConsent;
4860
+ window.ConsentEraConsent = ConsentEraConsent;
4861
+ }
4862
+
4863
+ export { CallbackHandler, ConsentBanner, ConsentEraApiError, ConsentEraClient, ConsentEraConsent, ConsentManager, ConsentModeBridge, ConsentSession, ConsentStorage, ConsentValidator, ConsenteraAuthError, ConsenteraConfigError, ConsenteraConflictError, ConsenteraError, ConsenteraGuardianError, ConsenteraIdentityError, ConsenteraNetworkError, ConsenteraNotFoundError, ConsenteraPermissionError, ConsenteraRateLimitError, ConsenteraServerError, ConsenteraTimeoutError, ConsenteraValidationError, DEFAULT_RETRY, DEFAULT_TIMEOUT_MS, DFConfigClient, GPPManager, HttpTransport, PreferenceCenter, PrincipalClient, SDK_HEADER_VALUE, SDK_NAME, SDK_PLATFORM, SDK_VERSION, TCFManager, buildClientContext, claimedCallbackStatus, ConsentEraConsent as default, installConsentModeBridge, isBrowser, isSecretKey, isSiteKey, newRequestId, parseRetryAfterMs, principalBody, readDecisionMessage, readStored, removeStored, storageAvailable, storageKeys, verifyCallbackSignature, writeStored };
4864
+ //# sourceMappingURL=consentera-consent.mjs.map