@sendoracloud/sdk-react-native 1.40.0 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +76 -22
- package/dist/index.cjs +2246 -996
- package/dist/index.d.cts +1003 -551
- package/dist/index.d.ts +1003 -551
- package/dist/index.js +2260 -1016
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -30,18 +30,24 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
AMBIGUOUS_HELD_GUEST: () => AMBIGUOUS_HELD_GUEST,
|
|
33
34
|
AlreadyIdentifiedError: () => AlreadyIdentifiedError,
|
|
34
35
|
Auth: () => Auth,
|
|
35
36
|
AuthError: () => AuthError,
|
|
36
37
|
CredentialInUseError: () => CredentialInUseError,
|
|
37
38
|
EmailAlreadyTakenError: () => EmailAlreadyTakenError,
|
|
39
|
+
HELD_GUEST_SLOTS: () => HELD_GUEST_SLOTS,
|
|
40
|
+
HeldGuestLimitError: () => HeldGuestLimitError,
|
|
41
|
+
HeldGuestProjectMismatch: () => HeldGuestProjectMismatch,
|
|
38
42
|
LinkError: () => LinkError,
|
|
39
43
|
Links: () => Links,
|
|
40
44
|
SendoraCloud: () => SendoraCloud,
|
|
41
45
|
computeDeviceFingerprint: () => computeDeviceFingerprint,
|
|
46
|
+
decodeJwtPayload: () => decodeJwtPayload,
|
|
42
47
|
default: () => index_default,
|
|
43
48
|
extractShortcodeFromUrl: () => extractShortcodeFromUrl,
|
|
44
|
-
getPlayInstallReferrer: () => getPlayInstallReferrer
|
|
49
|
+
getPlayInstallReferrer: () => getPlayInstallReferrer,
|
|
50
|
+
projectVerdict: () => projectVerdict
|
|
45
51
|
});
|
|
46
52
|
module.exports = __toCommonJS(index_exports);
|
|
47
53
|
var import_react_native_get_random_values = require("react-native-get-random-values");
|
|
@@ -186,13 +192,42 @@ var Storage = class {
|
|
|
186
192
|
});
|
|
187
193
|
}
|
|
188
194
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
195
|
+
* Clear this SDK's keys from memory, AsyncStorage and the secure adapter.
|
|
196
|
+
*
|
|
197
|
+
* **With `opts.keys` (ADR-033, the `reset()` path):** removes EXACTLY the
|
|
198
|
+
* listed bare keys of the active namespace — minus `opts.except` — from both
|
|
199
|
+
* stores. An exact list is what lets `Sendora.reset()` keep the held-guest
|
|
200
|
+
* slots by default (a guest's refresh token is its only credential, so
|
|
201
|
+
* sweeping them on logout would be deletion by assumption, s58.316), and it
|
|
202
|
+
* never touches a sibling `sendora_<env>_` keyspace that happens to share
|
|
203
|
+
* the prefix. Values kept in `except` stay in the cache too.
|
|
204
|
+
*
|
|
205
|
+
* **Without `opts` (legacy):** the pre-1.41 sweep — every prefixed
|
|
206
|
+
* AsyncStorage key (via `getAllKeys()`, falling back to the secure-key list)
|
|
207
|
+
* plus every secure key from the adapter.
|
|
194
208
|
*/
|
|
195
|
-
async clearAll() {
|
|
209
|
+
async clearAll(opts) {
|
|
210
|
+
if (opts) {
|
|
211
|
+
const except = new Set(opts.except ?? []);
|
|
212
|
+
const store2 = await getAsyncStorage();
|
|
213
|
+
for (const k of opts.keys) {
|
|
214
|
+
if (except.has(k)) continue;
|
|
215
|
+
this.cache.delete(k);
|
|
216
|
+
if (this.isSecure(k)) {
|
|
217
|
+
try {
|
|
218
|
+
await this.secure.removeItem(this.secureName(k));
|
|
219
|
+
} catch {
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (store2) {
|
|
223
|
+
try {
|
|
224
|
+
await store2.removeItem(this.prefix + k);
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
196
231
|
this.cache.clear();
|
|
197
232
|
if (this.secure) {
|
|
198
233
|
for (const k of this.secureKeys) {
|
|
@@ -228,7 +263,7 @@ var Storage = class {
|
|
|
228
263
|
// package.json
|
|
229
264
|
var package_default = {
|
|
230
265
|
name: "@sendoracloud/sdk-react-native",
|
|
231
|
-
version: "1.
|
|
266
|
+
version: "1.41.0",
|
|
232
267
|
description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
233
268
|
type: "module",
|
|
234
269
|
main: "./dist/index.cjs",
|
|
@@ -285,7 +320,7 @@ var package_default = {
|
|
|
285
320
|
scripts: {
|
|
286
321
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
287
322
|
typecheck: "tsc --noEmit",
|
|
288
|
-
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts && tsx test/anonymous-over-account.test.ts"
|
|
323
|
+
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts && tsx test/anonymous-over-account.test.ts && tsx test/auth-machine-conformance.test.ts && tsx test/held-guests.test.ts"
|
|
289
324
|
},
|
|
290
325
|
devDependencies: {
|
|
291
326
|
"@types/react": "^19.2.16",
|
|
@@ -420,29 +455,149 @@ function requireOptionalNativeModule(name) {
|
|
|
420
455
|
return void 0;
|
|
421
456
|
}
|
|
422
457
|
|
|
458
|
+
// src/project-verdict.ts
|
|
459
|
+
var PROJECT_CLAIM = "project";
|
|
460
|
+
function projectVerdict(expectedProjectId, accessJwt) {
|
|
461
|
+
if (expectedProjectId === void 0) return "match";
|
|
462
|
+
const payload = decodeJwtPayload(accessJwt);
|
|
463
|
+
if (payload === null) return "unknown";
|
|
464
|
+
if (!Object.prototype.hasOwnProperty.call(payload, PROJECT_CLAIM)) return "unknown";
|
|
465
|
+
const claim = payload[PROJECT_CLAIM];
|
|
466
|
+
if (claim === null) return "mismatch";
|
|
467
|
+
if (typeof claim === "string") return claim === expectedProjectId ? "match" : "mismatch";
|
|
468
|
+
return "mismatch";
|
|
469
|
+
}
|
|
470
|
+
function decodeJwtPayload(token) {
|
|
471
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
472
|
+
const parts = token.split(".");
|
|
473
|
+
if (parts.length !== 3) return null;
|
|
474
|
+
const bytes = decodeBase64Url(parts[1]);
|
|
475
|
+
if (bytes === null) return null;
|
|
476
|
+
const json = decodeUtf8(bytes);
|
|
477
|
+
if (json === null) return null;
|
|
478
|
+
let parsed;
|
|
479
|
+
try {
|
|
480
|
+
parsed = JSON.parse(json);
|
|
481
|
+
} catch {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
485
|
+
return parsed;
|
|
486
|
+
}
|
|
487
|
+
var B64_LOOKUP = (() => {
|
|
488
|
+
const table = new Int16Array(128).fill(-1);
|
|
489
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
490
|
+
for (let i = 0; i < alphabet.length; i++) table[alphabet.charCodeAt(i)] = i;
|
|
491
|
+
table["-".charCodeAt(0)] = 62;
|
|
492
|
+
table["_".charCodeAt(0)] = 63;
|
|
493
|
+
table["+".charCodeAt(0)] = 62;
|
|
494
|
+
table["/".charCodeAt(0)] = 63;
|
|
495
|
+
return table;
|
|
496
|
+
})();
|
|
497
|
+
function decodeBase64Url(input) {
|
|
498
|
+
let end = input.length;
|
|
499
|
+
while (end > 0 && input.charCodeAt(end - 1) === 61) end--;
|
|
500
|
+
if (end % 4 === 1) return null;
|
|
501
|
+
const out = new Uint8Array(Math.floor(end * 3 / 4));
|
|
502
|
+
let buffer = 0;
|
|
503
|
+
let bits = 0;
|
|
504
|
+
let o = 0;
|
|
505
|
+
for (let i = 0; i < end; i++) {
|
|
506
|
+
const code = input.charCodeAt(i);
|
|
507
|
+
const v = code < 128 ? B64_LOOKUP[code] : -1;
|
|
508
|
+
if (v < 0) return null;
|
|
509
|
+
buffer = buffer << 6 | v;
|
|
510
|
+
bits += 6;
|
|
511
|
+
if (bits >= 8) {
|
|
512
|
+
bits -= 8;
|
|
513
|
+
out[o++] = buffer >> bits & 255;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return o === out.length ? out : out.subarray(0, o);
|
|
517
|
+
}
|
|
518
|
+
function decodeUtf8(bytes) {
|
|
519
|
+
let out = "";
|
|
520
|
+
let i = 0;
|
|
521
|
+
const n = bytes.length;
|
|
522
|
+
while (i < n) {
|
|
523
|
+
const b0 = bytes[i];
|
|
524
|
+
let cp;
|
|
525
|
+
let need;
|
|
526
|
+
if (b0 < 128) {
|
|
527
|
+
cp = b0;
|
|
528
|
+
need = 0;
|
|
529
|
+
} else if (b0 >= 194 && b0 <= 223) {
|
|
530
|
+
cp = b0 & 31;
|
|
531
|
+
need = 1;
|
|
532
|
+
} else if (b0 >= 224 && b0 <= 239) {
|
|
533
|
+
cp = b0 & 15;
|
|
534
|
+
need = 2;
|
|
535
|
+
} else if (b0 >= 240 && b0 <= 244) {
|
|
536
|
+
cp = b0 & 7;
|
|
537
|
+
need = 3;
|
|
538
|
+
} else {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
if (i + need >= n) return null;
|
|
542
|
+
for (let k = 1; k <= need; k++) {
|
|
543
|
+
const b = bytes[i + k];
|
|
544
|
+
if ((b & 192) !== 128) return null;
|
|
545
|
+
cp = cp << 6 | b & 63;
|
|
546
|
+
}
|
|
547
|
+
if (need === 2 && cp < 2048 || need === 3 && (cp < 65536 || cp > 1114111) || cp >= 55296 && cp <= 57343) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
if (cp > 65535) {
|
|
551
|
+
const v = cp - 65536;
|
|
552
|
+
out += String.fromCharCode(55296 + (v >> 10), 56320 + (v & 1023));
|
|
553
|
+
} else {
|
|
554
|
+
out += String.fromCharCode(cp);
|
|
555
|
+
}
|
|
556
|
+
i += need + 1;
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
|
|
423
561
|
// src/auth.ts
|
|
424
562
|
var import_react_native2 = require("react-native");
|
|
425
563
|
var TOKEN_KEY = "auth_access_token";
|
|
426
564
|
var TOKEN_EXPIRES_KEY = "auth_access_expires";
|
|
427
565
|
var REFRESH_KEY = "auth_refresh_token";
|
|
428
566
|
var USER_KEY = "auth_user";
|
|
567
|
+
var SESSION_KEY = "auth_session";
|
|
568
|
+
var MINT_KEY_KEY = "auth_mint_key";
|
|
569
|
+
var HELD_GUEST_SLOTS = 5;
|
|
570
|
+
var HELD_SLOT_KEYS = ["auth_held_0", "auth_held_1", "auth_held_2", "auth_held_3", "auth_held_4"];
|
|
571
|
+
var AUTH_SECURE_KEYS = [REFRESH_KEY, TOKEN_KEY, USER_KEY, SESSION_KEY, ...HELD_SLOT_KEYS];
|
|
572
|
+
var AUTH_HYDRATE_KEYS = [TOKEN_KEY, TOKEN_EXPIRES_KEY, REFRESH_KEY, USER_KEY, SESSION_KEY, MINT_KEY_KEY, ...HELD_SLOT_KEYS];
|
|
429
573
|
var REFRESH_SAFETY_MS = 3e4;
|
|
574
|
+
var SWITCH_CREDENTIAL_MAX_AGE_MS = 5 * 6e4;
|
|
575
|
+
var DEAD_CODES = ["INVALID_REFRESH_TOKEN"];
|
|
576
|
+
var RELEASE_HELD_ON_END_REASONS = ["deleted", "revoked", "expired"];
|
|
577
|
+
var AMBIGUOUS_HELD_GUEST = "AMBIGUOUS_HELD_GUEST";
|
|
430
578
|
function isAuthApiResponse(v) {
|
|
431
579
|
if (!v || typeof v !== "object") return false;
|
|
432
580
|
const r = v;
|
|
433
581
|
const user = r.user;
|
|
434
582
|
const tokens = r.tokens;
|
|
435
|
-
if (!user
|
|
583
|
+
if (!isAuthUserShape(user)) return false;
|
|
436
584
|
if (!tokens) return false;
|
|
437
585
|
if (typeof tokens.accessToken !== "string" || tokens.accessToken.length === 0) return false;
|
|
438
586
|
if (typeof tokens.refreshToken !== "string" || tokens.refreshToken.length === 0) return false;
|
|
439
587
|
if (typeof tokens.expiresIn !== "number" || tokens.expiresIn <= 0) return false;
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (user
|
|
444
|
-
|
|
445
|
-
if (
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
function isAuthUserShape(user) {
|
|
591
|
+
if (!user || typeof user !== "object") return false;
|
|
592
|
+
const u = user;
|
|
593
|
+
if (typeof u.id !== "string" || u.id.length === 0) return false;
|
|
594
|
+
if (u.isAnonymous !== void 0 && typeof u.isAnonymous !== "boolean") return false;
|
|
595
|
+
if (u.email !== void 0 && u.email !== null && typeof u.email !== "string") return false;
|
|
596
|
+
if (u.emailVerified !== void 0 && typeof u.emailVerified !== "boolean") return false;
|
|
597
|
+
if (u.name !== void 0 && u.name !== null && typeof u.name !== "string") return false;
|
|
598
|
+
if (u.signupMethod !== void 0 && u.signupMethod !== null && typeof u.signupMethod !== "string") return false;
|
|
599
|
+
if (u.lastLoginMethod !== void 0 && u.lastLoginMethod !== null && typeof u.lastLoginMethod !== "string") return false;
|
|
600
|
+
if (u.project !== void 0 && u.project !== null && typeof u.project !== "string") return false;
|
|
446
601
|
return true;
|
|
447
602
|
}
|
|
448
603
|
var EmailAlreadyTakenError = class extends Error {
|
|
@@ -489,6 +644,11 @@ function readCollisionDetails(parsed) {
|
|
|
489
644
|
collision: d.collision === "identity" || d.collision === "email" ? d.collision : void 0
|
|
490
645
|
};
|
|
491
646
|
}
|
|
647
|
+
function readEndReason(parsed) {
|
|
648
|
+
const details = parsed?.error?.details;
|
|
649
|
+
const v = details?.endReason;
|
|
650
|
+
return v === "expired" || v === "revoked" || v === "evicted" || v === "deleted" || v === "reuse" || v === "unknown" ? v : void 0;
|
|
651
|
+
}
|
|
492
652
|
var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
|
|
493
653
|
"network",
|
|
494
654
|
"server",
|
|
@@ -531,6 +691,9 @@ function classifyAuthErrorKind(code, status) {
|
|
|
531
691
|
case "PLAY_GAMES_UNAVAILABLE":
|
|
532
692
|
case "ENTITLEMENT_ERROR":
|
|
533
693
|
case "tier_required":
|
|
694
|
+
// ADR-033 §3.5 [2c]: the two project-guard refusals reuse the `config` kind.
|
|
695
|
+
case "SESSION_WITHHELD":
|
|
696
|
+
case "PROJECT_MISMATCH":
|
|
534
697
|
return "config";
|
|
535
698
|
case "UNAUTHORIZED":
|
|
536
699
|
case "INVALID_CREDENTIALS":
|
|
@@ -556,11 +719,30 @@ var AuthError = class extends Error {
|
|
|
556
719
|
this.name = "AuthError";
|
|
557
720
|
this.code = code;
|
|
558
721
|
this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
|
|
559
|
-
this.retryable = RETRYABLE_KINDS.has(this.kind);
|
|
722
|
+
this.retryable = opts?.retryable ?? RETRYABLE_KINDS.has(this.kind);
|
|
560
723
|
this.retryAfterSeconds = opts?.retryAfterSeconds;
|
|
561
724
|
this.status = opts?.status;
|
|
725
|
+
this.endReason = opts?.endReason;
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
var HeldGuestLimitError = class extends AuthError {
|
|
729
|
+
constructor(message = `This device already holds ${HELD_GUEST_SLOTS} guest accounts; restore or discard one before minting another`) {
|
|
730
|
+
super("HELD_GUEST_LIMIT", message, { kind: "unknown" });
|
|
731
|
+
this.name = "HeldGuestLimitError";
|
|
562
732
|
}
|
|
563
733
|
};
|
|
734
|
+
var HeldGuestProjectMismatch = class extends AuthError {
|
|
735
|
+
constructor(userId, expectedProjectId, actualProjectId) {
|
|
736
|
+
super("HELD_GUEST_PROJECT_MISMATCH", "The held guest belongs to a different project than this app is bound to", { kind: "unknown" });
|
|
737
|
+
this.name = "HeldGuestProjectMismatch";
|
|
738
|
+
this.userId = userId;
|
|
739
|
+
this.expectedProjectId = expectedProjectId;
|
|
740
|
+
this.actualProjectId = actualProjectId;
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
function withheldError(code, message) {
|
|
744
|
+
return new AuthError(code, message, { kind: "config", retryable: false });
|
|
745
|
+
}
|
|
564
746
|
function asAuthError(err) {
|
|
565
747
|
if (err instanceof AuthError) return err;
|
|
566
748
|
if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
|
|
@@ -621,11 +803,113 @@ async function fetchPlayGamesAuthCodeNative() {
|
|
|
621
803
|
}
|
|
622
804
|
return { serverAuthCode };
|
|
623
805
|
}
|
|
806
|
+
var LOGIN_PATH = "/api/v1/auth-service/login";
|
|
807
|
+
var SOCIAL_LOGIN_PATH = "/api/v1/auth-service/login/social";
|
|
808
|
+
var GAME_CENTER_LOGIN_PATH = "/api/v1/auth-service/login/game-center";
|
|
809
|
+
var PLAY_GAMES_LOGIN_PATH = "/api/v1/auth-service/login/play-games";
|
|
810
|
+
var MFA_CHALLENGE_PATH = "/api/v1/auth-service/mfa/challenge";
|
|
811
|
+
var MAGIC_LINK_VERIFY_PATH = "/api/v1/auth-service/magic-link/verify";
|
|
812
|
+
var EMAIL_OTP_VERIFY_PATH = "/api/v1/auth-service/email-otp/verify";
|
|
813
|
+
var PASSKEY_FINISH_PATH = "/api/v1/auth-service/passkeys/authenticate/finish";
|
|
814
|
+
var LINK_EMAIL_PATH = "/api/v1/auth-service/me/link/email";
|
|
815
|
+
var LINK_SOCIAL_PATH = "/api/v1/auth-service/me/link/social";
|
|
816
|
+
var LINK_GAME_CENTER_PATH = "/api/v1/auth-service/me/link/game-center";
|
|
817
|
+
var LINK_PLAY_GAMES_PATH = "/api/v1/auth-service/me/link/play-games";
|
|
818
|
+
var REFRESH_PATH = "/api/v1/auth-service/token/refresh";
|
|
819
|
+
var REVOKE_PATH = "/api/v1/auth-service/token/revoke";
|
|
820
|
+
var SOCIAL_KINDS = /* @__PURE__ */ new Set(["google", "github", "apple", "microsoft", "linkedin", "facebook", "twitter", "discord"]);
|
|
821
|
+
function signInWire(credential) {
|
|
822
|
+
switch (credential.kind) {
|
|
823
|
+
case "password":
|
|
824
|
+
return { path: LOGIN_PATH, body: { email: credential.email, password: credential.password } };
|
|
825
|
+
case "gameCenter": {
|
|
826
|
+
const { kind: _k, ...payload } = credential;
|
|
827
|
+
return { path: GAME_CENTER_LOGIN_PATH, body: { ...payload } };
|
|
828
|
+
}
|
|
829
|
+
case "playGames":
|
|
830
|
+
return { path: PLAY_GAMES_LOGIN_PATH, body: { serverAuthCode: credential.serverAuthCode } };
|
|
831
|
+
case "magicLink":
|
|
832
|
+
return { path: MAGIC_LINK_VERIFY_PATH, body: { token: credential.token } };
|
|
833
|
+
case "emailOtp":
|
|
834
|
+
return { path: EMAIL_OTP_VERIFY_PATH, body: { email: credential.email, code: credential.code } };
|
|
835
|
+
case "passkey": {
|
|
836
|
+
const a = credential.assertion;
|
|
837
|
+
const body = "response" in a ? { ...a } : { response: a, userId: null };
|
|
838
|
+
return { path: PASSKEY_FINISH_PATH, body };
|
|
839
|
+
}
|
|
840
|
+
case "mfa":
|
|
841
|
+
return { path: MFA_CHALLENGE_PATH, body: { challengeToken: credential.challengeToken, code: credential.code } };
|
|
842
|
+
default: {
|
|
843
|
+
const { kind, ...rest } = credential;
|
|
844
|
+
return { path: SOCIAL_LOGIN_PATH, body: { provider: kind, ...stripUndefined(rest) } };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function linkWire(credential) {
|
|
849
|
+
switch (credential.kind) {
|
|
850
|
+
case "password":
|
|
851
|
+
return { path: LINK_EMAIL_PATH, body: { email: credential.email, password: credential.password } };
|
|
852
|
+
case "gameCenter": {
|
|
853
|
+
const { kind: _k, ...payload } = credential;
|
|
854
|
+
return { path: LINK_GAME_CENTER_PATH, body: { ...payload } };
|
|
855
|
+
}
|
|
856
|
+
case "playGames":
|
|
857
|
+
return { path: LINK_PLAY_GAMES_PATH, body: { serverAuthCode: credential.serverAuthCode } };
|
|
858
|
+
case "magicLink":
|
|
859
|
+
case "emailOtp":
|
|
860
|
+
case "passkey":
|
|
861
|
+
case "mfa":
|
|
862
|
+
return null;
|
|
863
|
+
default: {
|
|
864
|
+
const { kind, ...rest } = credential;
|
|
865
|
+
return SOCIAL_KINDS.has(kind) ? { path: LINK_SOCIAL_PATH, body: { provider: kind, ...stripUndefined(rest) } } : null;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function stripUndefined(o) {
|
|
870
|
+
const out = {};
|
|
871
|
+
for (const [k, v] of Object.entries(o)) if (v !== void 0) out[k] = v;
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
874
|
+
function providerLabel(credential) {
|
|
875
|
+
switch (credential.kind) {
|
|
876
|
+
case "password":
|
|
877
|
+
return "password";
|
|
878
|
+
case "gameCenter":
|
|
879
|
+
return "gamecenter";
|
|
880
|
+
case "playGames":
|
|
881
|
+
return "playgames";
|
|
882
|
+
case "magicLink":
|
|
883
|
+
return "magic_link";
|
|
884
|
+
case "emailOtp":
|
|
885
|
+
return "email_otp";
|
|
886
|
+
default:
|
|
887
|
+
return credential.kind;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function generateMintKey() {
|
|
891
|
+
const g = globalThis;
|
|
892
|
+
if (!g.crypto?.getRandomValues) return null;
|
|
893
|
+
const bytes = new Uint8Array(16);
|
|
894
|
+
g.crypto.getRandomValues(bytes);
|
|
895
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
896
|
+
}
|
|
624
897
|
var Auth = class {
|
|
625
898
|
constructor(hooks) {
|
|
899
|
+
// ── The ONE identity slot (written only by commit()) ──────────────────
|
|
626
900
|
this.user = null;
|
|
627
901
|
this.accessToken = null;
|
|
628
902
|
this.accessExpiresAt = 0;
|
|
903
|
+
/** The in-memory mirror of the K held slots (index = slot number). Written only by commit(). */
|
|
904
|
+
this.held = HELD_SLOT_KEYS.map(() => null);
|
|
905
|
+
/** Non-null ⇔ `status() === "withheld"` (ADR-033 [2c]). Never persisted. */
|
|
906
|
+
this.withheld = null;
|
|
907
|
+
/** The refresh token the withheld record carried — the CAS key of `discardWithheld()`. */
|
|
908
|
+
this.withheldRefresh = null;
|
|
909
|
+
/** B15: held guests whose `POST /guest/held` report has not yet returned 2xx. Persisted in `auth_session`. */
|
|
910
|
+
this.heldReportsPending = /* @__PURE__ */ new Set();
|
|
911
|
+
/** The in-flight identity-changing proposal; the proactive cron pauses while it is set. */
|
|
912
|
+
this.attempt = null;
|
|
629
913
|
/**
|
|
630
914
|
* Set once we have PROOF that this device's clock runs fast, which
|
|
631
915
|
* releases the `exp` guard in `canServeCachedToken`.
|
|
@@ -650,15 +934,10 @@ var Auth = class {
|
|
|
650
934
|
this.inflight = Promise.resolve();
|
|
651
935
|
this.refreshInflight = null;
|
|
652
936
|
/**
|
|
653
|
-
* Wave 62 — refresh failure back-pressure.
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* back off so the next caller doesn't immediately fire another
|
|
658
|
-
* fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
|
|
659
|
-
* the counter resets + retries are allowed. AppState foreground
|
|
660
|
-
* also clears it so a user returning to the app gets an immediate
|
|
661
|
-
* fresh attempt.
|
|
937
|
+
* Wave 62 — refresh failure back-pressure. Consecutive network-timeout
|
|
938
|
+
* failures don't wipe identity (refresh may still be valid; iOS NSURLSession
|
|
939
|
+
* was just stalled) but do back off so the next caller doesn't immediately
|
|
940
|
+
* fire another fetch that will also time out. AppState foreground clears it.
|
|
662
941
|
*/
|
|
663
942
|
this.refreshFailureCount = 0;
|
|
664
943
|
this.refreshCooldownUntil = 0;
|
|
@@ -668,107 +947,237 @@ var Auth = class {
|
|
|
668
947
|
this.lastDeletionCancelled = null;
|
|
669
948
|
this.lastAnonRetirement = null;
|
|
670
949
|
this.lastTakeover = null;
|
|
671
|
-
/**
|
|
672
|
-
* Anon refresh token captured at `signIn()` time, stashed keyed to the
|
|
673
|
-
* issued `mfaChallengeToken` so the later `challengeMfa()` can forward it
|
|
674
|
-
* for device-takeover (s58.111). Before this, the MFA branch wiped the anon
|
|
675
|
-
* identity before the challenge resolved → the takeover hint was lost → one
|
|
676
|
-
* device ended with two user_ids + duplicate pushes (audit s58.203 fix).
|
|
677
|
-
*/
|
|
678
|
-
this.pendingAnonTakeover = null;
|
|
679
|
-
/**
|
|
680
|
-
* True once `hydrate()` has run. Distinguishes "no session restored YET"
|
|
681
|
-
* from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
|
|
682
|
-
*/
|
|
950
|
+
/** True once `hydrate()` has run. Gates `onAuthStateChanged`'s replay-on-subscribe. */
|
|
683
951
|
this.hydrated = false;
|
|
684
952
|
/**
|
|
685
|
-
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
953
|
+
* Proactive refresh (s58.72). Fires when access-token age crosses ~80% of
|
|
954
|
+
* TTL, repairs a stale deadline the combined guard can see, and performs the
|
|
955
|
+
* single gap-fill refresh for an `unknown` record. Armed ONLY by `commit()`
|
|
956
|
+
* (install / hydrate), stopped by the clear and withhold paths. ⚠ Never
|
|
957
|
+
* starts a refresh while an identity attempt is pending (ADR-033 §3.3
|
|
958
|
+
* scheduling rule 2) and never refreshes a held guest.
|
|
690
959
|
*/
|
|
691
960
|
this.proactiveRefreshTimer = null;
|
|
692
961
|
this.proactiveRefreshAppStateUnsub = null;
|
|
962
|
+
this.pendingWipeHook = null;
|
|
693
963
|
this.hooks = hooks;
|
|
694
964
|
this.readyPromise = new Promise((res) => {
|
|
695
965
|
this.resolveReady = res;
|
|
696
966
|
});
|
|
697
967
|
}
|
|
968
|
+
// ============================================================
|
|
969
|
+
// HYDRATE (ADR-033 §3.3 rows 1–4; INV-9 hydrate coherence)
|
|
970
|
+
// ============================================================
|
|
698
971
|
/**
|
|
699
|
-
*
|
|
700
|
-
* init() after Storage.hydrate() has run.
|
|
701
|
-
*
|
|
972
|
+
* Restore the session from storage. Called by the parent SDK during
|
|
973
|
+
* init() after Storage.hydrate() has run. Disk-only, never waits on the
|
|
974
|
+
* network; resolves the internal ready gate so queued auth calls unblock.
|
|
702
975
|
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
* (
|
|
713
|
-
*
|
|
714
|
-
*
|
|
976
|
+
* Reads `auth_session` FIRST, then the four frozen keys. When the frozen keys
|
|
977
|
+
* carry a refresh token that differs from the record's, the FROZEN keys win:
|
|
978
|
+
* every SDK version writes them, so after a 1.41 → 1.40 → 1.41 round trip
|
|
979
|
+
* they are the fresher copy and the stale `auth_session` must not resurrect a
|
|
980
|
+
* rotated-away token. The record is then written forward from them.
|
|
981
|
+
*
|
|
982
|
+
* Coherence (INV-9): user ∧ refresh → judged by the project guard
|
|
983
|
+
* (match → installed; mismatch → `withheld`, ZERO writes; unknown → stay
|
|
984
|
+
* `unknown`, one gap-fill refresh re-judges it); refresh ∧ ¬user → `unknown`
|
|
985
|
+
* (LIMBO_A — never `signed_out`, never a mint; the refresh token is the only
|
|
986
|
+
* thing that can recover the account, 1.28.0); user ∧ ¬refresh → the stale
|
|
987
|
+
* blob is cleared → `signed_out`. Held slots: a slot whose `userId` equals the
|
|
988
|
+
* current user is dropped (current wins); a slot that fails to parse is KEPT
|
|
989
|
+
* and ignored, never cleared (P11).
|
|
715
990
|
*/
|
|
716
991
|
hydrate() {
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
992
|
+
const disk = this.readDiskSession();
|
|
993
|
+
this.heldReportsPending = new Set(disk.heldReportsPending);
|
|
994
|
+
const hadRefresh = !!disk.refreshToken;
|
|
995
|
+
if (disk.refreshToken && disk.user) {
|
|
996
|
+
const verdict = this.verdictFor(disk.user, disk.accessToken);
|
|
997
|
+
if (verdict === "mismatch") {
|
|
998
|
+
this.enterWithheld(disk.user, disk.accessToken, disk.refreshToken);
|
|
999
|
+
} else if (verdict === "match") {
|
|
1000
|
+
this.commit({
|
|
1001
|
+
kind: "install",
|
|
1002
|
+
reason: "hydrate",
|
|
1003
|
+
record: {
|
|
1004
|
+
user: disk.user,
|
|
1005
|
+
accessToken: disk.accessToken ?? "",
|
|
1006
|
+
refreshToken: disk.refreshToken,
|
|
1007
|
+
expiresIn: 0
|
|
1008
|
+
},
|
|
1009
|
+
accessExpiresAt: disk.accessToken ? disk.accessExpiresAt : 0,
|
|
1010
|
+
writeForward: disk.legacyOnly
|
|
1011
|
+
});
|
|
1012
|
+
} else {
|
|
1013
|
+
this.startProactiveRefreshCron();
|
|
1014
|
+
}
|
|
1015
|
+
} else if (disk.refreshToken) {
|
|
1016
|
+
this.startProactiveRefreshCron();
|
|
1017
|
+
} else if (disk.user || this.hooks.storage.get(USER_KEY) || this.hooks.storage.get(SESSION_KEY)) {
|
|
1018
|
+
this.commit({ kind: "clear", reason: "stale_blob" });
|
|
1019
|
+
}
|
|
1020
|
+
this.hydrateHeldSlots();
|
|
1021
|
+
this.hydrated = true;
|
|
1022
|
+
this.resolveReady();
|
|
1023
|
+
if (hadRefresh && this.status() === "unknown") {
|
|
1024
|
+
setTimeout(() => {
|
|
1025
|
+
void this.refreshAccessToken();
|
|
1026
|
+
}, 0);
|
|
1027
|
+
}
|
|
1028
|
+
this.retryPendingHeldReports();
|
|
1029
|
+
}
|
|
1030
|
+
/** Read `auth_session` + the four frozen keys and reconcile them (see `hydrate()`). */
|
|
1031
|
+
readDiskSession() {
|
|
1032
|
+
const st = this.hooks.storage;
|
|
1033
|
+
let record = null;
|
|
1034
|
+
const raw = st.get(SESSION_KEY);
|
|
1035
|
+
if (raw) {
|
|
1036
|
+
try {
|
|
1037
|
+
const parsed = JSON.parse(raw);
|
|
1038
|
+
if (parsed && typeof parsed === "object" && parsed.v === 2) record = parsed;
|
|
1039
|
+
} catch {
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
const legacyUserRaw = st.get(USER_KEY);
|
|
1043
|
+
const legacyToken = st.get(TOKEN_KEY);
|
|
1044
|
+
const legacyRefresh = st.get(REFRESH_KEY);
|
|
1045
|
+
const legacyExpires = st.get(TOKEN_EXPIRES_KEY);
|
|
1046
|
+
let legacyUser = null;
|
|
1047
|
+
if (legacyUserRaw) {
|
|
722
1048
|
try {
|
|
723
|
-
const parsed = JSON.parse(
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1049
|
+
const parsed = JSON.parse(legacyUserRaw);
|
|
1050
|
+
if (isAuthUserShape(parsed)) legacyUser = parsed;
|
|
1051
|
+
} catch {
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
const pending = Array.isArray(record?.heldReportsPending) ? record.heldReportsPending.filter((x) => typeof x === "string") : [];
|
|
1055
|
+
const recordRefresh = typeof record?.refreshToken === "string" && record.refreshToken ? record.refreshToken : null;
|
|
1056
|
+
const recordUser = record?.user && isAuthUserShape(record.user) ? record.user : null;
|
|
1057
|
+
const useRecord = recordRefresh !== null && legacyRefresh !== null && legacyRefresh === recordRefresh;
|
|
1058
|
+
if (useRecord) {
|
|
1059
|
+
return {
|
|
1060
|
+
user: recordUser ?? legacyUser,
|
|
1061
|
+
accessToken: typeof record.accessToken === "string" && record.accessToken ? record.accessToken : legacyToken,
|
|
1062
|
+
accessExpiresAt: typeof record.accessExpiresAt === "number" ? record.accessExpiresAt : legacyExpires ? Number(legacyExpires) : 0,
|
|
1063
|
+
refreshToken: recordRefresh,
|
|
1064
|
+
heldReportsPending: pending,
|
|
1065
|
+
legacyOnly: false
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
return {
|
|
1069
|
+
user: legacyUser,
|
|
1070
|
+
accessToken: legacyToken,
|
|
1071
|
+
accessExpiresAt: legacyExpires ? Number(legacyExpires) : 0,
|
|
1072
|
+
refreshToken: legacyRefresh,
|
|
1073
|
+
heldReportsPending: pending,
|
|
1074
|
+
legacyOnly: legacyRefresh !== null
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
/** Load the K slots into the in-memory mirror; dedupe against the current user (current wins); keep unparseable slots untouched. */
|
|
1078
|
+
hydrateHeldSlots() {
|
|
1079
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1080
|
+
for (let i = 0; i < HELD_SLOT_KEYS.length; i++) {
|
|
1081
|
+
const rec = this.readSlot(i);
|
|
1082
|
+
if (!rec) continue;
|
|
1083
|
+
if (this.user && rec.userId === this.user.id) {
|
|
1084
|
+
this.writeSlot(i, null);
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (seen.has(rec.userId)) {
|
|
1088
|
+
const j = this.held.findIndex((h) => h?.userId === rec.userId);
|
|
1089
|
+
const other = this.held[j];
|
|
1090
|
+
if (rec.heldAt >= other.heldAt) {
|
|
1091
|
+
this.writeSlot(j, null);
|
|
1092
|
+
this.held[i] = rec;
|
|
729
1093
|
} else {
|
|
730
|
-
this.
|
|
731
|
-
this.accessExpiresAt = 0;
|
|
1094
|
+
this.writeSlot(i, null);
|
|
732
1095
|
}
|
|
733
|
-
|
|
734
|
-
} catch {
|
|
735
|
-
this.hooks.storage.remove(USER_KEY);
|
|
736
|
-
this.hooks.storage.remove(TOKEN_KEY);
|
|
737
|
-
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
1096
|
+
continue;
|
|
738
1097
|
}
|
|
1098
|
+
seen.add(rec.userId);
|
|
1099
|
+
this.held[i] = rec;
|
|
739
1100
|
}
|
|
740
|
-
this.hydrated = true;
|
|
741
|
-
if (this.user && this.hooks.storage.get(REFRESH_KEY)) this.startProactiveRefreshCron();
|
|
742
|
-
this.resolveReady();
|
|
743
1101
|
}
|
|
744
|
-
|
|
745
|
-
|
|
1102
|
+
/** Parse one slot off disk. `null` for an empty OR unparseable slot (the latter is left on disk, P11). */
|
|
1103
|
+
readSlot(i) {
|
|
1104
|
+
const raw = this.hooks.storage.get(HELD_SLOT_KEYS[i]);
|
|
1105
|
+
if (!raw) return null;
|
|
1106
|
+
try {
|
|
1107
|
+
const p = JSON.parse(raw);
|
|
1108
|
+
if (!p || typeof p !== "object" || p.v !== 1) return null;
|
|
1109
|
+
if (typeof p.userId !== "string" || !p.userId) return null;
|
|
1110
|
+
if (typeof p.refreshToken !== "string" || !p.refreshToken) return null;
|
|
1111
|
+
return {
|
|
1112
|
+
v: 1,
|
|
1113
|
+
userId: p.userId,
|
|
1114
|
+
sid: typeof p.sid === "string" ? p.sid : "",
|
|
1115
|
+
refreshToken: p.refreshToken,
|
|
1116
|
+
tokenAtHold: typeof p.tokenAtHold === "string" && p.tokenAtHold ? p.tokenAtHold : p.refreshToken,
|
|
1117
|
+
heldAt: typeof p.heldAt === "number" && Number.isFinite(p.heldAt) ? p.heldAt : 0,
|
|
1118
|
+
project: typeof p.project === "string" ? p.project : null,
|
|
1119
|
+
autoRestore: p.autoRestore !== false
|
|
1120
|
+
};
|
|
1121
|
+
} catch {
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
746
1124
|
}
|
|
1125
|
+
// ============================================================
|
|
1126
|
+
// STATUS + READINESS (ADR-033 §3.5)
|
|
1127
|
+
// ============================================================
|
|
747
1128
|
/**
|
|
748
|
-
*
|
|
749
|
-
* the anonymous user_id whenever a previously-anon device signs in
|
|
750
|
-
* to an identified account. Use this to clean up any local mirror
|
|
751
|
-
* keyed by the anonymous id — your own users table, an analytics
|
|
752
|
-
* cache, an audience filter — without needing a webhook receiver.
|
|
1129
|
+
* The closed boot/identity state. Synchronous, never waits on the network.
|
|
753
1130
|
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
* `
|
|
1131
|
+
* - `unknown` — not hydrated yet, OR a refresh token is on disk but the
|
|
1132
|
+
* user is not readable / not yet judged (the gap-fill
|
|
1133
|
+
* refresh settles it). Session-installing verbs QUEUE here.
|
|
1134
|
+
* - `signed_out` — no refresh token. The only state a mint executes in.
|
|
1135
|
+
* - `anonymous` / `identified` — a live session of that kind.
|
|
1136
|
+
* - `withheld` — a stored session refused by `init({expectedProjectId})`.
|
|
759
1137
|
*
|
|
760
|
-
* ⚠
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1138
|
+
* ⚠ This is the ONLY way to tell `withheld` and `unknown` from `signed_out`:
|
|
1139
|
+
* `getUserSync()` is `null` and `isReady()` is `true` in all three. Consumers
|
|
1140
|
+
* MUST default-branch — a later value ships opt-in-gated or in a MAJOR.
|
|
1141
|
+
*/
|
|
1142
|
+
status() {
|
|
1143
|
+
if (this.withheld) return "withheld";
|
|
1144
|
+
if (!this.hydrated) return "unknown";
|
|
1145
|
+
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
1146
|
+
if (!refresh) return "signed_out";
|
|
1147
|
+
if (!this.user) return "unknown";
|
|
1148
|
+
return this.user.isAnonymous ? "anonymous" : "identified";
|
|
1149
|
+
}
|
|
1150
|
+
/** Resolves after hydrate with the settled status — possibly `unknown` or `withheld`. Never waits on the network. */
|
|
1151
|
+
async authStateReady() {
|
|
1152
|
+
await this.readyPromise;
|
|
1153
|
+
return this.status();
|
|
1154
|
+
}
|
|
1155
|
+
getCurrentUser() {
|
|
1156
|
+
return this.user;
|
|
1157
|
+
}
|
|
1158
|
+
/** Alias of `getCurrentUser()` gated on hydration (parity with `Sendora.getUserSync()`). */
|
|
1159
|
+
getUserSync() {
|
|
1160
|
+
return this.hydrated ? this.user : null;
|
|
1161
|
+
}
|
|
1162
|
+
/** True once `hydrate()` has run. `true` in `withheld` and `unknown` too — read `status()`. */
|
|
1163
|
+
isReady() {
|
|
1164
|
+
return this.hydrated;
|
|
1165
|
+
}
|
|
1166
|
+
// ============================================================
|
|
1167
|
+
// LISTENERS
|
|
1168
|
+
// ============================================================
|
|
1169
|
+
/**
|
|
1170
|
+
* Subscribe to device-takeover events (s58.111+). LEGACY after ADR-033: a
|
|
1171
|
+
* wave-1 SDK never sends the guest hint, so the server never retires a guest
|
|
1172
|
+
* for its sign-ins and this fires only if the SERVER reports a retirement.
|
|
1173
|
+
* Kept callable; replace your handler with `onAuthStateChanged` +
|
|
1174
|
+
* `guest_held` / `getHeldGuests()`. Returns an unsubscribe function.
|
|
769
1175
|
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
1176
|
+
* Historically the account-DELETING paths were the gaming sign-ins —
|
|
1177
|
+
* `signInWithGameCenter` / `signInWithPlayGames` (and `loginSocial`) adopting
|
|
1178
|
+
* an existing account retired this device's guest row while the call itself
|
|
1179
|
+
* resolved successfully. On 1.41.0 those calls HOLD the guest instead; a
|
|
1180
|
+
* takeover can only be reported for a sign-in that reached the legacy path.
|
|
772
1181
|
*/
|
|
773
1182
|
onDeviceTakeover(listener) {
|
|
774
1183
|
this.takeoverListeners.add(listener);
|
|
@@ -777,45 +1186,55 @@ var Auth = class {
|
|
|
777
1186
|
};
|
|
778
1187
|
}
|
|
779
1188
|
/**
|
|
780
|
-
* Subscribe to every auth-state transition
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
* The reason it exists: a session that dies in the BACKGROUND (the server
|
|
785
|
-
* rejected the stored refresh token) used to emit no signal whatsoever, so
|
|
786
|
-
* an app could not distinguish it from a deliberate sign-out and only
|
|
787
|
-
* noticed when `getAccessToken()` began returning null. That transition is
|
|
788
|
-
* now `{ event: "signed_out", reason: "session_expired" }`.
|
|
1189
|
+
* Subscribe to every auth-state transition — the single stream that answers
|
|
1190
|
+
* "what happened to my session?". Firebase `onAuthStateChanged` / Supabase
|
|
1191
|
+
* `onAuthStateChange` parity.
|
|
789
1192
|
*
|
|
790
|
-
*
|
|
791
|
-
* hydrated
|
|
792
|
-
*
|
|
793
|
-
*
|
|
1193
|
+
* Replays the SETTLED boot state exactly once on subscribe, once `init()` has
|
|
1194
|
+
* hydrated, as `initial{status,user,withheld?}` — including `signed_out`,
|
|
1195
|
+
* `unknown` and `withheld`, so a subscriber can never confuse "no session"
|
|
1196
|
+
* with "not hydrated yet". With a live session (`anonymous` / `identified`)
|
|
1197
|
+
* `initial` is FOLLOWED by the legacy `signed_in{user}` replay that
|
|
1198
|
+
* 1.28.0–1.40.0 sent (kept — ADR-023); new code should read `initial` and
|
|
1199
|
+
* ignore a `signed_in` that carries no `cause` right after it.
|
|
794
1200
|
*
|
|
795
1201
|
* A failed sign-in emits NOTHING: the session is unchanged, so there is no
|
|
796
1202
|
* transition to report — the rejected promise is the whole story.
|
|
797
1203
|
*
|
|
798
|
-
* ⚠ This is a TRANSITION stream, not a health or connectivity signal
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
* refresh token produces `signed_out(session_expired)`. For liveness read
|
|
805
|
-
* `getAccessToken()` (null = no usable token right now).
|
|
1204
|
+
* ⚠ This is a TRANSITION stream, not a health or connectivity signal. A
|
|
1205
|
+
* transient failure of the background token refresh (offline, timeout, 429,
|
|
1206
|
+
* 5xx) emits nothing: the stored refresh token is kept and the refresh retried
|
|
1207
|
+
* with backoff. Only a server-REJECTED refresh token produces `signed_out`
|
|
1208
|
+
* (reason `session_expired`, or the server's own `revoked` / `deleted`). For
|
|
1209
|
+
* liveness read `getAccessToken()` (null = no usable token right now).
|
|
806
1210
|
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
1211
|
+
* Replacing a guest with an account emits `guest_held` then ONE `signed_in`
|
|
1212
|
+
* — no `signed_out`. Listeners are best-effort; a throwing listener is
|
|
1213
|
+
* swallowed (dev-logged). Returns an unsubscribe function.
|
|
809
1214
|
*/
|
|
810
1215
|
onAuthStateChanged(listener) {
|
|
811
1216
|
this.authStateListeners.add(listener);
|
|
812
|
-
if (this.hydrated
|
|
813
|
-
const
|
|
1217
|
+
if (this.hydrated) {
|
|
1218
|
+
const status = this.status();
|
|
1219
|
+
const change = {
|
|
1220
|
+
event: "initial",
|
|
1221
|
+
status,
|
|
1222
|
+
user: this.user,
|
|
1223
|
+
...this.withheld ? { withheld: this.withheld } : {}
|
|
1224
|
+
};
|
|
814
1225
|
try {
|
|
815
|
-
listener(
|
|
1226
|
+
listener(change);
|
|
816
1227
|
} catch (err) {
|
|
817
1228
|
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
818
1229
|
}
|
|
1230
|
+
const user = this.user;
|
|
1231
|
+
if (user && (status === "anonymous" || status === "identified") && this.authStateListeners.has(listener)) {
|
|
1232
|
+
try {
|
|
1233
|
+
listener({ event: "signed_in", user });
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
819
1238
|
}
|
|
820
1239
|
return () => {
|
|
821
1240
|
this.authStateListeners.delete(listener);
|
|
@@ -831,16 +1250,10 @@ var Auth = class {
|
|
|
831
1250
|
}
|
|
832
1251
|
}
|
|
833
1252
|
}
|
|
834
|
-
/**
|
|
835
|
-
* Inspect the most recent device-takeover event observed by the
|
|
836
|
-
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
837
|
-
* happened during this session — useful for late subscribers or
|
|
838
|
-
* post-sign-in side-effects (e.g. app-launch hydrate).
|
|
839
|
-
*/
|
|
1253
|
+
/** The most recent device-takeover event observed, or null. Cleared on signOut. */
|
|
840
1254
|
getLastDeviceTakeover() {
|
|
841
1255
|
return this.lastTakeover;
|
|
842
1256
|
}
|
|
843
|
-
/** Internal — fan out to subscribers + cache the latest. */
|
|
844
1257
|
fireDeviceTakeover(retiredAnonUserId, identifiedUserId) {
|
|
845
1258
|
const evt = { retiredAnonUserId, identifiedUserId, at: /* @__PURE__ */ new Date() };
|
|
846
1259
|
this.lastTakeover = evt;
|
|
@@ -855,10 +1268,8 @@ var Auth = class {
|
|
|
855
1268
|
/**
|
|
856
1269
|
* Subscribe to deletion-cancelled events (s58.269): fires when a sign-in
|
|
857
1270
|
* cancelled a pending self-service account deletion within its grace window
|
|
858
|
-
* (the account is restored, same user_id).
|
|
859
|
-
*
|
|
860
|
-
* loginSocial / signInWithGameCenter / signInWithPlayGames. Returns an
|
|
861
|
-
* unsubscribe function; listener errors are swallowed (best-effort).
|
|
1271
|
+
* (the account is restored, same user_id). Also surfaced as
|
|
1272
|
+
* `SignInResult.reactivated`. Returns an unsubscribe function.
|
|
862
1273
|
*/
|
|
863
1274
|
onDeletionCancelled(listener) {
|
|
864
1275
|
this.deletionCancelledListeners.add(listener);
|
|
@@ -871,19 +1282,15 @@ var Auth = class {
|
|
|
871
1282
|
return this.lastDeletionCancelled;
|
|
872
1283
|
}
|
|
873
1284
|
/**
|
|
874
|
-
* What the last sign-in did with this device's guest account
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
* your other account" is a real offer rather than a guess. `"retired"` means
|
|
880
|
-
* migrate from `onDeviceTakeover`'s id; `"none"` means there was nothing to
|
|
881
|
-
* reconcile. Cleared on signOut.
|
|
1285
|
+
* What the last sign-in did with this device's guest account (s58.278).
|
|
1286
|
+
* LEGACY: a wave-1 SDK never presents a guest token, so a current backend
|
|
1287
|
+
* answers `"none"` for every sign-in. Read `getHeldGuests()` instead — the
|
|
1288
|
+
* guest is on this device, held. Recorded only when the server states the
|
|
1289
|
+
* field. Cleared on signOut.
|
|
882
1290
|
*/
|
|
883
1291
|
getLastAnonRetirement() {
|
|
884
1292
|
return this.lastAnonRetirement;
|
|
885
1293
|
}
|
|
886
|
-
/** Internal — fan out deletion-cancelled to subscribers + cache the latest. */
|
|
887
1294
|
fireDeletionCancelled(userId) {
|
|
888
1295
|
const evt = { userId, at: /* @__PURE__ */ new Date() };
|
|
889
1296
|
this.lastDeletionCancelled = evt;
|
|
@@ -895,42 +1302,9 @@ var Auth = class {
|
|
|
895
1302
|
}
|
|
896
1303
|
}
|
|
897
1304
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
* Hard contract (matches sdk-ios / sdk-android, and the industry
|
|
902
|
-
* token-client pattern — Supabase `_callRefreshToken`, Auth0
|
|
903
|
-
* `getTokenSilently`, Firebase `getIdToken`): this method NEVER
|
|
904
|
-
* returns a token past its `exp`. Callers attach the result as a
|
|
905
|
-
* Bearer to arbitrary resource servers — including third-party
|
|
906
|
-
* backends (PostgREST/Supabase) that have NO knowledge of Sendora's
|
|
907
|
-
* own refresh grace window — so a token must be either fresh or
|
|
908
|
-
* absent. A token expired by hours is useless and yields a 401.
|
|
909
|
-
*
|
|
910
|
-
* Resolution order:
|
|
911
|
-
* 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
|
|
912
|
-
* before BOTH the tracked deadline and the token's own `exp`) →
|
|
913
|
-
* return it. Fast path, no I/O. See `canServeCachedToken` for why
|
|
914
|
-
* both are required and when the second is released.
|
|
915
|
-
* Pass `{ forceRefresh: true }` to skip this step when the caller
|
|
916
|
-
* knows the tracked deadline is wrong.
|
|
917
|
-
* 2. No cached token AND no refresh token in storage → genuinely
|
|
918
|
-
* signed out → null.
|
|
919
|
-
* 3. Expired/near-expiry but a refresh chain exists:
|
|
920
|
-
* a. A refresh is already inflight → AWAIT it and return the
|
|
921
|
-
* fresh token. Concurrent callers coalesce onto the single
|
|
922
|
-
* in-flight `/token/refresh` (single-flight) — nobody is
|
|
923
|
-
* handed a stale token. The inflight fetch is
|
|
924
|
-
* AbortController-bounded (http.ts caps `/auth-service/*` at
|
|
925
|
-
* 10s), so awaiting can NEVER hang the JS bridge. That
|
|
926
|
-
* bounded-fetch guarantee — NOT the old stale-token return —
|
|
927
|
-
* is what actually fixed the Wave-62 cold-launch hang.
|
|
928
|
-
* b. A recent refresh failed and we're in backoff cooldown →
|
|
929
|
-
* null. Honest "no usable token right now" rather than a
|
|
930
|
-
* provably-expired one; the AppState foreground hook clears
|
|
931
|
-
* the cooldown for an immediate retry on resume.
|
|
932
|
-
* c. Otherwise → fire the refresh and await it.
|
|
933
|
-
*/
|
|
1305
|
+
// ============================================================
|
|
1306
|
+
// TOKEN LIVENESS (s58.281 — unchanged machinery)
|
|
1307
|
+
// ============================================================
|
|
934
1308
|
/**
|
|
935
1309
|
* The cached-token guard, and the single place the two deadlines are
|
|
936
1310
|
* combined. Both must agree before a cached token is served:
|
|
@@ -941,16 +1315,8 @@ var Auth = class {
|
|
|
941
1315
|
* full offset on a constantly-wrong clock.
|
|
942
1316
|
*
|
|
943
1317
|
* Guard 2 is released once `clockSkewConfirmed` proves the second failure
|
|
944
|
-
* mode is the one we are actually in.
|
|
945
|
-
*
|
|
946
|
-
* The gap this closes: `accessExpiresAt` is written in the device's clock
|
|
947
|
-
* frame at mint and PERSISTED. Step the clock backwards between mint and
|
|
948
|
-
* read — an iPhone acquiring automatic time after a long power-off, a
|
|
949
|
-
* restore, or a manual set — and the deadline is stale by the size of the
|
|
950
|
-
* correction, surviving relaunches. The SDK then serves a provably dead
|
|
951
|
-
* token, every request 401s, and because the fast path returns before
|
|
952
|
-
* reaching `persist()` or `wipeLocalIdentity()` nothing emits
|
|
953
|
-
* `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
|
|
1318
|
+
* mode is the one we are actually in. ⚠ NEVER derive one deadline from the
|
|
1319
|
+
* other (`accessExpiresAt = exp * 1000` throws away skew-invariance).
|
|
954
1320
|
*/
|
|
955
1321
|
canServeToken(token, expiresAt, nowMs) {
|
|
956
1322
|
if (!token || !expiresAt) return false;
|
|
@@ -975,10 +1341,7 @@ var Auth = class {
|
|
|
975
1341
|
* the correction, so a mint-time probe records "clock is fast", and the flag
|
|
976
1342
|
* then waves through the very token the guard exists to refuse. Probing here
|
|
977
1343
|
* — only on a refresh we performed BECAUSE `exp` contradicted a tracked
|
|
978
|
-
* deadline that still claimed life — separates the two cases,
|
|
979
|
-
* the clock has either been corrected (fresh token reads live → stale
|
|
980
|
-
* deadline, guard holds) or is genuinely fast (fresh token still reads dead
|
|
981
|
-
* → release the guard, one probe, no loop).
|
|
1344
|
+
* deadline that still claimed life — separates the two cases: one probe, no loop.
|
|
982
1345
|
*/
|
|
983
1346
|
async refreshAndReconcile(nowMs) {
|
|
984
1347
|
const reconciling = this.deadlinesDisagree(nowMs);
|
|
@@ -986,15 +1349,7 @@ var Auth = class {
|
|
|
986
1349
|
if (reconciling && token) this.learnClockSkewFrom(token, Date.now());
|
|
987
1350
|
return token;
|
|
988
1351
|
}
|
|
989
|
-
/**
|
|
990
|
-
* Record whether a token the server issued MOMENTS AGO already reads as
|
|
991
|
-
* expired. It cannot genuinely be — so a `false` here is proof the device
|
|
992
|
-
* clock runs fast, and releases the `exp` guard for this process.
|
|
993
|
-
*
|
|
994
|
-
* Call this at every site that installs a server-issued token, and only
|
|
995
|
-
* with a token that just arrived. Handing it a cached token would let a
|
|
996
|
-
* stale deadline confirm itself.
|
|
997
|
-
*/
|
|
1352
|
+
/** A token the server issued MOMENTS AGO already reads as expired ⇒ the clock runs fast; release the `exp` guard for this process. */
|
|
998
1353
|
learnClockSkewFrom(freshToken, nowMs) {
|
|
999
1354
|
if (!accessTokenExpIsFuture(freshToken, nowMs)) this.clockSkewConfirmed = true;
|
|
1000
1355
|
}
|
|
@@ -1002,7 +1357,32 @@ var Auth = class {
|
|
|
1002
1357
|
deadlinesDisagree(nowMs) {
|
|
1003
1358
|
return this.trackedDeadlineAlive(nowMs) && !this.canServeCachedToken(nowMs);
|
|
1004
1359
|
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Returns a VALID (non-expired) access token, or null.
|
|
1362
|
+
*
|
|
1363
|
+
* Hard contract (matches sdk-ios / sdk-android, and the industry
|
|
1364
|
+
* token-client pattern — Supabase `_callRefreshToken`, Auth0
|
|
1365
|
+
* `getTokenSilently`, Firebase `getIdToken`): this method NEVER returns a
|
|
1366
|
+
* token past its `exp`. Callers attach the result as a Bearer to arbitrary
|
|
1367
|
+
* resource servers that know nothing of Sendora's refresh grace, so a token
|
|
1368
|
+
* must be either fresh or absent.
|
|
1369
|
+
*
|
|
1370
|
+
* Resolution order:
|
|
1371
|
+
* 0. `withheld` → null, no refresh (ADR-033 [2c]: the refused session is
|
|
1372
|
+
* never served and never refreshed by this process).
|
|
1373
|
+
* 1. Cached token with comfortable life left (>= 30 s before BOTH the
|
|
1374
|
+
* tracked deadline and the token's own `exp`) → return it. Fast path.
|
|
1375
|
+
* `{ forceRefresh: true }` skips it (the caller has out-of-band evidence
|
|
1376
|
+
* the tracked deadline is wrong); it does NOT bypass the single-flight or
|
|
1377
|
+
* the backoff cooldown.
|
|
1378
|
+
* 2. No refresh token in storage → genuinely signed out → null.
|
|
1379
|
+
* 3. Expired/near-expiry but a refresh chain exists: a refresh already
|
|
1380
|
+
* inflight → await it (single-flight, AbortController-bounded, can never
|
|
1381
|
+
* hang the bridge); in backoff cooldown → null (honest "no token now");
|
|
1382
|
+
* else fire the refresh and await it.
|
|
1383
|
+
*/
|
|
1005
1384
|
async getAccessToken(options) {
|
|
1385
|
+
if (this.withheld) return null;
|
|
1006
1386
|
if (!options?.forceRefresh && this.canServeCachedToken(Date.now())) {
|
|
1007
1387
|
return this.accessToken;
|
|
1008
1388
|
}
|
|
@@ -1011,205 +1391,245 @@ var Auth = class {
|
|
|
1011
1391
|
if (Date.now() < this.refreshCooldownUntil) return null;
|
|
1012
1392
|
return this.refreshAndReconcile(Date.now());
|
|
1013
1393
|
}
|
|
1014
|
-
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
1394
|
+
/** Sync read — returns whatever's cached even if expired (`null` in `withheld`). Prefer the async variant. */
|
|
1015
1395
|
getAccessTokenSync() {
|
|
1396
|
+
if (this.withheld) return null;
|
|
1016
1397
|
return this.accessToken;
|
|
1017
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* The currently-stored refresh token, or null when signed out (and in
|
|
1401
|
+
* `withheld`). Kept for integrators that journal the guest token for the
|
|
1402
|
+
* deprecated `merge()`; since 1.41.0 the guest is HELD on the device instead —
|
|
1403
|
+
* read `getHeldGuests()`.
|
|
1404
|
+
*/
|
|
1405
|
+
getRefreshToken() {
|
|
1406
|
+
if (this.withheld) return null;
|
|
1407
|
+
return this.hooks.storage.get(REFRESH_KEY) ?? null;
|
|
1408
|
+
}
|
|
1409
|
+
// ============================================================
|
|
1410
|
+
// MINT (ADR-033 §3.3 rows 6–11)
|
|
1411
|
+
// ============================================================
|
|
1018
1412
|
/**
|
|
1019
1413
|
* Return the current session, minting an anonymous one only when there is
|
|
1020
1414
|
* none.
|
|
1021
1415
|
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
* `
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1416
|
+
* - `anonymous` / `identified` → returned as-is, no network (the 1.29 / 1.40.0
|
|
1417
|
+
* guards as one row). Read `user.isAnonymous` to know which kind you got.
|
|
1418
|
+
* - `signed_out` → with `autoRestoreHeldGuest` on (default) and an eligible
|
|
1419
|
+
* held guest on the device, the NEWEST one is restored instead of minting —
|
|
1420
|
+
* visibly: `signed_in{cause:'restore'}` + `guest_released{reason:'restored'}`.
|
|
1421
|
+
* A dead slot is released (`guest_released{dead}`) and the next tried; none
|
|
1422
|
+
* left → a fresh mint in the same call. Otherwise `POST /anonymous` →
|
|
1423
|
+
* `signed_in{cause:'mint'}`.
|
|
1424
|
+
* - `unknown` → queued until the gap-fill refresh settles the status.
|
|
1425
|
+
* - `withheld` → rejects `AuthError{kind:'config', code:'SESSION_WITHHELD'}`.
|
|
1031
1426
|
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
* `signed_out`, because this path never wipes anything; it persists over the
|
|
1037
|
-
* top. An app that called it on a "looks signed out" reading silently turned
|
|
1038
|
-
* a paying account into a fresh guest, and its own session-death listener
|
|
1039
|
-
* never fired. The account itself survives (row + server session untouched,
|
|
1040
|
-
* recoverable by its credential) but the device is now a stranger to it.
|
|
1041
|
-
* Returning the identified session is a no-op for that caller and the honest
|
|
1042
|
-
* answer to "make sure I have a session" — read `user.isAnonymous` if you
|
|
1043
|
-
* need to know which kind you got.
|
|
1044
|
-
*
|
|
1045
|
-
* Pass `forceNew: true` to deliberately abandon the current session —
|
|
1046
|
-
* whichever kind — and mint a separate anonymous one. That is the ONLY way
|
|
1047
|
-
* this method replaces an identified session, and it does so silently (the
|
|
1048
|
-
* caller asked for it); `signOut()` first if you want a `signed_out`.
|
|
1427
|
+
* `forceNew: true` abandons the current session and mints a separate guest:
|
|
1428
|
+
* from a guest the current guest is HELD (never deleted); from an identified
|
|
1429
|
+
* session it is replaced silently (its refresh token revoked). At the held
|
|
1430
|
+
* capacity (K=5) it throws `HeldGuestLimitError` and mutates nothing.
|
|
1049
1431
|
*/
|
|
1050
1432
|
signInAnonymously(opts) {
|
|
1051
1433
|
return this.serialize(async () => {
|
|
1434
|
+
this.rejectIfWithheld("signInAnonymously");
|
|
1435
|
+
await this.awaitSettled();
|
|
1052
1436
|
const existing = this.user;
|
|
1053
1437
|
if (!opts?.forceNew && existing && this.hooks.storage.get(REFRESH_KEY)) {
|
|
1054
1438
|
return existing;
|
|
1055
1439
|
}
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1440
|
+
const heldCount = this.heldCount();
|
|
1441
|
+
const t = heldCount + (this.status() === "anonymous" ? 1 : 0);
|
|
1442
|
+
if (opts?.forceNew && t >= HELD_GUEST_SLOTS) {
|
|
1443
|
+
throw new HeldGuestLimitError();
|
|
1444
|
+
}
|
|
1445
|
+
if (!opts?.forceNew && this.status() === "signed_out") {
|
|
1446
|
+
const eligible = this.eligibleHeldSlots();
|
|
1447
|
+
if (eligible.length > 0 && (this.hooks.autoRestoreHeldGuest !== false || t >= HELD_GUEST_SLOTS)) {
|
|
1448
|
+
for (const i of eligible) {
|
|
1449
|
+
const restored = await this.restoreSlotForMint(i);
|
|
1450
|
+
if (restored) return restored;
|
|
1451
|
+
}
|
|
1452
|
+
} else if (t >= HELD_GUEST_SLOTS) {
|
|
1453
|
+
throw new HeldGuestLimitError();
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return this.mintFresh({ name: opts?.name, metadata: opts?.metadata, forceNew: opts?.forceNew === true });
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
/** Alias of `signInAnonymously()` — "make sure I have a session". */
|
|
1460
|
+
ensureSession() {
|
|
1461
|
+
return this.signInAnonymously();
|
|
1462
|
+
}
|
|
1463
|
+
/** Row 6 (and row 11's second half): `POST /anonymous` → ProjectGuard on the RESPONSE → install. */
|
|
1464
|
+
async mintFresh(opts) {
|
|
1465
|
+
this.attempt = { kind: "mint", expect: this.snapshotIdentity() };
|
|
1466
|
+
try {
|
|
1467
|
+
await this.awaitRefreshQuiet();
|
|
1468
|
+
const expect = this.snapshotIdentity();
|
|
1469
|
+
this.attempt = { kind: "mint", expect };
|
|
1470
|
+
const mintKey = this.currentMintKey();
|
|
1471
|
+
const body = {};
|
|
1472
|
+
if (opts.name !== void 0) body.name = opts.name;
|
|
1473
|
+
if (opts.metadata !== void 0) body.metadata = opts.metadata;
|
|
1474
|
+
if (mintKey) body.mintKey = mintKey;
|
|
1475
|
+
const data = await this.callAuth("/api/v1/auth-service/anonymous", body);
|
|
1476
|
+
await this.awaitRefreshQuiet();
|
|
1477
|
+
const outcome = this.commit({
|
|
1478
|
+
kind: "install",
|
|
1479
|
+
reason: "mint",
|
|
1480
|
+
record: { user: data.user, accessToken: data.tokens.accessToken, refreshToken: data.tokens.refreshToken, expiresIn: data.tokens.expiresIn },
|
|
1481
|
+
expect,
|
|
1482
|
+
holdPrevious: opts.forceNew,
|
|
1483
|
+
response: data
|
|
1059
1484
|
});
|
|
1060
|
-
|
|
1485
|
+
if (outcome.status === "project_mismatch") {
|
|
1486
|
+
this.revokeFireAndForget(data.tokens.refreshToken);
|
|
1487
|
+
throw withheldError("PROJECT_MISMATCH", "The minted session belongs to a different project than init({expectedProjectId}); nothing was installed");
|
|
1488
|
+
}
|
|
1489
|
+
if (outcome.status === "held_limit") {
|
|
1490
|
+
this.revokeFireAndForget(data.tokens.refreshToken);
|
|
1491
|
+
throw new HeldGuestLimitError();
|
|
1492
|
+
}
|
|
1493
|
+
if (outcome.status !== "applied") {
|
|
1494
|
+
this.revokeFireAndForget(data.tokens.refreshToken);
|
|
1495
|
+
throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the mint was in flight; nothing was installed", { kind: "unknown" });
|
|
1496
|
+
}
|
|
1497
|
+
this.hooks.storage.remove(MINT_KEY_KEY);
|
|
1061
1498
|
return data.user;
|
|
1062
|
-
}
|
|
1499
|
+
} finally {
|
|
1500
|
+
this.attempt = null;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Row 7 — one auto-restore attempt for `signInAnonymously()`. Returns the
|
|
1505
|
+
* restored user, or `null` when the slot was dead and released (the caller
|
|
1506
|
+
* tries the next slot, then mints). A transient failure propagates.
|
|
1507
|
+
*/
|
|
1508
|
+
async restoreSlotForMint(i) {
|
|
1509
|
+
const slot = this.held[i];
|
|
1510
|
+
if (!slot) return null;
|
|
1511
|
+
const expect = this.snapshotIdentity();
|
|
1512
|
+
this.attempt = { kind: "restore", expect };
|
|
1513
|
+
try {
|
|
1514
|
+
const refreshed = await this.refreshHeld(i);
|
|
1515
|
+
if (refreshed.outcome === "dead") return null;
|
|
1516
|
+
if (refreshed.outcome === "kept") {
|
|
1517
|
+
throw new AuthError("RESTORE_FAILED", "The held guest could not be refreshed right now; it is still held", { kind: "server", retryable: true });
|
|
1518
|
+
}
|
|
1519
|
+
return this.installRestored(i, refreshed.data, expect);
|
|
1520
|
+
} finally {
|
|
1521
|
+
this.attempt = null;
|
|
1522
|
+
}
|
|
1063
1523
|
}
|
|
1524
|
+
/** The per-attempt B10 key: reuse the persisted one (a crash-retry) or mint a new one and persist it until MINT_OK. */
|
|
1525
|
+
currentMintKey() {
|
|
1526
|
+
const existing = this.hooks.storage.get(MINT_KEY_KEY);
|
|
1527
|
+
if (existing && existing.length >= 16 && existing.length <= 128) return existing;
|
|
1528
|
+
const fresh = generateMintKey();
|
|
1529
|
+
if (fresh) this.hooks.storage.set(MINT_KEY_KEY, fresh);
|
|
1530
|
+
return fresh;
|
|
1531
|
+
}
|
|
1532
|
+
// ============================================================
|
|
1533
|
+
// SIGN UP (ADR-033 §3.5 upgrade)
|
|
1534
|
+
// ============================================================
|
|
1064
1535
|
/**
|
|
1065
|
-
* Create an email/password account
|
|
1536
|
+
* Create an email/password account — routed by `status()`:
|
|
1066
1537
|
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
* to
|
|
1538
|
+
* - `anonymous` → `POST /me/link/email` on the current Bearer session: the
|
|
1539
|
+
* guest is promoted IN PLACE (same `user.id`), the rotated
|
|
1540
|
+
* pair is installed, `signed_in{cause:'link'}` fires. A
|
|
1541
|
+
* taken email throws `EmailAlreadyTakenError` and changes
|
|
1542
|
+
* nothing.
|
|
1543
|
+
* - `signed_out` → `POST /signup`, a fresh account.
|
|
1544
|
+
* - `identified` → `AlreadyIdentifiedError`, decided LOCALLY (no round-trip,
|
|
1545
|
+
* nothing minted — use `linkEmailPassword()` instead).
|
|
1546
|
+
* - `unknown` → waits for the status to settle, then routes as above.
|
|
1547
|
+
* - `withheld` → rejects `SESSION_WITHHELD`.
|
|
1076
1548
|
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
* fast-refresh races. The refresh token in storage is the
|
|
1080
|
-
* authoritative signal — if it's there, the upgrade path is
|
|
1081
|
-
* worth attempting; backend has the source of truth.
|
|
1549
|
+
* Never calls `/upgrade` (ADR-033 INV-4): a generic 401 there used to wipe
|
|
1550
|
+
* the guest, and the "dead token → wipe → fresh signup" branch is gone.
|
|
1082
1551
|
*/
|
|
1083
1552
|
signUp(email, password, opts) {
|
|
1084
1553
|
return this.serialize(async () => {
|
|
1085
|
-
|
|
1086
|
-
|
|
1554
|
+
this.rejectIfWithheld("signUp");
|
|
1555
|
+
await this.awaitSettled();
|
|
1556
|
+
const status = this.status();
|
|
1557
|
+
if (status === "identified") throw new AlreadyIdentifiedError();
|
|
1558
|
+
if (status === "anonymous") {
|
|
1559
|
+
const body = { email, password };
|
|
1560
|
+
if (opts?.name !== void 0) body.name = opts.name;
|
|
1561
|
+
const result = await this.doLink(LINK_EMAIL_PATH, body, { legacyEmit: true, label: "password" });
|
|
1562
|
+
if (result.status === "credential_in_use") throw new EmailAlreadyTakenError();
|
|
1563
|
+
if (result.status === "recent_auth_required") {
|
|
1564
|
+
throw new AuthError("RECENT_AUTH_REQUIRED", "Re-authenticate before adding a password", { status: 403 });
|
|
1565
|
+
}
|
|
1566
|
+
return result.user;
|
|
1567
|
+
}
|
|
1568
|
+
const expect = this.snapshotIdentity();
|
|
1569
|
+
this.attempt = { kind: "sign_in", expect };
|
|
1570
|
+
try {
|
|
1571
|
+
const body = { email, password };
|
|
1572
|
+
if (opts?.name !== void 0) body.name = opts.name;
|
|
1573
|
+
if (opts?.metadata !== void 0) body.metadata = opts.metadata;
|
|
1574
|
+
let data;
|
|
1087
1575
|
try {
|
|
1088
|
-
|
|
1089
|
-
refreshToken: refresh,
|
|
1090
|
-
email,
|
|
1091
|
-
password,
|
|
1092
|
-
name: opts?.name
|
|
1093
|
-
});
|
|
1094
|
-
this.persist(data);
|
|
1095
|
-
return data.user;
|
|
1576
|
+
data = await this.callAuth("/api/v1/auth-service/signup", body);
|
|
1096
1577
|
} catch (err) {
|
|
1097
|
-
if (err instanceof AuthError && (err.code === "NOT_ANONYMOUS" || err.code === "FORBIDDEN_NON_ANONYMOUS")) {
|
|
1098
|
-
throw new AlreadyIdentifiedError(err.message);
|
|
1099
|
-
}
|
|
1100
1578
|
if (err instanceof AuthError && (err.code === "CONFLICT" || err.code === "EMAIL_ALREADY_TAKEN")) {
|
|
1101
1579
|
throw new EmailAlreadyTakenError(err.message);
|
|
1102
1580
|
}
|
|
1103
|
-
|
|
1104
|
-
await this.wipeLocalIdentity("session_expired");
|
|
1105
|
-
} else {
|
|
1106
|
-
throw err;
|
|
1107
|
-
}
|
|
1581
|
+
throw err;
|
|
1108
1582
|
}
|
|
1109
|
-
|
|
1110
|
-
try {
|
|
1111
|
-
const data = await this.callAuth("/api/v1/auth-service/signup", {
|
|
1112
|
-
email,
|
|
1113
|
-
password,
|
|
1114
|
-
name: opts?.name,
|
|
1115
|
-
metadata: opts?.metadata
|
|
1116
|
-
});
|
|
1117
|
-
this.persist(data);
|
|
1583
|
+
this.installSignIn(data, expect);
|
|
1118
1584
|
return data.user;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
throw new EmailAlreadyTakenError(err.message);
|
|
1122
|
-
}
|
|
1123
|
-
throw err;
|
|
1585
|
+
} finally {
|
|
1586
|
+
this.attempt = null;
|
|
1124
1587
|
}
|
|
1125
1588
|
});
|
|
1126
1589
|
}
|
|
1590
|
+
// ============================================================
|
|
1591
|
+
// SIGN-IN (ADR-033 §3.3 rows 17–19: SIGN_IN = switch) — legacy signatures
|
|
1592
|
+
// ============================================================
|
|
1127
1593
|
/**
|
|
1128
1594
|
* Sign in with email + password. When the user has confirmed MFA,
|
|
1129
1595
|
* returns `{mfaRequired, mfaChallengeToken}` instead of an
|
|
1130
1596
|
* `AuthUser`; caller must follow up with `challengeMfa()`.
|
|
1597
|
+
*
|
|
1598
|
+
* A sign-in from a guest HOLDS the guest on this device (`guest_held`, then
|
|
1599
|
+
* ONE `signed_in{cause:'sign_in'}`); the guest's server row survives. The
|
|
1600
|
+
* request never carries the guest's refresh token (ADR-033 INV-3).
|
|
1131
1601
|
*/
|
|
1132
1602
|
signIn(email, password) {
|
|
1133
1603
|
return this.serialize(async () => {
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
);
|
|
1140
|
-
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
1141
|
-
let parsed;
|
|
1142
|
-
try {
|
|
1143
|
-
parsed = await res.json();
|
|
1144
|
-
} catch {
|
|
1145
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1146
|
-
}
|
|
1147
|
-
if (!res.ok || !parsed.success || !parsed.data) {
|
|
1148
|
-
throw new AuthError(
|
|
1149
|
-
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1150
|
-
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1151
|
-
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1152
|
-
);
|
|
1153
|
-
}
|
|
1154
|
-
const r = parsed.data;
|
|
1155
|
-
if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
|
|
1156
|
-
if (prevAnonRefreshToken) {
|
|
1157
|
-
this.pendingAnonTakeover = { challengeToken: r.mfaChallengeToken, prevAnonRefreshToken };
|
|
1158
|
-
}
|
|
1159
|
-
const u = r.user;
|
|
1160
|
-
return { mfaRequired: true, mfaChallengeToken: r.mfaChallengeToken, userId: u?.id ?? "" };
|
|
1161
|
-
}
|
|
1162
|
-
if (!isAuthApiResponse(parsed.data)) {
|
|
1163
|
-
throw new AuthError(parsed.error?.code ?? "AUTH_ERROR", parsed.error?.message ?? "Login failed");
|
|
1164
|
-
}
|
|
1165
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1166
|
-
this.persist(parsed.data);
|
|
1167
|
-
return parsed.data.user;
|
|
1604
|
+
this.rejectIfWithheld("signIn");
|
|
1605
|
+
await this.awaitSettled();
|
|
1606
|
+
const r = await this.doSignIn({ kind: "password", email, password });
|
|
1607
|
+
if (r.kind === "mfa") return { mfaRequired: true, mfaChallengeToken: r.challengeToken, userId: r.userId };
|
|
1608
|
+
return r.user;
|
|
1168
1609
|
});
|
|
1169
1610
|
}
|
|
1170
1611
|
/**
|
|
1171
1612
|
* Verify an IdP-issued credential (authorization code OR id_token)
|
|
1172
1613
|
* and mint a Sendora session. Customer's app handles the IdP
|
|
1173
1614
|
* dance — typically via expo-auth-session / react-native-app-auth
|
|
1174
|
-
* — then hands the result here.
|
|
1175
|
-
*
|
|
1615
|
+
* — then hands the result here. Mirrors the web SDK's surface 1:1.
|
|
1616
|
+
*
|
|
1617
|
+
* `link: true` (ADR-033 INV-14) — attach-or-error, never a silent switch:
|
|
1618
|
+
* from a guest the account is promoted IN PLACE (same `user.id`,
|
|
1619
|
+
* `signed_in{cause:'link'}`); over an identified session the identity is
|
|
1620
|
+
* attached (`user_updated{cause:'link'}`), an identity already on the account
|
|
1621
|
+
* resolves quietly, one owned elsewhere throws `CredentialInUseError`
|
|
1622
|
+
* (or switches when `onCredentialInUse: "adopt"`), a stale session throws
|
|
1623
|
+
* `RECENT_AUTH_REQUIRED`. In `signed_out` it is a plain sign-in.
|
|
1176
1624
|
*/
|
|
1177
1625
|
loginSocial(input) {
|
|
1626
|
+
const { link, onCredentialInUse, ...socialInput } = input;
|
|
1627
|
+
const credential = { ...socialInput, kind: input.provider };
|
|
1178
1628
|
return this.serialize(async () => {
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
if (link) payload.linkAnonymous = true;
|
|
1184
|
-
if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
|
|
1185
|
-
const res = await post(
|
|
1186
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1187
|
-
"/api/v1/auth-service/login/social",
|
|
1188
|
-
payload
|
|
1189
|
-
);
|
|
1190
|
-
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
1191
|
-
let parsed;
|
|
1192
|
-
try {
|
|
1193
|
-
parsed = await res.json();
|
|
1194
|
-
} catch {
|
|
1195
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1196
|
-
}
|
|
1197
|
-
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1198
|
-
if (parsed.error?.code === "CREDENTIAL_IN_USE") {
|
|
1199
|
-
throw new CredentialInUseError(parsed.error.message, {
|
|
1200
|
-
...readCollisionDetails(parsed),
|
|
1201
|
-
status: res.status
|
|
1202
|
-
});
|
|
1203
|
-
}
|
|
1204
|
-
throw new AuthError(
|
|
1205
|
-
parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
|
|
1206
|
-
parsed.error?.message ?? "Social login failed",
|
|
1207
|
-
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1208
|
-
);
|
|
1209
|
-
}
|
|
1210
|
-
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1211
|
-
this.persist(parsed.data);
|
|
1212
|
-
return parsed.data.user;
|
|
1629
|
+
this.rejectIfWithheld("loginSocial");
|
|
1630
|
+
await this.awaitSettled();
|
|
1631
|
+
if (link && this.hasSession()) return this.legacyLink(credential, onCredentialInUse);
|
|
1632
|
+
return (await this.doSignInDone(credential)).user;
|
|
1213
1633
|
});
|
|
1214
1634
|
}
|
|
1215
1635
|
signInWithGoogle(code, redirectUri, link) {
|
|
@@ -1234,57 +1654,189 @@ var Auth = class {
|
|
|
1234
1654
|
return this.loginSocial({ provider: "discord", code, redirectUri });
|
|
1235
1655
|
}
|
|
1236
1656
|
signInWithGameCenter(payloadOrOpts, maybeOpts) {
|
|
1237
|
-
const path = "/api/v1/auth-service/login/game-center";
|
|
1238
1657
|
if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
|
|
1239
|
-
|
|
1658
|
+
const payload = payloadOrOpts;
|
|
1659
|
+
return this.gameIdentitySignIn(async () => ({ kind: "gameCenter", ...payload }), maybeOpts);
|
|
1240
1660
|
}
|
|
1241
1661
|
return this.gameIdentitySignIn(
|
|
1242
|
-
|
|
1243
|
-
async () => await fetchGameCenterPayloadNative(),
|
|
1662
|
+
async () => ({ kind: "gameCenter", ...await fetchGameCenterPayloadNative() }),
|
|
1244
1663
|
payloadOrOpts
|
|
1245
1664
|
);
|
|
1246
1665
|
}
|
|
1247
1666
|
signInWithPlayGames(payloadOrOpts, maybeOpts) {
|
|
1248
|
-
const path = "/api/v1/auth-service/login/play-games";
|
|
1249
1667
|
if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
|
|
1250
1668
|
const { serverAuthCode } = payloadOrOpts;
|
|
1251
|
-
return this.gameIdentitySignIn(
|
|
1669
|
+
return this.gameIdentitySignIn(async () => ({ kind: "playGames", serverAuthCode }), maybeOpts);
|
|
1252
1670
|
}
|
|
1253
1671
|
return this.gameIdentitySignIn(
|
|
1254
|
-
|
|
1255
|
-
async () => await fetchPlayGamesAuthCodeNative(),
|
|
1672
|
+
async () => ({ kind: "playGames", ...await fetchPlayGamesAuthCodeNative() }),
|
|
1256
1673
|
payloadOrOpts
|
|
1257
1674
|
);
|
|
1258
1675
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
/** Link email + password to the current account (sub preserved). */
|
|
1274
|
-
linkEmailPassword(email, password) {
|
|
1275
|
-
return this.linkCredential("/api/v1/auth-service/me/link/email", { email, password });
|
|
1276
|
-
}
|
|
1277
|
-
/** Link an OAuth social identity (verified idToken or code) to the current account. */
|
|
1278
|
-
linkSocial(input) {
|
|
1279
|
-
return this.linkCredential("/api/v1/auth-service/me/link/social", input);
|
|
1280
|
-
}
|
|
1281
|
-
/** Convenience: link a Google identity (native idToken, or web code+redirectUri). */
|
|
1282
|
-
linkGoogle(input) {
|
|
1283
|
-
return this.linkSocial({ provider: "google", ...input });
|
|
1676
|
+
/**
|
|
1677
|
+
* Shared gaming-identity sign-in. Resolves the credential FIRST (for the
|
|
1678
|
+
* no-arg native path this triggers the Game Center / Play Games sheet inside
|
|
1679
|
+
* the serialized op — a cancelled sheet leaves the session untouched), then
|
|
1680
|
+
* routes by `link` and `status()` exactly like `loginSocial`.
|
|
1681
|
+
*/
|
|
1682
|
+
gameIdentitySignIn(credentialOf, opts) {
|
|
1683
|
+
return this.serialize(async () => {
|
|
1684
|
+
this.rejectIfWithheld("signInWithGameCenter/PlayGames");
|
|
1685
|
+
await this.awaitSettled();
|
|
1686
|
+
const credential = await credentialOf();
|
|
1687
|
+
if (opts?.link && this.hasSession()) return this.legacyLink(credential, opts.onCredentialInUse);
|
|
1688
|
+
return (await this.doSignInDone(credential)).user;
|
|
1689
|
+
});
|
|
1284
1690
|
}
|
|
1285
|
-
/**
|
|
1286
|
-
|
|
1287
|
-
|
|
1691
|
+
/**
|
|
1692
|
+
* The legacy-signature LINK (`signInWith*({link:true})`, `loginSocial({link:true})`)
|
|
1693
|
+
* in `anonymous` / `identified` (ADR-033 rows 15–16, §8-Q18):
|
|
1694
|
+
* - a promotion / attach resolves with the user (`signed_in{cause:'link'}` on
|
|
1695
|
+
* the same-`sub` promotion — preserved from 1.30–1.40; `user_updated` on an
|
|
1696
|
+
* identified attach; NOTHING on `alreadyLinked`);
|
|
1697
|
+
* - 409 → `CredentialInUseError`, or with `onCredentialInUse: "adopt"` the
|
|
1698
|
+
* explicit switch to the owning account (the guest is HELD);
|
|
1699
|
+
* - 403 → `AuthError` `RECENT_AUTH_REQUIRED` (kind `recent_auth_required`).
|
|
1700
|
+
*/
|
|
1701
|
+
async legacyLink(credential, policy) {
|
|
1702
|
+
const wire = linkWire(credential);
|
|
1703
|
+
if (!wire) return (await this.doSignInDone(credential)).user;
|
|
1704
|
+
const r = await this.doLink(wire.path, wire.body, { legacyEmit: true, label: providerLabel(credential) });
|
|
1705
|
+
if (r.status === "done") return r.user;
|
|
1706
|
+
if (r.status === "credential_in_use") {
|
|
1707
|
+
if (policy === "adopt") return (await this.doSignInDone(credential)).user;
|
|
1708
|
+
throw new CredentialInUseError(r.message, { provider: r.provider, collision: r.collision, status: r.httpStatus });
|
|
1709
|
+
}
|
|
1710
|
+
throw new AuthError("RECENT_AUTH_REQUIRED", r.message, { status: 403 });
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Exchange the MFA challenge token from `signIn` for a real session by
|
|
1714
|
+
* submitting a TOTP or recovery code. A wrong code throws and preserves the
|
|
1715
|
+
* current session for a retry; success holds the guest (if any) and installs
|
|
1716
|
+
* the account exactly like a direct sign-in.
|
|
1717
|
+
*/
|
|
1718
|
+
challengeMfa(mfaChallengeToken, code) {
|
|
1719
|
+
return this.serialize(async () => {
|
|
1720
|
+
this.rejectIfWithheld("challengeMfa");
|
|
1721
|
+
await this.awaitSettled();
|
|
1722
|
+
return (await this.doSignInDone({ kind: "mfa", challengeToken: mfaChallengeToken, code })).user;
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
// ============================================================
|
|
1726
|
+
// TYPED VERBS (ADR-033 §3.5) — signInWith / link / switchAccount
|
|
1727
|
+
// ============================================================
|
|
1728
|
+
/**
|
|
1729
|
+
* Plain sign-in / SWITCH with a typed credential. Never links: from a guest
|
|
1730
|
+
* the guest is HELD (`previous.held === true`); from an identified account
|
|
1731
|
+
* the previous session is replaced and its refresh token revoked
|
|
1732
|
+
* (`previous.isAnonymous === false` ⇒ skip `mergeAnon`, reset local save
|
|
1733
|
+
* state). The request never carries a guest credential (INV-3). MFA returns
|
|
1734
|
+
* `{status:'mfa_required', verify(code)}`.
|
|
1735
|
+
*/
|
|
1736
|
+
signInWith(credential) {
|
|
1737
|
+
return this.serialize(async () => {
|
|
1738
|
+
this.rejectIfWithheld("signInWith");
|
|
1739
|
+
await this.awaitSettled();
|
|
1740
|
+
return this.toSignInResult(await this.doSignIn(credential));
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Attach a credential to the CURRENT session — the ONLY upgrade (INV-4).
|
|
1745
|
+
* From a guest: promoted in place (same `user.id`, `upgraded: true`,
|
|
1746
|
+
* `user_updated{cause:'link'}`). From an identified account: attached
|
|
1747
|
+
* (`user_updated`), or `alreadyLinked: true` with no event. Owned elsewhere →
|
|
1748
|
+
* `{status:'credential_in_use', switchAccount(), keepCurrent()}` with nothing
|
|
1749
|
+
* mutated; stale session → `{status:'recent_auth_required'}`. Throws
|
|
1750
|
+
* `NOT_SIGNED_IN` in `signed_out` (nothing to attach to) and
|
|
1751
|
+
* `UNSUPPORTED_LINK_CREDENTIAL` for a kind that cannot be attached
|
|
1752
|
+
* (`magicLink`, `emailOtp`, `passkey`, `mfa`).
|
|
1753
|
+
*/
|
|
1754
|
+
link(credential) {
|
|
1755
|
+
return this.serialize(async () => {
|
|
1756
|
+
this.rejectIfWithheld("link");
|
|
1757
|
+
await this.awaitSettled();
|
|
1758
|
+
const wire = linkWire(credential);
|
|
1759
|
+
if (!wire) {
|
|
1760
|
+
throw new AuthError("UNSUPPORTED_LINK_CREDENTIAL", `A ${credential.kind} credential cannot be attached to an account; sign in with it instead`, { kind: "config" });
|
|
1761
|
+
}
|
|
1762
|
+
if (!this.hasSession()) throw new AuthError("NOT_SIGNED_IN", "Sign in before linking a credential");
|
|
1763
|
+
const r = await this.doLink(wire.path, wire.body, { legacyEmit: false, label: providerLabel(credential) });
|
|
1764
|
+
if (r.status === "done") return { status: "done", user: r.user, upgraded: r.upgraded, alreadyLinked: r.alreadyLinked };
|
|
1765
|
+
if (r.status === "recent_auth_required") return { status: "recent_auth_required" };
|
|
1766
|
+
const refusedAt = Date.now();
|
|
1767
|
+
return {
|
|
1768
|
+
status: "credential_in_use",
|
|
1769
|
+
provider: r.provider,
|
|
1770
|
+
collision: r.collision,
|
|
1771
|
+
switchAccount: () => this.switchAccount(credential, refusedAt),
|
|
1772
|
+
keepCurrent: () => void 0
|
|
1773
|
+
};
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* The ONLY path from a refused link to a sign-in, BOUND to the refused
|
|
1778
|
+
* credential (INV-14): the same payload is reused while under 5 minutes old;
|
|
1779
|
+
* older than that, a Game Center / Play Games credential is re-fetched
|
|
1780
|
+
* natively and the Game Center `teamPlayerId` compared — a different player
|
|
1781
|
+
* aborts with `CREDENTIAL_CHANGED` and nothing is mutated. (A Play Games
|
|
1782
|
+
* server auth code carries no player id, so a re-fetched code is used as-is.)
|
|
1783
|
+
*/
|
|
1784
|
+
switchAccount(credential, refusedAt) {
|
|
1785
|
+
return this.serialize(async () => {
|
|
1786
|
+
this.rejectIfWithheld("switchAccount");
|
|
1787
|
+
await this.awaitSettled();
|
|
1788
|
+
let bound = credential;
|
|
1789
|
+
if (Date.now() - refusedAt > SWITCH_CREDENTIAL_MAX_AGE_MS) {
|
|
1790
|
+
if (credential.kind === "gameCenter") {
|
|
1791
|
+
const fresh = await fetchGameCenterPayloadNative();
|
|
1792
|
+
if (fresh.teamPlayerId !== credential.teamPlayerId) {
|
|
1793
|
+
throw new AuthError("CREDENTIAL_CHANGED", "The Game Center player on this device is no longer the one whose link was refused", { kind: "invalid_credential" });
|
|
1794
|
+
}
|
|
1795
|
+
bound = { kind: "gameCenter", ...fresh };
|
|
1796
|
+
} else if (credential.kind === "playGames") {
|
|
1797
|
+
bound = { kind: "playGames", ...await fetchPlayGamesAuthCodeNative() };
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
return this.toSignInResult(await this.doSignIn(bound));
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
toSignInResult(r) {
|
|
1804
|
+
if (r.kind === "mfa") {
|
|
1805
|
+
const challengeToken = r.challengeToken;
|
|
1806
|
+
return {
|
|
1807
|
+
status: "mfa_required",
|
|
1808
|
+
challengeToken,
|
|
1809
|
+
verify: (code) => this.signInWith({ kind: "mfa", challengeToken, code })
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
return { status: "done", user: r.user, previous: r.previous, reactivated: r.reactivated };
|
|
1813
|
+
}
|
|
1814
|
+
// ============================================================
|
|
1815
|
+
// IDENTITY LINKING (ADR-030) — legacy link*() verbs
|
|
1816
|
+
// ============================================================
|
|
1817
|
+
//
|
|
1818
|
+
// Attach a SECOND credential to the CURRENT account, preserving the `sub`.
|
|
1819
|
+
// From an identified account: no token rotation; the cached user is refreshed
|
|
1820
|
+
// in place (`user_updated{cause:'link'}`). From a guest (1.30.0 / ADR-033
|
|
1821
|
+
// INV-4): the server promotes the guest in place and ROTATES the session; the
|
|
1822
|
+
// returned pair is installed (the old refresh token is revoked server-side).
|
|
1823
|
+
// Collision → `CredentialInUseError` (never merges). Require a session
|
|
1824
|
+
// (throw NOT_SIGNED_IN otherwise).
|
|
1825
|
+
/** Link email + password to the current account (sub preserved). */
|
|
1826
|
+
linkEmailPassword(email, password) {
|
|
1827
|
+
return this.linkCredential(LINK_EMAIL_PATH, { email, password }, "password");
|
|
1828
|
+
}
|
|
1829
|
+
/** Link an OAuth social identity (verified idToken or code) to the current account. */
|
|
1830
|
+
linkSocial(input) {
|
|
1831
|
+
return this.linkCredential(LINK_SOCIAL_PATH, stripUndefined(input), input.provider);
|
|
1832
|
+
}
|
|
1833
|
+
/** Convenience: link a Google identity (native idToken, or web code+redirectUri). */
|
|
1834
|
+
linkGoogle(input) {
|
|
1835
|
+
return this.linkSocial({ provider: "google", ...input });
|
|
1836
|
+
}
|
|
1837
|
+
/** Convenience: link an Apple identity (native idToken, or web code+redirectUri). */
|
|
1838
|
+
linkApple(input) {
|
|
1839
|
+
return this.linkSocial({ provider: "apple", ...input });
|
|
1288
1840
|
}
|
|
1289
1841
|
/**
|
|
1290
1842
|
* Link an Apple Game Center identity to the current account. No-arg drives the
|
|
@@ -1293,7 +1845,7 @@ var Auth = class {
|
|
|
1293
1845
|
*/
|
|
1294
1846
|
async linkGameCenter(payload) {
|
|
1295
1847
|
const body = payload ?? await fetchGameCenterPayloadNative();
|
|
1296
|
-
return this.linkCredential(
|
|
1848
|
+
return this.linkCredential(LINK_GAME_CENTER_PATH, { ...body }, "gamecenter");
|
|
1297
1849
|
}
|
|
1298
1850
|
/**
|
|
1299
1851
|
* Link a Google Play Games identity to the current account. No-arg drives the
|
|
@@ -1301,74 +1853,41 @@ var Auth = class {
|
|
|
1301
1853
|
*/
|
|
1302
1854
|
async linkPlayGames(payload) {
|
|
1303
1855
|
const body = payload ?? await fetchPlayGamesAuthCodeNative();
|
|
1304
|
-
return this.linkCredential(
|
|
1856
|
+
return this.linkCredential(LINK_PLAY_GAMES_PATH, { ...body }, "playgames");
|
|
1305
1857
|
}
|
|
1306
1858
|
/**
|
|
1307
|
-
* Shared link executor
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
* deadlock — same non-serialized posture as deleteAccount().
|
|
1859
|
+
* Shared `link*()` executor — the LINK row with the `link*()` emission
|
|
1860
|
+
* (`user_updated{cause:'link'}`, never `signed_in`). A 409 throws
|
|
1861
|
+
* `CredentialInUseError`; a 403 throws `RECENT_AUTH_REQUIRED`; no session →
|
|
1862
|
+
* `NOT_SIGNED_IN` with no network call.
|
|
1312
1863
|
*/
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
path,
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
1323
|
-
let parsed;
|
|
1324
|
-
try {
|
|
1325
|
-
parsed = await res.json();
|
|
1326
|
-
} catch {
|
|
1327
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1328
|
-
}
|
|
1329
|
-
if (!res.ok || !parsed.success || !parsed.data) {
|
|
1330
|
-
const code = parsed.error?.code ?? `HTTP_${res.status}`;
|
|
1331
|
-
if (code === "CREDENTIAL_IN_USE") {
|
|
1332
|
-
throw new CredentialInUseError(parsed.error?.message, {
|
|
1333
|
-
...readCollisionDetails(parsed),
|
|
1334
|
-
status: res.status
|
|
1335
|
-
});
|
|
1864
|
+
linkCredential(path, body, label) {
|
|
1865
|
+
return this.serialize(async () => {
|
|
1866
|
+
this.rejectIfWithheld("link");
|
|
1867
|
+
await this.awaitSettled();
|
|
1868
|
+
if (!this.hasSession()) throw new AuthError("NOT_SIGNED_IN", "Sign in before linking a credential");
|
|
1869
|
+
const r = await this.doLink(path, body, { legacyEmit: false, label });
|
|
1870
|
+
if (r.status === "done") return r.user;
|
|
1871
|
+
if (r.status === "credential_in_use") {
|
|
1872
|
+
throw new CredentialInUseError(r.message, { provider: r.provider, collision: r.collision, status: r.httpStatus });
|
|
1336
1873
|
}
|
|
1337
|
-
throw new AuthError(
|
|
1338
|
-
|
|
1339
|
-
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
1340
|
-
});
|
|
1341
|
-
}
|
|
1342
|
-
if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
|
|
1343
|
-
this.persist(parsed.data);
|
|
1344
|
-
return parsed.data.user;
|
|
1345
|
-
}
|
|
1346
|
-
if (parsed.data.user) this.updateLocalUser(parsed.data.user);
|
|
1347
|
-
return parsed.data.user ?? this.user;
|
|
1348
|
-
}
|
|
1349
|
-
/**
|
|
1350
|
-
* Refresh the cached user after a link — the sub is unchanged, so only the
|
|
1351
|
-
* user object + its storage copy are updated (no token/identity rotation).
|
|
1352
|
-
*/
|
|
1353
|
-
updateLocalUser(user) {
|
|
1354
|
-
this.user = user;
|
|
1355
|
-
this.hooks.storage.set(USER_KEY, JSON.stringify(user));
|
|
1874
|
+
throw new AuthError("RECENT_AUTH_REQUIRED", r.message, { status: 403 });
|
|
1875
|
+
});
|
|
1356
1876
|
}
|
|
1357
1877
|
/**
|
|
1358
1878
|
* List the auth methods/identities linked to the current account (ADR-030
|
|
1359
|
-
* read side, s58.270)
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1364
|
-
* `lastLoginMethod`). Resolves a FRESH access token first (mirrors
|
|
1365
|
-
* `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
|
|
1879
|
+
* read side, s58.270): every social/gaming identity plus a `hasPassword`
|
|
1880
|
+
* flag — the cross-device / reinstall-durable source of truth for a
|
|
1881
|
+
* "Connected: Game Center · Google" UI. Bearer-gated network read that
|
|
1882
|
+
* resolves a FRESH access token first. Firebase `user.providerData` /
|
|
1883
|
+
* Supabase `user.identities` parity.
|
|
1366
1884
|
*/
|
|
1367
1885
|
async listLinkedIdentities() {
|
|
1886
|
+
this.rejectIfWithheld("listLinkedIdentities");
|
|
1368
1887
|
const token = await this.getAccessToken();
|
|
1369
1888
|
if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before listing linked identities");
|
|
1370
1889
|
const res = await getJson(
|
|
1371
|
-
|
|
1890
|
+
this.http(),
|
|
1372
1891
|
"/api/v1/auth-service/me/identities",
|
|
1373
1892
|
{ Authorization: `Bearer ${token}` }
|
|
1374
1893
|
);
|
|
@@ -1385,32 +1904,23 @@ var Auth = class {
|
|
|
1385
1904
|
return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
|
|
1386
1905
|
}
|
|
1387
1906
|
/**
|
|
1388
|
-
* Remove one linked provider from the signed-in account
|
|
1389
|
-
*
|
|
1390
|
-
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1391
|
-
* next to each entry on a Connected Accounts screen calls.
|
|
1907
|
+
* Remove one linked provider from the signed-in account — the write half of
|
|
1908
|
+
* `listLinkedIdentities()`.
|
|
1392
1909
|
*
|
|
1393
1910
|
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1394
|
-
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
1397
|
-
* lockout, not an inconvenience. A password counts as a credential.
|
|
1398
|
-
*
|
|
1399
|
-
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1400
|
-
* `listLinkedIdentities()` and disable Disconnect when
|
|
1401
|
-
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1402
|
-
* backstop, not the UX.
|
|
1911
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"` — a
|
|
1912
|
+
* permanent lockout, not an inconvenience. Prefer preventing the tap: disable
|
|
1913
|
+
* Disconnect when `identities.length + (hasPassword ? 1 : 0) <= 1`.
|
|
1403
1914
|
*
|
|
1404
1915
|
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1405
|
-
* provider is not linked
|
|
1406
|
-
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1407
|
-
* screen report "disconnected" for a credential that is still attached.
|
|
1916
|
+
* provider is not linked (an absent provider is NOT reported as removed).
|
|
1408
1917
|
*/
|
|
1409
1918
|
async unlink(provider) {
|
|
1919
|
+
this.rejectIfWithheld("unlink");
|
|
1410
1920
|
const token = await this.getAccessToken();
|
|
1411
1921
|
if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before unlinking an identity");
|
|
1412
1922
|
const res = await del(
|
|
1413
|
-
|
|
1923
|
+
this.http(),
|
|
1414
1924
|
`/api/v1/auth-service/me/identities/${encodeURIComponent(provider)}`,
|
|
1415
1925
|
{ Authorization: `Bearer ${token}` }
|
|
1416
1926
|
);
|
|
@@ -1434,129 +1944,35 @@ var Auth = class {
|
|
|
1434
1944
|
remaining: Number(parsed.data.remaining ?? 0)
|
|
1435
1945
|
};
|
|
1436
1946
|
}
|
|
1437
|
-
/**
|
|
1438
|
-
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
1439
|
-
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
1440
|
-
* identity, forwards it as `prevAnonRefreshToken`, and opts into
|
|
1441
|
-
* `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
|
|
1442
|
-
* centrally from `retiredAnonUserId`.
|
|
1443
|
-
*/
|
|
1444
|
-
gameIdentitySignIn(path, identityBody, opts) {
|
|
1445
|
-
const link = opts?.link;
|
|
1446
|
-
const onCredentialInUse = opts?.onCredentialInUse;
|
|
1447
|
-
return this.serialize(async () => {
|
|
1448
|
-
const body = typeof identityBody === "function" ? await identityBody() : identityBody;
|
|
1449
|
-
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1450
|
-
const payload = { ...body };
|
|
1451
|
-
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1452
|
-
if (link) payload.linkAnonymous = true;
|
|
1453
|
-
if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
|
|
1454
|
-
const res = await post(
|
|
1455
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1456
|
-
path,
|
|
1457
|
-
payload
|
|
1458
|
-
);
|
|
1459
|
-
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
1460
|
-
let parsed;
|
|
1461
|
-
try {
|
|
1462
|
-
parsed = await res.json();
|
|
1463
|
-
} catch {
|
|
1464
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1465
|
-
}
|
|
1466
|
-
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1467
|
-
if (parsed.error?.code === "CREDENTIAL_IN_USE") {
|
|
1468
|
-
throw new CredentialInUseError(parsed.error.message, {
|
|
1469
|
-
...readCollisionDetails(parsed),
|
|
1470
|
-
status: res.status
|
|
1471
|
-
});
|
|
1472
|
-
}
|
|
1473
|
-
throw new AuthError(
|
|
1474
|
-
parsed.error?.code ?? "GAME_LOGIN_FAILED",
|
|
1475
|
-
parsed.error?.message ?? "Game sign-in failed",
|
|
1476
|
-
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1477
|
-
);
|
|
1478
|
-
}
|
|
1479
|
-
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1480
|
-
this.persist(parsed.data);
|
|
1481
|
-
return parsed.data.user;
|
|
1482
|
-
});
|
|
1483
|
-
}
|
|
1484
|
-
/**
|
|
1485
|
-
* Read the stored anon refresh token if (and only if) the local
|
|
1486
|
-
* subject is currently anonymous. Used by every identified-session
|
|
1487
|
-
* mint path so the backend can run device-takeover (s58.111 +
|
|
1488
|
-
* s58.112). Returns `undefined` when not anonymous — never forward
|
|
1489
|
-
* an identified user's own refresh token.
|
|
1490
|
-
*/
|
|
1491
|
-
readPrevAnonRefreshToken() {
|
|
1492
|
-
if (!this.user?.isAnonymous) return void 0;
|
|
1493
|
-
return this.hooks.storage.get(REFRESH_KEY) ?? void 0;
|
|
1494
|
-
}
|
|
1495
|
-
/**
|
|
1496
|
-
* Exchange the MFA challenge token from `signIn` for a real session
|
|
1497
|
-
* by submitting a TOTP or recovery code from the user's authenticator.
|
|
1498
|
-
*/
|
|
1499
|
-
challengeMfa(mfaChallengeToken, code) {
|
|
1500
|
-
return this.serialize(async () => {
|
|
1501
|
-
const stashed = this.pendingAnonTakeover?.challengeToken === mfaChallengeToken ? this.pendingAnonTakeover.prevAnonRefreshToken : void 0;
|
|
1502
|
-
const prevAnonRefreshToken = stashed ?? this.readPrevAnonRefreshToken();
|
|
1503
|
-
const body = {
|
|
1504
|
-
challengeToken: mfaChallengeToken,
|
|
1505
|
-
code
|
|
1506
|
-
};
|
|
1507
|
-
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1508
|
-
const data = await this.callAuth("/api/v1/auth-service/mfa/challenge", body);
|
|
1509
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1510
|
-
this.persist(data);
|
|
1511
|
-
return data.user;
|
|
1512
|
-
});
|
|
1513
|
-
}
|
|
1514
1947
|
// ============================================================
|
|
1515
|
-
// MAGIC LINK
|
|
1948
|
+
// MAGIC LINK / EMAIL OTP (sign-in continuations — SIGN_IN row)
|
|
1516
1949
|
// ============================================================
|
|
1517
1950
|
async sendMagicLink(email) {
|
|
1518
|
-
const res = await post(
|
|
1519
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1520
|
-
"/api/v1/auth-service/magic-link/request",
|
|
1521
|
-
{ email }
|
|
1522
|
-
);
|
|
1951
|
+
const res = await post(this.http(), "/api/v1/auth-service/magic-link/request", { email });
|
|
1523
1952
|
if (!res || !res.ok) {
|
|
1524
1953
|
throw new AuthError("MAGIC_LINK_REQUEST_FAILED", "Failed to send link");
|
|
1525
1954
|
}
|
|
1526
1955
|
}
|
|
1956
|
+
/** A magic-link token is routinely expired or already consumed (mail scanners pre-fetch, users tap twice); a rejection never costs the session. */
|
|
1527
1957
|
verifyMagicLink(token) {
|
|
1528
1958
|
return this.serialize(async () => {
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
|
|
1533
|
-
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1534
|
-
this.persist(data);
|
|
1535
|
-
return data.user;
|
|
1959
|
+
this.rejectIfWithheld("verifyMagicLink");
|
|
1960
|
+
await this.awaitSettled();
|
|
1961
|
+
return (await this.doSignInDone({ kind: "magicLink", token })).user;
|
|
1536
1962
|
});
|
|
1537
1963
|
}
|
|
1538
|
-
// ============================================================
|
|
1539
|
-
// EMAIL OTP (6-digit cross-device code)
|
|
1540
|
-
// ============================================================
|
|
1541
1964
|
async sendEmailOtp(email) {
|
|
1542
|
-
const res = await post(
|
|
1543
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1544
|
-
"/api/v1/auth-service/email-otp/request",
|
|
1545
|
-
{ email }
|
|
1546
|
-
);
|
|
1965
|
+
const res = await post(this.http(), "/api/v1/auth-service/email-otp/request", { email });
|
|
1547
1966
|
if (!res || !res.ok) {
|
|
1548
1967
|
throw new AuthError("EMAIL_OTP_REQUEST_FAILED", "Failed to send code");
|
|
1549
1968
|
}
|
|
1550
1969
|
}
|
|
1970
|
+
/** Mistyping a 6-digit code is the most common outcome of this flow; the session survives for the retry. */
|
|
1551
1971
|
verifyEmailOtp(email, code) {
|
|
1552
1972
|
return this.serialize(async () => {
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
|
|
1557
|
-
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1558
|
-
this.persist(data);
|
|
1559
|
-
return data.user;
|
|
1973
|
+
this.rejectIfWithheld("verifyEmailOtp");
|
|
1974
|
+
await this.awaitSettled();
|
|
1975
|
+
return (await this.doSignInDone({ kind: "emailOtp", email, code })).user;
|
|
1560
1976
|
});
|
|
1561
1977
|
}
|
|
1562
1978
|
// ============================================================
|
|
@@ -1564,153 +1980,282 @@ var Auth = class {
|
|
|
1564
1980
|
// ============================================================
|
|
1565
1981
|
/** Trigger password-reset email. Resolves on 204 even when address is unknown (anti-enumeration). */
|
|
1566
1982
|
async requestPasswordReset(email) {
|
|
1567
|
-
const res = await post(
|
|
1568
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1569
|
-
"/api/v1/auth-service/password/forgot",
|
|
1570
|
-
{ email }
|
|
1571
|
-
);
|
|
1983
|
+
const res = await post(this.http(), "/api/v1/auth-service/password/forgot", { email });
|
|
1572
1984
|
if (!res || !res.ok) {
|
|
1573
1985
|
throw new AuthError("PASSWORD_RESET_REQUEST_FAILED", "Failed to send reset email");
|
|
1574
1986
|
}
|
|
1575
1987
|
}
|
|
1576
1988
|
/** Pair the reset-email token with the new password to complete the reset. */
|
|
1577
1989
|
async resetPassword(token, newPassword) {
|
|
1578
|
-
const res = await post(
|
|
1579
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1580
|
-
"/api/v1/auth-service/password/reset",
|
|
1581
|
-
{ token, newPassword }
|
|
1582
|
-
);
|
|
1990
|
+
const res = await post(this.http(), "/api/v1/auth-service/password/reset", { token, newPassword });
|
|
1583
1991
|
if (!res || !res.ok) {
|
|
1584
1992
|
throw new AuthError("PASSWORD_RESET_FAILED", "Reset failed");
|
|
1585
1993
|
}
|
|
1586
1994
|
}
|
|
1587
1995
|
/** Verify the email-address token from the link Sendora sent on signup. */
|
|
1588
1996
|
async verifyEmail(token) {
|
|
1589
|
-
const res = await post(
|
|
1590
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1591
|
-
"/api/v1/auth-service/email/verify",
|
|
1592
|
-
{ token }
|
|
1593
|
-
);
|
|
1997
|
+
const res = await post(this.http(), "/api/v1/auth-service/email/verify", { token });
|
|
1594
1998
|
if (!res || !res.ok) {
|
|
1595
1999
|
throw new AuthError("EMAIL_VERIFICATION_FAILED", "Verification failed");
|
|
1596
2000
|
}
|
|
1597
2001
|
}
|
|
1598
2002
|
/** Re-send the email-verification email for the currently-signed-in user. */
|
|
1599
2003
|
async sendVerificationEmail() {
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
const res = await post(
|
|
1603
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1604
|
-
"/api/v1/auth-service/email/verify/resend",
|
|
1605
|
-
{},
|
|
1606
|
-
headers
|
|
1607
|
-
);
|
|
2004
|
+
this.rejectIfWithheld("sendVerificationEmail");
|
|
2005
|
+
const headers = await this.bearerHeaders();
|
|
2006
|
+
const res = await post(this.http(), "/api/v1/auth-service/email/verify/resend", {}, headers);
|
|
1608
2007
|
if (!res || !res.ok) {
|
|
1609
2008
|
throw new AuthError("VERIFICATION_RESEND_FAILED", "Failed to send verification email");
|
|
1610
2009
|
}
|
|
1611
2010
|
}
|
|
1612
2011
|
// ============================================================
|
|
1613
|
-
//
|
|
2012
|
+
// HELD GUESTS (ADR-033 §3.5) — getHeldGuests / claim / restore / discard
|
|
2013
|
+
// ============================================================
|
|
2014
|
+
/**
|
|
2015
|
+
* The guests held on this device, newest first (`heldAt` desc). Never exposes
|
|
2016
|
+
* tokens. Best-effort: a guest may have been deleted server-side since it was
|
|
2017
|
+
* held — that is only discovered when its token is presented (restore /
|
|
2018
|
+
* claim / discard), as `guest_released{reason:'dead'}`.
|
|
2019
|
+
*
|
|
2020
|
+
* `autoRestore === false` ⇔ the slot was stamped by `deleteAccount()`: only an
|
|
2021
|
+
* explicit `restoreHeldGuest(id)` revives it. `project` is the guest's JWT
|
|
2022
|
+
* `project` claim at HOLD (`null` when it was unreadable).
|
|
2023
|
+
*/
|
|
2024
|
+
getHeldGuests() {
|
|
2025
|
+
return this.heldSorted().map((h) => ({ userId: h.userId, heldAt: h.heldAt, autoRestore: h.autoRestore, project: h.project }));
|
|
2026
|
+
}
|
|
2027
|
+
/** The newest held guest, or null. */
|
|
2028
|
+
getHeldGuest() {
|
|
2029
|
+
const newest = this.heldSorted()[0];
|
|
2030
|
+
return newest ? { userId: newest.userId, heldAt: newest.heldAt } : null;
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Fold a held guest INTO the current identified account (`POST /merge`):
|
|
2034
|
+
* reassigns its events, profile, entities and push tokens, DELETES the guest
|
|
2035
|
+
* row and fires `auth.user_merged`. Irreversible, Sendora-side only — it does
|
|
2036
|
+
* not touch app data keyed on the guest's `sub`. ⚠ OPTIONAL: an app whose
|
|
2037
|
+
* backend does not act on `auth.user_merged` MUST NOT call it.
|
|
2038
|
+
*
|
|
2039
|
+
* Identified only. `id` may be omitted only when exactly one guest is held
|
|
2040
|
+
* (else `AMBIGUOUS_HELD_GUEST`). The held token is refreshed FIRST (proves
|
|
2041
|
+
* liveness, repairs a spent parent inside the 60 s grace) and the rotation is
|
|
2042
|
+
* persisted into the slot BEFORE the merge; a dead guest releases the slot
|
|
2043
|
+
* (`guest_released{dead}`) and rejects `HELD_GUEST_GONE`. Emits
|
|
2044
|
+
* `user_updated{cause:'merge'}` + `guest_released{reason:'claimed'}`.
|
|
2045
|
+
*/
|
|
2046
|
+
claimHeldGuest(id) {
|
|
2047
|
+
return this.serialize(async () => {
|
|
2048
|
+
this.rejectIfWithheld("claimHeldGuest");
|
|
2049
|
+
await this.awaitSettled();
|
|
2050
|
+
const status = this.status();
|
|
2051
|
+
if (status !== "identified") {
|
|
2052
|
+
throw new AuthError(
|
|
2053
|
+
"CLAIM_REQUIRES_IDENTIFIED",
|
|
2054
|
+
status === "anonymous" ? "claimHeldGuest() merges a guest INTO an identified account; the current session is a guest" : "Sign in to an account before claiming a held guest",
|
|
2055
|
+
{ kind: "unknown" }
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
const i = this.resolveSlot(id);
|
|
2059
|
+
const expect = this.snapshotIdentity();
|
|
2060
|
+
this.attempt = { kind: "claim", expect };
|
|
2061
|
+
try {
|
|
2062
|
+
const refreshed = await this.refreshHeld(i);
|
|
2063
|
+
if (refreshed.outcome === "dead") throw new AuthError("HELD_GUEST_GONE", "The held guest is no longer available on the server", { kind: "unknown" });
|
|
2064
|
+
if (refreshed.outcome === "kept") throw new AuthError("RESTORE_FAILED", "The held guest could not be refreshed right now; it is still held", { kind: "server", retryable: true });
|
|
2065
|
+
const heldRefresh = refreshed.data.tokens.refreshToken;
|
|
2066
|
+
const target = this.hooks.storage.get(REFRESH_KEY);
|
|
2067
|
+
if (!target) throw new AuthError("NOT_SIGNED_IN", "Sign in before claiming a held guest");
|
|
2068
|
+
const merged = await this.postMerge(heldRefresh, target);
|
|
2069
|
+
await this.awaitRefreshQuiet();
|
|
2070
|
+
const user = this.user;
|
|
2071
|
+
if (!user) throw new AuthError("NOT_SIGNED_IN", "The session ended while the claim was in flight");
|
|
2072
|
+
const outcome = this.commit({
|
|
2073
|
+
kind: "install",
|
|
2074
|
+
reason: "claim",
|
|
2075
|
+
record: { user, accessToken: merged.tokens.accessToken, refreshToken: merged.tokens.refreshToken, expiresIn: merged.tokens.expiresIn },
|
|
2076
|
+
expect,
|
|
2077
|
+
expectRefresh: target
|
|
2078
|
+
});
|
|
2079
|
+
if (outcome.status !== "applied") {
|
|
2080
|
+
throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the claim was in flight", { kind: "unknown" });
|
|
2081
|
+
}
|
|
2082
|
+
this.commit({ kind: "release", reason: "claimed", slot: i, userId: refreshed.data.user.id });
|
|
2083
|
+
return { user, stats: merged.stats };
|
|
2084
|
+
} finally {
|
|
2085
|
+
this.attempt = null;
|
|
2086
|
+
}
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Sign back in AS a held guest without merging (the RESTORE row). From
|
|
2091
|
+
* `signed_out` or an identified account (whose refresh token is revoked
|
|
2092
|
+
* fire-and-forget); from a guest the CURRENT guest is held first (its slot is
|
|
2093
|
+
* written before the chosen one is released, so there is never a window with
|
|
2094
|
+
* zero handles to either). `id` may be omitted only when exactly one guest is
|
|
2095
|
+
* held. Client-side project check FIRST: a slot whose recorded `project`
|
|
2096
|
+
* mismatches `expectedProjectId ?? config.projectId` throws
|
|
2097
|
+
* `HeldGuestProjectMismatch` with no network and the slot kept. A dead guest
|
|
2098
|
+
* releases the slot and rejects `HELD_GUEST_GONE`. Emits
|
|
2099
|
+
* `signed_in{cause:'restore'}` (re-register push) + `guest_released{restored}`.
|
|
2100
|
+
*/
|
|
2101
|
+
restoreHeldGuest(id) {
|
|
2102
|
+
return this.serialize(async () => {
|
|
2103
|
+
this.rejectIfWithheld("restoreHeldGuest");
|
|
2104
|
+
await this.awaitSettled();
|
|
2105
|
+
const i = this.resolveSlot(id);
|
|
2106
|
+
const slot = this.held[i];
|
|
2107
|
+
const expectedProject = this.hooks.expectedProjectId ?? this.hooks.projectId;
|
|
2108
|
+
if (expectedProject !== void 0 && slot.project !== null && slot.project !== expectedProject) {
|
|
2109
|
+
throw new HeldGuestProjectMismatch(slot.userId, expectedProject, slot.project);
|
|
2110
|
+
}
|
|
2111
|
+
const status = this.status();
|
|
2112
|
+
if (status === "anonymous" && this.freeSlotIndex() === -1) throw new HeldGuestLimitError();
|
|
2113
|
+
const expect = this.snapshotIdentity();
|
|
2114
|
+
this.attempt = { kind: "restore", expect };
|
|
2115
|
+
try {
|
|
2116
|
+
const refreshed = await this.refreshHeld(i);
|
|
2117
|
+
if (refreshed.outcome === "dead") throw new AuthError("HELD_GUEST_GONE", "The held guest is no longer available on the server", { kind: "unknown" });
|
|
2118
|
+
if (refreshed.outcome === "kept") throw new AuthError("RESTORE_FAILED", "The held guest could not be refreshed right now; it is still held", { kind: "server", retryable: true });
|
|
2119
|
+
return this.installRestored(i, refreshed.data, expect);
|
|
2120
|
+
} finally {
|
|
2121
|
+
this.attempt = null;
|
|
2122
|
+
}
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* DELETE a held guest on the server (`POST /guest/discard` — a real erasure:
|
|
2127
|
+
* data → credentials → row → `auth.user_deleted{reason:'guest_discarded'}`)
|
|
2128
|
+
* and release its slot. The app is the requester on behalf of its player
|
|
2129
|
+
* (s58.316); the SDK never calls this on its own. `id` may be omitted only
|
|
2130
|
+
* when exactly one guest is held. A 2xx or a 404 (already gone) releases the
|
|
2131
|
+
* slot; any other failure keeps it. Emits `guest_released{reason:'discarded'}`.
|
|
2132
|
+
*/
|
|
2133
|
+
discardHeldGuest(id) {
|
|
2134
|
+
return this.serialize(async () => {
|
|
2135
|
+
this.rejectIfWithheld("discardHeldGuest");
|
|
2136
|
+
await this.awaitSettled();
|
|
2137
|
+
const i = this.resolveSlot(id);
|
|
2138
|
+
await this.discardSlot(i);
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
/** Shared body of `discardHeldGuest()` and `deleteAccount({alsoDiscardHeldGuests})`. */
|
|
2142
|
+
async discardSlot(i) {
|
|
2143
|
+
const slot = this.held[i];
|
|
2144
|
+
if (!slot) return;
|
|
2145
|
+
this.attempt = { kind: "discard", expect: this.snapshotIdentity() };
|
|
2146
|
+
try {
|
|
2147
|
+
const res = await post(this.http(), "/api/v1/auth-service/guest/discard", { refreshToken: slot.refreshToken });
|
|
2148
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
2149
|
+
if (!res.ok && res.status !== 404) {
|
|
2150
|
+
const parsed = await res.json().catch(() => ({}));
|
|
2151
|
+
throw new AuthError(parsed.error?.code ?? `HTTP_${res.status}`, parsed.error?.message ?? `HTTP ${res.status}`, {
|
|
2152
|
+
status: res.status,
|
|
2153
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
this.commit({ kind: "release", reason: "discarded", slot: i, userId: slot.userId });
|
|
2157
|
+
} finally {
|
|
2158
|
+
this.attempt = null;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
// ============================================================
|
|
2162
|
+
// WITHHELD (ADR-033 [2c]) — getWithheld / discardWithheld
|
|
2163
|
+
// ============================================================
|
|
2164
|
+
/**
|
|
2165
|
+
* While `status() === "withheld"`: why the stored session was refused. Never
|
|
2166
|
+
* exposes tokens. `userId` lets a `sub`-keyed backend record oldSub → newSub
|
|
2167
|
+
* before `discardWithheld()`. `null` in every other state.
|
|
2168
|
+
*/
|
|
2169
|
+
getWithheld() {
|
|
2170
|
+
return this.withheld;
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Resolve `withheld` — the local counterpart of `signOut()` in that state
|
|
2174
|
+
* (and what `signOut()` does there). Precondition `status() === "withheld"`
|
|
2175
|
+
* (else resolves `false`, nothing mutated). CAS on the withheld refresh token;
|
|
2176
|
+
* clears the session keys only; fire-and-forget `/token/revoke` like
|
|
2177
|
+
* `signOut()`; LOCAL ONLY — never `/guest/discard`, never a server delete;
|
|
2178
|
+
* KEEPS held slots, the event queue and the persisted `user_id`. Emits
|
|
2179
|
+
* `signed_out{reason:'user'}`. The other exit is relaunching with the matching
|
|
2180
|
+
* `expectedProjectId`.
|
|
2181
|
+
*/
|
|
2182
|
+
discardWithheld() {
|
|
2183
|
+
return this.serialize(async () => {
|
|
2184
|
+
if (!this.withheld) return false;
|
|
2185
|
+
return this.commit({ kind: "discard_withheld" }).status === "applied";
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
// ============================================================
|
|
2189
|
+
// SESSION VERBS (Bearer; NOT transitions — reject in `withheld`)
|
|
1614
2190
|
// ============================================================
|
|
1615
2191
|
async enrollMfa() {
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
"/api/v1/auth-service/mfa/enroll/start",
|
|
1619
|
-
{},
|
|
1620
|
-
this.bearerHeaders()
|
|
1621
|
-
);
|
|
2192
|
+
this.rejectIfWithheld("enrollMfa");
|
|
2193
|
+
const res = await post(this.http(), "/api/v1/auth-service/mfa/enroll/start", {}, await this.bearerHeaders());
|
|
1622
2194
|
return await this.unwrap(res, "MFA_ENROLL_FAILED");
|
|
1623
2195
|
}
|
|
1624
2196
|
async confirmMfa(code) {
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
"/api/v1/auth-service/mfa/enroll/confirm",
|
|
1628
|
-
{ code },
|
|
1629
|
-
this.bearerHeaders()
|
|
1630
|
-
);
|
|
2197
|
+
this.rejectIfWithheld("confirmMfa");
|
|
2198
|
+
const res = await post(this.http(), "/api/v1/auth-service/mfa/enroll/confirm", { code }, await this.bearerHeaders());
|
|
1631
2199
|
const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
|
|
1632
2200
|
return Boolean(data.confirmed);
|
|
1633
2201
|
}
|
|
1634
2202
|
/**
|
|
1635
|
-
* Disable MFA.
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
1638
|
-
*
|
|
1639
|
-
*
|
|
1640
|
-
* ⚠ Discarded its response until s58.335, and `request()` never throws on a
|
|
1641
|
-
* non-2xx (it warns and returns the Response), so the refusal had nowhere to
|
|
1642
|
-
* surface — the app reported MFA as off while TOTP stayed enrolled.
|
|
2203
|
+
* Disable MFA. ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with
|
|
2204
|
+
* 403 `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`. The response is
|
|
2205
|
+
* inspected (s58.335): `request()` never throws on a non-2xx, so a discarded
|
|
2206
|
+
* return would report the refusal as success.
|
|
1643
2207
|
*/
|
|
1644
2208
|
async disableMfa() {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
"/api/v1/auth-service/mfa/disable",
|
|
1648
|
-
{},
|
|
1649
|
-
this.bearerHeaders()
|
|
1650
|
-
);
|
|
2209
|
+
this.rejectIfWithheld("disableMfa");
|
|
2210
|
+
const res = await post(this.http(), "/api/v1/auth-service/mfa/disable", {}, await this.bearerHeaders());
|
|
1651
2211
|
await this.unwrap(res, "MFA_DISABLE_FAILED");
|
|
1652
2212
|
}
|
|
1653
|
-
// ============================================================
|
|
1654
|
-
// DEVICE SESSIONS (Bearer-authenticated)
|
|
1655
|
-
// ============================================================
|
|
1656
2213
|
async listMySessions() {
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
"/api/v1/auth-service/sessions/me",
|
|
1660
|
-
this.bearerHeaders()
|
|
1661
|
-
);
|
|
2214
|
+
this.rejectIfWithheld("listMySessions");
|
|
2215
|
+
const res = await getJson(this.http(), "/api/v1/auth-service/sessions/me", await this.bearerHeaders());
|
|
1662
2216
|
if (!res || !res.ok) return [];
|
|
1663
2217
|
const parsed = await res.json();
|
|
1664
2218
|
return Array.isArray(parsed.data) ? parsed.data : [];
|
|
1665
2219
|
}
|
|
1666
|
-
/**
|
|
1667
|
-
* Revoke one of the caller's own sessions ("sign out that device").
|
|
1668
|
-
* ⚠ Discarded its response until s58.335 — a "sign out this device" button
|
|
1669
|
-
* that reports success on a failure is the one place a user acts on the claim
|
|
1670
|
-
* and then stops worrying.
|
|
1671
|
-
*/
|
|
2220
|
+
/** Revoke one of the caller's own sessions ("sign out that device"). Inspects the response (s58.335). */
|
|
1672
2221
|
async revokeSession(sessionId) {
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
`/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
|
|
1676
|
-
this.bearerHeaders()
|
|
1677
|
-
);
|
|
2222
|
+
this.rejectIfWithheld("revokeSession");
|
|
2223
|
+
const res = await del(this.http(), `/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`, await this.bearerHeaders());
|
|
1678
2224
|
await this.unwrap(res, "SESSION_REVOKE_FAILED");
|
|
1679
2225
|
}
|
|
1680
|
-
/** Revoke every session for the caller.
|
|
2226
|
+
/** Revoke every session for the caller. Inspects the response (s58.335). */
|
|
1681
2227
|
async revokeAllSessions() {
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
"/api/v1/auth-service/sessions/me",
|
|
1685
|
-
this.bearerHeaders()
|
|
1686
|
-
);
|
|
2228
|
+
this.rejectIfWithheld("revokeAllSessions");
|
|
2229
|
+
const res = await del(this.http(), "/api/v1/auth-service/sessions/me", await this.bearerHeaders());
|
|
1687
2230
|
await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
|
|
1688
2231
|
}
|
|
2232
|
+
// ============================================================
|
|
2233
|
+
// DELETE (ADR-033 §3.3 row 28)
|
|
2234
|
+
// ============================================================
|
|
1689
2235
|
/**
|
|
1690
2236
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
1691
|
-
* Honors the project's configured grace period:
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
* session). Requires a signed-in user. Throws on a non-2xx response.
|
|
2237
|
+
* Honors the project's configured grace period: `"purged"` (hard-deleted
|
|
2238
|
+
* now) or `"pending"` (deactivated + sessions revoked now, hard-deleted at
|
|
2239
|
+
* `scheduledPurgeAt`; cancellable by signing back in). Resolves a FRESH access
|
|
2240
|
+
* token first — a stale token's 401 would silently strand the user with an
|
|
2241
|
+
* undeleted account.
|
|
1697
2242
|
*
|
|
1698
|
-
*
|
|
1699
|
-
* the
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
* the
|
|
2243
|
+
* Held guests (ADR-033): deleting an account never touches a held guest, on
|
|
2244
|
+
* the device or the server. By default they SURVIVE with auto-restore
|
|
2245
|
+
* suppressed (`autoRestore: false` — only an explicit `restoreHeldGuest(id)`
|
|
2246
|
+
* revives one; `ensureSession()` mints fresh). `alsoDiscardHeldGuests: true`
|
|
2247
|
+
* is the player's explicit ask to erase them too: each is discarded after the
|
|
2248
|
+
* 2xx, a slot that fails to discard stays held and is listed in
|
|
2249
|
+
* `discardFailed`. `heldGuests` = guests still held afterwards. The queued
|
|
2250
|
+
* analytics events are dropped (erasure completeness, 1.39.1).
|
|
1703
2251
|
*/
|
|
1704
|
-
async deleteAccount() {
|
|
2252
|
+
async deleteAccount(opts) {
|
|
2253
|
+
this.rejectIfWithheld("deleteAccount");
|
|
1705
2254
|
const token = await this.getAccessToken();
|
|
1706
2255
|
if (!token) {
|
|
1707
2256
|
throw new AuthError("NOT_SIGNED_IN", "Sign in before deleting your account");
|
|
1708
2257
|
}
|
|
1709
|
-
const res = await del(
|
|
1710
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1711
|
-
"/api/v1/auth-service/me",
|
|
1712
|
-
{ Authorization: `Bearer ${token}` }
|
|
1713
|
-
);
|
|
2258
|
+
const res = await del(this.http(), "/api/v1/auth-service/me", { Authorization: `Bearer ${token}` });
|
|
1714
2259
|
if (!res || !res.ok) {
|
|
1715
2260
|
throw new Error(`deleteAccount failed${res ? ` (HTTP ${res.status})` : " (network error)"}`);
|
|
1716
2261
|
}
|
|
@@ -1730,33 +2275,34 @@ var Auth = class {
|
|
|
1730
2275
|
}
|
|
1731
2276
|
} catch {
|
|
1732
2277
|
}
|
|
1733
|
-
await this.
|
|
1734
|
-
|
|
2278
|
+
await this.commitAsync({ kind: "clear", reason: "account_deleted", suppressHeldAutoRestore: true });
|
|
2279
|
+
const discardFailed = [];
|
|
2280
|
+
if (opts?.alsoDiscardHeldGuests) {
|
|
2281
|
+
for (let i = 0; i < HELD_SLOT_KEYS.length; i++) {
|
|
2282
|
+
const slot = this.held[i];
|
|
2283
|
+
if (!slot) continue;
|
|
2284
|
+
try {
|
|
2285
|
+
await this.discardSlot(i);
|
|
2286
|
+
} catch {
|
|
2287
|
+
discardFailed.push(slot.userId);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
return { ...out, heldGuests: this.heldCount(), discardFailed };
|
|
1735
2292
|
}
|
|
1736
2293
|
// ============================================================
|
|
1737
|
-
// OIDC SSO
|
|
2294
|
+
// OIDC / SAML SSO (sign-in continuation)
|
|
1738
2295
|
// ============================================================
|
|
1739
2296
|
/**
|
|
1740
|
-
* Start OIDC sign-in. Returns the IdP's authorization URL — caller
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
1743
|
-
*
|
|
1744
|
-
* `returnTo`
|
|
1745
|
-
*
|
|
1746
|
-
* On the customer's app side, `returnTo` MUST be a custom URL scheme
|
|
1747
|
-
* registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
|
|
1748
|
-
* (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
|
|
1749
|
-
* call `consumeSsoFragment(url)` on the captured URL.
|
|
2297
|
+
* Start OIDC sign-in. Returns the IdP's authorization URL — caller opens it
|
|
2298
|
+
* via `Linking.openURL()` (or `WebBrowser.openAuthSessionAsync`). The IdP
|
|
2299
|
+
* redirects through Sendora's callback to the `returnTo` URL with
|
|
2300
|
+
* `#sendora_oidc_token=...` in the fragment; hand that URL to
|
|
2301
|
+
* `consumeSsoFragment(url)`. `returnTo` MUST be a custom URL scheme
|
|
2302
|
+
* registered in Info.plist / AndroidManifest. No guest hint travels (INV-3).
|
|
1750
2303
|
*/
|
|
1751
2304
|
async startSso(returnTo) {
|
|
1752
|
-
const
|
|
1753
|
-
const body = { returnTo };
|
|
1754
|
-
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1755
|
-
const res = await post(
|
|
1756
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1757
|
-
"/api/v1/auth-service/sso/oidc/start",
|
|
1758
|
-
body
|
|
1759
|
-
);
|
|
2305
|
+
const res = await post(this.http(), "/api/v1/auth-service/sso/oidc/start", { returnTo });
|
|
1760
2306
|
if (!res || !res.ok) throw new AuthError("SSO_START_FAILED", `HTTP ${res?.status ?? "network"}`);
|
|
1761
2307
|
const parsed = await res.json();
|
|
1762
2308
|
if (!parsed.success || !parsed.data?.authorizationUrl) {
|
|
@@ -1764,147 +2310,555 @@ var Auth = class {
|
|
|
1764
2310
|
}
|
|
1765
2311
|
return { authorizationUrl: parsed.data.authorizationUrl };
|
|
1766
2312
|
}
|
|
1767
|
-
/**
|
|
1768
|
-
* Kick off a SAML 2.0 SSO flow. Mirrors `startSso` for SAML —
|
|
1769
|
-
* backend POST `/auth-service/sso/saml/start` returns the IdP
|
|
1770
|
-
* AuthnRequest URL. Caller opens it with
|
|
1771
|
-
* ASWebAuthenticationSession (iOS) / Custom Tabs (Android) /
|
|
1772
|
-
* Linking, captures the callback URL via the standard
|
|
1773
|
-
* `Linking.addEventListener` flow, then passes the captured URL to
|
|
1774
|
-
* `consumeSsoFragment(url)`. consumeSsoFragment handles both OIDC
|
|
1775
|
-
* and SAML fragments transparently.
|
|
1776
|
-
*/
|
|
2313
|
+
/** Kick off a SAML 2.0 SSO flow. Mirrors `startSso`; `consumeSsoFragment` handles both fragments. */
|
|
1777
2314
|
async startSaml(returnTo) {
|
|
1778
|
-
const
|
|
1779
|
-
const body = { returnTo };
|
|
1780
|
-
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1781
|
-
const res = await post(
|
|
1782
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1783
|
-
"/api/v1/auth-service/sso/saml/start",
|
|
1784
|
-
body
|
|
1785
|
-
);
|
|
2315
|
+
const res = await post(this.http(), "/api/v1/auth-service/sso/saml/start", { returnTo });
|
|
1786
2316
|
if (!res || !res.ok) throw new AuthError("SAML_START_FAILED", `HTTP ${res?.status ?? "network"}`);
|
|
1787
2317
|
const parsed = await res.json();
|
|
1788
2318
|
if (!parsed.success || !parsed.data?.authorizationUrl) {
|
|
1789
2319
|
throw new AuthError("SAML_START_FAILED", "Missing authorizationUrl");
|
|
1790
2320
|
}
|
|
1791
|
-
return { authorizationUrl: parsed.data.authorizationUrl };
|
|
2321
|
+
return { authorizationUrl: parsed.data.authorizationUrl };
|
|
2322
|
+
}
|
|
2323
|
+
/**
|
|
2324
|
+
* Consume an OIDC/SAML callback URL captured via `Linking.addEventListener`.
|
|
2325
|
+
* Reads the fragment for `sendora_oidc_token` / `sendora_saml_token`,
|
|
2326
|
+
* exchanges it for a session via `/token/refresh`, and installs it as a
|
|
2327
|
+
* sign-in (holding a guest, if any). The callback is parsed and exchanged
|
|
2328
|
+
* FIRST — an IdP "Cancel" or a garbage fragment leaves the session untouched.
|
|
2329
|
+
*/
|
|
2330
|
+
consumeSsoFragment(callbackUrl) {
|
|
2331
|
+
return this.serialize(async () => {
|
|
2332
|
+
this.rejectIfWithheld("consumeSsoFragment");
|
|
2333
|
+
await this.awaitSettled();
|
|
2334
|
+
const hashIdx = callbackUrl.indexOf("#");
|
|
2335
|
+
const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
|
|
2336
|
+
const params = new URLSearchParams(fragment);
|
|
2337
|
+
const err = params.get("sendora_oidc_error") ?? params.get("sendora_saml_error");
|
|
2338
|
+
if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
|
|
2339
|
+
const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
|
|
2340
|
+
if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
|
|
2341
|
+
const retiredAnonUserId = params.get("sendora_retired_anon");
|
|
2342
|
+
const expect = this.snapshotIdentity();
|
|
2343
|
+
this.attempt = { kind: "sign_in", expect };
|
|
2344
|
+
try {
|
|
2345
|
+
await this.awaitRefreshQuiet();
|
|
2346
|
+
const res = await post(this.http(), REFRESH_PATH, { refreshToken: refresh });
|
|
2347
|
+
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
|
|
2348
|
+
const parsed = await res.json();
|
|
2349
|
+
const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
|
|
2350
|
+
if (!rotated) {
|
|
2351
|
+
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
|
|
2352
|
+
}
|
|
2353
|
+
const d = parsed.data;
|
|
2354
|
+
const user = isAuthUserShape(d?.user) ? d.user : this.userFromClaims(rotated.accessToken);
|
|
2355
|
+
const data = {
|
|
2356
|
+
user,
|
|
2357
|
+
tokens: { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn, tokenType: "Bearer" },
|
|
2358
|
+
...retiredAnonUserId ? { retiredAnonUserId } : {}
|
|
2359
|
+
};
|
|
2360
|
+
await this.awaitRefreshQuiet();
|
|
2361
|
+
this.installSignIn(data, expect);
|
|
2362
|
+
return user;
|
|
2363
|
+
} finally {
|
|
2364
|
+
this.attempt = null;
|
|
2365
|
+
}
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
/** Populate a user object from access-token claims when a response carries none (pre-B17 `/token/refresh`). */
|
|
2369
|
+
userFromClaims(access) {
|
|
2370
|
+
const claims = decodeJwtPayload(access);
|
|
2371
|
+
return {
|
|
2372
|
+
id: typeof claims?.sub === "string" ? claims.sub : "",
|
|
2373
|
+
email: typeof claims?.email === "string" ? claims.email : null,
|
|
2374
|
+
emailVerified: typeof claims?.email_verified === "boolean" ? claims.email_verified : true,
|
|
2375
|
+
name: typeof claims?.name === "string" ? claims.name : null,
|
|
2376
|
+
isAnonymous: claims?.is_anonymous === true,
|
|
2377
|
+
...claims && Object.prototype.hasOwnProperty.call(claims, "project") ? { project: typeof claims.project === "string" ? claims.project : null } : {}
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* Build the Authorization header for end-user-authenticated routes. Awaits
|
|
2382
|
+
* `getAccessToken()` (parity with sdk-web since 1.41.0), so a cold start with
|
|
2383
|
+
* no cached access token or an expired token transparently refreshes first.
|
|
2384
|
+
* Throws `NOT_SIGNED_IN` when there is genuinely no usable session.
|
|
2385
|
+
*/
|
|
2386
|
+
async bearerHeaders() {
|
|
2387
|
+
const accessToken = await this.getAccessToken();
|
|
2388
|
+
if (!accessToken) {
|
|
2389
|
+
throw new AuthError("NOT_SIGNED_IN", "Not signed in");
|
|
2390
|
+
}
|
|
2391
|
+
return { Authorization: `Bearer ${accessToken}` };
|
|
2392
|
+
}
|
|
2393
|
+
async unwrap(res, errCode) {
|
|
2394
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
2395
|
+
const parsed = await res.json().catch(() => ({}));
|
|
2396
|
+
if (!res.ok || !parsed.success) {
|
|
2397
|
+
throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`, {
|
|
2398
|
+
status: res.status,
|
|
2399
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
return parsed.data;
|
|
2403
|
+
}
|
|
2404
|
+
// ============================================================
|
|
2405
|
+
// SIGN OUT (ADR-033 §3.3 row 27) — local-first, the one deliberate exception
|
|
2406
|
+
// ============================================================
|
|
2407
|
+
/**
|
|
2408
|
+
* Sign out: clear the local session FIRST so the user is logged out on
|
|
2409
|
+
* device even if the revoke network call hangs (airplane mode, 5xx); revoke
|
|
2410
|
+
* is fire-and-forget after the clear. Rotates the analytics anon id (a
|
|
2411
|
+
* person boundary). KEEPS held guests — a guest's refresh token is its only
|
|
2412
|
+
* credential, so wiping the slots on logout would be deletion by assumption
|
|
2413
|
+
* (use `discardHeldGuest()` / `reset({includeHeldGuests:true})` deliberately).
|
|
2414
|
+
* In `withheld` this is `discardWithheld()`.
|
|
2415
|
+
*/
|
|
2416
|
+
signOut() {
|
|
2417
|
+
return this.serialize(async () => {
|
|
2418
|
+
if (this.withheld) {
|
|
2419
|
+
this.commit({ kind: "discard_withheld" });
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
await this.commitAsync({ kind: "clear", reason: "user" });
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
// ============================================================
|
|
2426
|
+
// DEPRECATED, kept callable
|
|
2427
|
+
// ============================================================
|
|
2428
|
+
/**
|
|
2429
|
+
* @deprecated Transfer every event + trait from an anonymous user to the
|
|
2430
|
+
* currently-signed-in (target) user, then delete the anonymous row.
|
|
2431
|
+
*
|
|
2432
|
+
* Since 1.41.0 the guest you signed in over is HELD on this device, so a
|
|
2433
|
+
* token that matches a held slot (its current refresh token OR the token it
|
|
2434
|
+
* was held with) routes to `claimHeldGuest(thatGuest)` — a DESTRUCTIVE claim.
|
|
2435
|
+
* Any other token takes the raw `/merge` path as before. Prefer
|
|
2436
|
+
* `claimHeldGuest(id)`; never call either from a backend that does not act
|
|
2437
|
+
* on `auth.user_merged`.
|
|
2438
|
+
*/
|
|
2439
|
+
async merge(anonymousRefreshToken, targetRefreshToken) {
|
|
2440
|
+
const i = this.held.findIndex((h) => h !== null && (h.refreshToken === anonymousRefreshToken || h.tokenAtHold === anonymousRefreshToken));
|
|
2441
|
+
if (i !== -1 && targetRefreshToken === void 0) {
|
|
2442
|
+
const claimed = await this.claimHeldGuest(this.held[i].userId);
|
|
2443
|
+
return claimed.stats;
|
|
2444
|
+
}
|
|
2445
|
+
const target = targetRefreshToken ?? this.getRefreshToken();
|
|
2446
|
+
if (!target) {
|
|
2447
|
+
throw new AuthError(
|
|
2448
|
+
"NO_TARGET_REFRESH",
|
|
2449
|
+
"merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
const merged = await this.postMerge(anonymousRefreshToken, target);
|
|
2453
|
+
const user = this.user;
|
|
2454
|
+
if (user && this.hooks.storage.get(REFRESH_KEY) === target) {
|
|
2455
|
+
await this.awaitRefreshQuiet();
|
|
2456
|
+
this.commit({
|
|
2457
|
+
kind: "install",
|
|
2458
|
+
reason: "claim",
|
|
2459
|
+
record: { user, accessToken: merged.tokens.accessToken, refreshToken: merged.tokens.refreshToken, expiresIn: merged.tokens.expiresIn },
|
|
2460
|
+
expect: this.snapshotIdentity(),
|
|
2461
|
+
expectRefresh: target,
|
|
2462
|
+
silent: true
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
return merged.stats;
|
|
2466
|
+
}
|
|
2467
|
+
/** `POST /merge` — bypasses `callAuth`'s `isAuthApiResponse` gate (`/merge` returns `{ ...stats, tokens, csrfToken }`, no `user`). */
|
|
2468
|
+
async postMerge(anonymousRefreshToken, targetRefreshToken) {
|
|
2469
|
+
const res = await post(this.http(), "/api/v1/auth-service/merge", { anonymousRefreshToken, targetRefreshToken });
|
|
2470
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
2471
|
+
let parsed;
|
|
2472
|
+
try {
|
|
2473
|
+
parsed = await res.json();
|
|
2474
|
+
} catch {
|
|
2475
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
2476
|
+
}
|
|
2477
|
+
if (!res.ok || !parsed.success || !parsed.data) {
|
|
2478
|
+
throw new AuthError(
|
|
2479
|
+
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
2480
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
2481
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
2482
|
+
);
|
|
2483
|
+
}
|
|
2484
|
+
const tokens = readRotatedTokens({ tokens: parsed.data.tokens });
|
|
2485
|
+
if (!tokens) throw new AuthError("MERGE_FAILED", "merge response carried no rotated token pair", { status: res.status });
|
|
2486
|
+
const d = parsed.data;
|
|
2487
|
+
return {
|
|
2488
|
+
tokens,
|
|
2489
|
+
stats: {
|
|
2490
|
+
mergedUserId: d.mergedUserId ?? "",
|
|
2491
|
+
deletedAnonymousId: d.deletedAnonymousId ?? "",
|
|
2492
|
+
eventsReassigned: d.eventsReassigned ?? 0,
|
|
2493
|
+
profilesReassigned: d.profilesReassigned ?? 0,
|
|
2494
|
+
profilesDropped: d.profilesDropped ?? 0,
|
|
2495
|
+
entitiesReassigned: d.entitiesReassigned ?? 0,
|
|
2496
|
+
entitiesDropped: d.entitiesDropped ?? 0
|
|
2497
|
+
}
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
// ============================================================
|
|
2501
|
+
// INTERNALS — attempts, settling, refresh, cron
|
|
2502
|
+
// ============================================================
|
|
2503
|
+
http() {
|
|
2504
|
+
return { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug };
|
|
2505
|
+
}
|
|
2506
|
+
/** ADR-033 [2c]: in `withheld` every verb in REJECTED_IN_WITHHELD rejects with no network and no write. */
|
|
2507
|
+
rejectIfWithheld(verb) {
|
|
2508
|
+
if (this.withheld) {
|
|
2509
|
+
throw withheldError(
|
|
2510
|
+
"SESSION_WITHHELD",
|
|
2511
|
+
`${verb}() is unavailable while the stored session is withheld (its project claim disagrees with init({expectedProjectId})). Call discardWithheld() or relaunch with the matching project.`
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
/** True in `anonymous` / `identified` — a live session a LINK can attach to. */
|
|
2516
|
+
hasSession() {
|
|
2517
|
+
const s = this.status();
|
|
2518
|
+
return s === "anonymous" || s === "identified";
|
|
2519
|
+
}
|
|
2520
|
+
serialize(op) {
|
|
2521
|
+
const guarded = async () => {
|
|
2522
|
+
await this.readyPromise;
|
|
2523
|
+
try {
|
|
2524
|
+
return await op();
|
|
2525
|
+
} catch (err) {
|
|
2526
|
+
throw asAuthError(err);
|
|
2527
|
+
}
|
|
2528
|
+
};
|
|
2529
|
+
const next = this.inflight.then(guarded, guarded);
|
|
2530
|
+
this.inflight = next.catch(() => void 0);
|
|
2531
|
+
return next;
|
|
2532
|
+
}
|
|
2533
|
+
/**
|
|
2534
|
+
* INV-5 — a session-installing verb in `unknown` QUEUES until the status
|
|
2535
|
+
* settles: the single gap-fill refresh either installs the identity or
|
|
2536
|
+
* reports the token dead (`signed_out`). Offline it keeps waiting with the
|
|
2537
|
+
* refresh backoff (never minting, never wiping). Ends in `withheld` → rejects.
|
|
2538
|
+
*/
|
|
2539
|
+
async awaitSettled() {
|
|
2540
|
+
await this.readyPromise;
|
|
2541
|
+
while (!this.withheld && this.status() === "unknown") {
|
|
2542
|
+
await this.refreshAccessToken();
|
|
2543
|
+
if (this.withheld || this.status() !== "unknown") break;
|
|
2544
|
+
const wait = Math.min(3e4, Math.max(100, this.refreshCooldownUntil - Date.now()));
|
|
2545
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
2546
|
+
}
|
|
2547
|
+
if (this.withheld) this.rejectIfWithheld("auth");
|
|
2548
|
+
}
|
|
2549
|
+
/** An identity-changing attempt awaits any in-flight refresh at start AND at commit (ADR-033 §3.3 scheduling rule 1). */
|
|
2550
|
+
async awaitRefreshQuiet() {
|
|
2551
|
+
while (this.refreshInflight) {
|
|
2552
|
+
await this.refreshInflight.catch(() => void 0);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
snapshotIdentity() {
|
|
2556
|
+
if (!this.hooks.storage.get(REFRESH_KEY) || !this.user) return { sub: null, sid: null };
|
|
2557
|
+
const claims = decodeJwtPayload(this.accessToken);
|
|
2558
|
+
return { sub: this.user.id, sid: typeof claims?.sid === "string" ? claims.sid : null };
|
|
2559
|
+
}
|
|
2560
|
+
/** The identity CAS (INV-2): `sid` when both sides know it, else the `sub`; a signed-out side never matches a live one. */
|
|
2561
|
+
identityMatches(expect) {
|
|
2562
|
+
const cur = this.snapshotIdentity();
|
|
2563
|
+
if (expect.sub !== cur.sub) return false;
|
|
2564
|
+
if (expect.sid !== null && cur.sid !== null) return expect.sid === cur.sid;
|
|
2565
|
+
return true;
|
|
2566
|
+
}
|
|
2567
|
+
/** The current session's JWT `project` claim (string | null), or undefined when unreadable. */
|
|
2568
|
+
claimProject(accessToken) {
|
|
2569
|
+
const claims = decodeJwtPayload(accessToken);
|
|
2570
|
+
if (!claims || !Object.prototype.hasOwnProperty.call(claims, "project")) return void 0;
|
|
2571
|
+
const p = claims.project;
|
|
2572
|
+
return typeof p === "string" ? p : null;
|
|
2573
|
+
}
|
|
2574
|
+
/**
|
|
2575
|
+
* The ProjectGuard (INV-15) as `commit()` applies it: the shared
|
|
2576
|
+
* `projectVerdict()` over the access JWT, and — when the claim is unreadable
|
|
2577
|
+
* — the B17 `user.project` field as the second source. Absent on both = `unknown`.
|
|
2578
|
+
*/
|
|
2579
|
+
verdictFor(user, accessToken) {
|
|
2580
|
+
const expected = this.hooks.expectedProjectId;
|
|
2581
|
+
if (expected === void 0) return "match";
|
|
2582
|
+
const v = projectVerdict(expected, accessToken);
|
|
2583
|
+
if (v !== "unknown") return v;
|
|
2584
|
+
if (user.project !== void 0) {
|
|
2585
|
+
if (user.project === null) return "mismatch";
|
|
2586
|
+
return user.project === expected ? "match" : "mismatch";
|
|
2587
|
+
}
|
|
2588
|
+
return "unknown";
|
|
2589
|
+
}
|
|
2590
|
+
heldCount() {
|
|
2591
|
+
return this.held.reduce((n, h) => n + (h ? 1 : 0), 0);
|
|
2592
|
+
}
|
|
2593
|
+
heldSorted() {
|
|
2594
|
+
return this.held.filter((h) => h !== null).sort((a, b) => b.heldAt - a.heldAt);
|
|
2595
|
+
}
|
|
2596
|
+
/** Slot indices eligible for auto-restore, newest first (ADR-033 §3.3 "eligible"). */
|
|
2597
|
+
eligibleHeldSlots() {
|
|
2598
|
+
const expected = this.hooks.expectedProjectId ?? this.hooks.projectId;
|
|
2599
|
+
return this.held.map((h, i) => ({ h, i })).filter(({ h }) => h !== null && h.autoRestore !== false && !(expected !== void 0 && h.project !== null && h.project !== expected)).sort((a, b) => b.h.heldAt - a.h.heldAt).map(({ i }) => i);
|
|
2600
|
+
}
|
|
2601
|
+
freeSlotIndex() {
|
|
2602
|
+
return this.held.findIndex((h) => h === null);
|
|
2603
|
+
}
|
|
2604
|
+
/** The `id` rule shared by claim / restore / discard: omit it ONLY when exactly one guest is held. */
|
|
2605
|
+
resolveSlot(id) {
|
|
2606
|
+
const present = this.held.map((h, i2) => h ? i2 : -1).filter((i2) => i2 !== -1);
|
|
2607
|
+
if (id === void 0) {
|
|
2608
|
+
if (present.length === 1) return present[0];
|
|
2609
|
+
if (present.length === 0) throw new AuthError("NO_HELD_GUEST", "No guest is held on this device", { kind: "unknown" });
|
|
2610
|
+
throw new AuthError(AMBIGUOUS_HELD_GUEST, `${present.length} guests are held on this device; pass the userId of the one you mean`, { kind: "unknown" });
|
|
2611
|
+
}
|
|
2612
|
+
const i = this.held.findIndex((h) => h?.userId === id);
|
|
2613
|
+
if (i === -1) throw new AuthError("HELD_GUEST_NOT_FOUND", `No held guest with userId ${id}`, { kind: "unknown" });
|
|
2614
|
+
return i;
|
|
2615
|
+
}
|
|
2616
|
+
// ── SIGN_IN row implementation ───────────────────────────────────────────
|
|
2617
|
+
async doSignInDone(credential) {
|
|
2618
|
+
const r = await this.doSignIn(credential);
|
|
2619
|
+
if (r.kind === "mfa") {
|
|
2620
|
+
throw new AuthError("MFA_REQUIRED", "Multi-factor authentication is required; call challengeMfa(mfaChallengeToken, code)", { kind: "invalid_credential" });
|
|
2621
|
+
}
|
|
2622
|
+
return r;
|
|
1792
2623
|
}
|
|
1793
2624
|
/**
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
1797
|
-
*
|
|
2625
|
+
* The SIGN_IN / SWITCH row (ADR-033 §3.3 row 18). The body carries NO guest
|
|
2626
|
+
* hint; a success HOLDS a previous guest (post-refresh tokens) and installs
|
|
2627
|
+
* the new session under the identity CAS; any failure leaves the caller
|
|
2628
|
+
* exactly as found.
|
|
1798
2629
|
*/
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
const
|
|
1807
|
-
|
|
1808
|
-
const
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2630
|
+
async doSignIn(credential) {
|
|
2631
|
+
if (this.status() === "anonymous" && this.freeSlotIndex() === -1) throw new HeldGuestLimitError();
|
|
2632
|
+
this.attempt = { kind: "sign_in", expect: this.snapshotIdentity() };
|
|
2633
|
+
try {
|
|
2634
|
+
await this.awaitRefreshQuiet();
|
|
2635
|
+
const expect = this.snapshotIdentity();
|
|
2636
|
+
this.attempt = { kind: "sign_in", expect };
|
|
2637
|
+
const prev = this.user ? { userId: this.user.id, isAnonymous: this.user.isAnonymous } : null;
|
|
2638
|
+
const { path, body } = signInWire(credential);
|
|
2639
|
+
const res = await post(this.http(), path, body);
|
|
2640
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
2641
|
+
let parsed;
|
|
2642
|
+
try {
|
|
2643
|
+
parsed = await res.json();
|
|
2644
|
+
} catch {
|
|
2645
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
2646
|
+
}
|
|
2647
|
+
if (!res.ok || !parsed.success || !parsed.data) {
|
|
2648
|
+
if (parsed.error?.code === "CREDENTIAL_IN_USE") {
|
|
2649
|
+
throw new CredentialInUseError(parsed.error.message, { ...readCollisionDetails(parsed), status: res.status });
|
|
2650
|
+
}
|
|
2651
|
+
throw new AuthError(
|
|
2652
|
+
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
2653
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
2654
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
2655
|
+
);
|
|
2656
|
+
}
|
|
2657
|
+
const r = parsed.data;
|
|
2658
|
+
if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
|
|
2659
|
+
const u = r.user;
|
|
2660
|
+
return { kind: "mfa", challengeToken: r.mfaChallengeToken, userId: typeof u?.id === "string" ? u.id : "" };
|
|
2661
|
+
}
|
|
2662
|
+
if (!isAuthApiResponse(parsed.data)) {
|
|
2663
|
+
throw new AuthError(parsed.error?.code ?? "AUTH_ERROR", parsed.error?.message ?? "Sign-in failed");
|
|
1819
2664
|
}
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
const
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2665
|
+
const data = parsed.data;
|
|
2666
|
+
await this.awaitRefreshQuiet();
|
|
2667
|
+
const { held } = this.installSignIn(data, expect);
|
|
2668
|
+
return {
|
|
2669
|
+
kind: "done",
|
|
2670
|
+
user: data.user,
|
|
2671
|
+
previous: prev ? { ...prev, held } : null,
|
|
2672
|
+
reactivated: data.reactivatedFromDeletion === true
|
|
1828
2673
|
};
|
|
1829
|
-
|
|
1830
|
-
this.
|
|
1831
|
-
user,
|
|
1832
|
-
tokens: {
|
|
1833
|
-
accessToken: access,
|
|
1834
|
-
refreshToken: newRefresh,
|
|
1835
|
-
expiresIn,
|
|
1836
|
-
tokenType: "Bearer"
|
|
1837
|
-
},
|
|
1838
|
-
...retiredAnonUserId ? { retiredAnonUserId } : {}
|
|
1839
|
-
});
|
|
1840
|
-
return user;
|
|
1841
|
-
});
|
|
1842
|
-
}
|
|
1843
|
-
bearerHeaders() {
|
|
1844
|
-
if (!this.accessToken) {
|
|
1845
|
-
throw new AuthError("NOT_SIGNED_IN", "Not signed in");
|
|
2674
|
+
} finally {
|
|
2675
|
+
this.attempt = null;
|
|
1846
2676
|
}
|
|
1847
|
-
return { Authorization: `Bearer ${this.accessToken}` };
|
|
1848
2677
|
}
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
const
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
2678
|
+
/** Commit a sign-in response as the SIGN_IN row; a CAS drop revokes the dropped refresh so server session B is not left live (row 19). */
|
|
2679
|
+
installSignIn(data, expect) {
|
|
2680
|
+
const outcome = this.commit({
|
|
2681
|
+
kind: "install",
|
|
2682
|
+
reason: "sign_in",
|
|
2683
|
+
record: { user: data.user, accessToken: data.tokens.accessToken, refreshToken: data.tokens.refreshToken, expiresIn: data.tokens.expiresIn },
|
|
2684
|
+
expect,
|
|
2685
|
+
holdPrevious: true,
|
|
2686
|
+
response: data
|
|
2687
|
+
});
|
|
2688
|
+
if (outcome.status === "project_mismatch") {
|
|
2689
|
+
this.revokeFireAndForget(data.tokens.refreshToken);
|
|
2690
|
+
throw withheldError("PROJECT_MISMATCH", "The signed-in session belongs to a different project than init({expectedProjectId}); nothing was installed");
|
|
1857
2691
|
}
|
|
1858
|
-
|
|
2692
|
+
if (outcome.status === "cas_mismatch" || outcome.status === "held_limit") {
|
|
2693
|
+
this.revokeFireAndForget(data.tokens.refreshToken);
|
|
2694
|
+
if (outcome.status === "held_limit") throw new HeldGuestLimitError();
|
|
2695
|
+
throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the sign-in was in flight; nothing was installed", { kind: "unknown" });
|
|
2696
|
+
}
|
|
2697
|
+
return { held: outcome.status === "applied" && outcome.held };
|
|
1859
2698
|
}
|
|
2699
|
+
// ── LINK row implementation ──────────────────────────────────────────────
|
|
1860
2700
|
/**
|
|
1861
|
-
*
|
|
1862
|
-
*
|
|
1863
|
-
*
|
|
2701
|
+
* The LINK / LINK-UPGRADE rows (ADR-033 §3.3 rows 15–16) on the current
|
|
2702
|
+
* Bearer session. 200 with a rotated pair (`upgraded`) → ProjectGuard →
|
|
2703
|
+
* install (same `sub`); 200 attached → user updated in place; 200
|
|
2704
|
+
* `alreadyLinked` → nothing; 409 / 403 → typed outcomes, nothing mutated.
|
|
1864
2705
|
*/
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
await this.
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2706
|
+
async doLink(path, body, ctx) {
|
|
2707
|
+
this.attempt = { kind: "link", expect: this.snapshotIdentity() };
|
|
2708
|
+
try {
|
|
2709
|
+
await this.awaitRefreshQuiet();
|
|
2710
|
+
const expect = this.snapshotIdentity();
|
|
2711
|
+
this.attempt = { kind: "link", expect };
|
|
2712
|
+
const token = await this.getAccessToken();
|
|
2713
|
+
if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before linking a credential");
|
|
2714
|
+
const res = await post(this.http(), path, body, { Authorization: `Bearer ${token}` });
|
|
2715
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
2716
|
+
let parsed;
|
|
2717
|
+
try {
|
|
2718
|
+
parsed = await res.json();
|
|
2719
|
+
} catch {
|
|
2720
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1875
2721
|
}
|
|
1876
|
-
|
|
2722
|
+
if (!res.ok || !parsed.success || !parsed.data) {
|
|
2723
|
+
const code = parsed.error?.code ?? `HTTP_${res.status}`;
|
|
2724
|
+
if (code === "CREDENTIAL_IN_USE") {
|
|
2725
|
+
const details = readCollisionDetails(parsed);
|
|
2726
|
+
return {
|
|
2727
|
+
status: "credential_in_use",
|
|
2728
|
+
provider: details.provider ?? ctx.label,
|
|
2729
|
+
collision: details.collision ?? (path === LINK_EMAIL_PATH ? "email" : "identity"),
|
|
2730
|
+
message: parsed.error?.message ?? "This credential is already linked to a different account",
|
|
2731
|
+
httpStatus: res.status
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
if (code === "RECENT_AUTH_REQUIRED") {
|
|
2735
|
+
return { status: "recent_auth_required", message: parsed.error?.message ?? "Re-authenticate before changing credentials" };
|
|
2736
|
+
}
|
|
2737
|
+
throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
|
|
2738
|
+
status: res.status,
|
|
2739
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
const d = parsed.data;
|
|
2743
|
+
const alreadyLinked = d.alreadyLinked === true;
|
|
2744
|
+
await this.awaitRefreshQuiet();
|
|
2745
|
+
if (d.upgraded === true && isAuthApiResponse(d)) {
|
|
2746
|
+
const outcome = this.commit({
|
|
2747
|
+
kind: "install",
|
|
2748
|
+
reason: "upgrade",
|
|
2749
|
+
record: { user: d.user, accessToken: d.tokens.accessToken, refreshToken: d.tokens.refreshToken, expiresIn: d.tokens.expiresIn },
|
|
2750
|
+
expect,
|
|
2751
|
+
legacyEmit: ctx.legacyEmit,
|
|
2752
|
+
response: d
|
|
2753
|
+
});
|
|
2754
|
+
if (outcome.status === "project_mismatch") {
|
|
2755
|
+
this.revokeFireAndForget(d.tokens.refreshToken);
|
|
2756
|
+
throw withheldError("PROJECT_MISMATCH", "The promoted session belongs to a different project than init({expectedProjectId}); nothing was installed");
|
|
2757
|
+
}
|
|
2758
|
+
if (outcome.status !== "applied") {
|
|
2759
|
+
this.revokeFireAndForget(d.tokens.refreshToken);
|
|
2760
|
+
throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the link was in flight; nothing was installed", { kind: "unknown" });
|
|
2761
|
+
}
|
|
2762
|
+
return { status: "done", user: d.user, upgraded: true, alreadyLinked };
|
|
2763
|
+
}
|
|
2764
|
+
if (isAuthUserShape(d.user) && !alreadyLinked) {
|
|
2765
|
+
const outcome = this.commit({ kind: "user", reason: "link", user: d.user, expect });
|
|
2766
|
+
if (outcome.status !== "applied") {
|
|
2767
|
+
throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the link was in flight", { kind: "unknown" });
|
|
2768
|
+
}
|
|
2769
|
+
return { status: "done", user: d.user, upgraded: false, alreadyLinked: false };
|
|
2770
|
+
}
|
|
2771
|
+
const user = this.user ?? (isAuthUserShape(d.user) ? d.user : null);
|
|
2772
|
+
if (!user) throw new AuthError("NOT_SIGNED_IN", "The session ended while the link was in flight");
|
|
2773
|
+
return { status: "done", user, upgraded: false, alreadyLinked };
|
|
2774
|
+
} finally {
|
|
2775
|
+
this.attempt = null;
|
|
2776
|
+
}
|
|
1877
2777
|
}
|
|
1878
|
-
//
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2778
|
+
// ── Held-token refresh (REFRESH_OK/DEAD routed to a slot, rows 22 + 25) ──
|
|
2779
|
+
/**
|
|
2780
|
+
* Present a held guest's refresh token to `/token/refresh` — the ONLY way a
|
|
2781
|
+
* held token is ever refreshed (the proactive cron never touches a slot).
|
|
2782
|
+
* The rotation is persisted INTO THE SLOT under a per-slot CAS before anything
|
|
2783
|
+
* else happens; a dead verdict releases the slot only for `endReason ∈
|
|
2784
|
+
* deleted|revoked|expired` (`reuse`/absent keep it: `kept`).
|
|
2785
|
+
*/
|
|
2786
|
+
async refreshHeld(i) {
|
|
2787
|
+
const slot = this.held[i];
|
|
2788
|
+
if (!slot) return { outcome: "dead" };
|
|
2789
|
+
const sent = slot.refreshToken;
|
|
2790
|
+
const res = await post(this.http(), REFRESH_PATH, { refreshToken: sent });
|
|
2791
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
2792
|
+
let parsed;
|
|
2793
|
+
try {
|
|
2794
|
+
parsed = await res.json();
|
|
2795
|
+
} catch {
|
|
2796
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
2797
|
+
}
|
|
2798
|
+
if (!res.ok || !parsed.success) {
|
|
2799
|
+
const code = parsed.error?.code ?? `HTTP_${res.status}`;
|
|
2800
|
+
if (isDeadRefreshError(code)) {
|
|
2801
|
+
const endReason = readEndReason(parsed);
|
|
2802
|
+
if (endReason !== void 0 && RELEASE_HELD_ON_END_REASONS.includes(endReason)) {
|
|
2803
|
+
this.commit({ kind: "release", reason: "dead", slot: i, userId: slot.userId, endReason });
|
|
2804
|
+
return { outcome: "dead" };
|
|
2805
|
+
}
|
|
2806
|
+
return { outcome: "kept" };
|
|
1886
2807
|
}
|
|
2808
|
+
throw new AuthError(code, parsed.error?.message ?? `HTTP ${res.status}`, { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) });
|
|
2809
|
+
}
|
|
2810
|
+
const rotated = readRotatedTokens(parsed.data);
|
|
2811
|
+
if (!rotated) throw new AuthError("PARSE_ERROR", "refresh response carried no token pair", { status: res.status });
|
|
2812
|
+
const d = parsed.data;
|
|
2813
|
+
const user = isAuthUserShape(d?.user) ? d.user : { ...this.userFromClaims(rotated.accessToken), id: slot.userId, isAnonymous: true };
|
|
2814
|
+
const persisted = this.commit({ kind: "slot_tokens", slot: i, sent, refreshToken: rotated.refreshToken });
|
|
2815
|
+
if (persisted.status !== "applied") return { outcome: "kept" };
|
|
2816
|
+
return {
|
|
2817
|
+
outcome: "ok",
|
|
2818
|
+
data: { user, tokens: { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn, tokenType: "Bearer" } }
|
|
1887
2819
|
};
|
|
1888
|
-
const next = this.inflight.then(guarded, guarded);
|
|
1889
|
-
this.inflight = next.catch(() => void 0);
|
|
1890
|
-
return next;
|
|
1891
2820
|
}
|
|
2821
|
+
/** The RESTORE row's install half: ProjectGuard on the refresh response, install BEFORE releasing the slot. */
|
|
2822
|
+
installRestored(i, data, expect) {
|
|
2823
|
+
const slot = this.held[i];
|
|
2824
|
+
const verdict = this.verdictFor(data.user, data.tokens.accessToken);
|
|
2825
|
+
if (verdict === "mismatch") {
|
|
2826
|
+
const actual = this.claimProject(data.tokens.accessToken) ?? data.user.project ?? null;
|
|
2827
|
+
this.commit({ kind: "slot_project", slot: i, project: actual });
|
|
2828
|
+
throw new HeldGuestProjectMismatch(slot.userId, this.hooks.expectedProjectId ?? this.hooks.projectId ?? "", actual);
|
|
2829
|
+
}
|
|
2830
|
+
const outcome = this.commit({
|
|
2831
|
+
kind: "install",
|
|
2832
|
+
reason: "restore",
|
|
2833
|
+
record: { user: data.user, accessToken: data.tokens.accessToken, refreshToken: data.tokens.refreshToken, expiresIn: data.tokens.expiresIn },
|
|
2834
|
+
expect,
|
|
2835
|
+
holdPrevious: true,
|
|
2836
|
+
response: data
|
|
2837
|
+
});
|
|
2838
|
+
if (outcome.status === "held_limit") throw new HeldGuestLimitError();
|
|
2839
|
+
if (outcome.status !== "applied") {
|
|
2840
|
+
throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the restore was in flight; nothing was installed", { kind: "unknown" });
|
|
2841
|
+
}
|
|
2842
|
+
this.commit({ kind: "release", reason: "restored", slot: i, userId: slot.userId });
|
|
2843
|
+
return data.user;
|
|
2844
|
+
}
|
|
2845
|
+
// ── Current-token refresh (rows 20–26) ───────────────────────────────────
|
|
1892
2846
|
/**
|
|
1893
|
-
* Hot-path token refresh. Single-flight via `refreshInflight` so a
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
1896
|
-
*
|
|
2847
|
+
* Hot-path token refresh. Single-flight via `refreshInflight` so a UI batch
|
|
2848
|
+
* firing 10 simultaneous reads never produces 10 `/refresh` round-trips.
|
|
2849
|
+
* Returns null on any failure so callers can decide whether to surface a
|
|
2850
|
+
* sign-in prompt or proceed anonymously.
|
|
1897
2851
|
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
1900
|
-
* the
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
* surface NOT_SIGNED_IN to the host app instead of looping.
|
|
2852
|
+
* The result is applied ONLY if the token that was sent is still the one on
|
|
2853
|
+
* disk (token CAS, INV-2): a refresh landing after `signOut()` must not
|
|
2854
|
+
* resurrect the session, and an OLD token's rejection must not wipe a newer
|
|
2855
|
+
* one. A rotation is not a transition; the user object is adopted when the
|
|
2856
|
+
* SDK has none (gap-fill from `unknown`, 1.29.0) and re-judged by the project
|
|
2857
|
+
* guard before install (a mismatch → `withheld`, INV-15).
|
|
1905
2858
|
*/
|
|
1906
2859
|
async refreshAccessToken() {
|
|
1907
2860
|
if (this.refreshInflight) return this.refreshInflight;
|
|
2861
|
+
if (this.withheld) return null;
|
|
1908
2862
|
const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
|
|
1909
2863
|
if (!refreshSnapshot) return null;
|
|
1910
2864
|
this.refreshInflight = (async () => {
|
|
@@ -1926,27 +2880,64 @@ var Auth = class {
|
|
|
1926
2880
|
}
|
|
1927
2881
|
}
|
|
1928
2882
|
sent = current ?? refreshSnapshot;
|
|
1929
|
-
const
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
2883
|
+
const res = await post(this.http(), REFRESH_PATH, { refreshToken: sent });
|
|
2884
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
2885
|
+
let parsed;
|
|
2886
|
+
try {
|
|
2887
|
+
parsed = await res.json();
|
|
2888
|
+
} catch {
|
|
2889
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
2890
|
+
}
|
|
2891
|
+
if (!res.ok || !parsed.success) {
|
|
2892
|
+
throw new AuthError(parsed.error?.code ?? `HTTP_${res.status}`, parsed.error?.message ?? `HTTP ${res.status}`, {
|
|
2893
|
+
status: res.status,
|
|
2894
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed),
|
|
2895
|
+
endReason: readEndReason(parsed)
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
const rotated = readRotatedTokens(parsed.data);
|
|
2899
|
+
if (!rotated) throw new AuthError("PARSE_ERROR", "refresh response carried no token pair", { status: res.status });
|
|
2900
|
+
const d = parsed.data;
|
|
2901
|
+
const responseUser = isAuthUserShape(d?.user) ? d.user : null;
|
|
2902
|
+
if (this.hooks.storage.get(REFRESH_KEY) === sent) {
|
|
2903
|
+
const user = responseUser ?? this.user;
|
|
2904
|
+
if (!user) {
|
|
2905
|
+
this.commit({ kind: "tokens", sent, accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn });
|
|
2906
|
+
} else {
|
|
2907
|
+
const verdict = this.verdictFor(user, rotated.accessToken);
|
|
2908
|
+
if (verdict === "mismatch") {
|
|
2909
|
+
this.commit({ kind: "withhold", user, sent, accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn });
|
|
2910
|
+
this.refreshFailureCount = 0;
|
|
2911
|
+
this.refreshCooldownUntil = 0;
|
|
2912
|
+
return null;
|
|
2913
|
+
}
|
|
2914
|
+
this.commit({
|
|
2915
|
+
kind: "install",
|
|
2916
|
+
reason: "refresh",
|
|
2917
|
+
record: { user, accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn },
|
|
2918
|
+
expectRefresh: sent
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
} else {
|
|
2922
|
+
const i = this.held.findIndex((h) => h?.refreshToken === sent);
|
|
2923
|
+
if (i !== -1) this.commit({ kind: "slot_tokens", slot: i, sent, refreshToken: rotated.refreshToken });
|
|
2924
|
+
this.refreshFailureCount = 0;
|
|
2925
|
+
this.refreshCooldownUntil = 0;
|
|
2926
|
+
return null;
|
|
1943
2927
|
}
|
|
1944
2928
|
this.refreshFailureCount = 0;
|
|
1945
2929
|
this.refreshCooldownUntil = 0;
|
|
1946
|
-
return
|
|
2930
|
+
return rotated.accessToken;
|
|
1947
2931
|
} catch (err) {
|
|
1948
|
-
if (err instanceof AuthError && isDeadRefreshError(err.code)
|
|
1949
|
-
|
|
2932
|
+
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
2933
|
+
if (this.hooks.storage.get(REFRESH_KEY) === sent) {
|
|
2934
|
+
await this.commitAsync({ kind: "clear", reason: signedOutReasonFor(err.endReason), expectRefresh: sent });
|
|
2935
|
+
} else {
|
|
2936
|
+
const i = this.held.findIndex((h) => h?.refreshToken === sent);
|
|
2937
|
+
if (i !== -1 && err.endReason !== void 0 && RELEASE_HELD_ON_END_REASONS.includes(err.endReason)) {
|
|
2938
|
+
this.commit({ kind: "release", reason: "dead", slot: i, userId: this.held[i].userId, endReason: err.endReason });
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
1950
2941
|
this.refreshFailureCount = 0;
|
|
1951
2942
|
this.refreshCooldownUntil = 0;
|
|
1952
2943
|
return null;
|
|
@@ -1961,16 +2952,7 @@ var Auth = class {
|
|
|
1961
2952
|
})();
|
|
1962
2953
|
return this.refreshInflight;
|
|
1963
2954
|
}
|
|
1964
|
-
/**
|
|
1965
|
-
* Wave 62 — AppState resume hook. Clears stuck backoff so a user
|
|
1966
|
-
* who backgrounded the app during a network blip + returns now
|
|
1967
|
-
* gets an immediate fresh refresh attempt instead of waiting on
|
|
1968
|
-
* the cooldown timer.
|
|
1969
|
-
*
|
|
1970
|
-
* Called from the AppState 'active' listener registered in
|
|
1971
|
-
* startProactiveRefreshCron. Idempotent + cheap — three field
|
|
1972
|
-
* writes; no I/O.
|
|
1973
|
-
*/
|
|
2955
|
+
/** Wave 62 — AppState resume hook: clear stuck backoff so a returning user gets an immediate attempt. */
|
|
1974
2956
|
resetRefreshState() {
|
|
1975
2957
|
this.refreshFailureCount = 0;
|
|
1976
2958
|
this.refreshCooldownUntil = 0;
|
|
@@ -1981,9 +2963,11 @@ var Auth = class {
|
|
|
1981
2963
|
const TICK_MS = 6e4;
|
|
1982
2964
|
const JITTER_MS = 3e4;
|
|
1983
2965
|
const tick = async () => {
|
|
1984
|
-
if (
|
|
2966
|
+
if (this.attempt || this.withheld) return;
|
|
2967
|
+
if (!this.hooks.storage.get(REFRESH_KEY)) return;
|
|
1985
2968
|
const now = Date.now();
|
|
1986
|
-
if (!this.canServeCachedToken(now)) {
|
|
2969
|
+
if (!this.user || !this.accessToken || !this.accessExpiresAt || !this.canServeCachedToken(now)) {
|
|
2970
|
+
if (now < this.refreshCooldownUntil) return;
|
|
1987
2971
|
await this.refreshAndReconcile(now);
|
|
1988
2972
|
return;
|
|
1989
2973
|
}
|
|
@@ -2021,82 +3005,34 @@ var Auth = class {
|
|
|
2021
3005
|
this.proactiveRefreshAppStateUnsub = null;
|
|
2022
3006
|
}
|
|
2023
3007
|
}
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
* Throws AuthError when:
|
|
2042
|
-
* - `anonymousRefreshToken` is invalid / expired / revoked,
|
|
2043
|
-
* - the bound source row is not anonymous (CONFLICT — use upgrade),
|
|
2044
|
-
* - the target is anonymous (CONFLICT — sign in first),
|
|
2045
|
-
* - both tokens resolve to the same user (CONFLICT — no-op).
|
|
2046
|
-
*
|
|
2047
|
-
* Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
|
|
2048
|
-
*/
|
|
2049
|
-
async merge(anonymousRefreshToken, targetRefreshToken) {
|
|
2050
|
-
const target = targetRefreshToken ?? this.getRefreshToken();
|
|
2051
|
-
if (!target) {
|
|
2052
|
-
throw new AuthError(
|
|
2053
|
-
"NO_TARGET_REFRESH",
|
|
2054
|
-
"merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
|
|
2055
|
-
);
|
|
2056
|
-
}
|
|
2057
|
-
const res = await post(
|
|
2058
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
2059
|
-
"/api/v1/auth-service/merge",
|
|
2060
|
-
{ anonymousRefreshToken, targetRefreshToken: target }
|
|
2061
|
-
);
|
|
2062
|
-
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
2063
|
-
let parsed;
|
|
2064
|
-
try {
|
|
2065
|
-
parsed = await res.json();
|
|
2066
|
-
} catch {
|
|
2067
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
2068
|
-
}
|
|
2069
|
-
if (!res.ok || !parsed.success || !parsed.data) {
|
|
2070
|
-
throw new AuthError(
|
|
2071
|
-
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
2072
|
-
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
2073
|
-
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
2074
|
-
);
|
|
3008
|
+
revokeFireAndForget(refreshToken) {
|
|
3009
|
+
void post(this.http(), REVOKE_PATH, { refreshToken }).catch(() => void 0);
|
|
3010
|
+
}
|
|
3011
|
+
/** B15: report a held guest, fire-and-forget; the pending flag is cleared only on a 2xx and retried on hydrate. */
|
|
3012
|
+
reportHeldFireAndForget(userId, refreshToken) {
|
|
3013
|
+
void post(this.http(), "/api/v1/auth-service/guest/held", { refreshToken }).then((res) => {
|
|
3014
|
+
if (res && res.ok) this.commit({ kind: "held_report", userId, pending: false });
|
|
3015
|
+
}).catch(() => void 0);
|
|
3016
|
+
}
|
|
3017
|
+
retryPendingHeldReports() {
|
|
3018
|
+
for (const userId of Array.from(this.heldReportsPending)) {
|
|
3019
|
+
const slot = this.held.find((h) => h?.userId === userId);
|
|
3020
|
+
if (!slot) {
|
|
3021
|
+
this.commit({ kind: "held_report", userId, pending: false });
|
|
3022
|
+
continue;
|
|
3023
|
+
}
|
|
3024
|
+
this.reportHeldFireAndForget(userId, slot.refreshToken);
|
|
2075
3025
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
this.
|
|
2081
|
-
this.
|
|
2082
|
-
this.hooks.storage.set(REFRESH_KEY, t.refreshToken);
|
|
3026
|
+
}
|
|
3027
|
+
/** `Sendora.reset({includeHeldGuests:true})`: release every slot locally (NO server delete), via commit. */
|
|
3028
|
+
releaseAllHeldGuests() {
|
|
3029
|
+
for (let i = 0; i < HELD_SLOT_KEYS.length; i++) {
|
|
3030
|
+
const slot = this.held[i];
|
|
3031
|
+
if (slot) this.commit({ kind: "release", reason: "reset", slot: i, userId: slot.userId });
|
|
2083
3032
|
}
|
|
2084
|
-
return {
|
|
2085
|
-
mergedUserId: parsed.data.mergedUserId ?? "",
|
|
2086
|
-
deletedAnonymousId: parsed.data.deletedAnonymousId ?? "",
|
|
2087
|
-
eventsReassigned: parsed.data.eventsReassigned ?? 0,
|
|
2088
|
-
profilesReassigned: parsed.data.profilesReassigned ?? 0,
|
|
2089
|
-
profilesDropped: parsed.data.profilesDropped ?? 0,
|
|
2090
|
-
entitiesReassigned: parsed.data.entitiesReassigned ?? 0,
|
|
2091
|
-
entitiesDropped: parsed.data.entitiesDropped ?? 0
|
|
2092
|
-
};
|
|
2093
3033
|
}
|
|
2094
3034
|
async callAuth(path, body) {
|
|
2095
|
-
const res = await post(
|
|
2096
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
2097
|
-
path,
|
|
2098
|
-
body
|
|
2099
|
-
);
|
|
3035
|
+
const res = await post(this.http(), path, body);
|
|
2100
3036
|
if (!res) {
|
|
2101
3037
|
throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
2102
3038
|
}
|
|
@@ -2115,57 +3051,143 @@ var Auth = class {
|
|
|
2115
3051
|
}
|
|
2116
3052
|
return parsed.data;
|
|
2117
3053
|
}
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
3054
|
+
// ============================================================
|
|
3055
|
+
// commit() — THE ONLY WRITER (ADR-033 §3.3, INV-1)
|
|
3056
|
+
// ============================================================
|
|
3057
|
+
//
|
|
3058
|
+
// Every assignment of `this.user` / `this.accessToken` / `this.accessExpiresAt`
|
|
3059
|
+
// / `this.held` / `this.withheld`, every write of an auth storage key, every
|
|
3060
|
+
// cron start/stop and every `onAuthStateChanged` emission happens inside this
|
|
3061
|
+
// method or the private helpers only it calls. Public methods build a `Tx`
|
|
3062
|
+
// and hand it over; the CAS and the ProjectGuard are applied HERE, not at
|
|
3063
|
+
// the call sites, so no call site can forget them.
|
|
3064
|
+
commit(tx) {
|
|
3065
|
+
switch (tx.kind) {
|
|
3066
|
+
case "install":
|
|
3067
|
+
return this.commitInstall(tx);
|
|
3068
|
+
case "clear":
|
|
3069
|
+
return this.commitClear(tx);
|
|
3070
|
+
case "user":
|
|
3071
|
+
return this.commitUser(tx);
|
|
3072
|
+
case "release":
|
|
3073
|
+
return this.commitRelease(tx);
|
|
3074
|
+
case "withhold":
|
|
3075
|
+
return this.commitWithhold(tx);
|
|
3076
|
+
case "discard_withheld":
|
|
3077
|
+
return this.commitDiscardWithheld();
|
|
3078
|
+
case "tokens":
|
|
3079
|
+
return this.commitTokens(tx);
|
|
3080
|
+
case "slot_tokens":
|
|
3081
|
+
return this.commitSlotTokens(tx);
|
|
3082
|
+
case "slot_project":
|
|
3083
|
+
return this.commitSlotProject(tx);
|
|
3084
|
+
case "held_report":
|
|
3085
|
+
return this.commitHeldReport(tx);
|
|
3086
|
+
default:
|
|
3087
|
+
return { status: "dropped" };
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
/** `commit()` for the clear path, awaiting the parent's `onAnonymousWipe` hook before resolving (the emission has already fired). */
|
|
3091
|
+
async commitAsync(tx) {
|
|
3092
|
+
const result = this.commit(tx);
|
|
3093
|
+
if (this.pendingWipeHook) {
|
|
3094
|
+
const p = this.pendingWipeHook;
|
|
3095
|
+
this.pendingWipeHook = null;
|
|
3096
|
+
await p.catch(() => void 0);
|
|
3097
|
+
}
|
|
3098
|
+
return result;
|
|
3099
|
+
}
|
|
3100
|
+
commitInstall(tx) {
|
|
3101
|
+
const { record } = tx;
|
|
3102
|
+
if (tx.expect && !this.identityMatches(tx.expect)) return { status: "cas_mismatch" };
|
|
3103
|
+
if (tx.expectRefresh !== void 0 && this.hooks.storage.get(REFRESH_KEY) !== tx.expectRefresh) return { status: "cas_mismatch" };
|
|
3104
|
+
if (tx.reason !== "hydrate") {
|
|
3105
|
+
const verdict = this.verdictFor(record.user, record.accessToken);
|
|
3106
|
+
if (verdict === "mismatch") return { status: "project_mismatch" };
|
|
3107
|
+
}
|
|
3108
|
+
const prevUser = this.user;
|
|
3109
|
+
const prevRefresh = this.hooks.storage.get(REFRESH_KEY);
|
|
3110
|
+
let held = false;
|
|
3111
|
+
if (tx.reason === "hydrate") {
|
|
3112
|
+
this.user = record.user;
|
|
3113
|
+
this.accessToken = record.accessToken || null;
|
|
3114
|
+
this.accessExpiresAt = this.accessToken ? tx.accessExpiresAt ?? 0 : 0;
|
|
3115
|
+
if (tx.writeForward) this.writeSessionRecordOnly(record.user, this.accessToken, this.accessExpiresAt, record.refreshToken);
|
|
3116
|
+
this.hooks.onIdentityChange(record.user.id);
|
|
3117
|
+
this.startProactiveRefreshCron();
|
|
3118
|
+
return { status: "applied", held: false };
|
|
3119
|
+
}
|
|
3120
|
+
if (tx.holdPrevious && prevUser && prevRefresh && prevUser.isAnonymous && prevUser.id !== record.user.id) {
|
|
3121
|
+
const free = this.freeSlotIndex();
|
|
3122
|
+
if (free === -1) return { status: "held_limit" };
|
|
3123
|
+
const slot = {
|
|
3124
|
+
v: 1,
|
|
3125
|
+
userId: prevUser.id,
|
|
3126
|
+
sid: this.snapshotIdentity().sid ?? "",
|
|
3127
|
+
refreshToken: prevRefresh,
|
|
3128
|
+
tokenAtHold: prevRefresh,
|
|
3129
|
+
// Strictly newer than every held slot: two holds in the same millisecond would otherwise tie,
|
|
3130
|
+
// leaving "newest" (getHeldGuests() order, the auto-restore pick) undefined. Web found it as a
|
|
3131
|
+
// 1-in-4 flake; same fix in all four SDKs (s58.356).
|
|
3132
|
+
heldAt: Math.max(Date.now(), ...this.held.filter((h) => h !== null).map((h) => h.heldAt + 1)),
|
|
3133
|
+
project: this.claimProject(this.accessToken) ?? (typeof prevUser.project === "string" ? prevUser.project : null),
|
|
3134
|
+
autoRestore: true
|
|
3135
|
+
};
|
|
3136
|
+
this.writeSlot(free, slot);
|
|
3137
|
+
this.heldReportsPending.add(prevUser.id);
|
|
3138
|
+
held = true;
|
|
3139
|
+
this.emitAuthState({ event: "guest_held", userId: prevUser.id, count: this.heldCount(), user: prevUser });
|
|
3140
|
+
} else if (prevUser && prevRefresh && !prevUser.isAnonymous && prevUser.id !== record.user.id && (tx.reason === "sign_in" || tx.reason === "mint" || tx.reason === "restore")) {
|
|
3141
|
+
this.revokeFireAndForget(prevRefresh);
|
|
3142
|
+
}
|
|
3143
|
+
const userChanged = !prevUser || JSON.stringify(prevUser) !== JSON.stringify(record.user);
|
|
3144
|
+
this.user = record.user;
|
|
3145
|
+
this.accessToken = record.accessToken;
|
|
3146
|
+
this.accessExpiresAt = Date.now() + record.expiresIn * 1e3;
|
|
3147
|
+
this.writeSessionRecord(record.user, record.accessToken, this.accessExpiresAt, record.refreshToken);
|
|
3148
|
+
this.hooks.onIdentityChange(record.user.id);
|
|
2127
3149
|
this.startProactiveRefreshCron();
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
user:
|
|
2134
|
-
|
|
2135
|
-
|
|
3150
|
+
switch (tx.reason) {
|
|
3151
|
+
case "mint":
|
|
3152
|
+
this.emitAuthState({ event: "signed_in", user: record.user, cause: "mint" });
|
|
3153
|
+
break;
|
|
3154
|
+
case "sign_in":
|
|
3155
|
+
this.emitAuthState({ event: "signed_in", user: record.user, cause: "sign_in" });
|
|
3156
|
+
break;
|
|
3157
|
+
case "restore":
|
|
3158
|
+
this.emitAuthState({ event: "signed_in", user: record.user, cause: "restore" });
|
|
3159
|
+
break;
|
|
3160
|
+
case "upgrade":
|
|
3161
|
+
if (tx.legacyEmit) this.emitAuthState({ event: "signed_in", user: record.user, cause: "link" });
|
|
3162
|
+
else this.emitAuthState({ event: "user_updated", user: record.user, cause: "link" });
|
|
3163
|
+
break;
|
|
3164
|
+
case "claim":
|
|
3165
|
+
if (!tx.silent) this.emitAuthState({ event: "user_updated", user: record.user, cause: "merge" });
|
|
3166
|
+
break;
|
|
3167
|
+
case "refresh":
|
|
3168
|
+
case "recover":
|
|
3169
|
+
if (!prevUser) this.emitAuthState({ event: "signed_in", user: record.user });
|
|
3170
|
+
else if (userChanged) this.emitAuthState({ event: "user_updated", user: record.user, cause: "refresh" });
|
|
3171
|
+
break;
|
|
3172
|
+
default:
|
|
3173
|
+
break;
|
|
2136
3174
|
}
|
|
2137
|
-
|
|
2138
|
-
if (data
|
|
2139
|
-
this.
|
|
2140
|
-
this.emitAuthState({ event: "
|
|
3175
|
+
const data = tx.response;
|
|
3176
|
+
if (data?.retiredAnonUserId) {
|
|
3177
|
+
this.fireDeviceTakeover(data.retiredAnonUserId, record.user.id);
|
|
3178
|
+
this.emitAuthState({ event: "device_takeover", user: record.user, retiredAnonUserId: data.retiredAnonUserId });
|
|
2141
3179
|
}
|
|
3180
|
+
if (data?.anonRetirement) this.lastAnonRetirement = data.anonRetirement;
|
|
3181
|
+
if (data?.reactivatedFromDeletion) {
|
|
3182
|
+
this.fireDeletionCancelled(record.user.id);
|
|
3183
|
+
this.emitAuthState({ event: "deletion_cancelled", user: record.user });
|
|
3184
|
+
}
|
|
3185
|
+
if (held && prevUser) this.reportHeldFireAndForget(prevUser.id, prevRefresh);
|
|
3186
|
+
return { status: "applied", held };
|
|
2142
3187
|
}
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
* `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
|
|
2147
|
-
* emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
|
|
2148
|
-
* on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
|
|
2149
|
-
* not a spurious logout/login pair.
|
|
2150
|
-
*
|
|
2151
|
-
* ⚠ Never call this before a fallible network request. A failed sign-in must
|
|
2152
|
-
* leave the caller exactly as it found them; for an anonymous session the
|
|
2153
|
-
* refresh token dropped here is the ONLY durable handle on the account, so a
|
|
2154
|
-
* pre-call wipe that is not followed by a successful `persist()` orphans the
|
|
2155
|
-
* account permanently (offline: deterministically). Capture the anon refresh
|
|
2156
|
-
* into a local, make the call, and wipe only after the response validates.
|
|
2157
|
-
*/
|
|
2158
|
-
/**
|
|
2159
|
-
* True when `sent` is still the refresh token on disk — i.e. nothing
|
|
2160
|
-
* replaced or cleared the session while a request carrying it was in
|
|
2161
|
-
* flight. Guards both directions of the refresh race: installing a rotated
|
|
2162
|
-
* token over a session that has since been signed out or replaced, and
|
|
2163
|
-
* wiping a fresh session because an OLD token was rejected.
|
|
2164
|
-
*/
|
|
2165
|
-
tokenStillCurrent(sent) {
|
|
2166
|
-
return this.hooks.storage.get(REFRESH_KEY) === sent;
|
|
2167
|
-
}
|
|
2168
|
-
async wipeLocalIdentity(reason = "replaced") {
|
|
3188
|
+
commitClear(tx) {
|
|
3189
|
+
if (tx.expectRefresh !== void 0 && this.hooks.storage.get(REFRESH_KEY) !== tx.expectRefresh) return { status: "dropped" };
|
|
3190
|
+
const prevRefresh = this.hooks.storage.get(REFRESH_KEY);
|
|
2169
3191
|
this.user = null;
|
|
2170
3192
|
this.accessToken = null;
|
|
2171
3193
|
this.accessExpiresAt = 0;
|
|
@@ -2173,21 +3195,216 @@ var Auth = class {
|
|
|
2173
3195
|
this.lastTakeover = null;
|
|
2174
3196
|
this.lastDeletionCancelled = null;
|
|
2175
3197
|
this.lastAnonRetirement = null;
|
|
2176
|
-
this.
|
|
2177
|
-
this.
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
3198
|
+
this.clearSessionKeys();
|
|
3199
|
+
this.stopProactiveRefreshCron();
|
|
3200
|
+
if (tx.suppressHeldAutoRestore) {
|
|
3201
|
+
for (let i = 0; i < HELD_SLOT_KEYS.length; i++) {
|
|
3202
|
+
const slot = this.held[i];
|
|
3203
|
+
if (slot && slot.autoRestore) this.writeSlot(i, { ...slot, autoRestore: false });
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
const reason = tx.reason;
|
|
3207
|
+
if (reason === "stale_blob" || reason === "replaced") return { status: "applied", held: false };
|
|
3208
|
+
if (reason === "user" && prevRefresh) this.revokeFireAndForget(prevRefresh);
|
|
3209
|
+
this.pendingWipeHook = this.wipeLocalIdentity(reason);
|
|
3210
|
+
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
3211
|
+
return { status: "applied", held: false };
|
|
3212
|
+
}
|
|
3213
|
+
/**
|
|
3214
|
+
* The parent-SDK half of a clear: rotate the analytics anon id at a person
|
|
3215
|
+
* boundary (`"user"` / `"account_deleted"`), drop the queue on deletion, null
|
|
3216
|
+
* the analytics user id. Kept as its own method so its reasons stay pinned.
|
|
3217
|
+
*/
|
|
3218
|
+
wipeLocalIdentity(reason) {
|
|
3219
|
+
return this.hooks.onAnonymousWipe(reason);
|
|
3220
|
+
}
|
|
3221
|
+
commitUser(tx) {
|
|
3222
|
+
if (tx.expect && !this.identityMatches(tx.expect)) return { status: "cas_mismatch" };
|
|
3223
|
+
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
3224
|
+
if (!refresh) return { status: "dropped" };
|
|
3225
|
+
this.user = tx.user;
|
|
3226
|
+
this.writeSessionRecord(tx.user, this.accessToken, this.accessExpiresAt, refresh);
|
|
3227
|
+
this.hooks.onIdentityChange(tx.user.id);
|
|
3228
|
+
this.emitAuthState({ event: "user_updated", user: tx.user, cause: tx.reason });
|
|
3229
|
+
return { status: "applied", held: false };
|
|
3230
|
+
}
|
|
3231
|
+
commitRelease(tx) {
|
|
3232
|
+
const slot = this.held[tx.slot];
|
|
3233
|
+
if (!slot) return { status: "dropped" };
|
|
3234
|
+
this.writeSlot(tx.slot, null);
|
|
3235
|
+
if (this.heldReportsPending.delete(tx.userId)) this.persistPendingReports();
|
|
3236
|
+
const change = tx.endReason ? { event: "guest_released", userId: tx.userId, reason: tx.reason, endReason: tx.endReason } : { event: "guest_released", userId: tx.userId, reason: tx.reason };
|
|
3237
|
+
this.emitAuthState(change);
|
|
3238
|
+
return { status: "applied", held: false };
|
|
3239
|
+
}
|
|
3240
|
+
/** Row 21 — a refresh of the CURRENT token answered with a mismatching claim: persist the rotation (token CAS) and withhold. */
|
|
3241
|
+
commitWithhold(tx) {
|
|
3242
|
+
if (this.hooks.storage.get(REFRESH_KEY) !== tx.sent) return { status: "dropped" };
|
|
3243
|
+
const expiresAt = Date.now() + tx.expiresIn * 1e3;
|
|
3244
|
+
this.writeTokens(tx.accessToken, expiresAt, tx.refreshToken);
|
|
3245
|
+
this.enterWithheld(tx.user, tx.accessToken, tx.refreshToken, true);
|
|
3246
|
+
return { status: "withheld" };
|
|
3247
|
+
}
|
|
3248
|
+
/** Enter `withheld`: in-memory only (ZERO storage writes), no cron, analytics quarantined; emits `session_withheld` only for an in-process transition. */
|
|
3249
|
+
enterWithheld(user, accessToken, refreshToken, emit = false) {
|
|
3250
|
+
const expected = this.hooks.expectedProjectId ?? "";
|
|
3251
|
+
const actual = this.claimProject(accessToken) ?? (user.project === void 0 ? null : user.project);
|
|
3252
|
+
this.user = null;
|
|
3253
|
+
this.accessToken = null;
|
|
3254
|
+
this.accessExpiresAt = 0;
|
|
3255
|
+
this.withheld = { reason: "project_mismatch", expectedProjectId: expected, actualProjectId: actual, userId: user.id, isAnonymous: user.isAnonymous === true };
|
|
3256
|
+
this.withheldRefresh = refreshToken;
|
|
2181
3257
|
this.stopProactiveRefreshCron();
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
3258
|
+
try {
|
|
3259
|
+
this.hooks.onIdentityWithheld?.(this.withheld);
|
|
3260
|
+
} catch (err) {
|
|
3261
|
+
if (this.hooks.debug) console.warn("[Sendora] onIdentityWithheld hook threw", err);
|
|
3262
|
+
}
|
|
3263
|
+
if (emit) {
|
|
3264
|
+
this.emitAuthState({ event: "session_withheld", reason: "project_mismatch", expectedProjectId: expected, actualProjectId: actual, userId: user.id });
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
/** Row 14 — `discardWithheld()` / `signOut()` in `withheld`: LOCAL ONLY, keeps held slots + queue + `user_id`. */
|
|
3268
|
+
commitDiscardWithheld() {
|
|
3269
|
+
if (!this.withheld) return { status: "dropped" };
|
|
3270
|
+
const onDisk = this.hooks.storage.get(REFRESH_KEY);
|
|
3271
|
+
if (this.withheldRefresh !== null && onDisk !== null && onDisk !== this.withheldRefresh) return { status: "dropped" };
|
|
3272
|
+
const refresh = onDisk ?? this.withheldRefresh;
|
|
3273
|
+
this.clearSessionKeys();
|
|
3274
|
+
this.withheld = null;
|
|
3275
|
+
this.withheldRefresh = null;
|
|
3276
|
+
if (refresh) this.revokeFireAndForget(refresh);
|
|
3277
|
+
this.emitAuthState({ event: "signed_out", user: null, reason: "user" });
|
|
3278
|
+
return { status: "applied", held: false };
|
|
3279
|
+
}
|
|
3280
|
+
/** A rotated pair for the current session when no user is known to install (pre-B17 gap-fill). Token CAS. */
|
|
3281
|
+
commitTokens(tx) {
|
|
3282
|
+
if (this.hooks.storage.get(REFRESH_KEY) !== tx.sent) return { status: "dropped" };
|
|
3283
|
+
const expiresAt = Date.now() + tx.expiresIn * 1e3;
|
|
3284
|
+
this.writeTokens(tx.accessToken, expiresAt, tx.refreshToken);
|
|
3285
|
+
this.accessToken = tx.accessToken;
|
|
3286
|
+
this.accessExpiresAt = expiresAt;
|
|
3287
|
+
return { status: "applied", held: false };
|
|
3288
|
+
}
|
|
3289
|
+
/** Row 22 — CAS PER SLOT: re-read the slot at write time; if it no longer holds `sent`, DROP. */
|
|
3290
|
+
commitSlotTokens(tx) {
|
|
3291
|
+
const onDisk = this.readSlot(tx.slot);
|
|
3292
|
+
if (!onDisk || onDisk.refreshToken !== tx.sent) return { status: "dropped" };
|
|
3293
|
+
this.writeSlot(tx.slot, { ...onDisk, refreshToken: tx.refreshToken });
|
|
3294
|
+
return { status: "applied", held: false };
|
|
3295
|
+
}
|
|
3296
|
+
/** RESTORE row: a `null` slot project judged on the refresh response records the claim it saw. */
|
|
3297
|
+
commitSlotProject(tx) {
|
|
3298
|
+
const onDisk = this.readSlot(tx.slot);
|
|
3299
|
+
if (!onDisk) return { status: "dropped" };
|
|
3300
|
+
this.writeSlot(tx.slot, { ...onDisk, project: tx.project });
|
|
3301
|
+
return { status: "applied", held: false };
|
|
3302
|
+
}
|
|
3303
|
+
commitHeldReport(tx) {
|
|
3304
|
+
const before = this.heldReportsPending.size;
|
|
3305
|
+
if (tx.pending) this.heldReportsPending.add(tx.userId);
|
|
3306
|
+
else this.heldReportsPending.delete(tx.userId);
|
|
3307
|
+
if (this.heldReportsPending.size !== before) this.persistPendingReports();
|
|
3308
|
+
return { status: "applied", held: false };
|
|
3309
|
+
}
|
|
3310
|
+
// ── Storage write helpers (called ONLY from the commit handlers above) ──
|
|
3311
|
+
/** Dual-write: the four frozen keys (downgrade-readable) + the `auth_session` record. */
|
|
3312
|
+
writeSessionRecord(user, accessToken, accessExpiresAt, refreshToken) {
|
|
3313
|
+
const st = this.hooks.storage;
|
|
3314
|
+
st.set(USER_KEY, JSON.stringify(user));
|
|
3315
|
+
if (accessToken) {
|
|
3316
|
+
st.set(TOKEN_KEY, accessToken);
|
|
3317
|
+
st.set(TOKEN_EXPIRES_KEY, String(accessExpiresAt));
|
|
3318
|
+
} else {
|
|
3319
|
+
st.remove(TOKEN_KEY);
|
|
3320
|
+
st.remove(TOKEN_EXPIRES_KEY);
|
|
3321
|
+
}
|
|
3322
|
+
st.set(REFRESH_KEY, refreshToken);
|
|
3323
|
+
const record = { v: 2, user, accessToken, accessExpiresAt, refreshToken };
|
|
3324
|
+
if (this.heldReportsPending.size > 0) record.heldReportsPending = Array.from(this.heldReportsPending);
|
|
3325
|
+
st.set(SESSION_KEY, JSON.stringify(record));
|
|
3326
|
+
}
|
|
3327
|
+
/** Hydrate's write-forward: ONLY the `auth_session` record (the frozen keys already hold the session untouched). */
|
|
3328
|
+
writeSessionRecordOnly(user, accessToken, accessExpiresAt, refreshToken) {
|
|
3329
|
+
const record = { v: 2, user, accessToken, accessExpiresAt, refreshToken };
|
|
3330
|
+
if (this.heldReportsPending.size > 0) record.heldReportsPending = Array.from(this.heldReportsPending);
|
|
3331
|
+
this.hooks.storage.set(SESSION_KEY, JSON.stringify(record));
|
|
3332
|
+
}
|
|
3333
|
+
/** The rotated pair only (the user is unchanged) — frozen keys + the record's token fields. */
|
|
3334
|
+
writeTokens(accessToken, accessExpiresAt, refreshToken) {
|
|
3335
|
+
const st = this.hooks.storage;
|
|
3336
|
+
st.set(TOKEN_KEY, accessToken);
|
|
3337
|
+
st.set(TOKEN_EXPIRES_KEY, String(accessExpiresAt));
|
|
3338
|
+
st.set(REFRESH_KEY, refreshToken);
|
|
3339
|
+
let record = { v: 2 };
|
|
3340
|
+
const raw = st.get(SESSION_KEY);
|
|
3341
|
+
if (raw) {
|
|
3342
|
+
try {
|
|
3343
|
+
const parsed = JSON.parse(raw);
|
|
3344
|
+
if (parsed && typeof parsed === "object" && parsed.v === 2) record = parsed;
|
|
3345
|
+
} catch {
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
if (!record.user) {
|
|
3349
|
+
const legacy = st.get(USER_KEY);
|
|
3350
|
+
if (legacy) {
|
|
3351
|
+
try {
|
|
3352
|
+
const u = JSON.parse(legacy);
|
|
3353
|
+
if (isAuthUserShape(u)) record.user = u;
|
|
3354
|
+
} catch {
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
record.accessToken = accessToken;
|
|
3359
|
+
record.accessExpiresAt = accessExpiresAt;
|
|
3360
|
+
record.refreshToken = refreshToken;
|
|
3361
|
+
if (this.heldReportsPending.size > 0) record.heldReportsPending = Array.from(this.heldReportsPending);
|
|
3362
|
+
st.set(SESSION_KEY, JSON.stringify(record));
|
|
3363
|
+
}
|
|
3364
|
+
/** Clear EXACTLY the session keys (the four frozen + the record). Held slots, the queue and `user_id` are never touched here. */
|
|
3365
|
+
clearSessionKeys() {
|
|
3366
|
+
const st = this.hooks.storage;
|
|
3367
|
+
st.remove(USER_KEY);
|
|
3368
|
+
st.remove(TOKEN_KEY);
|
|
3369
|
+
st.remove(TOKEN_EXPIRES_KEY);
|
|
3370
|
+
st.remove(REFRESH_KEY);
|
|
3371
|
+
this.persistPendingReports();
|
|
3372
|
+
}
|
|
3373
|
+
/** Keep the B15 pending-report flags on disk even with no session (a stub record); remove the record when nothing is pending. */
|
|
3374
|
+
persistPendingReports() {
|
|
3375
|
+
const st = this.hooks.storage;
|
|
3376
|
+
if (this.hooks.storage.get(REFRESH_KEY) && this.user) {
|
|
3377
|
+
this.writeSessionRecord(this.user, this.accessToken, this.accessExpiresAt, this.hooks.storage.get(REFRESH_KEY));
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
if (this.heldReportsPending.size > 0) {
|
|
3381
|
+
st.set(SESSION_KEY, JSON.stringify({ v: 2, heldReportsPending: Array.from(this.heldReportsPending) }));
|
|
3382
|
+
} else {
|
|
3383
|
+
st.remove(SESSION_KEY);
|
|
2185
3384
|
}
|
|
2186
3385
|
}
|
|
3386
|
+
writeSlot(i, slot) {
|
|
3387
|
+
const key = HELD_SLOT_KEYS[i];
|
|
3388
|
+
this.held[i] = slot;
|
|
3389
|
+
if (slot) this.hooks.storage.set(key, JSON.stringify(slot));
|
|
3390
|
+
else this.hooks.storage.remove(key);
|
|
3391
|
+
}
|
|
2187
3392
|
};
|
|
3393
|
+
function signedOutReasonFor(endReason) {
|
|
3394
|
+
switch (endReason) {
|
|
3395
|
+
case "revoked":
|
|
3396
|
+
return "revoked";
|
|
3397
|
+
case "deleted":
|
|
3398
|
+
return "deleted";
|
|
3399
|
+
case "evicted":
|
|
3400
|
+
return "evicted";
|
|
3401
|
+
default:
|
|
3402
|
+
return "session_expired";
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
2188
3405
|
function isDeadRefreshError(code) {
|
|
2189
3406
|
if (!code) return false;
|
|
2190
|
-
return code
|
|
3407
|
+
return DEAD_CODES.includes(code);
|
|
2191
3408
|
}
|
|
2192
3409
|
function readRotatedTokens(data) {
|
|
2193
3410
|
if (!data || typeof data !== "object") return null;
|
|
@@ -2205,19 +3422,6 @@ function accessTokenExpIsFuture(token, nowMs) {
|
|
|
2205
3422
|
if (typeof exp !== "number" || !Number.isFinite(exp)) return true;
|
|
2206
3423
|
return nowMs < exp * 1e3;
|
|
2207
3424
|
}
|
|
2208
|
-
function decodeJwtPayload(token) {
|
|
2209
|
-
try {
|
|
2210
|
-
const parts = token.split(".");
|
|
2211
|
-
if (parts.length !== 3) return null;
|
|
2212
|
-
const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
2213
|
-
const pad = (4 - padded.length % 4) % 4;
|
|
2214
|
-
if (typeof atob !== "function") return null;
|
|
2215
|
-
const json = atob(padded + "===".slice(0, pad));
|
|
2216
|
-
return JSON.parse(json);
|
|
2217
|
-
} catch {
|
|
2218
|
-
return null;
|
|
2219
|
-
}
|
|
2220
|
-
}
|
|
2221
3425
|
|
|
2222
3426
|
// src/links.ts
|
|
2223
3427
|
var import_react_native3 = require("react-native");
|
|
@@ -3017,14 +4221,20 @@ var ANON_KEY = "anon_id";
|
|
|
3017
4221
|
var USER_ID_KEY = "user_id";
|
|
3018
4222
|
var EVENT_QUEUE_KEY = "event_queue";
|
|
3019
4223
|
var SCHEMA_VERSION_KEY = "schema_version";
|
|
3020
|
-
var SCHEMA_VERSION = "
|
|
3021
|
-
var
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
4224
|
+
var SCHEMA_VERSION = "2";
|
|
4225
|
+
var SECURE_KEYS = AUTH_SECURE_KEYS;
|
|
4226
|
+
var ALL_SDK_KEYS = [ANON_KEY, USER_ID_KEY, SCHEMA_VERSION_KEY, EVENT_QUEUE_KEY, ...AUTH_HYDRATE_KEYS];
|
|
4227
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4228
|
+
function normalizeExpectedProjectId(expected, projectId) {
|
|
4229
|
+
if (expected === void 0) return void 0;
|
|
4230
|
+
if (typeof expected !== "string" || !UUID_RE.test(expected)) {
|
|
4231
|
+
throw new Error(`[sendora] init: expectedProjectId must be a UUID (got ${JSON.stringify(expected)}).`);
|
|
4232
|
+
}
|
|
4233
|
+
if (projectId !== void 0 && projectId !== expected) {
|
|
4234
|
+
throw new Error(`[sendora] init: expectedProjectId (${expected}) must equal projectId (${projectId}) when both are set.`);
|
|
4235
|
+
}
|
|
4236
|
+
return expected;
|
|
4237
|
+
}
|
|
3028
4238
|
var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
|
|
3029
4239
|
var STORAGE_PREFIX_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
3030
4240
|
function normalizeStoragePrefix(prefix) {
|
|
@@ -3163,6 +4373,8 @@ var SendoraSDK = class {
|
|
|
3163
4373
|
this.sessionId = "";
|
|
3164
4374
|
this.autoTrackCleanup = null;
|
|
3165
4375
|
this.eventQueue = null;
|
|
4376
|
+
/** ADR-033 [2c]: subscribers to `onIdentityWithheld`. */
|
|
4377
|
+
this.withheldListeners = /* @__PURE__ */ new Set();
|
|
3166
4378
|
// --- Engagement time (foreground-only, Wave 75) ---
|
|
3167
4379
|
this.engEnabled = false;
|
|
3168
4380
|
this.engScreen = null;
|
|
@@ -3210,6 +4422,7 @@ var SendoraSDK = class {
|
|
|
3210
4422
|
`[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
|
|
3211
4423
|
);
|
|
3212
4424
|
}
|
|
4425
|
+
const expectedProjectId = normalizeExpectedProjectId(config.expectedProjectId, config.projectId);
|
|
3213
4426
|
if (config.secureStorage || config.storagePrefix !== void 0) {
|
|
3214
4427
|
const prefix = normalizeStoragePrefix(config.storagePrefix);
|
|
3215
4428
|
this.storage = new Storage({
|
|
@@ -3218,16 +4431,7 @@ var SendoraSDK = class {
|
|
|
3218
4431
|
prefix
|
|
3219
4432
|
});
|
|
3220
4433
|
}
|
|
3221
|
-
await this.storage.hydrate([
|
|
3222
|
-
ANON_KEY,
|
|
3223
|
-
USER_ID_KEY,
|
|
3224
|
-
SCHEMA_VERSION_KEY,
|
|
3225
|
-
EVENT_QUEUE_KEY,
|
|
3226
|
-
...AUTH_HYDRATE_KEYS
|
|
3227
|
-
]);
|
|
3228
|
-
if (!this.storage.get(SCHEMA_VERSION_KEY)) {
|
|
3229
|
-
this.storage.set(SCHEMA_VERSION_KEY, SCHEMA_VERSION);
|
|
3230
|
-
}
|
|
4434
|
+
await this.storage.hydrate([...ALL_SDK_KEYS]);
|
|
3231
4435
|
let anon = this.storage.get(ANON_KEY);
|
|
3232
4436
|
if (!anon) {
|
|
3233
4437
|
anon = uuid();
|
|
@@ -3253,6 +4457,22 @@ var SendoraSDK = class {
|
|
|
3253
4457
|
apiUrl: this.config.apiUrl,
|
|
3254
4458
|
publicKey: this.config.publicKey,
|
|
3255
4459
|
debug: this.debug,
|
|
4460
|
+
expectedProjectId,
|
|
4461
|
+
projectId: this.config.projectId,
|
|
4462
|
+
autoRestoreHeldGuest: config.autoRestoreHeldGuest !== false,
|
|
4463
|
+
// ADR-033 [2c]: the stored session was refused by the project guard —
|
|
4464
|
+
// quarantine analytics by nulling the IN-MEMORY user id only. The
|
|
4465
|
+
// persisted `user_id` is untouched (a withheld hydrate writes nothing).
|
|
4466
|
+
onIdentityWithheld: (info) => {
|
|
4467
|
+
this.userId = null;
|
|
4468
|
+
for (const cb of this.withheldListeners) {
|
|
4469
|
+
try {
|
|
4470
|
+
cb(info);
|
|
4471
|
+
} catch (err) {
|
|
4472
|
+
if (this.debug) console.warn("[sendoracloud] onIdentityWithheld listener threw", err);
|
|
4473
|
+
}
|
|
4474
|
+
}
|
|
4475
|
+
},
|
|
3256
4476
|
onIdentityChange: (userId) => {
|
|
3257
4477
|
this.userId = userId;
|
|
3258
4478
|
if (userId) this.storage.set(USER_ID_KEY, userId);
|
|
@@ -3269,6 +4489,9 @@ var SendoraSDK = class {
|
|
|
3269
4489
|
}
|
|
3270
4490
|
});
|
|
3271
4491
|
this.auth.hydrate();
|
|
4492
|
+
if (this.auth.status() !== "withheld" && this.storage.get(SCHEMA_VERSION_KEY) !== SCHEMA_VERSION) {
|
|
4493
|
+
this.storage.set(SCHEMA_VERSION_KEY, SCHEMA_VERSION);
|
|
4494
|
+
}
|
|
3272
4495
|
this.links = new Links({
|
|
3273
4496
|
apiUrl: this.config.apiUrl,
|
|
3274
4497
|
publicKey: this.config.publicKey,
|
|
@@ -4009,23 +5232,44 @@ var SendoraSDK = class {
|
|
|
4009
5232
|
if (!this._chatbotSessionId) this._chatbotSessionId = `chat_${uuid().replace(/-/g, "").slice(0, 24)}`;
|
|
4010
5233
|
return this._chatbotSessionId;
|
|
4011
5234
|
}
|
|
5235
|
+
/**
|
|
5236
|
+
* ADR-033 [2c]: subscribe to the project guard refusing a stored session
|
|
5237
|
+
* (`auth.status() === "withheld"`). Fires once per withhold with
|
|
5238
|
+
* `getWithheld()`'s payload, after the SDK has nulled its in-memory
|
|
5239
|
+
* analytics user id. Record `info.userId` (oldSub) in your backend before
|
|
5240
|
+
* calling `auth.discardWithheld()`. Returns an unsubscribe function.
|
|
5241
|
+
*/
|
|
5242
|
+
onIdentityWithheld(cb) {
|
|
5243
|
+
this.withheldListeners.add(cb);
|
|
5244
|
+
return () => {
|
|
5245
|
+
this.withheldListeners.delete(cb);
|
|
5246
|
+
};
|
|
5247
|
+
}
|
|
4012
5248
|
/**
|
|
4013
5249
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
4014
|
-
* the
|
|
4015
|
-
*
|
|
5250
|
+
* the storage writes so a caller who kills the app immediately after
|
|
5251
|
+
* doesn't leave stale identity on disk.
|
|
5252
|
+
*
|
|
5253
|
+
* Signs out through `auth` (so the server session is revoked and
|
|
5254
|
+
* `onAuthStateChanged` fires `signed_out`), drops the queued events, then
|
|
5255
|
+
* clears EXACTLY this SDK's keys in the active namespace.
|
|
4016
5256
|
*
|
|
4017
|
-
*
|
|
4018
|
-
*
|
|
4019
|
-
*
|
|
4020
|
-
*
|
|
4021
|
-
* `
|
|
5257
|
+
* ⚠ KEEPS the held guests by default (ADR-033). A guest displaced by a
|
|
5258
|
+
* sign-in is held on the device (`auth.getHeldGuests()`), and its refresh
|
|
5259
|
+
* token is its only credential — wiping the slots on logout would strand a
|
|
5260
|
+
* guest whose progress the player may still want. Pass
|
|
5261
|
+
* `{ includeHeldGuests: true }` to release every slot too (locally only —
|
|
5262
|
+
* `guest_released{reason:'reset'}` per guest, NO server delete; use
|
|
5263
|
+
* `auth.discardHeldGuest(id)` for that). In `withheld` this resolves the
|
|
5264
|
+
* withheld session like `auth.discardWithheld()` and keeps the slots too.
|
|
4022
5265
|
*/
|
|
4023
|
-
async reset() {
|
|
5266
|
+
async reset(opts) {
|
|
4024
5267
|
await this.auth.signOut().catch(() => void 0);
|
|
4025
5268
|
this.userId = null;
|
|
4026
5269
|
this.sessionId = "";
|
|
4027
5270
|
this.eventQueue?.clear();
|
|
4028
|
-
|
|
5271
|
+
if (opts?.includeHeldGuests) this.auth.releaseAllHeldGuests();
|
|
5272
|
+
await this.storage.clearAll({ keys: ALL_SDK_KEYS, except: opts?.includeHeldGuests ? [] : HELD_SLOT_KEYS });
|
|
4029
5273
|
this.storage.set(ANON_KEY, this.anonId);
|
|
4030
5274
|
}
|
|
4031
5275
|
/** Tear down auto-track listeners. Call in test harnesses. */
|
|
@@ -4085,15 +5329,21 @@ var SendoraCloud = new SendoraSDK();
|
|
|
4085
5329
|
var index_default = SendoraCloud;
|
|
4086
5330
|
// Annotate the CommonJS export names for ESM import in node:
|
|
4087
5331
|
0 && (module.exports = {
|
|
5332
|
+
AMBIGUOUS_HELD_GUEST,
|
|
4088
5333
|
AlreadyIdentifiedError,
|
|
4089
5334
|
Auth,
|
|
4090
5335
|
AuthError,
|
|
4091
5336
|
CredentialInUseError,
|
|
4092
5337
|
EmailAlreadyTakenError,
|
|
5338
|
+
HELD_GUEST_SLOTS,
|
|
5339
|
+
HeldGuestLimitError,
|
|
5340
|
+
HeldGuestProjectMismatch,
|
|
4093
5341
|
LinkError,
|
|
4094
5342
|
Links,
|
|
4095
5343
|
SendoraCloud,
|
|
4096
5344
|
computeDeviceFingerprint,
|
|
5345
|
+
decodeJwtPayload,
|
|
4097
5346
|
extractShortcodeFromUrl,
|
|
4098
|
-
getPlayInstallReferrer
|
|
5347
|
+
getPlayInstallReferrer,
|
|
5348
|
+
projectVerdict
|
|
4099
5349
|
});
|