@layers/amba-react-native 4.0.11 → 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 +32 -9
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +32 -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.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
1975
|
-
*
|
|
1976
|
-
*
|
|
1977
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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");
|