@consentera/consent-sdk 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/LICENSE +21 -0
  3. package/README.md +489 -0
  4. package/dist/consentera-consent.cjs +4919 -0
  5. package/dist/consentera-consent.cjs.map +1 -0
  6. package/dist/consentera-consent.min.js +2 -0
  7. package/dist/consentera-consent.min.js.map +1 -0
  8. package/dist/consentera-consent.mjs +4864 -0
  9. package/dist/consentera-consent.mjs.map +1 -0
  10. package/dist/react/index.cjs +2731 -0
  11. package/dist/react/index.cjs.map +1 -0
  12. package/dist/react/index.mjs +2724 -0
  13. package/dist/react/index.mjs.map +1 -0
  14. package/dist/types/consent/CallbackHandler.d.ts +246 -0
  15. package/dist/types/consent/ConsentManager.d.ts +128 -0
  16. package/dist/types/consent/ConsentSession.d.ts +127 -0
  17. package/dist/types/consent/ConsentValidator.d.ts +63 -0
  18. package/dist/types/consent/artifactRead.d.ts +48 -0
  19. package/dist/types/consent/consentPopup.d.ts +115 -0
  20. package/dist/types/core/ConsentEraClient.d.ts +106 -0
  21. package/dist/types/core/ConsenteraConsent.d.ts +163 -0
  22. package/dist/types/core/errors.d.ts +108 -0
  23. package/dist/types/core/http.d.ts +176 -0
  24. package/dist/types/core/version.d.ts +36 -0
  25. package/dist/types/df/DFConfigClient.d.ts +59 -0
  26. package/dist/types/gcm/ConsentModeBridge.d.ts +54 -0
  27. package/dist/types/gpp/GPPManager.d.ts +62 -0
  28. package/dist/types/index.d.mts +5 -0
  29. package/dist/types/index.d.ts +28 -0
  30. package/dist/types/principal/PrincipalClient.d.ts +34 -0
  31. package/dist/types/react/ConsentEraProvider.d.ts +58 -0
  32. package/dist/types/react/ConsentGate.d.ts +40 -0
  33. package/dist/types/react/index.d.mts +4 -0
  34. package/dist/types/react/index.d.ts +10 -0
  35. package/dist/types/react/useConsentEra.d.ts +65 -0
  36. package/dist/types/react/useConsentValidation.d.ts +23 -0
  37. package/dist/types/storage/ConsentStorage.d.ts +39 -0
  38. package/dist/types/tcf/TCFManager.d.ts +46 -0
  39. package/dist/types/types/consent-lifecycle.d.ts +804 -0
  40. package/dist/types/types/index.d.ts +311 -0
  41. package/dist/types/ui/ConsentBanner.d.ts +22 -0
  42. package/dist/types/ui/PreferenceCenter.d.ts +24 -0
  43. package/dist/types/utils/EventEmitter.d.ts +32 -0
  44. package/dist/types/utils/Logger.d.ts +16 -0
  45. package/dist/types/utils/browserStorage.d.ts +35 -0
  46. package/dist/types/utils/context.d.ts +81 -0
  47. package/dist/types/utils/helpers.d.ts +48 -0
  48. package/package.json +132 -0
