@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.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
- return parseResponse(response);
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
- if (typeof globalThis.crypto?.getRandomValues === "function") {
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
- constructor(persistence, seed, vault, plaintext, vaultUnreadable) {
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
- const belongs = fromVault !== null && recordPresent && fromVault.anonymous_id === seed.anonymous_id;
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) seed.app_user_id = 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
- async clearSession(rotateAnonymousId) {
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
- if (rotateAnonymousId) this.anonymousId = uuidv4();
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
- async flush(opts) {
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
- if (opts?.hydrating && stored && (stored.session_token !== null || stored.refresh_token !== null) && stored.anonymous_id === this.anonymousId) {
420
- this.sessionToken = isExpired(stored.expires_at) ? null : stored.session_token;
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
- if (!this.vaultUnreadable) try {
427
- await this.vault.write({
428
- session_token: this.sessionToken,
429
- refresh_token: this.refreshToken,
430
- expires_at: this.expiresAt,
431
- anonymous_id: this.anonymousId,
432
- app_user_id: this.appUserId
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
- const record = {
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
- await this.storage.removeItem(this.key);
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.13";
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 postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
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
- * POST helper for endpoints that return `{ data: { user } }` WITHOUT
1975
- * minting new tokens. Used by `linkSmsOtp` / `linkEmailOtp` / `unlink*`
1976
- * — these are "decorate the current user," not "re-authenticate."
1977
- * Existing session tokens stay untouched.
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
- return postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
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 postUserNoRotate(s, P.authLink, {
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 postUserNoRotate(s, P.authLink, {
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 postUserNoRotate(s, P.authLink, {
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
- await s.http.post(P.authLogout, {});
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
  } },