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