@@ -0,0 +1,2724 @@
1
+ 'use client';
2
+ import { jsx, Fragment } from 'react/jsx-runtime';
3
+ import { createContext, useMemo, useState, useEffect, useContext, useRef, useCallback } from 'react';
4
+
5
+ /**
6
+ * Consentera Consent SDK — the error taxonomy.
7
+ *
8
+ * ONE class with a discriminant, plus subclasses for `instanceof`. Before 2.0.0
9
+ * there was a single `ConsentEraApiError` carrying `statusCode` and the raw
10
+ * `responseBody`, which meant a caller who wanted to know *what went wrong* had
11
+ * to dig a string out of an untyped body — and the two things support asks for
12
+ * first, the canonical code and the request id, were both discarded. The API
13
+ * has carried them the whole time: the envelope is `{code, message}`
14
+ * (core/apierrors/errors.go:33-38) over ~141 codes of which 22 are canonical
15
+ * (core/apierrors/canonical_codes.go), and `X-Request-ID` is set by
16
+ * chi middleware and CORS-exposed so a browser can read it
17
+ * (cmd/api/main.go:1266, :1272).
18
+ *
19
+ * `kind` is the discriminant to switch on. The CODE is the platform's word and
20
+ * may be one of many; the KIND is this SDK's classification of it and is a
21
+ * closed set, so `switch (err.kind)` stays exhaustive when the platform adds a
22
+ * code. Both are on the error — never infer the kind from the code yourself.
23
+ */
24
+ /**
25
+ * Canonical code → kind. Codes taken from the platform's registry; anything not
26
+ * listed falls back to the HTTP status, and a code we have never seen is
27
+ * therefore still classified rather than dropped into `unknown`.
28
+ */
29
+ const CODE_KIND = {
30
+ // 400 — the request itself
31
+ VALIDATION_ERROR: 'validation',
32
+ INVALID_REQUEST_BODY: 'validation',
33
+ INVALID_JSON: 'validation',
34
+ INVALID_UUID: 'validation',
35
+ BAD_REQUEST: 'validation',
36
+ MISSING_REQUIRED_FIELD: 'validation',
37
+ MISSING_TENANT_ID: 'validation',
38
+ INVALID_TENANT_ID: 'validation',
39
+ INVALID_DATE_OF_BIRTH: 'validation',
40
+ INVALID_IDENTIFIER_FORMAT: 'validation',
41
+ // identity — the U58 / lifecycle identity family, which is what an
42
+ // integrator gets wrong most often and most expensively
43
+ DATA_PRINCIPAL_REF_REFUSED: 'identity',
44
+ UNKNOWN_IDENTIFIER_FIELD: 'identity',
45
+ IDENTIFIER_REQUIRED: 'identity',
46
+ IDENTITY_MISMATCH: 'identity',
47
+ // guardian / age
48
+ GUARDIAN_REQUIRED: 'guardian',
49
+ GUARDIAN_CONSENT_REQUIRED: 'guardian',
50
+ GUARDIAN_TOKEN_REQUIRED: 'guardian',
51
+ GUARDIAN_EVIDENCE_MISSING: 'guardian',
52
+ GUARDIAN_IDENTITY_NOT_VERIFIED: 'guardian',
53
+ GUARDIAN_ADULT_NOT_VERIFIED: 'guardian',
54
+ GUARDIAN_VERIFICATION_METHOD_INVALID: 'guardian',
55
+ AGE_REQUIRED: 'guardian',
56
+ // 401
57
+ UNAUTHORIZED: 'auth',
58
+ INVALID_CREDENTIALS: 'auth',
59
+ TOKEN_MISSING: 'auth',
60
+ TOKEN_INVALID: 'auth',
61
+ TOKEN_EXPIRED: 'auth',
62
+ TOKEN_MALFORMED: 'auth',
63
+ TOKEN_REVOKED: 'auth',
64
+ SESSION_NOT_FOUND: 'auth',
65
+ SESSION_EXPIRED: 'auth',
66
+ SESSION_REVOKED: 'auth',
67
+ // 403
68
+ FORBIDDEN: 'permission',
69
+ TENANT_MISMATCH: 'permission',
70
+ TENANT_SUSPENDED: 'permission',
71
+ ACCOUNT_DISABLED: 'permission',
72
+ PRIVILEGE_ESCALATION: 'permission',
73
+ SCHEME_NOT_CONFIGURED: 'permission',
74
+ CSRF_TOKEN_MISSING: 'permission',
75
+ CSRF_TOKEN_INVALID: 'permission',
76
+ // 404 / 409 / 429 / 5xx
77
+ NOT_FOUND: 'not_found',
78
+ USER_NOT_FOUND: 'not_found',
79
+ CONFLICT: 'conflict',
80
+ IDEMPOTENCY_KEY_REUSE: 'conflict',
81
+ RATE_LIMIT_EXCEEDED: 'rate_limit',
82
+ INTERNAL_ERROR: 'server',
83
+ INTERNAL_SERVER_ERROR: 'server',
84
+ DATABASE_ERROR: 'server',
85
+ };
86
+ function kindForStatus(status) {
87
+ if (status === 401)
88
+ return 'auth';
89
+ if (status === 403)
90
+ return 'permission';
91
+ if (status === 404)
92
+ return 'not_found';
93
+ if (status === 409)
94
+ return 'conflict';
95
+ if (status === 429)
96
+ return 'rate_limit';
97
+ if (status >= 500)
98
+ return 'server';
99
+ if (status >= 400)
100
+ return 'validation';
101
+ return 'unknown';
102
+ }
103
+ /**
104
+ * Parse `Retry-After`, which RFC 9110 §10.2.3 allows to be either a count of
105
+ * seconds or an HTTP-date. Returns milliseconds, or undefined when the header
106
+ * is absent or unparseable — never NaN, because a NaN delay becomes an
107
+ * immediate retry and turns a 429 into a hot loop.
108
+ */
109
+ function parseRetryAfterMs(header, now = Date.now()) {
110
+ if (!header)
111
+ return undefined;
112
+ const trimmed = header.trim();
113
+ if (/^\d+$/.test(trimmed)) {
114
+ const seconds = Number(trimmed);
115
+ return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : undefined;
116
+ }
117
+ const at = Date.parse(trimmed);
118
+ if (Number.isNaN(at))
119
+ return undefined;
120
+ return Math.max(0, at - now);
121
+ }
122
+ /** The base error every road in this SDK throws. */
123
+ class ConsenteraError extends Error {
124
+ /** This SDK's classification. A closed set — safe to switch on. */
125
+ kind;
126
+ /** The platform's canonical error code, when the response carried one. */
127
+ code;
128
+ /** HTTP status; 0 for a transport failure that never reached a status. */
129
+ status;
130
+ /** `X-Request-ID` off the response. Quote this in a support ticket. */
131
+ requestId;
132
+ /** The parsed response body, or the raw text when it was not JSON. */
133
+ responseBody;
134
+ /** Whether THIS SDK would retry it. Already applied internally. */
135
+ retryable;
136
+ /** Server-asked wait, from `Retry-After`, in ms. */
137
+ retryAfterMs;
138
+ constructor(init) {
139
+ super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
140
+ this.name = new.target.name;
141
+ this.kind = init.kind;
142
+ this.code = init.code;
143
+ this.status = init.status ?? 0;
144
+ this.requestId = init.requestId;
145
+ this.responseBody = init.responseBody;
146
+ this.retryable = init.retryable ?? false;
147
+ this.retryAfterMs = init.retryAfterMs;
148
+ // Extending Error across the ES5 target rollup emits breaks instanceof
149
+ // without this; new.target is the actual subclass.
150
+ Object.setPrototypeOf(this, new.target.prototype);
151
+ }
152
+ /** @deprecated 2.0.0 — use `status`. Kept so 1.x `err.statusCode` still reads. */
153
+ get statusCode() {
154
+ return this.status;
155
+ }
156
+ /** A one-line form safe to log: no body, no identifiers. */
157
+ toString() {
158
+ const bits = [this.name, this.code ?? this.kind];
159
+ if (this.status)
160
+ bits.push(String(this.status));
161
+ if (this.requestId)
162
+ bits.push(`req=${this.requestId}`);
163
+ return `${bits.join(' ')}: ${this.message}`;
164
+ }
165
+ }
166
+ class ConsenteraConfigError extends ConsenteraError {
167
+ }
168
+ class ConsenteraAuthError extends ConsenteraError {
169
+ }
170
+ class ConsenteraPermissionError extends ConsenteraError {
171
+ }
172
+ class ConsenteraValidationError extends ConsenteraError {
173
+ }
174
+ class ConsenteraIdentityError extends ConsenteraError {
175
+ }
176
+ class ConsenteraGuardianError extends ConsenteraError {
177
+ }
178
+ class ConsenteraNotFoundError extends ConsenteraError {
179
+ }
180
+ class ConsenteraConflictError extends ConsenteraError {
181
+ }
182
+ class ConsenteraRateLimitError extends ConsenteraError {
183
+ }
184
+ class ConsenteraServerError extends ConsenteraError {
185
+ }
186
+ class ConsenteraNetworkError extends ConsenteraError {
187
+ }
188
+ class ConsenteraTimeoutError extends ConsenteraError {
189
+ }
190
+ const KIND_CLASS = {
191
+ config: ConsenteraConfigError,
192
+ auth: ConsenteraAuthError,
193
+ permission: ConsenteraPermissionError,
194
+ validation: ConsenteraValidationError,
195
+ identity: ConsenteraIdentityError,
196
+ guardian: ConsenteraGuardianError,
197
+ not_found: ConsenteraNotFoundError,
198
+ conflict: ConsenteraConflictError,
199
+ rate_limit: ConsenteraRateLimitError,
200
+ server: ConsenteraServerError,
201
+ network: ConsenteraNetworkError,
202
+ timeout: ConsenteraTimeoutError,
203
+ cancelled: ConsenteraError,
204
+ unknown: ConsenteraError,
205
+ };
206
+ function newOfKind(init) {
207
+ return new KIND_CLASS[init.kind](init);
208
+ }
209
+ /** Pull `{code, message}` off a parsed body, tolerating the `{error:{...}}` wrapper. */
210
+ function readEnvelope(body) {
211
+ if (!body || typeof body !== 'object')
212
+ return {};
213
+ const o = body;
214
+ const inner = o.error && typeof o.error === 'object' ? o.error : o;
215
+ const code = typeof inner.code === 'string' ? inner.code : undefined;
216
+ const message = typeof inner.message === 'string' ? inner.message : undefined;
217
+ return { code, message };
218
+ }
219
+ /** Build the error for a non-2xx response. */
220
+ function errorFromResponse(args) {
221
+ const { code, message } = readEnvelope(args.body);
222
+ const kind = (code && CODE_KIND[code]) || kindForStatus(args.status);
223
+ const retryable = args.status === 429 || args.status >= 500;
224
+ return newOfKind({
225
+ kind,
226
+ code,
227
+ status: args.status,
228
+ requestId: args.requestId,
229
+ responseBody: args.body,
230
+ retryable,
231
+ retryAfterMs: args.retryAfterMs,
232
+ message: message ||
233
+ `${args.method} ${args.path} failed: ${args.status}${args.statusText ? ` ${args.statusText}` : ''}`,
234
+ });
235
+ }
236
+ /** A request that never reached a status: DNS, TLS, offline, CORS. */
237
+ function networkError(message, cause) {
238
+ return new ConsenteraNetworkError({ kind: 'network', message, status: 0, retryable: true, cause });
239
+ }
240
+ /** The SDK's own deadline fired. */
241
+ function timeoutError(message, cause) {
242
+ return new ConsenteraTimeoutError({ kind: 'timeout', message, status: 0, retryable: true, cause });
243
+ }
244
+ /** The caller's AbortSignal fired. NEVER retryable: the caller asked to stop. */
245
+ function cancelledError(message, cause) {
246
+ return new ConsenteraError({ kind: 'cancelled', message, status: 0, retryable: false, cause });
247
+ }
248
+ /** Misconfiguration found before anything went on the wire. */
249
+ function configError(message, code) {
250
+ return new ConsenteraConfigError({ kind: 'config', message, status: 0, code, retryable: false });
251
+ }
252
+
253
+ /**
254
+ * Consentera Consent SDK — THE artifact read, in one place.
255
+ *
256
+ * `ConsentSession.getArtifact` and the callback handler's confirmation both
257
+ * read GET /consent/artifacts/{artifact_id}, and before this file each spelled
258
+ * the request itself. Both spelled it WITHOUT `?session_id=`, which on the
259
+ * platform that ships is the difference between two answers:
260
+ *
261
+ * without session_id a consent whose artifact is still being written is a
262
+ * 404 ARTIFACT_NOT_FOUND — the same word a guessed id
263
+ * gets. The callback handler therefore had to read
264
+ * every 404 as "maybe pending" and poll it, so a FORGED
265
+ * artifact_id came back `pending` instead of refused.
266
+ * with session_id 202 + Retry-After while the projection is owed; 404
267
+ * only when the id was never issued for that session or
268
+ * will never be written. (GetArtifactHandler,
269
+ * consent/collection.go:4409-4520 at 4fda7e3d05; walk
270
+ * finding F018; SDK register WEB-033.)
271
+ *
272
+ * Measured on setup.consentera.in (4fda7e3d05): an unknown artifact read with a
273
+ * real session is 404; the walk's real artifact with its session is 200. See
274
+ * fixtures/platform-wire/artifact-read.*.json at the repo root.
275
+ *
276
+ * WHAT THE session_id DOES NOT DO (walk finding F077, measured): once the
277
+ * artifact row exists the 200 path reads by tenant + artifact_id ALONE. The
278
+ * same artifact read with a random session_id is still 200. So a 200 does not
279
+ * prove the artifact belongs to your session; compare its `data_principal_id`
280
+ * with the one your session create returned (the callback handler does).
281
+ */
282
+ /** Retry-After when the 202 carried neither the header nor `retry_after`. */
283
+ const DEFAULT_PENDING_RETRY_MS = 1000;
284
+ function artifactPath(artifactId) {
285
+ // encodeURIComponent: an id with a URL-special character (or a caller passing
286
+ // a path fragment) would otherwise reshape the request.
287
+ return `/consent/artifacts/${encodeURIComponent(artifactId)}`;
288
+ }
289
+ /**
290
+ * Read one artifact. Resolves `recorded` (200) or `pending` (202); a 404, a
291
+ * 403 and everything else is the transport's typed ConsenteraError.
292
+ */
293
+ async function readArtifact(request, artifactId, options = {}) {
294
+ const res = await request('GET', artifactPath(artifactId), undefined, { session_id: options.sessionId || undefined }, { raw: true, signal: options.signal, timeoutMs: options.timeoutMs });
295
+ if (res.status === 200 && res.body && typeof res.body === 'object') {
296
+ return { state: 'recorded', artifact: res.body, requestId: res.requestId };
297
+ }
298
+ if (res.status === 202 && res.body && typeof res.body === 'object') {
299
+ const pending = res.body;
300
+ const fromBody = typeof pending.retry_after === 'number' && pending.retry_after >= 0 ? pending.retry_after * 1000 : undefined;
301
+ return {
302
+ state: 'pending',
303
+ pending,
304
+ retryAfterMs: res.retryAfterMs ?? fromBody ?? DEFAULT_PENDING_RETRY_MS,
305
+ requestId: res.requestId,
306
+ };
307
+ }
308
+ // Any other 2xx (a 204, a 200 with no body) is not an answer this road
309
+ // gives. Reporting it as an artifact would be inventing one.
310
+ throw errorFromResponse({
311
+ status: res.status,
312
+ statusText: 'not an artifact read answer (expected 200 or 202 with a JSON body)',
313
+ body: res.body,
314
+ requestId: res.requestId,
315
+ method: 'GET',
316
+ path: artifactPath(artifactId),
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Consentera Consent SDK — guarded browser storage.
322
+ *
323
+ * EVERY ACCESS IS WRAPPED, AND THAT IS NOT DEFENSIVENESS. `localStorage` and
324
+ * `sessionStorage` THROW rather than return null in ordinary, common
325
+ * configurations: Safari's private browsing on old versions, a site with
326
+ * cookies blocked, a sandboxed iframe without `allow-same-origin`, a quota that
327
+ * is full. Before 2.0.0 `ConsentStorage.save()` called `localStorage.setItem`
328
+ * bare, so a visitor with storage blocked did not get a degraded banner — they
329
+ * got an exception out of the middle of a consent write.
330
+ *
331
+ * ONE implementation, so the "did we remember to try/catch this one" question
332
+ * is asked once. `ConsenteraConsent.getOrCreateDeviceId` already had the right
333
+ * shape; it was the only place that did.
334
+ *
335
+ * When storage is unavailable the SDK degrades to a per-page memory map: the
336
+ * flow still works within the page, it just does not survive a reload. That is
337
+ * the correct trade for a consent handshake, which is short-lived.
338
+ */
339
+ const memory = {
340
+ local: new Map(),
341
+ session: new Map(),
342
+ };
343
+ /** The one place key names are spelled, so a reader and a writer cannot drift. */
344
+ const storageKeys = {
345
+ session: (id) => `consentera_session_${id}`,
346
+ callback: (id) => `consentera_callback_${id}`,
347
+ pendingSync: 'consentera_pending_sync',
348
+ deviceId: 'consentera_device_id',
349
+ };
350
+ function backing(kind) {
351
+ try {
352
+ if (typeof window === 'undefined')
353
+ return null;
354
+ const s = kind === 'local' ? window.localStorage : window.sessionStorage;
355
+ // Touching the object is itself what throws in a blocked context, so the
356
+ // probe has to be a real operation rather than a truthiness check.
357
+ const probe = '__consentera_probe__';
358
+ s.setItem(probe, '1');
359
+ s.removeItem(probe);
360
+ return s;
361
+ }
362
+ catch {
363
+ return null;
364
+ }
365
+ }
366
+ function readStored(kind, key) {
367
+ const s = backing(kind);
368
+ if (!s)
369
+ return memory[kind].get(key) ?? null;
370
+ try {
371
+ return s.getItem(key);
372
+ }
373
+ catch {
374
+ return memory[kind].get(key) ?? null;
375
+ }
376
+ }
377
+ /** Returns false when the value could not be persisted anywhere durable. */
378
+ function writeStored(kind, key, value) {
379
+ const s = backing(kind);
380
+ if (s) {
381
+ try {
382
+ s.setItem(key, value);
383
+ return true;
384
+ }
385
+ catch {
386
+ /* quota or policy — fall through to memory */
387
+ }
388
+ }
389
+ memory[kind].set(key, value);
390
+ return false;
391
+ }
392
+ function removeStored(kind, key) {
393
+ const s = backing(kind);
394
+ if (s) {
395
+ try {
396
+ s.removeItem(key);
397
+ }
398
+ catch {
399
+ /* ignore */
400
+ }
401
+ }
402
+ memory[kind].delete(key);
403
+ }
404
+
405
+ /**
406
+ * The SDK's own version, sent on every request as `X-Consentera-SDK`.
407
+ *
408
+ * KEPT IN SYNC BY A TEST, NOT BY A BUILD STEP. `src/__tests__/core/version.test.ts`
409
+ * reads package.json and fails when the two disagree, so a release that forgets
410
+ * this file cannot go green. A build-time codegen would have been the other
411
+ * option; it was rejected because the constant then does not exist when a host
412
+ * builds from src, and because a generated file in the tree is one command from
413
+ * being overwritten with the wrong value and nothing noticing.
414
+ */
415
+ const SDK_VERSION = '2.0.0';
416
+ const SDK_PLATFORM = 'web';
417
+ /** The value of the `X-Consentera-SDK` header: `<surface>/<version>`. */
418
+ const SDK_HEADER_VALUE = `js/${SDK_VERSION}`;
419
+ /**
420
+ * The `User-Agent` half of the pair, agreed across all six surfaces
421
+ * (coordinator ruling 2026-09-22):
422
+ *
423
+ * User-Agent: ConsenteraSDK/2.0.0 (<platform>; <runtime>)
424
+ * X-Consentera-SDK: <surface>/2.0.0
425
+ *
426
+ * `ConsenteraSDK/<version>` is the form the PLATFORM ALREADY PARSES:
427
+ * `internal/core/audit/user_agent_coarsening_test.go:39-40` asserts that
428
+ * `ConsenteraSDK/2.3.1 (Android 14; SM-G991B; build 4471)` coarsens to
429
+ * `ConsenteraSDK/2`, so this is the shape its audit pipeline expects rather
430
+ * than a new one. (A second spelling, `consentera-sdk/1.2`, appears in
431
+ * `validation_envelope_test.go:233`; the pair above is the agreed one.)
432
+ *
433
+ * IN A BROWSER THIS HEADER CANNOT BE SENT. `User-Agent` is a forbidden header
434
+ * name (fetch spec §forbidden-request-header), so a browser silently drops any
435
+ * attempt to set it — the SDK does not try, and the browser build identifies
436
+ * itself with `X-Consentera-SDK` alone. The Node build (and the CLI) send both,
437
+ * because there the header is ours to set.
438
+ */
439
+ function userAgentValue(runtime) {
440
+ const rt = (typeof process !== 'undefined' && process.versions?.node ? `node ${process.versions.node}` : 'unknown');
441
+ return `ConsenteraSDK/${SDK_VERSION} (${SDK_PLATFORM}; ${rt})`;
442
+ }
443
+
444
+ /**
445
+ * Consentera Consent SDK — the transport.
446
+ *
447
+ * Everything that talks to the platform goes through `HttpTransport.request`.
448
+ * One implementation, because the four things below are the ones that are
449
+ * always missing when each caller writes its own `fetch`:
450
+ *
451
+ * 1. A DEADLINE. `fetch` has none. Before 2.0.0 a hung connection hung the
452
+ * caller's promise forever, which on a consent gate means a page that never
453
+ * renders. Every request now carries an AbortController, linked to the
454
+ * caller's own signal so `AbortSignal` cancellation still works.
455
+ * 2. RETRIES that are safe. Exponential backoff with full jitter on network
456
+ * failure, 5xx and 429, honouring `Retry-After` when the server sends one.
457
+ * 3. ONE IDEMPOTENCY KEY PER LOGICAL OPERATION. Before 2.0.0 the key was
458
+ * minted inside the request function from `Date.now()` + `Math.random()`,
459
+ * so it changed on every attempt and the caller could not supply one — a
460
+ * dedupe mechanism that was present and could not dedupe anything. The key
461
+ * is now minted ONCE per logical operation, reused on every retry of it,
462
+ * and `options.idempotencyKey` overrides it.
463
+ * 4. THE REQUEST ID. The platform sets `X-Request-ID` and CORS-exposes it
464
+ * (cmd/api/main.go:1266, :1272). It is now on every error.
465
+ *
466
+ * WHAT IS DELIBERATELY NOT HERE: no request or response BODY is ever logged.
467
+ * The body of a session create is the Data Principal's identifiers.
468
+ */
469
+ const DEFAULT_RETRY = { attempts: 3, baseDelayMs: 250, maxDelayMs: 4000 };
470
+ const DEFAULT_TIMEOUT_MS = 10_000;
471
+ /** `typeof window !== 'undefined'` in one place, so a test can reason about it. */
472
+ function isBrowser() {
473
+ return typeof window !== 'undefined';
474
+ }
475
+ /** A secret DF key: the platform's prefixes for the two secret classes. */
476
+ function isSecretKey(key) {
477
+ return !!key && (key.startsWith('tiq_live_') || key.startsWith('tiq_test_'));
478
+ }
479
+ /** A public site key. */
480
+ function isSiteKey(key) {
481
+ return !!key && key.startsWith('tiq_pub_');
482
+ }
483
+ /**
484
+ * THE ONE REFUSAL, in one place — used by BOTH entry points (ConsentEraClient
485
+ * and ConsenteraConsent), because a rule enforced at only one door is not a
486
+ * rule. A secret key in a browser is not a warning: the bundle is public, so by
487
+ * the time it runs the key is already published to every visitor. Refusing at
488
+ * construction is the only point at which the integrator still has the option
489
+ * of not shipping it.
490
+ *
491
+ * `fix` is the entry-point-specific remedy (a proxy endpoint for the lifecycle
492
+ * client, a public site key for the cookie SDK); everything else — the reason,
493
+ * the prefix echo, the escape hatch — is identical, which is exactly why it
494
+ * lives here rather than being copied and left to drift.
495
+ */
496
+ function assertNoSecretKeyInBrowser(opts) {
497
+ if (!isBrowser() || !opts.apiKey || opts.unsafeAllowSecretKeyInBrowser)
498
+ return;
499
+ throw configError('Consentera: `apiKey` is a SECRET and this SDK is running in a browser, where every ' +
500
+ 'visitor can read the bundle. ' +
501
+ opts.fix +
502
+ ' ' +
503
+ (isSecretKey(opts.apiKey)
504
+ ? `The key supplied starts with "${opts.apiKey.slice(0, 9)}" — rotate it, it is now in a bundle. `
505
+ : '') +
506
+ 'If you are certain this code never reaches a browser (a test harness with a jsdom window, ' +
507
+ 'a server-side render that only looks like one), set `unsafeAllowSecretKeyInBrowser: true` ' +
508
+ 'and every request will warn.', 'SECRET_KEY_IN_BROWSER');
509
+ }
510
+ /**
511
+ * Crypto-strong id. `Math.random()` was what minted idempotency keys before
512
+ * 2.0.0; it is neither unpredictable nor collision-safe at 6 characters.
513
+ */
514
+ function newRequestId() {
515
+ const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined;
516
+ if (c && typeof c.randomUUID === 'function')
517
+ return c.randomUUID();
518
+ if (c && typeof c.getRandomValues === 'function') {
519
+ const b = c.getRandomValues(new Uint8Array(16));
520
+ return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
521
+ }
522
+ // Last resort for an ancient browser. Recorded rather than silent: a caller
523
+ // on such a browser should supply its own idempotencyKey.
524
+ return `nc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
525
+ }
526
+ function sleep(ms, signal) {
527
+ return new Promise((resolve, reject) => {
528
+ if (signal?.aborted) {
529
+ reject(cancelledError('request cancelled'));
530
+ return;
531
+ }
532
+ const t = setTimeout(() => {
533
+ signal?.removeEventListener('abort', onAbort);
534
+ resolve();
535
+ }, ms);
536
+ const onAbort = () => {
537
+ clearTimeout(t);
538
+ reject(cancelledError('request cancelled'));
539
+ };
540
+ signal?.addEventListener('abort', onAbort, { once: true });
541
+ });
542
+ }
543
+ /** Full-jitter backoff (AWS's "Exponential Backoff and Jitter"): random in [0, cap]. */
544
+ function backoffMs(attempt, policy, random = Math.random) {
545
+ const exp = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt);
546
+ return Math.floor(random() * exp);
547
+ }
548
+ class HttpTransport {
549
+ config;
550
+ logger;
551
+ constructor(config, logger) {
552
+ this.config = config;
553
+ this.logger = logger;
554
+ }
555
+ /** Swap config after construction (the client re-reads customHeaders each call). */
556
+ updateConfig(patch) {
557
+ this.config = { ...this.config, ...patch };
558
+ }
559
+ /**
560
+ * The credential decision for one road, in one place so the policy can be
561
+ * read rather than reconstructed from call sites.
562
+ *
563
+ * Returns the auth headers to send, or throws a config error naming the fix.
564
+ */
565
+ authHeadersFor(road, viaProxy) {
566
+ const { apiKey, siteKey, unsafeAllowSecretKeyInBrowser } = this.config;
567
+ const browser = isBrowser();
568
+ // In proxy mode the DF's own server holds the credential and adds it. The
569
+ // SDK sends none — a key sent here would be a key in the bundle.
570
+ if (viaProxy)
571
+ return {};
572
+ if (road === 'session')
573
+ return {};
574
+ if (road === 'public') {
575
+ if (siteKey)
576
+ return { 'X-API-Key': siteKey };
577
+ // A public road with no site key is legitimate: /notices/current takes a
578
+ // tenant_code in the query.
579
+ return {};
580
+ }
581
+ // road === 'df'
582
+ if (browser && !unsafeAllowSecretKeyInBrowser) {
583
+ throw configError('Consentera: this call needs a Data Fiduciary credential, which is a SECRET and must ' +
584
+ 'never be in a browser bundle. Set `proxyEndpoint` to your own server route (it holds ' +
585
+ 'the tiq_live_/tiq_test_ key and forwards the call), or run this call server-side. ' +
586
+ 'A public site key (tiq_pub_) cannot authorise it: the lifecycle roads are ' +
587
+ 'server-to-server. A site key opens only the public read roads and the ' +
588
+ 'session-scoped render/submit, and only for the origins in its allowed_domains.', 'SECRET_KEY_IN_BROWSER');
589
+ }
590
+ if (apiKey) {
591
+ if (browser && unsafeAllowSecretKeyInBrowser) {
592
+ this.logger.warn('Consentera: SENDING A SECRET KEY FROM A BROWSER because ' +
593
+ 'unsafeAllowSecretKeyInBrowser is set. Every visitor to this page can read it. ' +
594
+ 'Rotate the key and move to proxyEndpoint.');
595
+ }
596
+ return { 'X-API-Key': apiKey };
597
+ }
598
+ if (isSiteKey(siteKey)) {
599
+ throw configError('Consentera: a site key (tiq_pub_) was supplied for a Data Fiduciary lifecycle road. ' +
600
+ 'Those roads are server-to-server; a site key opens only the public read roads and the ' +
601
+ 'session-scoped render/submit, so this call would be refused 403. Use `proxyEndpoint`, ' +
602
+ 'or a secret key server-side.', 'SITE_KEY_ON_DF_ROAD');
603
+ }
604
+ throw configError('Consentera: no credential for a Data Fiduciary road. Set `proxyEndpoint` (browser) or ' +
605
+ '`apiKey` (server).', 'NO_CREDENTIAL');
606
+ }
607
+ urlFor(path, query) {
608
+ const { proxyEndpoint, apiEndpoint } = this.config;
609
+ // A session/public road is reachable without the DF's proxy, but when a
610
+ // proxy is configured everything goes through it: one origin to allow-list.
611
+ const viaProxy = !!proxyEndpoint;
612
+ const base = viaProxy ? proxyEndpoint : apiEndpoint;
613
+ const prefix = this.config.apiPathPrefix ?? '/api/v1/public';
614
+ if (prefix !== '/api/v1/public' && prefix !== '/api/v1/cookie-consent') {
615
+ throw configError('Consentera: unsupported transport API plane.');
616
+ }
617
+ let url = viaProxy ? `${base}${path}` : `${base}${prefix}${path}`;
618
+ if (query) {
619
+ const params = new URLSearchParams();
620
+ for (const [k, v] of Object.entries(query)) {
621
+ if (v !== undefined && v !== null)
622
+ params.set(k, String(v));
623
+ }
624
+ const qs = params.toString();
625
+ if (qs)
626
+ url += `?${qs}`;
627
+ }
628
+ return { url, viaProxy };
629
+ }
630
+ async request(method, path, body, options = {}) {
631
+ const road = options.road ?? 'df';
632
+ const { url, viaProxy } = this.urlFor(path, options.query);
633
+ const policy = { ...DEFAULT_RETRY, ...this.config.retry, ...options.retry };
634
+ const timeoutMs = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
635
+ const doFetch = this.config.fetchImpl ?? globalThis.fetch;
636
+ if (typeof doFetch !== 'function') {
637
+ throw configError('Consentera: no fetch implementation available in this environment.');
638
+ }
639
+ const headers = {
640
+ Accept: 'application/json',
641
+ // THE PAIR, and why only one half of it is here.
642
+ //
643
+ // All six surfaces identify themselves as
644
+ // User-Agent: ConsenteraSDK/<version> (<platform>; <runtime>)
645
+ // X-Consentera-SDK: <surface>/<version>
646
+ // the first being the form the platform's audit pipeline already parses
647
+ // (core/audit/user_agent_coarsening_test.go:39-40 coarsens
648
+ // `ConsenteraSDK/2.3.1 (…)` to `ConsenteraSDK/2`).
649
+ //
650
+ // `User-Agent` is a FORBIDDEN HEADER NAME in a browser: fetch drops any
651
+ // attempt to set it, silently, so setting it here would be a line that
652
+ // looks like identification and is not. It is therefore added only when
653
+ // there is no `window` — the Node build — and the browser identifies
654
+ // itself with X-Consentera-SDK alone.
655
+ 'X-Consentera-SDK': SDK_HEADER_VALUE,
656
+ ...(isBrowser() ? {} : { 'User-Agent': userAgentValue() }),
657
+ ...this.authHeadersFor(road, viaProxy),
658
+ };
659
+ if (!viaProxy && road !== 'session' && this.config.tenantId)
660
+ headers['X-Tenant-Id'] = this.config.tenantId;
661
+ if (body !== undefined)
662
+ headers['Content-Type'] = 'application/json';
663
+ // ONE key for this logical operation, reused on every attempt below.
664
+ if (method !== 'GET')
665
+ headers['Idempotency-Key'] = options.idempotencyKey ?? newRequestId();
666
+ const custom = typeof this.config.customHeaders === 'function' ? this.config.customHeaders() : this.config.customHeaders;
667
+ Object.assign(headers, custom, options.headers);
668
+ let outgoing = body;
669
+ if (body !== undefined && this.config.beforeSend) {
670
+ const kept = this.config.beforeSend({ method, path, body });
671
+ if (kept === null || kept === undefined) {
672
+ throw configError(`Consentera: beforeSend refused ${method} ${path}. Nothing was sent.`, 'BEFORE_SEND_REFUSED');
673
+ }
674
+ outgoing = kept;
675
+ }
676
+ const payload = outgoing === undefined ? undefined : JSON.stringify(outgoing);
677
+ let lastError;
678
+ for (let attempt = 0; attempt < policy.attempts; attempt++) {
679
+ if (options.signal?.aborted)
680
+ throw cancelledError('request cancelled before attempt');
681
+ const started = Date.now();
682
+ let timedOut = false;
683
+ const controller = new AbortController();
684
+ const timer = setTimeout(() => {
685
+ timedOut = true;
686
+ controller.abort();
687
+ }, timeoutMs);
688
+ const onCallerAbort = () => controller.abort();
689
+ options.signal?.addEventListener('abort', onCallerAbort, { once: true });
690
+ try {
691
+ const response = await doFetch(url, {
692
+ method,
693
+ headers,
694
+ body: payload,
695
+ signal: controller.signal,
696
+ });
697
+ const elapsed = Date.now() - started;
698
+ const requestId = response.headers?.get?.('X-Request-Id') ?? undefined;
699
+ if (response.ok) {
700
+ // NEVER the body — see the header of this file.
701
+ this.logger.debug(`${method} ${path} -> ${response.status} (${elapsed}ms)`, { requestId });
702
+ const wrap = (parsed) => (options.raw
703
+ ? {
704
+ status: response.status,
705
+ body: parsed,
706
+ requestId,
707
+ retryAfterMs: parseRetryAfterMs(response.headers?.get?.('Retry-After')),
708
+ }
709
+ : parsed);
710
+ if (response.status === 204)
711
+ return wrap(undefined);
712
+ const text = await response.text();
713
+ if (!text)
714
+ return wrap(undefined);
715
+ try {
716
+ return wrap(JSON.parse(text));
717
+ }
718
+ catch {
719
+ // A 200 whose body is not JSON is a SERVER problem. Reporting it as
720
+ // a network error (which 1.x did) sends the integrator to look at
721
+ // their connection.
722
+ throw errorFromResponse({
723
+ status: response.status,
724
+ body: text,
725
+ requestId,
726
+ method,
727
+ path,
728
+ statusText: 'response body is not JSON',
729
+ });
730
+ }
731
+ }
732
+ const rawText = await response.text();
733
+ let parsed = rawText;
734
+ try {
735
+ parsed = JSON.parse(rawText);
736
+ }
737
+ catch {
738
+ /* keep the text */
739
+ }
740
+ const retryAfterMs = parseRetryAfterMs(response.headers?.get?.('Retry-After'));
741
+ lastError = errorFromResponse({
742
+ status: response.status,
743
+ statusText: response.statusText,
744
+ body: parsed,
745
+ requestId,
746
+ retryAfterMs,
747
+ method,
748
+ path,
749
+ });
750
+ this.logger.debug(`${method} ${path} -> ${response.status} ${lastError.code ?? ''} (${elapsed}ms)`, { requestId });
751
+ }
752
+ catch (err) {
753
+ if (err instanceof ConsenteraError) {
754
+ if (!err.retryable)
755
+ throw err;
756
+ lastError = err;
757
+ }
758
+ else if (timedOut) {
759
+ lastError = timeoutError(`${method} ${path} timed out after ${timeoutMs}ms`, err);
760
+ }
761
+ else if (options.signal?.aborted) {
762
+ throw cancelledError(`${method} ${path} cancelled`, err);
763
+ }
764
+ else {
765
+ lastError = networkError(`${method} ${path}: ${err?.message ?? 'network error'}`, err);
766
+ }
767
+ }
768
+ finally {
769
+ clearTimeout(timer);
770
+ options.signal?.removeEventListener('abort', onCallerAbort);
771
+ }
772
+ if (!lastError.retryable || attempt === policy.attempts - 1)
773
+ throw lastError;
774
+ if (policy.maxRetryAfterMs !== undefined && lastError.retryAfterMs !== undefined &&
775
+ lastError.retryAfterMs > policy.maxRetryAfterMs)
776
+ throw lastError;
777
+ const wait = lastError.retryAfterMs ?? backoffMs(attempt, policy);
778
+ this.logger.debug(`${method} ${path} retry ${attempt + 1}/${policy.attempts - 1} in ${wait}ms (${lastError.code ?? lastError.kind})`);
779
+ await sleep(wait, options.signal);
780
+ }
781
+ /* istanbul ignore next — the loop always throws on its last attempt. */
782
+ throw lastError ?? networkError(`${method} ${path} failed`);
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Consentera Consent SDK — the consent popup, and the ONE message it listens for.
788
+ *
789
+ * ─── WHAT /collect ACTUALLY POSTS (SDK register WEB-035) ────────────────────
790
+ *
791
+ * After a decision, an EMBEDDED /collect page (window.parent !== window) posts
792
+ * exactly one message to its parent. That is decisionMessage() in
793
+ * consentera-ui/src/app/collect/[sessionId]/decision-message.ts, posted at
794
+ * page.tsx:1535-1545 (grant) and :1670-1680 (decline) on the served commit
795
+ * 4fda7e3d05:
796
+ *
797
+ * { type: 'consentera:submitted' | 'consentera:declined',
798
+ * session_id, artifact_id, status: 'granted'|'partial'|'denied', pending,
799
+ * sessionId, redirectUrl, choices } // the three legacy fields
800
+ *
801
+ * `choices` rides only on a submit. The TARGET ORIGIN is the origin of the
802
+ * session's callback_url (redirect-trust.ts parentOriginFor), so the page that
803
+ * frames /collect must be on the callback's origin or the browser drops the
804
+ * message without a word.
805
+ *
806
+ * Before this file the SDK opened the page with window.open and listened for
807
+ * `consentera:consent-result`, a type the platform has never sent. That was
808
+ * wrong twice over, because a window.open popup is a TOP-LEVEL page. It posts
809
+ * nothing: it navigates to the callback instead. The storage fallback polled
810
+ * sessionStorage, which a separate window does not share. So the promise could
811
+ * end only when the window closed, as `unverified`, or at the 10-minute
812
+ * timeout.
813
+ *
814
+ * So the popup is now what the platform's own website popup is: a dialog on
815
+ * THIS page with /collect in an iframe (consentera-ui/src/lib/onboarding/
816
+ * website-consent.ts, createDialog). It is the presentation in which /collect
817
+ * reports the decision.
818
+ *
819
+ * ─── WHAT THE RESULT IS, AND WHAT IT IS NOT ────────────────────────────────
820
+ *
821
+ * The message comes from the platform's origin and from the frame this SDK
822
+ * opened (both are checked), so it is the platform page's report of the
823
+ * decision. It is not the consent record. The record is the artefact: confirm
824
+ * it with getArtifact(artifact_id, { sessionId }) through your proxy, or on
825
+ * your server, before you act on a grant.
826
+ */
827
+ const TYPES = new Set(['consentera:submitted', 'consentera:declined']);
828
+ const STATUSES = new Set(['granted', 'partial', 'denied']);
829
+ function queryOf(url) {
830
+ if (typeof url !== 'string' || !url)
831
+ return null;
832
+ try {
833
+ return new URL(url, 'https://collect.invalid').searchParams;
834
+ }
835
+ catch {
836
+ return null;
837
+ }
838
+ }
839
+ /**
840
+ * The platform's own rule for a status the message did not carry
841
+ * (decision-message.ts decisionStatus): the redirect URL's status when it has
842
+ * one, else from the choices — none granted → denied, some denied → partial,
843
+ * else granted.
844
+ */
845
+ function derivedStatus(q, choices) {
846
+ const fromUrl = q?.get('status');
847
+ if (fromUrl && STATUSES.has(fromUrl))
848
+ return fromUrl;
849
+ const values = Object.values(choices ?? {});
850
+ const granted = values.filter(Boolean).length;
851
+ if (granted === 0)
852
+ return 'denied';
853
+ if (granted < values.length)
854
+ return 'partial';
855
+ return 'granted';
856
+ }
857
+ /**
858
+ * Read a MessageEvent as /collect's decision — or null when it is not one, or
859
+ * not from where it must come from.
860
+ *
861
+ * Exported for a Data Fiduciary that frames consent_url itself: the same
862
+ * checks, one implementation.
863
+ *
864
+ * origin must equal the /collect origin (the consent_url's origin)
865
+ * source when given, must be the frame that was opened
866
+ * type consentera:submitted | consentera:declined
867
+ * session `session_id` (or the legacy `sessionId`) must be this session;
868
+ * when both are present they must agree
869
+ */
870
+ function readDecisionMessage(event, expected) {
871
+ if (!expected.origin || event.origin !== expected.origin)
872
+ return null;
873
+ if (expected.source !== undefined && event.source !== expected.source)
874
+ return null;
875
+ const data = event.data;
876
+ if (!data || typeof data !== 'object' || typeof data.type !== 'string' || !TYPES.has(data.type))
877
+ return null;
878
+ const snake = typeof data.session_id === 'string' && data.session_id ? data.session_id : undefined;
879
+ const legacy = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : undefined;
880
+ if (snake && legacy && snake !== legacy)
881
+ return null;
882
+ const sessionId = snake ?? legacy;
883
+ if (!sessionId || sessionId !== expected.sessionId)
884
+ return null;
885
+ const redirectUrl = typeof data.redirectUrl === 'string' && data.redirectUrl ? data.redirectUrl : undefined;
886
+ const q = queryOf(redirectUrl);
887
+ const choices = data.choices && typeof data.choices === 'object' ? data.choices : undefined;
888
+ const type = data.type;
889
+ // The platform's own value when it sent one; otherwise its own derivation.
890
+ // A decline carries no choices, so it derives `denied` unless the redirect
891
+ // URL says otherwise.
892
+ const status = typeof data.status === 'string' && STATUSES.has(data.status)
893
+ ? data.status
894
+ : derivedStatus(q, type === 'consentera:declined' ? undefined : choices);
895
+ const artifactId = typeof data.artifact_id === 'string' && data.artifact_id ? data.artifact_id : (q?.get('artifact_id') ?? '');
896
+ return {
897
+ outcome: 'decided',
898
+ type,
899
+ session_id: sessionId,
900
+ artifact_id: artifactId,
901
+ status,
902
+ pending: data.pending === true || q?.get('pending') === '1',
903
+ ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
904
+ ...(choices ? { choices } : {}),
905
+ };
906
+ }
907
+ /**
908
+ * Refuse a callback that cannot deliver the decision to this page.
909
+ * Returns the refusal, or null when the callback is fine or unknown.
910
+ */
911
+ function callbackOriginProblem(callbackUrl, pageOrigin) {
912
+ if (!callbackUrl)
913
+ return null;
914
+ let cb;
915
+ try {
916
+ cb = new URL(callbackUrl, pageOrigin);
917
+ }
918
+ catch {
919
+ return `the session's callback_url (${callbackUrl}) is not a URL`;
920
+ }
921
+ if (cb.protocol !== 'https:' && cb.protocol !== 'http:') {
922
+ return (`the session's callback_url is an app link (${cb.protocol}//…). /collect then posts its decision to its ` +
923
+ 'own origin, which this page is not, so the popup would never hear it. Use redirectToConsent, or ' +
924
+ 'create the session with a callback_url on this page’s origin.');
925
+ }
926
+ if (cb.origin !== pageOrigin) {
927
+ return (`/collect posts the decision to the callback's origin (${cb.origin}), and this page is ${pageOrigin}. ` +
928
+ 'The browser drops a message addressed to another origin, so the popup would never hear it. ' +
929
+ 'Create the session with a callback_url on this page’s origin.');
930
+ }
931
+ return null;
932
+ }
933
+ /**
934
+ * Open consent_url in a dialog on this page and resolve with the decision
935
+ * /collect posts, or `dismissed` when the person closes it.
936
+ */
937
+ function openConsentDialog(session, options = {}) {
938
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
939
+ throw new Error('openConsentPopup can only be used in browser environments');
940
+ }
941
+ const problem = callbackOriginProblem(options.callbackUrl, window.location.origin);
942
+ if (problem) {
943
+ return Promise.reject(configError(`Consentera: ${problem}`, 'CALLBACK_ORIGIN_MISMATCH'));
944
+ }
945
+ let consentOrigin;
946
+ try {
947
+ consentOrigin = new URL(session.consent_url, window.location.href).origin;
948
+ }
949
+ catch {
950
+ return Promise.reject(configError('Consentera: consent_url is not a URL.', 'CONSENT_URL_INVALID'));
951
+ }
952
+ const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
953
+ const title = options.title ?? 'Your consent choices';
954
+ const host = options.container ?? document.body;
955
+ return new Promise((resolve, reject) => {
956
+ const previouslyFocused = document.activeElement;
957
+ const previousOverflow = document.documentElement.style.overflow;
958
+ const overlay = document.createElement('div');
959
+ overlay.dataset.consenteraPopup = 'true';
960
+ overlay.style.cssText =
961
+ 'position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;' +
962
+ 'padding:12px;background:rgba(15,23,42,.6)';
963
+ const box = document.createElement('div');
964
+ box.setAttribute('role', 'dialog');
965
+ box.setAttribute('aria-modal', 'true');
966
+ box.setAttribute('aria-label', title);
967
+ box.style.cssText =
968
+ 'display:flex;flex-direction:column;width:100%;max-width:680px;height:min(780px,100%);background:#fff;' +
969
+ 'border-radius:14px;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.35)';
970
+ const head = document.createElement('div');
971
+ head.style.cssText = 'display:flex;justify-content:flex-end;padding:8px;border-bottom:1px solid #e5e7eb';
972
+ const close = document.createElement('button');
973
+ close.type = 'button';
974
+ close.textContent = 'Close';
975
+ close.setAttribute('aria-label', `Close — ${title}`);
976
+ close.style.cssText =
977
+ 'font:inherit;font-size:14px;padding:8px 14px;min-height:40px;border:1px solid #d1d5db;border-radius:8px;' +
978
+ 'background:#fff;color:#111827;cursor:pointer';
979
+ const frame = document.createElement('iframe');
980
+ frame.title = title;
981
+ frame.src = session.consent_url;
982
+ frame.style.cssText = 'flex:1;width:100%;border:0;background:#fff';
983
+ head.append(close);
984
+ box.append(head, frame);
985
+ overlay.append(box);
986
+ host.append(overlay);
987
+ document.documentElement.style.overflow = 'hidden';
988
+ close.focus();
989
+ let settled = false;
990
+ const finish = (fn) => {
991
+ if (settled)
992
+ return;
993
+ settled = true;
994
+ clearTimeout(deadline);
995
+ window.removeEventListener('message', onMessage);
996
+ document.removeEventListener('keydown', onKey, true);
997
+ overlay.remove();
998
+ document.documentElement.style.overflow = previousOverflow;
999
+ if (previouslyFocused && typeof previouslyFocused.focus === 'function')
1000
+ previouslyFocused.focus();
1001
+ fn();
1002
+ };
1003
+ const dismiss = () => finish(() => resolve({ outcome: 'dismissed', session_id: session.consent_session_id }));
1004
+ const onMessage = (event) => {
1005
+ const decision = readDecisionMessage(event, {
1006
+ origin: consentOrigin,
1007
+ sessionId: session.consent_session_id,
1008
+ source: frame.contentWindow,
1009
+ });
1010
+ if (decision)
1011
+ finish(() => resolve(decision));
1012
+ };
1013
+ const onKey = (event) => {
1014
+ if (event.key === 'Escape') {
1015
+ event.preventDefault();
1016
+ dismiss();
1017
+ }
1018
+ };
1019
+ const deadline = setTimeout(() => finish(() => reject(new Error(`Consentera: the consent dialog had no decision within ${timeoutMs}ms. The session is still ` +
1020
+ 'valid — verify it server-side, or reopen it.'))), timeoutMs);
1021
+ close.addEventListener('click', dismiss);
1022
+ window.addEventListener('message', onMessage);
1023
+ document.addEventListener('keydown', onKey, true);
1024
+ });
1025
+ }
1026
+
1027
+ /**
1028
+ * ConsentEra Consent SDK — Session Management
1029
+ * Create consent sessions, retrieve artifacts, handle redirect flows
1030
+ */
1031
+ class ConsentSession {
1032
+ request;
1033
+ config;
1034
+ logger;
1035
+ /**
1036
+ * The callback_url each session was created with, in this page. The popup
1037
+ * needs it: /collect posts its decision to that URL's origin.
1038
+ */
1039
+ callbackUrls = new Map();
1040
+ constructor(request, config, logger) {
1041
+ this.request = request;
1042
+ this.config = config;
1043
+ this.logger = logger;
1044
+ }
1045
+ /**
1046
+ * Create a new consent session for consent collection.
1047
+ * Returns a session with consent_url for redirect/popup flow.
1048
+ *
1049
+ * createSession({
1050
+ * data_principal: { email: 'riya@example.in' },
1051
+ * notice_internal_name: 'bnb_consent_v2',
1052
+ * age: { date_of_birth: '1998-04-12' },
1053
+ * })
1054
+ *
1055
+ * Identity is `data_principal` — the identifiers, keyed by THIS
1056
+ * ORGANISATION'S locked integration key — or `data_principal_id`; the type of
1057
+ * {@link CreateSessionRequest} requires at least one of the two, because the
1058
+ * API refuses a request with neither.
1059
+ *
1060
+ * ─── THIS IS THE U58 WIRE, AND THERE IS NO OVERLAP WINDOW ────────────────
1061
+ *
1062
+ * `data_principal_ref`, `data_principal_ref_type`, `data_principal_details`,
1063
+ * `locale_pref`, `notice_language`, `template_language` and `purpose_ids` are
1064
+ * DELETED from the API's request struct. This SDK version talks to an API
1065
+ * carrying U58 and to no other, in both directions:
1066
+ *
1067
+ * old SDK -> new API the identifiers are dropped, then 400
1068
+ * new SDK -> old API the identifiers are dropped, then 400
1069
+ *
1070
+ * EVERY FIELD THIS BUILDS IS A FIELD THE API DECODES. The handler decodes
1071
+ * with a plain `json.Decoder` and NO `DisallowUnknownFields` — on BOTH wires
1072
+ * — so a key it does not know is dropped in silence rather than refused. That
1073
+ * is why the two wires cannot be mixed and why the failure above is a 400
1074
+ * about a MISSING identifier rather than about the field you sent. Check a
1075
+ * field against the request struct before adding it.
1076
+ */
1077
+ async createSession(params, options) {
1078
+ // THE CALLBACK STATE, minted here and nowhere else — before the call,
1079
+ // because callback_url is a REQUEST field. See withCallbackState.
1080
+ const requestedCallback = params.callback_url || this.config.callbackUrl;
1081
+ const state = requestedCallback ? newCallbackState() : undefined;
1082
+ const body = {
1083
+ ui_mode: params.ui_mode || 'redirect',
1084
+ callback_url: requestedCallback && state ? withCallbackState(requestedCallback, state) : undefined,
1085
+ };
1086
+ // The identifiers, by the tenant's own field names. Sent as-is: this SDK
1087
+ // does not know the key and must not guess at it — a field outside the key
1088
+ // is the API's 400 UNKNOWN_IDENTIFIER_FIELD, which can name the allowed
1089
+ // fields, and a guess here could only turn that into silence.
1090
+ if (params.data_principal && Object.keys(params.data_principal).length > 0) {
1091
+ body.data_principal = params.data_principal;
1092
+ }
1093
+ if (params.data_principal_id)
1094
+ body.data_principal_id = params.data_principal_id;
1095
+ if (params.age && params.age.date_of_birth) {
1096
+ body.age = { date_of_birth: params.age.date_of_birth };
1097
+ }
1098
+ // ONE language field. Only sent when a language was actually asked for: it
1099
+ // goes to the FRONT of the server's locale fallback chain, AHEAD of the
1100
+ // tenant's own default, so a hard-coded fallback here would override that
1101
+ // default for every caller who never set one.
1102
+ const language = params.language || this.config.language;
1103
+ if (language)
1104
+ body.language = language;
1105
+ if (params.session_ref)
1106
+ body.session_ref = params.session_ref;
1107
+ if (params.notice_internal_name)
1108
+ body.notice_internal_name = params.notice_internal_name;
1109
+ if (params.notice_version_number !== undefined) {
1110
+ body.notice_version_number = params.notice_version_number;
1111
+ }
1112
+ if (params.customer_token)
1113
+ body.customer_token = params.customer_token;
1114
+ // The guardian channel — top-level, and never inside data_principal, which
1115
+ // holds the identifiers of the person the consent is ABOUT. Either, not
1116
+ // both: one invitation goes to one address.
1117
+ if (params.guardian_email)
1118
+ body.guardian_email = params.guardian_email;
1119
+ if (params.guardian_phone)
1120
+ body.guardian_phone = params.guardian_phone;
1121
+ if (params.guardian_relationship) {
1122
+ body.guardian_relationship = params.guardian_relationship;
1123
+ }
1124
+ const response = await this.request('POST', '/consent/sessions', body, undefined, {
1125
+ // ONE key for this logical session creation, reused across the
1126
+ // transport's own retries so a retried create cannot mint two sessions
1127
+ // for one person.
1128
+ idempotencyKey: options?.idempotencyKey ?? newRequestId(),
1129
+ signal: options?.signal,
1130
+ timeoutMs: options?.timeoutMs,
1131
+ });
1132
+ // THE STORED HALF OF THE CALLBACK HANDSHAKE. CallbackHandler.parseCallback
1133
+ // reads this record back, compares the session id and the STATE this SDK
1134
+ // put on callback_url, and refuses anything that does not match. It is not
1135
+ // the server's challengeNonce: that is the hosted page's credential, it
1136
+ // rides consent_url, and the platform's return never carries it — storing
1137
+ // it here is what made every genuine web redirect come back `unverified`.
1138
+ const persisted = writeStored('session', storageKeys.session(response.consent_session_id), JSON.stringify({
1139
+ session_id: response.consent_session_id,
1140
+ ...(state ? { state } : {}),
1141
+ notice_hash: response.notice_hash,
1142
+ // The person this session is ABOUT. The callback handler compares it
1143
+ // with the artifact's data_principal_id, because the platform's 200
1144
+ // artifact read does not check the session (walk finding F077).
1145
+ data_principal_id: response.data_principal_id,
1146
+ created_at: new Date().toISOString(),
1147
+ }));
1148
+ if (!persisted) {
1149
+ // Storage is blocked; the handshake now lives in memory and will not
1150
+ // survive the redirect. Say so, because the callback will come back
1151
+ // `unverified` and the integrator needs to know why.
1152
+ this.logger.warn('Consentera: browser storage is unavailable, so the callback handshake cannot survive a ' +
1153
+ 'page navigation. Use ui_mode "popup", or verify the artifact server-side.');
1154
+ }
1155
+ if (typeof body.callback_url === 'string' && body.callback_url) {
1156
+ this.callbackUrls.set(response.consent_session_id, body.callback_url);
1157
+ }
1158
+ this.logger.info('Consent session created', {
1159
+ session_id: response.consent_session_id,
1160
+ });
1161
+ return response;
1162
+ }
1163
+ /**
1164
+ * Redirect the user to the ConsentEra consent collection widget.
1165
+ * Only works in browser environments.
1166
+ */
1167
+ redirectToConsent(session) {
1168
+ if (typeof window === 'undefined') {
1169
+ throw new Error('redirectToConsent can only be used in browser environments');
1170
+ }
1171
+ window.location.href = session.consent_url;
1172
+ }
1173
+ /**
1174
+ * Open the consent page in a dialog ON THIS PAGE and resolve with the
1175
+ * decision it reports, or `{ outcome: 'dismissed' }` when the person closes
1176
+ * it. Rejects after `timeoutMs` (default 10 minutes).
1177
+ *
1178
+ * const r = await consent.openConsentPopup(session);
1179
+ * if (r.outcome === 'decided') confirmOnServer(r.session_id, r.artifact_id);
1180
+ *
1181
+ * It listens for the ONE message /collect posts, `consentera:submitted` /
1182
+ * `consentera:declined`, from the /collect origin and the frame it opened.
1183
+ * See consent/consentPopup.ts for the wire and for why this is a dialog and
1184
+ * not window.open (a top-level /collect posts nothing) (SDK register WEB-035).
1185
+ *
1186
+ * THIS PAGE MUST BE ON THE CALLBACK'S ORIGIN: /collect addresses the message
1187
+ * to the session's callback_url origin. A session created by this SDK is
1188
+ * checked before anything opens (`CALLBACK_ORIGIN_MISMATCH`); pass
1189
+ * `callbackUrl` when your server set a different one. The page must also be
1190
+ * in your integration client's Allowed Domains, or the platform refuses to
1191
+ * be framed (frame-ancestors).
1192
+ *
1193
+ * The result is what the platform page reported, not the consent record:
1194
+ * confirm the artefact before acting on a grant.
1195
+ */
1196
+ openConsentPopup(session, options) {
1197
+ return openConsentDialog(session, {
1198
+ ...options,
1199
+ callbackUrl: options?.callbackUrl ?? this.callbackUrls.get(session.consent_session_id) ?? this.config.callbackUrl,
1200
+ });
1201
+ }
1202
+ /**
1203
+ * Read a consent artifact.
1204
+ *
1205
+ * const r = await consent.getArtifact(artifactId, { sessionId });
1206
+ * if (r.state === 'pending') retryIn(r.retryAfterMs); // 202: recorded, still being written
1207
+ * else use(r.artifact); // 200
1208
+ *
1209
+ * PASS THE SESSION ID the callback gave you. The platform answers 202 for a
1210
+ * consent whose artifact is still being written only when it can see which
1211
+ * session is asking; without it that consent is a 404, the same answer a
1212
+ * guessed id gets. A 404 WITH a session id means the artifact was never
1213
+ * issued for that session: it throws a ConsenteraNotFoundError.
1214
+ *
1215
+ * A 200 is not proof the artifact belongs to your session — the platform
1216
+ * checks the session only while the artifact is missing (walk finding F077).
1217
+ * Compare `artifact.data_principal_id` with your session's.
1218
+ */
1219
+ async getArtifact(artifactId, options) {
1220
+ return readArtifact(this.request, artifactId, options);
1221
+ }
1222
+ }
1223
+ /**
1224
+ * A fresh per-attempt callback state: 32 random bytes, hex. From the platform
1225
+ * CSPRNG only — there is no Math.random fallback, because a guessable state is
1226
+ * a forgeable callback. (`newRequestId` may fall back; this must not.)
1227
+ */
1228
+ function newCallbackState() {
1229
+ const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined;
1230
+ if (!c || typeof c.getRandomValues !== 'function') {
1231
+ throw configError('Consentera: no crypto.getRandomValues here, so no unguessable callback state can be made. ' +
1232
+ 'This SDK will not fall back to Math.random for it.', 'NO_SECURE_RANDOM');
1233
+ }
1234
+ return Array.from(c.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, '0')).join('');
1235
+ }
1236
+ /**
1237
+ * callback_url with this SDK's `state` added to its query.
1238
+ *
1239
+ * WHY IT SURVIVES THE ROUND TRIP — a platform fact, read on pre-main
1240
+ * 35cd853ac7: addRedirectParams appends session_id/artifact_id to callback_url
1241
+ * with `&` when it already has a `?` (redirect_params.go:17), and
1242
+ * setRedirectParam then parses, SETS status / pending / sig and re-encodes —
1243
+ * every other key, `state` included, is kept. The mobile SDKs have bound their
1244
+ * callbacks this way since 2.0.0.
1245
+ *
1246
+ * `state` IS RESERVED. A callback_url that already carries one is refused
1247
+ * rather than overwritten: overwriting would silently break the Data
1248
+ * Fiduciary's own use of it, and keeping theirs would bind nothing.
1249
+ */
1250
+ function withCallbackState(callbackUrl, state) {
1251
+ let u;
1252
+ try {
1253
+ u = new URL(callbackUrl);
1254
+ }
1255
+ catch {
1256
+ 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');
1257
+ }
1258
+ if (u.searchParams.has('state')) {
1259
+ throw configError('Consentera: callback_url already carries a `state` parameter. This SDK puts its own callback ' +
1260
+ 'state there to bind the return to this browser; use a different parameter name for yours.', 'CALLBACK_STATE_RESERVED');
1261
+ }
1262
+ u.searchParams.set('state', state);
1263
+ return u.toString();
1264
+ }
1265
+
1266
+ /**
1267
+ * ConsentEra Consent SDK — Context Helpers
1268
+ * Build client context and affirmative action payloads
1269
+ */
1270
+ /** Strip the query and fragment: the parts that carry the host's own data. */
1271
+ function pagePath(href) {
1272
+ try {
1273
+ const u = new URL(href);
1274
+ return `${u.origin}${u.pathname}`;
1275
+ }
1276
+ catch {
1277
+ return undefined;
1278
+ }
1279
+ }
1280
+ /**
1281
+ * Build client context from the current browser environment.
1282
+ *
1283
+ * @param sessionId optional consent session to correlate against
1284
+ * @param mode how much to collect; see {@link ContextMode}. Default `minimal`.
1285
+ */
1286
+ function buildClientContext(sessionId, mode = 'minimal') {
1287
+ if (mode === 'none')
1288
+ return undefined;
1289
+ if (typeof window === 'undefined') {
1290
+ return {
1291
+ user_agent: 'Consentera SDK (SSR)',
1292
+ platform: 'web',
1293
+ };
1294
+ }
1295
+ const ua = navigator.userAgent;
1296
+ let browserName = 'Unknown';
1297
+ let browserVersion = '';
1298
+ let osName = 'Unknown';
1299
+ let osVersion = '';
1300
+ let deviceType = 'desktop';
1301
+ // Browser detection
1302
+ if (ua.includes('Chrome') && !ua.includes('Edg')) {
1303
+ browserName = 'Chrome';
1304
+ browserVersion = ua.match(/Chrome\/([\d.]+)/)?.[1] || '';
1305
+ }
1306
+ else if (ua.includes('Safari') && !ua.includes('Chrome')) {
1307
+ browserName = 'Safari';
1308
+ browserVersion = ua.match(/Version\/([\d.]+)/)?.[1] || '';
1309
+ }
1310
+ else if (ua.includes('Firefox')) {
1311
+ browserName = 'Firefox';
1312
+ browserVersion = ua.match(/Firefox\/([\d.]+)/)?.[1] || '';
1313
+ }
1314
+ else if (ua.includes('Edg')) {
1315
+ browserName = 'Edge';
1316
+ browserVersion = ua.match(/Edg\/([\d.]+)/)?.[1] || '';
1317
+ }
1318
+ // OS detection
1319
+ if (ua.includes('Windows')) {
1320
+ osName = 'Windows';
1321
+ osVersion = ua.match(/Windows NT ([\d.]+)/)?.[1] || '';
1322
+ }
1323
+ else if (ua.includes('Mac OS X')) {
1324
+ osName = 'macOS';
1325
+ osVersion = ua.match(/Mac OS X ([\d_.]+)/)?.[1]?.replace(/_/g, '.') || '';
1326
+ }
1327
+ else if (ua.includes('Android')) {
1328
+ osName = 'Android';
1329
+ osVersion = ua.match(/Android ([\d.]+)/)?.[1] || '';
1330
+ }
1331
+ else if (ua.includes('iPhone') || ua.includes('iPad')) {
1332
+ osName = 'iOS';
1333
+ osVersion = ua.match(/OS ([\d_]+)/)?.[1]?.replace(/_/g, '.') || '';
1334
+ }
1335
+ else if (ua.includes('Linux')) {
1336
+ osName = 'Linux';
1337
+ }
1338
+ // Device type
1339
+ if (/Mobi|Android.*Mobile|iPhone/.test(ua)) {
1340
+ deviceType = 'mobile';
1341
+ }
1342
+ else if (/iPad|Android(?!.*Mobile)|Tablet/.test(ua)) {
1343
+ deviceType = 'tablet';
1344
+ }
1345
+ const minimal = {
1346
+ platform: 'web',
1347
+ device_type: deviceType,
1348
+ browser_name: browserName,
1349
+ os_name: osName,
1350
+ session_id: sessionId,
1351
+ };
1352
+ if (mode === 'minimal')
1353
+ return minimal;
1354
+ return {
1355
+ ...minimal,
1356
+ user_agent: ua,
1357
+ screen_resolution: `${window.screen.width}x${window.screen.height}`,
1358
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1359
+ browser_version: browserVersion,
1360
+ os_version: osVersion,
1361
+ page_url: pagePath(window.location.href),
1362
+ referrer: document.referrer ? pagePath(document.referrer) : undefined,
1363
+ };
1364
+ }
1365
+ /**
1366
+ * Build an affirmative action payload.
1367
+ */
1368
+ function buildAffirmativeAction(uiEventId, type = 'button') {
1369
+ return {
1370
+ type,
1371
+ ui_event_id: uiEventId,
1372
+ captured_at: new Date().toISOString(),
1373
+ };
1374
+ }
1375
+ /**
1376
+ * Turn a {@link PrincipalRef} into the request-body fields that name the
1377
+ * person. Sends exactly ONE of the two keys, never both and never an empty
1378
+ * object.
1379
+ *
1380
+ * NEVER BOTH, and that is not tidiness: the API refuses a request carrying
1381
+ * `data_principal_ref` even when `data_principal_id` is also present, for the
1382
+ * stated reason that two fields naming a person can disagree and the caller
1383
+ * would never learn which one the answer was about. Sending one key keeps this
1384
+ * SDK on the right side of that rule by construction.
1385
+ */
1386
+ function principalBody(who) {
1387
+ if ('data_principal_id' in who && who.data_principal_id) {
1388
+ return { data_principal_id: who.data_principal_id };
1389
+ }
1390
+ const ids = who.data_principal_identifiers;
1391
+ if (!ids || Object.keys(ids).length === 0) {
1392
+ // The identifier FIELDS are the tenant's own locked integration key, which
1393
+ // this SDK cannot know and does not enumerate (F015): a message listing a
1394
+ // fixed five would name fields this organisation may not use and omit ones
1395
+ // it does. The SERVER lists the key's actual fields in its
1396
+ // UNKNOWN_IDENTIFIER_FIELD refusal; here we only say WHERE the fields come
1397
+ // from.
1398
+ throw new Error('Consentera: name the Data Principal with data_principal_id, or with ' +
1399
+ 'data_principal_identifiers carrying the identifier fields of this ' +
1400
+ 'organisation\'s integration key. data_principal_ref is refused by the API.');
1401
+ }
1402
+ return { data_principal_identifiers: ids };
1403
+ }
1404
+
1405
+ /**
1406
+ * ConsentEra Consent SDK — Consent Validation
1407
+ * Validate consent status for single or multiple purposes
1408
+ */
1409
+ class ConsentValidator {
1410
+ request;
1411
+ logger;
1412
+ constructor(request, logger) {
1413
+ this.request = request;
1414
+ this.logger = logger;
1415
+ }
1416
+ /**
1417
+ * Validate consent for a single purpose.
1418
+ * Returns ALLOW or DENY with reason code.
1419
+ *
1420
+ * check({ data_principal_identifiers: { email: 'riya@example.in' } },
1421
+ * 'product_analytics')
1422
+ * check({ data_principal_id: '…' }, 'product_analytics')
1423
+ *
1424
+ * ─── data_principal_ref IS REFUSED OUTRIGHT ────────────────────────────
1425
+ *
1426
+ * Owner ruling 2026-09-21, no transition period
1427
+ * (consent/lifecycle_identity.go:8-11). A request carrying it is answered
1428
+ * 400 VALIDATION_ERROR whose message begins `DATA_PRINCIPAL_REF_REFUSED`,
1429
+ * and the refusal fires even when `data_principal_id` is also present.
1430
+ *
1431
+ * NAME PEOPLE BY THE ORGANISATION'S LOCKED KEY FIELDS (F015): the mobile atom
1432
+ * is `mobile` on this road AND on create — `phone` is refused by name. See
1433
+ * {@link DataPrincipalIdentifiers}.
1434
+ */
1435
+ async check(who, purposeCode, options) {
1436
+ const body = { ...principalBody(who), purpose_code: purposeCode };
1437
+ const response = await this.request('POST', '/consent/validate', body, undefined, { signal: options?.signal, timeoutMs: options?.timeoutMs });
1438
+ // Never log the identifiers themselves — they are raw PII, and this line
1439
+ // used to carry the opaque handle verbatim. Log WHICH way the person was
1440
+ // named, which is what a support question actually needs.
1441
+ this.logger.debug('Consent validated', {
1442
+ namedBy: 'data_principal_id' in who ? 'data_principal_id' : 'data_principal_identifiers',
1443
+ purpose: purposeCode,
1444
+ decision: response.decision,
1445
+ });
1446
+ return response;
1447
+ }
1448
+ /**
1449
+ * Validate consent for multiple purposes at once.
1450
+ * Returns results map indexed by purpose code.
1451
+ *
1452
+ * `data_principal_ref` AND `data_principal_refs` are both refused here
1453
+ * (consent/validate.go:249,257).
1454
+ */
1455
+ async checkBulk(who, purposeCodes, options) {
1456
+ const body = {
1457
+ ...principalBody(who),
1458
+ purpose_codes: purposeCodes,
1459
+ };
1460
+ const response = await this.request('POST', '/consent/validate/bulk', body, undefined, { signal: options?.signal, timeoutMs: options?.timeoutMs });
1461
+ this.logger.debug('Bulk consent validated', {
1462
+ namedBy: 'data_principal_id' in who ? 'data_principal_id' : 'data_principal_identifiers',
1463
+ purposes: purposeCodes.length,
1464
+ results: response.results?.map((r) => `${r.purpose_code}:${r.decision}`),
1465
+ });
1466
+ return response;
1467
+ }
1468
+ /**
1469
+ * Quick boolean check — is this purpose allowed?
1470
+ *
1471
+ * isAllowed({ data_principal_identifiers: { email: 'riya@example.in' } },
1472
+ * 'product_analytics')
1473
+ */
1474
+ async isAllowed(who, purposeCode, options) {
1475
+ const result = await this.check(who, purposeCode, options);
1476
+ return result.decision === 'ALLOW';
1477
+ }
1478
+ /**
1479
+ * Check multiple purposes and return a map of purpose → boolean.
1480
+ */
1481
+ async areAllowed(who, purposeCodes) {
1482
+ const response = await this.checkBulk(who, purposeCodes);
1483
+ const map = new Map();
1484
+ for (const result of response.results || []) {
1485
+ // purpose_code is omitempty on the wire; a result without one is keyed
1486
+ // by its purpose_id rather than dropped under an `undefined` key.
1487
+ map.set(result.purpose_code ?? result.purpose_id, result.decision === 'ALLOW');
1488
+ }
1489
+ return map;
1490
+ }
1491
+ }
1492
+
1493
+ /**
1494
+ * ConsentEra Consent SDK — Consent Lifecycle Manager
1495
+ * Update, withdraw, and renew consent decisions
1496
+ */
1497
+ class ConsentManager {
1498
+ request;
1499
+ logger;
1500
+ /**
1501
+ * Called after every successful consent mutation with
1502
+ * {dataPrincipalId, changes:[{purpose_id,status}], source}. The client wires this
1503
+ * to its EventEmitter as the public `consent.changed` event — downstream
1504
+ * code (tracker gates, Consent Mode bridges) subscribes instead of polling.
1505
+ */
1506
+ onChanged;
1507
+ contextMode;
1508
+ constructor(request, logger, options) {
1509
+ this.request = request;
1510
+ this.logger = logger;
1511
+ this.contextMode = options?.contextMode ?? 'minimal';
1512
+ }
1513
+ /** The client context for a mutation, at whatever detail the host allowed. */
1514
+ context() {
1515
+ return buildClientContext(undefined, this.contextMode);
1516
+ }
1517
+ /** One key per logical operation, reused by the transport across its retries. */
1518
+ mutation(options) {
1519
+ return {
1520
+ idempotencyKey: options?.idempotencyKey ?? newRequestId(),
1521
+ signal: options?.signal,
1522
+ timeoutMs: options?.timeoutMs,
1523
+ };
1524
+ }
1525
+ emitChanged(dataPrincipalId, changes, source) {
1526
+ try {
1527
+ this.onChanged?.({ dataPrincipalId, changes, source });
1528
+ }
1529
+ catch (e) {
1530
+ this.logger.warn('consent.changed listener threw', { error: String(e) });
1531
+ }
1532
+ }
1533
+ // =========================================================================
1534
+ // Consent Update
1535
+ // =========================================================================
1536
+ /**
1537
+ * Get the current consent context for a data principal.
1538
+ * Returns all consents with their current status.
1539
+ *
1540
+ * ─── THIS ROAD TAKES THE ID AND NOTHING ELSE ──────────────────────────
1541
+ *
1542
+ * It is a GET, so the only place an identifier could go is the query string,
1543
+ * and the platform refuses to put one there: a query string is written
1544
+ * VERBATIM into the access log of every hop that sees the request line, kept
1545
+ * in browser history, and sent onward in the Referer header
1546
+ * (consent/lifecycle_identity.go:120-133). So this road was RESTRICTED to
1547
+ * `data_principal_id`, not converted to identifiers the way the POST roads
1548
+ * were — `?data_principal_ref=` is refused `DATA_PRINCIPAL_REF_REFUSED`
1549
+ * even when `data_principal_id` is also present.
1550
+ *
1551
+ * IF YOU HOLD AN IDENTIFIER AND NOT THE ID: resolve it once with
1552
+ * `validate.check({ data_principal_identifiers: … }, purpose)` and read
1553
+ * `data_principal_id` off that response, then call this with the id.
1554
+ *
1555
+ * The parameter used to be `dataPrincipalIdOrRef` and guessed by UUID shape:
1556
+ * anything not UUID-shaped went out as `data_principal_ref`, which this road
1557
+ * now refuses. The guess is gone — a non-UUID is rejected here, by name.
1558
+ */
1559
+ async getUpdateContext(dataPrincipalId, queryParams) {
1560
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(dataPrincipalId)) {
1561
+ throw new Error('Consentera: getUpdateContext takes a data_principal_id (a uuid), not an identifier. ' +
1562
+ 'This road deliberately accepts no identifiers — a query string is written verbatim ' +
1563
+ 'into access logs, browser history and Referer headers. Resolve the id once with ' +
1564
+ 'validate.check({ data_principal_identifiers: … }, purpose) and pass the ' +
1565
+ 'data_principal_id from its response.');
1566
+ }
1567
+ const params = { data_principal_id: dataPrincipalId };
1568
+ if (queryParams?.language_code)
1569
+ params.language_code = queryParams.language_code;
1570
+ return this.request('GET', '/consent/update/context', undefined, params);
1571
+ }
1572
+ /**
1573
+ * Update consent decisions for a data principal.
1574
+ * Pass an array of purpose updates (grant or deny).
1575
+ */
1576
+ async update(dataPrincipalId, updates, context, uiEventId = 'btn_save_preferences', options) {
1577
+ const body = {
1578
+ data_principal_id: dataPrincipalId,
1579
+ notice_version_id: context.noticeVersionId,
1580
+ notice_hash: context.noticeHash,
1581
+ language_code: context.languageCode || 'en',
1582
+ updates,
1583
+ affirmative_action: buildAffirmativeAction(uiEventId),
1584
+ client_context: this.context(),
1585
+ };
1586
+ await this.request('POST', '/consent/update', body, undefined, this.mutation(options));
1587
+ this.logger.info('Consent updated', {
1588
+ dataPrincipal: dataPrincipalId,
1589
+ updates: updates.map((u) => `${u.purpose_id}:${u.new_status}`),
1590
+ });
1591
+ this.emitChanged(dataPrincipalId, updates.map((u) => ({ purpose_id: u.purpose_id, status: u.new_status })), 'update');
1592
+ }
1593
+ /**
1594
+ * Convenience: Grant specific purposes.
1595
+ */
1596
+ async grant(dataPrincipalId, purposeIds, context, uiEventId = 'btn_grant_consent', options) {
1597
+ const updates = purposeIds.map((id) => ({
1598
+ purpose_id: id,
1599
+ new_status: 'granted',
1600
+ }));
1601
+ return this.update(dataPrincipalId, updates, context, uiEventId, options);
1602
+ }
1603
+ /**
1604
+ * Convenience: Deny specific purposes.
1605
+ */
1606
+ async deny(dataPrincipalId, purposeIds, context, uiEventId = 'btn_deny_consent', options) {
1607
+ const updates = purposeIds.map((id) => ({
1608
+ purpose_id: id,
1609
+ new_status: 'denied',
1610
+ }));
1611
+ return this.update(dataPrincipalId, updates, context, uiEventId, options);
1612
+ }
1613
+ // =========================================================================
1614
+ // Consent Withdrawal
1615
+ // =========================================================================
1616
+ /**
1617
+ * Get the withdrawal context — which consents can be withdrawn.
1618
+ */
1619
+ async getWithdrawalContext(dataPrincipalId) {
1620
+ return this.request('GET', '/consent/withdrawal/context', undefined, { data_principal_id: dataPrincipalId });
1621
+ }
1622
+ /**
1623
+ * Withdraw consent for specific purposes.
1624
+ */
1625
+ async withdraw(dataPrincipalId, purposeIds, reason, uiEventId = 'btn_withdraw_consent', options) {
1626
+ const body = {
1627
+ data_principal_id: dataPrincipalId,
1628
+ purposes: purposeIds,
1629
+ reason,
1630
+ affirmative_action: buildAffirmativeAction(uiEventId),
1631
+ client_context: this.context(),
1632
+ };
1633
+ await this.request('POST', '/consent/withdraw', body, undefined, this.mutation(options));
1634
+ this.logger.info('Consent withdrawn', {
1635
+ dataPrincipal: dataPrincipalId,
1636
+ purposes: purposeIds,
1637
+ });
1638
+ this.emitChanged(dataPrincipalId, purposeIds.map((p) => ({ purpose_id: p, status: 'withdrawn' })), 'withdraw');
1639
+ }
1640
+ /**
1641
+ * Bulk withdraw consent for multiple purposes.
1642
+ */
1643
+ async withdrawBulk(dataPrincipalId, purposeIds, reason, uiEventId = 'btn_withdraw_all', options) {
1644
+ const body = {
1645
+ data_principal_id: dataPrincipalId,
1646
+ purposes: purposeIds,
1647
+ reason,
1648
+ affirmative_action: buildAffirmativeAction(uiEventId),
1649
+ client_context: this.context(),
1650
+ };
1651
+ await this.request('POST', '/consent/withdraw/bulk', body, undefined, this.mutation(options));
1652
+ this.logger.info('Bulk consent withdrawn', {
1653
+ dataPrincipal: dataPrincipalId,
1654
+ purposes: purposeIds.length,
1655
+ });
1656
+ this.emitChanged(dataPrincipalId, purposeIds.map((p) => ({ purpose_id: p, status: 'withdrawn' })), 'withdraw_bulk');
1657
+ }
1658
+ /**
1659
+ * Get withdrawal analytics (reasons, trends).
1660
+ */
1661
+ async getWithdrawalAnalytics() {
1662
+ return this.request('GET', '/consent/withdrawal/analytics');
1663
+ }
1664
+ // =========================================================================
1665
+ // Consent Renewal
1666
+ // =========================================================================
1667
+ /**
1668
+ * Get the renewal context — which consents are expiring and need renewal.
1669
+ */
1670
+ async getRenewalContext(dataPrincipalId) {
1671
+ return this.request('GET', '/consent/renewal/context', undefined, { data_principal_id: dataPrincipalId });
1672
+ }
1673
+ /**
1674
+ * Renew consent for specific purposes.
1675
+ */
1676
+ async renew(dataPrincipalId, purposeIds, noticeHash, uiEventId = 'btn_renew_consent', options) {
1677
+ const body = {
1678
+ data_principal_id: dataPrincipalId,
1679
+ purpose_ids: purposeIds,
1680
+ notice_hash: noticeHash,
1681
+ captured_at: buildAffirmativeAction(uiEventId).captured_at,
1682
+ client_context: this.context(),
1683
+ };
1684
+ const response = await this.request('POST', '/consent/renew', body, undefined, { ...this.mutation(options), raw: true });
1685
+ this.logger.info('Consent renewed', {
1686
+ dataPrincipal: dataPrincipalId,
1687
+ purposes: purposeIds,
1688
+ });
1689
+ this.emitChanged(dataPrincipalId, response.body.renewed_consents.map((p) => ({ purpose_id: p.purpose_id, status: 'granted' })), 'renew');
1690
+ return response;
1691
+ }
1692
+ /**
1693
+ * Bulk renew all expiring consents.
1694
+ */
1695
+ async renewBulk(dataPrincipalId, purposeIds, noticeHash, uiEventId = 'btn_renew_all', options) {
1696
+ const body = {
1697
+ data_principal_id: dataPrincipalId,
1698
+ renewals: purposeIds.map(purpose_id => ({ purpose_id })),
1699
+ notice_hash: noticeHash,
1700
+ captured_at: buildAffirmativeAction(uiEventId).captured_at,
1701
+ client_context: this.context(),
1702
+ };
1703
+ const response = await this.request('POST', '/consent/renew/bulk', body, undefined, { ...this.mutation(options), raw: true });
1704
+ this.logger.info('Bulk consent renewed', {
1705
+ dataPrincipal: dataPrincipalId,
1706
+ purposes: response.body.success_count,
1707
+ });
1708
+ if (response.body.renewed_consents.length > 0) {
1709
+ this.emitChanged(dataPrincipalId, response.body.renewed_consents.map((p) => ({
1710
+ purpose_id: p.purpose_id, status: 'granted',
1711
+ })), 'renew');
1712
+ }
1713
+ return response;
1714
+ }
1715
+ }
1716
+
1717
+ /**
1718
+ * Consentera Consent SDK — Callback Handler
1719
+ *
1720
+ * ─── THIS ROAD FAILS CLOSED, AND BEFORE 2.0.0 IT DID NOT ───────────────────
1721
+ *
1722
+ * The 1.x handler read `session_id`, `artifact_id` and `status` out of
1723
+ * `window.location.search`, looked up the stored session, logged
1724
+ * "Callback verified against stored session" — and then verified NOTHING. It
1725
+ * never parsed the record it had stored, never compared the nonce, never
1726
+ * compared the notice hash, and returned `status: 'completed'` for any URL
1727
+ * carrying an `artifact_id` (the `normalizeStatus` default). A link to
1728
+ *
1729
+ * https://your-site.example/consent/done?session_id=x&artifact_id=y
1730
+ *
1731
+ * made `consent.handleCallback()` answer "completed" with no consent given.
1732
+ * The log line said "verified" the whole time, which is why it survived review.
1733
+ *
1734
+ * What replaces it:
1735
+ *
1736
+ * 1. The stored record is PARSED. Its session id must equal the one in the
1737
+ * URL, and the `state` THIS SDK minted and put on callback_url at create
1738
+ * must come back on the return. A mismatch, an absence, or an
1739
+ * unparseable record is `unverified`.
1740
+ *
1741
+ * IT IS THE SDK'S OWN STATE, NOT THE SERVER'S challengeNonce. Until the
1742
+ * callback-contract fix this compared the create response's
1743
+ * `challengeNonce` with a `nonce` on the return — and the platform's
1744
+ * return never carries one (collection.go:4225-4288 adds session_id,
1745
+ * artifact_id, status, pending and, when signed, sig). challengeNonce is
1746
+ * the hosted page's own credential: it rides consent_url
1747
+ * (`/collect/<id>?nonce=…`, :2010) and comes back on the page's submit
1748
+ * (:3509-3543). So every genuine web redirect came back `unverified`.
1749
+ * The state survives because the platform builds the return ON TOP of
1750
+ * callback_url's existing query — the mobile SDKs' binding, the same way.
1751
+ * 2. `completed` is only ever reached by CONFIRMING THE ARTIFACT with the
1752
+ * platform — `GET /consent/artifacts/{id}` through the same road the rest
1753
+ * of the SDK uses, which in a browser is the Data Fiduciary's proxy. The
1754
+ * URL is a claim; the artifact is the evidence.
1755
+ * 3. Everything else is `unverified`. There is no path from a query string to
1756
+ * `completed`.
1757
+ *
1758
+ * `parseCallback` is therefore ASYNC now. The synchronous
1759
+ * {@link CallbackHandler.readCallbackParams} is kept for a caller that only
1760
+ * wants to know what the URL says — it is named so that using it as a decision
1761
+ * is visibly the wrong thing.
1762
+ */
1763
+ const PLATFORM_CALLBACK_STATUSES = new Set(['granted', 'partial', 'denied']);
1764
+ /** Map the raw `status` query value onto the platform's vocabulary. Exact match: the server writes lowercase. */
1765
+ function claimedCallbackStatus(raw) {
1766
+ return raw && PLATFORM_CALLBACK_STATUSES.has(raw) ? raw : 'unknown';
1767
+ }
1768
+ class CallbackHandler {
1769
+ logger;
1770
+ request;
1771
+ options;
1772
+ constructor(logger, request, options = {}) {
1773
+ this.logger = logger;
1774
+ this.request = request;
1775
+ this.options = options;
1776
+ }
1777
+ /**
1778
+ * Read the callback URL's claims. THIS IS NOT A DECISION — the values come
1779
+ * from the address bar and anyone can type them. Use {@link parseCallback}.
1780
+ */
1781
+ readCallbackParams(searchParams) {
1782
+ let params;
1783
+ if (!searchParams) {
1784
+ if (typeof window === 'undefined') {
1785
+ throw new Error('readCallbackParams requires searchParams in non-browser environments');
1786
+ }
1787
+ params = new URLSearchParams(window.location.search);
1788
+ }
1789
+ else if (typeof searchParams === 'string') {
1790
+ params = new URLSearchParams(searchParams);
1791
+ }
1792
+ else {
1793
+ params = searchParams;
1794
+ }
1795
+ return {
1796
+ session_id: params.get('session_id') || params.get('consent_session_id') || '',
1797
+ artifact_id: params.get('artifact_id') || params.get('consent_artifact_id') || undefined,
1798
+ raw_status: params.get('status') || '',
1799
+ claimed_status: claimedCallbackStatus(params.get('status')),
1800
+ pending: params.get('pending') === '1',
1801
+ state: params.get('state') || undefined,
1802
+ // The platform's HMAC over session|artifact|status; present only when the
1803
+ // DF registered a callback_signing_secret (collection.go:4280-4288).
1804
+ sig: params.get('sig') || undefined,
1805
+ error: params.get('error') || undefined,
1806
+ };
1807
+ }
1808
+ /**
1809
+ * Verify a consent callback. Resolves `completed` ONLY when the stored
1810
+ * session matches and the artifact was confirmed with the platform.
1811
+ */
1812
+ async parseCallback(searchParams) {
1813
+ const claim = this.readCallbackParams(searchParams);
1814
+ const base = {
1815
+ session_id: claim.session_id,
1816
+ artifact_id: claim.artifact_id,
1817
+ claimed_status: claim.claimed_status,
1818
+ claimed_pending: claim.pending,
1819
+ status: 'unverified',
1820
+ };
1821
+ if (claim.error) {
1822
+ return this.finish({ ...base, status: 'error', error: claim.error, reason: 'the platform returned an error' });
1823
+ }
1824
+ if (!claim.session_id) {
1825
+ return this.finish({ ...base, status: 'unverified', reason: 'the callback names no session' });
1826
+ }
1827
+ // 1. The stored side of the handshake.
1828
+ const raw = readStored('session', storageKeys.session(claim.session_id));
1829
+ if (!raw) {
1830
+ return this.finish({
1831
+ ...base,
1832
+ reason: 'no session was stored in this browser for that id — the session was created elsewhere, ' +
1833
+ 'the tab was replaced, or the callback was not produced by this flow',
1834
+ });
1835
+ }
1836
+ let stored;
1837
+ try {
1838
+ stored = JSON.parse(raw);
1839
+ }
1840
+ catch {
1841
+ removeStored('session', storageKeys.session(claim.session_id));
1842
+ return this.finish({ ...base, reason: 'the stored session record could not be parsed' });
1843
+ }
1844
+ removeStored('session', storageKeys.session(claim.session_id));
1845
+ if (stored.session_id && stored.session_id !== claim.session_id) {
1846
+ return this.finish({ ...base, reason: 'the stored session id does not match the callback' });
1847
+ }
1848
+ // The state is the anti-forgery binding: this SDK minted it at create, put
1849
+ // it on callback_url, and only a real return trip to THIS browser carries
1850
+ // it back. It is REQUIRED — a record without one cannot bind anything.
1851
+ if (!stored.state) {
1852
+ return this.finish({
1853
+ ...base,
1854
+ reason: 'the stored session has no callback state: it was created without a callback_url, or by an ' +
1855
+ 'SDK version that bound the callback to the server challengeNonce (which the platform never ' +
1856
+ 'returns). Read the consent back through your backend instead.',
1857
+ });
1858
+ }
1859
+ if (!claim.state) {
1860
+ return this.finish({ ...base, reason: 'the callback carries no state, and the session was created with one' });
1861
+ }
1862
+ if (!timingSafeEqual(stored.state, claim.state)) {
1863
+ return this.finish({ ...base, reason: 'the callback state does not match the stored session' });
1864
+ }
1865
+ // 2. THE PLATFORM'S SIGNATURE, when it sent one.
1866
+ //
1867
+ // A `sig` on the URL means the DF registered a callback_signing_secret and
1868
+ // the platform signed status + artifact with it. Ignoring it would make the
1869
+ // signature decorative, so a sig that cannot be checked is `unverified` —
1870
+ // the SDK does not fall back to "well, the state matched".
1871
+ let signature = 'absent';
1872
+ if (claim.sig) {
1873
+ if (!this.options.verifySignature) {
1874
+ return this.finish({
1875
+ ...base,
1876
+ signature: 'unverifiable',
1877
+ reason: 'the callback is signed and no verifier is configured. The signing key is your ' +
1878
+ 'callback_signing_secret, which must not be in a browser: set ' +
1879
+ '`verifyCallbackSignature` to call your own server, which uses ' +
1880
+ 'verifyCallbackSignature(secret, …) from this package.',
1881
+ });
1882
+ }
1883
+ let ok = false;
1884
+ try {
1885
+ ok = await this.options.verifySignature({
1886
+ sessionId: claim.session_id,
1887
+ artifactId: claim.artifact_id ?? '',
1888
+ status: claim.raw_status,
1889
+ sig: claim.sig,
1890
+ });
1891
+ }
1892
+ catch (err) {
1893
+ return this.finish({
1894
+ ...base,
1895
+ signature: 'unverifiable',
1896
+ reason: `the signature verifier threw: ${String(err)}`,
1897
+ });
1898
+ }
1899
+ if (!ok) {
1900
+ return this.finish({ ...base, signature: 'invalid', reason: 'the callback signature did not verify' });
1901
+ }
1902
+ signature = 'verified';
1903
+ }
1904
+ // 3. The platform's vocabulary, exactly: granted | partial | denied
1905
+ // (collection.go:4094-4098). A denial is a real, verified outcome — no
1906
+ // artifact exists to confirm, and the state above proved the round trip.
1907
+ // Anything else did not come from the platform and goes nowhere near the
1908
+ // success road: before this, `completed`, `success`, a missing status or
1909
+ // any other value fell through to the artifact read and could come back
1910
+ // `completed`, while `expired`/`rejected`/`timeout` — which the platform
1911
+ // never sends — had branches of their own.
1912
+ if (claim.claimed_status === 'denied') {
1913
+ return this.finish({ ...base, status: 'denied', signature });
1914
+ }
1915
+ if (claim.claimed_status === 'unknown') {
1916
+ return this.finish({
1917
+ ...base,
1918
+ signature,
1919
+ reason: `the callback status ${JSON.stringify(claim.raw_status)} is not a status the platform issues ` +
1920
+ '(granted | partial | denied). Read the consent back through your backend.',
1921
+ });
1922
+ }
1923
+ // 4. granted | partial: `completed` requires the artifact, from the
1924
+ // platform, not the URL. pending=1 (always set on the capture road)
1925
+ // means the first read may be a 202; confirmArtifact waits for it.
1926
+ if (!claim.artifact_id) {
1927
+ return this.finish({ ...base, signature, reason: 'the callback claims success but names no artifact' });
1928
+ }
1929
+ if (!this.request) {
1930
+ return this.finish({
1931
+ ...base,
1932
+ signature,
1933
+ reason: 'no transport available to confirm the artifact. Construct the handler through ' +
1934
+ 'ConsentEraClient so it can call GET /consent/artifacts/{id}.',
1935
+ });
1936
+ }
1937
+ return this.confirmArtifact(base, claim.artifact_id, claim.session_id, stored.data_principal_id, signature);
1938
+ }
1939
+ /**
1940
+ * Read the artifact back FOR THIS SESSION, waiting while the platform says
1941
+ * it is still being written.
1942
+ *
1943
+ * THE READ CARRIES ?session_id=, AND THAT DECIDES WHAT A 404 MEANS (WEB-033).
1944
+ * With it the platform answers 202 + Retry-After while the projection is
1945
+ * owed and 404 only when the id was never issued for this session (or will
1946
+ * never be written). Until WEB-033 the read went out without it, so every
1947
+ * absence was a 404, and this method polled every 404 as "maybe pending" — a
1948
+ * forged artifact_id came back `pending` instead of refused.
1949
+ *
1950
+ * 200 the artifact. Then it must name the person this browser's session
1951
+ * was created for (below). Then `completed`.
1952
+ * 202 recorded, not readable yet: wait Retry-After inside the budget, else
1953
+ * `pending`.
1954
+ * 404 `unverified`, at once. Not polled: it is an answer, not a delay.
1955
+ * else `unverified` (403, network, timeout — unproven is unproven).
1956
+ *
1957
+ * THE BINDING IS THE PERSON, NOT A RESPONSE FIELD (WEB-034). This method used
1958
+ * to compare `consent_session_id` on the artifact with the callback's
1959
+ * session. The platform's artifact carries no such field (measured on
1960
+ * setup.consentera.in, fixtures/platform-wire/artifact-read.200.*.json), so
1961
+ * the check was skipped on every real response and bound nothing. What the
1962
+ * artifact does carry is `data_principal_id`, and the session create
1963
+ * returned the same field for the person the session is about. They must be
1964
+ * equal. This matters because the platform's 200 path does not check the
1965
+ * session (walk finding F077, measured: the same artifact read with a random
1966
+ * session_id is still 200), so without it a callback carrying someone
1967
+ * else's artifact_id would confirm.
1968
+ */
1969
+ async confirmArtifact(base, artifactId, sessionId, expectedPrincipal, signature) {
1970
+ const budgetMs = this.options.artifactWaitMs ?? 15_000;
1971
+ const deadline = Date.now() + budgetMs;
1972
+ for (;;) {
1973
+ let read;
1974
+ try {
1975
+ read = await readArtifact(this.request, artifactId, { sessionId });
1976
+ }
1977
+ catch (err) {
1978
+ const detail = err instanceof ConsenteraError ? err.toString() : String(err);
1979
+ if (err instanceof ConsenteraError && (err.status === 404 || err.kind === 'not_found')) {
1980
+ return this.finish({
1981
+ ...base,
1982
+ signature,
1983
+ reason: `the platform has no artifact ${artifactId} for session ${sessionId}: it was never ` +
1984
+ 'issued for this session, or will never be written. A forged or mismatched ' +
1985
+ `artifact_id reads exactly like this. (${detail})`,
1986
+ });
1987
+ }
1988
+ // A 403, a network failure, anything else: unproven, so unverified.
1989
+ return this.finish({ ...base, signature, reason: `the artifact could not be confirmed: ${detail}` });
1990
+ }
1991
+ if (read.state === 'recorded') {
1992
+ const artifact = read.artifact;
1993
+ if (artifact.artifact_id && artifact.artifact_id !== artifactId) {
1994
+ return this.finish({ ...base, signature, reason: 'the platform returned a different artifact than the one read' });
1995
+ }
1996
+ if (!expectedPrincipal) {
1997
+ return this.finish({
1998
+ ...base,
1999
+ signature,
2000
+ reason: 'the stored session names no data_principal_id, so the artifact cannot be tied to ' +
2001
+ 'the person this session was created for. Create the session with this SDK version, ' +
2002
+ 'or confirm the artifact server-side.',
2003
+ });
2004
+ }
2005
+ if (artifact.data_principal_id !== expectedPrincipal) {
2006
+ return this.finish({
2007
+ ...base,
2008
+ signature,
2009
+ reason: "the artifact names a different Data Principal from this browser's session — it is " +
2010
+ 'not this consent',
2011
+ });
2012
+ }
2013
+ return this.finish({ ...base, status: 'completed', artifact, signature });
2014
+ }
2015
+ // 202: recorded, still being written.
2016
+ const remaining = deadline - Date.now();
2017
+ if (remaining <= 0 || read.retryAfterMs > remaining) {
2018
+ return this.finish({
2019
+ ...base,
2020
+ status: 'pending',
2021
+ signature,
2022
+ retryAfterMs: read.retryAfterMs,
2023
+ reason: `the platform says the consent was recorded and its artifact is still being written ` +
2024
+ `(${read.pending.reason || 'pending'}); read it again in ${read.retryAfterMs}ms. ` +
2025
+ 'This is NOT a failure and NOT a consent that did not happen.',
2026
+ });
2027
+ }
2028
+ await new Promise((r) => setTimeout(r, read.retryAfterMs));
2029
+ }
2030
+ }
2031
+ /** Is this page a consent callback? Says nothing about whether it is genuine. */
2032
+ isCallback(searchParams) {
2033
+ const params = new URLSearchParams(searchParams || (typeof window !== 'undefined' ? window.location.search : ''));
2034
+ return params.has('session_id') || params.has('consent_session_id');
2035
+ }
2036
+ /** Record the outcome for the popup flow to collect, and log it. */
2037
+ finish(result) {
2038
+ if (result.session_id) {
2039
+ writeStored('session', storageKeys.callback(result.session_id), JSON.stringify(result));
2040
+ }
2041
+ if (result.status === 'completed') {
2042
+ this.logger.info('Consent callback verified', { session_id: result.session_id, status: result.status });
2043
+ }
2044
+ else if (result.status === 'pending') {
2045
+ this.logger.info('Consent callback verified; artifact not readable yet', {
2046
+ session_id: result.session_id,
2047
+ status: result.status,
2048
+ });
2049
+ }
2050
+ else {
2051
+ this.logger.warn('Consent callback NOT verified', {
2052
+ session_id: result.session_id,
2053
+ status: result.status,
2054
+ reason: result.reason,
2055
+ });
2056
+ }
2057
+ return result;
2058
+ }
2059
+ }
2060
+ /** Constant-time string compare, so a state cannot be guessed byte by byte. */
2061
+ function timingSafeEqual(a, b) {
2062
+ if (a.length !== b.length)
2063
+ return false;
2064
+ let diff = 0;
2065
+ for (let i = 0; i < a.length; i++)
2066
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
2067
+ return diff === 0;
2068
+ }
2069
+
2070
+ /**
2071
+ * ConsentEra Consent SDK — DF Configuration Client
2072
+ * Fetch Data Fiduciary configuration, purposes, and notice details
2073
+ */
2074
+ class DFConfigClient {
2075
+ request;
2076
+ logger;
2077
+ cachedConfig = null;
2078
+ constructor(request, logger) {
2079
+ this.request = request;
2080
+ this.logger = logger;
2081
+ }
2082
+ /**
2083
+ * Get the full DF configuration (tenant info, purposes, notices, branding).
2084
+ * Results are cached for the lifetime of the client instance.
2085
+ */
2086
+ async getConfig(language) {
2087
+ if (this.cachedConfig)
2088
+ return this.cachedConfig;
2089
+ const params = {};
2090
+ if (language)
2091
+ params.language_code = language;
2092
+ const config = await this.request('GET', '/df/config', undefined, params,
2093
+ // The DF read roads are openable by a public site key (route membership,
2094
+ // not the four legacy permission names — see README). Marking the road
2095
+ // 'public' is what stops the transport demanding a secret / proxy for a
2096
+ // browser call that legitimately carries only the site key.
2097
+ { road: 'public' });
2098
+ this.cachedConfig = config;
2099
+ this.logger.info('DF config loaded', {
2100
+ tenant: config.tenant_name,
2101
+ purposes: config.purposes?.length,
2102
+ });
2103
+ return config;
2104
+ }
2105
+ /**
2106
+ * Get all purposes configured for this DF.
2107
+ */
2108
+ async getPurposes() {
2109
+ const response = await this.request('GET', '/df/purposes', undefined, undefined, { road: 'public' });
2110
+ return Array.isArray(response) ? response : response.purposes || [];
2111
+ }
2112
+ /**
2113
+ * Get purposes associated with a specific notice.
2114
+ * This returns the ordered list of purposes as they appear in the notice.
2115
+ */
2116
+ async getNoticePurposes(noticeInternalName, language) {
2117
+ const params = {};
2118
+ if (noticeInternalName)
2119
+ params.notice_internal_name = noticeInternalName;
2120
+ if (language)
2121
+ params.language_code = language;
2122
+ return this.request('GET', '/df/notice/purposes', undefined, params, { road: 'public' });
2123
+ }
2124
+ /**
2125
+ * Get the pre-rendered HTML+CSS snapshot for a notice, including purpose snapshot.
2126
+ * Use this instead of getConfig() for bootstrapping notice-based consent UIs.
2127
+ *
2128
+ * @param noticeName - internal_name of the notice (required)
2129
+ * @param noticeLanguage - language of the notice content (default: tenant primary language)
2130
+ * @param templateLanguage - language for widget UI labels (optional)
2131
+ */
2132
+ async getNoticeTemplate(noticeName, noticeLanguage, templateLanguage) {
2133
+ const params = { notice: noticeName };
2134
+ if (noticeLanguage)
2135
+ params.notice_language = noticeLanguage;
2136
+ if (templateLanguage)
2137
+ params.template_language = templateLanguage;
2138
+ const response = await this.request('GET', '/df/notice/template', undefined, params, { road: 'public' });
2139
+ this.logger.info('Notice template loaded', { notice: noticeName, language: noticeLanguage });
2140
+ // The endpoint wraps in { data: ... }
2141
+ return response.data ?? response;
2142
+ }
2143
+ /**
2144
+ * Get the widget template for a consent SESSION's collection UI.
2145
+ *
2146
+ * THE ROUTE CHANGED IN 2.0.0, because the old one did not exist. This called
2147
+ * GET /df/widget-template, which is a 404 — there is no such platform route.
2148
+ * The real widget template is session-scoped: GET
2149
+ * /consent/sessions/{id}/widget-template (routes_consent.go:125), which sits
2150
+ * in the pre-auth throttle group and takes NO DF credential — the session id
2151
+ * and its nonce are the capability. So this needs a session id, and rides the
2152
+ * `session` road (no key, no proxy required).
2153
+ *
2154
+ * For the notice-authoring template keyed by internal_name, use
2155
+ * {@link getNoticeTemplate}, which is the site-key-openable /df/notice/template.
2156
+ *
2157
+ * @param sessionId the consent_session_id from createSession()
2158
+ */
2159
+ async getSessionWidgetTemplate(sessionId) {
2160
+ return this.request('GET', `/consent/sessions/${encodeURIComponent(sessionId)}/widget-template`, undefined, undefined, { road: 'session' });
2161
+ }
2162
+ /**
2163
+ * Clear the cached config (useful after config changes).
2164
+ */
2165
+ clearCache() {
2166
+ this.cachedConfig = null;
2167
+ }
2168
+ }
2169
+
2170
+ /**
2171
+ * ConsentEra Consent SDK — Principal Client
2172
+ * Data principal rights: Portal SSO, data export, deletion requests
2173
+ */
2174
+ class PrincipalClient {
2175
+ request;
2176
+ logger;
2177
+ // `config` is accepted and ignored: the portal road takes nothing from it,
2178
+ // and the argument stays so the client's construction call reads the same as
2179
+ // every other sub-client's.
2180
+ constructor(request, _config, logger) {
2181
+ this.request = request;
2182
+ this.logger = logger;
2183
+ }
2184
+ /**
2185
+ * Generate a principal portal SSO token.
2186
+ * Returns a URL the data principal can visit to manage their consents.
2187
+ *
2188
+ * THIS ROAD STILL TAKES data_principal_ref, AND THAT IS CORRECT. It is
2189
+ * identity/dfclient's (handlers.go:1418-1428), not the consent module's, so
2190
+ * the 2026-09-21 ruling that refuses the field across the consent surface
2191
+ * does not reach it. A sweep that converted this call would break a working
2192
+ * endpoint.
2193
+ */
2194
+ async getPortalToken(dataPrincipalRef, redirectPath) {
2195
+ const body = {
2196
+ data_principal_ref: dataPrincipalRef,
2197
+ redirect_path: redirectPath,
2198
+ };
2199
+ const response = await this.request('POST', '/df/principal-token', body);
2200
+ this.logger.info('Principal portal token generated', {
2201
+ dataPrincipal: dataPrincipalRef,
2202
+ expires_in: response.expires_in,
2203
+ });
2204
+ return response;
2205
+ }
2206
+ /**
2207
+ * Get the full portal URL for a data principal.
2208
+ * Convenience method that returns just the redirect URL string.
2209
+ */
2210
+ async getPortalUrl(dataPrincipalRef, redirectPath) {
2211
+ const token = await this.getPortalToken(dataPrincipalRef, redirectPath);
2212
+ return token.redirect_url;
2213
+ }
2214
+ /**
2215
+ * Open the principal portal in a new browser window/tab.
2216
+ */
2217
+ async openPortal(dataPrincipalRef, redirectPath) {
2218
+ if (typeof window === 'undefined') {
2219
+ throw new Error('openPortal can only be used in browser environments');
2220
+ }
2221
+ const url = await this.getPortalUrl(dataPrincipalRef, redirectPath);
2222
+ window.open(url, '_blank', 'noopener,noreferrer');
2223
+ this.logger.info('Principal portal opened', { dataPrincipal: dataPrincipalRef });
2224
+ }
2225
+ }
2226
+
2227
+ /**
2228
+ * Simple Event Emitter
2229
+ */
2230
+ class EventEmitter {
2231
+ events = new Map();
2232
+ /**
2233
+ * Register event listener
2234
+ */
2235
+ on(event, callback) {
2236
+ if (!this.events.has(event)) {
2237
+ this.events.set(event, new Set());
2238
+ }
2239
+ this.events.get(event).add(callback);
2240
+ }
2241
+ /**
2242
+ * Remove event listener
2243
+ */
2244
+ off(event, callback) {
2245
+ const callbacks = this.events.get(event);
2246
+ if (callbacks) {
2247
+ callbacks.delete(callback);
2248
+ }
2249
+ }
2250
+ /**
2251
+ * Emit event
2252
+ */
2253
+ emit(event, ...args) {
2254
+ const callbacks = this.events.get(event);
2255
+ if (callbacks) {
2256
+ callbacks.forEach((callback) => {
2257
+ try {
2258
+ callback(...args);
2259
+ }
2260
+ catch (error) {
2261
+ console.error(`Error in event handler for ${event}:`, error);
2262
+ }
2263
+ });
2264
+ }
2265
+ }
2266
+ /**
2267
+ * Register one-time event listener
2268
+ */
2269
+ once(event, callback) {
2270
+ const onceWrapper = (...args) => {
2271
+ this.off(event, onceWrapper);
2272
+ callback(...args);
2273
+ };
2274
+ this.on(event, onceWrapper);
2275
+ }
2276
+ /**
2277
+ * Remove all listeners
2278
+ */
2279
+ removeAllListeners(event) {
2280
+ if (event) {
2281
+ this.events.delete(event);
2282
+ }
2283
+ else {
2284
+ this.events.clear();
2285
+ }
2286
+ }
2287
+ }
2288
+
2289
+ /* eslint-disable no-console -- THIS FILE IS THE CONSOLE WRITE. The `no-console`
2290
+ rule exists so that no OTHER file writes to the host's console directly; the
2291
+ whole point of routing through here is that a host can set the level or
2292
+ replace the sink. Disabling it anywhere else is the thing the rule is for. */
2293
+ class Logger {
2294
+ level;
2295
+ prefix = '[ConsentEra]';
2296
+ constructor(level = 'info') {
2297
+ this.level = level;
2298
+ }
2299
+ shouldLog(level) {
2300
+ const levels = ['debug', 'info', 'warn', 'error'];
2301
+ return levels.indexOf(level) >= levels.indexOf(this.level);
2302
+ }
2303
+ debug(...args) {
2304
+ if (this.shouldLog('debug')) {
2305
+ console.debug(this.prefix, ...args);
2306
+ }
2307
+ }
2308
+ info(...args) {
2309
+ if (this.shouldLog('info')) {
2310
+ console.info(this.prefix, ...args);
2311
+ }
2312
+ }
2313
+ warn(...args) {
2314
+ if (this.shouldLog('warn')) {
2315
+ console.warn(this.prefix, ...args);
2316
+ }
2317
+ }
2318
+ error(...args) {
2319
+ if (this.shouldLog('error')) {
2320
+ console.error(this.prefix, ...args);
2321
+ }
2322
+ }
2323
+ setLevel(level) {
2324
+ this.level = level;
2325
+ }
2326
+ }
2327
+
2328
+ /**
2329
+ * Consentera Consent SDK — the client.
2330
+ *
2331
+ * ─── THE CREDENTIAL RULE, AND IT IS ENFORCED HERE AT CONSTRUCTION ──────────
2332
+ *
2333
+ * A BROWSER BUNDLE NEVER CARRIES A SECRET (owner ruling 2026-09-22). Before
2334
+ * 2.0.0 this file said the opposite in a comment — "Prefer site key (public,
2335
+ * safe for frontend) over API key (secret, server-side only)" — and then sent
2336
+ * whichever of the two it was given, from wherever it was running. Both halves
2337
+ * of that sentence were wrong in a way that only showed up in production:
2338
+ *
2339
+ * the site key does not work. The platform replaces a site key's permissions
2340
+ * with exactly {consent.render, widget.render, session.submit, session.render}
2341
+ * (core/auth/df/api_usage.go:105-110, :1115) and NO ROUTE REQUIRES ANY OF THEM
2342
+ * — a grep for RequireDFPermission of those four names over the whole API
2343
+ * returns nothing. Every consent lifecycle road requires
2344
+ * engagement.consent.{collect,read,validate,update,withdraw,renew}
2345
+ * (cmd/api/routes_consent.go:377-569). The intersection is empty, so a browser
2346
+ * configured the recommended way got 403 on every call.
2347
+ *
2348
+ * the secret key does work — which is worse. It is the only credential that
2349
+ * authorises those roads, so "make it work" meant putting a tiq_live_ key in
2350
+ * a public bundle.
2351
+ *
2352
+ * So the rule is now structural rather than advisory:
2353
+ *
2354
+ * in a browser a Data Fiduciary road MUST go through `proxyEndpoint` — your
2355
+ * own server route, which holds the secret and forwards. An
2356
+ * `apiKey` in a browser is REFUSED AT CONSTRUCTION.
2357
+ * on a server `apiKey` directly, as always.
2358
+ * public roads may carry `siteKey`; they carry no secret.
2359
+ * session roads carry no key at all: the session id and its nonce are the
2360
+ * capability.
2361
+ *
2362
+ * WHAT THE PLATFORM MUST STILL DO for the public half to be usable from a
2363
+ * browser without a proxy is a separate unit and is written down in
2364
+ * README.md → "What the platform must guarantee".
2365
+ */
2366
+ class ConsentEraClient extends EventEmitter {
2367
+ config;
2368
+ logger;
2369
+ transport;
2370
+ /** Consent session creation and artifact retrieval */
2371
+ session;
2372
+ /** Consent validation (single + bulk) */
2373
+ validate;
2374
+ /** Consent update, withdrawal, and renewal */
2375
+ manage;
2376
+ /** Callback handler for redirect flows */
2377
+ callback;
2378
+ /** DF configuration and notice purposes */
2379
+ df;
2380
+ /** Data principal rights and portal SSO */
2381
+ principal;
2382
+ /**
2383
+ * Unified consent namespace — aggregates session, validate, manage, callback.
2384
+ */
2385
+ consent;
2386
+ constructor(config) {
2387
+ super();
2388
+ if (!config.apiEndpoint && !config.proxyEndpoint) {
2389
+ throw configError('Consentera: set `proxyEndpoint` (your own server route — required in a browser for ' +
2390
+ 'consent lifecycle roads) or `apiEndpoint` (server-side use).', 'ENDPOINT_REQUIRED');
2391
+ }
2392
+ // A tenant id is needed only where THIS SDK names the tenant: direct mode,
2393
+ // which sends X-Tenant-Id. Behind `proxyEndpoint` your server holds the key
2394
+ // and the key names the tenant, so the SDK sends no tenant header, and
2395
+ // demanding the value anyway made every integrator invent one (WEB-036).
2396
+ if (!config.tenantId && !config.proxyEndpoint) {
2397
+ throw configError('Consentera: tenantId is required with `apiEndpoint`. (Behind `proxyEndpoint` it is optional: ' +
2398
+ 'your server supplies the tenant.)', 'TENANT_ID_REQUIRED');
2399
+ }
2400
+ // THE ONE REFUSAL — the shared guard, so this client and the ConsenteraConsent
2401
+ // cookie SDK enforce the identical rule at their two doors.
2402
+ assertNoSecretKeyInBrowser({
2403
+ apiKey: config.apiKey,
2404
+ unsafeAllowSecretKeyInBrowser: config.unsafeAllowSecretKeyInBrowser,
2405
+ fix: 'Move the key to your own server and point the SDK at it with ' +
2406
+ '`proxyEndpoint: "/api/consentera"`; the browser then sends no credential at all.',
2407
+ });
2408
+ this.config = config;
2409
+ this.logger = new Logger(config.debug ? 'debug' : 'info');
2410
+ this.transport = new HttpTransport({
2411
+ tenantId: config.tenantId,
2412
+ apiEndpoint: config.apiEndpoint,
2413
+ proxyEndpoint: config.proxyEndpoint,
2414
+ apiKey: config.apiKey,
2415
+ siteKey: config.siteKey,
2416
+ customHeaders: config.customHeaders,
2417
+ unsafeAllowSecretKeyInBrowser: config.unsafeAllowSecretKeyInBrowser,
2418
+ timeoutMs: config.timeoutMs,
2419
+ retry: config.retry,
2420
+ beforeSend: config.beforeSend,
2421
+ fetchImpl: config.fetchImpl,
2422
+ }, this.logger);
2423
+ const requestFn = this.request.bind(this);
2424
+ this.session = new ConsentSession(requestFn, config, this.logger);
2425
+ this.validate = new ConsentValidator(requestFn, this.logger);
2426
+ this.manage = new ConsentManager(requestFn, this.logger, {
2427
+ contextMode: config.collectContext ?? 'minimal',
2428
+ });
2429
+ // Public event bus: every successful consent mutation (update/grant/deny/
2430
+ // withdraw/renew) re-emits as `consent.changed` — subscribe with
2431
+ // client.on('consent.changed', cb) instead of polling validate.
2432
+ this.manage.onChanged = (payload) => this.emit('consent.changed', payload);
2433
+ this.callback = new CallbackHandler(this.logger, requestFn, {
2434
+ verifySignature: config.verifyCallbackSignature,
2435
+ artifactWaitMs: config.artifactWaitMs,
2436
+ });
2437
+ this.df = new DFConfigClient(requestFn, this.logger);
2438
+ this.principal = new PrincipalClient(requestFn, config, this.logger);
2439
+ this.consent = {
2440
+ createSession: this.session.createSession.bind(this.session),
2441
+ redirectToConsent: this.session.redirectToConsent.bind(this.session),
2442
+ openConsentPopup: this.session.openConsentPopup.bind(this.session),
2443
+ getArtifact: this.session.getArtifact.bind(this.session),
2444
+ validate: this.validate.check.bind(this.validate),
2445
+ validateBulk: this.validate.checkBulk.bind(this.validate),
2446
+ isAllowed: this.validate.isAllowed.bind(this.validate),
2447
+ areAllowed: this.validate.areAllowed.bind(this.validate),
2448
+ handleCallback: this.callback.parseCallback.bind(this.callback),
2449
+ isCallback: this.callback.isCallback.bind(this.callback),
2450
+ getContext: this.manage.getUpdateContext.bind(this.manage),
2451
+ update: this.manage.update.bind(this.manage),
2452
+ grant: this.manage.grant.bind(this.manage),
2453
+ deny: this.manage.deny.bind(this.manage),
2454
+ getWithdrawalContext: this.manage.getWithdrawalContext.bind(this.manage),
2455
+ withdraw: this.manage.withdraw.bind(this.manage),
2456
+ withdrawBulk: this.manage.withdrawBulk.bind(this.manage),
2457
+ getRenewalContext: this.manage.getRenewalContext.bind(this.manage),
2458
+ renew: this.manage.renew.bind(this.manage),
2459
+ renewBulk: this.manage.renewBulk.bind(this.manage),
2460
+ };
2461
+ this.logger.info('Consentera client initialised', {
2462
+ mode: config.proxyEndpoint ? 'proxy' : 'direct',
2463
+ tenantId: config.tenantId,
2464
+ });
2465
+ }
2466
+ /**
2467
+ * Central HTTP request method. Delegates to {@link HttpTransport}, which owns
2468
+ * the deadline, the retry policy, the idempotency key and the credential rule.
2469
+ *
2470
+ * @param queryParams query string values (the legacy 4th-argument position)
2471
+ * @param options per-request road, idempotency key, signal, timeout, retry
2472
+ */
2473
+ async request(method, path, body, queryParams, options = {}) {
2474
+ try {
2475
+ return await this.transport.request(method, path, body, {
2476
+ ...options,
2477
+ query: { ...queryParams, ...options.query },
2478
+ });
2479
+ }
2480
+ catch (err) {
2481
+ if (err instanceof ConsenteraError)
2482
+ this.emit('error', err);
2483
+ throw err;
2484
+ }
2485
+ }
2486
+ /** Build client context — delegates to shared helper */
2487
+ static buildClientContext = buildClientContext;
2488
+ /** Build affirmative action — delegates to shared helper */
2489
+ static buildAffirmativeAction = buildAffirmativeAction;
2490
+ /** Get the current configuration. The credentials are redacted. */
2491
+ getConfig() {
2492
+ const copy = { ...this.config };
2493
+ if (copy.apiKey)
2494
+ copy.apiKey = `${copy.apiKey.slice(0, 9)}…`;
2495
+ return copy;
2496
+ }
2497
+ }
2498
+
2499
+ const ConsentEraContext = createContext(null);
2500
+ function ConsentEraProvider({ config, children }) {
2501
+ // Construct in the memo, but NEVER write state from it. The outcome — client
2502
+ // or error — is the memo's value, so a double invocation under StrictMode
2503
+ // produces the same value twice instead of two state writes.
2504
+ const built = useMemo(() => {
2505
+ try {
2506
+ return { client: new ConsentEraClient(config), error: null };
2507
+ }
2508
+ catch (err) {
2509
+ return { client: null, error: err };
2510
+ }
2511
+ // The client is rebuilt only when something it is made of changes. `config`
2512
+ // itself is deliberately not a dependency: hosts pass an object literal,
2513
+ // which is a new reference every render.
2514
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2515
+ }, [config.tenantId, config.apiKey, config.siteKey, config.apiEndpoint, config.proxyEndpoint]);
2516
+ const [runtimeError, setRuntimeError] = useState(null);
2517
+ useEffect(() => {
2518
+ // A new client means the previous client's errors are no longer ours.
2519
+ setRuntimeError(null);
2520
+ const client = built.client;
2521
+ if (!client)
2522
+ return;
2523
+ const handleError = (err) => setRuntimeError(err);
2524
+ client.on('error', handleError);
2525
+ return () => {
2526
+ client.off('error', handleError);
2527
+ };
2528
+ }, [built.client]);
2529
+ const value = useMemo(() => ({ client: built.client, loading: false, error: built.error ?? runtimeError }), [built.client, built.error, runtimeError]);
2530
+ return jsx(ConsentEraContext.Provider, { value: value, children: children });
2531
+ }
2532
+ /**
2533
+ * What a hook sees outside any provider. A VALUE and not a throw (WEB-038): a
2534
+ * throw during render unmounts the whole tree above the nearest error
2535
+ * boundary, so a consent SDK that throws takes down the page it was meant to
2536
+ * gate. The gate below fails closed on it; the error says what to fix.
2537
+ */
2538
+ const MISSING_PROVIDER = {
2539
+ client: null,
2540
+ loading: false,
2541
+ error: new Error('Consentera: useConsentEra* was called outside <ConsentEraProvider>. Wrap the tree in it.'),
2542
+ };
2543
+ /**
2544
+ * The context as provided: `client` is null when the configuration was
2545
+ * refused (then `error` says why) or when there is no provider at all (then
2546
+ * `error` says that). NEVER throws.
2547
+ */
2548
+ function useConsentEraContext() {
2549
+ return useContext(ConsentEraContext) ?? MISSING_PROVIDER;
2550
+ }
2551
+ /**
2552
+ * The client, or `client: null` with the reason in `error`. NEVER throws
2553
+ * during render (SDK register WEB-038).
2554
+ *
2555
+ * It used to throw when the provider's configuration was refused: a missing
2556
+ * tenantId, a secret key in a browser. That threw from inside render, so a
2557
+ * configuration mistake did not produce a closed consent gate. It produced a
2558
+ * blank page, measured in the React demos. Render your fallback on
2559
+ * `client === null`. `error` is a ConsenteraError whose `code` names the
2560
+ * refusal (`SECRET_KEY_IN_BROWSER`, `ENDPOINT_REQUIRED`, …).
2561
+ */
2562
+ function useConsentEraClient() {
2563
+ return useConsentEraContext();
2564
+ }
2565
+
2566
+ /**
2567
+ * Main hook for accessing all Consentera consent operations.
2568
+ *
2569
+ * @example
2570
+ * ```tsx
2571
+ * function LoanPage() {
2572
+ * const { consent, error } = useConsentEra();
2573
+ * if (!consent) return <p>Consent is unavailable: {error?.message}</p>;
2574
+ *
2575
+ * const handleApply = async () => {
2576
+ * const check = await consent.validate(
2577
+ * { data_principal_identifiers: { email: 'user@example.com' } },
2578
+ * 'credit_assessment'
2579
+ * );
2580
+ * if (check.decision === 'DENY') {
2581
+ * const s = await consent.createSession({
2582
+ * // keyed by THIS organisation's locked integration key — the SAME
2583
+ * // open map the validate call above passes, one vocabulary on both
2584
+ * // roads (F015). Only the wire KEY differs: `data_principal` here
2585
+ * // may mint a person, `data_principal_identifiers` only resolves.
2586
+ * data_principal: { email: 'user@example.com' },
2587
+ * notice_internal_name: 'loan_notice',
2588
+ * });
2589
+ * consent.redirectToConsent(s);
2590
+ * }
2591
+ * };
2592
+ * }
2593
+ * ```
2594
+ */
2595
+ function useConsentEra() {
2596
+ const { client, loading, error } = useConsentEraClient();
2597
+ return useMemo(() => ({
2598
+ ready: client !== null,
2599
+ consent: client?.consent ?? null,
2600
+ session: client?.session ?? null,
2601
+ validate: client?.validate ?? null,
2602
+ manage: client?.manage ?? null,
2603
+ callback: client?.callback ?? null,
2604
+ df: client?.df ?? null,
2605
+ principal: client?.principal ?? null,
2606
+ loading,
2607
+ error,
2608
+ }), [client, loading, error]);
2609
+ }
2610
+
2611
+ /**
2612
+ * Stable identity for a `who` that hosts pass as an object literal.
2613
+ *
2614
+ * IT ENUMERATES WHATEVER IS THERE, and since F015 that is load-bearing rather
2615
+ * than merely tidy. `data_principal_identifiers` is an OPEN map keyed by the
2616
+ * tenant's own locked integration key, so a key function that walked a FIXED
2617
+ * field list would read nothing at all for a tenant keyed on an atom outside
2618
+ * that list — every such person would hash to the same empty key, the hook
2619
+ * would treat two different people as one, and the gate would answer for the
2620
+ * wrong person. Under the old closed five-field type that bug was impossible;
2621
+ * under the open map it is one `['email','mobile',…]` away.
2622
+ */
2623
+ function whoKey(who) {
2624
+ if (!who)
2625
+ return '';
2626
+ if ('data_principal_id' in who && who.data_principal_id)
2627
+ return `id:${who.data_principal_id}`;
2628
+ const ids = who.data_principal_identifiers ?? {};
2629
+ return `ids:${Object.keys(ids)
2630
+ .sort()
2631
+ .map((k) => `${k}=${ids[k] ?? ''}`)
2632
+ .join('&')}`;
2633
+ }
2634
+ function useConsentValidation(who, purposeCode, options) {
2635
+ // `client` is null when the provider's configuration was refused or there is
2636
+ // no provider. Every hook below is still called (the rules of hooks); none
2637
+ // of them sends anything without a client, and the gate stays closed with
2638
+ // the reason in `error` (WEB-038: this used to throw during render).
2639
+ const { client, error: clientError } = useConsentEraClient();
2640
+ const [allowed, setAllowed] = useState(null);
2641
+ const [result, setResult] = useState(null);
2642
+ const [loading, setLoading] = useState(false);
2643
+ const [error, setError] = useState(null);
2644
+ const autoCheck = options?.autoCheck !== false;
2645
+ const watchChanges = options?.watchChanges !== false;
2646
+ const key = whoKey(who);
2647
+ // The value the effect depends on; `who` by reference would re-run forever.
2648
+ const stableWho = useMemo(() => who, [key]); // eslint-disable-line react-hooks/exhaustive-deps
2649
+ // Monotonic: only the newest request may write state.
2650
+ const seqRef = useRef(0);
2651
+ const abortRef = useRef(null);
2652
+ const recheck = useCallback(async () => {
2653
+ if (!client || !stableWho || !purposeCode)
2654
+ return;
2655
+ abortRef.current?.abort();
2656
+ const controller = new AbortController();
2657
+ abortRef.current = controller;
2658
+ const seq = ++seqRef.current;
2659
+ const current = () => seq === seqRef.current && !controller.signal.aborted;
2660
+ setLoading(true);
2661
+ setError(null);
2662
+ try {
2663
+ const response = await client.validate.check(stableWho, purposeCode, {
2664
+ signal: controller.signal,
2665
+ });
2666
+ if (!current())
2667
+ return;
2668
+ setResult(response);
2669
+ setAllowed(response.decision === 'ALLOW');
2670
+ }
2671
+ catch (err) {
2672
+ if (!current())
2673
+ return;
2674
+ setError(err);
2675
+ // FAIL CLOSED: an unknown decision is not an allowed one.
2676
+ setAllowed(null);
2677
+ }
2678
+ finally {
2679
+ if (current())
2680
+ setLoading(false);
2681
+ }
2682
+ }, [client, stableWho, purposeCode]);
2683
+ useEffect(() => {
2684
+ if (autoCheck && stableWho && purposeCode) {
2685
+ void recheck();
2686
+ }
2687
+ return () => {
2688
+ abortRef.current?.abort();
2689
+ };
2690
+ }, [autoCheck, stableWho, purposeCode, recheck]);
2691
+ // Close the gate when consent changes under it.
2692
+ useEffect(() => {
2693
+ if (!client || !watchChanges || !stableWho || !purposeCode)
2694
+ return;
2695
+ const onChanged = (payload) => {
2696
+ const touched = payload?.changes?.some((c) => c.purpose_id === purposeCode);
2697
+ // A change with no purpose list, or one naming this purpose, both mean
2698
+ // "what you know is stale". Re-checking is cheap; being wrong is not.
2699
+ if (touched || !payload?.changes?.length)
2700
+ void recheck();
2701
+ };
2702
+ client.on('consent.changed', onChanged);
2703
+ return () => {
2704
+ client.off('consent.changed', onChanged);
2705
+ };
2706
+ }, [client, watchChanges, stableWho, purposeCode, recheck]);
2707
+ if (!client)
2708
+ return { allowed: null, result: null, loading: false, error: clientError, recheck };
2709
+ return { allowed, result, loading, error, recheck };
2710
+ }
2711
+
2712
+ function ConsentGate({ who, purposeCode, children, fallback = null, loading: loadingContent, }) {
2713
+ const { allowed, loading } = useConsentValidation(who, purposeCode);
2714
+ if (loading) {
2715
+ return jsx(Fragment, { children: loadingContent ?? fallback });
2716
+ }
2717
+ if (!allowed) {
2718
+ return jsx(Fragment, { children: fallback });
2719
+ }
2720
+ return jsx(Fragment, { children: children });
2721
+ }
2722
+
2723
+ export { ConsentEraProvider, ConsentGate, useConsentEra, useConsentEraClient, useConsentEraContext, useConsentValidation };
2724
+ //# sourceMappingURL=index.mjs.map