@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/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,119 @@
|
|
|
1
|
-
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@consentera/consent-sdk`.
|
|
4
|
+
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [2.1.0] — 2026-09-27
|
|
7
|
+
|
|
8
|
+
A minor release. Nothing you call today changes shape; the new behaviour is
|
|
9
|
+
additive or opt-in. Detail for each item is in the sections below.
|
|
10
|
+
|
|
11
|
+
- **Added:** `ValidateResponse.applied` / `retry_after` and the opt-in
|
|
12
|
+
`validate(who, code, { retryWhenPending: true })` (F232, platform
|
|
13
|
+
`936715ad71`).
|
|
14
|
+
- **Added:** `ConsenteraPlanLimitError` (`kind: 'plan_limit'`) for the
|
|
15
|
+
platform's 409 `PLAN_LIMIT_REACHED`. An exhaustive `switch` on `kind` needs
|
|
16
|
+
the new case.
|
|
17
|
+
- **Fixed:** `RenewResponse.retry_after` / `BulkRenewResponse.retry_after` are
|
|
18
|
+
read from the field the API sends.
|
|
19
|
+
- **Fixed:** `apiEndpoint` / `proxyEndpoint` and the notice widget's
|
|
20
|
+
`data-api-base` keep their path prefix and never produce `//`.
|
|
21
|
+
- **Deprecated:** the notice widget deriving its API base from its own
|
|
22
|
+
`<script src>` (HC-12). It still works and logs one warning; set
|
|
23
|
+
`data-api-base`.
|
|
24
|
+
- **Deprecated:** `RenewResponse.retry_after_seconds`; removed in the next
|
|
25
|
+
minor release. Read `retry_after`.
|
|
26
|
+
|
|
27
|
+
## Detail (2.1.0) — validate says when its answer is not yet applied (F232)
|
|
28
|
+
|
|
29
|
+
Platform producer: **`936715ad71`** (F129, the pending-act overlay on
|
|
30
|
+
`POST /consent/validate`). From that commit the platform keeps an older DENY
|
|
31
|
+
while a newer grant is still being applied, and marks the answer
|
|
32
|
+
`applied: false` with `retry_after` (seconds).
|
|
33
|
+
|
|
34
|
+
### Added — MINOR version bump
|
|
35
|
+
|
|
36
|
+
- **`ValidateResponse.applied`** (boolean, always set by the SDK) and
|
|
37
|
+
**`ValidateResponse.retry_after`** (seconds, optional), on `validate` and on
|
|
38
|
+
every `validateBulk` result. An absent `applied` (a platform older than
|
|
39
|
+
`936715ad71`) and a non-boolean one both read as `true`; a `retry_after` that
|
|
40
|
+
is not a finite number >= 0 is dropped.
|
|
41
|
+
- **`ValidateOptions.retryWhenPending`** (default `false`): when the answer is
|
|
42
|
+
`applied: false`, wait `retry_after` (1 s if absent, capped at 5 s,
|
|
43
|
+
`VALIDATE_PENDING_RETRY_CAP_SECONDS`), ask once more and return that answer.
|
|
44
|
+
Honours `signal` during the wait. Single-purpose `validate`/`isAllowed` only.
|
|
45
|
+
- `normalizeValidateResponse` is exported for callers that read validate
|
|
46
|
+
through their own transport.
|
|
47
|
+
|
|
48
|
+
Nothing changes unless you read the new fields or pass the option.
|
|
49
|
+
|
|
50
|
+
## Detail (2.1.0) — base URLs keep their prefix; the notice widget's src-derived base is deprecated
|
|
51
|
+
|
|
52
|
+
### Changed — MINOR version bump
|
|
53
|
+
|
|
54
|
+
- **`consentera-notice-widget.js`: a path prefix in `data-api-base` is kept** on
|
|
55
|
+
every request. The post-submit hand-over uses the server's absolute URL
|
|
56
|
+
(`consent_url`, else `redirect_url`) as-is and resolves a relative one under
|
|
57
|
+
the base instead of against the host root. A non-http(s) redirect target is
|
|
58
|
+
refused.
|
|
59
|
+
- The widget without `data-api-base` still works. It now derives the base more
|
|
60
|
+
carefully: a src of the platform's form `<base>/sdk/<file>` gives `<base>`, so
|
|
61
|
+
a gateway prefix survives (2.0 kept only the origin); any other src gives its
|
|
62
|
+
origin, as before.
|
|
63
|
+
|
|
64
|
+
### Deprecated
|
|
65
|
+
|
|
66
|
+
- **Deriving the notice widget's API base from its own `src`.** It is wrong for
|
|
67
|
+
a self-hosted or CDN-served copy (the requests go to the CDN). The widget
|
|
68
|
+
logs one `console.warn`: `set data-api-base; deriving from the script src is
|
|
69
|
+
deprecated`. Set `data-api-base`. With neither the attribute nor an http(s)
|
|
70
|
+
src (an inline copy), the widget logs an error, emits
|
|
71
|
+
`consentera:notice-error` and makes no request.
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
|
|
75
|
+
- `apiEndpoint` and `proxyEndpoint` are trimmed of whitespace and trailing
|
|
76
|
+
slashes once, when the config is read (`normalizeBaseUrl`), so
|
|
77
|
+
`https://x/prefix/` and `https://x/prefix` produce the same single-slash URLs
|
|
78
|
+
with the prefix kept. Applies to the transport (`ConsentEraClient`) and the
|
|
79
|
+
cookie SDK (`ConsentEraConsent`).
|
|
80
|
+
|
|
81
|
+
## Detail (2.1.0) — renewal back-off reads `retry_after`
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- **`RenewResponse.retry_after`** (seconds). The platform names the back-off
|
|
86
|
+
field `retry_after` — the canonical field registry's name and the one every
|
|
87
|
+
202 envelope carries (platform pre-main, `consent/collection.go:4531`,
|
|
88
|
+
`consent/update_context.go:228`). `RenewResponse` (and `BulkRenewResponse`)
|
|
89
|
+
modelled `retry_after_seconds`, which the API has never sent, so a caller
|
|
90
|
+
reading the documented field always got `undefined`. `renew` and `renewBulk`
|
|
91
|
+
now return `retry_after`.
|
|
92
|
+
|
|
93
|
+
### Deprecated
|
|
94
|
+
|
|
95
|
+
- **`RenewResponse.retry_after_seconds`**, for one release. It is populated
|
|
96
|
+
from `retry_after` so existing readers start working, and a body carrying
|
|
97
|
+
only the old spelling still decodes into `retry_after`; when both arrive,
|
|
98
|
+
`retry_after` wins. It will be removed in the next minor release.
|
|
99
|
+
|
|
100
|
+
## Detail (2.1.0) — plan-limit refusal is its own error
|
|
101
|
+
|
|
102
|
+
### Added
|
|
103
|
+
|
|
104
|
+
- **`ConsenteraPlanLimitError`** (`kind: 'plan_limit'`). The platform refuses a
|
|
105
|
+
consent submit or update that would add a NEW (data principal, purpose)
|
|
106
|
+
grant past the plan's `consents_max` with 409 `PLAN_LIMIT_REACHED`,
|
|
107
|
+
`details.reason_code: 'new_consents_only'`, and records nothing (platform
|
|
108
|
+
pre-main, core/consentbridge/grant_cap.go). It used to fall through to
|
|
109
|
+
`kind: 'conflict'` — this SDK's word for a duplicate. It now carries
|
|
110
|
+
`reasonCode`, `limitKey`, `details` and `recorded: false`, is never retried,
|
|
111
|
+
and fires no `consent.changed`. The DEPA ingest's
|
|
112
|
+
`ARTEFACT_NOT_ACCEPTED_PLAN_LIMIT` maps to the same class.
|
|
113
|
+
`ConsenteraErrorKind` gains `'plan_limit'`: an exhaustive `switch` on `kind`
|
|
114
|
+
needs the new case.
|
|
115
|
+
|
|
116
|
+
## Detail (2.0.0) — renewal request wire correction
|
|
2
117
|
|
|
3
118
|
Existing `renewBulk(principalId, purposeIds, noticeHash, uiEventId, options)`
|
|
4
119
|
keeps its call signature and maps purpose IDs to the platform's `renewals`
|
|
@@ -8,12 +123,7 @@ argument remains accepted but is not a server-stored renewal field. Exported
|
|
|
8
123
|
request types now describe these actual wire bodies. Response metadata and
|
|
9
124
|
idempotency/cancellation options remain available.
|
|
10
125
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
All notable changes to `@consentera/consent-sdk`.
|
|
14
|
-
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
15
|
-
|
|
16
|
-
## Unreleased — callback status contract (not published)
|
|
126
|
+
## Detail (2.0.0) — callback status contract
|
|
17
127
|
|
|
18
128
|
### Changed
|
|
19
129
|
|
|
@@ -47,7 +157,7 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
47
157
|
- **`CallbackStatus` no longer includes `'expired'`.** Nothing on the platform
|
|
48
158
|
produced it; a return URL saying `status=expired` is now `unverified`.
|
|
49
159
|
|
|
50
|
-
##
|
|
160
|
+
## Detail (2.0.0) — M4 integration
|
|
51
161
|
|
|
52
162
|
### Changed
|
|
53
163
|
|
package/README.md
CHANGED
|
@@ -259,6 +259,19 @@ if (await ce.consent.isAllowed({ data_principal_identifiers: { email } }, 'produ
|
|
|
259
259
|
the next call can name the person by `{ data_principal_id }` and send no
|
|
260
260
|
identifier at all.
|
|
261
261
|
|
|
262
|
+
**A DENY that is still catching up.** Right after the person grants, the
|
|
263
|
+
platform can still be applying that grant, and until it has, validate answers
|
|
264
|
+
from the state it recorded before, typically a DENY. From platform `936715ad71`
|
|
265
|
+
the answer says so: `applied` is false and `retry_after` gives the seconds to
|
|
266
|
+
wait before asking again. That flag is true on every other answer, and on a
|
|
267
|
+
platform older than that commit (the field is absent there). A newer *refusal*
|
|
268
|
+
is never pending: it decides at once. Treat an unapplied DENY as "not yet", not
|
|
269
|
+
as "no": keep processing off, and ask again. `ce.consent.validate(who,
|
|
270
|
+
'product_analytics', { retryWhenPending: true })` waits `retry_after` (1 s if
|
|
271
|
+
none was sent, capped at 5 s), asks once more and returns that second answer,
|
|
272
|
+
whatever it says. The re-ask is off by default. Bulk results carry both fields
|
|
273
|
+
but are not re-asked.
|
|
274
|
+
|
|
262
275
|
### Naming a Data Principal
|
|
263
276
|
|
|
264
277
|
`data_principal_identifiers` is an **open map keyed by your organisation's own
|
|
@@ -387,6 +400,49 @@ withdraw, …) remain server-to-server and go through `proxyEndpoint` in a brows
|
|
|
387
400
|
|
|
388
401
|
---
|
|
389
402
|
|
|
403
|
+
## Base URL and path prefixes
|
|
404
|
+
|
|
405
|
+
`apiEndpoint` and `proxyEndpoint` may carry a path prefix
|
|
406
|
+
(`https://gw.corp.example/consentera`); the prefix is kept. Trailing slashes are
|
|
407
|
+
trimmed once, when the configuration is read, so `https://x/prefix/` and
|
|
408
|
+
`https://x/prefix` send the same request and no request carries `//`.
|
|
409
|
+
|
|
410
|
+
Against `apiEndpoint` the SDK appends `/api/v1/public/...` (consent roads) and
|
|
411
|
+
`/api/v1/cookie-consent/...` (the cookie banner). That `/api/v1` route prefix is
|
|
412
|
+
fixed and not configurable. Through `proxyEndpoint` it appends only the SDK path
|
|
413
|
+
(`/consent/...`, `/df/...`), so your proxy chooses the upstream path — that is
|
|
414
|
+
the way to target a gateway that re-maps `/api/v1`. The root README, "Base URLs,
|
|
415
|
+
gateway prefixes and the fixed route prefix", has the rule for every SDK.
|
|
416
|
+
|
|
417
|
+
### The drop-in notice widget (`consentera-notice-widget.js`)
|
|
418
|
+
|
|
419
|
+
**Set `data-api-base`.** Without it the widget falls back to deriving the base
|
|
420
|
+
from its own `src`, and that fallback is **deprecated**. When the src has the
|
|
421
|
+
platform's form `<API base>/sdk/<file>`, everything before `/sdk/` is used, so a
|
|
422
|
+
gateway prefix is kept. Any other src is ambiguous and only its **origin** is
|
|
423
|
+
used. That is wrong whenever the file is self-hosted or served from a CDN,
|
|
424
|
+
because the requests then go to the CDN. The widget logs one `console.warn`
|
|
425
|
+
(`set data-api-base; deriving from the script src is deprecated`). An inline
|
|
426
|
+
copy with neither the attribute nor an http(s) `src` logs
|
|
427
|
+
`[consentera-notice] data-api-base is required`, emits
|
|
428
|
+
`consentera:notice-error`, and makes **no** network call.
|
|
429
|
+
|
|
430
|
+
```html
|
|
431
|
+
<div id="consentera-notice"></div>
|
|
432
|
+
<script
|
|
433
|
+
src="https://cdn.your-site.example/consentera-notice-widget.js"
|
|
434
|
+
data-api-base="https://<your Consentera API host>[/<gateway prefix>]"
|
|
435
|
+
data-tenant-code="<your tenant code>"
|
|
436
|
+
data-slug="<consent page slug>"
|
|
437
|
+
></script>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
A path prefix in `data-api-base` is kept on every call. The hand-over to the
|
|
441
|
+
hosted consent page uses the server's URL exactly as sent when it is absolute;
|
|
442
|
+
a relative one is resolved under `data-api-base`, prefix included.
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
390
446
|
## Reliability
|
|
391
447
|
|
|
392
448
|
Every request carries a deadline, retries safely, and can be cancelled.
|
|
@@ -431,6 +487,39 @@ try {
|
|
|
431
487
|
|
|
432
488
|
Switch on `kind` (closed, exhaustive); read `code` for the platform's exact word.
|
|
433
489
|
|
|
490
|
+
### When the organisation's plan cannot take a new consent
|
|
491
|
+
|
|
492
|
+
`consent.update()` (and `grant()` / `deny()`, which call it) throws
|
|
493
|
+
**`ConsenteraPlanLimitError`** — `kind: 'plan_limit'`, `code: 'PLAN_LIMIT_REACHED'`,
|
|
494
|
+
`status: 409`, `reasonCode: 'new_consents_only'` — when the change would add a
|
|
495
|
+
**new** (data principal, purpose) grant past the plan's `consents_max`.
|
|
496
|
+
**Nothing was recorded**, no `consent.changed` event fires, and the SDK does not
|
|
497
|
+
retry it (the same request gets the same answer until the plan changes).
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
import { ConsenteraPlanLimitError } from '@consentera/consent-sdk';
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
await ce.consent.grant(principalId, [purposeId], noticeContext);
|
|
504
|
+
} catch (err) {
|
|
505
|
+
if (err instanceof ConsenteraPlanLimitError) {
|
|
506
|
+
// NOT the person's refusal — never store or show it as "denied".
|
|
507
|
+
// Leave their existing choices as they were and tell them, neutrally,
|
|
508
|
+
// that this organisation can't accept new consents right now
|
|
509
|
+
// (err.message is the platform's person-facing sentence).
|
|
510
|
+
showNotice(err.message);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
throw err;
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
A denial, a withdrawal, a renewal and a re-grant of a purpose the person already
|
|
518
|
+
holds are never refused, and creating a session is never refused. On the hosted
|
|
519
|
+
page (`collect` popup / redirect) the platform shows the person the message
|
|
520
|
+
itself and fires no callback, so the popup resolves `dismissed` when they close
|
|
521
|
+
it — never `denied`.
|
|
522
|
+
|
|
434
523
|
## Privacy defaults
|
|
435
524
|
|
|
436
525
|
- **`collectContext: 'minimal'`** by default: platform, device type, browser
|
|
@@ -447,8 +536,8 @@ Switch on `kind` (closed, exhaustive); read `code` for the platform's exact word
|
|
|
447
536
|
Every request carries the pair agreed across all six Consentera SDKs:
|
|
448
537
|
|
|
449
538
|
```
|
|
450
|
-
User-Agent: ConsenteraSDK/2.
|
|
451
|
-
X-Consentera-SDK: js/2.
|
|
539
|
+
User-Agent: ConsenteraSDK/2.1.0 (<platform>; <runtime>)
|
|
540
|
+
X-Consentera-SDK: js/2.1.0
|
|
452
541
|
```
|
|
453
542
|
|
|
454
543
|
`ConsenteraSDK/<version>` is the form the platform's audit pipeline already
|
|
@@ -1749,6 +1749,15 @@ const CODE_KIND = {
|
|
|
1749
1749
|
CONFLICT: 'conflict',
|
|
1750
1750
|
IDEMPOTENCY_KEY_REUSE: 'conflict',
|
|
1751
1751
|
RATE_LIMIT_EXCEEDED: 'rate_limit',
|
|
1752
|
+
// 409 — the ORGANISATION's plan, never the person's answer. The consent cap
|
|
1753
|
+
// refuses a submit/update that would add a NEW (data principal, purpose)
|
|
1754
|
+
// grant past the plan's consents_max (platform
|
|
1755
|
+
// core/consentbridge/grant_cap.go: NewGrantCapRefusal), and nothing was
|
|
1756
|
+
// recorded. The DEPA ingest answers the same stop in its own vocabulary
|
|
1757
|
+
// (consent/depa/df_handlers.go). Its own kind, so it is never read as a
|
|
1758
|
+
// `conflict` (a duplicate), a network failure, or a refusal by the person.
|
|
1759
|
+
PLAN_LIMIT_REACHED: 'plan_limit',
|
|
1760
|
+
ARTEFACT_NOT_ACCEPTED_PLAN_LIMIT: 'plan_limit',
|
|
1752
1761
|
INTERNAL_ERROR: 'server',
|
|
1753
1762
|
INTERNAL_SERVER_ERROR: 'server',
|
|
1754
1763
|
DATABASE_ERROR: 'server',
|
|
@@ -1857,6 +1866,41 @@ class ConsenteraNetworkError extends ConsenteraError {
|
|
|
1857
1866
|
}
|
|
1858
1867
|
class ConsenteraTimeoutError extends ConsenteraError {
|
|
1859
1868
|
}
|
|
1869
|
+
/**
|
|
1870
|
+
* The organisation's plan cannot take this change: HTTP 409 with code
|
|
1871
|
+
* `PLAN_LIMIT_REACHED` (or `ARTEFACT_NOT_ACCEPTED_PLAN_LIMIT` on the DEPA
|
|
1872
|
+
* ingest). For consent it means the submit or update would have added a NEW
|
|
1873
|
+
* (data principal, purpose) grant past the plan's `consents_max`, and
|
|
1874
|
+
* `reasonCode` is `'new_consents_only'`.
|
|
1875
|
+
*
|
|
1876
|
+
* What it is NOT, and must never be shown or reported as:
|
|
1877
|
+
* * the person's refusal — they did not deny anything; do not record,
|
|
1878
|
+
* display or emit it as `denied`;
|
|
1879
|
+
* * a transient failure — retrying the same request gets the same answer
|
|
1880
|
+
* until the organisation's plan changes, so this SDK never retries it.
|
|
1881
|
+
*
|
|
1882
|
+
* `recorded` is always `false`: the platform wrote nothing, fired no callback
|
|
1883
|
+
* and emitted no event. Show the person a neutral "this organisation can't
|
|
1884
|
+
* accept new consents right now" (the platform's own `message` is written for
|
|
1885
|
+
* them) and leave their existing choices as they were.
|
|
1886
|
+
*/
|
|
1887
|
+
class ConsenteraPlanLimitError extends ConsenteraError {
|
|
1888
|
+
/** `details.reason_code` — `'new_consents_only'` for the consent cap. */
|
|
1889
|
+
reasonCode;
|
|
1890
|
+
/** `details.limit_key` — set on an admin create refusal (e.g. `'consents_max'`), absent on a consent submit. */
|
|
1891
|
+
limitKey;
|
|
1892
|
+
/** The envelope's `details`, as sent. */
|
|
1893
|
+
details;
|
|
1894
|
+
/** Always false: a plan-limit refusal records nothing. */
|
|
1895
|
+
recorded = false;
|
|
1896
|
+
constructor(init) {
|
|
1897
|
+
super({ ...init, retryable: false });
|
|
1898
|
+
const d = init.details;
|
|
1899
|
+
this.details = d;
|
|
1900
|
+
this.reasonCode = typeof d?.reason_code === 'string' ? d.reason_code : undefined;
|
|
1901
|
+
this.limitKey = typeof d?.limit_key === 'string' ? d.limit_key : undefined;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1860
1904
|
const KIND_CLASS = {
|
|
1861
1905
|
config: ConsenteraConfigError,
|
|
1862
1906
|
auth: ConsenteraAuthError,
|
|
@@ -1867,6 +1911,7 @@ const KIND_CLASS = {
|
|
|
1867
1911
|
not_found: ConsenteraNotFoundError,
|
|
1868
1912
|
conflict: ConsenteraConflictError,
|
|
1869
1913
|
rate_limit: ConsenteraRateLimitError,
|
|
1914
|
+
plan_limit: ConsenteraPlanLimitError,
|
|
1870
1915
|
server: ConsenteraServerError,
|
|
1871
1916
|
network: ConsenteraNetworkError,
|
|
1872
1917
|
timeout: ConsenteraTimeoutError,
|
|
@@ -1876,7 +1921,7 @@ const KIND_CLASS = {
|
|
|
1876
1921
|
function newOfKind(init) {
|
|
1877
1922
|
return new KIND_CLASS[init.kind](init);
|
|
1878
1923
|
}
|
|
1879
|
-
/** Pull `{code, message}` off a parsed body, tolerating the `{error:{...}}` wrapper. */
|
|
1924
|
+
/** Pull `{code, message, details}` off a parsed body, tolerating the `{error:{...}}` wrapper. */
|
|
1880
1925
|
function readEnvelope(body) {
|
|
1881
1926
|
if (!body || typeof body !== 'object')
|
|
1882
1927
|
return {};
|
|
@@ -1884,16 +1929,20 @@ function readEnvelope(body) {
|
|
|
1884
1929
|
const inner = o.error && typeof o.error === 'object' ? o.error : o;
|
|
1885
1930
|
const code = typeof inner.code === 'string' ? inner.code : undefined;
|
|
1886
1931
|
const message = typeof inner.message === 'string' ? inner.message : undefined;
|
|
1887
|
-
|
|
1932
|
+
const details = inner.details && typeof inner.details === 'object' && !Array.isArray(inner.details)
|
|
1933
|
+
? inner.details
|
|
1934
|
+
: undefined;
|
|
1935
|
+
return { code, message, details };
|
|
1888
1936
|
}
|
|
1889
1937
|
/** Build the error for a non-2xx response. */
|
|
1890
1938
|
function errorFromResponse(args) {
|
|
1891
|
-
const { code, message } = readEnvelope(args.body);
|
|
1939
|
+
const { code, message, details } = readEnvelope(args.body);
|
|
1892
1940
|
const kind = (code && CODE_KIND[code]) || kindForStatus(args.status);
|
|
1893
|
-
const retryable = args.status === 429 || args.status >= 500;
|
|
1941
|
+
const retryable = kind !== 'plan_limit' && (args.status === 429 || args.status >= 500);
|
|
1894
1942
|
return newOfKind({
|
|
1895
1943
|
kind,
|
|
1896
1944
|
code,
|
|
1945
|
+
details,
|
|
1897
1946
|
status: args.status,
|
|
1898
1947
|
requestId: args.requestId,
|
|
1899
1948
|
responseBody: args.body,
|
|
@@ -1936,7 +1985,7 @@ const ConsentEraApiError = ConsenteraError;
|
|
|
1936
1985
|
* being overwritten with the wrong value and nothing noticing.
|
|
1937
1986
|
*/
|
|
1938
1987
|
const SDK_NAME = 'consent-sdk-js';
|
|
1939
|
-
const SDK_VERSION = '2.
|
|
1988
|
+
const SDK_VERSION = '2.1.0';
|
|
1940
1989
|
const SDK_PLATFORM = 'web';
|
|
1941
1990
|
/** The value of the `X-Consentera-SDK` header: `<surface>/<version>`. */
|
|
1942
1991
|
const SDK_HEADER_VALUE = `js/${SDK_VERSION}`;
|
|
@@ -1944,8 +1993,8 @@ const SDK_HEADER_VALUE = `js/${SDK_VERSION}`;
|
|
|
1944
1993
|
* The `User-Agent` half of the pair, agreed across all six surfaces
|
|
1945
1994
|
* (coordinator ruling 2026-09-22):
|
|
1946
1995
|
*
|
|
1947
|
-
* User-Agent: ConsenteraSDK/2.
|
|
1948
|
-
* X-Consentera-SDK: <surface>/2.
|
|
1996
|
+
* User-Agent: ConsenteraSDK/2.1.0 (<platform>; <runtime>)
|
|
1997
|
+
* X-Consentera-SDK: <surface>/2.1.0
|
|
1949
1998
|
*
|
|
1950
1999
|
* `ConsenteraSDK/<version>` is the form the PLATFORM ALREADY PARSES:
|
|
1951
2000
|
* `internal/core/audit/user_agent_coarsening_test.go:39-40` asserts that
|
|
@@ -2047,7 +2096,7 @@ function newRequestId() {
|
|
|
2047
2096
|
// on such a browser should supply its own idempotencyKey.
|
|
2048
2097
|
return `nc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
2049
2098
|
}
|
|
2050
|
-
function sleep(ms, signal) {
|
|
2099
|
+
function sleep$1(ms, signal) {
|
|
2051
2100
|
return new Promise((resolve, reject) => {
|
|
2052
2101
|
if (signal?.aborted) {
|
|
2053
2102
|
reject(cancelledError('request cancelled'));
|
|
@@ -2069,16 +2118,35 @@ function backoffMs(attempt, policy, random = Math.random) {
|
|
|
2069
2118
|
const exp = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt);
|
|
2070
2119
|
return Math.floor(random() * exp);
|
|
2071
2120
|
}
|
|
2121
|
+
/**
|
|
2122
|
+
* The ONE normalisation of a configured base URL (HC-13): surrounding
|
|
2123
|
+
* whitespace and every trailing slash are trimmed, once, when the config is
|
|
2124
|
+
* read — and nothing else changes, so a gateway path prefix
|
|
2125
|
+
* ("https://gw.corp/consentera/") is kept. Every join below then adds exactly
|
|
2126
|
+
* one slash. The same rule Android (`trimEnd('/')`) and the CLI (`/\/+$/`)
|
|
2127
|
+
* apply.
|
|
2128
|
+
*/
|
|
2129
|
+
function normalizeBaseUrl(base) {
|
|
2130
|
+
return (typeof base === 'string' ? base.trim().replace(/\/+$/, '') : base);
|
|
2131
|
+
}
|
|
2132
|
+
function normalizeBases(config) {
|
|
2133
|
+
const out = { ...config };
|
|
2134
|
+
if (out.apiEndpoint !== undefined)
|
|
2135
|
+
out.apiEndpoint = normalizeBaseUrl(out.apiEndpoint);
|
|
2136
|
+
if (out.proxyEndpoint !== undefined)
|
|
2137
|
+
out.proxyEndpoint = normalizeBaseUrl(out.proxyEndpoint);
|
|
2138
|
+
return out;
|
|
2139
|
+
}
|
|
2072
2140
|
class HttpTransport {
|
|
2073
2141
|
config;
|
|
2074
2142
|
logger;
|
|
2075
2143
|
constructor(config, logger) {
|
|
2076
|
-
this.config = config;
|
|
2144
|
+
this.config = normalizeBases(config);
|
|
2077
2145
|
this.logger = logger;
|
|
2078
2146
|
}
|
|
2079
2147
|
/** Swap config after construction (the client re-reads customHeaders each call). */
|
|
2080
2148
|
updateConfig(patch) {
|
|
2081
|
-
this.config = { ...this.config, ...patch };
|
|
2149
|
+
this.config = { ...this.config, ...normalizeBases(patch) };
|
|
2082
2150
|
}
|
|
2083
2151
|
/**
|
|
2084
2152
|
* The credential decision for one road, in one place so the policy can be
|
|
@@ -2300,7 +2368,7 @@ class HttpTransport {
|
|
|
2300
2368
|
throw lastError;
|
|
2301
2369
|
const wait = lastError.retryAfterMs ?? backoffMs(attempt, policy);
|
|
2302
2370
|
this.logger.debug(`${method} ${path} retry ${attempt + 1}/${policy.attempts - 1} in ${wait}ms (${lastError.code ?? lastError.kind})`);
|
|
2303
|
-
await sleep(wait, options.signal);
|
|
2371
|
+
await sleep$1(wait, options.signal);
|
|
2304
2372
|
}
|
|
2305
2373
|
/* istanbul ignore next — the loop always throws on its last attempt. */
|
|
2306
2374
|
throw lastError ?? networkError(`${method} ${path} failed`);
|
|
@@ -2379,7 +2447,9 @@ class ConsentEraConsent extends EventEmitter {
|
|
|
2379
2447
|
"Consentera API origin (the SDK calls `${apiEndpoint}/api/v1/cookie-consent/*`); with " +
|
|
2380
2448
|
'the script-tag build, set `data-api-endpoint`.', 'ENDPOINT_REQUIRED');
|
|
2381
2449
|
}
|
|
2382
|
-
|
|
2450
|
+
// Trimmed ONCE, here (HC-13): "https://x/prefix/" and "https://x/prefix"
|
|
2451
|
+
// are the same base, the prefix is kept, and no request carries "//".
|
|
2452
|
+
this.config = this.mergeDefaults({ ...config, apiEndpoint: normalizeBaseUrl(config.apiEndpoint) });
|
|
2383
2453
|
this.logger = new Logger(config.apiKey ? 'info' : 'debug');
|
|
2384
2454
|
this.storage = new ConsentStorage(this.config.storage);
|
|
2385
2455
|
this.deviceId = this.getOrCreateDeviceId();
|
|
@@ -3688,6 +3758,56 @@ function principalBody(who) {
|
|
|
3688
3758
|
* ConsentEra Consent SDK — Consent Validation
|
|
3689
3759
|
* Validate consent status for single or multiple purposes
|
|
3690
3760
|
*/
|
|
3761
|
+
/** The longest the {@link ValidateOptions.retryWhenPending} re-ask will wait, in seconds. */
|
|
3762
|
+
const VALIDATE_PENDING_RETRY_CAP_SECONDS = 5;
|
|
3763
|
+
/** The wait used when an `applied: false` answer carries no usable `retry_after`. */
|
|
3764
|
+
const VALIDATE_PENDING_DEFAULT_SECONDS = 1;
|
|
3765
|
+
/**
|
|
3766
|
+
* The platform's validate answer, with `applied` and `retry_after` read the
|
|
3767
|
+
* one way every SDK reads them:
|
|
3768
|
+
*
|
|
3769
|
+
* * `applied` — a JSON boolean is kept; absent (a platform older than
|
|
3770
|
+
* 936715ad71) or any other type reads as `true`.
|
|
3771
|
+
* * `retry_after` — kept when it is a finite number >= 0 (rounded up to whole
|
|
3772
|
+
* seconds); anything else is dropped.
|
|
3773
|
+
*
|
|
3774
|
+
* Every other field passes through untouched.
|
|
3775
|
+
*/
|
|
3776
|
+
function normalizeValidateResponse(raw) {
|
|
3777
|
+
const r = raw;
|
|
3778
|
+
const out = { ...r };
|
|
3779
|
+
out.applied = typeof r.applied === 'boolean' ? r.applied : true;
|
|
3780
|
+
const ra = r.retry_after;
|
|
3781
|
+
if (typeof ra === 'number' && Number.isFinite(ra) && ra >= 0) {
|
|
3782
|
+
out.retry_after = Math.ceil(ra);
|
|
3783
|
+
}
|
|
3784
|
+
else {
|
|
3785
|
+
delete out.retry_after;
|
|
3786
|
+
}
|
|
3787
|
+
return out;
|
|
3788
|
+
}
|
|
3789
|
+
/** The wait before the one re-ask, in milliseconds. */
|
|
3790
|
+
function pendingRetryDelayMs(answer) {
|
|
3791
|
+
const asked = answer.retry_after ?? VALIDATE_PENDING_DEFAULT_SECONDS;
|
|
3792
|
+
return Math.min(asked, VALIDATE_PENDING_RETRY_CAP_SECONDS) * 1000;
|
|
3793
|
+
}
|
|
3794
|
+
function sleep(ms, signal) {
|
|
3795
|
+
return new Promise((resolve, reject) => {
|
|
3796
|
+
if (signal?.aborted) {
|
|
3797
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
3798
|
+
return;
|
|
3799
|
+
}
|
|
3800
|
+
const t = setTimeout(() => {
|
|
3801
|
+
signal?.removeEventListener('abort', onAbort);
|
|
3802
|
+
resolve();
|
|
3803
|
+
}, ms);
|
|
3804
|
+
const onAbort = () => {
|
|
3805
|
+
clearTimeout(t);
|
|
3806
|
+
reject(signal?.reason ?? new Error('aborted'));
|
|
3807
|
+
};
|
|
3808
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
3809
|
+
});
|
|
3810
|
+
}
|
|
3691
3811
|
class ConsentValidator {
|
|
3692
3812
|
request;
|
|
3693
3813
|
logger;
|
|
@@ -3716,7 +3836,19 @@ class ConsentValidator {
|
|
|
3716
3836
|
*/
|
|
3717
3837
|
async check(who, purposeCode, options) {
|
|
3718
3838
|
const body = { ...principalBody(who), purpose_code: purposeCode };
|
|
3719
|
-
const
|
|
3839
|
+
const ask = async () => normalizeValidateResponse(await this.request('POST', '/consent/validate', body, undefined, { signal: options?.signal, timeoutMs: options?.timeoutMs }));
|
|
3840
|
+
let response = await ask();
|
|
3841
|
+
if (options?.retryWhenPending && response.applied === false) {
|
|
3842
|
+
// ONE re-ask, never a loop: the second answer is returned whatever it
|
|
3843
|
+
// says, so a projection that is still behind cannot hold the caller.
|
|
3844
|
+
const waitMs = pendingRetryDelayMs(response);
|
|
3845
|
+
this.logger.debug('Validate answer not yet applied; asking once more', {
|
|
3846
|
+
purpose: purposeCode,
|
|
3847
|
+
waitMs,
|
|
3848
|
+
});
|
|
3849
|
+
await sleep(waitMs, options.signal);
|
|
3850
|
+
response = await ask();
|
|
3851
|
+
}
|
|
3720
3852
|
// Never log the identifiers themselves — they are raw PII, and this line
|
|
3721
3853
|
// used to carry the opaque handle verbatim. Log WHICH way the person was
|
|
3722
3854
|
// named, which is what a support question actually needs.
|
|
@@ -3739,7 +3871,10 @@ class ConsentValidator {
|
|
|
3739
3871
|
...principalBody(who),
|
|
3740
3872
|
purpose_codes: purposeCodes,
|
|
3741
3873
|
};
|
|
3742
|
-
const
|
|
3874
|
+
const raw = await this.request('POST', '/consent/validate/bulk', body, undefined, { signal: options?.signal, timeoutMs: options?.timeoutMs });
|
|
3875
|
+
const response = Array.isArray(raw?.results)
|
|
3876
|
+
? { ...raw, results: raw.results.map((r) => normalizeValidateResponse(r)) }
|
|
3877
|
+
: raw;
|
|
3743
3878
|
this.logger.debug('Bulk consent validated', {
|
|
3744
3879
|
namedBy: 'data_principal_id' in who ? 'data_principal_id' : 'data_principal_identifiers',
|
|
3745
3880
|
purposes: purposeCodes.length,
|
|
@@ -3854,6 +3989,16 @@ class ConsentManager {
|
|
|
3854
3989
|
/**
|
|
3855
3990
|
* Update consent decisions for a data principal.
|
|
3856
3991
|
* Pass an array of purpose updates (grant or deny).
|
|
3992
|
+
*
|
|
3993
|
+
* @throws {ConsenteraPlanLimitError} HTTP 409 `PLAN_LIMIT_REACHED`,
|
|
3994
|
+
* `reasonCode: 'new_consents_only'` — the update would add a NEW
|
|
3995
|
+
* (data principal, purpose) grant past the organisation's plan
|
|
3996
|
+
* (`consents_max`). NOTHING was recorded and no `consent.changed` event
|
|
3997
|
+
* fires. It is not the person's refusal: never record or show it as
|
|
3998
|
+
* `denied`, and do not retry the same update — show a neutral "can't
|
|
3999
|
+
* accept new consents right now" instead. A denial, a withdrawal and a
|
|
4000
|
+
* re-grant of a purpose the person already holds are never refused.
|
|
4001
|
+
* `grant()` and `deny()` go through here and throw the same.
|
|
3857
4002
|
*/
|
|
3858
4003
|
async update(dataPrincipalId, updates, context, uiEventId = 'btn_save_preferences', options) {
|
|
3859
4004
|
const body = {
|
|
@@ -3963,7 +4108,7 @@ class ConsentManager {
|
|
|
3963
4108
|
captured_at: buildAffirmativeAction(uiEventId).captured_at,
|
|
3964
4109
|
client_context: this.context(),
|
|
3965
4110
|
};
|
|
3966
|
-
const response = await this.request('POST', '/consent/renew', body, undefined, { ...this.mutation(options), raw: true });
|
|
4111
|
+
const response = withRetryAfter(await this.request('POST', '/consent/renew', body, undefined, { ...this.mutation(options), raw: true }));
|
|
3967
4112
|
this.logger.info('Consent renewed', {
|
|
3968
4113
|
dataPrincipal: dataPrincipalId,
|
|
3969
4114
|
purposes: purposeIds,
|
|
@@ -3982,7 +4127,7 @@ class ConsentManager {
|
|
|
3982
4127
|
captured_at: buildAffirmativeAction(uiEventId).captured_at,
|
|
3983
4128
|
client_context: this.context(),
|
|
3984
4129
|
};
|
|
3985
|
-
const response = await this.request('POST', '/consent/renew/bulk', body, undefined, { ...this.mutation(options), raw: true });
|
|
4130
|
+
const response = withRetryAfter(await this.request('POST', '/consent/renew/bulk', body, undefined, { ...this.mutation(options), raw: true }));
|
|
3986
4131
|
this.logger.info('Bulk consent renewed', {
|
|
3987
4132
|
dataPrincipal: dataPrincipalId,
|
|
3988
4133
|
purposes: response.body.success_count,
|
|
@@ -3995,6 +4140,25 @@ class ConsentManager {
|
|
|
3995
4140
|
return response;
|
|
3996
4141
|
}
|
|
3997
4142
|
}
|
|
4143
|
+
/**
|
|
4144
|
+
* The renewal back-off is `retry_after` on the wire (seconds) — the platform's
|
|
4145
|
+
* canonical field name. RenewResponse used to model `retry_after_seconds`, which
|
|
4146
|
+
* the API never sends. For one release both spellings are read and both are
|
|
4147
|
+
* populated: `retry_after` wins when both arrive, a legacy `retry_after_seconds`
|
|
4148
|
+
* still decodes, and the deprecated alias mirrors the canonical value. Nothing is
|
|
4149
|
+
* added when neither is present, and the caller's object is never mutated.
|
|
4150
|
+
*/
|
|
4151
|
+
function withRetryAfter(response) {
|
|
4152
|
+
const body = response?.body;
|
|
4153
|
+
if (!body)
|
|
4154
|
+
return response;
|
|
4155
|
+
const canonical = typeof body.retry_after === 'number' ? body.retry_after : undefined;
|
|
4156
|
+
const legacy = typeof body.retry_after_seconds === 'number' ? body.retry_after_seconds : undefined;
|
|
4157
|
+
const value = canonical ?? legacy;
|
|
4158
|
+
if (value === undefined || (body.retry_after === value && body.retry_after_seconds === value))
|
|
4159
|
+
return response;
|
|
4160
|
+
return { ...response, body: { ...body, retry_after: value, retry_after_seconds: value } };
|
|
4161
|
+
}
|
|
3998
4162
|
|
|
3999
4163
|
/**
|
|
4000
4164
|
* Consentera Consent SDK — Callback Handler
|
|
@@ -4883,6 +5047,7 @@ exports.ConsenteraIdentityError = ConsenteraIdentityError;
|
|
|
4883
5047
|
exports.ConsenteraNetworkError = ConsenteraNetworkError;
|
|
4884
5048
|
exports.ConsenteraNotFoundError = ConsenteraNotFoundError;
|
|
4885
5049
|
exports.ConsenteraPermissionError = ConsenteraPermissionError;
|
|
5050
|
+
exports.ConsenteraPlanLimitError = ConsenteraPlanLimitError;
|
|
4886
5051
|
exports.ConsenteraRateLimitError = ConsenteraRateLimitError;
|
|
4887
5052
|
exports.ConsenteraServerError = ConsenteraServerError;
|
|
4888
5053
|
exports.ConsenteraTimeoutError = ConsenteraTimeoutError;
|
|
@@ -4899,6 +5064,7 @@ exports.SDK_NAME = SDK_NAME;
|
|
|
4899
5064
|
exports.SDK_PLATFORM = SDK_PLATFORM;
|
|
4900
5065
|
exports.SDK_VERSION = SDK_VERSION;
|
|
4901
5066
|
exports.TCFManager = TCFManager;
|
|
5067
|
+
exports.VALIDATE_PENDING_RETRY_CAP_SECONDS = VALIDATE_PENDING_RETRY_CAP_SECONDS;
|
|
4902
5068
|
exports.buildClientContext = buildClientContext;
|
|
4903
5069
|
exports.claimedCallbackStatus = claimedCallbackStatus;
|
|
4904
5070
|
exports.default = ConsentEraConsent;
|
|
@@ -4907,6 +5073,7 @@ exports.isBrowser = isBrowser;
|
|
|
4907
5073
|
exports.isSecretKey = isSecretKey;
|
|
4908
5074
|
exports.isSiteKey = isSiteKey;
|
|
4909
5075
|
exports.newRequestId = newRequestId;
|
|
5076
|
+
exports.normalizeValidateResponse = normalizeValidateResponse;
|
|
4910
5077
|
exports.parseRetryAfterMs = parseRetryAfterMs;
|
|
4911
5078
|
exports.principalBody = principalBody;
|
|
4912
5079
|
exports.readDecisionMessage = readDecisionMessage;
|