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