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