@layers/amba-react-native 4.0.13 → 4.1.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/dist/index.cjs +754 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +81 -10
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +81 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +754 -62
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -94,21 +94,31 @@ var AmbaApiError = class extends Error {
|
|
|
94
94
|
//#region src/http-client.ts
|
|
95
95
|
/** Default amba API endpoint. */
|
|
96
96
|
const DEFAULT_BASE_URL = "https://api.amba.dev";
|
|
97
|
+
/** Server codes for a session token the server no longer accepts. */
|
|
98
|
+
const SESSION_REFUSED_CODES = new Set(["SESSION_REVOKED", "INVALID_SESSION_TOKEN"]);
|
|
97
99
|
var AmbaHttpClient = class {
|
|
98
100
|
apiKey;
|
|
99
101
|
baseUrl;
|
|
100
102
|
sdkPlatform;
|
|
101
103
|
sdkVersionHeader;
|
|
104
|
+
appVersion;
|
|
105
|
+
deviceRegion;
|
|
106
|
+
deviceOs;
|
|
102
107
|
identity;
|
|
103
108
|
fetchImpl;
|
|
104
109
|
ensureSession;
|
|
110
|
+
onSessionRefused;
|
|
105
111
|
constructor(opts) {
|
|
106
112
|
this.apiKey = opts.apiKey;
|
|
107
113
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
108
114
|
this.sdkPlatform = opts.sdkPlatform;
|
|
109
115
|
this.sdkVersionHeader = opts.sdkVersionHeader;
|
|
116
|
+
this.appVersion = opts.appVersion;
|
|
117
|
+
this.deviceRegion = opts.deviceRegion;
|
|
118
|
+
this.deviceOs = opts.deviceOs;
|
|
110
119
|
this.identity = opts.identity;
|
|
111
120
|
this.ensureSession = opts.ensureSession;
|
|
121
|
+
this.onSessionRefused = opts.onSessionRefused;
|
|
112
122
|
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
113
123
|
}
|
|
114
124
|
/** Build the absolute URL for an API path, optionally with a query string. */
|
|
@@ -134,6 +144,9 @@ var AmbaHttpClient = class {
|
|
|
134
144
|
Accept: "application/json",
|
|
135
145
|
...extra
|
|
136
146
|
};
|
|
147
|
+
if (this.appVersion) h["X-App-Version"] = this.appVersion;
|
|
148
|
+
if (this.deviceRegion) h["X-Device-Region"] = this.deviceRegion;
|
|
149
|
+
if (this.deviceOs) h["X-Device-OS"] = this.deviceOs;
|
|
137
150
|
const tok = this.identity.getSessionToken();
|
|
138
151
|
if (tok) h.Authorization = `Bearer ${tok}`;
|
|
139
152
|
return h;
|
|
@@ -154,11 +167,13 @@ var AmbaHttpClient = class {
|
|
|
154
167
|
async delete(path) {
|
|
155
168
|
return this.request("DELETE", path);
|
|
156
169
|
}
|
|
157
|
-
async request(method, path, body, query, sessionToken) {
|
|
170
|
+
async request(method, path, body, query, sessionToken, resent = false) {
|
|
158
171
|
if (this.ensureSession && !sessionToken && !this.identity.getSessionToken()) await this.ensureSession(path);
|
|
159
172
|
const url = this.url(path, query);
|
|
160
173
|
const headers = this.buildHeaders();
|
|
161
174
|
if (sessionToken) headers.Authorization = `Bearer ${sessionToken}`;
|
|
175
|
+
const storedToken = sessionToken ? null : this.identity.getSessionToken();
|
|
176
|
+
const storedUserId = storedToken ? this.identity.getAppUserId() : null;
|
|
162
177
|
const init = {
|
|
163
178
|
method,
|
|
164
179
|
headers,
|
|
@@ -170,7 +185,20 @@ var AmbaHttpClient = class {
|
|
|
170
185
|
} catch (err) {
|
|
171
186
|
throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
|
|
172
187
|
}
|
|
173
|
-
|
|
188
|
+
try {
|
|
189
|
+
return await parseResponse(response);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (!resent && storedToken && this.onSessionRefused && !path.startsWith("/v1/client/auth/") && err instanceof AmbaApiError && SESSION_REFUSED_CODES.has(err.code) && await this.recoverSession(storedToken, storedUserId)) return this.request(method, path, body, query, void 0, true);
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Run the `onSessionRefused` hook; a hook that throws counts as false. */
|
|
196
|
+
async recoverSession(usedToken, usedUserId) {
|
|
197
|
+
try {
|
|
198
|
+
return await this.onSessionRefused?.(usedToken, usedUserId) === true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
174
202
|
}
|
|
175
203
|
};
|
|
176
204
|
async function parseResponse(response) {
|
|
@@ -227,13 +255,31 @@ function mapHttpError(response, rawBody, isJson) {
|
|
|
227
255
|
|
|
228
256
|
//#endregion
|
|
229
257
|
//#region src/uuid.ts
|
|
258
|
+
/** Thrown when the runtime has no `crypto.getRandomValues`. */
|
|
259
|
+
var SecureRandomUnavailableError = class extends Error {
|
|
260
|
+
code = "SECURE_RANDOM_UNAVAILABLE";
|
|
261
|
+
constructor() {
|
|
262
|
+
super("Amba needs crypto.getRandomValues to generate identifiers, and this runtime does not provide it. Install react-native-get-random-values and import it on the first line of your app's entry file, before any Amba import: import 'react-native-get-random-values';");
|
|
263
|
+
this.name = "SecureRandomUnavailableError";
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
function secureGetRandomValues() {
|
|
267
|
+
const crypto = globalThis.crypto;
|
|
268
|
+
const grv = crypto?.getRandomValues;
|
|
269
|
+
if (!crypto || typeof grv !== "function") throw new SecureRandomUnavailableError();
|
|
270
|
+
return grv.bind(crypto);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Throws `SecureRandomUnavailableError` when the runtime cannot generate
|
|
274
|
+
* secure identifiers. `IdentityStore.create` calls it so a missing polyfill
|
|
275
|
+
* surfaces at `configure()`, before any identifier is needed.
|
|
276
|
+
*/
|
|
277
|
+
function assertSecureRandomAvailable() {
|
|
278
|
+
secureGetRandomValues();
|
|
279
|
+
}
|
|
230
280
|
function getRandomBytes(n) {
|
|
231
281
|
const bytes = new Uint8Array(n);
|
|
232
|
-
|
|
233
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
234
|
-
return bytes;
|
|
235
|
-
}
|
|
236
|
-
for (let i = 0; i < n; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
282
|
+
secureGetRandomValues()(bytes);
|
|
237
283
|
return bytes;
|
|
238
284
|
}
|
|
239
285
|
/**
|
|
@@ -252,6 +298,30 @@ function uuidv4() {
|
|
|
252
298
|
//#region src/identity.ts
|
|
253
299
|
/** Storage key under the configured prefix. Bumped when shape changes. */
|
|
254
300
|
const IDENTITY_KEY = "identity_v1";
|
|
301
|
+
/**
|
|
302
|
+
* Whether a vault entry belongs to this install's record. An entry another
|
|
303
|
+
* install wrote (`install_id`) never does. Each flush writes the vault and
|
|
304
|
+
* then the record, both stamped with the flush's sequence number. An entry
|
|
305
|
+
* of this install newer than the record comes from a flush that stopped
|
|
306
|
+
* between the two writes, so it holds the latest session. Otherwise the
|
|
307
|
+
* entry belongs only when the record vouches for it (`vault_seq`), which a
|
|
308
|
+
* sign-in or sign-out that couldn't write the vault withdraws. An entry
|
|
309
|
+
* written before 4.0.14 belongs while a record from then vouches for it and
|
|
310
|
+
* carries the same anonymous id and user.
|
|
311
|
+
*/
|
|
312
|
+
function entryBelongs(entry, anonymousId, appUserId, installId, recordSeq, vaultSeq) {
|
|
313
|
+
const entrySeq = typeof entry.write_seq === "number" ? entry.write_seq : 0;
|
|
314
|
+
if (entrySeq === 0 || entry.install_id == null) {
|
|
315
|
+
const sameUser = entry.app_user_id == null || appUserId === null || entry.app_user_id === appUserId;
|
|
316
|
+
return vaultSeq === 0 && entry.anonymous_id === anonymousId && sameUser;
|
|
317
|
+
}
|
|
318
|
+
if (installId === null || entry.install_id !== installId) return false;
|
|
319
|
+
if (entrySeq > recordSeq) return true;
|
|
320
|
+
return entrySeq === vaultSeq;
|
|
321
|
+
}
|
|
322
|
+
function seqOf(value) {
|
|
323
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
324
|
+
}
|
|
255
325
|
function isExpired(expiresAt) {
|
|
256
326
|
if (!expiresAt) return false;
|
|
257
327
|
const t = Date.parse(expiresAt);
|
|
@@ -269,6 +339,7 @@ var IdentityStore = class IdentityStore {
|
|
|
269
339
|
sessionToken = null;
|
|
270
340
|
refreshToken = null;
|
|
271
341
|
expiresAt = null;
|
|
342
|
+
anonymousSession = null;
|
|
272
343
|
/**
|
|
273
344
|
* Credentials an earlier SDK version left in the AsyncStorage record, with
|
|
274
345
|
* the user they belong to. They stay on disk until the vault holds the
|
|
@@ -281,16 +352,44 @@ var IdentityStore = class IdentityStore {
|
|
|
281
352
|
* succeeds, so a session this launch never loaded is not overwritten.
|
|
282
353
|
*/
|
|
283
354
|
vaultUnreadable;
|
|
284
|
-
|
|
355
|
+
/** Highest flush sequence number seen or used. */
|
|
356
|
+
seq;
|
|
357
|
+
/** `write_seq` of the stored record. */
|
|
358
|
+
recordSeq;
|
|
359
|
+
/** `vault_seq` of the stored record (see `PersistedIdentity`). */
|
|
360
|
+
vaultSeq;
|
|
361
|
+
/**
|
|
362
|
+
* The session the vouched vault entry holds, by refresh token and user. A
|
|
363
|
+
* failed or held vault write withdraws the vouch only when the session
|
|
364
|
+
* changed from it, so a link that only moves the anonymous id keeps it.
|
|
365
|
+
*/
|
|
366
|
+
vaultHolds;
|
|
367
|
+
/** Serializes flushes, so the vault and the record stay in write order. */
|
|
368
|
+
flushChain = Promise.resolve();
|
|
369
|
+
/** This install's id (see `PersistedIdentity.install_id`). */
|
|
370
|
+
installId;
|
|
371
|
+
/** Whether the stored record carries `installId`. */
|
|
372
|
+
installIdStored;
|
|
373
|
+
constructor(persistence, seed, vault, plaintext, vaultUnreadable, seq) {
|
|
285
374
|
this.persistence = persistence;
|
|
286
375
|
this.vault = vault;
|
|
287
376
|
this.plaintext = plaintext;
|
|
288
377
|
this.vaultUnreadable = vaultUnreadable;
|
|
378
|
+
this.seq = seq;
|
|
379
|
+
this.installId = seed.install_id ?? uuidv4();
|
|
380
|
+
this.installIdStored = seed.install_id !== null;
|
|
381
|
+
this.recordSeq = seed.write_seq;
|
|
382
|
+
this.vaultSeq = seed.vault_seq;
|
|
383
|
+
this.vaultHolds = {
|
|
384
|
+
refreshToken: seed.refresh_token,
|
|
385
|
+
appUserId: seed.app_user_id
|
|
386
|
+
};
|
|
289
387
|
this.anonymousId = seed.anonymous_id;
|
|
290
388
|
this.appUserId = seed.app_user_id;
|
|
291
389
|
this.sessionToken = seed.session_token;
|
|
292
390
|
this.refreshToken = seed.refresh_token;
|
|
293
391
|
this.expiresAt = seed.expires_at;
|
|
392
|
+
this.anonymousSession = seed.anonymous_session;
|
|
294
393
|
}
|
|
295
394
|
/**
|
|
296
395
|
* Build a store, hydrating any prior identity from AsyncStorage and the
|
|
@@ -302,8 +401,13 @@ var IdentityStore = class IdentityStore {
|
|
|
302
401
|
* AsyncStorage record are used for this launch and moved into the vault;
|
|
303
402
|
* the AsyncStorage copy is removed once the vault write succeeds. A vault
|
|
304
403
|
* holding its own credentials wins over them.
|
|
404
|
+
*
|
|
405
|
+
* Throws `SecureRandomUnavailableError` when the runtime has no
|
|
406
|
+
* `crypto.getRandomValues`: every anonymous id the store mints must come
|
|
407
|
+
* from a secure generator.
|
|
305
408
|
*/
|
|
306
409
|
static async create(persistence, vault = null) {
|
|
410
|
+
assertSecureRandomAvailable();
|
|
307
411
|
const raw = await persistence.read(IDENTITY_KEY);
|
|
308
412
|
let recordPresent = false;
|
|
309
413
|
let seed;
|
|
@@ -314,7 +418,11 @@ var IdentityStore = class IdentityStore {
|
|
|
314
418
|
app_user_id: parsed.app_user_id ?? null,
|
|
315
419
|
session_token: parsed.session_token ?? null,
|
|
316
420
|
refresh_token: parsed.refresh_token ?? null,
|
|
317
|
-
expires_at: parsed.expires_at ?? null
|
|
421
|
+
expires_at: parsed.expires_at ?? null,
|
|
422
|
+
anonymous_session: typeof parsed.anonymous_session === "boolean" ? parsed.anonymous_session : null,
|
|
423
|
+
write_seq: seqOf(parsed.write_seq) ?? 0,
|
|
424
|
+
vault_seq: parsed.vault_seq === void 0 ? 0 : seqOf(parsed.vault_seq),
|
|
425
|
+
install_id: typeof parsed.install_id === "string" ? parsed.install_id : null
|
|
318
426
|
};
|
|
319
427
|
recordPresent = typeof parsed.anonymous_id === "string";
|
|
320
428
|
} catch {
|
|
@@ -330,6 +438,7 @@ var IdentityStore = class IdentityStore {
|
|
|
330
438
|
}
|
|
331
439
|
} : null;
|
|
332
440
|
let vaultReadable = true;
|
|
441
|
+
let seq = seed.write_seq;
|
|
333
442
|
if (vault) {
|
|
334
443
|
let fromVault = null;
|
|
335
444
|
try {
|
|
@@ -337,20 +446,28 @@ var IdentityStore = class IdentityStore {
|
|
|
337
446
|
} catch {
|
|
338
447
|
vaultReadable = false;
|
|
339
448
|
}
|
|
340
|
-
|
|
449
|
+
if (fromVault) seq = Math.max(seq, seqOf(fromVault.write_seq) ?? 0);
|
|
450
|
+
const belongs = fromVault !== null && recordPresent && entryBelongs(fromVault, seed.anonymous_id, seed.app_user_id, seed.install_id, seed.write_seq, seed.vault_seq);
|
|
341
451
|
if (fromVault && belongs) {
|
|
342
452
|
seed.session_token = fromVault.session_token;
|
|
343
453
|
seed.refresh_token = fromVault.refresh_token;
|
|
344
454
|
seed.expires_at = fromVault.expires_at;
|
|
345
455
|
if (fromVault.app_user_id != null) seed.app_user_id = fromVault.app_user_id;
|
|
456
|
+
} else if (fromVault) {
|
|
457
|
+
seed.write_seq = Math.max(seed.write_seq, seqOf(fromVault.write_seq) ?? 0);
|
|
458
|
+
seed.vault_seq = null;
|
|
346
459
|
}
|
|
347
460
|
}
|
|
348
|
-
if (vaultReadable && seed.session_token === null && seed.refresh_token === null)
|
|
461
|
+
if (vaultReadable && seed.session_token === null && seed.refresh_token === null) {
|
|
462
|
+
if (seed.app_user_id !== null && seed.anonymous_session === false) seed.anonymous_id = uuidv4();
|
|
463
|
+
seed.app_user_id = null;
|
|
464
|
+
seed.anonymous_session = null;
|
|
465
|
+
}
|
|
349
466
|
if (isExpired(seed.expires_at)) {
|
|
350
467
|
seed.session_token = null;
|
|
351
468
|
seed.expires_at = null;
|
|
352
469
|
}
|
|
353
|
-
const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable);
|
|
470
|
+
const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable, seq);
|
|
354
471
|
await store.flush({ hydrating: true });
|
|
355
472
|
return store;
|
|
356
473
|
}
|
|
@@ -360,7 +477,43 @@ var IdentityStore = class IdentityStore {
|
|
|
360
477
|
appUserId: this.appUserId,
|
|
361
478
|
sessionToken: this.sessionToken,
|
|
362
479
|
refreshToken: this.refreshToken,
|
|
363
|
-
expiresAt: this.expiresAt
|
|
480
|
+
expiresAt: this.expiresAt,
|
|
481
|
+
anonymousSession: this.anonymousSession
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Whether the vault still can't be read. When the launch read failed, the
|
|
486
|
+
* vault is read again first, and a session it holds for this install is
|
|
487
|
+
* loaded (same rule as `create`). While it can't be read, the stored
|
|
488
|
+
* session is unknown: callers must not replace it with a new user.
|
|
489
|
+
*/
|
|
490
|
+
async vaultStillUnreadable() {
|
|
491
|
+
if (!this.vault || !this.vaultUnreadable) return false;
|
|
492
|
+
let stored;
|
|
493
|
+
try {
|
|
494
|
+
stored = await this.vault.read();
|
|
495
|
+
} catch {
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
this.vaultUnreadable = false;
|
|
499
|
+
this.noteStoredSeq(stored);
|
|
500
|
+
if (this.sessionToken === null && this.refreshToken === null) this.adoptStored(stored);
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
/** Keep the next sequence number above an entry read from the vault. */
|
|
504
|
+
noteStoredSeq(stored) {
|
|
505
|
+
if (stored) this.seq = Math.max(this.seq, seqOf(stored.write_seq) ?? 0);
|
|
506
|
+
}
|
|
507
|
+
/** Load a stored vault session that belongs to this install. */
|
|
508
|
+
adoptStored(stored) {
|
|
509
|
+
if (!stored || stored.session_token === null && stored.refresh_token === null || !entryBelongs(stored, this.anonymousId, this.appUserId, this.installIdStored ? this.installId : null, this.recordSeq, this.vaultSeq)) return;
|
|
510
|
+
this.sessionToken = isExpired(stored.expires_at) ? null : stored.session_token;
|
|
511
|
+
this.refreshToken = stored.refresh_token;
|
|
512
|
+
this.expiresAt = isExpired(stored.expires_at) ? null : stored.expires_at;
|
|
513
|
+
if (stored.app_user_id != null) this.appUserId = stored.app_user_id;
|
|
514
|
+
this.vaultHolds = {
|
|
515
|
+
refreshToken: this.refreshToken,
|
|
516
|
+
appUserId: this.appUserId
|
|
364
517
|
};
|
|
365
518
|
}
|
|
366
519
|
getAnonymousId() {
|
|
@@ -378,19 +531,53 @@ var IdentityStore = class IdentityStore {
|
|
|
378
531
|
isAuthenticated() {
|
|
379
532
|
return this.sessionToken !== null;
|
|
380
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Store a session. `anonymous` records whether it is a purely anonymous
|
|
536
|
+
* one (true only for `/v1/client/auth/anonymous`); omit it to keep the
|
|
537
|
+
* current value (a token refresh of the same session).
|
|
538
|
+
*/
|
|
381
539
|
async setSession(args) {
|
|
382
540
|
this.appUserId = args.appUserId;
|
|
383
541
|
this.sessionToken = args.sessionToken;
|
|
384
542
|
this.refreshToken = args.refreshToken;
|
|
385
543
|
this.expiresAt = args.expiresAt;
|
|
544
|
+
if (args.anonymous !== void 0) this.anonymousSession = args.anonymous;
|
|
545
|
+
await this.flush();
|
|
546
|
+
}
|
|
547
|
+
/** The current session gained an identity (a link). */
|
|
548
|
+
async markIdentified() {
|
|
549
|
+
if (this.anonymousSession === false) return;
|
|
550
|
+
this.anonymousSession = false;
|
|
386
551
|
await this.flush();
|
|
387
552
|
}
|
|
388
|
-
|
|
553
|
+
/**
|
|
554
|
+
* Adopt the anonymous id the server holds for this install's user: the id
|
|
555
|
+
* an anonymous sign-in answered with, or the fresh id a link gave the user.
|
|
556
|
+
*/
|
|
557
|
+
async setAnonymousId(anonymousId) {
|
|
558
|
+
if (anonymousId === this.anonymousId) return;
|
|
559
|
+
this.anonymousId = anonymousId;
|
|
560
|
+
await this.flush();
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* End the session. The anonymous id is rotated when `rotateAnonymousId`
|
|
564
|
+
* is set, and whenever the session being ended was not purely anonymous:
|
|
565
|
+
* that id then identifies an account with a real sign-in, and the next
|
|
566
|
+
* person on the device must start fresh. A session of unknown kind (stored
|
|
567
|
+
* by an SDK before 4.0.14) counts as identified, unless
|
|
568
|
+
* `keepIdWhenKindUnknown` is set (the server decides on the next
|
|
569
|
+
* anonymous sign-in).
|
|
570
|
+
*/
|
|
571
|
+
async clearSession(rotateAnonymousId, opts = {}) {
|
|
572
|
+
const hadSession = this.sessionToken !== null || this.refreshToken !== null;
|
|
573
|
+
const identified = this.anonymousSession === false || this.anonymousSession === null && !opts.keepIdWhenKindUnknown;
|
|
574
|
+
const nextAnonymousId = rotateAnonymousId || hadSession && identified ? uuidv4() : this.anonymousId;
|
|
389
575
|
this.appUserId = null;
|
|
390
576
|
this.sessionToken = null;
|
|
391
577
|
this.refreshToken = null;
|
|
392
578
|
this.expiresAt = null;
|
|
393
|
-
|
|
579
|
+
this.anonymousSession = null;
|
|
580
|
+
this.anonymousId = nextAnonymousId;
|
|
394
581
|
await this.flush({ signingOut: true });
|
|
395
582
|
}
|
|
396
583
|
/**
|
|
@@ -400,55 +587,107 @@ var IdentityStore = class IdentityStore {
|
|
|
400
587
|
* from `clearSession`: when the vault can't be emptied, the install moves
|
|
401
588
|
* to a new anonymous id so the entry left behind no longer belongs to it.
|
|
402
589
|
*/
|
|
403
|
-
|
|
590
|
+
flush(opts) {
|
|
591
|
+
const run = this.flushChain.then(() => this.flushNow(opts));
|
|
592
|
+
this.flushChain = run.catch(() => void 0);
|
|
593
|
+
return run;
|
|
594
|
+
}
|
|
595
|
+
async flushNow(opts) {
|
|
404
596
|
if (!this.vault) {
|
|
597
|
+
const seq$1 = ++this.seq;
|
|
598
|
+
const recordSeq$1 = opts?.hydrating ? this.recordSeq : seq$1;
|
|
405
599
|
const payload = {
|
|
406
600
|
anonymous_id: this.anonymousId,
|
|
407
601
|
app_user_id: this.appUserId,
|
|
408
602
|
session_token: this.sessionToken,
|
|
409
603
|
refresh_token: this.refreshToken,
|
|
410
|
-
expires_at: this.expiresAt
|
|
604
|
+
expires_at: this.expiresAt,
|
|
605
|
+
anonymous_session: this.anonymousSession,
|
|
606
|
+
write_seq: recordSeq$1,
|
|
607
|
+
vault_seq: null,
|
|
608
|
+
install_id: this.installId
|
|
411
609
|
};
|
|
412
610
|
await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
|
|
611
|
+
this.recordSeq = recordSeq$1;
|
|
612
|
+
this.installIdStored = true;
|
|
413
613
|
return;
|
|
414
614
|
}
|
|
415
|
-
let saved = false;
|
|
416
615
|
if (this.vaultUnreadable) try {
|
|
417
616
|
const stored = await this.vault.read();
|
|
418
617
|
this.vaultUnreadable = false;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
this.refreshToken = stored.refresh_token;
|
|
422
|
-
this.expiresAt = isExpired(stored.expires_at) ? null : stored.expires_at;
|
|
423
|
-
if (stored.app_user_id != null) this.appUserId = stored.app_user_id;
|
|
424
|
-
}
|
|
618
|
+
this.noteStoredSeq(stored);
|
|
619
|
+
if (opts?.hydrating) this.adoptStored(stored);
|
|
425
620
|
} catch {}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
621
|
+
const seq = ++this.seq;
|
|
622
|
+
if (opts?.signingOut) {
|
|
623
|
+
this.vaultSeq = null;
|
|
624
|
+
try {
|
|
625
|
+
await this.persistence.write(IDENTITY_KEY, JSON.stringify(this.recordFor(seq, false)));
|
|
626
|
+
this.recordSeq = seq;
|
|
627
|
+
this.installIdStored = true;
|
|
628
|
+
} catch {}
|
|
629
|
+
}
|
|
630
|
+
let holdVaultWrite = false;
|
|
631
|
+
if (!this.installIdStored && opts?.hydrating && !this.vaultUnreadable) try {
|
|
632
|
+
await this.persistence.write(IDENTITY_KEY, JSON.stringify(this.recordFor(this.recordSeq, false)));
|
|
633
|
+
this.installIdStored = true;
|
|
634
|
+
} catch {
|
|
635
|
+
holdVaultWrite = true;
|
|
636
|
+
}
|
|
637
|
+
const slice = {
|
|
638
|
+
session_token: this.sessionToken,
|
|
639
|
+
refresh_token: this.refreshToken,
|
|
640
|
+
expires_at: this.expiresAt,
|
|
641
|
+
anonymous_id: this.anonymousId,
|
|
642
|
+
app_user_id: this.appUserId,
|
|
643
|
+
write_seq: seq,
|
|
644
|
+
install_id: this.installId
|
|
645
|
+
};
|
|
646
|
+
let saved = false;
|
|
647
|
+
if (!this.vaultUnreadable && !holdVaultWrite) try {
|
|
648
|
+
await this.vault.write(slice);
|
|
434
649
|
saved = true;
|
|
650
|
+
this.vaultSeq = seq;
|
|
651
|
+
this.vaultHolds = {
|
|
652
|
+
refreshToken: slice.refresh_token,
|
|
653
|
+
appUserId: slice.app_user_id ?? null
|
|
654
|
+
};
|
|
435
655
|
} catch (err) {
|
|
436
656
|
if (typeof console !== "undefined") console.warn("[amba] could not save the session to the secure store", err);
|
|
437
657
|
}
|
|
658
|
+
const vaultHasLive = this.vaultHolds.refreshToken === this.refreshToken && this.vaultHolds.appUserId === this.appUserId;
|
|
659
|
+
if (opts?.signingOut && !saved || !vaultHasLive) this.vaultSeq = null;
|
|
438
660
|
if (opts?.signingOut && !saved) {
|
|
439
661
|
this.anonymousId = uuidv4();
|
|
440
662
|
if (typeof console !== "undefined") console.warn("[amba] could not clear the secure store at sign-out; starting a new anonymous id so the stored session can't be restored");
|
|
441
663
|
}
|
|
664
|
+
const recordSeq = opts?.hydrating && !saved ? this.recordSeq : seq;
|
|
665
|
+
const record = this.recordFor(recordSeq, saved && vaultHasLive);
|
|
666
|
+
await this.persistence.write(IDENTITY_KEY, JSON.stringify(record));
|
|
667
|
+
this.recordSeq = recordSeq;
|
|
668
|
+
this.installIdStored = true;
|
|
669
|
+
if (record.refresh_token === null) this.plaintext = null;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* The AsyncStorage record for a vault in use. It keeps the earlier
|
|
673
|
+
* version's plaintext copy only while that copy is still the live session
|
|
674
|
+
* (same user, same refresh token) and the vault doesn't hold it yet
|
|
675
|
+
* (`saved`). Sign-out, another user or a refresh (which retires the old
|
|
676
|
+
* refresh token) drops it.
|
|
677
|
+
*/
|
|
678
|
+
recordFor(recordSeq, saved) {
|
|
442
679
|
const keep = !saved && this.plaintext !== null && this.refreshToken !== null && this.plaintext.appUserId === this.appUserId && this.plaintext.slice.refresh_token === this.refreshToken ? this.plaintext.slice : null;
|
|
443
|
-
|
|
680
|
+
return {
|
|
444
681
|
anonymous_id: this.anonymousId,
|
|
445
682
|
app_user_id: this.appUserId,
|
|
446
683
|
session_token: keep?.session_token ?? null,
|
|
447
684
|
refresh_token: keep?.refresh_token ?? null,
|
|
448
|
-
expires_at: keep?.expires_at ?? null
|
|
685
|
+
expires_at: keep?.expires_at ?? null,
|
|
686
|
+
anonymous_session: this.anonymousSession,
|
|
687
|
+
write_seq: recordSeq,
|
|
688
|
+
vault_seq: this.vaultSeq,
|
|
689
|
+
install_id: this.installId
|
|
449
690
|
};
|
|
450
|
-
await this.persistence.write(IDENTITY_KEY, JSON.stringify(record));
|
|
451
|
-
if (!keep) this.plaintext = null;
|
|
452
691
|
}
|
|
453
692
|
};
|
|
454
693
|
function freshSeed() {
|
|
@@ -457,7 +696,11 @@ function freshSeed() {
|
|
|
457
696
|
app_user_id: null,
|
|
458
697
|
session_token: null,
|
|
459
698
|
refresh_token: null,
|
|
460
|
-
expires_at: null
|
|
699
|
+
expires_at: null,
|
|
700
|
+
anonymous_session: null,
|
|
701
|
+
write_seq: 0,
|
|
702
|
+
vault_seq: null,
|
|
703
|
+
install_id: null
|
|
461
704
|
};
|
|
462
705
|
}
|
|
463
706
|
|
|
@@ -516,7 +759,9 @@ var SecureTokenVault = class {
|
|
|
516
759
|
refresh_token: parsed.refresh_token ?? null,
|
|
517
760
|
expires_at: parsed.expires_at ?? null,
|
|
518
761
|
anonymous_id: parsed.anonymous_id ?? null,
|
|
519
|
-
app_user_id: parsed.app_user_id ?? null
|
|
762
|
+
app_user_id: parsed.app_user_id ?? null,
|
|
763
|
+
write_seq: parsed.write_seq ?? null,
|
|
764
|
+
install_id: parsed.install_id ?? null
|
|
520
765
|
};
|
|
521
766
|
} catch {
|
|
522
767
|
return null;
|
|
@@ -524,7 +769,15 @@ var SecureTokenVault = class {
|
|
|
524
769
|
}
|
|
525
770
|
async write(slice) {
|
|
526
771
|
if (isEmpty(slice)) {
|
|
527
|
-
|
|
772
|
+
try {
|
|
773
|
+
await this.storage.removeItem(this.key);
|
|
774
|
+
} catch (err) {
|
|
775
|
+
try {
|
|
776
|
+
await this.storage.setItem(this.key, JSON.stringify(slice));
|
|
777
|
+
} catch {
|
|
778
|
+
throw err;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
528
781
|
return;
|
|
529
782
|
}
|
|
530
783
|
await this.storage.setItem(this.key, JSON.stringify(slice));
|
|
@@ -870,6 +1123,25 @@ var OfflineQueue = class {
|
|
|
870
1123
|
await this.clear();
|
|
871
1124
|
}
|
|
872
1125
|
/**
|
|
1126
|
+
* Give the ops queued before any session under `anonymousId` to the
|
|
1127
|
+
* signed-in `appUserId`. Those ops replay under the session that holds
|
|
1128
|
+
* their anonymous id; the SDK calls this before that session's anonymous
|
|
1129
|
+
* id changes (a link, or adopting the account's id), so they keep an owner
|
|
1130
|
+
* that matches.
|
|
1131
|
+
*/
|
|
1132
|
+
async claimAnonymousOps(anonymousId, appUserId) {
|
|
1133
|
+
await this.mutate((ops) => ({
|
|
1134
|
+
next: ops.map((op) => op.owner && op.owner.appUserId === null && op.owner.anonymousId === anonymousId ? {
|
|
1135
|
+
...op,
|
|
1136
|
+
owner: {
|
|
1137
|
+
appUserId,
|
|
1138
|
+
anonymousId
|
|
1139
|
+
}
|
|
1140
|
+
} : op),
|
|
1141
|
+
result: void 0
|
|
1142
|
+
}));
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
873
1145
|
* Replay the current owner's ops in FIFO order. Each op is removed only
|
|
874
1146
|
* after it succeeds; the first failure stops the flush and retains the
|
|
875
1147
|
* remainder (so order is preserved and nothing is dropped mid-batch). Ops
|
|
@@ -1500,6 +1772,117 @@ const notifications = {
|
|
|
1500
1772
|
}
|
|
1501
1773
|
};
|
|
1502
1774
|
|
|
1775
|
+
//#endregion
|
|
1776
|
+
//#region src/experiment-reporter.ts
|
|
1777
|
+
/** Keys remembered before the oldest are forgotten (bounds memory). */
|
|
1778
|
+
const DEFAULT_MAX_REPORTED = 1e4;
|
|
1779
|
+
var ExperimentAssignmentReporter = class {
|
|
1780
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1781
|
+
reported = /* @__PURE__ */ new Set();
|
|
1782
|
+
constructor(maxReported = DEFAULT_MAX_REPORTED) {
|
|
1783
|
+
this.maxReported = maxReported;
|
|
1784
|
+
}
|
|
1785
|
+
/** Add a listener. Returns a function that removes it. */
|
|
1786
|
+
subscribe(listener) {
|
|
1787
|
+
if (typeof listener !== "function") throw new TypeError("onExperimentAssigned expects a function");
|
|
1788
|
+
this.listeners.add(listener);
|
|
1789
|
+
return () => {
|
|
1790
|
+
this.listeners.delete(listener);
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Call the listeners for `assignment` when this user has not reported this
|
|
1795
|
+
* experiment yet in the session. Returns `assignment` unchanged.
|
|
1796
|
+
*/
|
|
1797
|
+
report(assignment) {
|
|
1798
|
+
if (this.listeners.size === 0) return assignment;
|
|
1799
|
+
const id = `${assignment.bucketingId}\u0000${assignment.experimentKey}`;
|
|
1800
|
+
if (this.reported.has(id)) {
|
|
1801
|
+
this.reported.delete(id);
|
|
1802
|
+
this.reported.add(id);
|
|
1803
|
+
return assignment;
|
|
1804
|
+
}
|
|
1805
|
+
this.reported.add(id);
|
|
1806
|
+
if (this.reported.size > this.maxReported) {
|
|
1807
|
+
const oldest = this.reported.values().next().value;
|
|
1808
|
+
if (oldest !== void 0) this.reported.delete(oldest);
|
|
1809
|
+
}
|
|
1810
|
+
for (const listener of Array.from(this.listeners)) try {
|
|
1811
|
+
const result = listener({ ...assignment });
|
|
1812
|
+
if (result && typeof result.then === "function") result.then(void 0, logListenerError);
|
|
1813
|
+
} catch (err) {
|
|
1814
|
+
logListenerError(err);
|
|
1815
|
+
}
|
|
1816
|
+
return assignment;
|
|
1817
|
+
}
|
|
1818
|
+
/** Start a new session: every experiment reports again. */
|
|
1819
|
+
reset() {
|
|
1820
|
+
this.reported.clear();
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
function logListenerError(err) {
|
|
1824
|
+
console.warn("[amba] onExperimentAssigned listener threw", err);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
//#endregion
|
|
1828
|
+
//#region src/client-info.ts
|
|
1829
|
+
/**
|
|
1830
|
+
* Values for the `X-App-Version`, `X-Device-Region` and `X-Device-OS`
|
|
1831
|
+
* request headers. Each is omitted when unknown, so every helper here
|
|
1832
|
+
* returns `undefined` for a value the header can't carry as-is.
|
|
1833
|
+
*/
|
|
1834
|
+
/** Longest `X-App-Version` value sent (matches the core's limit). */
|
|
1835
|
+
const MAX_APP_VERSION_LENGTH = 64;
|
|
1836
|
+
/**
|
|
1837
|
+
* An app version for `X-App-Version`: the text before the first whitespace
|
|
1838
|
+
* (`"2.3.1 (45)"` sends `2.3.1`), which must start with an ASCII letter or
|
|
1839
|
+
* digit, hold only letters, digits and `.` `_` `+` `-`, and be at most
|
|
1840
|
+
* {@link MAX_APP_VERSION_LENGTH} characters: the shape the API records for
|
|
1841
|
+
* segment targeting. Anything else returns `undefined`.
|
|
1842
|
+
*/
|
|
1843
|
+
function normalizeAppVersion(raw) {
|
|
1844
|
+
if (typeof raw !== "string") return void 0;
|
|
1845
|
+
const v = raw.trim().split(/\s+/)[0] ?? "";
|
|
1846
|
+
if (v.length === 0 || v.length > MAX_APP_VERSION_LENGTH) return void 0;
|
|
1847
|
+
return /^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(v) ? v : void 0;
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* The region subtag of a BCP 47 locale tag (`en-US` → `US`, `zh-Hant-TW` →
|
|
1851
|
+
* `TW`, POSIX `fr_FR.UTF-8` → `FR`, iOS `en_US@rg=gbzzzz` → `US`),
|
|
1852
|
+
* uppercased. Returns `undefined` when the tag carries no two-letter region
|
|
1853
|
+
* (`en`, `es-419`, `sr-Latn`).
|
|
1854
|
+
*/
|
|
1855
|
+
function regionFromLocale(tag) {
|
|
1856
|
+
if (typeof tag !== "string" || tag.length === 0) return void 0;
|
|
1857
|
+
const parts = tag.split(/[.@]/)[0].split(/[-_]/);
|
|
1858
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1859
|
+
const part = parts[i];
|
|
1860
|
+
if (part.length === 1) break;
|
|
1861
|
+
if (/^[A-Za-z]{2}$/.test(part)) return part.toUpperCase();
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
const DEVICE_OS_VALUES = [
|
|
1865
|
+
"ios",
|
|
1866
|
+
"android",
|
|
1867
|
+
"web",
|
|
1868
|
+
"macos",
|
|
1869
|
+
"windows",
|
|
1870
|
+
"linux",
|
|
1871
|
+
"other"
|
|
1872
|
+
];
|
|
1873
|
+
/**
|
|
1874
|
+
* Map a platform OS name (React Native's `Platform.OS`, for example) to an
|
|
1875
|
+
* `X-Device-OS` value. Listed names pass through lowercased; any other
|
|
1876
|
+
* non-empty name is a known-but-unlisted OS and maps to `"other"`. Empty or
|
|
1877
|
+
* missing returns `undefined`.
|
|
1878
|
+
*/
|
|
1879
|
+
function deviceOsFromPlatform(os) {
|
|
1880
|
+
if (typeof os !== "string") return void 0;
|
|
1881
|
+
const v = os.trim().toLowerCase();
|
|
1882
|
+
if (v.length === 0) return void 0;
|
|
1883
|
+
return DEVICE_OS_VALUES.includes(v) ? v : "other";
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1503
1886
|
//#endregion
|
|
1504
1887
|
//#region src/realtime.ts
|
|
1505
1888
|
const INITIAL_BACKOFF_MS = 500;
|
|
@@ -1823,7 +2206,7 @@ function delay(ms) {
|
|
|
1823
2206
|
//#endregion
|
|
1824
2207
|
//#region src/index.ts
|
|
1825
2208
|
/** SDK semver — burned in at build time. Bump alongside package.json. */
|
|
1826
|
-
const SDK_VERSION = "4.0
|
|
2209
|
+
const SDK_VERSION = "4.1.0";
|
|
1827
2210
|
/**
|
|
1828
2211
|
* Default host that serves deployed functions. `Amba.functions.invoke(name)`
|
|
1829
2212
|
* targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
|
|
@@ -1860,6 +2243,13 @@ function realtimeAuthHeaders() {
|
|
|
1860
2243
|
};
|
|
1861
2244
|
}
|
|
1862
2245
|
const authSubscribers = /* @__PURE__ */ new Set();
|
|
2246
|
+
/**
|
|
2247
|
+
* `Amba.onExperimentAssigned` listeners. `experiments.getVariant` reports
|
|
2248
|
+
* each result here; the reporter calls the listeners once per experiment per
|
|
2249
|
+
* user session. A sign-out (any auth change to no session) and `configure`
|
|
2250
|
+
* start a new session.
|
|
2251
|
+
*/
|
|
2252
|
+
const experimentReporter = new ExperimentAssignmentReporter();
|
|
1863
2253
|
function snapshotSession(user) {
|
|
1864
2254
|
if (!user) return null;
|
|
1865
2255
|
return {
|
|
@@ -1870,6 +2260,7 @@ function snapshotSession(user) {
|
|
|
1870
2260
|
};
|
|
1871
2261
|
}
|
|
1872
2262
|
function notifyAuthSubscribers(session) {
|
|
2263
|
+
if (session === null) experimentReporter.reset();
|
|
1873
2264
|
for (const cb of authSubscribers) try {
|
|
1874
2265
|
cb(session);
|
|
1875
2266
|
} catch (err) {
|
|
@@ -1935,21 +2326,52 @@ function expiresAtFromResult(result) {
|
|
|
1935
2326
|
if (result.expires_at && typeof result.expires_at === "string") return result.expires_at;
|
|
1936
2327
|
return new Date(Date.now() + 3600 * 1e3).toISOString();
|
|
1937
2328
|
}
|
|
1938
|
-
async function persistAuthResult(state_, result) {
|
|
2329
|
+
async function persistAuthResult(state_, result, anonymous) {
|
|
1939
2330
|
await state_.identity.setSession({
|
|
1940
2331
|
appUserId: result.user.id,
|
|
1941
2332
|
sessionToken: result.session_token,
|
|
1942
2333
|
refreshToken: result.refresh_token,
|
|
1943
|
-
expiresAt: expiresAtFromResult(result)
|
|
2334
|
+
expiresAt: expiresAtFromResult(result),
|
|
2335
|
+
anonymous
|
|
1944
2336
|
});
|
|
1945
2337
|
}
|
|
2338
|
+
function nonEmptyString(value) {
|
|
2339
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
2340
|
+
}
|
|
2341
|
+
/**
|
|
2342
|
+
* POST to an endpoint that answers with a session pair, and store it.
|
|
2343
|
+
* An anonymous sign-in records a purely anonymous session and adopts the
|
|
2344
|
+
* anonymous id the server answered with (the server mints one when the
|
|
2345
|
+
* request carried none). A link adopts the account's rotated anonymous id.
|
|
2346
|
+
* A refresh keeps the session's kind; every other sign-in records an
|
|
2347
|
+
* identified session.
|
|
2348
|
+
*/
|
|
1946
2349
|
async function postAuth(state_, path, body) {
|
|
1947
2350
|
const result = normalizeAuthResult((await state_.http.post(path, body)).data);
|
|
1948
|
-
await persistAuthResult(state_, result);
|
|
2351
|
+
await persistAuthResult(state_, result, path === P.authAnonymous ? true : path === P.authRefresh ? void 0 : false);
|
|
2352
|
+
const adoptedAnonymousId = path === P.authAnonymous ? nonEmptyString(result.anonymous_id) ?? nonEmptyString(result.user?.anonymous_id) : path === P.authLink ? nonEmptyString(result.user?.anonymous_id) : null;
|
|
2353
|
+
if (adoptedAnonymousId) await adoptAnonymousId(state_, adoptedAnonymousId);
|
|
1949
2354
|
state_.eventQueue?.flush();
|
|
1950
2355
|
await notifyFromAuthResult(result);
|
|
1951
2356
|
return result;
|
|
1952
2357
|
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Adopt the anonymous id the server holds for the signed-in user. Offline
|
|
2360
|
+
* writes queued before any session under the old id would replay under this
|
|
2361
|
+
* session; they are handed to the signed-in user first, so the id change
|
|
2362
|
+
* leaves them replayable.
|
|
2363
|
+
*/
|
|
2364
|
+
async function adoptAnonymousId(state_, anonymousId) {
|
|
2365
|
+
const previous = state_.identity.getAnonymousId();
|
|
2366
|
+
if (anonymousId === previous) return;
|
|
2367
|
+
const appUserId = state_.identity.getAppUserId();
|
|
2368
|
+
if (appUserId && state_.offlineEnabled) try {
|
|
2369
|
+
await getOfflineQueue(state_).claimAnonymousOps(previous, appUserId);
|
|
2370
|
+
} catch (err) {
|
|
2371
|
+
if (typeof console !== "undefined") console.warn("[amba] could not reassign queued offline writes", err);
|
|
2372
|
+
}
|
|
2373
|
+
await state_.identity.setAnonymousId(anonymousId);
|
|
2374
|
+
}
|
|
1953
2375
|
/** Paths that must NOT trigger lazy anonymous sign-in. */
|
|
1954
2376
|
function pathSkipsLazySession(path) {
|
|
1955
2377
|
if (path.startsWith("/v1/client/auth/")) return true;
|
|
@@ -1961,9 +2383,14 @@ function pathSkipsLazySession(path) {
|
|
|
1961
2383
|
}
|
|
1962
2384
|
async function ensureAnonymousSession(state_) {
|
|
1963
2385
|
if (state_.identity.getSessionToken()) return;
|
|
2386
|
+
if (state_.anonSignInInflight) {
|
|
2387
|
+
await state_.anonSignInInflight.catch(() => {});
|
|
2388
|
+
if (state_.identity.getSessionToken()) return;
|
|
2389
|
+
}
|
|
1964
2390
|
if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
|
|
1965
2391
|
try {
|
|
1966
|
-
await
|
|
2392
|
+
if (await resumeWithRefreshToken(state_)) return;
|
|
2393
|
+
await signInAnonymouslyFresh(state_);
|
|
1967
2394
|
} finally {
|
|
1968
2395
|
state_.anonSignInInflight = null;
|
|
1969
2396
|
}
|
|
@@ -1971,10 +2398,218 @@ async function ensureAnonymousSession(state_) {
|
|
|
1971
2398
|
await state_.anonSignInInflight;
|
|
1972
2399
|
}
|
|
1973
2400
|
/**
|
|
1974
|
-
*
|
|
1975
|
-
*
|
|
1976
|
-
*
|
|
1977
|
-
*
|
|
2401
|
+
* Restore a stored session whose session token expired while the app was
|
|
2402
|
+
* closed, using its refresh token, so `isAuthenticated` and
|
|
2403
|
+
* `onAuthStateChange` show the signed-in user without waiting for an
|
|
2404
|
+
* authenticated call. Shares the in-flight slot with the lazy sign-in, so a
|
|
2405
|
+
* concurrent authenticated call waits for it. A rejected refresh token ends
|
|
2406
|
+
* the session (subscribers hear `null`); this never signs in anonymously.
|
|
2407
|
+
*/
|
|
2408
|
+
function restoreStoredSession(state_) {
|
|
2409
|
+
if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
|
|
2410
|
+
try {
|
|
2411
|
+
await resumeWithRefreshToken(state_);
|
|
2412
|
+
} finally {
|
|
2413
|
+
state_.anonSignInInflight = null;
|
|
2414
|
+
}
|
|
2415
|
+
})();
|
|
2416
|
+
return state_.anonSignInInflight;
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* Server answers that mean the refresh token's session is over. A 401
|
|
2420
|
+
* without the server's envelope (a proxy, a captive portal) maps to
|
|
2421
|
+
* `UNAUTHORIZED` and leaves the session in place.
|
|
2422
|
+
*/
|
|
2423
|
+
const SESSION_ENDED_CODES = new Set(["INVALID_TOKEN", "SESSION_REVOKED"]);
|
|
2424
|
+
function isSessionEnded(err) {
|
|
2425
|
+
return err instanceof AmbaApiError && SESSION_ENDED_CODES.has(err.code);
|
|
2426
|
+
}
|
|
2427
|
+
/** Record a link in flight until it settles; returns the same promise. */
|
|
2428
|
+
function trackLink(state_, link) {
|
|
2429
|
+
const links = state_.linksInflight ??= /* @__PURE__ */ new Set();
|
|
2430
|
+
links.add(link);
|
|
2431
|
+
const settle = () => {
|
|
2432
|
+
links.delete(link);
|
|
2433
|
+
};
|
|
2434
|
+
link.then(settle, settle);
|
|
2435
|
+
return link;
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* After the server refused the stored session token on an authenticated
|
|
2439
|
+
* call (`SESSION_REVOKED`, `INVALID_SESSION_TOKEN`). True when a usable
|
|
2440
|
+
* session for the user the call was sent for is stored afterwards, so the
|
|
2441
|
+
* call is sent once more as that user:
|
|
2442
|
+
* - a link in flight on this device stores the pair it returns, which is
|
|
2443
|
+
* waited for first;
|
|
2444
|
+
* - a session another flow stored after the call left is used as is;
|
|
2445
|
+
* - otherwise the refresh token continues the session. A rejected refresh
|
|
2446
|
+
* token ends it (subscribers hear `null`) and the call's own error
|
|
2447
|
+
* reaches the app.
|
|
2448
|
+
* A session of any other user (a sign-in meanwhile, or the anonymous user
|
|
2449
|
+
* a lazy sign-in starts after the session ended) answers false, and the
|
|
2450
|
+
* call fails with the server's code. Offline or a server error keeps the
|
|
2451
|
+
* session and answers false. The
|
|
2452
|
+
* refresh runs in the in-flight slot the launch restore and the lazy
|
|
2453
|
+
* sign-in share, so calls refused together end or continue the session
|
|
2454
|
+
* once.
|
|
2455
|
+
*/
|
|
2456
|
+
async function recoverRefusedSession(state_, usedToken, usedUserId) {
|
|
2457
|
+
if (usedUserId === null) return false;
|
|
2458
|
+
const usableForCaller = (token) => token !== null && token !== usedToken && state_.identity.getAppUserId() === usedUserId;
|
|
2459
|
+
if (state_.linksInflight && state_.linksInflight.size > 0) await Promise.allSettled(state_.linksInflight);
|
|
2460
|
+
const current = state_.identity.getSessionToken();
|
|
2461
|
+
if (current !== usedToken) return usableForCaller(current);
|
|
2462
|
+
if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
|
|
2463
|
+
try {
|
|
2464
|
+
await resumeWithRefreshToken(state_);
|
|
2465
|
+
} catch {} finally {
|
|
2466
|
+
state_.anonSignInInflight = null;
|
|
2467
|
+
}
|
|
2468
|
+
})();
|
|
2469
|
+
await state_.anonSignInInflight.catch(() => {});
|
|
2470
|
+
return usableForCaller(state_.identity.getSessionToken());
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* Continue the stored session with its refresh token. True when it did.
|
|
2474
|
+
* False when there is no refresh token, or the server rejected it: the
|
|
2475
|
+
* session is then cleared (the anonymous id rotates unless the session was
|
|
2476
|
+
* purely anonymous) and subscribers hear the sign-out. Any other failure
|
|
2477
|
+
* (offline, a 5xx) is thrown, so a flaky network never starts a new user.
|
|
2478
|
+
*/
|
|
2479
|
+
async function resumeWithRefreshToken(state_) {
|
|
2480
|
+
const used = state_.identity.getRefreshToken();
|
|
2481
|
+
if (!used) return false;
|
|
2482
|
+
try {
|
|
2483
|
+
await refreshSession(state_);
|
|
2484
|
+
return true;
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
if (state_.identity.getRefreshToken() !== used) return state_.identity.getSessionToken() !== null;
|
|
2487
|
+
if (!isSessionEnded(err)) throw err;
|
|
2488
|
+
await state_.identity.clearSession(false, { keepIdWhenKindUnknown: true });
|
|
2489
|
+
notifyAuthSubscribers(null);
|
|
2490
|
+
return false;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
/**
|
|
2494
|
+
* `/v1/client/auth/anonymous` with the device's anonymous id. When the
|
|
2495
|
+
* server answers `IDENTITY_SIGN_IN_REQUIRED` (the id belongs to a user who
|
|
2496
|
+
* signs in with a linked identity), the device takes a new anonymous id and
|
|
2497
|
+
* signs in once more as a new anonymous user.
|
|
2498
|
+
*/
|
|
2499
|
+
async function signInAnonymouslyFresh(state_) {
|
|
2500
|
+
try {
|
|
2501
|
+
return await postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
|
|
2502
|
+
} catch (err) {
|
|
2503
|
+
if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
|
|
2504
|
+
return recoverFromHeldAnonymousId(state_);
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
const IDENTITY_SIGN_IN_REQUIRED = "IDENTITY_SIGN_IN_REQUIRED";
|
|
2508
|
+
/**
|
|
2509
|
+
* After `/anonymous` answered `IDENTITY_SIGN_IN_REQUIRED`: continue the
|
|
2510
|
+
* stored session when this device holds its refresh token, and otherwise
|
|
2511
|
+
* take a new anonymous id and sign in as a new anonymous user. While the
|
|
2512
|
+
* secure store can't be read (an iOS device not yet unlocked since a
|
|
2513
|
+
* restart), the stored session is unknown, so the call fails with
|
|
2514
|
+
* `SECURE_STORE_UNAVAILABLE` and replaces nothing.
|
|
2515
|
+
*/
|
|
2516
|
+
async function recoverFromHeldAnonymousId(state_) {
|
|
2517
|
+
if (await state_.identity.vaultStillUnreadable()) throw new AmbaApiError("SECURE_STORE_UNAVAILABLE", "the stored session can't be read yet (the device may be locked); retry once it is unlocked");
|
|
2518
|
+
const used = state_.identity.getRefreshToken();
|
|
2519
|
+
if (used) try {
|
|
2520
|
+
return await refreshSession(state_);
|
|
2521
|
+
} catch (refreshErr) {
|
|
2522
|
+
if (!isSessionEnded(refreshErr)) throw refreshErr;
|
|
2523
|
+
const current = state_.identity.getRefreshToken();
|
|
2524
|
+
if (current !== null && current !== used) throw refreshErr;
|
|
2525
|
+
}
|
|
2526
|
+
await state_.identity.clearSession(true);
|
|
2527
|
+
return postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
|
|
2528
|
+
}
|
|
2529
|
+
/**
|
|
2530
|
+
* Rotate the session pair with the stored refresh token. The session keeps
|
|
2531
|
+
* its user and its kind (anonymous or identified), and auth-state
|
|
2532
|
+
* subscribers hear the refreshed session. The server includes the user; an
|
|
2533
|
+
* older server that leaves it out keeps the stored user id, and the result's
|
|
2534
|
+
* user then carries only that id.
|
|
2535
|
+
*/
|
|
2536
|
+
function refreshSession(state_) {
|
|
2537
|
+
if (state_.refreshInflight) return state_.refreshInflight;
|
|
2538
|
+
const inflight = refreshSessionOnce(state_);
|
|
2539
|
+
state_.refreshInflight = inflight;
|
|
2540
|
+
const release = () => {
|
|
2541
|
+
if (state_.refreshInflight === inflight) state_.refreshInflight = null;
|
|
2542
|
+
};
|
|
2543
|
+
inflight.then(release, release);
|
|
2544
|
+
return inflight;
|
|
2545
|
+
}
|
|
2546
|
+
async function refreshSessionOnce(state_) {
|
|
2547
|
+
const refreshToken = state_.identity.getRefreshToken();
|
|
2548
|
+
if (!refreshToken) throw new AmbaApiError("UNAUTHORIZED", "no refresh token available — call signIn* first");
|
|
2549
|
+
const env = await state_.http.post(P.authRefresh, { refresh_token: refreshToken });
|
|
2550
|
+
if (state_.identity.getRefreshToken() !== refreshToken) throw new AmbaApiError("SESSION_CHANGED", "the session changed while it was being refreshed");
|
|
2551
|
+
const appUserId = env.data.user?.id ?? state_.identity.getAppUserId();
|
|
2552
|
+
if (!appUserId) throw new AmbaApiError("UNAUTHORIZED", "the refreshed session names no user — call signIn* first");
|
|
2553
|
+
const user = env.data.user ? normalizeUserAuthProviders(env.data.user) : {
|
|
2554
|
+
id: appUserId,
|
|
2555
|
+
auth_providers: [],
|
|
2556
|
+
properties: {}
|
|
2557
|
+
};
|
|
2558
|
+
const result = {
|
|
2559
|
+
session_token: env.data.session_token,
|
|
2560
|
+
refresh_token: env.data.refresh_token,
|
|
2561
|
+
user,
|
|
2562
|
+
expires_at: env.data.expires_at ?? null
|
|
2563
|
+
};
|
|
2564
|
+
await persistAuthResult(state_, result, void 0);
|
|
2565
|
+
const anonymousId = nonEmptyString(user.anonymous_id);
|
|
2566
|
+
if (anonymousId && anonymousId !== state_.identity.getAnonymousId() && state_.identity.getRefreshToken() === result.refresh_token) {
|
|
2567
|
+
if (state_.identity.snapshot().anonymousSession === true) await state_.identity.markIdentified();
|
|
2568
|
+
await adoptAnonymousId(state_, anonymousId);
|
|
2569
|
+
}
|
|
2570
|
+
state_.eventQueue?.flush();
|
|
2571
|
+
await notifyFromAuthResult(result);
|
|
2572
|
+
return result;
|
|
2573
|
+
}
|
|
2574
|
+
/**
|
|
2575
|
+
* POST an OTP or email + password link. The request asks the server to
|
|
2576
|
+
* rotate the session (`rotate_session: true`): the link revokes every token
|
|
2577
|
+
* minted for this user before it, and answers with the pair that replaces
|
|
2578
|
+
* them, which is stored here. The user id does not change, so no auth-state
|
|
2579
|
+
* change fires. The session counts as identified from now on, and the
|
|
2580
|
+
* account's rotated anonymous id is adopted.
|
|
2581
|
+
*/
|
|
2582
|
+
function postLink(state_, body) {
|
|
2583
|
+
return trackLink(state_, postLinkOnce(state_, body));
|
|
2584
|
+
}
|
|
2585
|
+
async function postLinkOnce(state_, body) {
|
|
2586
|
+
const env = await state_.http.post(P.authLink, {
|
|
2587
|
+
...body,
|
|
2588
|
+
rotate_session: true
|
|
2589
|
+
});
|
|
2590
|
+
const user = normalizeUserAuthProviders(env.data.user);
|
|
2591
|
+
const sessionToken = nonEmptyString(env.data.session_token);
|
|
2592
|
+
const refreshToken = nonEmptyString(env.data.refresh_token);
|
|
2593
|
+
if (sessionToken && refreshToken) await state_.identity.setSession({
|
|
2594
|
+
appUserId: user.id,
|
|
2595
|
+
sessionToken,
|
|
2596
|
+
refreshToken,
|
|
2597
|
+
expiresAt: expiresAtFromResult({
|
|
2598
|
+
session_token: sessionToken,
|
|
2599
|
+
refresh_token: refreshToken,
|
|
2600
|
+
user,
|
|
2601
|
+
expires_at: env.data.expires_at ?? null
|
|
2602
|
+
}),
|
|
2603
|
+
anonymous: false
|
|
2604
|
+
});
|
|
2605
|
+
else await state_.identity.markIdentified();
|
|
2606
|
+
const anonymousId = nonEmptyString(user.anonymous_id);
|
|
2607
|
+
if (anonymousId) await adoptAnonymousId(state_, anonymousId);
|
|
2608
|
+
return user;
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* POST helper for endpoints that return `{ data: { user } }` and mint no
|
|
2612
|
+
* tokens. Used by `unlink*`: existing session tokens stay untouched.
|
|
1978
2613
|
*/
|
|
1979
2614
|
async function postUserNoRotate(state_, path, body) {
|
|
1980
2615
|
return normalizeUserAuthProviders((await state_.http.post(path, body)).data.user);
|
|
@@ -2042,6 +2677,7 @@ const P = {
|
|
|
2042
2677
|
aiAnthropic: "/v1/client/ai/anthropic/messages",
|
|
2043
2678
|
config: "/v1/client/config",
|
|
2044
2679
|
flags: "/v1/client/flags",
|
|
2680
|
+
experiments: "/v1/client/experiments",
|
|
2045
2681
|
diagnosticsPing: "/v1/client/diagnostics/ping",
|
|
2046
2682
|
achievements: "/v1/client/achievements",
|
|
2047
2683
|
challenges: "/v1/client/challenges",
|
|
@@ -2185,6 +2821,39 @@ async function deleteDirect(s, name, id) {
|
|
|
2185
2821
|
return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
|
|
2186
2822
|
}
|
|
2187
2823
|
/**
|
|
2824
|
+
* The device OS for `X-Device-OS` from React Native's `Platform.OS`
|
|
2825
|
+
* (`ios`, `android`, `web` for react-native-web, `macos`, `windows`).
|
|
2826
|
+
* `undefined` outside React Native (Node, tests).
|
|
2827
|
+
*/
|
|
2828
|
+
function detectDeviceOs() {
|
|
2829
|
+
try {
|
|
2830
|
+
const os = require("react-native").Platform?.OS;
|
|
2831
|
+
return deviceOsFromPlatform(typeof os === "string" ? os : void 0);
|
|
2832
|
+
} catch {
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
/**
|
|
2837
|
+
* The device locale's region for `X-Device-Region`: the iOS region setting
|
|
2838
|
+
* (`AppleLocale`) or Android's locale, falling back to the JS runtime's
|
|
2839
|
+
* default locale. `undefined` when none carries a two-letter region.
|
|
2840
|
+
*/
|
|
2841
|
+
function detectDeviceRegion() {
|
|
2842
|
+
try {
|
|
2843
|
+
const rn = require("react-native");
|
|
2844
|
+
const ios = (rn.NativeModules?.["SettingsManager"])?.settings;
|
|
2845
|
+
const android = rn.NativeModules?.["I18nManager"];
|
|
2846
|
+
const tag = ios?.AppleLocale ?? android?.localeIdentifier;
|
|
2847
|
+
const region = regionFromLocale(typeof tag === "string" ? tag : void 0);
|
|
2848
|
+
if (region) return region;
|
|
2849
|
+
} catch {}
|
|
2850
|
+
try {
|
|
2851
|
+
return regionFromLocale(Intl.DateTimeFormat().resolvedOptions().locale);
|
|
2852
|
+
} catch {
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
/**
|
|
2188
2857
|
* Top-level SDK namespace for React Native.
|
|
2189
2858
|
*/
|
|
2190
2859
|
const Amba = {
|
|
@@ -2216,17 +2885,22 @@ const Amba = {
|
|
|
2216
2885
|
baseUrl,
|
|
2217
2886
|
sdkPlatform: "react-native",
|
|
2218
2887
|
sdkVersionHeader,
|
|
2888
|
+
appVersion: normalizeAppVersion(config.appVersion),
|
|
2889
|
+
deviceRegion: detectDeviceRegion(),
|
|
2890
|
+
deviceOs: detectDeviceOs(),
|
|
2219
2891
|
identity,
|
|
2220
2892
|
ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
|
|
2221
2893
|
if (pathSkipsLazySession(path)) return;
|
|
2222
2894
|
await ensureAnonymousSession(getState());
|
|
2223
|
-
}
|
|
2895
|
+
},
|
|
2896
|
+
onSessionRefused: (usedToken, usedUserId) => recoverRefusedSession(getState(), usedToken, usedUserId)
|
|
2224
2897
|
});
|
|
2225
2898
|
const prior = state;
|
|
2226
2899
|
_offlineEnableToken += 1;
|
|
2227
2900
|
if (prior?.offlineQueue) await prior.offlineQueue.dispose();
|
|
2228
2901
|
prior?.detachEventAppState?.();
|
|
2229
2902
|
if (prior?.eventQueue) await prior.eventQueue.dispose();
|
|
2903
|
+
experimentReporter.reset();
|
|
2230
2904
|
const nextState = {
|
|
2231
2905
|
http,
|
|
2232
2906
|
identity,
|
|
@@ -2265,6 +2939,7 @@ const Amba = {
|
|
|
2265
2939
|
} catch {}
|
|
2266
2940
|
}
|
|
2267
2941
|
if (!nextState.functionsBaseUrl && !PROJECT_ID_PATTERN.test(nextState.projectId)) resolveFunctionsProjectId(nextState).catch(() => {});
|
|
2942
|
+
if (config.autoAnonymousSession !== false && !identity.getSessionToken() && identity.getRefreshToken()) restoreStoredSession(nextState).catch(() => {});
|
|
2268
2943
|
},
|
|
2269
2944
|
_reset: () => {
|
|
2270
2945
|
state?.detachEventAppState?.();
|
|
@@ -2346,7 +3021,12 @@ const Amba = {
|
|
|
2346
3021
|
auth: {
|
|
2347
3022
|
signInAnonymously: async () => {
|
|
2348
3023
|
const s = getState();
|
|
2349
|
-
|
|
3024
|
+
try {
|
|
3025
|
+
return await postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
|
|
3026
|
+
} catch (err) {
|
|
3027
|
+
if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
|
|
3028
|
+
return recoverFromHeldAnonymousId(s);
|
|
3029
|
+
}
|
|
2350
3030
|
},
|
|
2351
3031
|
signInWithEmail: async (email, password) => {
|
|
2352
3032
|
requireEmail(email);
|
|
@@ -2419,12 +3099,12 @@ const Amba = {
|
|
|
2419
3099
|
const s = getState();
|
|
2420
3100
|
const sessionToken = s.identity.getSessionToken();
|
|
2421
3101
|
if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
|
|
2422
|
-
return postAuth(s, P.authLink, {
|
|
3102
|
+
return trackLink(s, postAuth(s, P.authLink, {
|
|
2423
3103
|
provider: providerTrimmed,
|
|
2424
3104
|
token: credential,
|
|
2425
3105
|
session_token: sessionToken,
|
|
2426
3106
|
...nonceField(options)
|
|
2427
|
-
});
|
|
3107
|
+
}));
|
|
2428
3108
|
},
|
|
2429
3109
|
linkSmsOtp: async (phone, code) => {
|
|
2430
3110
|
requireE164Phone(phone);
|
|
@@ -2432,7 +3112,7 @@ const Amba = {
|
|
|
2432
3112
|
const s = getState();
|
|
2433
3113
|
const sessionToken = s.identity.getSessionToken();
|
|
2434
3114
|
if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
|
|
2435
|
-
return
|
|
3115
|
+
return postLink(s, {
|
|
2436
3116
|
provider: "sms_otp",
|
|
2437
3117
|
phone,
|
|
2438
3118
|
code,
|
|
@@ -2445,7 +3125,7 @@ const Amba = {
|
|
|
2445
3125
|
const s = getState();
|
|
2446
3126
|
const sessionToken = s.identity.getSessionToken();
|
|
2447
3127
|
if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
|
|
2448
|
-
return
|
|
3128
|
+
return postLink(s, {
|
|
2449
3129
|
provider: "email_otp",
|
|
2450
3130
|
email,
|
|
2451
3131
|
code,
|
|
@@ -2458,7 +3138,7 @@ const Amba = {
|
|
|
2458
3138
|
const s = getState();
|
|
2459
3139
|
const sessionToken = s.identity.getSessionToken();
|
|
2460
3140
|
if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
|
|
2461
|
-
return
|
|
3141
|
+
return postLink(s, {
|
|
2462
3142
|
provider: "email_password",
|
|
2463
3143
|
email,
|
|
2464
3144
|
password,
|
|
@@ -2477,7 +3157,8 @@ const Amba = {
|
|
|
2477
3157
|
signOut: async (rotateAnonymousId = false) => {
|
|
2478
3158
|
const s = getState();
|
|
2479
3159
|
if (s.identity.getSessionToken()) try {
|
|
2480
|
-
|
|
3160
|
+
const refreshToken = s.identity.getRefreshToken();
|
|
3161
|
+
await s.http.post(P.authLogout, refreshToken ? { refresh_token: refreshToken } : {});
|
|
2481
3162
|
} catch (err) {
|
|
2482
3163
|
if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
|
|
2483
3164
|
}
|
|
@@ -2490,12 +3171,7 @@ const Amba = {
|
|
|
2490
3171
|
s.eventQueue?.flush();
|
|
2491
3172
|
notifyAuthSubscribers(null);
|
|
2492
3173
|
},
|
|
2493
|
-
refresh: async () =>
|
|
2494
|
-
const s = getState();
|
|
2495
|
-
const refreshToken = s.identity.getRefreshToken();
|
|
2496
|
-
if (!refreshToken) throw new AmbaApiError("UNAUTHORIZED", "no refresh token available — call signIn* first");
|
|
2497
|
-
return postAuth(s, P.authRefresh, { refresh_token: refreshToken });
|
|
2498
|
-
},
|
|
3174
|
+
refresh: async () => refreshSession(getState()),
|
|
2499
3175
|
me: async () => {
|
|
2500
3176
|
const s = getState();
|
|
2501
3177
|
if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
|
|
@@ -2916,6 +3592,22 @@ const Amba = {
|
|
|
2916
3592
|
}
|
|
2917
3593
|
}
|
|
2918
3594
|
},
|
|
3595
|
+
experiments: {
|
|
3596
|
+
getVariant: async (key) => {
|
|
3597
|
+
const s = getState();
|
|
3598
|
+
const userBefore = s.identity.getAppUserId();
|
|
3599
|
+
const env = await s.http.get(`${P.experiments}/${encodePathSegment(key)}/assignment`);
|
|
3600
|
+
return experimentReporter.report({
|
|
3601
|
+
experimentKey: env.data.experiment_key,
|
|
3602
|
+
variant: env.data.variant,
|
|
3603
|
+
bucketingId: userBefore ?? s.identity.getAppUserId() ?? ""
|
|
3604
|
+
});
|
|
3605
|
+
},
|
|
3606
|
+
trackExposure: async (key) => {
|
|
3607
|
+
await getState().http.post(`${P.experiments}/${encodePathSegment(key)}/exposure`, {});
|
|
3608
|
+
}
|
|
3609
|
+
},
|
|
3610
|
+
onExperimentAssigned: (listener) => experimentReporter.subscribe(listener),
|
|
2919
3611
|
diagnostics: { ping: async () => {
|
|
2920
3612
|
return await getState().http.post(P.diagnosticsPing, {});
|
|
2921
3613
|
} },
|