@consentera/consent-sdk 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -8
- package/README.md +91 -2
- package/dist/consentera-consent.cjs +183 -16
- package/dist/consentera-consent.cjs.map +1 -1
- package/dist/consentera-consent.min.js +1 -1
- package/dist/consentera-consent.min.js.map +1 -1
- package/dist/consentera-consent.mjs +181 -17
- package/dist/consentera-consent.mjs.map +1 -1
- package/dist/react/index.cjs +177 -15
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.mjs +177 -15
- package/dist/react/index.mjs.map +1 -1
- package/dist/types/consent/ConsentManager.d.ts +10 -0
- package/dist/types/consent/ConsentValidator.d.ts +27 -0
- package/dist/types/core/ConsentEraClient.d.ts +1 -1
- package/dist/types/core/errors.d.ts +32 -1
- package/dist/types/core/http.d.ts +9 -0
- package/dist/types/core/version.d.ts +4 -4
- package/dist/types/index.d.ts +2 -2
- package/dist/types/types/consent-lifecycle.d.ts +31 -1
- package/package.json +1 -1
package/dist/react/index.cjs
CHANGED
|
@@ -81,6 +81,15 @@ const CODE_KIND = {
|
|
|
81
81
|
CONFLICT: 'conflict',
|
|
82
82
|
IDEMPOTENCY_KEY_REUSE: 'conflict',
|
|
83
83
|
RATE_LIMIT_EXCEEDED: 'rate_limit',
|
|
84
|
+
// 409 — the ORGANISATION's plan, never the person's answer. The consent cap
|
|
85
|
+
// refuses a submit/update that would add a NEW (data principal, purpose)
|
|
86
|
+
// grant past the plan's consents_max (platform
|
|
87
|
+
// core/consentbridge/grant_cap.go: NewGrantCapRefusal), and nothing was
|
|
88
|
+
// recorded. The DEPA ingest answers the same stop in its own vocabulary
|
|
89
|
+
// (consent/depa/df_handlers.go). Its own kind, so it is never read as a
|
|
90
|
+
// `conflict` (a duplicate), a network failure, or a refusal by the person.
|
|
91
|
+
PLAN_LIMIT_REACHED: 'plan_limit',
|
|
92
|
+
ARTEFACT_NOT_ACCEPTED_PLAN_LIMIT: 'plan_limit',
|
|
84
93
|
INTERNAL_ERROR: 'server',
|
|
85
94
|
INTERNAL_SERVER_ERROR: 'server',
|
|
86
95
|
DATABASE_ERROR: 'server',
|
|
@@ -189,6 +198,41 @@ class ConsenteraNetworkError extends ConsenteraError {
|
|
|
189
198
|
}
|
|
190
199
|
class ConsenteraTimeoutError extends ConsenteraError {
|
|
191
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* The organisation's plan cannot take this change: HTTP 409 with code
|
|
203
|
+
* `PLAN_LIMIT_REACHED` (or `ARTEFACT_NOT_ACCEPTED_PLAN_LIMIT` on the DEPA
|
|
204
|
+
* ingest). For consent it means the submit or update would have added a NEW
|
|
205
|
+
* (data principal, purpose) grant past the plan's `consents_max`, and
|
|
206
|
+
* `reasonCode` is `'new_consents_only'`.
|
|
207
|
+
*
|
|
208
|
+
* What it is NOT, and must never be shown or reported as:
|
|
209
|
+
* * the person's refusal — they did not deny anything; do not record,
|
|
210
|
+
* display or emit it as `denied`;
|
|
211
|
+
* * a transient failure — retrying the same request gets the same answer
|
|
212
|
+
* until the organisation's plan changes, so this SDK never retries it.
|
|
213
|
+
*
|
|
214
|
+
* `recorded` is always `false`: the platform wrote nothing, fired no callback
|
|
215
|
+
* and emitted no event. Show the person a neutral "this organisation can't
|
|
216
|
+
* accept new consents right now" (the platform's own `message` is written for
|
|
217
|
+
* them) and leave their existing choices as they were.
|
|
218
|
+
*/
|
|
219
|
+
class ConsenteraPlanLimitError extends ConsenteraError {
|
|
220
|
+
/** `details.reason_code` — `'new_consents_only'` for the consent cap. */
|
|
221
|
+
reasonCode;
|
|
222
|
+
/** `details.limit_key` — set on an admin create refusal (e.g. `'consents_max'`), absent on a consent submit. */
|
|
223
|
+
limitKey;
|
|
224
|
+
/** The envelope's `details`, as sent. */
|
|
225
|
+
details;
|
|
226
|
+
/** Always false: a plan-limit refusal records nothing. */
|
|
227
|
+
recorded = false;
|
|
228
|
+
constructor(init) {
|
|
229
|
+
super({ ...init, retryable: false });
|
|
230
|
+
const d = init.details;
|
|
231
|
+
this.details = d;
|
|
232
|
+
this.reasonCode = typeof d?.reason_code === 'string' ? d.reason_code : undefined;
|
|
233
|
+
this.limitKey = typeof d?.limit_key === 'string' ? d.limit_key : undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
192
236
|
const KIND_CLASS = {
|
|
193
237
|
config: ConsenteraConfigError,
|
|
194
238
|
auth: ConsenteraAuthError,
|
|
@@ -199,6 +243,7 @@ const KIND_CLASS = {
|
|
|
199
243
|
not_found: ConsenteraNotFoundError,
|
|
200
244
|
conflict: ConsenteraConflictError,
|
|
201
245
|
rate_limit: ConsenteraRateLimitError,
|
|
246
|
+
plan_limit: ConsenteraPlanLimitError,
|
|
202
247
|
server: ConsenteraServerError,
|
|
203
248
|
network: ConsenteraNetworkError,
|
|
204
249
|
timeout: ConsenteraTimeoutError,
|
|
@@ -208,7 +253,7 @@ const KIND_CLASS = {
|
|
|
208
253
|
function newOfKind(init) {
|
|
209
254
|
return new KIND_CLASS[init.kind](init);
|
|
210
255
|
}
|
|
211
|
-
/** Pull `{code, message}` off a parsed body, tolerating the `{error:{...}}` wrapper. */
|
|
256
|
+
/** Pull `{code, message, details}` off a parsed body, tolerating the `{error:{...}}` wrapper. */
|
|
212
257
|
function readEnvelope(body) {
|
|
213
258
|
if (!body || typeof body !== 'object')
|
|
214
259
|
return {};
|
|
@@ -216,16 +261,20 @@ function readEnvelope(body) {
|
|
|
216
261
|
const inner = o.error && typeof o.error === 'object' ? o.error : o;
|
|
217
262
|
const code = typeof inner.code === 'string' ? inner.code : undefined;
|
|
218
263
|
const message = typeof inner.message === 'string' ? inner.message : undefined;
|
|
219
|
-
|
|
264
|
+
const details = inner.details && typeof inner.details === 'object' && !Array.isArray(inner.details)
|
|
265
|
+
? inner.details
|
|
266
|
+
: undefined;
|
|
267
|
+
return { code, message, details };
|
|
220
268
|
}
|
|
221
269
|
/** Build the error for a non-2xx response. */
|
|
222
270
|
function errorFromResponse(args) {
|
|
223
|
-
const { code, message } = readEnvelope(args.body);
|
|
271
|
+
const { code, message, details } = readEnvelope(args.body);
|
|
224
272
|
const kind = (code && CODE_KIND[code]) || kindForStatus(args.status);
|
|
225
|
-
const retryable = args.status === 429 || args.status >= 500;
|
|
273
|
+
const retryable = kind !== 'plan_limit' && (args.status === 429 || args.status >= 500);
|
|
226
274
|
return newOfKind({
|
|
227
275
|
kind,
|
|
228
276
|
code,
|
|
277
|
+
details,
|
|
229
278
|
status: args.status,
|
|
230
279
|
requestId: args.requestId,
|
|
231
280
|
responseBody: args.body,
|
|
@@ -414,7 +463,7 @@ function removeStored(kind, key) {
|
|
|
414
463
|
* builds from src, and because a generated file in the tree is one command from
|
|
415
464
|
* being overwritten with the wrong value and nothing noticing.
|
|
416
465
|
*/
|
|
417
|
-
const SDK_VERSION = '2.
|
|
466
|
+
const SDK_VERSION = '2.1.0';
|
|
418
467
|
const SDK_PLATFORM = 'web';
|
|
419
468
|
/** The value of the `X-Consentera-SDK` header: `<surface>/<version>`. */
|
|
420
469
|
const SDK_HEADER_VALUE = `js/${SDK_VERSION}`;
|
|
@@ -422,8 +471,8 @@ const SDK_HEADER_VALUE = `js/${SDK_VERSION}`;
|
|
|
422
471
|
* The `User-Agent` half of the pair, agreed across all six surfaces
|
|
423
472
|
* (coordinator ruling 2026-09-22):
|
|
424
473
|
*
|
|
425
|
-
* User-Agent: ConsenteraSDK/2.
|
|
426
|
-
* X-Consentera-SDK: <surface>/2.
|
|
474
|
+
* User-Agent: ConsenteraSDK/2.1.0 (<platform>; <runtime>)
|
|
475
|
+
* X-Consentera-SDK: <surface>/2.1.0
|
|
427
476
|
*
|
|
428
477
|
* `ConsenteraSDK/<version>` is the form the PLATFORM ALREADY PARSES:
|
|
429
478
|
* `internal/core/audit/user_agent_coarsening_test.go:39-40` asserts that
|
|
@@ -525,7 +574,7 @@ function newRequestId() {
|
|
|
525
574
|
// on such a browser should supply its own idempotencyKey.
|
|
526
575
|
return `nc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
527
576
|
}
|
|
528
|
-
function sleep(ms, signal) {
|
|
577
|
+
function sleep$1(ms, signal) {
|
|
529
578
|
return new Promise((resolve, reject) => {
|
|
530
579
|
if (signal?.aborted) {
|
|
531
580
|
reject(cancelledError('request cancelled'));
|
|
@@ -547,16 +596,35 @@ function backoffMs(attempt, policy, random = Math.random) {
|
|
|
547
596
|
const exp = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt);
|
|
548
597
|
return Math.floor(random() * exp);
|
|
549
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* The ONE normalisation of a configured base URL (HC-13): surrounding
|
|
601
|
+
* whitespace and every trailing slash are trimmed, once, when the config is
|
|
602
|
+
* read — and nothing else changes, so a gateway path prefix
|
|
603
|
+
* ("https://gw.corp/consentera/") is kept. Every join below then adds exactly
|
|
604
|
+
* one slash. The same rule Android (`trimEnd('/')`) and the CLI (`/\/+$/`)
|
|
605
|
+
* apply.
|
|
606
|
+
*/
|
|
607
|
+
function normalizeBaseUrl(base) {
|
|
608
|
+
return (typeof base === 'string' ? base.trim().replace(/\/+$/, '') : base);
|
|
609
|
+
}
|
|
610
|
+
function normalizeBases(config) {
|
|
611
|
+
const out = { ...config };
|
|
612
|
+
if (out.apiEndpoint !== undefined)
|
|
613
|
+
out.apiEndpoint = normalizeBaseUrl(out.apiEndpoint);
|
|
614
|
+
if (out.proxyEndpoint !== undefined)
|
|
615
|
+
out.proxyEndpoint = normalizeBaseUrl(out.proxyEndpoint);
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
550
618
|
class HttpTransport {
|
|
551
619
|
config;
|
|
552
620
|
logger;
|
|
553
621
|
constructor(config, logger) {
|
|
554
|
-
this.config = config;
|
|
622
|
+
this.config = normalizeBases(config);
|
|
555
623
|
this.logger = logger;
|
|
556
624
|
}
|
|
557
625
|
/** Swap config after construction (the client re-reads customHeaders each call). */
|
|
558
626
|
updateConfig(patch) {
|
|
559
|
-
this.config = { ...this.config, ...patch };
|
|
627
|
+
this.config = { ...this.config, ...normalizeBases(patch) };
|
|
560
628
|
}
|
|
561
629
|
/**
|
|
562
630
|
* The credential decision for one road, in one place so the policy can be
|
|
@@ -778,7 +846,7 @@ class HttpTransport {
|
|
|
778
846
|
throw lastError;
|
|
779
847
|
const wait = lastError.retryAfterMs ?? backoffMs(attempt, policy);
|
|
780
848
|
this.logger.debug(`${method} ${path} retry ${attempt + 1}/${policy.attempts - 1} in ${wait}ms (${lastError.code ?? lastError.kind})`);
|
|
781
|
-
await sleep(wait, options.signal);
|
|
849
|
+
await sleep$1(wait, options.signal);
|
|
782
850
|
}
|
|
783
851
|
/* istanbul ignore next — the loop always throws on its last attempt. */
|
|
784
852
|
throw lastError ?? networkError(`${method} ${path} failed`);
|
|
@@ -1408,6 +1476,56 @@ function principalBody(who) {
|
|
|
1408
1476
|
* ConsentEra Consent SDK — Consent Validation
|
|
1409
1477
|
* Validate consent status for single or multiple purposes
|
|
1410
1478
|
*/
|
|
1479
|
+
/** The longest the {@link ValidateOptions.retryWhenPending} re-ask will wait, in seconds. */
|
|
1480
|
+
const VALIDATE_PENDING_RETRY_CAP_SECONDS = 5;
|
|
1481
|
+
/** The wait used when an `applied: false` answer carries no usable `retry_after`. */
|
|
1482
|
+
const VALIDATE_PENDING_DEFAULT_SECONDS = 1;
|
|
1483
|
+
/**
|
|
1484
|
+
* The platform's validate answer, with `applied` and `retry_after` read the
|
|
1485
|
+
* one way every SDK reads them:
|
|
1486
|
+
*
|
|
1487
|
+
* * `applied` — a JSON boolean is kept; absent (a platform older than
|
|
1488
|
+
* 936715ad71) or any other type reads as `true`.
|
|
1489
|
+
* * `retry_after` — kept when it is a finite number >= 0 (rounded up to whole
|
|
1490
|
+
* seconds); anything else is dropped.
|
|
1491
|
+
*
|
|
1492
|
+
* Every other field passes through untouched.
|
|
1493
|
+
*/
|
|
1494
|
+
function normalizeValidateResponse(raw) {
|
|
1495
|
+
const r = raw;
|
|
1496
|
+
const out = { ...r };
|
|
1497
|
+
out.applied = typeof r.applied === 'boolean' ? r.applied : true;
|
|
1498
|
+
const ra = r.retry_after;
|
|
1499
|
+
if (typeof ra === 'number' && Number.isFinite(ra) && ra >= 0) {
|
|
1500
|
+
out.retry_after = Math.ceil(ra);
|
|
1501
|
+
}
|
|
1502
|
+
else {
|
|
1503
|
+
delete out.retry_after;
|
|
1504
|
+
}
|
|
1505
|
+
return out;
|
|
1506
|
+
}
|
|
1507
|
+
/** The wait before the one re-ask, in milliseconds. */
|
|
1508
|
+
function pendingRetryDelayMs(answer) {
|
|
1509
|
+
const asked = answer.retry_after ?? VALIDATE_PENDING_DEFAULT_SECONDS;
|
|
1510
|
+
return Math.min(asked, VALIDATE_PENDING_RETRY_CAP_SECONDS) * 1000;
|
|
1511
|
+
}
|
|
1512
|
+
function sleep(ms, signal) {
|
|
1513
|
+
return new Promise((resolve, reject) => {
|
|
1514
|
+
if (signal?.aborted) {
|
|
1515
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
const t = setTimeout(() => {
|
|
1519
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1520
|
+
resolve();
|
|
1521
|
+
}, ms);
|
|
1522
|
+
const onAbort = () => {
|
|
1523
|
+
clearTimeout(t);
|
|
1524
|
+
reject(signal?.reason ?? new Error('aborted'));
|
|
1525
|
+
};
|
|
1526
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1411
1529
|
class ConsentValidator {
|
|
1412
1530
|
request;
|
|
1413
1531
|
logger;
|
|
@@ -1436,7 +1554,19 @@ class ConsentValidator {
|
|
|
1436
1554
|
*/
|
|
1437
1555
|
async check(who, purposeCode, options) {
|
|
1438
1556
|
const body = { ...principalBody(who), purpose_code: purposeCode };
|
|
1439
|
-
const
|
|
1557
|
+
const ask = async () => normalizeValidateResponse(await this.request('POST', '/consent/validate', body, undefined, { signal: options?.signal, timeoutMs: options?.timeoutMs }));
|
|
1558
|
+
let response = await ask();
|
|
1559
|
+
if (options?.retryWhenPending && response.applied === false) {
|
|
1560
|
+
// ONE re-ask, never a loop: the second answer is returned whatever it
|
|
1561
|
+
// says, so a projection that is still behind cannot hold the caller.
|
|
1562
|
+
const waitMs = pendingRetryDelayMs(response);
|
|
1563
|
+
this.logger.debug('Validate answer not yet applied; asking once more', {
|
|
1564
|
+
purpose: purposeCode,
|
|
1565
|
+
waitMs,
|
|
1566
|
+
});
|
|
1567
|
+
await sleep(waitMs, options.signal);
|
|
1568
|
+
response = await ask();
|
|
1569
|
+
}
|
|
1440
1570
|
// Never log the identifiers themselves — they are raw PII, and this line
|
|
1441
1571
|
// used to carry the opaque handle verbatim. Log WHICH way the person was
|
|
1442
1572
|
// named, which is what a support question actually needs.
|
|
@@ -1459,7 +1589,10 @@ class ConsentValidator {
|
|
|
1459
1589
|
...principalBody(who),
|
|
1460
1590
|
purpose_codes: purposeCodes,
|
|
1461
1591
|
};
|
|
1462
|
-
const
|
|
1592
|
+
const raw = await this.request('POST', '/consent/validate/bulk', body, undefined, { signal: options?.signal, timeoutMs: options?.timeoutMs });
|
|
1593
|
+
const response = Array.isArray(raw?.results)
|
|
1594
|
+
? { ...raw, results: raw.results.map((r) => normalizeValidateResponse(r)) }
|
|
1595
|
+
: raw;
|
|
1463
1596
|
this.logger.debug('Bulk consent validated', {
|
|
1464
1597
|
namedBy: 'data_principal_id' in who ? 'data_principal_id' : 'data_principal_identifiers',
|
|
1465
1598
|
purposes: purposeCodes.length,
|
|
@@ -1574,6 +1707,16 @@ class ConsentManager {
|
|
|
1574
1707
|
/**
|
|
1575
1708
|
* Update consent decisions for a data principal.
|
|
1576
1709
|
* Pass an array of purpose updates (grant or deny).
|
|
1710
|
+
*
|
|
1711
|
+
* @throws {ConsenteraPlanLimitError} HTTP 409 `PLAN_LIMIT_REACHED`,
|
|
1712
|
+
* `reasonCode: 'new_consents_only'` — the update would add a NEW
|
|
1713
|
+
* (data principal, purpose) grant past the organisation's plan
|
|
1714
|
+
* (`consents_max`). NOTHING was recorded and no `consent.changed` event
|
|
1715
|
+
* fires. It is not the person's refusal: never record or show it as
|
|
1716
|
+
* `denied`, and do not retry the same update — show a neutral "can't
|
|
1717
|
+
* accept new consents right now" instead. A denial, a withdrawal and a
|
|
1718
|
+
* re-grant of a purpose the person already holds are never refused.
|
|
1719
|
+
* `grant()` and `deny()` go through here and throw the same.
|
|
1577
1720
|
*/
|
|
1578
1721
|
async update(dataPrincipalId, updates, context, uiEventId = 'btn_save_preferences', options) {
|
|
1579
1722
|
const body = {
|
|
@@ -1683,7 +1826,7 @@ class ConsentManager {
|
|
|
1683
1826
|
captured_at: buildAffirmativeAction(uiEventId).captured_at,
|
|
1684
1827
|
client_context: this.context(),
|
|
1685
1828
|
};
|
|
1686
|
-
const response = await this.request('POST', '/consent/renew', body, undefined, { ...this.mutation(options), raw: true });
|
|
1829
|
+
const response = withRetryAfter(await this.request('POST', '/consent/renew', body, undefined, { ...this.mutation(options), raw: true }));
|
|
1687
1830
|
this.logger.info('Consent renewed', {
|
|
1688
1831
|
dataPrincipal: dataPrincipalId,
|
|
1689
1832
|
purposes: purposeIds,
|
|
@@ -1702,7 +1845,7 @@ class ConsentManager {
|
|
|
1702
1845
|
captured_at: buildAffirmativeAction(uiEventId).captured_at,
|
|
1703
1846
|
client_context: this.context(),
|
|
1704
1847
|
};
|
|
1705
|
-
const response = await this.request('POST', '/consent/renew/bulk', body, undefined, { ...this.mutation(options), raw: true });
|
|
1848
|
+
const response = withRetryAfter(await this.request('POST', '/consent/renew/bulk', body, undefined, { ...this.mutation(options), raw: true }));
|
|
1706
1849
|
this.logger.info('Bulk consent renewed', {
|
|
1707
1850
|
dataPrincipal: dataPrincipalId,
|
|
1708
1851
|
purposes: response.body.success_count,
|
|
@@ -1715,6 +1858,25 @@ class ConsentManager {
|
|
|
1715
1858
|
return response;
|
|
1716
1859
|
}
|
|
1717
1860
|
}
|
|
1861
|
+
/**
|
|
1862
|
+
* The renewal back-off is `retry_after` on the wire (seconds) — the platform's
|
|
1863
|
+
* canonical field name. RenewResponse used to model `retry_after_seconds`, which
|
|
1864
|
+
* the API never sends. For one release both spellings are read and both are
|
|
1865
|
+
* populated: `retry_after` wins when both arrive, a legacy `retry_after_seconds`
|
|
1866
|
+
* still decodes, and the deprecated alias mirrors the canonical value. Nothing is
|
|
1867
|
+
* added when neither is present, and the caller's object is never mutated.
|
|
1868
|
+
*/
|
|
1869
|
+
function withRetryAfter(response) {
|
|
1870
|
+
const body = response?.body;
|
|
1871
|
+
if (!body)
|
|
1872
|
+
return response;
|
|
1873
|
+
const canonical = typeof body.retry_after === 'number' ? body.retry_after : undefined;
|
|
1874
|
+
const legacy = typeof body.retry_after_seconds === 'number' ? body.retry_after_seconds : undefined;
|
|
1875
|
+
const value = canonical ?? legacy;
|
|
1876
|
+
if (value === undefined || (body.retry_after === value && body.retry_after_seconds === value))
|
|
1877
|
+
return response;
|
|
1878
|
+
return { ...response, body: { ...body, retry_after: value, retry_after_seconds: value } };
|
|
1879
|
+
}
|
|
1718
1880
|
|
|
1719
1881
|
/**
|
|
1720
1882
|
* Consentera Consent SDK — Callback Handler
|