@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/LICENSE +21 -0
- package/README.md +200 -0
- package/lib/commonjs/gate.js +87 -0
- package/lib/commonjs/gate.js.map +1 -0
- package/lib/commonjs/index.js +92 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/session.js +805 -0
- package/lib/commonjs/session.js.map +1 -0
- package/lib/module/gate.js +80 -0
- package/lib/module/gate.js.map +1 -0
- package/lib/module/index.js +23 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/session.js +789 -0
- package/lib/module/session.js.map +1 -0
- package/lib/typescript/src/gate.d.ts +45 -0
- package/lib/typescript/src/gate.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +25 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/session.d.ts +600 -0
- package/lib/typescript/src/session.d.ts.map +1 -0
- package/package.json +130 -0
- package/src/gate.ts +81 -0
- package/src/index.ts +50 -0
- package/src/session.ts +1051 -0
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
|
+
}
|