@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.
package/src/session.ts ADDED
@@ -0,0 +1,1051 @@
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: string | null | undefined, now = Date.now()): number | null {
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
+ readonly status?: number;
61
+ /** The platform's canonical error code, e.g. `VALIDATION_ERROR`. */
62
+ readonly code?: string;
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
+ readonly platformMessage?: string;
73
+ /** `X-Request-Id` off the response, for a support ticket. */
74
+ readonly requestId?: string;
75
+ /** True for a timeout or a transport failure — the call may be retried. */
76
+ readonly retryable: boolean;
77
+
78
+ constructor(
79
+ message: string,
80
+ opts: {
81
+ status?: number;
82
+ code?: string;
83
+ platformMessage?: string;
84
+ requestId?: string;
85
+ retryable?: boolean;
86
+ } = {},
87
+ ) {
88
+ super(message);
89
+ this.name = 'ConsenteraError';
90
+ this.status = opts.status;
91
+ this.code = opts.code;
92
+ this.platformMessage = opts.platformMessage;
93
+ this.requestId = opts.requestId;
94
+ this.retryable = opts.retryable ?? false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The platform's error envelope, `{code, message}` (core/apierrors/errors.go
100
+ * APIError), read out of a response body. Tolerates the older
101
+ * `{error: {code, message}}` nesting. Never throws: a body that is not an
102
+ * envelope yields nothing.
103
+ */
104
+ export function readErrorEnvelope(text: string): { code?: string; message?: string } {
105
+ try {
106
+ const parsed = JSON.parse(text) as {
107
+ code?: unknown;
108
+ message?: unknown;
109
+ error?: { code?: unknown; message?: unknown };
110
+ };
111
+ const src = parsed && typeof parsed === 'object' && parsed.error && typeof parsed.error === 'object'
112
+ && parsed.code === undefined ? parsed.error : parsed;
113
+ return {
114
+ code: typeof src?.code === 'string' && src.code ? src.code : undefined,
115
+ message: typeof src?.message === 'string' && src.message ? src.message : undefined,
116
+ };
117
+ } catch {
118
+ return {};
119
+ }
120
+ }
121
+
122
+ /**
123
+ * 160 bits from the platform CSPRNG, URL-safe, unpadded.
124
+ *
125
+ * REFUSES RATHER THAN DEGRADING. React Native ships no `crypto.getRandomValues`
126
+ * by default, and the obvious substitute — `Math.random` — is not a CSPRNG: this
127
+ * nonce is the only thing standing between a forged
128
+ * `myapp://consent/callback?status=granted` from another app and a host that
129
+ * believes it, so a predictable one is worse than none. Install
130
+ * `react-native-get-random-values` and import it once at the top of your entry
131
+ * file, which is what every RN crypto consumer already does.
132
+ */
133
+ export function newCallbackState(): string {
134
+ const g = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array } }).crypto;
135
+ if (!g?.getRandomValues) {
136
+ throw new ConsenteraError(
137
+ 'no cryptographic random source: install react-native-get-random-values and ' +
138
+ "add `import 'react-native-get-random-values';` to the top of index.js. " +
139
+ 'This SDK will not fall back to Math.random for the callback nonce.',
140
+ );
141
+ }
142
+ const bytes = g.getRandomValues(new Uint8Array(20));
143
+ let bin = '';
144
+ bytes.forEach((b) => {
145
+ bin += String.fromCharCode(b);
146
+ });
147
+ // btoa exists in RN's JS runtime (Hermes and JSC both provide it).
148
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
149
+ }
150
+
151
+ /** The one shape the SDK needs from whichever in-app browser the host installed. */
152
+ export interface InAppBrowserAdapter {
153
+ /** Open `url`, intercept `redirectScheme`, resolve the URL or null on dismiss. */
154
+ openAuth(url: string, redirectScheme: string): Promise<string | null>;
155
+ }
156
+
157
+ let injectedBrowser: InAppBrowserAdapter | null = null;
158
+
159
+ /**
160
+ * Supply the in-app browser explicitly — for Expo, or for a host that already
161
+ * has one:
162
+ *
163
+ * import * as WebBrowser from 'expo-web-browser';
164
+ * setInAppBrowser({
165
+ * openAuth: async (url, scheme) => {
166
+ * const r = await WebBrowser.openAuthSessionAsync(url, scheme);
167
+ * return r.type === 'success' ? r.url : null;
168
+ * },
169
+ * });
170
+ */
171
+ export function setInAppBrowser(adapter: InAppBrowserAdapter | null): void {
172
+ injectedBrowser = adapter;
173
+ }
174
+
175
+ /**
176
+ * The injected adapter, else `react-native-inappbrowser-reborn` if the host
177
+ * installed it, else null — and null makes `presentConsent` THROW rather than
178
+ * fall back to `Linking.openURL`.
179
+ *
180
+ * The peer is resolved with a guarded `require` rather than a top-level import
181
+ * on purpose: a static import would make the peer mandatory for every consumer,
182
+ * including the ones that inject their own.
183
+ */
184
+ export function resolveInAppBrowser(): InAppBrowserAdapter | null {
185
+ if (injectedBrowser) return injectedBrowser;
186
+ try {
187
+ // Declared locally rather than pulling in @types/node: this is Metro's
188
+ // require, and the SDK has no other CommonJS surface.
189
+ const req = (globalThis as { require?: (id: string) => unknown }).require
190
+ ?? (eval('require') as (id: string) => unknown);
191
+ const mod = req('react-native-inappbrowser-reborn') as {
192
+ InAppBrowser?: {
193
+ isAvailable(): Promise<boolean>;
194
+ openAuth(url: string, redirect: string, opts?: object): Promise<{ type: string; url?: string }>;
195
+ };
196
+ };
197
+ const b = mod?.InAppBrowser;
198
+ if (!b || !NativeModules.RNInAppBrowser) return null;
199
+ return {
200
+ openAuth: async (url, redirectScheme) => {
201
+ const r = await b.openAuth(url, redirectScheme, {
202
+ ephemeralWebSession: false,
203
+ showTitle: true,
204
+ enableUrlBarHiding: false,
205
+ });
206
+ return r.type === 'success' && r.url ? r.url : null;
207
+ },
208
+ };
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * ConsenteraSession — the React Native twin of the Android SDK's session
216
+ * package. Pure JS: the DPDP flow needs no native module.
217
+ *
218
+ * Flow (identical to every other platform):
219
+ * 1. createSession({ dataPrincipalRef, noticeInternalName }) via YOUR
220
+ * backend/proxy (the app never holds tiq_live_*)
221
+ * 2. presentConsent(session) → the hosted collect page in an IN-APP browser
222
+ * (react-native-inappbrowser-reborn, or your own via setInAppBrowser).
223
+ * It REFUSES rather than falling back to the external browser.
224
+ * 3. deep-link back is BEST-EFFORT (Chrome blocks gesture-less custom-scheme
225
+ * redirects) — always re-validate on appState → 'active', and put any link
226
+ * through parseCallback, which checks scheme, host, path and the `state`
227
+ * nonce before you look at `status`
228
+ * 4. validate()/withdraw() by a PrincipalRef — `data_principal_id` or typed
229
+ * `data_principal_identifiers`. A bare `data_principal_ref` is refused
230
+ * outright by the API. openPortal() for the full DP portal (rights,
231
+ * receipts, grievances) via one-tap SSO — that road, and only that road,
232
+ * still takes the old pair.
233
+ */
234
+
235
+ /**
236
+ * The one check on `backendBaseUrl`: present, and an absolute http(s) URL.
237
+ * Throws a {@link ConsenteraError} with code `BACKEND_BASE_URL_REQUIRED`.
238
+ */
239
+ function assertBackendBaseUrl(value: unknown): string {
240
+ const base = typeof value === 'string' ? value.trim() : '';
241
+ // A regex, NOT `new URL(base).protocol`: React Native's built-in URL class
242
+ // implements the constructor and `href` only, and its other getters throw
243
+ // "not implemented" unless the host installed a polyfill — so a check built
244
+ // on them would refuse every URL on a device while passing under Jest.
245
+ if (!/^https?:\/\/[^\s/?#]+/i.test(base)) {
246
+ throw new ConsenteraError(
247
+ 'backendBaseUrl is required and must be an absolute http(s) URL. There is no default ' +
248
+ "server: set it to your own backend's Consentera route (the server that holds the " +
249
+ 'tiq_live_ key).',
250
+ { code: 'BACKEND_BASE_URL_REQUIRED' },
251
+ );
252
+ }
253
+ return value as string;
254
+ }
255
+
256
+ export interface SessionConfig {
257
+ /**
258
+ * Your backend/proxy base, e.g. https://api.yourbank.in/consentera.
259
+ * REQUIRED, with no default: a blank or non-http(s) value throws
260
+ * `ConsenteraError` (`code: 'BACKEND_BASE_URL_REQUIRED'`) from the constructor.
261
+ */
262
+ backendBaseUrl: string;
263
+ /** Registered deep-link scheme (ops: ALLOWED_CALLBACK_APP_SCHEMES) */
264
+ callbackScheme: string;
265
+ callbackHost?: string;
266
+ callbackPath?: string;
267
+ /** Per-ATTEMPT timeout in ms (default 30000) — a dead backend fails fast. */
268
+ requestTimeoutMs?: number;
269
+ /**
270
+ * Ceiling on the whole call including retries (default 2x requestTimeoutMs).
271
+ * Without it, N retries multiply the caller's wait by N and a consent screen
272
+ * hangs for a minute and a half on a bad network.
273
+ */
274
+ totalTimeoutMs?: number;
275
+ /** Attempts INCLUDING the first (default 3). 1 disables retrying. */
276
+ maxAttempts?: number;
277
+ /** Full-jitter base delay in ms (default 250). */
278
+ retryBaseDelayMs?: number;
279
+ /**
280
+ * The identifier fields of your organisation's locked integration key, in
281
+ * the order you prefer them for signing a person in to the privacy portal —
282
+ * e.g. `['customer_id', 'mobile']`. Optional. {@link ConsenteraSession.openPortal}
283
+ * uses it to choose which ONE identifier to send when you pass it several;
284
+ * without it, pass exactly one (MOB-043).
285
+ */
286
+ identifierScheme?: string[];
287
+ /**
288
+ * Opt-in diagnostics sink. Undefined (the default) means the SDK is SILENT.
289
+ *
290
+ * It replaces a bare `console.warn`, which could not be turned off, redirected
291
+ * or levelled. Nothing PII-bearing is passed here: the messages name
292
+ * conditions, never values, and the transport deliberately keeps response
293
+ * bodies out of its errors for the same reason.
294
+ */
295
+ onDiagnostic?: (message: string) => void;
296
+ }
297
+
298
+ /**
299
+ * The deep link handed to the platform as `callback_url`, carrying `state`.
300
+ *
301
+ * THE STATE SURVIVES THE ROUND TRIP, AND THAT IS A PLATFORM FACT, not an
302
+ * assumption: `addRedirectParams` (consent/collection.go) appends with `&` when
303
+ * the callback URL already contains a `?`, so `myapp://consent/callback?state=X`
304
+ * comes back as `…?artifact_id=…&pending=1&session_id=…&state=X&status=…`
305
+ * (setRedirectParam re-encodes the query, so the keys arrive sorted). If that ever
306
+ * changes, `parseCallback` starts refusing every callback rather than silently
307
+ * accepting a forged one.
308
+ */
309
+ export function callbackUrlFor(cfg: SessionConfig, state: string): string {
310
+ const host = cfg.callbackHost ?? 'consent';
311
+ const path = cfg.callbackPath ?? '/callback';
312
+ return `${cfg.callbackScheme}://${host}${path}?state=${encodeURIComponent(state)}`;
313
+ }
314
+
315
+ /**
316
+ * THE STATUS VOCABULARY THE PLATFORM PUTS ON A CALLBACK — and only these.
317
+ *
318
+ * consent/collection.go (platform pre-main 35cd853ac7) derives it at
319
+ * :4094-4098 — `granted`; no purpose granted → `denied`; some denied →
320
+ * `partial` — and SETS it on the redirect at :4243. Any other value
321
+ * (`success`, `completed`, `expired`, a different case, none at all) did not
322
+ * come from the platform and is `unknown`: never read it as a grant.
323
+ */
324
+ export type CallbackStatus = 'granted' | 'partial' | 'denied' | 'unknown';
325
+
326
+ /** Map a raw `status` query value onto the platform's vocabulary. Exact match: the server writes lowercase. */
327
+ export function callbackStatusOf(raw: string | null | undefined): CallbackStatus {
328
+ return raw === 'granted' || raw === 'partial' || raw === 'denied' ? raw : 'unknown';
329
+ }
330
+
331
+ /**
332
+ * Parsed from the deep link the hosted page redirects to on submit.
333
+ *
334
+ * A CALLBACK IS A HINT AND NEVER A DECISION. What `parseCallback` guarantees is
335
+ * narrower and still worth having: this callback came back to the deep link THIS
336
+ * SDK asked for, carrying the nonce THIS SDK generated.
337
+ */
338
+ export interface ConsentCallback {
339
+ sessionId?: string;
340
+ artifactId?: string;
341
+ /** The raw `status` query value, exactly as it arrived. Prefer {@link callbackStatus}. */
342
+ status?: string;
343
+ /**
344
+ * `status` read against the platform's vocabulary: `granted`, `partial`
345
+ * (some purposes declined) or `denied`, else `unknown`. A HINT, not proof —
346
+ * confirm by reading the consent back through your backend (`validate`).
347
+ */
348
+ callbackStatus: CallbackStatus;
349
+ /**
350
+ * `pending=1` on the return (collection.go:4274, walk finding F018): the
351
+ * consent was recorded and its record is STILL BEING WRITTEN. The platform
352
+ * sets it on every capture. It is not signed and says nothing about whether
353
+ * a consent exists — it tells your backend to expect the read-back to wait
354
+ * (202 + Retry-After) rather than to treat an early miss as "no consent".
355
+ */
356
+ pending: boolean;
357
+ /**
358
+ * The nonce this SDK generated, echoed back by the platform and already
359
+ * compared against the expected value before this object was returned.
360
+ */
361
+ state?: string;
362
+ /**
363
+ * The platform's HMAC over (session_id, artifact_id, status), present only
364
+ * when the DF has a `callback_signing_secret` (collection.go:4175-4182).
365
+ *
366
+ * DO NOT VERIFY IT IN THE APP. Verifying needs the secret, and a secret in an
367
+ * app binary is not a secret. Send it to your own backend.
368
+ */
369
+ signature?: string;
370
+ /** Every query parameter, for logging. */
371
+ parameters: Record<string, string>;
372
+ }
373
+
374
+ export interface ConsentSession {
375
+ consent_session_id: string;
376
+ consent_url: string;
377
+ notice_hash?: string;
378
+ /**
379
+ * When this session stops being usable. Read it: a host that cannot see the
380
+ * expiry cannot decide whether to re-create rather than re-open. camelCase is
381
+ * the API's entrenched contract (collection.go:241).
382
+ */
383
+ expiresAt?: string;
384
+ /** The notice VERSION this session is bound to (collection.go:243). */
385
+ notice_version_id?: string;
386
+ /** The language the platform actually served, after its fallback chain. */
387
+ languageCode?: string;
388
+ /** The schema version the hosted page renders with (collection.go:247). */
389
+ ui_schema_version?: string;
390
+ /**
391
+ * The client-generated nonce this SDK put on the callback URL.
392
+ *
393
+ * NOT FROM THE SERVER — `createSession` sets it after parsing the response.
394
+ * `challengeNonce` is the SERVER's nonce for the render/submit legs and is a
395
+ * different value with a different job. It is not persisted: if the app is
396
+ * killed while the browser is open it is gone, which is the case
397
+ * resume-revalidate covers.
398
+ */
399
+ callbackState?: string;
400
+ /**
401
+ * camelCase on the wire, and that is the wire, not a transcription error:
402
+ * `expiresAt`, `languageCode` and `challengeNonce` are pinned camelCase in the
403
+ * API's own struct as an entrenched contract while the rest of the response is
404
+ * snake_case.
405
+ */
406
+ challengeNonce?: string;
407
+ /**
408
+ * The platform's own uuid for the person. Always returned — store it and send
409
+ * it as `dataPrincipalId` next time.
410
+ */
411
+ data_principal_id?: string;
412
+ /**
413
+ * Consequences that did not refuse the request. READ THEM: an unknown age, or
414
+ * a guardian channel whose invitation could not be sent, arrives here with a
415
+ * 200 and is otherwise invisible.
416
+ */
417
+ warnings?: string[];
418
+ /**
419
+ * Present when this session is a child's and an invitation went out to the
420
+ * guardian channel the request carried. The consent is NOT recorded until that
421
+ * guardian verifies.
422
+ */
423
+ guardian_verification?: GuardianVerificationPending;
424
+ }
425
+
426
+ /**
427
+ * The §9(1) state a child's session rests in while the guardian verifies.
428
+ *
429
+ * IT CARRIES NO LINK AND NO TOKEN, deliberately. The verification URL is a
430
+ * bearer credential — address-bound, 72 hours, single use — and it goes to the
431
+ * guardian's own address. Returning it here would put it in the organisation's
432
+ * logs, and the organisation is not the party the link is for.
433
+ */
434
+ export interface GuardianVerificationPending {
435
+ /** `'pending'`: the guardianship exists and nothing about it is proven yet. */
436
+ status: string;
437
+ /** Which channel the invitation went out on — `'email'` or `'sms'`. */
438
+ channel: string;
439
+ /** Names the guardianship, so it can be followed on the guardian APIs. */
440
+ link_id: string;
441
+ }
442
+
443
+ /**
444
+ * `data_principal_identifiers` — how every consent LIFECYCLE road names a
445
+ * person.
446
+ *
447
+ * ─── IT IS THE SAME SHAPE AS `dataPrincipal` ON SESSION CREATE (F015) ─────
448
+ *
449
+ * An OPEN map keyed by THE TENANT'S OWN LOCKED INTEGRATION KEY — the same
450
+ * value `data_principal` carries on create (update_context.go:104-119).
451
+ * F015 (consent/one-identifier-vocabulary-20260922) deleted the earlier
452
+ * closed five-field object, because a closed struct cannot carry a per-tenant
453
+ * vocabulary:
454
+ *
455
+ * * ONE VOCABULARY NOW, ONE SPELLING. The mobile atom is `mobile` on BOTH
456
+ * roads; F015 removed the old server-side fold, so `phone` is refused BY
457
+ * NAME (400 UNKNOWN_IDENTIFIER_FIELD, "…use mobile"). Do NOT send `phone`.
458
+ * * THE ADMISSIBLE SET IS PER-TENANT, so this SDK cannot know it and must NOT
459
+ * allow-list. Send the fields the organisation locked; the SERVER answers
460
+ * UNKNOWN_IDENTIFIER_FIELD, naming the field, when you get it wrong.
461
+ * * THERE IS NO `pan` KEY — it is evidence-class and can never be a scheme
462
+ * field.
463
+ *
464
+ * Only the wire KEY differs from create (`data_principal` may mint a person,
465
+ * `data_principal_identifiers` resolves only). Any ONE field is enough. A raw
466
+ * 12-digit `aadhaar` value is refused 400 INVALID_IDENTIFIER_FORMAT (F015
467
+ * folded the old AADHAAR_RAW_REFUSED into that one refusal); send the
468
+ * Aadhaar-LINKED token, never the number.
469
+ */
470
+ export type DataPrincipalIdentifiers = Record<string, string>;
471
+
472
+ /**
473
+ * The ONE way a consent lifecycle road names a person.
474
+ *
475
+ * `data_principal_ref` is REFUSED OUTRIGHT since 2026-09-21 — no transition
476
+ * period (consent/lifecycle_identity.go:8-11) — and the refusal fires even when
477
+ * `data_principal_id` is also present, because two fields naming a person can
478
+ * disagree and the caller would never learn which one the answer was about.
479
+ * A union makes the refused request unrepresentable rather than a round trip.
480
+ */
481
+ export type PrincipalRef =
482
+ | { dataPrincipalId: string; dataPrincipalIdentifiers?: never }
483
+ | { dataPrincipalIdentifiers: DataPrincipalIdentifiers; dataPrincipalId?: never };
484
+
485
+ /** Turn a {@link PrincipalRef} into the body fields that name the person. */
486
+ export function principalBody(who: PrincipalRef): Record<string, unknown> {
487
+ if ('dataPrincipalId' in who && who.dataPrincipalId) {
488
+ return { data_principal_id: who.dataPrincipalId };
489
+ }
490
+ const ids = who.dataPrincipalIdentifiers;
491
+ if (!ids || Object.keys(ids).length === 0) {
492
+ // The fields are the tenant's own locked integration key, which this SDK
493
+ // cannot know and does not enumerate (F015). The server lists them in its
494
+ // UNKNOWN_IDENTIFIER_FIELD refusal.
495
+ // ConsenteraError, not a bare Error: "you named nobody" is the caller's
496
+ // own bug and a caller must be able to switch on it, like every other
497
+ // failure this SDK raises. The code is the one the SERVER would answer with
498
+ // if the body reached it, so the same branch handles both.
499
+ throw new ConsenteraError(
500
+ 'Consentera: name the Data Principal with dataPrincipalId, or with ' +
501
+ 'dataPrincipalIdentifiers carrying the identifier fields of this ' +
502
+ "organisation's integration key. data_principal_ref is refused by the API.",
503
+ { code: 'IDENTIFIER_REQUIRED' }
504
+ );
505
+ }
506
+ return { data_principal_identifiers: ids };
507
+ }
508
+
509
+ /**
510
+ * The ONE identifier the portal road is sent, as `[kind, value]`.
511
+ *
512
+ * The map's only entry, or, when it has several, the first field of
513
+ * `scheme` that it carries. It throws (a ConsenteraError whose `code` is the
514
+ * one the server would use) rather than guess: an empty map is
515
+ * IDENTIFIER_REQUIRED, several entries and no scheme to choose by is
516
+ * IDENTIFIER_AMBIGUOUS, and `phone` is UNKNOWN_IDENTIFIER_FIELD.
517
+ */
518
+ export function portalIdentifier(
519
+ identifiers: DataPrincipalIdentifiers,
520
+ scheme?: readonly string[],
521
+ ): [kind: string, value: string] {
522
+ const entries = Object.entries(identifiers ?? {}).filter(([, v]) => typeof v === 'string' && v.trim() !== '');
523
+ if (entries.some(([k]) => k === 'phone')) {
524
+ throw new ConsenteraError(
525
+ "Consentera: `phone` is not an identifier field. This platform spells it `mobile` (F015).",
526
+ { code: 'UNKNOWN_IDENTIFIER_FIELD' },
527
+ );
528
+ }
529
+ if (entries.length === 0) {
530
+ throw new ConsenteraError(
531
+ 'Consentera: name the person for the portal with one identifier of your organisation\'s ' +
532
+ "integration key, e.g. { mobile: '+91…' }.",
533
+ { code: 'IDENTIFIER_REQUIRED' },
534
+ );
535
+ }
536
+ const only = entries.length === 1 ? entries[0] : undefined;
537
+ if (only) return [only[0], only[1]];
538
+ const chosen = (scheme ?? []).find((field) => entries.some(([k]) => k === field));
539
+ const chosenValue = chosen === undefined ? undefined : identifiers[chosen];
540
+ if (chosen === undefined || chosenValue === undefined) {
541
+ throw new ConsenteraError(
542
+ `Consentera: the portal takes ONE identifier and ${entries.length} were given ` +
543
+ `(${entries.map(([k]) => k).join(', ')}). Pass one, or set identifierScheme in the ` +
544
+ 'SessionConfig so the SDK can choose.',
545
+ { code: 'IDENTIFIER_AMBIGUOUS' },
546
+ );
547
+ }
548
+ return [chosen, chosenValue];
549
+ }
550
+
551
+ export interface ConsentDecision {
552
+ decision: string; // ALLOW | DENY
553
+ reason_code?: string;
554
+ purpose_code?: string;
555
+ effective_at?: string;
556
+ expires_at?: string;
557
+ }
558
+
559
+ export interface PortalSession {
560
+ portal_url: string;
561
+ expires_in_seconds: number;
562
+ auto_provisioned: boolean;
563
+ }
564
+
565
+ export interface CreateSessionRequest {
566
+ /**
567
+ * The person's identifiers, KEYED BY THE FIELD NAMES OF YOUR ORGANISATION'S
568
+ * LOCKED INTEGRATION KEY — `{ email: 'riya@example.in' }`,
569
+ * `{ customer_id: 'CUST-90210', mobile: '+919876500000' }`.
570
+ *
571
+ * A MAP AND NOT A SET OF NAMED FIELDS (U58). The admissible key set is a
572
+ * PER-TENANT fact the platform reads at request time, and THE TYPE COMES FROM
573
+ * THE FIELD NAME: there is no declared type to disagree with the value and no
574
+ * shape to guess from. A field outside your key is refused 400
575
+ * UNKNOWN_IDENTIFIER_FIELD, whose message LISTS the allowed fields; too few of
576
+ * them is 400 IDENTIFIER_REQUIRED.
577
+ *
578
+ * NO SPELLING IS FOLDED ANY MORE. On a key of {email, mobile}, `phone` is an
579
+ * unknown field, not a spelling of `mobile`.
580
+ */
581
+ dataPrincipal?: Record<string, string>;
582
+ /** The platform's own uuid for the person, from any previous createSession response. */
583
+ dataPrincipalId?: string;
584
+ noticeInternalName: string;
585
+ /** Bind exactly this version NUMBER of the notice code; omit for its default version. */
586
+ noticeVersionNumber?: number;
587
+ sessionRef?: string;
588
+ /**
589
+ * YYYY-MM-DD, sent as `age.date_of_birth` (U58; it was
590
+ * `data_principal_details.date_of_birth`). Age is server-authoritative: the
591
+ * date is re-read every time, so a person graduates at eighteen without
592
+ * anyone updating a flag. Omit it and the person's age is UNKNOWN — the
593
+ * session is still created, and purposes restricted for children are then
594
+ * refused. A malformed or implausible date is refused 400
595
+ * INVALID_DATE_OF_BIRTH.
596
+ */
597
+ dateOfBirth?: string;
598
+ /**
599
+ * Preferred language for the consent page, sent as `language` — ONE field,
600
+ * replacing `locale_pref`, `notice_language` and `template_language`
601
+ * together (U58). It goes AHEAD of the tenant's own default in the server's
602
+ * fallback chain, so send it only when a language was actually asked for. A
603
+ * language the platform does not serve is refused 400 UNSUPPORTED_LANGUAGE.
604
+ */
605
+ language?: string;
606
+
607
+ // ─── The guardian channel (U58) ────────────────────────────────────────
608
+ //
609
+ // NEW ON THIS SURFACE. Before this flip a React Native host could send a
610
+ // `dateOfBirth` and nothing else, so a child whose parent is not already a
611
+ // customer of the organisation could not consent through this SDK at all.
612
+ //
613
+ // WHEN YOU NEED ONE: when `dateOfBirth` is a child's. The gate is the
614
+ // SERVER'S determination from that date — there is no flag to omit — and a
615
+ // request without a channel is refused 412 GUARDIAN_REQUIRED.
616
+ //
617
+ // EITHER, NOT BOTH: one invitation goes to one address, and two addresses
618
+ // name two people with nothing saying which is the guardian.
619
+ //
620
+ // TOP-LEVEL, AND NEVER INSIDE `dataPrincipal`, which holds the identifiers of
621
+ // the person the consent is ABOUT. A guardian contact is a channel to a
622
+ // DIFFERENT person and identifies nobody on this request.
623
+
624
+ /** Where the guardian's verification request is sent. Not with [guardianPhone]. */
625
+ guardianEmail?: string;
626
+ /** Where the guardian's verification request is sent. Not with [guardianEmail]. */
627
+ guardianPhone?: string;
628
+ /**
629
+ * What the child SAYS the guardian is to them ('mother', 'father', 'legal
630
+ * guardian', …). Recorded as a CLAIM and confirmed or corrected by the
631
+ * guardian on the verification road; nothing downstream treats it as proven.
632
+ */
633
+ guardianRelationship?: string;
634
+ }
635
+
636
+ export class ConsenteraSession {
637
+ constructor(private cfg: SessionConfig) {
638
+ // NO DEFAULT SERVER, and the refusal names the field. The app talks to its
639
+ // OWN backend and nothing else, so no host this SDK could ship would ever
640
+ // be the right one — and SDKs in this repository did ship one, on a domain
641
+ // the company does not own. The type says `string`, but a JS caller, an
642
+ // unset env var or a blank remote-config value reaches here as undefined
643
+ // or '', which used to surface as a TypeError on `.startsWith` or as a
644
+ // fetch to a relative path at the first request.
645
+ const base = assertBackendBaseUrl(cfg?.backendBaseUrl);
646
+ // Enterprise guard: consent traffic must be HTTPS in production. Plain
647
+ // HTTP is tolerated only for the local dev bridges, and loudly.
648
+ if (base.startsWith('http://') && !/^http:\/\/(10\.0\.2\.2|localhost|127\.0\.0\.1)[:/]/.test(base)) {
649
+ cfg.onDiagnostic?.('backendBaseUrl is plain HTTP — production integrations must use HTTPS.');
650
+ }
651
+ }
652
+
653
+ /**
654
+ * ONE transport for every road.
655
+ *
656
+ * WHAT IT ADDS, and why each one:
657
+ *
658
+ * * **Identity.** `X-Consentera-SDK: react-native/<version>` on every
659
+ * request. Without it the platform cannot tell which SDK build produced a
660
+ * failure, which is the first question on every support ticket.
661
+ * * **Idempotency.** One key per OPERATION, not per attempt: a retried
662
+ * mutation must not record a second consent or a second withdrawal. The key
663
+ * is minted once per `post` call and reused across its retries.
664
+ * * **Retry with backoff and FULL JITTER**, on 429, 5xx and transport
665
+ * failures only. Never on a 4xx: a refused body is refused on every
666
+ * attempt, and retrying it just spends the tenant's rate budget.
667
+ * * **`Retry-After` is obeyed** when the server sends one, in either its
668
+ * delta-seconds or HTTP-date form. The platform rate-limits these roads
669
+ * (routes_consent.go:377,389,406,567), so this is not hypothetical.
670
+ * * **Request id**, surfaced on the error rather than discarded.
671
+ * * **A per-ATTEMPT timeout inside a per-CALL deadline**, so N retries cannot
672
+ * silently multiply the caller's wait by N.
673
+ */
674
+ private async post<T>(path: string, body: unknown, opts: { idempotent?: boolean } = {}): Promise<T> {
675
+ const perAttemptMs = this.cfg.requestTimeoutMs ?? 30000;
676
+ const deadline = Date.now() + (this.cfg.totalTimeoutMs ?? perAttemptMs * 2);
677
+ const maxAttempts = Math.max(1, this.cfg.maxAttempts ?? 3);
678
+ // MINTED ONCE, REUSED ACROSS RETRIES. A fresh key per attempt would defeat
679
+ // the whole mechanism — that is the bug idempotency keys exist to prevent.
680
+ const idempotencyKey = opts.idempotent === false ? undefined : newCallbackState();
681
+
682
+ let lastError: ConsenteraError | undefined;
683
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
684
+ const controller = new AbortController();
685
+ const budget = Math.min(perAttemptMs, Math.max(0, deadline - Date.now()));
686
+ const timer = setTimeout(() => controller.abort(), budget);
687
+ let res: Response;
688
+ try {
689
+ res = await fetch(`${this.cfg.backendBaseUrl}${path}`, {
690
+ method: 'POST',
691
+ headers: {
692
+ 'Content-Type': 'application/json',
693
+ 'User-Agent': SDK_USER_AGENT,
694
+ 'X-Consentera-SDK': SDK_IDENTIFIER,
695
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
696
+ },
697
+ body: JSON.stringify(body),
698
+ signal: controller.signal,
699
+ });
700
+ } catch (e: unknown) {
701
+ const err = e as { name?: string; message?: string };
702
+ lastError = new ConsenteraError(
703
+ err?.name === 'AbortError'
704
+ ? `Consentera ${path}: request timed out after ${budget}ms`
705
+ : `Consentera ${path}: ${err?.message ?? 'network error'}`,
706
+ { retryable: true },
707
+ );
708
+ if (attempt < maxAttempts && (await this.backoff(attempt, null, deadline))) continue;
709
+ throw lastError;
710
+ } finally {
711
+ clearTimeout(timer);
712
+ }
713
+
714
+ const requestId = res.headers.get('X-Request-Id') ?? res.headers.get('x-request-id') ?? undefined;
715
+ const text = await res.text();
716
+ if (res.ok) {
717
+ try {
718
+ return JSON.parse(text) as T;
719
+ } catch {
720
+ // A 200 that is not JSON is a proxy or a captive portal, never the
721
+ // platform. It is NOT retried: the same hop answers the same way.
722
+ throw new ConsenteraError(
723
+ `Consentera ${path}: a 2xx response was not JSON (${text.length} bytes)`,
724
+ { status: res.status, requestId },
725
+ );
726
+ }
727
+ }
728
+
729
+ // The envelope's code and message ride on the error as FIELDS, never in
730
+ // the message: the body can carry the person's own identifiers back, and
731
+ // an exception message ends up wherever the host logs (MOB-042).
732
+ const envelope = readErrorEnvelope(text);
733
+ const code = envelope.code;
734
+ const retryable = res.status === 429 || res.status >= 500;
735
+ lastError = new ConsenteraError(
736
+ `Consentera ${path} failed with ${res.status}${code ? ` (${code})` : ''}`,
737
+ { status: res.status, code, platformMessage: envelope.message, requestId, retryable },
738
+ );
739
+ if (retryable && attempt < maxAttempts) {
740
+ const ok = await this.backoff(attempt, res.headers.get('Retry-After'), deadline);
741
+ if (ok) continue;
742
+ }
743
+ throw lastError;
744
+ }
745
+ /* istanbul ignore next — the loop either returns or throws */
746
+ throw lastError ?? new ConsenteraError(`Consentera ${path}: no attempt was made`);
747
+ }
748
+
749
+ /**
750
+ * Sleep before the next attempt. Returns false when the call's deadline would
751
+ * be passed, in which case the caller throws instead of sleeping into it.
752
+ *
753
+ * FULL JITTER: `random(0, base * 2^n)`, the AWS architecture-blog form. A
754
+ * fixed backoff synchronises every client that failed on the same server
755
+ * event and reproduces the spike that caused it.
756
+ */
757
+ private async backoff(attempt: number, retryAfter: string | null, deadline: number): Promise<boolean> {
758
+ let waitMs: number;
759
+ const serverAsked = parseRetryAfter(retryAfter);
760
+ if (serverAsked !== null) {
761
+ // The server named a time. Obey it exactly — jittering a value the server
762
+ // computed just puts some clients back inside the window it rejected.
763
+ waitMs = serverAsked;
764
+ } else {
765
+ const base = this.cfg.retryBaseDelayMs ?? 250;
766
+ waitMs = Math.random() * base * Math.pow(2, attempt - 1);
767
+ }
768
+ if (Date.now() + waitMs >= deadline) return false;
769
+ await new Promise((r) => setTimeout(r, waitMs));
770
+ return true;
771
+ }
772
+
773
+ /**
774
+ * Create a consent session; returns the hosted collect URL.
775
+ *
776
+ * createSession({
777
+ * dataPrincipal: { email: 'riya@example.in' },
778
+ * noticeInternalName: 'bnb_consent_v2',
779
+ * dateOfBirth: '1998-04-12',
780
+ * })
781
+ *
782
+ * Send `dataPrincipal` — the identifiers, keyed by YOUR organisation's locked
783
+ * integration key — or `dataPrincipalId` when you already hold the platform's
784
+ * uuid for the person. Sent together they must agree, or the call is refused
785
+ * 409 IDENTITY_MISMATCH. A request carrying neither falls to the key's floor,
786
+ * 400 IDENTIFIER_REQUIRED.
787
+ *
788
+ * ─── THIS IS THE U58 WIRE, AND THERE IS NO OVERLAP WINDOW ──────────────
789
+ *
790
+ * EVERY KEY BELOW IS ONE THE API DECODES. The handler decodes with NO
791
+ * DisallowUnknownFields — on the old wire and on this one alike — so a key it
792
+ * does not know is dropped in SILENCE; the request is then refused for
793
+ * carrying no identifier, which names a condition and not the field you sent.
794
+ * That is why an SDK on the wrong wire fails obscurely rather than loudly,
795
+ * and why this version talks to an API carrying U58 and to no other.
796
+ */
797
+ async createSession(req: CreateSessionRequest): Promise<ConsentSession> {
798
+ // THE CALLBACK NONCE, minted here and nowhere else. It has to exist BEFORE
799
+ // the call, because `callback_url` is a REQUEST field — there is no later
800
+ // point at which anything could be added to the URL the platform will
801
+ // redirect to. That is also why it is not `challengeNonce`, which arrives in
802
+ // the RESPONSE, one round trip too late to appear in the callback.
803
+ const state = newCallbackState();
804
+ const callback = callbackUrlFor(this.cfg, state);
805
+ const session = await this.post<ConsentSession>('/consent/sessions', {
806
+ notice_internal_name: req.noticeInternalName,
807
+ ui_mode: 'redirect',
808
+ callback_url: callback,
809
+ // Sent verbatim: this SDK does not know the tenant's key and must not
810
+ // guess at it — a guess could only turn the API's field-naming 400 into
811
+ // silence.
812
+ ...(req.dataPrincipal && Object.keys(req.dataPrincipal).length > 0
813
+ ? { data_principal: req.dataPrincipal }
814
+ : {}),
815
+ ...(req.dataPrincipalId ? { data_principal_id: req.dataPrincipalId } : {}),
816
+ ...(req.sessionRef ? { session_ref: req.sessionRef } : {}),
817
+ ...(req.noticeVersionNumber !== undefined ? { notice_version_number: req.noticeVersionNumber } : {}),
818
+ ...(req.dateOfBirth ? { age: { date_of_birth: req.dateOfBirth } } : {}),
819
+ ...(req.language ? { language: req.language } : {}),
820
+ // Top level, never inside data_principal.
821
+ ...(req.guardianEmail ? { guardian_email: req.guardianEmail } : {}),
822
+ ...(req.guardianPhone ? { guardian_phone: req.guardianPhone } : {}),
823
+ ...(req.guardianRelationship ? { guardian_relationship: req.guardianRelationship } : {}),
824
+ });
825
+ session.callbackState = state;
826
+ return session;
827
+ }
828
+
829
+ /**
830
+ * Present the hosted consent notice in an IN-APP browser.
831
+ *
832
+ * ─── WHY A PEER AND NOT `Linking.openURL` ───────────────────────────────
833
+ *
834
+ * This used to be `Linking.openURL`, which leaves the app entirely for the
835
+ * external browser. Three things follow and none is acceptable for a consent
836
+ * surface: the callback comes back over a custom scheme any installed app can
837
+ * also claim; nothing tells the caller whether the page even opened; and the
838
+ * person is gone from the app with no cancellation signal. React Native has no
839
+ * built-in Custom Tabs / SFSafariViewController binding, so the in-app browser
840
+ * comes from a peer.
841
+ *
842
+ * INSTALL THE PEER:
843
+ *
844
+ * npm i react-native-inappbrowser-reborn # then: cd ios && pod install
845
+ *
846
+ * Expo apps can pass `expo-web-browser`'s `openAuthSessionAsync` through
847
+ * {@link SessionConfig} instead — see `openInAppBrowser` below.
848
+ *
849
+ * ─── IT REFUSES RATHER THAN DEGRADING ───────────────────────────────────
850
+ *
851
+ * With no peer available this THROWS. It does not fall back to
852
+ * `Linking.openURL`, because that would quietly restore every property above
853
+ * on exactly the devices where the peer failed to link.
854
+ *
855
+ * @returns the callback URL the in-app browser intercepted, or null when the
856
+ * person dismissed it. THE DEEP LINK IS STILL BEST-EFFORT — Chrome blocks
857
+ * gesture-less custom-scheme redirects — so treat null as "unknown" and
858
+ * re-validate, never as "denied".
859
+ */
860
+ async presentConsent(session: ConsentSession | string): Promise<string | null> {
861
+ const url = typeof session === 'string' ? session : session.consent_url;
862
+ const browser = resolveInAppBrowser();
863
+ if (!browser) {
864
+ throw new ConsenteraError(
865
+ 'no in-app browser is available. Install react-native-inappbrowser-reborn ' +
866
+ '(npm i react-native-inappbrowser-reborn && cd ios && pod install), or pass an ' +
867
+ 'expo-web-browser openAuthSessionAsync adapter. This SDK will not fall back to ' +
868
+ 'Linking.openURL, which hands the hosted notice to the external browser and leaves ' +
869
+ 'the callback to any app that claims the scheme.',
870
+ );
871
+ }
872
+ const redirect = `${this.cfg.callbackScheme}://`;
873
+ const result = await browser.openAuth(url, redirect);
874
+ return result;
875
+ }
876
+
877
+ /**
878
+ * Open a non-consent hosted page (the DP portal) in the same in-app browser.
879
+ * Returns nothing to check: the portal has no consent callback.
880
+ */
881
+ private async presentHosted(url: string): Promise<void> {
882
+ const browser = resolveInAppBrowser();
883
+ if (!browser) {
884
+ throw new ConsenteraError(
885
+ 'no in-app browser is available — install react-native-inappbrowser-reborn.',
886
+ );
887
+ }
888
+ await browser.openAuth(url, `${this.cfg.callbackScheme}://`);
889
+ }
890
+
891
+ /**
892
+ * Authoritative decision check — the ONLY source of consent truth.
893
+ *
894
+ * validate({ dataPrincipalIdentifiers: { email: 'riya@example.in' } }, 'product_analytics')
895
+ * validate({ dataPrincipalId: '…' }, 'product_analytics')
896
+ *
897
+ * `data_principal_ref` is REFUSED OUTRIGHT (400, message prefixed
898
+ * `DATA_PRINCIPAL_REF_REFUSED`). Name people by the organisation's locked
899
+ * key fields — the mobile atom is `mobile` here and on create alike (F015);
900
+ * `phone` is refused by name.
901
+ */
902
+ // `async`, and that is not cosmetic. principalBody() THROWS when the caller
903
+ // names nobody, and on a non-async method returning Promise<T> that throw is
904
+ // SYNCHRONOUS: a caller written as `session.validate(...).catch(handle)`
905
+ // never reaches its handler and the app takes an uncaught exception instead.
906
+ // Marking it async turns the throw into a rejection, so both call styles —
907
+ // try/await and .catch() — behave the same. Found by the F015 test below,
908
+ // which had to use `.rejects` and got a synchronous throw.
909
+ async validate(who: PrincipalRef, purposeCode: string): Promise<ConsentDecision> {
910
+ return this.post<ConsentDecision>('/consent/validate', {
911
+ ...principalBody(who),
912
+ purpose_code: purposeCode,
913
+ });
914
+ }
915
+
916
+ /** Withdraw purposes (codes or UUIDs) for the person named by [who]. */
917
+ async withdraw(who: PrincipalRef, purposes: string[]): Promise<unknown> {
918
+ return this.post('/consent/withdraw', { ...principalBody(who), purposes });
919
+ }
920
+
921
+ /**
922
+ * Mint a fresh single-use DP-portal SSO link (never cache it).
923
+ *
924
+ * createPortalSession({ mobile: '+919876500000' })
925
+ * createPortalSession({ customer_id: 'CUST-90210' })
926
+ *
927
+ * The person is named THE SAME WAY AS EVERYWHERE ELSE in this SDK: a map
928
+ * keyed by your organisation's identifier fields (F015). The KEY is the
929
+ * identifier's kind. Before MOB-043 this took a bare string and always
930
+ * declared it `email`, whatever your organisation is keyed on, so an
931
+ * organisation keyed on mobile or customer_id sent every person's number as
932
+ * an email address.
933
+ *
934
+ * THE WIRE IS STILL THE PAIR. The portal road (rights/principal
935
+ * portal_session_handlers.go at 4fda7e3d05) takes one `data_principal_ref`
936
+ * and its `data_principal_ref_type`, and defaults an absent type to email.
937
+ * So this SDK always sends the type, and sends exactly ONE identifier: the
938
+ * map's only entry, or, when you pass several and configured
939
+ * {@link SessionConfig.identifierScheme}, the first scheme field present.
940
+ *
941
+ * `phone` is refused here, before the wire (UNKNOWN_IDENTIFIER_FIELD). The
942
+ * portal road would silently fold it to `mobile`, but this SDK's
943
+ * vocabulary has one spelling, and on every other road `phone` is refused
944
+ * by name. The accepted kinds are the platform's resolver set:
945
+ * customer_id, email, mobile, aadhaar. The platform refuses anything else
946
+ * and names that set. It auto-provisions a portal account only from an
947
+ * email; for another kind the person must already have one (404, whose
948
+ * `platformMessage` says so).
949
+ */
950
+ // `async` so that a refusal before the wire is a REJECTION, not a synchronous
951
+ // throw a `.catch()` caller never sees (the same reason validate is async).
952
+ async createPortalSession(identifiers: DataPrincipalIdentifiers): Promise<PortalSession> {
953
+ const [kind, value] = portalIdentifier(identifiers, this.cfg.identifierScheme);
954
+ return this.post<PortalSession>('/consent/portal-sessions', {
955
+ data_principal_ref: value,
956
+ data_principal_ref_type: kind,
957
+ });
958
+ }
959
+
960
+ /** Mint + open the full DP portal in one tap, in the same in-app browser. */
961
+ async openPortal(identifiers: DataPrincipalIdentifiers): Promise<void> {
962
+ const p = await this.createPortalSession(identifiers);
963
+ await this.presentHosted(p.portal_url);
964
+ }
965
+
966
+ /**
967
+ * Parse and CHECK the deep link the hosted page redirects to on submit.
968
+ *
969
+ * ─── WHAT IS CHECKED, AND WHY EACH ONE ──────────────────────────────────
970
+ *
971
+ * scheme AND host AND path, all three. This used to compare only
972
+ * `url.startsWith('myapp://')`, which accepts
973
+ * `myapp://anything/anywhere?status=granted`. A custom scheme is first-come on
974
+ * Android and undefined on iOS: another installed app can register the same
975
+ * one, and the only thing that distinguishes OUR callback from its invention
976
+ * is the whole URL plus the nonce.
977
+ *
978
+ * `state` must equal `expectedState` when one is given — the nonce
979
+ * `createSession` put on `callback_url`, echoed back because
980
+ * `addRedirectParams` (consent/collection.go) appends to an existing query
981
+ * rather than replacing it.
982
+ *
983
+ * The query is parsed with the platform URL parser, not by splitting on `=`.
984
+ * The old hand-rolled split kept only the first two parts of each pair, so any
985
+ * value containing `=` — a base64 artifact id, for instance — was silently
986
+ * truncated.
987
+ *
988
+ * ─── WHAT IS STILL NOT PROVEN ───────────────────────────────────────────
989
+ *
990
+ * That the person granted anything. `status=granted` is a query parameter, and
991
+ * this SDK cannot verify the platform's `sig` without the DF's signing secret,
992
+ * which must not be in the app. ALWAYS confirm by reading the consent back
993
+ * through your backend (`validate`) — and expect that read to wait while
994
+ * `pending` is true.
995
+ *
996
+ * `callbackStatus` is `granted | partial | denied` exactly as the platform
997
+ * issues them; anything else is `unknown`, never a grant.
998
+ *
999
+ * Pass `expectedState` as null ONLY when the app was killed during the browser
1000
+ * leg and `ConsentSession.callbackState` is genuinely gone — then re-validate
1001
+ * rather than trusting this.
1002
+ *
1003
+ * @throws ConsenteraError when the link is not ours.
1004
+ */
1005
+ parseCallback(url: string, expectedState: string | null): ConsentCallback {
1006
+ let parsed: URL;
1007
+ try {
1008
+ parsed = new URL(url);
1009
+ } catch {
1010
+ throw new ConsenteraError(`callback rejected: ${url.slice(0, 80)} did not parse as a URL`);
1011
+ }
1012
+ // URL normalises `scheme:` with the colon; compare without it.
1013
+ const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
1014
+ if (scheme !== this.cfg.callbackScheme.toLowerCase()) {
1015
+ throw new ConsenteraError(
1016
+ `callback rejected: scheme was ${scheme}, expected ${this.cfg.callbackScheme}`,
1017
+ );
1018
+ }
1019
+ const expectedHost = (this.cfg.callbackHost ?? 'consent').toLowerCase();
1020
+ if (parsed.hostname.toLowerCase() !== expectedHost) {
1021
+ throw new ConsenteraError(
1022
+ `callback rejected: host was ${parsed.hostname}, expected ${expectedHost}`,
1023
+ );
1024
+ }
1025
+ const expectedPath = this.cfg.callbackPath ?? '/callback';
1026
+ if (parsed.pathname !== expectedPath) {
1027
+ throw new ConsenteraError(
1028
+ `callback rejected: path was ${parsed.pathname}, expected ${expectedPath}`,
1029
+ );
1030
+ }
1031
+ const parameters: Record<string, string> = {};
1032
+ parsed.searchParams.forEach((v, k) => {
1033
+ parameters[k] = v;
1034
+ });
1035
+ if (expectedState !== null && parameters.state !== expectedState) {
1036
+ throw new ConsenteraError(
1037
+ 'callback rejected: state did not match the nonce this session was created with',
1038
+ );
1039
+ }
1040
+ return {
1041
+ sessionId: parameters.session_id,
1042
+ artifactId: parameters.artifact_id,
1043
+ status: parameters.status,
1044
+ callbackStatus: callbackStatusOf(parameters.status),
1045
+ pending: parameters.pending === '1',
1046
+ state: parameters.state,
1047
+ signature: parameters.sig,
1048
+ parameters,
1049
+ };
1050
+ }
1051
+ }