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