@consentera/consent-sdk 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +245 -0
- package/LICENSE +21 -0
- package/README.md +489 -0
- package/dist/consentera-consent.cjs +4919 -0
- package/dist/consentera-consent.cjs.map +1 -0
- package/dist/consentera-consent.min.js +2 -0
- package/dist/consentera-consent.min.js.map +1 -0
- package/dist/consentera-consent.mjs +4864 -0
- package/dist/consentera-consent.mjs.map +1 -0
- package/dist/react/index.cjs +2731 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.mjs +2724 -0
- package/dist/react/index.mjs.map +1 -0
- package/dist/types/consent/CallbackHandler.d.ts +246 -0
- package/dist/types/consent/ConsentManager.d.ts +128 -0
- package/dist/types/consent/ConsentSession.d.ts +127 -0
- package/dist/types/consent/ConsentValidator.d.ts +63 -0
- package/dist/types/consent/artifactRead.d.ts +48 -0
- package/dist/types/consent/consentPopup.d.ts +115 -0
- package/dist/types/core/ConsentEraClient.d.ts +106 -0
- package/dist/types/core/ConsenteraConsent.d.ts +163 -0
- package/dist/types/core/errors.d.ts +108 -0
- package/dist/types/core/http.d.ts +176 -0
- package/dist/types/core/version.d.ts +36 -0
- package/dist/types/df/DFConfigClient.d.ts +59 -0
- package/dist/types/gcm/ConsentModeBridge.d.ts +54 -0
- package/dist/types/gpp/GPPManager.d.ts +62 -0
- package/dist/types/index.d.mts +5 -0
- package/dist/types/index.d.ts +28 -0
- package/dist/types/principal/PrincipalClient.d.ts +34 -0
- package/dist/types/react/ConsentEraProvider.d.ts +58 -0
- package/dist/types/react/ConsentGate.d.ts +40 -0
- package/dist/types/react/index.d.mts +4 -0
- package/dist/types/react/index.d.ts +10 -0
- package/dist/types/react/useConsentEra.d.ts +65 -0
- package/dist/types/react/useConsentValidation.d.ts +23 -0
- package/dist/types/storage/ConsentStorage.d.ts +39 -0
- package/dist/types/tcf/TCFManager.d.ts +46 -0
- package/dist/types/types/consent-lifecycle.d.ts +804 -0
- package/dist/types/types/index.d.ts +311 -0
- package/dist/types/ui/ConsentBanner.d.ts +22 -0
- package/dist/types/ui/PreferenceCenter.d.ts +24 -0
- package/dist/types/utils/EventEmitter.d.ts +32 -0
- package/dist/types/utils/Logger.d.ts +16 -0
- package/dist/types/utils/browserStorage.d.ts +35 -0
- package/dist/types/utils/context.d.ts +81 -0
- package/dist/types/utils/helpers.d.ts +48 -0
- package/package.json +132 -0
package/README.md
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
# @consentera/consent-sdk
|
|
2
|
+
|
|
3
|
+
Consent management for the web, against the [Consentera](https://docs.consentera.in)
|
|
4
|
+
platform. India's **DPDP Act** first, with TCF 2.2 and GPP surfaces for sites that
|
|
5
|
+
also need them.
|
|
6
|
+
|
|
7
|
+
- **Consent lifecycle** — create a consent session, validate a purpose, update,
|
|
8
|
+
withdraw, renew, verify the artefact.
|
|
9
|
+
- **Cookie banner + preference centre** — a drop-in surface for cookie consent.
|
|
10
|
+
- **React** — a provider, hooks and a `<ConsentGate>` that closes when consent is
|
|
11
|
+
withdrawn.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npm install @consentera/consent-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Node ≥ 20. React ≥ 18 (optional peer, only for `@consentera/consent-sdk/react`).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quickstart (10 minutes)
|
|
22
|
+
|
|
23
|
+
### 1. Put your secret key on YOUR server, never in the page
|
|
24
|
+
|
|
25
|
+
A Data Fiduciary credential (`tiq_live_…` / `tiq_test_…`) is a **secret**. Every
|
|
26
|
+
consent lifecycle road needs one, and a browser bundle is public, so the browser
|
|
27
|
+
talks to **your** server and your server talks to us.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// app/api/consentera/[...path]/route.ts (Next.js — any server framework works)
|
|
31
|
+
export async function POST(req: Request, { params }: { params: { path: string[] } }) {
|
|
32
|
+
// CONSENTERA_API_URL is YOUR tenant's API origin — the SDK ships no default.
|
|
33
|
+
const upstream = `${process.env.CONSENTERA_API_URL}/api/v1/public/${params.path.join('/')}`;
|
|
34
|
+
const res = await fetch(upstream, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
'X-API-Key': process.env.CONSENTERA_API_KEY!, // the secret, server-side only
|
|
39
|
+
'X-Tenant-Id': process.env.CONSENTERA_TENANT_ID!,
|
|
40
|
+
// pass these through so the SDK's guarantees survive the hop
|
|
41
|
+
'Idempotency-Key': req.headers.get('Idempotency-Key') ?? '',
|
|
42
|
+
'X-Consentera-SDK': req.headers.get('X-Consentera-SDK') ?? '',
|
|
43
|
+
},
|
|
44
|
+
body: await req.text(),
|
|
45
|
+
});
|
|
46
|
+
// expose the request id so SDK errors can carry it
|
|
47
|
+
const out = new Response(res.body, { status: res.status });
|
|
48
|
+
const rid = res.headers.get('X-Request-Id');
|
|
49
|
+
if (rid) out.headers.set('X-Request-Id', rid);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2. Point the SDK at your route
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { ConsentEraClient } from '@consentera/consent-sdk';
|
|
58
|
+
|
|
59
|
+
const ce = new ConsentEraClient({
|
|
60
|
+
proxyEndpoint: '/api/consentera', // in a browser this is required
|
|
61
|
+
callbackUrl: 'https://your-site.example/consent/done',
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
No `tenantId` behind a proxy: your route supplies the tenant (the
|
|
66
|
+
`X-Tenant-Id` above) and the SDK sends no tenant header through it. With
|
|
67
|
+
`apiEndpoint` (direct, server-side) `tenantId` is required, and a client
|
|
68
|
+
without it is refused with `TENANT_ID_REQUIRED`.
|
|
69
|
+
|
|
70
|
+
Putting `apiKey` here in a browser is **refused at construction** with
|
|
71
|
+
`SECRET_KEY_IN_BROWSER`. That is deliberate — see *The credential model* below.
|
|
72
|
+
|
|
73
|
+
### 3. Ask for consent
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const session = await ce.consent.createSession({
|
|
77
|
+
// The identifiers, keyed by YOUR organisation's locked integration key.
|
|
78
|
+
// A field outside the key is 400 UNKNOWN_IDENTIFIER_FIELD, and the message
|
|
79
|
+
// lists the fields your key allows.
|
|
80
|
+
data_principal: { email: 'riya@example.in' },
|
|
81
|
+
notice_internal_name: 'bnb_consent_v2',
|
|
82
|
+
age: { date_of_birth: '1998-04-12' }, // the one age signal
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
ce.consent.redirectToConsent(session); // or openConsentPopup(session)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`createSession` adds **its own `state`** to your `callbackUrl` — 32 random bytes,
|
|
89
|
+
fresh per session, kept in this browser's session storage — so the return can
|
|
90
|
+
be tied to the browser that started it. The platform keeps every parameter
|
|
91
|
+
already on `callback_url` when it builds the return, so the state comes back.
|
|
92
|
+
`state` is therefore reserved: a `callbackUrl` that already carries one is
|
|
93
|
+
refused with `CALLBACK_STATE_RESERVED`. Pick a different name for your own
|
|
94
|
+
parameter. `callbackUrl` must be absolute (`INVALID_CALLBACK_URL` otherwise).
|
|
95
|
+
|
|
96
|
+
`openConsentPopup(session)` shows the consent page **in a dialog on your page**
|
|
97
|
+
and resolves when the person decides:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const r = await ce.consent.openConsentPopup(session);
|
|
101
|
+
if (r.outcome === 'decided') {
|
|
102
|
+
// r.status: 'granted' | 'partial' | 'denied'; r.pending: the record is still
|
|
103
|
+
// being written (redirect pending=1), so the read-back may answer 202 first
|
|
104
|
+
await confirmOnYourServer(r.session_id, r.artifact_id); // the artefact is the record
|
|
105
|
+
}
|
|
106
|
+
// r.outcome === 'dismissed': the person closed the dialog
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
It listens for the one message the consent page posts to the page that frames
|
|
110
|
+
it, `consentera:submitted` / `consentera:declined`, and only from the consent
|
|
111
|
+
page's origin and the frame it opened.
|
|
112
|
+
|
|
113
|
+
**The consent page posts that message only when it is framed.** A consent
|
|
114
|
+
page in its own window or tab (`window.open`, a redirect) posts nothing. It
|
|
115
|
+
sends the person to your `callback_url` instead, and that is what
|
|
116
|
+
`redirectToConsent` + `handleCallback` are for. That is why the popup is a
|
|
117
|
+
dialog with the page in an iframe, not a browser window. Two further
|
|
118
|
+
conditions, both set by the platform:
|
|
119
|
+
|
|
120
|
+
- the consent page addresses that message to the **origin of the session's
|
|
121
|
+
`callback_url`**, so your page must be on that origin. The SDK refuses up
|
|
122
|
+
front with `CALLBACK_ORIGIN_MISMATCH` rather than wait for a message the
|
|
123
|
+
browser would drop;
|
|
124
|
+
- the platform lets only the **Allowed Domains** of your integration client
|
|
125
|
+
frame the page.
|
|
126
|
+
|
|
127
|
+
The message is the consent page's report, not the consent record. Confirm the
|
|
128
|
+
artefact (step 4) before you act on a grant.
|
|
129
|
+
|
|
130
|
+
### 4. Verify the return trip — the artefact, not the URL
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// on https://your-site.example/consent/done
|
|
134
|
+
// arriving as ?artifact_id=…&pending=1&session_id=…&state=<ours>&status=granted|partial|denied
|
|
135
|
+
const result = await ce.consent.handleCallback();
|
|
136
|
+
|
|
137
|
+
if (result.status === 'completed') {
|
|
138
|
+
// the artefact was read from the platform FOR THIS SESSION and names the
|
|
139
|
+
// person this browser's session was created for
|
|
140
|
+
proceed(result.artifact);
|
|
141
|
+
} else {
|
|
142
|
+
// 'unverified' | 'denied' | 'pending' | 'error' — result.reason says which and why
|
|
143
|
+
askAgain(result.reason);
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`handleCallback()` is **async** and returns `completed` **only** after confirming
|
|
148
|
+
the artefact with the platform. A query string can never produce `completed`.
|
|
149
|
+
|
|
150
|
+
**What the platform puts on the return URL** — and nothing else:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
<your callback_url>?artifact_id=<uuid>&pending=1&session_id=<uuid>&status=granted|partial|denied
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
plus `&sig=<hex>` only when your integration client holds a callback signing
|
|
157
|
+
secret (a hosted consent page's return is never signed). `status` is one of
|
|
158
|
+
exactly three values, `granted`, `partial` or `denied`; the SDK exposes it as
|
|
159
|
+
`result.claimed_status`, and any other value (`completed`, `success`, `expired`,
|
|
160
|
+
a missing status) is `'unknown'` and is **never** confirmed — the result is
|
|
161
|
+
`unverified`. `pending=1` (`result.claimed_pending`) means the consent was
|
|
162
|
+
recorded and its record is **still being written**; the platform sets it on
|
|
163
|
+
every capture, and it is why the first artefact read may answer 202.
|
|
164
|
+
|
|
165
|
+
**Both are hints, not proof.** They are query parameters, and `pending` is not
|
|
166
|
+
signed. Confirm the consent by reading the record back through your backend —
|
|
167
|
+
which is what `handleCallback()` does on the `granted`/`partial` road — and read
|
|
168
|
+
the artefact's purposes for what was granted: `partial` means some purposes
|
|
169
|
+
were declined.
|
|
170
|
+
|
|
171
|
+
**Statuses**: `completed` (verified; `claimed_status` says `granted` or
|
|
172
|
+
`partial`), `denied`, `pending` (the consent WAS recorded, the artefact is not
|
|
173
|
+
readable yet — see below), `error`, and `unverified` (nothing is proven; treat
|
|
174
|
+
exactly as "no consent").
|
|
175
|
+
|
|
176
|
+
#### The signature
|
|
177
|
+
|
|
178
|
+
When you register a `callback_signing_secret` on your m2m client, the platform
|
|
179
|
+
signs the callback:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
sig = hex( HMAC_SHA256( callback_signing_secret,
|
|
183
|
+
session_id + "|" + artifact_id + "|" + status ) )
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**That key is yours and must not be in a browser.** So the browser cannot verify
|
|
187
|
+
it: point `verifyCallbackSignature` at your own server, which does.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// browser
|
|
191
|
+
const ce = new ConsentEraClient({
|
|
192
|
+
tenantId: TENANT,
|
|
193
|
+
proxyEndpoint: '/api/consentera',
|
|
194
|
+
verifyCallbackSignature: async (p) =>
|
|
195
|
+
(await fetch('/api/consentera/verify-callback', { method: 'POST', body: JSON.stringify(p) })).ok,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// your server route
|
|
199
|
+
import { verifyCallbackSignature } from '@consentera/consent-sdk';
|
|
200
|
+
const ok = await verifyCallbackSignature(process.env.CONSENTERA_CALLBACK_SECRET!, params);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
A callback that **carries** a `sig` with no verifier configured is `unverified`.
|
|
204
|
+
A signature nobody checks is not a control, so the SDK will not quietly ignore
|
|
205
|
+
one the platform bothered to produce. If you have registered no signing secret,
|
|
206
|
+
no `sig` is sent and the state plus the artefact confirmation are the controls.
|
|
207
|
+
|
|
208
|
+
**The order of checks.** The `state` must come back and must match the one
|
|
209
|
+
stored at create. If it is missing or different, the result is `unverified` and
|
|
210
|
+
nothing is read. Only then is `status` looked at, and only as a hint:
|
|
211
|
+
`granted`/`partial` → the artefact is read back for this session; `denied` →
|
|
212
|
+
`denied`; anything else → `unverified`. The server's `challengeNonce` is not
|
|
213
|
+
part of this: it is the hosted page's own credential, carried on `consent_url`,
|
|
214
|
+
and the return never carries it.
|
|
215
|
+
|
|
216
|
+
#### How the artefact is confirmed
|
|
217
|
+
|
|
218
|
+
The SDK reads `GET /consent/artifacts/{artifact_id}?session_id={session_id}`
|
|
219
|
+
through your proxy. **The `session_id` is what gives the answer its meaning:**
|
|
220
|
+
|
|
221
|
+
| Platform answer | `handleCallback()` |
|
|
222
|
+
|---|---|
|
|
223
|
+
| **200** with the artefact | `completed` — once the artefact's `data_principal_id` equals the one your session create returned |
|
|
224
|
+
| **202** + `Retry-After` — recorded, still being written | waits `Retry-After` inside `artifactWaitMs` (default 15 000), else **`pending`** with `retryAfterMs` |
|
|
225
|
+
| **404** `ARTIFACT_NOT_FOUND` | `unverified` at once: the id was never issued for this session. A forged `artifact_id` looks exactly like this |
|
|
226
|
+
| anything else | `unverified` |
|
|
227
|
+
|
|
228
|
+
Without the `session_id` the platform answers 404 for a consent whose artefact
|
|
229
|
+
is still being written as well as for an id it never issued, which is why the
|
|
230
|
+
SDK always sends it.
|
|
231
|
+
|
|
232
|
+
**Why the person is compared.** The platform checks `session_id` only while
|
|
233
|
+
the artefact is still being written. Once it exists, the 200 is returned for
|
|
234
|
+
*any* session id (measured on the platform; walk finding F077). The artefact
|
|
235
|
+
carries no session id. It does carry `data_principal_id`, and the session
|
|
236
|
+
create returned the same field for the person the session is about, so the
|
|
237
|
+
SDK compares the two.
|
|
238
|
+
|
|
239
|
+
`pending` is **not** a failure and **not** a consent that did not happen: the
|
|
240
|
+
decision is recorded. Read it again after `retryAfterMs`:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
const r = await ce.consent.getArtifact(artifactId, { sessionId });
|
|
244
|
+
if (r.state === 'pending') setTimeout(retry, r.retryAfterMs); // 202
|
|
245
|
+
else use(r.artifact); // 200
|
|
246
|
+
// a 404 throws ConsenteraNotFoundError (code ARTIFACT_NOT_FOUND)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### 5. Gate on consent later
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
if (await ce.consent.isAllowed({ data_principal_identifiers: { email } }, 'product_analytics')) {
|
|
253
|
+
loadAnalytics();
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`validate()` returns the platform's whole answer, including
|
|
258
|
+
`data_principal_id`, the platform's id for the person you asked about. Keep it:
|
|
259
|
+
the next call can name the person by `{ data_principal_id }` and send no
|
|
260
|
+
identifier at all.
|
|
261
|
+
|
|
262
|
+
### Naming a Data Principal
|
|
263
|
+
|
|
264
|
+
`data_principal_identifiers` is an **open map keyed by your organisation's own
|
|
265
|
+
locked integration key** — the same shape `data_principal` takes on session
|
|
266
|
+
create, validated by the same validator. It is not a fixed vocabulary, so this
|
|
267
|
+
SDK does not enumerate one and does not allow-list: a tenant keyed on
|
|
268
|
+
`{customer_id}` names people by `customer_id`, one keyed on `{email, mobile}`
|
|
269
|
+
by those.
|
|
270
|
+
|
|
271
|
+
**One spelling, both roads.** The mobile atom is **`mobile`** on session create
|
|
272
|
+
*and* on the lifecycle roads. It used to be `phone` here and `mobile` there,
|
|
273
|
+
folded server-side; F015 removed the fold, so **`phone` is now refused by name**
|
|
274
|
+
— the refusal even tells you the atom to use.
|
|
275
|
+
|
|
276
|
+
Only the wire KEY differs between the two roads: create spells the object
|
|
277
|
+
`data_principal` (and may mint a person), the lifecycle roads spell it
|
|
278
|
+
`data_principal_identifiers` (resolve-only, never creates anybody).
|
|
279
|
+
|
|
280
|
+
The refusals you will meet, all from that one validator:
|
|
281
|
+
|
|
282
|
+
| code | meaning |
|
|
283
|
+
|---|---|
|
|
284
|
+
| `UNKNOWN_IDENTIFIER_FIELD` | a field outside your key, named — and for a vernacular spelling, the atom to use instead |
|
|
285
|
+
| `IDENTIFIER_REQUIRED` | nothing named a person |
|
|
286
|
+
| `INVALID_IDENTIFIER_FORMAT` | a value that cannot be an identifier of its type (a raw 12-digit Aadhaar lives here — send the Aadhaar-linked token) |
|
|
287
|
+
| `SCHEME_NOT_CONFIGURED` | the organisation has not locked how it identifies people yet |
|
|
288
|
+
|
|
289
|
+
They arrive as `err.code` on a `ConsenteraError` with `err.kind === 'identity'`
|
|
290
|
+
(`'guardian'` for the age/guardian family), so you can branch on the class and
|
|
291
|
+
still read the platform's exact word.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## React
|
|
296
|
+
|
|
297
|
+
```tsx
|
|
298
|
+
'use client';
|
|
299
|
+
import { ConsentEraProvider, useConsentEra, ConsentGate } from '@consentera/consent-sdk/react';
|
|
300
|
+
|
|
301
|
+
export function App({ children }) {
|
|
302
|
+
return (
|
|
303
|
+
<ConsentEraProvider config={{ tenantId: TENANT, proxyEndpoint: '/api/consentera' }}>
|
|
304
|
+
{children}
|
|
305
|
+
</ConsentEraProvider>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function Marketing({ email }: { email: string }) {
|
|
310
|
+
return (
|
|
311
|
+
<ConsentGate
|
|
312
|
+
who={{ data_principal_identifiers: { email } }}
|
|
313
|
+
purposeCode="marketing_email"
|
|
314
|
+
fallback={<AskForConsent />}
|
|
315
|
+
>
|
|
316
|
+
<MarketingContent />
|
|
317
|
+
</ConsentGate>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The built bundle carries `'use client'`, so it works in the Next.js App Router.
|
|
323
|
+
`<ConsentGate>` **fails closed** — unknown, loading and errored all render the
|
|
324
|
+
fallback — and it **re-checks when consent changes**, so a withdrawal anywhere in
|
|
325
|
+
the app closes the gate without a remount.
|
|
326
|
+
|
|
327
|
+
**No hook throws during render.** When the provider's configuration is refused
|
|
328
|
+
(a secret key in a browser, no endpoint), or there is no provider at all,
|
|
329
|
+
`useConsentEraClient()` returns `client: null`. `useConsentEra()` returns
|
|
330
|
+
`ready: false` and `null` namespaces. `error` says why, and a
|
|
331
|
+
`ConsenteraError` carries its `code`. `<ConsentGate>` renders its fallback. A
|
|
332
|
+
configuration mistake therefore closes the gate instead of blanking the page:
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
const { consent, error } = useConsentEra();
|
|
336
|
+
if (!consent) return <p>Consent is unavailable: {error?.message}</p>;
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## The credential model
|
|
342
|
+
|
|
343
|
+
| credential | where it may live | what it opens |
|
|
344
|
+
|---|---|---|
|
|
345
|
+
| `tiq_live_` / `tiq_test_` secret key | your server only | every consent lifecycle road |
|
|
346
|
+
| `tiq_pub_` site key | a browser bundle | public roads (notice fetch, consent-page config/submit) |
|
|
347
|
+
| consent session id + nonce | the consent page | that one session's render/submit |
|
|
348
|
+
|
|
349
|
+
**In a browser, lifecycle roads must go through `proxyEndpoint`.** The SDK
|
|
350
|
+
enforces this: `apiKey` with a `window` present is refused at construction, and a
|
|
351
|
+
`df` road with no proxy raises `SECRET_KEY_IN_BROWSER` naming the fix.
|
|
352
|
+
|
|
353
|
+
If you are certain your code never reaches a browser but a `window` exists anyway
|
|
354
|
+
(a jsdom harness, an SSR shim), `unsafeAllowSecretKeyInBrowser: true` allows it
|
|
355
|
+
and every request warns.
|
|
356
|
+
|
|
357
|
+
### What the platform must guarantee
|
|
358
|
+
|
|
359
|
+
This is now enforced by **route membership plus a fail-closed origin binding**,
|
|
360
|
+
not by the four legacy permission names — that mechanism (F017, platform PR
|
|
361
|
+
#1781) supersedes the earlier finding that a site key authorised *zero* roads.
|
|
362
|
+
For a browser to reach the SDK's `public` and `session` roads directly, without a
|
|
363
|
+
proxy, the platform guarantees are:
|
|
364
|
+
|
|
365
|
+
- **A site key opens a fixed SET of routes by membership**, not by carrying one
|
|
366
|
+
of `consent.render / widget.render / session.submit / session.render`. Those
|
|
367
|
+
four permission names are checked by no route (`RequireDFPermission("…")` grep:
|
|
368
|
+
0 hits) and are no longer how access is decided.
|
|
369
|
+
- **`allowed_domains` on the m2m client must be NON-EMPTY.** The key is bound to
|
|
370
|
+
its registered origins and refused fail-closed everywhere else, so a leaked
|
|
371
|
+
site key works nowhere the DF did not list — but a key with an EMPTY
|
|
372
|
+
`allowed_domains` therefore works **nowhere at all**. Register your origins, or
|
|
373
|
+
the browser calls 403 with a correct key.
|
|
374
|
+
- **Render and submit need NO credential.** `GET /consent/sessions/{id}/render`,
|
|
375
|
+
`POST /consent/sessions/{id}/submit` and `GET
|
|
376
|
+
/consent/sessions/{id}/widget-template` authorise on the **session id + its
|
|
377
|
+
nonce** — the SDK's `session` road sends no key and no `X-Tenant-Id`, because
|
|
378
|
+
the session is the capability.
|
|
379
|
+
- The DF read roads (`/df/config`, `/df/purposes`, `/df/notice/purposes`,
|
|
380
|
+
`/df/notice/template`) are the SDK's `public` road: openable by a site key
|
|
381
|
+
whose `allowed_domains` includes the calling origin.
|
|
382
|
+
|
|
383
|
+
What this SDK still enforces on its side: a **secret** key (`tiq_live_` /
|
|
384
|
+
`tiq_test_`) is refused in a browser at construction — that is orthogonal to the
|
|
385
|
+
above and does not change. The `df` lifecycle roads (create session, validate,
|
|
386
|
+
withdraw, …) remain server-to-server and go through `proxyEndpoint` in a browser.
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Reliability
|
|
391
|
+
|
|
392
|
+
Every request carries a deadline, retries safely, and can be cancelled.
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
const ce = new ConsentEraClient({
|
|
396
|
+
tenantId: TENANT,
|
|
397
|
+
proxyEndpoint: '/api/consentera',
|
|
398
|
+
timeoutMs: 10_000, // default
|
|
399
|
+
retry: { attempts: 3, baseDelayMs: 250, maxDelayMs: 4000 }, // default
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// one key per logical operation — a double-clicked Save is ONE consent write
|
|
403
|
+
await ce.consent.update(id, updates, context, 'btn_save', { idempotencyKey: formSubmissionId });
|
|
404
|
+
|
|
405
|
+
// cancel from your own code
|
|
406
|
+
const ac = new AbortController();
|
|
407
|
+
await ce.consent.validate(who, 'analytics', { signal: ac.signal });
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Retries happen on network failure, 5xx and 429, with exponential backoff and full
|
|
411
|
+
jitter, honouring `Retry-After`. The idempotency key is minted **once per logical
|
|
412
|
+
operation** and reused across every retry of it, so a retry can never write twice.
|
|
413
|
+
|
|
414
|
+
## Errors
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { ConsenteraError } from '@consentera/consent-sdk';
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
await ce.consent.createSession({ ... });
|
|
421
|
+
} catch (err) {
|
|
422
|
+
if (err instanceof ConsenteraError) {
|
|
423
|
+
err.kind; // 'identity' | 'guardian' | 'rate_limit' | 'auth' | … (closed set)
|
|
424
|
+
err.code; // 'UNKNOWN_IDENTIFIER_FIELD' — the platform's canonical code
|
|
425
|
+
err.status; // 400
|
|
426
|
+
err.requestId; // quote this in a support ticket
|
|
427
|
+
err.retryAfterMs;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Switch on `kind` (closed, exhaustive); read `code` for the platform's exact word.
|
|
433
|
+
|
|
434
|
+
## Privacy defaults
|
|
435
|
+
|
|
436
|
+
- **`collectContext: 'minimal'`** by default: platform, device type, browser
|
|
437
|
+
family, OS family. No UA string, no screen size, no timezone, no page URL, no
|
|
438
|
+
referrer. `'full'` adds them, with the page URL's **query and fragment removed**;
|
|
439
|
+
`'none'` sends no context at all.
|
|
440
|
+
- **`beforeSend`** gets the last look at every request body. Return it to send,
|
|
441
|
+
return `null` to refuse (which raises — it never silently sends nothing).
|
|
442
|
+
- Debug logging **never prints a request or response body**. The body of a session
|
|
443
|
+
create is the Data Principal's identifiers.
|
|
444
|
+
|
|
445
|
+
## How the SDK identifies itself
|
|
446
|
+
|
|
447
|
+
Every request carries the pair agreed across all six Consentera SDKs:
|
|
448
|
+
|
|
449
|
+
```
|
|
450
|
+
User-Agent: ConsenteraSDK/2.0.0 (<platform>; <runtime>)
|
|
451
|
+
X-Consentera-SDK: js/2.0.0
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`ConsenteraSDK/<version>` is the form the platform's audit pipeline already
|
|
455
|
+
parses. **In a browser only the second is sent**: `User-Agent` is a forbidden
|
|
456
|
+
fetch header, so the browser drops any attempt to set it — the Node build and
|
|
457
|
+
the CLI send both.
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
new ConsentEraClient({
|
|
461
|
+
tenantId: TENANT,
|
|
462
|
+
proxyEndpoint: '/api/consentera',
|
|
463
|
+
collectContext: 'none',
|
|
464
|
+
beforeSend: ({ body }) => redactForYourPolicy(body),
|
|
465
|
+
});
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
## Content Security Policy
|
|
469
|
+
|
|
470
|
+
The SDK makes no `eval` and inserts no `<script>`. It does inject a `<style>`
|
|
471
|
+
element for the banner and preference centre, so a strict `style-src` needs
|
|
472
|
+
either `'unsafe-inline'` or the SDK's styles disabled (bring your own UI).
|
|
473
|
+
|
|
474
|
+
The CDN build is at `dist/consentera-consent.min.js` (`unpkg`/`jsdelivr` point
|
|
475
|
+
there). Pin the version and use the published SRI hash.
|
|
476
|
+
|
|
477
|
+
## Migrating from 1.x
|
|
478
|
+
|
|
479
|
+
See [CHANGELOG.md](./CHANGELOG.md). In short: `handleCallback()` is async and
|
|
480
|
+
fails closed, a failed consent sync now throws instead of reporting success,
|
|
481
|
+
`apiKey` is refused in a browser, identifiers replaced `data_principal_ref`,
|
|
482
|
+
telemetry is off by default, and the cookie banner (`ConsenteraConsent`, the
|
|
483
|
+
default export) **requires `apiEndpoint`** — there is no default server, and a
|
|
484
|
+
missing one is refused at construction with `ENDPOINT_REQUIRED` (script tag:
|
|
485
|
+
`data-api-endpoint`).
|
|
486
|
+
|
|
487
|
+
## Licence
|
|
488
|
+
|
|
489
|
+
MIT — see [LICENSE](./LICENSE).
|