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