@consentera/react-native-consent 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.
@@ -0,0 +1,789 @@
1
+ import { NativeModules } from 'react-native';
2
+
3
+ /**
4
+ * What this SDK calls itself on the wire, in `X-Consentera-SDK`.
5
+ *
6
+ * ONE declaration. The Android and iOS twins each had an SDK_VERSION constant
7
+ * that no request builder ever read, which is the same as not having one.
8
+ */
9
+ export const SDK_VERSION = '2.0.0';
10
+ export const SDK_IDENTIFIER = `react-native/${SDK_VERSION}`;
11
+
12
+ /**
13
+ * The User-Agent the platform's audit coarsening actually parses.
14
+ *
15
+ * THE PLATFORM PARSES User-Agent, SO THAT IS WHERE THE VERSION GOES.
16
+ *
17
+ * consentera-api/internal/core/audit/user_agent_coarsening_test.go:39-40 drives
18
+ * CoarsenUserAgent with `ConsenteraSDK/2.3.1 (Android 14; SM-G991B; build 4471)`
19
+ * and asserts it coarsens to `ConsenteraSDK/2` — the platform has a rule, a test
20
+ * and an audit consequence for exactly that spelling. The other string in the
21
+ * platform tree, `consentera-sdk/1.2` (validation_envelope_test.go:233), is an
22
+ * inert fixture with no parser behind it. So `ConsenteraSDK/<version>` is the one
23
+ * that is agreed by evidence rather than by preference; raised with the platform
24
+ * lane as MANIFEST-CORRECTIONS M-11.
25
+ *
26
+ * AND X-Consentera-SDK STAYS, because the two answer different questions.
27
+ * User-Agent is DELIBERATELY COARSENED into the audit record — `ConsenteraSDK/2`
28
+ * is all that survives, which is the point: an audit row must not carry a
29
+ * fingerprint of the person's device. A support ticket needs the exact build, and
30
+ * that is what the custom header carries, outside the audit trail.
31
+ */
32
+ export const SDK_USER_AGENT = `ConsenteraSDK/${SDK_VERSION} (react-native)`;
33
+
34
+ /**
35
+ * `Retry-After` in either RFC 9110 form — delta-seconds, or an HTTP-date — as
36
+ * milliseconds, or null when the header is absent or unparseable.
37
+ *
38
+ * Capped at 60s: a server that asks for an hour is not something a consent UI
39
+ * can wait out, and the caller's own deadline should decide.
40
+ */
41
+ export function parseRetryAfter(header, now = Date.now()) {
42
+ if (!header) return null;
43
+ const trimmed = header.trim();
44
+ if (/^\d+$/.test(trimmed)) return Math.min(Number(trimmed) * 1000, 60000);
45
+ const when = Date.parse(trimmed);
46
+ if (Number.isNaN(when)) return null;
47
+ return Math.min(Math.max(0, when - now), 60000);
48
+ }
49
+
50
+ /**
51
+ * A typed error with the status, the canonical code and the request id.
52
+ *
53
+ * Errors used to be `new Error(\`Consentera \${path} \${status}: \${body}\`)` — a
54
+ * string, with the response body interpolated into the message (where it could
55
+ * carry the person's own identifiers into whatever the host logs), and nothing
56
+ * a caller could switch on.
57
+ */
58
+ export class ConsenteraError extends Error {
59
+ /** HTTP status, when this came from a non-2xx answer. */
60
+
61
+ /** The platform's canonical error code, e.g. `VALIDATION_ERROR`. */
62
+
63
+ /**
64
+ * The platform's own sentence for a refusal: the `message` of its
65
+ * `{code, message}` envelope, verbatim (SDK register MOB-042). It is what
66
+ * an app shows the person to say WHY: "\"phone\" is not one of this
67
+ * organisation's identifier fields — send email". It is kept out of
68
+ * `message`, like the rest of the body: `message` goes wherever the host
69
+ * logs, and this is a sentence for a screen. Undefined when the answer had
70
+ * no envelope, for example a proxy's HTML page.
71
+ */
72
+
73
+ /** `X-Request-Id` off the response, for a support ticket. */
74
+
75
+ /** True for a timeout or a transport failure — the call may be retried. */
76
+
77
+ constructor(message, opts = {}) {
78
+ super(message);
79
+ this.name = 'ConsenteraError';
80
+ this.status = opts.status;
81
+ this.code = opts.code;
82
+ this.platformMessage = opts.platformMessage;
83
+ this.requestId = opts.requestId;
84
+ this.retryable = opts.retryable ?? false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The platform's error envelope, `{code, message}` (core/apierrors/errors.go
90
+ * APIError), read out of a response body. Tolerates the older
91
+ * `{error: {code, message}}` nesting. Never throws: a body that is not an
92
+ * envelope yields nothing.
93
+ */
94
+ export function readErrorEnvelope(text) {
95
+ try {
96
+ const parsed = JSON.parse(text);
97
+ const src = parsed && typeof parsed === 'object' && parsed.error && typeof parsed.error === 'object' && parsed.code === undefined ? parsed.error : parsed;
98
+ return {
99
+ code: typeof src?.code === 'string' && src.code ? src.code : undefined,
100
+ message: typeof src?.message === 'string' && src.message ? src.message : undefined
101
+ };
102
+ } catch {
103
+ return {};
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 160 bits from the platform CSPRNG, URL-safe, unpadded.
109
+ *
110
+ * REFUSES RATHER THAN DEGRADING. React Native ships no `crypto.getRandomValues`
111
+ * by default, and the obvious substitute — `Math.random` — is not a CSPRNG: this
112
+ * nonce is the only thing standing between a forged
113
+ * `myapp://consent/callback?status=granted` from another app and a host that
114
+ * believes it, so a predictable one is worse than none. Install
115
+ * `react-native-get-random-values` and import it once at the top of your entry
116
+ * file, which is what every RN crypto consumer already does.
117
+ */
118
+ export function newCallbackState() {
119
+ const g = globalThis.crypto;
120
+ if (!g?.getRandomValues) {
121
+ throw new ConsenteraError('no cryptographic random source: install react-native-get-random-values and ' + "add `import 'react-native-get-random-values';` to the top of index.js. " + 'This SDK will not fall back to Math.random for the callback nonce.');
122
+ }
123
+ const bytes = g.getRandomValues(new Uint8Array(20));
124
+ let bin = '';
125
+ bytes.forEach(b => {
126
+ bin += String.fromCharCode(b);
127
+ });
128
+ // btoa exists in RN's JS runtime (Hermes and JSC both provide it).
129
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
130
+ }
131
+
132
+ /** The one shape the SDK needs from whichever in-app browser the host installed. */
133
+
134
+ let injectedBrowser = null;
135
+
136
+ /**
137
+ * Supply the in-app browser explicitly — for Expo, or for a host that already
138
+ * has one:
139
+ *
140
+ * import * as WebBrowser from 'expo-web-browser';
141
+ * setInAppBrowser({
142
+ * openAuth: async (url, scheme) => {
143
+ * const r = await WebBrowser.openAuthSessionAsync(url, scheme);
144
+ * return r.type === 'success' ? r.url : null;
145
+ * },
146
+ * });
147
+ */
148
+ export function setInAppBrowser(adapter) {
149
+ injectedBrowser = adapter;
150
+ }
151
+
152
+ /**
153
+ * The injected adapter, else `react-native-inappbrowser-reborn` if the host
154
+ * installed it, else null — and null makes `presentConsent` THROW rather than
155
+ * fall back to `Linking.openURL`.
156
+ *
157
+ * The peer is resolved with a guarded `require` rather than a top-level import
158
+ * on purpose: a static import would make the peer mandatory for every consumer,
159
+ * including the ones that inject their own.
160
+ */
161
+ export function resolveInAppBrowser() {
162
+ if (injectedBrowser) return injectedBrowser;
163
+ try {
164
+ // Declared locally rather than pulling in @types/node: this is Metro's
165
+ // require, and the SDK has no other CommonJS surface.
166
+ const req = globalThis.require ?? eval('require');
167
+ const mod = req('react-native-inappbrowser-reborn');
168
+ const b = mod?.InAppBrowser;
169
+ if (!b || !NativeModules.RNInAppBrowser) return null;
170
+ return {
171
+ openAuth: async (url, redirectScheme) => {
172
+ const r = await b.openAuth(url, redirectScheme, {
173
+ ephemeralWebSession: false,
174
+ showTitle: true,
175
+ enableUrlBarHiding: false
176
+ });
177
+ return r.type === 'success' && r.url ? r.url : null;
178
+ }
179
+ };
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * ConsenteraSession — the React Native twin of the Android SDK's session
187
+ * package. Pure JS: the DPDP flow needs no native module.
188
+ *
189
+ * Flow (identical to every other platform):
190
+ * 1. createSession({ dataPrincipalRef, noticeInternalName }) via YOUR
191
+ * backend/proxy (the app never holds tiq_live_*)
192
+ * 2. presentConsent(session) → the hosted collect page in an IN-APP browser
193
+ * (react-native-inappbrowser-reborn, or your own via setInAppBrowser).
194
+ * It REFUSES rather than falling back to the external browser.
195
+ * 3. deep-link back is BEST-EFFORT (Chrome blocks gesture-less custom-scheme
196
+ * redirects) — always re-validate on appState → 'active', and put any link
197
+ * through parseCallback, which checks scheme, host, path and the `state`
198
+ * nonce before you look at `status`
199
+ * 4. validate()/withdraw() by a PrincipalRef — `data_principal_id` or typed
200
+ * `data_principal_identifiers`. A bare `data_principal_ref` is refused
201
+ * outright by the API. openPortal() for the full DP portal (rights,
202
+ * receipts, grievances) via one-tap SSO — that road, and only that road,
203
+ * still takes the old pair.
204
+ */
205
+
206
+ /**
207
+ * The one check on `backendBaseUrl`: present, and an absolute http(s) URL.
208
+ * Throws a {@link ConsenteraError} with code `BACKEND_BASE_URL_REQUIRED`.
209
+ */
210
+ function assertBackendBaseUrl(value) {
211
+ const base = typeof value === 'string' ? value.trim() : '';
212
+ // A regex, NOT `new URL(base).protocol`: React Native's built-in URL class
213
+ // implements the constructor and `href` only, and its other getters throw
214
+ // "not implemented" unless the host installed a polyfill — so a check built
215
+ // on them would refuse every URL on a device while passing under Jest.
216
+ if (!/^https?:\/\/[^\s/?#]+/i.test(base)) {
217
+ throw new ConsenteraError('backendBaseUrl is required and must be an absolute http(s) URL. There is no default ' + "server: set it to your own backend's Consentera route (the server that holds the " + 'tiq_live_ key).', {
218
+ code: 'BACKEND_BASE_URL_REQUIRED'
219
+ });
220
+ }
221
+ return value;
222
+ }
223
+ /**
224
+ * The deep link handed to the platform as `callback_url`, carrying `state`.
225
+ *
226
+ * THE STATE SURVIVES THE ROUND TRIP, AND THAT IS A PLATFORM FACT, not an
227
+ * assumption: `addRedirectParams` (consent/collection.go) appends with `&` when
228
+ * the callback URL already contains a `?`, so `myapp://consent/callback?state=X`
229
+ * comes back as `…?artifact_id=…&pending=1&session_id=…&state=X&status=…`
230
+ * (setRedirectParam re-encodes the query, so the keys arrive sorted). If that ever
231
+ * changes, `parseCallback` starts refusing every callback rather than silently
232
+ * accepting a forged one.
233
+ */
234
+ export function callbackUrlFor(cfg, state) {
235
+ const host = cfg.callbackHost ?? 'consent';
236
+ const path = cfg.callbackPath ?? '/callback';
237
+ return `${cfg.callbackScheme}://${host}${path}?state=${encodeURIComponent(state)}`;
238
+ }
239
+
240
+ /**
241
+ * THE STATUS VOCABULARY THE PLATFORM PUTS ON A CALLBACK — and only these.
242
+ *
243
+ * consent/collection.go (platform pre-main 35cd853ac7) derives it at
244
+ * :4094-4098 — `granted`; no purpose granted → `denied`; some denied →
245
+ * `partial` — and SETS it on the redirect at :4243. Any other value
246
+ * (`success`, `completed`, `expired`, a different case, none at all) did not
247
+ * come from the platform and is `unknown`: never read it as a grant.
248
+ */
249
+
250
+ /** Map a raw `status` query value onto the platform's vocabulary. Exact match: the server writes lowercase. */
251
+ export function callbackStatusOf(raw) {
252
+ return raw === 'granted' || raw === 'partial' || raw === 'denied' ? raw : 'unknown';
253
+ }
254
+
255
+ /**
256
+ * Parsed from the deep link the hosted page redirects to on submit.
257
+ *
258
+ * A CALLBACK IS A HINT AND NEVER A DECISION. What `parseCallback` guarantees is
259
+ * narrower and still worth having: this callback came back to the deep link THIS
260
+ * SDK asked for, carrying the nonce THIS SDK generated.
261
+ */
262
+
263
+ /**
264
+ * The §9(1) state a child's session rests in while the guardian verifies.
265
+ *
266
+ * IT CARRIES NO LINK AND NO TOKEN, deliberately. The verification URL is a
267
+ * bearer credential — address-bound, 72 hours, single use — and it goes to the
268
+ * guardian's own address. Returning it here would put it in the organisation's
269
+ * logs, and the organisation is not the party the link is for.
270
+ */
271
+
272
+ /**
273
+ * `data_principal_identifiers` — how every consent LIFECYCLE road names a
274
+ * person.
275
+ *
276
+ * ─── IT IS THE SAME SHAPE AS `dataPrincipal` ON SESSION CREATE (F015) ─────
277
+ *
278
+ * An OPEN map keyed by THE TENANT'S OWN LOCKED INTEGRATION KEY — the same
279
+ * value `data_principal` carries on create (update_context.go:104-119).
280
+ * F015 (consent/one-identifier-vocabulary-20260922) deleted the earlier
281
+ * closed five-field object, because a closed struct cannot carry a per-tenant
282
+ * vocabulary:
283
+ *
284
+ * * ONE VOCABULARY NOW, ONE SPELLING. The mobile atom is `mobile` on BOTH
285
+ * roads; F015 removed the old server-side fold, so `phone` is refused BY
286
+ * NAME (400 UNKNOWN_IDENTIFIER_FIELD, "…use mobile"). Do NOT send `phone`.
287
+ * * THE ADMISSIBLE SET IS PER-TENANT, so this SDK cannot know it and must NOT
288
+ * allow-list. Send the fields the organisation locked; the SERVER answers
289
+ * UNKNOWN_IDENTIFIER_FIELD, naming the field, when you get it wrong.
290
+ * * THERE IS NO `pan` KEY — it is evidence-class and can never be a scheme
291
+ * field.
292
+ *
293
+ * Only the wire KEY differs from create (`data_principal` may mint a person,
294
+ * `data_principal_identifiers` resolves only). Any ONE field is enough. A raw
295
+ * 12-digit `aadhaar` value is refused 400 INVALID_IDENTIFIER_FORMAT (F015
296
+ * folded the old AADHAAR_RAW_REFUSED into that one refusal); send the
297
+ * Aadhaar-LINKED token, never the number.
298
+ */
299
+
300
+ /**
301
+ * The ONE way a consent lifecycle road names a person.
302
+ *
303
+ * `data_principal_ref` is REFUSED OUTRIGHT since 2026-09-21 — no transition
304
+ * period (consent/lifecycle_identity.go:8-11) — and the refusal fires even when
305
+ * `data_principal_id` is also present, because two fields naming a person can
306
+ * disagree and the caller would never learn which one the answer was about.
307
+ * A union makes the refused request unrepresentable rather than a round trip.
308
+ */
309
+
310
+ /** Turn a {@link PrincipalRef} into the body fields that name the person. */
311
+ export function principalBody(who) {
312
+ if ('dataPrincipalId' in who && who.dataPrincipalId) {
313
+ return {
314
+ data_principal_id: who.dataPrincipalId
315
+ };
316
+ }
317
+ const ids = who.dataPrincipalIdentifiers;
318
+ if (!ids || Object.keys(ids).length === 0) {
319
+ // The fields are the tenant's own locked integration key, which this SDK
320
+ // cannot know and does not enumerate (F015). The server lists them in its
321
+ // UNKNOWN_IDENTIFIER_FIELD refusal.
322
+ // ConsenteraError, not a bare Error: "you named nobody" is the caller's
323
+ // own bug and a caller must be able to switch on it, like every other
324
+ // failure this SDK raises. The code is the one the SERVER would answer with
325
+ // if the body reached it, so the same branch handles both.
326
+ throw new ConsenteraError('Consentera: name the Data Principal with dataPrincipalId, or with ' + 'dataPrincipalIdentifiers carrying the identifier fields of this ' + "organisation's integration key. data_principal_ref is refused by the API.", {
327
+ code: 'IDENTIFIER_REQUIRED'
328
+ });
329
+ }
330
+ return {
331
+ data_principal_identifiers: ids
332
+ };
333
+ }
334
+
335
+ /**
336
+ * The ONE identifier the portal road is sent, as `[kind, value]`.
337
+ *
338
+ * The map's only entry, or, when it has several, the first field of
339
+ * `scheme` that it carries. It throws (a ConsenteraError whose `code` is the
340
+ * one the server would use) rather than guess: an empty map is
341
+ * IDENTIFIER_REQUIRED, several entries and no scheme to choose by is
342
+ * IDENTIFIER_AMBIGUOUS, and `phone` is UNKNOWN_IDENTIFIER_FIELD.
343
+ */
344
+ export function portalIdentifier(identifiers, scheme) {
345
+ const entries = Object.entries(identifiers ?? {}).filter(([, v]) => typeof v === 'string' && v.trim() !== '');
346
+ if (entries.some(([k]) => k === 'phone')) {
347
+ throw new ConsenteraError("Consentera: `phone` is not an identifier field. This platform spells it `mobile` (F015).", {
348
+ code: 'UNKNOWN_IDENTIFIER_FIELD'
349
+ });
350
+ }
351
+ if (entries.length === 0) {
352
+ throw new ConsenteraError('Consentera: name the person for the portal with one identifier of your organisation\'s ' + "integration key, e.g. { mobile: '+91…' }.", {
353
+ code: 'IDENTIFIER_REQUIRED'
354
+ });
355
+ }
356
+ const only = entries.length === 1 ? entries[0] : undefined;
357
+ if (only) return [only[0], only[1]];
358
+ const chosen = (scheme ?? []).find(field => entries.some(([k]) => k === field));
359
+ const chosenValue = chosen === undefined ? undefined : identifiers[chosen];
360
+ if (chosen === undefined || chosenValue === undefined) {
361
+ throw new ConsenteraError(`Consentera: the portal takes ONE identifier and ${entries.length} were given ` + `(${entries.map(([k]) => k).join(', ')}). Pass one, or set identifierScheme in the ` + 'SessionConfig so the SDK can choose.', {
362
+ code: 'IDENTIFIER_AMBIGUOUS'
363
+ });
364
+ }
365
+ return [chosen, chosenValue];
366
+ }
367
+ export class ConsenteraSession {
368
+ constructor(cfg) {
369
+ this.cfg = cfg;
370
+ // NO DEFAULT SERVER, and the refusal names the field. The app talks to its
371
+ // OWN backend and nothing else, so no host this SDK could ship would ever
372
+ // be the right one — and SDKs in this repository did ship one, on a domain
373
+ // the company does not own. The type says `string`, but a JS caller, an
374
+ // unset env var or a blank remote-config value reaches here as undefined
375
+ // or '', which used to surface as a TypeError on `.startsWith` or as a
376
+ // fetch to a relative path at the first request.
377
+ const base = assertBackendBaseUrl(cfg?.backendBaseUrl);
378
+ // Enterprise guard: consent traffic must be HTTPS in production. Plain
379
+ // HTTP is tolerated only for the local dev bridges, and loudly.
380
+ if (base.startsWith('http://') && !/^http:\/\/(10\.0\.2\.2|localhost|127\.0\.0\.1)[:/]/.test(base)) {
381
+ cfg.onDiagnostic?.('backendBaseUrl is plain HTTP — production integrations must use HTTPS.');
382
+ }
383
+ }
384
+
385
+ /**
386
+ * ONE transport for every road.
387
+ *
388
+ * WHAT IT ADDS, and why each one:
389
+ *
390
+ * * **Identity.** `X-Consentera-SDK: react-native/<version>` on every
391
+ * request. Without it the platform cannot tell which SDK build produced a
392
+ * failure, which is the first question on every support ticket.
393
+ * * **Idempotency.** One key per OPERATION, not per attempt: a retried
394
+ * mutation must not record a second consent or a second withdrawal. The key
395
+ * is minted once per `post` call and reused across its retries.
396
+ * * **Retry with backoff and FULL JITTER**, on 429, 5xx and transport
397
+ * failures only. Never on a 4xx: a refused body is refused on every
398
+ * attempt, and retrying it just spends the tenant's rate budget.
399
+ * * **`Retry-After` is obeyed** when the server sends one, in either its
400
+ * delta-seconds or HTTP-date form. The platform rate-limits these roads
401
+ * (routes_consent.go:377,389,406,567), so this is not hypothetical.
402
+ * * **Request id**, surfaced on the error rather than discarded.
403
+ * * **A per-ATTEMPT timeout inside a per-CALL deadline**, so N retries cannot
404
+ * silently multiply the caller's wait by N.
405
+ */
406
+ async post(path, body, opts = {}) {
407
+ const perAttemptMs = this.cfg.requestTimeoutMs ?? 30000;
408
+ const deadline = Date.now() + (this.cfg.totalTimeoutMs ?? perAttemptMs * 2);
409
+ const maxAttempts = Math.max(1, this.cfg.maxAttempts ?? 3);
410
+ // MINTED ONCE, REUSED ACROSS RETRIES. A fresh key per attempt would defeat
411
+ // the whole mechanism — that is the bug idempotency keys exist to prevent.
412
+ const idempotencyKey = opts.idempotent === false ? undefined : newCallbackState();
413
+ let lastError;
414
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
415
+ const controller = new AbortController();
416
+ const budget = Math.min(perAttemptMs, Math.max(0, deadline - Date.now()));
417
+ const timer = setTimeout(() => controller.abort(), budget);
418
+ let res;
419
+ try {
420
+ res = await fetch(`${this.cfg.backendBaseUrl}${path}`, {
421
+ method: 'POST',
422
+ headers: {
423
+ 'Content-Type': 'application/json',
424
+ 'User-Agent': SDK_USER_AGENT,
425
+ 'X-Consentera-SDK': SDK_IDENTIFIER,
426
+ ...(idempotencyKey ? {
427
+ 'Idempotency-Key': idempotencyKey
428
+ } : {})
429
+ },
430
+ body: JSON.stringify(body),
431
+ signal: controller.signal
432
+ });
433
+ } catch (e) {
434
+ const err = e;
435
+ lastError = new ConsenteraError(err?.name === 'AbortError' ? `Consentera ${path}: request timed out after ${budget}ms` : `Consentera ${path}: ${err?.message ?? 'network error'}`, {
436
+ retryable: true
437
+ });
438
+ if (attempt < maxAttempts && (await this.backoff(attempt, null, deadline))) continue;
439
+ throw lastError;
440
+ } finally {
441
+ clearTimeout(timer);
442
+ }
443
+ const requestId = res.headers.get('X-Request-Id') ?? res.headers.get('x-request-id') ?? undefined;
444
+ const text = await res.text();
445
+ if (res.ok) {
446
+ try {
447
+ return JSON.parse(text);
448
+ } catch {
449
+ // A 200 that is not JSON is a proxy or a captive portal, never the
450
+ // platform. It is NOT retried: the same hop answers the same way.
451
+ throw new ConsenteraError(`Consentera ${path}: a 2xx response was not JSON (${text.length} bytes)`, {
452
+ status: res.status,
453
+ requestId
454
+ });
455
+ }
456
+ }
457
+
458
+ // The envelope's code and message ride on the error as FIELDS, never in
459
+ // the message: the body can carry the person's own identifiers back, and
460
+ // an exception message ends up wherever the host logs (MOB-042).
461
+ const envelope = readErrorEnvelope(text);
462
+ const code = envelope.code;
463
+ const retryable = res.status === 429 || res.status >= 500;
464
+ lastError = new ConsenteraError(`Consentera ${path} failed with ${res.status}${code ? ` (${code})` : ''}`, {
465
+ status: res.status,
466
+ code,
467
+ platformMessage: envelope.message,
468
+ requestId,
469
+ retryable
470
+ });
471
+ if (retryable && attempt < maxAttempts) {
472
+ const ok = await this.backoff(attempt, res.headers.get('Retry-After'), deadline);
473
+ if (ok) continue;
474
+ }
475
+ throw lastError;
476
+ }
477
+ /* istanbul ignore next — the loop either returns or throws */
478
+ throw lastError ?? new ConsenteraError(`Consentera ${path}: no attempt was made`);
479
+ }
480
+
481
+ /**
482
+ * Sleep before the next attempt. Returns false when the call's deadline would
483
+ * be passed, in which case the caller throws instead of sleeping into it.
484
+ *
485
+ * FULL JITTER: `random(0, base * 2^n)`, the AWS architecture-blog form. A
486
+ * fixed backoff synchronises every client that failed on the same server
487
+ * event and reproduces the spike that caused it.
488
+ */
489
+ async backoff(attempt, retryAfter, deadline) {
490
+ let waitMs;
491
+ const serverAsked = parseRetryAfter(retryAfter);
492
+ if (serverAsked !== null) {
493
+ // The server named a time. Obey it exactly — jittering a value the server
494
+ // computed just puts some clients back inside the window it rejected.
495
+ waitMs = serverAsked;
496
+ } else {
497
+ const base = this.cfg.retryBaseDelayMs ?? 250;
498
+ waitMs = Math.random() * base * Math.pow(2, attempt - 1);
499
+ }
500
+ if (Date.now() + waitMs >= deadline) return false;
501
+ await new Promise(r => setTimeout(r, waitMs));
502
+ return true;
503
+ }
504
+
505
+ /**
506
+ * Create a consent session; returns the hosted collect URL.
507
+ *
508
+ * createSession({
509
+ * dataPrincipal: { email: 'riya@example.in' },
510
+ * noticeInternalName: 'bnb_consent_v2',
511
+ * dateOfBirth: '1998-04-12',
512
+ * })
513
+ *
514
+ * Send `dataPrincipal` — the identifiers, keyed by YOUR organisation's locked
515
+ * integration key — or `dataPrincipalId` when you already hold the platform's
516
+ * uuid for the person. Sent together they must agree, or the call is refused
517
+ * 409 IDENTITY_MISMATCH. A request carrying neither falls to the key's floor,
518
+ * 400 IDENTIFIER_REQUIRED.
519
+ *
520
+ * ─── THIS IS THE U58 WIRE, AND THERE IS NO OVERLAP WINDOW ──────────────
521
+ *
522
+ * EVERY KEY BELOW IS ONE THE API DECODES. The handler decodes with NO
523
+ * DisallowUnknownFields — on the old wire and on this one alike — so a key it
524
+ * does not know is dropped in SILENCE; the request is then refused for
525
+ * carrying no identifier, which names a condition and not the field you sent.
526
+ * That is why an SDK on the wrong wire fails obscurely rather than loudly,
527
+ * and why this version talks to an API carrying U58 and to no other.
528
+ */
529
+ async createSession(req) {
530
+ // THE CALLBACK NONCE, minted here and nowhere else. It has to exist BEFORE
531
+ // the call, because `callback_url` is a REQUEST field — there is no later
532
+ // point at which anything could be added to the URL the platform will
533
+ // redirect to. That is also why it is not `challengeNonce`, which arrives in
534
+ // the RESPONSE, one round trip too late to appear in the callback.
535
+ const state = newCallbackState();
536
+ const callback = callbackUrlFor(this.cfg, state);
537
+ const session = await this.post('/consent/sessions', {
538
+ notice_internal_name: req.noticeInternalName,
539
+ ui_mode: 'redirect',
540
+ callback_url: callback,
541
+ // Sent verbatim: this SDK does not know the tenant's key and must not
542
+ // guess at it — a guess could only turn the API's field-naming 400 into
543
+ // silence.
544
+ ...(req.dataPrincipal && Object.keys(req.dataPrincipal).length > 0 ? {
545
+ data_principal: req.dataPrincipal
546
+ } : {}),
547
+ ...(req.dataPrincipalId ? {
548
+ data_principal_id: req.dataPrincipalId
549
+ } : {}),
550
+ ...(req.sessionRef ? {
551
+ session_ref: req.sessionRef
552
+ } : {}),
553
+ ...(req.noticeVersionNumber !== undefined ? {
554
+ notice_version_number: req.noticeVersionNumber
555
+ } : {}),
556
+ ...(req.dateOfBirth ? {
557
+ age: {
558
+ date_of_birth: req.dateOfBirth
559
+ }
560
+ } : {}),
561
+ ...(req.language ? {
562
+ language: req.language
563
+ } : {}),
564
+ // Top level, never inside data_principal.
565
+ ...(req.guardianEmail ? {
566
+ guardian_email: req.guardianEmail
567
+ } : {}),
568
+ ...(req.guardianPhone ? {
569
+ guardian_phone: req.guardianPhone
570
+ } : {}),
571
+ ...(req.guardianRelationship ? {
572
+ guardian_relationship: req.guardianRelationship
573
+ } : {})
574
+ });
575
+ session.callbackState = state;
576
+ return session;
577
+ }
578
+
579
+ /**
580
+ * Present the hosted consent notice in an IN-APP browser.
581
+ *
582
+ * ─── WHY A PEER AND NOT `Linking.openURL` ───────────────────────────────
583
+ *
584
+ * This used to be `Linking.openURL`, which leaves the app entirely for the
585
+ * external browser. Three things follow and none is acceptable for a consent
586
+ * surface: the callback comes back over a custom scheme any installed app can
587
+ * also claim; nothing tells the caller whether the page even opened; and the
588
+ * person is gone from the app with no cancellation signal. React Native has no
589
+ * built-in Custom Tabs / SFSafariViewController binding, so the in-app browser
590
+ * comes from a peer.
591
+ *
592
+ * INSTALL THE PEER:
593
+ *
594
+ * npm i react-native-inappbrowser-reborn # then: cd ios && pod install
595
+ *
596
+ * Expo apps can pass `expo-web-browser`'s `openAuthSessionAsync` through
597
+ * {@link SessionConfig} instead — see `openInAppBrowser` below.
598
+ *
599
+ * ─── IT REFUSES RATHER THAN DEGRADING ───────────────────────────────────
600
+ *
601
+ * With no peer available this THROWS. It does not fall back to
602
+ * `Linking.openURL`, because that would quietly restore every property above
603
+ * on exactly the devices where the peer failed to link.
604
+ *
605
+ * @returns the callback URL the in-app browser intercepted, or null when the
606
+ * person dismissed it. THE DEEP LINK IS STILL BEST-EFFORT — Chrome blocks
607
+ * gesture-less custom-scheme redirects — so treat null as "unknown" and
608
+ * re-validate, never as "denied".
609
+ */
610
+ async presentConsent(session) {
611
+ const url = typeof session === 'string' ? session : session.consent_url;
612
+ const browser = resolveInAppBrowser();
613
+ if (!browser) {
614
+ throw new ConsenteraError('no in-app browser is available. Install react-native-inappbrowser-reborn ' + '(npm i react-native-inappbrowser-reborn && cd ios && pod install), or pass an ' + 'expo-web-browser openAuthSessionAsync adapter. This SDK will not fall back to ' + 'Linking.openURL, which hands the hosted notice to the external browser and leaves ' + 'the callback to any app that claims the scheme.');
615
+ }
616
+ const redirect = `${this.cfg.callbackScheme}://`;
617
+ const result = await browser.openAuth(url, redirect);
618
+ return result;
619
+ }
620
+
621
+ /**
622
+ * Open a non-consent hosted page (the DP portal) in the same in-app browser.
623
+ * Returns nothing to check: the portal has no consent callback.
624
+ */
625
+ async presentHosted(url) {
626
+ const browser = resolveInAppBrowser();
627
+ if (!browser) {
628
+ throw new ConsenteraError('no in-app browser is available — install react-native-inappbrowser-reborn.');
629
+ }
630
+ await browser.openAuth(url, `${this.cfg.callbackScheme}://`);
631
+ }
632
+
633
+ /**
634
+ * Authoritative decision check — the ONLY source of consent truth.
635
+ *
636
+ * validate({ dataPrincipalIdentifiers: { email: 'riya@example.in' } }, 'product_analytics')
637
+ * validate({ dataPrincipalId: '…' }, 'product_analytics')
638
+ *
639
+ * `data_principal_ref` is REFUSED OUTRIGHT (400, message prefixed
640
+ * `DATA_PRINCIPAL_REF_REFUSED`). Name people by the organisation's locked
641
+ * key fields — the mobile atom is `mobile` here and on create alike (F015);
642
+ * `phone` is refused by name.
643
+ */
644
+ // `async`, and that is not cosmetic. principalBody() THROWS when the caller
645
+ // names nobody, and on a non-async method returning Promise<T> that throw is
646
+ // SYNCHRONOUS: a caller written as `session.validate(...).catch(handle)`
647
+ // never reaches its handler and the app takes an uncaught exception instead.
648
+ // Marking it async turns the throw into a rejection, so both call styles —
649
+ // try/await and .catch() — behave the same. Found by the F015 test below,
650
+ // which had to use `.rejects` and got a synchronous throw.
651
+ async validate(who, purposeCode) {
652
+ return this.post('/consent/validate', {
653
+ ...principalBody(who),
654
+ purpose_code: purposeCode
655
+ });
656
+ }
657
+
658
+ /** Withdraw purposes (codes or UUIDs) for the person named by [who]. */
659
+ async withdraw(who, purposes) {
660
+ return this.post('/consent/withdraw', {
661
+ ...principalBody(who),
662
+ purposes
663
+ });
664
+ }
665
+
666
+ /**
667
+ * Mint a fresh single-use DP-portal SSO link (never cache it).
668
+ *
669
+ * createPortalSession({ mobile: '+919876500000' })
670
+ * createPortalSession({ customer_id: 'CUST-90210' })
671
+ *
672
+ * The person is named THE SAME WAY AS EVERYWHERE ELSE in this SDK: a map
673
+ * keyed by your organisation's identifier fields (F015). The KEY is the
674
+ * identifier's kind. Before MOB-043 this took a bare string and always
675
+ * declared it `email`, whatever your organisation is keyed on, so an
676
+ * organisation keyed on mobile or customer_id sent every person's number as
677
+ * an email address.
678
+ *
679
+ * THE WIRE IS STILL THE PAIR. The portal road (rights/principal
680
+ * portal_session_handlers.go at 4fda7e3d05) takes one `data_principal_ref`
681
+ * and its `data_principal_ref_type`, and defaults an absent type to email.
682
+ * So this SDK always sends the type, and sends exactly ONE identifier: the
683
+ * map's only entry, or, when you pass several and configured
684
+ * {@link SessionConfig.identifierScheme}, the first scheme field present.
685
+ *
686
+ * `phone` is refused here, before the wire (UNKNOWN_IDENTIFIER_FIELD). The
687
+ * portal road would silently fold it to `mobile`, but this SDK's
688
+ * vocabulary has one spelling, and on every other road `phone` is refused
689
+ * by name. The accepted kinds are the platform's resolver set:
690
+ * customer_id, email, mobile, aadhaar. The platform refuses anything else
691
+ * and names that set. It auto-provisions a portal account only from an
692
+ * email; for another kind the person must already have one (404, whose
693
+ * `platformMessage` says so).
694
+ */
695
+ // `async` so that a refusal before the wire is a REJECTION, not a synchronous
696
+ // throw a `.catch()` caller never sees (the same reason validate is async).
697
+ async createPortalSession(identifiers) {
698
+ const [kind, value] = portalIdentifier(identifiers, this.cfg.identifierScheme);
699
+ return this.post('/consent/portal-sessions', {
700
+ data_principal_ref: value,
701
+ data_principal_ref_type: kind
702
+ });
703
+ }
704
+
705
+ /** Mint + open the full DP portal in one tap, in the same in-app browser. */
706
+ async openPortal(identifiers) {
707
+ const p = await this.createPortalSession(identifiers);
708
+ await this.presentHosted(p.portal_url);
709
+ }
710
+
711
+ /**
712
+ * Parse and CHECK the deep link the hosted page redirects to on submit.
713
+ *
714
+ * ─── WHAT IS CHECKED, AND WHY EACH ONE ──────────────────────────────────
715
+ *
716
+ * scheme AND host AND path, all three. This used to compare only
717
+ * `url.startsWith('myapp://')`, which accepts
718
+ * `myapp://anything/anywhere?status=granted`. A custom scheme is first-come on
719
+ * Android and undefined on iOS: another installed app can register the same
720
+ * one, and the only thing that distinguishes OUR callback from its invention
721
+ * is the whole URL plus the nonce.
722
+ *
723
+ * `state` must equal `expectedState` when one is given — the nonce
724
+ * `createSession` put on `callback_url`, echoed back because
725
+ * `addRedirectParams` (consent/collection.go) appends to an existing query
726
+ * rather than replacing it.
727
+ *
728
+ * The query is parsed with the platform URL parser, not by splitting on `=`.
729
+ * The old hand-rolled split kept only the first two parts of each pair, so any
730
+ * value containing `=` — a base64 artifact id, for instance — was silently
731
+ * truncated.
732
+ *
733
+ * ─── WHAT IS STILL NOT PROVEN ───────────────────────────────────────────
734
+ *
735
+ * That the person granted anything. `status=granted` is a query parameter, and
736
+ * this SDK cannot verify the platform's `sig` without the DF's signing secret,
737
+ * which must not be in the app. ALWAYS confirm by reading the consent back
738
+ * through your backend (`validate`) — and expect that read to wait while
739
+ * `pending` is true.
740
+ *
741
+ * `callbackStatus` is `granted | partial | denied` exactly as the platform
742
+ * issues them; anything else is `unknown`, never a grant.
743
+ *
744
+ * Pass `expectedState` as null ONLY when the app was killed during the browser
745
+ * leg and `ConsentSession.callbackState` is genuinely gone — then re-validate
746
+ * rather than trusting this.
747
+ *
748
+ * @throws ConsenteraError when the link is not ours.
749
+ */
750
+ parseCallback(url, expectedState) {
751
+ let parsed;
752
+ try {
753
+ parsed = new URL(url);
754
+ } catch {
755
+ throw new ConsenteraError(`callback rejected: ${url.slice(0, 80)} did not parse as a URL`);
756
+ }
757
+ // URL normalises `scheme:` with the colon; compare without it.
758
+ const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
759
+ if (scheme !== this.cfg.callbackScheme.toLowerCase()) {
760
+ throw new ConsenteraError(`callback rejected: scheme was ${scheme}, expected ${this.cfg.callbackScheme}`);
761
+ }
762
+ const expectedHost = (this.cfg.callbackHost ?? 'consent').toLowerCase();
763
+ if (parsed.hostname.toLowerCase() !== expectedHost) {
764
+ throw new ConsenteraError(`callback rejected: host was ${parsed.hostname}, expected ${expectedHost}`);
765
+ }
766
+ const expectedPath = this.cfg.callbackPath ?? '/callback';
767
+ if (parsed.pathname !== expectedPath) {
768
+ throw new ConsenteraError(`callback rejected: path was ${parsed.pathname}, expected ${expectedPath}`);
769
+ }
770
+ const parameters = {};
771
+ parsed.searchParams.forEach((v, k) => {
772
+ parameters[k] = v;
773
+ });
774
+ if (expectedState !== null && parameters.state !== expectedState) {
775
+ throw new ConsenteraError('callback rejected: state did not match the nonce this session was created with');
776
+ }
777
+ return {
778
+ sessionId: parameters.session_id,
779
+ artifactId: parameters.artifact_id,
780
+ status: parameters.status,
781
+ callbackStatus: callbackStatusOf(parameters.status),
782
+ pending: parameters.pending === '1',
783
+ state: parameters.state,
784
+ signature: parameters.sig,
785
+ parameters
786
+ };
787
+ }
788
+ }
789
+ //# sourceMappingURL=session.js.map