@layers/amba-react-native 4.0.13 → 4.0.14

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,6 +94,8 @@ 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;
@@ -102,6 +104,7 @@ var AmbaHttpClient = class {
102
104
  identity;
103
105
  fetchImpl;
104
106
  ensureSession;
107
+ onSessionRefused;
105
108
  constructor(opts) {
106
109
  this.apiKey = opts.apiKey;
107
110
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
@@ -109,6 +112,7 @@ var AmbaHttpClient = class {
109
112
  this.sdkVersionHeader = opts.sdkVersionHeader;
110
113
  this.identity = opts.identity;
111
114
  this.ensureSession = opts.ensureSession;
115
+ this.onSessionRefused = opts.onSessionRefused;
112
116
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
113
117
  }
114
118
  /** Build the absolute URL for an API path, optionally with a query string. */
@@ -154,11 +158,13 @@ var AmbaHttpClient = class {
154
158
  async delete(path) {
155
159
  return this.request("DELETE", path);
156
160
  }
157
- async request(method, path, body, query, sessionToken) {
161
+ async request(method, path, body, query, sessionToken, resent = false) {
158
162
  if (this.ensureSession && !sessionToken && !this.identity.getSessionToken()) await this.ensureSession(path);
159
163
  const url = this.url(path, query);
160
164
  const headers = this.buildHeaders();
161
165
  if (sessionToken) headers.Authorization = `Bearer ${sessionToken}`;
166
+ const storedToken = sessionToken ? null : this.identity.getSessionToken();
167
+ const storedUserId = storedToken ? this.identity.getAppUserId() : null;
162
168
  const init = {
163
169
  method,
164
170
  headers,
@@ -170,7 +176,20 @@ var AmbaHttpClient = class {
170
176
  } catch (err) {
171
177
  throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
172
178
  }
173
- return parseResponse(response);
179
+ try {
180
+ return await parseResponse(response);
181
+ } catch (err) {
182
+ 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);
183
+ throw err;
184
+ }
185
+ }
186
+ /** Run the `onSessionRefused` hook; a hook that throws counts as false. */
187
+ async recoverSession(usedToken, usedUserId) {
188
+ try {
189
+ return await this.onSessionRefused?.(usedToken, usedUserId) === true;
190
+ } catch {
191
+ return false;
192
+ }
174
193
  }
175
194
  };
176
195
  async function parseResponse(response) {
@@ -227,13 +246,31 @@ function mapHttpError(response, rawBody, isJson) {
227
246
 
228
247
  //#endregion
229
248
  //#region src/uuid.ts
249
+ /** Thrown when the runtime has no `crypto.getRandomValues`. */
250
+ var SecureRandomUnavailableError = class extends Error {
251
+ code = "SECURE_RANDOM_UNAVAILABLE";
252
+ constructor() {
253
+ 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';");
254
+ this.name = "SecureRandomUnavailableError";
255
+ }
256
+ };
257
+ function secureGetRandomValues() {
258
+ const crypto = globalThis.crypto;
259
+ const grv = crypto?.getRandomValues;
260
+ if (!crypto || typeof grv !== "function") throw new SecureRandomUnavailableError();
261
+ return grv.bind(crypto);
262
+ }
263
+ /**
264
+ * Throws `SecureRandomUnavailableError` when the runtime cannot generate
265
+ * secure identifiers. `IdentityStore.create` calls it so a missing polyfill
266
+ * surfaces at `configure()`, before any identifier is needed.
267
+ */
268
+ function assertSecureRandomAvailable() {
269
+ secureGetRandomValues();
270
+ }
230
271
  function getRandomBytes(n) {
231
272
  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);
273
+ secureGetRandomValues()(bytes);
237
274
  return bytes;
238
275
  }
239
276
  /**
@@ -252,6 +289,30 @@ function uuidv4() {
252
289
  //#region src/identity.ts
253
290
  /** Storage key under the configured prefix. Bumped when shape changes. */
254
291
  const IDENTITY_KEY = "identity_v1";
292
+ /**
293
+ * Whether a vault entry belongs to this install's record. An entry another
294
+ * install wrote (`install_id`) never does. Each flush writes the vault and
295
+ * then the record, both stamped with the flush's sequence number. An entry
296
+ * of this install newer than the record comes from a flush that stopped
297
+ * between the two writes, so it holds the latest session. Otherwise the
298
+ * entry belongs only when the record vouches for it (`vault_seq`), which a
299
+ * sign-in or sign-out that couldn't write the vault withdraws. An entry
300
+ * written before 4.0.14 belongs while a record from then vouches for it and
301
+ * carries the same anonymous id and user.
302
+ */
303
+ function entryBelongs(entry, anonymousId, appUserId, installId, recordSeq, vaultSeq) {
304
+ const entrySeq = typeof entry.write_seq === "number" ? entry.write_seq : 0;
305
+ if (entrySeq === 0 || entry.install_id == null) {
306
+ const sameUser = entry.app_user_id == null || appUserId === null || entry.app_user_id === appUserId;
307
+ return vaultSeq === 0 && entry.anonymous_id === anonymousId && sameUser;
308
+ }
309
+ if (installId === null || entry.install_id !== installId) return false;
310
+ if (entrySeq > recordSeq) return true;
311
+ return entrySeq === vaultSeq;
312
+ }
313
+ function seqOf(value) {
314
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
315
+ }
255
316
  function isExpired(expiresAt) {
256
317
  if (!expiresAt) return false;
257
318
  const t = Date.parse(expiresAt);
@@ -269,6 +330,7 @@ var IdentityStore = class IdentityStore {
269
330
  sessionToken = null;
270
331
  refreshToken = null;
271
332
  expiresAt = null;
333
+ anonymousSession = null;
272
334
  /**
273
335
  * Credentials an earlier SDK version left in the AsyncStorage record, with
274
336
  * the user they belong to. They stay on disk until the vault holds the
@@ -281,16 +343,44 @@ var IdentityStore = class IdentityStore {
281
343
  * succeeds, so a session this launch never loaded is not overwritten.
282
344
  */
283
345
  vaultUnreadable;
284
- constructor(persistence, seed, vault, plaintext, vaultUnreadable) {
346
+ /** Highest flush sequence number seen or used. */
347
+ seq;
348
+ /** `write_seq` of the stored record. */
349
+ recordSeq;
350
+ /** `vault_seq` of the stored record (see `PersistedIdentity`). */
351
+ vaultSeq;
352
+ /**
353
+ * The session the vouched vault entry holds, by refresh token and user. A
354
+ * failed or held vault write withdraws the vouch only when the session
355
+ * changed from it, so a link that only moves the anonymous id keeps it.
356
+ */
357
+ vaultHolds;
358
+ /** Serializes flushes, so the vault and the record stay in write order. */
359
+ flushChain = Promise.resolve();
360
+ /** This install's id (see `PersistedIdentity.install_id`). */
361
+ installId;
362
+ /** Whether the stored record carries `installId`. */
363
+ installIdStored;
364
+ constructor(persistence, seed, vault, plaintext, vaultUnreadable, seq) {
285
365
  this.persistence = persistence;
286
366
  this.vault = vault;
287
367
  this.plaintext = plaintext;
288
368
  this.vaultUnreadable = vaultUnreadable;
369
+ this.seq = seq;
370
+ this.installId = seed.install_id ?? uuidv4();
371
+ this.installIdStored = seed.install_id !== null;
372
+ this.recordSeq = seed.write_seq;
373
+ this.vaultSeq = seed.vault_seq;
374
+ this.vaultHolds = {
375
+ refreshToken: seed.refresh_token,
376
+ appUserId: seed.app_user_id
377
+ };
289
378
  this.anonymousId = seed.anonymous_id;
290
379
  this.appUserId = seed.app_user_id;
291
380
  this.sessionToken = seed.session_token;
292
381
  this.refreshToken = seed.refresh_token;
293
382
  this.expiresAt = seed.expires_at;
383
+ this.anonymousSession = seed.anonymous_session;
294
384
  }
295
385
  /**
296
386
  * Build a store, hydrating any prior identity from AsyncStorage and the
@@ -302,8 +392,13 @@ var IdentityStore = class IdentityStore {
302
392
  * AsyncStorage record are used for this launch and moved into the vault;
303
393
  * the AsyncStorage copy is removed once the vault write succeeds. A vault
304
394
  * holding its own credentials wins over them.
395
+ *
396
+ * Throws `SecureRandomUnavailableError` when the runtime has no
397
+ * `crypto.getRandomValues`: every anonymous id the store mints must come
398
+ * from a secure generator.
305
399
  */
306
400
  static async create(persistence, vault = null) {
401
+ assertSecureRandomAvailable();
307
402
  const raw = await persistence.read(IDENTITY_KEY);
308
403
  let recordPresent = false;
309
404
  let seed;
@@ -314,7 +409,11 @@ var IdentityStore = class IdentityStore {
314
409
  app_user_id: parsed.app_user_id ?? null,
315
410
  session_token: parsed.session_token ?? null,
316
411
  refresh_token: parsed.refresh_token ?? null,
317
- expires_at: parsed.expires_at ?? null
412
+ expires_at: parsed.expires_at ?? null,
413
+ anonymous_session: typeof parsed.anonymous_session === "boolean" ? parsed.anonymous_session : null,
414
+ write_seq: seqOf(parsed.write_seq) ?? 0,
415
+ vault_seq: parsed.vault_seq === void 0 ? 0 : seqOf(parsed.vault_seq),
416
+ install_id: typeof parsed.install_id === "string" ? parsed.install_id : null
318
417
  };
319
418
  recordPresent = typeof parsed.anonymous_id === "string";
320
419
  } catch {
@@ -330,6 +429,7 @@ var IdentityStore = class IdentityStore {
330
429
  }
331
430
  } : null;
332
431
  let vaultReadable = true;
432
+ let seq = seed.write_seq;
333
433
  if (vault) {
334
434
  let fromVault = null;
335
435
  try {
@@ -337,20 +437,28 @@ var IdentityStore = class IdentityStore {
337
437
  } catch {
338
438
  vaultReadable = false;
339
439
  }
340
- const belongs = fromVault !== null && recordPresent && fromVault.anonymous_id === seed.anonymous_id;
440
+ if (fromVault) seq = Math.max(seq, seqOf(fromVault.write_seq) ?? 0);
441
+ const belongs = fromVault !== null && recordPresent && entryBelongs(fromVault, seed.anonymous_id, seed.app_user_id, seed.install_id, seed.write_seq, seed.vault_seq);
341
442
  if (fromVault && belongs) {
342
443
  seed.session_token = fromVault.session_token;
343
444
  seed.refresh_token = fromVault.refresh_token;
344
445
  seed.expires_at = fromVault.expires_at;
345
446
  if (fromVault.app_user_id != null) seed.app_user_id = fromVault.app_user_id;
447
+ } else if (fromVault) {
448
+ seed.write_seq = Math.max(seed.write_seq, seqOf(fromVault.write_seq) ?? 0);
449
+ seed.vault_seq = null;
346
450
  }
347
451
  }
348
- if (vaultReadable && seed.session_token === null && seed.refresh_token === null) seed.app_user_id = null;
452
+ if (vaultReadable && seed.session_token === null && seed.refresh_token === null) {
453
+ if (seed.app_user_id !== null && seed.anonymous_session === false) seed.anonymous_id = uuidv4();
454
+ seed.app_user_id = null;
455
+ seed.anonymous_session = null;
456
+ }
349
457
  if (isExpired(seed.expires_at)) {
350
458
  seed.session_token = null;
351
459
  seed.expires_at = null;
352
460
  }
353
- const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable);
461
+ const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable, seq);
354
462
  await store.flush({ hydrating: true });
355
463
  return store;
356
464
  }
@@ -360,7 +468,43 @@ var IdentityStore = class IdentityStore {
360
468
  appUserId: this.appUserId,
361
469
  sessionToken: this.sessionToken,
362
470
  refreshToken: this.refreshToken,
363
- expiresAt: this.expiresAt
471
+ expiresAt: this.expiresAt,
472
+ anonymousSession: this.anonymousSession
473
+ };
474
+ }
475
+ /**
476
+ * Whether the vault still can't be read. When the launch read failed, the
477
+ * vault is read again first, and a session it holds for this install is
478
+ * loaded (same rule as `create`). While it can't be read, the stored
479
+ * session is unknown: callers must not replace it with a new user.
480
+ */
481
+ async vaultStillUnreadable() {
482
+ if (!this.vault || !this.vaultUnreadable) return false;
483
+ let stored;
484
+ try {
485
+ stored = await this.vault.read();
486
+ } catch {
487
+ return true;
488
+ }
489
+ this.vaultUnreadable = false;
490
+ this.noteStoredSeq(stored);
491
+ if (this.sessionToken === null && this.refreshToken === null) this.adoptStored(stored);
492
+ return false;
493
+ }
494
+ /** Keep the next sequence number above an entry read from the vault. */
495
+ noteStoredSeq(stored) {
496
+ if (stored) this.seq = Math.max(this.seq, seqOf(stored.write_seq) ?? 0);
497
+ }
498
+ /** Load a stored vault session that belongs to this install. */
499
+ adoptStored(stored) {
500
+ 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;
501
+ this.sessionToken = isExpired(stored.expires_at) ? null : stored.session_token;
502
+ this.refreshToken = stored.refresh_token;
503
+ this.expiresAt = isExpired(stored.expires_at) ? null : stored.expires_at;
504
+ if (stored.app_user_id != null) this.appUserId = stored.app_user_id;
505
+ this.vaultHolds = {
506
+ refreshToken: this.refreshToken,
507
+ appUserId: this.appUserId
364
508
  };
365
509
  }
366
510
  getAnonymousId() {
@@ -378,19 +522,53 @@ var IdentityStore = class IdentityStore {
378
522
  isAuthenticated() {
379
523
  return this.sessionToken !== null;
380
524
  }
525
+ /**
526
+ * Store a session. `anonymous` records whether it is a purely anonymous
527
+ * one (true only for `/v1/client/auth/anonymous`); omit it to keep the
528
+ * current value (a token refresh of the same session).
529
+ */
381
530
  async setSession(args) {
382
531
  this.appUserId = args.appUserId;
383
532
  this.sessionToken = args.sessionToken;
384
533
  this.refreshToken = args.refreshToken;
385
534
  this.expiresAt = args.expiresAt;
535
+ if (args.anonymous !== void 0) this.anonymousSession = args.anonymous;
536
+ await this.flush();
537
+ }
538
+ /** The current session gained an identity (a link). */
539
+ async markIdentified() {
540
+ if (this.anonymousSession === false) return;
541
+ this.anonymousSession = false;
542
+ await this.flush();
543
+ }
544
+ /**
545
+ * Adopt the anonymous id the server holds for this install's user: the id
546
+ * an anonymous sign-in answered with, or the fresh id a link gave the user.
547
+ */
548
+ async setAnonymousId(anonymousId) {
549
+ if (anonymousId === this.anonymousId) return;
550
+ this.anonymousId = anonymousId;
386
551
  await this.flush();
387
552
  }
388
- async clearSession(rotateAnonymousId) {
553
+ /**
554
+ * End the session. The anonymous id is rotated when `rotateAnonymousId`
555
+ * is set, and whenever the session being ended was not purely anonymous:
556
+ * that id then identifies an account with a real sign-in, and the next
557
+ * person on the device must start fresh. A session of unknown kind (stored
558
+ * by an SDK before 4.0.14) counts as identified, unless
559
+ * `keepIdWhenKindUnknown` is set (the server decides on the next
560
+ * anonymous sign-in).
561
+ */
562
+ async clearSession(rotateAnonymousId, opts = {}) {
563
+ const hadSession = this.sessionToken !== null || this.refreshToken !== null;
564
+ const identified = this.anonymousSession === false || this.anonymousSession === null && !opts.keepIdWhenKindUnknown;
565
+ const nextAnonymousId = rotateAnonymousId || hadSession && identified ? uuidv4() : this.anonymousId;
389
566
  this.appUserId = null;
390
567
  this.sessionToken = null;
391
568
  this.refreshToken = null;
392
569
  this.expiresAt = null;
393
- if (rotateAnonymousId) this.anonymousId = uuidv4();
570
+ this.anonymousSession = null;
571
+ this.anonymousId = nextAnonymousId;
394
572
  await this.flush({ signingOut: true });
395
573
  }
396
574
  /**
@@ -400,55 +578,107 @@ var IdentityStore = class IdentityStore {
400
578
  * from `clearSession`: when the vault can't be emptied, the install moves
401
579
  * to a new anonymous id so the entry left behind no longer belongs to it.
402
580
  */
403
- async flush(opts) {
581
+ flush(opts) {
582
+ const run = this.flushChain.then(() => this.flushNow(opts));
583
+ this.flushChain = run.catch(() => void 0);
584
+ return run;
585
+ }
586
+ async flushNow(opts) {
404
587
  if (!this.vault) {
588
+ const seq$1 = ++this.seq;
589
+ const recordSeq$1 = opts?.hydrating ? this.recordSeq : seq$1;
405
590
  const payload = {
406
591
  anonymous_id: this.anonymousId,
407
592
  app_user_id: this.appUserId,
408
593
  session_token: this.sessionToken,
409
594
  refresh_token: this.refreshToken,
410
- expires_at: this.expiresAt
595
+ expires_at: this.expiresAt,
596
+ anonymous_session: this.anonymousSession,
597
+ write_seq: recordSeq$1,
598
+ vault_seq: null,
599
+ install_id: this.installId
411
600
  };
412
601
  await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
602
+ this.recordSeq = recordSeq$1;
603
+ this.installIdStored = true;
413
604
  return;
414
605
  }
415
- let saved = false;
416
606
  if (this.vaultUnreadable) try {
417
607
  const stored = await this.vault.read();
418
608
  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
- }
609
+ this.noteStoredSeq(stored);
610
+ if (opts?.hydrating) this.adoptStored(stored);
425
611
  } 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
- });
612
+ const seq = ++this.seq;
613
+ if (opts?.signingOut) {
614
+ this.vaultSeq = null;
615
+ try {
616
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(this.recordFor(seq, false)));
617
+ this.recordSeq = seq;
618
+ this.installIdStored = true;
619
+ } catch {}
620
+ }
621
+ let holdVaultWrite = false;
622
+ if (!this.installIdStored && opts?.hydrating && !this.vaultUnreadable) try {
623
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(this.recordFor(this.recordSeq, false)));
624
+ this.installIdStored = true;
625
+ } catch {
626
+ holdVaultWrite = true;
627
+ }
628
+ const slice = {
629
+ session_token: this.sessionToken,
630
+ refresh_token: this.refreshToken,
631
+ expires_at: this.expiresAt,
632
+ anonymous_id: this.anonymousId,
633
+ app_user_id: this.appUserId,
634
+ write_seq: seq,
635
+ install_id: this.installId
636
+ };
637
+ let saved = false;
638
+ if (!this.vaultUnreadable && !holdVaultWrite) try {
639
+ await this.vault.write(slice);
434
640
  saved = true;
641
+ this.vaultSeq = seq;
642
+ this.vaultHolds = {
643
+ refreshToken: slice.refresh_token,
644
+ appUserId: slice.app_user_id ?? null
645
+ };
435
646
  } catch (err) {
436
647
  if (typeof console !== "undefined") console.warn("[amba] could not save the session to the secure store", err);
437
648
  }
649
+ const vaultHasLive = this.vaultHolds.refreshToken === this.refreshToken && this.vaultHolds.appUserId === this.appUserId;
650
+ if (opts?.signingOut && !saved || !vaultHasLive) this.vaultSeq = null;
438
651
  if (opts?.signingOut && !saved) {
439
652
  this.anonymousId = uuidv4();
440
653
  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
654
  }
655
+ const recordSeq = opts?.hydrating && !saved ? this.recordSeq : seq;
656
+ const record = this.recordFor(recordSeq, saved && vaultHasLive);
657
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(record));
658
+ this.recordSeq = recordSeq;
659
+ this.installIdStored = true;
660
+ if (record.refresh_token === null) this.plaintext = null;
661
+ }
662
+ /**
663
+ * The AsyncStorage record for a vault in use. It keeps the earlier
664
+ * version's plaintext copy only while that copy is still the live session
665
+ * (same user, same refresh token) and the vault doesn't hold it yet
666
+ * (`saved`). Sign-out, another user or a refresh (which retires the old
667
+ * refresh token) drops it.
668
+ */
669
+ recordFor(recordSeq, saved) {
442
670
  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 = {
671
+ return {
444
672
  anonymous_id: this.anonymousId,
445
673
  app_user_id: this.appUserId,
446
674
  session_token: keep?.session_token ?? null,
447
675
  refresh_token: keep?.refresh_token ?? null,
448
- expires_at: keep?.expires_at ?? null
676
+ expires_at: keep?.expires_at ?? null,
677
+ anonymous_session: this.anonymousSession,
678
+ write_seq: recordSeq,
679
+ vault_seq: this.vaultSeq,
680
+ install_id: this.installId
449
681
  };
450
- await this.persistence.write(IDENTITY_KEY, JSON.stringify(record));
451
- if (!keep) this.plaintext = null;
452
682
  }
453
683
  };
454
684
  function freshSeed() {
@@ -457,7 +687,11 @@ function freshSeed() {
457
687
  app_user_id: null,
458
688
  session_token: null,
459
689
  refresh_token: null,
460
- expires_at: null
690
+ expires_at: null,
691
+ anonymous_session: null,
692
+ write_seq: 0,
693
+ vault_seq: null,
694
+ install_id: null
461
695
  };
462
696
  }
463
697
 
@@ -516,7 +750,9 @@ var SecureTokenVault = class {
516
750
  refresh_token: parsed.refresh_token ?? null,
517
751
  expires_at: parsed.expires_at ?? null,
518
752
  anonymous_id: parsed.anonymous_id ?? null,
519
- app_user_id: parsed.app_user_id ?? null
753
+ app_user_id: parsed.app_user_id ?? null,
754
+ write_seq: parsed.write_seq ?? null,
755
+ install_id: parsed.install_id ?? null
520
756
  };
521
757
  } catch {
522
758
  return null;
@@ -524,7 +760,15 @@ var SecureTokenVault = class {
524
760
  }
525
761
  async write(slice) {
526
762
  if (isEmpty(slice)) {
527
- await this.storage.removeItem(this.key);
763
+ try {
764
+ await this.storage.removeItem(this.key);
765
+ } catch (err) {
766
+ try {
767
+ await this.storage.setItem(this.key, JSON.stringify(slice));
768
+ } catch {
769
+ throw err;
770
+ }
771
+ }
528
772
  return;
529
773
  }
530
774
  await this.storage.setItem(this.key, JSON.stringify(slice));
@@ -870,6 +1114,25 @@ var OfflineQueue = class {
870
1114
  await this.clear();
871
1115
  }
872
1116
  /**
1117
+ * Give the ops queued before any session under `anonymousId` to the
1118
+ * signed-in `appUserId`. Those ops replay under the session that holds
1119
+ * their anonymous id; the SDK calls this before that session's anonymous
1120
+ * id changes (a link, or adopting the account's id), so they keep an owner
1121
+ * that matches.
1122
+ */
1123
+ async claimAnonymousOps(anonymousId, appUserId) {
1124
+ await this.mutate((ops) => ({
1125
+ next: ops.map((op) => op.owner && op.owner.appUserId === null && op.owner.anonymousId === anonymousId ? {
1126
+ ...op,
1127
+ owner: {
1128
+ appUserId,
1129
+ anonymousId
1130
+ }
1131
+ } : op),
1132
+ result: void 0
1133
+ }));
1134
+ }
1135
+ /**
873
1136
  * Replay the current owner's ops in FIFO order. Each op is removed only
874
1137
  * after it succeeds; the first failure stops the flush and retains the
875
1138
  * remainder (so order is preserved and nothing is dropped mid-batch). Ops
@@ -1823,7 +2086,7 @@ function delay(ms) {
1823
2086
  //#endregion
1824
2087
  //#region src/index.ts
1825
2088
  /** SDK semver — burned in at build time. Bump alongside package.json. */
1826
- const SDK_VERSION = "4.0.13";
2089
+ const SDK_VERSION = "4.0.14";
1827
2090
  /**
1828
2091
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
1829
2092
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -1935,21 +2198,52 @@ function expiresAtFromResult(result) {
1935
2198
  if (result.expires_at && typeof result.expires_at === "string") return result.expires_at;
1936
2199
  return new Date(Date.now() + 3600 * 1e3).toISOString();
1937
2200
  }
1938
- async function persistAuthResult(state_, result) {
2201
+ async function persistAuthResult(state_, result, anonymous) {
1939
2202
  await state_.identity.setSession({
1940
2203
  appUserId: result.user.id,
1941
2204
  sessionToken: result.session_token,
1942
2205
  refreshToken: result.refresh_token,
1943
- expiresAt: expiresAtFromResult(result)
2206
+ expiresAt: expiresAtFromResult(result),
2207
+ anonymous
1944
2208
  });
1945
2209
  }
2210
+ function nonEmptyString(value) {
2211
+ return typeof value === "string" && value.length > 0 ? value : null;
2212
+ }
2213
+ /**
2214
+ * POST to an endpoint that answers with a session pair, and store it.
2215
+ * An anonymous sign-in records a purely anonymous session and adopts the
2216
+ * anonymous id the server answered with (the server mints one when the
2217
+ * request carried none). A link adopts the account's rotated anonymous id.
2218
+ * A refresh keeps the session's kind; every other sign-in records an
2219
+ * identified session.
2220
+ */
1946
2221
  async function postAuth(state_, path, body) {
1947
2222
  const result = normalizeAuthResult((await state_.http.post(path, body)).data);
1948
- await persistAuthResult(state_, result);
2223
+ await persistAuthResult(state_, result, path === P.authAnonymous ? true : path === P.authRefresh ? void 0 : false);
2224
+ const adoptedAnonymousId = path === P.authAnonymous ? nonEmptyString(result.anonymous_id) ?? nonEmptyString(result.user?.anonymous_id) : path === P.authLink ? nonEmptyString(result.user?.anonymous_id) : null;
2225
+ if (adoptedAnonymousId) await adoptAnonymousId(state_, adoptedAnonymousId);
1949
2226
  state_.eventQueue?.flush();
1950
2227
  await notifyFromAuthResult(result);
1951
2228
  return result;
1952
2229
  }
2230
+ /**
2231
+ * Adopt the anonymous id the server holds for the signed-in user. Offline
2232
+ * writes queued before any session under the old id would replay under this
2233
+ * session; they are handed to the signed-in user first, so the id change
2234
+ * leaves them replayable.
2235
+ */
2236
+ async function adoptAnonymousId(state_, anonymousId) {
2237
+ const previous = state_.identity.getAnonymousId();
2238
+ if (anonymousId === previous) return;
2239
+ const appUserId = state_.identity.getAppUserId();
2240
+ if (appUserId && state_.offlineEnabled) try {
2241
+ await getOfflineQueue(state_).claimAnonymousOps(previous, appUserId);
2242
+ } catch (err) {
2243
+ if (typeof console !== "undefined") console.warn("[amba] could not reassign queued offline writes", err);
2244
+ }
2245
+ await state_.identity.setAnonymousId(anonymousId);
2246
+ }
1953
2247
  /** Paths that must NOT trigger lazy anonymous sign-in. */
1954
2248
  function pathSkipsLazySession(path) {
1955
2249
  if (path.startsWith("/v1/client/auth/")) return true;
@@ -1961,9 +2255,14 @@ function pathSkipsLazySession(path) {
1961
2255
  }
1962
2256
  async function ensureAnonymousSession(state_) {
1963
2257
  if (state_.identity.getSessionToken()) return;
2258
+ if (state_.anonSignInInflight) {
2259
+ await state_.anonSignInInflight.catch(() => {});
2260
+ if (state_.identity.getSessionToken()) return;
2261
+ }
1964
2262
  if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
1965
2263
  try {
1966
- await postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
2264
+ if (await resumeWithRefreshToken(state_)) return;
2265
+ await signInAnonymouslyFresh(state_);
1967
2266
  } finally {
1968
2267
  state_.anonSignInInflight = null;
1969
2268
  }
@@ -1971,10 +2270,218 @@ async function ensureAnonymousSession(state_) {
1971
2270
  await state_.anonSignInInflight;
1972
2271
  }
1973
2272
  /**
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.
2273
+ * Restore a stored session whose session token expired while the app was
2274
+ * closed, using its refresh token, so `isAuthenticated` and
2275
+ * `onAuthStateChange` show the signed-in user without waiting for an
2276
+ * authenticated call. Shares the in-flight slot with the lazy sign-in, so a
2277
+ * concurrent authenticated call waits for it. A rejected refresh token ends
2278
+ * the session (subscribers hear `null`); this never signs in anonymously.
2279
+ */
2280
+ function restoreStoredSession(state_) {
2281
+ if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
2282
+ try {
2283
+ await resumeWithRefreshToken(state_);
2284
+ } finally {
2285
+ state_.anonSignInInflight = null;
2286
+ }
2287
+ })();
2288
+ return state_.anonSignInInflight;
2289
+ }
2290
+ /**
2291
+ * Server answers that mean the refresh token's session is over. A 401
2292
+ * without the server's envelope (a proxy, a captive portal) maps to
2293
+ * `UNAUTHORIZED` and leaves the session in place.
2294
+ */
2295
+ const SESSION_ENDED_CODES = new Set(["INVALID_TOKEN", "SESSION_REVOKED"]);
2296
+ function isSessionEnded(err) {
2297
+ return err instanceof AmbaApiError && SESSION_ENDED_CODES.has(err.code);
2298
+ }
2299
+ /** Record a link in flight until it settles; returns the same promise. */
2300
+ function trackLink(state_, link) {
2301
+ const links = state_.linksInflight ??= /* @__PURE__ */ new Set();
2302
+ links.add(link);
2303
+ const settle = () => {
2304
+ links.delete(link);
2305
+ };
2306
+ link.then(settle, settle);
2307
+ return link;
2308
+ }
2309
+ /**
2310
+ * After the server refused the stored session token on an authenticated
2311
+ * call (`SESSION_REVOKED`, `INVALID_SESSION_TOKEN`). True when a usable
2312
+ * session for the user the call was sent for is stored afterwards, so the
2313
+ * call is sent once more as that user:
2314
+ * - a link in flight on this device stores the pair it returns, which is
2315
+ * waited for first;
2316
+ * - a session another flow stored after the call left is used as is;
2317
+ * - otherwise the refresh token continues the session. A rejected refresh
2318
+ * token ends it (subscribers hear `null`) and the call's own error
2319
+ * reaches the app.
2320
+ * A session of any other user (a sign-in meanwhile, or the anonymous user
2321
+ * a lazy sign-in starts after the session ended) answers false, and the
2322
+ * call fails with the server's code. Offline or a server error keeps the
2323
+ * session and answers false. The
2324
+ * refresh runs in the in-flight slot the launch restore and the lazy
2325
+ * sign-in share, so calls refused together end or continue the session
2326
+ * once.
2327
+ */
2328
+ async function recoverRefusedSession(state_, usedToken, usedUserId) {
2329
+ if (usedUserId === null) return false;
2330
+ const usableForCaller = (token) => token !== null && token !== usedToken && state_.identity.getAppUserId() === usedUserId;
2331
+ if (state_.linksInflight && state_.linksInflight.size > 0) await Promise.allSettled(state_.linksInflight);
2332
+ const current = state_.identity.getSessionToken();
2333
+ if (current !== usedToken) return usableForCaller(current);
2334
+ if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
2335
+ try {
2336
+ await resumeWithRefreshToken(state_);
2337
+ } catch {} finally {
2338
+ state_.anonSignInInflight = null;
2339
+ }
2340
+ })();
2341
+ await state_.anonSignInInflight.catch(() => {});
2342
+ return usableForCaller(state_.identity.getSessionToken());
2343
+ }
2344
+ /**
2345
+ * Continue the stored session with its refresh token. True when it did.
2346
+ * False when there is no refresh token, or the server rejected it: the
2347
+ * session is then cleared (the anonymous id rotates unless the session was
2348
+ * purely anonymous) and subscribers hear the sign-out. Any other failure
2349
+ * (offline, a 5xx) is thrown, so a flaky network never starts a new user.
2350
+ */
2351
+ async function resumeWithRefreshToken(state_) {
2352
+ const used = state_.identity.getRefreshToken();
2353
+ if (!used) return false;
2354
+ try {
2355
+ await refreshSession(state_);
2356
+ return true;
2357
+ } catch (err) {
2358
+ if (state_.identity.getRefreshToken() !== used) return state_.identity.getSessionToken() !== null;
2359
+ if (!isSessionEnded(err)) throw err;
2360
+ await state_.identity.clearSession(false, { keepIdWhenKindUnknown: true });
2361
+ notifyAuthSubscribers(null);
2362
+ return false;
2363
+ }
2364
+ }
2365
+ /**
2366
+ * `/v1/client/auth/anonymous` with the device's anonymous id. When the
2367
+ * server answers `IDENTITY_SIGN_IN_REQUIRED` (the id belongs to a user who
2368
+ * signs in with a linked identity), the device takes a new anonymous id and
2369
+ * signs in once more as a new anonymous user.
2370
+ */
2371
+ async function signInAnonymouslyFresh(state_) {
2372
+ try {
2373
+ return await postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
2374
+ } catch (err) {
2375
+ if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
2376
+ return recoverFromHeldAnonymousId(state_);
2377
+ }
2378
+ }
2379
+ const IDENTITY_SIGN_IN_REQUIRED = "IDENTITY_SIGN_IN_REQUIRED";
2380
+ /**
2381
+ * After `/anonymous` answered `IDENTITY_SIGN_IN_REQUIRED`: continue the
2382
+ * stored session when this device holds its refresh token, and otherwise
2383
+ * take a new anonymous id and sign in as a new anonymous user. While the
2384
+ * secure store can't be read (an iOS device not yet unlocked since a
2385
+ * restart), the stored session is unknown, so the call fails with
2386
+ * `SECURE_STORE_UNAVAILABLE` and replaces nothing.
2387
+ */
2388
+ async function recoverFromHeldAnonymousId(state_) {
2389
+ 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");
2390
+ const used = state_.identity.getRefreshToken();
2391
+ if (used) try {
2392
+ return await refreshSession(state_);
2393
+ } catch (refreshErr) {
2394
+ if (!isSessionEnded(refreshErr)) throw refreshErr;
2395
+ const current = state_.identity.getRefreshToken();
2396
+ if (current !== null && current !== used) throw refreshErr;
2397
+ }
2398
+ await state_.identity.clearSession(true);
2399
+ return postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
2400
+ }
2401
+ /**
2402
+ * Rotate the session pair with the stored refresh token. The session keeps
2403
+ * its user and its kind (anonymous or identified), and auth-state
2404
+ * subscribers hear the refreshed session. The server includes the user; an
2405
+ * older server that leaves it out keeps the stored user id, and the result's
2406
+ * user then carries only that id.
2407
+ */
2408
+ function refreshSession(state_) {
2409
+ if (state_.refreshInflight) return state_.refreshInflight;
2410
+ const inflight = refreshSessionOnce(state_);
2411
+ state_.refreshInflight = inflight;
2412
+ const release = () => {
2413
+ if (state_.refreshInflight === inflight) state_.refreshInflight = null;
2414
+ };
2415
+ inflight.then(release, release);
2416
+ return inflight;
2417
+ }
2418
+ async function refreshSessionOnce(state_) {
2419
+ const refreshToken = state_.identity.getRefreshToken();
2420
+ if (!refreshToken) throw new AmbaApiError("UNAUTHORIZED", "no refresh token available — call signIn* first");
2421
+ const env = await state_.http.post(P.authRefresh, { refresh_token: refreshToken });
2422
+ if (state_.identity.getRefreshToken() !== refreshToken) throw new AmbaApiError("SESSION_CHANGED", "the session changed while it was being refreshed");
2423
+ const appUserId = env.data.user?.id ?? state_.identity.getAppUserId();
2424
+ if (!appUserId) throw new AmbaApiError("UNAUTHORIZED", "the refreshed session names no user — call signIn* first");
2425
+ const user = env.data.user ? normalizeUserAuthProviders(env.data.user) : {
2426
+ id: appUserId,
2427
+ auth_providers: [],
2428
+ properties: {}
2429
+ };
2430
+ const result = {
2431
+ session_token: env.data.session_token,
2432
+ refresh_token: env.data.refresh_token,
2433
+ user,
2434
+ expires_at: env.data.expires_at ?? null
2435
+ };
2436
+ await persistAuthResult(state_, result, void 0);
2437
+ const anonymousId = nonEmptyString(user.anonymous_id);
2438
+ if (anonymousId && anonymousId !== state_.identity.getAnonymousId() && state_.identity.getRefreshToken() === result.refresh_token) {
2439
+ if (state_.identity.snapshot().anonymousSession === true) await state_.identity.markIdentified();
2440
+ await adoptAnonymousId(state_, anonymousId);
2441
+ }
2442
+ state_.eventQueue?.flush();
2443
+ await notifyFromAuthResult(result);
2444
+ return result;
2445
+ }
2446
+ /**
2447
+ * POST an OTP or email + password link. The request asks the server to
2448
+ * rotate the session (`rotate_session: true`): the link revokes every token
2449
+ * minted for this user before it, and answers with the pair that replaces
2450
+ * them, which is stored here. The user id does not change, so no auth-state
2451
+ * change fires. The session counts as identified from now on, and the
2452
+ * account's rotated anonymous id is adopted.
2453
+ */
2454
+ function postLink(state_, body) {
2455
+ return trackLink(state_, postLinkOnce(state_, body));
2456
+ }
2457
+ async function postLinkOnce(state_, body) {
2458
+ const env = await state_.http.post(P.authLink, {
2459
+ ...body,
2460
+ rotate_session: true
2461
+ });
2462
+ const user = normalizeUserAuthProviders(env.data.user);
2463
+ const sessionToken = nonEmptyString(env.data.session_token);
2464
+ const refreshToken = nonEmptyString(env.data.refresh_token);
2465
+ if (sessionToken && refreshToken) await state_.identity.setSession({
2466
+ appUserId: user.id,
2467
+ sessionToken,
2468
+ refreshToken,
2469
+ expiresAt: expiresAtFromResult({
2470
+ session_token: sessionToken,
2471
+ refresh_token: refreshToken,
2472
+ user,
2473
+ expires_at: env.data.expires_at ?? null
2474
+ }),
2475
+ anonymous: false
2476
+ });
2477
+ else await state_.identity.markIdentified();
2478
+ const anonymousId = nonEmptyString(user.anonymous_id);
2479
+ if (anonymousId) await adoptAnonymousId(state_, anonymousId);
2480
+ return user;
2481
+ }
2482
+ /**
2483
+ * POST helper for endpoints that return `{ data: { user } }` and mint no
2484
+ * tokens. Used by `unlink*`: existing session tokens stay untouched.
1978
2485
  */
1979
2486
  async function postUserNoRotate(state_, path, body) {
1980
2487
  return normalizeUserAuthProviders((await state_.http.post(path, body)).data.user);
@@ -2220,7 +2727,8 @@ const Amba = {
2220
2727
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
2221
2728
  if (pathSkipsLazySession(path)) return;
2222
2729
  await ensureAnonymousSession(getState());
2223
- }
2730
+ },
2731
+ onSessionRefused: (usedToken, usedUserId) => recoverRefusedSession(getState(), usedToken, usedUserId)
2224
2732
  });
