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