2225
2733
  const prior = state;
2226
2734
  _offlineEnableToken += 1;
@@ -2265,6 +2773,7 @@ const Amba = {
2265
2773
  } catch {}
2266
2774
  }
2267
2775
  if (!nextState.functionsBaseUrl && !PROJECT_ID_PATTERN.test(nextState.projectId)) resolveFunctionsProjectId(nextState).catch(() => {});
2776
+ if (config.autoAnonymousSession !== false && !identity.getSessionToken() && identity.getRefreshToken()) restoreStoredSession(nextState).catch(() => {});
2268
2777
  },
2269
2778
  _reset: () => {
2270
2779
  state?.detachEventAppState?.();
@@ -2346,7 +2855,12 @@ const Amba = {
2346
2855
  auth: {
2347
2856
  signInAnonymously: async () => {
2348
2857
  const s = getState();
2349
- return postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
2858
+ try {
2859
+ return await postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
2860
+ } catch (err) {
2861
+ if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
2862
+ return recoverFromHeldAnonymousId(s);
2863
+ }
2350
2864
  },
2351
2865
  signInWithEmail: async (email, password) => {
2352
2866
  requireEmail(email);
@@ -2419,12 +2933,12 @@ const Amba = {
2419
2933
  const s = getState();
2420
2934
  const sessionToken = s.identity.getSessionToken();
2421
2935
  if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2422
- return postAuth(s, P.authLink, {
2936
+ return trackLink(s, postAuth(s, P.authLink, {
2423
2937
  provider: providerTrimmed,
2424
2938
  token: credential,
2425
2939
  session_token: sessionToken,
2426
2940
  ...nonceField(options)
2427
- });
2941
+ }));
2428
2942
  },
2429
2943
  linkSmsOtp: async (phone, code) => {
2430
2944
  requireE164Phone(phone);
@@ -2432,7 +2946,7 @@ const Amba = {
2432
2946
  const s = getState();
2433
2947
  const sessionToken = s.identity.getSessionToken();
2434
2948
  if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2435
- return postUserNoRotate(s, P.authLink, {
2949
+ return postLink(s, {
2436
2950
  provider: "sms_otp",
2437
2951
  phone,
2438
2952
  code,
@@ -2445,7 +2959,7 @@ const Amba = {
2445
2959
  const s = getState();
2446
2960
  const sessionToken = s.identity.getSessionToken();
2447
2961
  if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2448
- return postUserNoRotate(s, P.authLink, {
2962
+ return postLink(s, {
2449
2963
  provider: "email_otp",
2450
2964
  email,
2451
2965
  code,
@@ -2458,7 +2972,7 @@ const Amba = {
2458
2972
  const s = getState();
2459
2973
  const sessionToken = s.identity.getSessionToken();
2460
2974
  if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2461
- return postUserNoRotate(s, P.authLink, {
2975
+ return postLink(s, {
2462
2976
  provider: "email_password",
2463
2977
  email,
2464
2978
  password,
@@ -2477,7 +2991,8 @@ const Amba = {
2477
2991
  signOut: async (rotateAnonymousId = false) => {
2478
2992
  const s = getState();
2479
2993
  if (s.identity.getSessionToken()) try {
2480
- await s.http.post(P.authLogout, {});
2994
+ const refreshToken = s.identity.getRefreshToken();
2995
+ await s.http.post(P.authLogout, refreshToken ? { refresh_token: refreshToken } : {});
2481
2996
  } catch (err) {
2482
2997
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
2483
2998
  }
@@ -2490,12 +3005,7 @@ const Amba = {
2490
3005
  s.eventQueue?.flush();
2491
3006
  notifyAuthSubscribers(null);
2492
3007
  },
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
- },
3008
+ refresh: async () => refreshSession(getState()),
2499
3009
  me: async () => {
2500
3010
  const s = getState();
2501
3011
  if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");