@sendoracloud/sdk-react-native 1.27.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +317 -57
- package/dist/index.d.cts +230 -19
- package/dist/index.d.ts +230 -19
- package/dist/index.js +317 -57
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -202,7 +202,7 @@ var Storage = class {
|
|
|
202
202
|
// package.json
|
|
203
203
|
var package_default = {
|
|
204
204
|
name: "@sendoracloud/sdk-react-native",
|
|
205
|
-
version: "1.
|
|
205
|
+
version: "1.30.0",
|
|
206
206
|
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`).",
|
|
207
207
|
type: "module",
|
|
208
208
|
main: "./dist/index.cjs",
|
|
@@ -258,7 +258,7 @@ var package_default = {
|
|
|
258
258
|
scripts: {
|
|
259
259
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
260
260
|
typecheck: "tsc --noEmit",
|
|
261
|
-
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"
|
|
261
|
+
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"
|
|
262
262
|
},
|
|
263
263
|
devDependencies: {
|
|
264
264
|
"@types/react": "^19.2.16",
|
|
@@ -435,18 +435,119 @@ var AlreadyIdentifiedError = class extends Error {
|
|
|
435
435
|
}
|
|
436
436
|
};
|
|
437
437
|
var CredentialInUseError = class extends Error {
|
|
438
|
-
constructor(message = "This credential is already linked to a different account") {
|
|
438
|
+
constructor(message = "This credential is already linked to a different account", opts) {
|
|
439
439
|
super(message);
|
|
440
|
+
/** Always `"CREDENTIAL_IN_USE"`. */
|
|
441
|
+
this.code = "CREDENTIAL_IN_USE";
|
|
442
|
+
/**
|
|
443
|
+
* Always `"credential_in_use"`. Carried so this can be handled in the same
|
|
444
|
+
* `switch (err.kind)` as every other auth failure.
|
|
445
|
+
*
|
|
446
|
+
* NOTE this class deliberately does NOT extend `AuthError` — widening
|
|
447
|
+
* `instanceof AuthError` to include it would silently re-route an upgrading
|
|
448
|
+
* app that checks `AuthError` before this class. The taxonomy fields are
|
|
449
|
+
* duplicated instead.
|
|
450
|
+
*/
|
|
451
|
+
this.kind = "credential_in_use";
|
|
452
|
+
/** Always false — a different account owns it; retrying changes nothing. */
|
|
453
|
+
this.retryable = false;
|
|
440
454
|
this.name = "CredentialInUseError";
|
|
455
|
+
this.provider = opts?.provider;
|
|
456
|
+
this.collision = opts?.collision;
|
|
457
|
+
this.status = opts?.status;
|
|
441
458
|
}
|
|
442
459
|
};
|
|
460
|
+
function readCollisionDetails(parsed) {
|
|
461
|
+
const details = parsed?.error?.details;
|
|
462
|
+
if (!details || typeof details !== "object") return {};
|
|
463
|
+
const d = details;
|
|
464
|
+
return {
|
|
465
|
+
provider: typeof d.provider === "string" ? d.provider : void 0,
|
|
466
|
+
collision: d.collision === "identity" || d.collision === "email" ? d.collision : void 0
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
|
|
470
|
+
"network",
|
|
471
|
+
"server",
|
|
472
|
+
"rate_limited",
|
|
473
|
+
"account_locked"
|
|
474
|
+
]);
|
|
475
|
+
function classifyAuthErrorKind(code, status) {
|
|
476
|
+
switch (code) {
|
|
477
|
+
case "NETWORK_ERROR":
|
|
478
|
+
case "NETWORK_TIMEOUT":
|
|
479
|
+
return "network";
|
|
480
|
+
case "PARSE_ERROR":
|
|
481
|
+
return "server";
|
|
482
|
+
case "RATE_LIMITED":
|
|
483
|
+
case "RATE_LIMIT":
|
|
484
|
+
case "RATE_LIMIT_EXCEEDED":
|
|
485
|
+
case "QUOTA_EXCEEDED":
|
|
486
|
+
return "rate_limited";
|
|
487
|
+
case "ACCOUNT_LOCKED":
|
|
488
|
+
return "account_locked";
|
|
489
|
+
case "CREDENTIAL_IN_USE":
|
|
490
|
+
return "credential_in_use";
|
|
491
|
+
case "NOT_ANONYMOUS":
|
|
492
|
+
case "FORBIDDEN_NON_ANONYMOUS":
|
|
493
|
+
return "already_identified";
|
|
494
|
+
case "PASSKEY_USER_CANCELLED":
|
|
495
|
+
case "GAME_CENTER_CANCELLED":
|
|
496
|
+
case "PLAY_GAMES_CANCELLED":
|
|
497
|
+
case "SSO_CANCELLED":
|
|
498
|
+
return "cancelled";
|
|
499
|
+
case "GAME_CENTER_UNAVAILABLE":
|
|
500
|
+
case "PLAY_GAMES_UNAVAILABLE":
|
|
501
|
+
case "ENTITLEMENT_ERROR":
|
|
502
|
+
case "tier_required":
|
|
503
|
+
return "config";
|
|
504
|
+
case "UNAUTHORIZED":
|
|
505
|
+
case "INVALID_CREDENTIALS":
|
|
506
|
+
case "INVALID_REFRESH_TOKEN":
|
|
507
|
+
case "EMAIL_OTP_INVALID":
|
|
508
|
+
case "MAGIC_LINK_INVALID":
|
|
509
|
+
case "PASSKEY_RP_MISMATCH":
|
|
510
|
+
case "NOT_SIGNED_IN":
|
|
511
|
+
return "invalid_credential";
|
|
512
|
+
default:
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
if (typeof status === "number") {
|
|
516
|
+
if (status === 429) return "rate_limited";
|
|
517
|
+
if (status >= 500) return "server";
|
|
518
|
+
if (status >= 400) return "invalid_credential";
|
|
519
|
+
}
|
|
520
|
+
return "unknown";
|
|
521
|
+
}
|
|
443
522
|
var AuthError = class extends Error {
|
|
444
|
-
constructor(code, message) {
|
|
523
|
+
constructor(code, message, opts) {
|
|
445
524
|
super(message);
|
|
446
525
|
this.name = "AuthError";
|
|
447
526
|
this.code = code;
|
|
527
|
+
this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
|
|
528
|
+
this.retryable = RETRYABLE_KINDS.has(this.kind);
|
|
529
|
+
this.retryAfterSeconds = opts?.retryAfterSeconds;
|
|
530
|
+
this.status = opts?.status;
|
|
448
531
|
}
|
|
449
532
|
};
|
|
533
|
+
function asAuthError(err) {
|
|
534
|
+
if (err instanceof AuthError) return err;
|
|
535
|
+
if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
|
|
536
|
+
return err;
|
|
537
|
+
}
|
|
538
|
+
const name = err?.name;
|
|
539
|
+
if (name === "NetworkTimeoutError") {
|
|
540
|
+
return new AuthError("NETWORK_TIMEOUT", err.message, { kind: "network" });
|
|
541
|
+
}
|
|
542
|
+
return new AuthError("UNKNOWN_ERROR", err instanceof Error ? err.message : String(err), {
|
|
543
|
+
kind: "unknown"
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
function readRetryAfterSeconds(parsed) {
|
|
547
|
+
const details = parsed?.error?.details;
|
|
548
|
+
const v = details?.retryAfterSeconds;
|
|
549
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
550
|
+
}
|
|
450
551
|
var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
|
|
451
552
|
async function fetchGameCenterPayloadNative() {
|
|
452
553
|
const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
|
|
@@ -511,6 +612,7 @@ var Auth = class {
|
|
|
511
612
|
this.refreshCooldownUntil = 0;
|
|
512
613
|
this.takeoverListeners = /* @__PURE__ */ new Set();
|
|
513
614
|
this.deletionCancelledListeners = /* @__PURE__ */ new Set();
|
|
615
|
+
this.authStateListeners = /* @__PURE__ */ new Set();
|
|
514
616
|
this.lastDeletionCancelled = null;
|
|
515
617
|
this.lastTakeover = null;
|
|
516
618
|
/**
|
|
@@ -521,6 +623,11 @@ var Auth = class {
|
|
|
521
623
|
* device ended with two user_ids + duplicate pushes (audit s58.203 fix).
|
|
522
624
|
*/
|
|
523
625
|
this.pendingAnonTakeover = null;
|
|
626
|
+
/**
|
|
627
|
+
* True once `hydrate()` has run. Distinguishes "no session restored YET"
|
|
628
|
+
* from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
|
|
629
|
+
*/
|
|
630
|
+
this.hydrated = false;
|
|
524
631
|
/**
|
|
525
632
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
526
633
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -575,9 +682,9 @@ var Auth = class {
|
|
|
575
682
|
this.hooks.storage.remove(USER_KEY);
|
|
576
683
|
this.hooks.storage.remove(TOKEN_KEY);
|
|
577
684
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
578
|
-
this.hooks.storage.remove(REFRESH_KEY);
|
|
579
685
|
}
|
|
580
686
|
}
|
|
687
|
+
this.hydrated = true;
|
|
581
688
|
this.resolveReady();
|
|
582
689
|
}
|
|
583
690
|
getCurrentUser() {
|
|
@@ -603,6 +710,52 @@ var Auth = class {
|
|
|
603
710
|
this.takeoverListeners.delete(listener);
|
|
604
711
|
};
|
|
605
712
|
}
|
|
713
|
+
/**
|
|
714
|
+
* Subscribe to every auth-state transition (1.28.0) — the single stream that
|
|
715
|
+
* answers "what happened to my session?". Firebase `onAuthStateChanged` /
|
|
716
|
+
* Supabase `onAuthStateChange` parity.
|
|
717
|
+
*
|
|
718
|
+
* The reason it exists: a session that dies in the BACKGROUND (the server
|
|
719
|
+
* rejected the stored refresh token) used to emit no signal whatsoever, so
|
|
720
|
+
* an app could not distinguish it from a deliberate sign-out and only
|
|
721
|
+
* noticed when `getAccessToken()` began returning null. That transition is
|
|
722
|
+
* now `{ event: "signed_out", reason: "session_expired" }`.
|
|
723
|
+
*
|
|
724
|
+
* Fires the CURRENT state immediately on subscribe once `init()` has
|
|
725
|
+
* hydrated (a `signed_in` when a session was restored from disk — including
|
|
726
|
+
* offline, since hydrate is disk-only), so a subscriber never misses the
|
|
727
|
+
* state it started in. Nothing is emitted for a signed-out cold start.
|
|
728
|
+
*
|
|
729
|
+
* A failed sign-in emits NOTHING: the session is unchanged, so there is no
|
|
730
|
+
* transition to report — the rejected promise is the whole story.
|
|
731
|
+
*
|
|
732
|
+
* Listeners are best-effort; a throwing listener is swallowed (dev-logged)
|
|
733
|
+
* and never affects auth state. Returns an unsubscribe function.
|
|
734
|
+
*/
|
|
735
|
+
onAuthStateChanged(listener) {
|
|
736
|
+
this.authStateListeners.add(listener);
|
|
737
|
+
if (this.hydrated && this.user) {
|
|
738
|
+
const user = this.user;
|
|
739
|
+
try {
|
|
740
|
+
listener({ event: "signed_in", user });
|
|
741
|
+
} catch (err) {
|
|
742
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return () => {
|
|
746
|
+
this.authStateListeners.delete(listener);
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
/** Fan a state change out to subscribers. Never throws. */
|
|
750
|
+
emitAuthState(change) {
|
|
751
|
+
for (const listener of this.authStateListeners) {
|
|
752
|
+
try {
|
|
753
|
+
listener(change);
|
|
754
|
+
} catch (err) {
|
|
755
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
606
759
|
/**
|
|
607
760
|
* Inspect the most recent device-takeover event observed by the
|
|
608
761
|
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
@@ -699,8 +852,27 @@ var Auth = class {
|
|
|
699
852
|
getAccessTokenSync() {
|
|
700
853
|
return this.accessToken;
|
|
701
854
|
}
|
|
855
|
+
/**
|
|
856
|
+
* Mint (or return) an anonymous session.
|
|
857
|
+
*
|
|
858
|
+
* Reuses the existing anonymous user when there already is one — the same
|
|
859
|
+
* short-circuit Firebase's `signInAnonymously` performs. Without it, an app
|
|
860
|
+
* that calls this defensively on every cold launch (the most natural thing
|
|
861
|
+
* to write) mints a fresh `user_id` each time, and `persist()` overwrites
|
|
862
|
+
* the stored refresh token — the previous anonymous account's only durable
|
|
863
|
+
* handle — with no takeover, no webhook and no state event. That is the same
|
|
864
|
+
* silent progress loss as a failed sign-in wiping the session, except it
|
|
865
|
+
* happens on a *healthy* network.
|
|
866
|
+
*
|
|
867
|
+
* Pass `forceNew: true` to deliberately abandon the current anonymous
|
|
868
|
+
* session and mint a separate one.
|
|
869
|
+
*/
|
|
702
870
|
signInAnonymously(opts) {
|
|
703
871
|
return this.serialize(async () => {
|
|
872
|
+
const existing = this.user;
|
|
873
|
+
if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
|
|
874
|
+
return existing;
|
|
875
|
+
}
|
|
704
876
|
const data = await this.callAuth("/api/v1/auth-service/anonymous", {
|
|
705
877
|
name: opts?.name,
|
|
706
878
|
metadata: opts?.metadata
|
|
@@ -749,7 +921,7 @@ var Auth = class {
|
|
|
749
921
|
throw new EmailAlreadyTakenError(err.message);
|
|
750
922
|
}
|
|
751
923
|
if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
|
|
752
|
-
await this.wipeLocalIdentity();
|
|
924
|
+
await this.wipeLocalIdentity("session_expired");
|
|
753
925
|
} else {
|
|
754
926
|
throw err;
|
|
755
927
|
}
|
|
@@ -790,10 +962,14 @@ var Auth = class {
|
|
|
790
962
|
try {
|
|
791
963
|
parsed = await res.json();
|
|
792
964
|
} catch {
|
|
793
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
965
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
794
966
|
}
|
|
795
967
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
796
|
-
throw new AuthError(
|
|
968
|
+
throw new AuthError(
|
|
969
|
+
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
970
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
971
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
972
|
+
);
|
|
797
973
|
}
|
|
798
974
|
const r = parsed.data;
|
|
799
975
|
if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
|
|
@@ -820,16 +996,12 @@ var Auth = class {
|
|
|
820
996
|
*/
|
|
821
997
|
loginSocial(input) {
|
|
822
998
|
return this.serialize(async () => {
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
826
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
827
|
-
}
|
|
828
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
829
|
-
const { link, ...socialInput } = input;
|
|
999
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1000
|
+
const { link, onCredentialInUse, ...socialInput } = input;
|
|
830
1001
|
const payload = { ...socialInput };
|
|
831
1002
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
832
1003
|
if (link) payload.linkAnonymous = true;
|
|
1004
|
+
if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
|
|
833
1005
|
const res = await post(
|
|
834
1006
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
835
1007
|
"/api/v1/auth-service/login/social",
|
|
@@ -840,14 +1012,22 @@ var Auth = class {
|
|
|
840
1012
|
try {
|
|
841
1013
|
parsed = await res.json();
|
|
842
1014
|
} catch {
|
|
843
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1015
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
844
1016
|
}
|
|
845
1017
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1018
|
+
if (parsed.error?.code === "CREDENTIAL_IN_USE") {
|
|
1019
|
+
throw new CredentialInUseError(parsed.error.message, {
|
|
1020
|
+
...readCollisionDetails(parsed),
|
|
1021
|
+
status: res.status
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
846
1024
|
throw new AuthError(
|
|
847
1025
|
parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
|
|
848
|
-
parsed.error?.message ?? "Social login failed"
|
|
1026
|
+
parsed.error?.message ?? "Social login failed",
|
|
1027
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
849
1028
|
);
|
|
850
1029
|
}
|
|
1030
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
851
1031
|
this.persist(parsed.data);
|
|
852
1032
|
return parsed.data.user;
|
|
853
1033
|
});
|
|
@@ -876,26 +1056,24 @@ var Auth = class {
|
|
|
876
1056
|
signInWithGameCenter(payloadOrOpts, maybeOpts) {
|
|
877
1057
|
const path = "/api/v1/auth-service/login/game-center";
|
|
878
1058
|
if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
|
|
879
|
-
return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts
|
|
1059
|
+
return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts);
|
|
880
1060
|
}
|
|
881
|
-
const link = payloadOrOpts?.link;
|
|
882
1061
|
return this.gameIdentitySignIn(
|
|
883
1062
|
path,
|
|
884
1063
|
async () => await fetchGameCenterPayloadNative(),
|
|
885
|
-
|
|
1064
|
+
payloadOrOpts
|
|
886
1065
|
);
|
|
887
1066
|
}
|
|
888
1067
|
signInWithPlayGames(payloadOrOpts, maybeOpts) {
|
|
889
1068
|
const path = "/api/v1/auth-service/login/play-games";
|
|
890
1069
|
if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
|
|
891
1070
|
const { serverAuthCode } = payloadOrOpts;
|
|
892
|
-
return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts
|
|
1071
|
+
return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts);
|
|
893
1072
|
}
|
|
894
|
-
const link = payloadOrOpts?.link;
|
|
895
1073
|
return this.gameIdentitySignIn(
|
|
896
1074
|
path,
|
|
897
1075
|
async () => await fetchPlayGamesAuthCodeNative(),
|
|
898
|
-
|
|
1076
|
+
payloadOrOpts
|
|
899
1077
|
);
|
|
900
1078
|
}
|
|
901
1079
|
// ============================================================
|
|
@@ -966,12 +1144,24 @@ var Auth = class {
|
|
|
966
1144
|
try {
|
|
967
1145
|
parsed = await res.json();
|
|
968
1146
|
} catch {
|
|
969
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1147
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
970
1148
|
}
|
|
971
1149
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
972
1150
|
const code = parsed.error?.code ?? `HTTP_${res.status}`;
|
|
973
|
-
if (code === "CREDENTIAL_IN_USE")
|
|
974
|
-
|
|
1151
|
+
if (code === "CREDENTIAL_IN_USE") {
|
|
1152
|
+
throw new CredentialInUseError(parsed.error?.message, {
|
|
1153
|
+
...readCollisionDetails(parsed),
|
|
1154
|
+
status: res.status
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
|
|
1158
|
+
status: res.status,
|
|
1159
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
|
|
1163
|
+
this.persist(parsed.data);
|
|
1164
|
+
return parsed.data.user;
|
|
975
1165
|
}
|
|
976
1166
|
if (parsed.data.user) this.updateLocalUser(parsed.data.user);
|
|
977
1167
|
return parsed.data.user ?? this.user;
|
|
@@ -1021,18 +1211,16 @@ var Auth = class {
|
|
|
1021
1211
|
* `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
|
|
1022
1212
|
* centrally from `retiredAnonUserId`.
|
|
1023
1213
|
*/
|
|
1024
|
-
gameIdentitySignIn(path, identityBody,
|
|
1214
|
+
gameIdentitySignIn(path, identityBody, opts) {
|
|
1215
|
+
const link = opts?.link;
|
|
1216
|
+
const onCredentialInUse = opts?.onCredentialInUse;
|
|
1025
1217
|
return this.serialize(async () => {
|
|
1026
1218
|
const body = typeof identityBody === "function" ? await identityBody() : identityBody;
|
|
1027
|
-
|
|
1028
|
-
if (this.user?.isAnonymous) {
|
|
1029
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
1030
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
1031
|
-
}
|
|
1032
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1219
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1033
1220
|
const payload = { ...body };
|
|
1034
1221
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1035
1222
|
if (link) payload.linkAnonymous = true;
|
|
1223
|
+
if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
|
|
1036
1224
|
const res = await post(
|
|
1037
1225
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1038
1226
|
path,
|
|
@@ -1043,14 +1231,22 @@ var Auth = class {
|
|
|
1043
1231
|
try {
|
|
1044
1232
|
parsed = await res.json();
|
|
1045
1233
|
} catch {
|
|
1046
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1234
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1047
1235
|
}
|
|
1048
1236
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1237
|
+
if (parsed.error?.code === "CREDENTIAL_IN_USE") {
|
|
1238
|
+
throw new CredentialInUseError(parsed.error.message, {
|
|
1239
|
+
...readCollisionDetails(parsed),
|
|
1240
|
+
status: res.status
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1049
1243
|
throw new AuthError(
|
|
1050
1244
|
parsed.error?.code ?? "GAME_LOGIN_FAILED",
|
|
1051
|
-
parsed.error?.message ?? "Game sign-in failed"
|
|
1245
|
+
parsed.error?.message ?? "Game sign-in failed",
|
|
1246
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1052
1247
|
);
|
|
1053
1248
|
}
|
|
1249
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1054
1250
|
this.persist(parsed.data);
|
|
1055
1251
|
return parsed.data.user;
|
|
1056
1252
|
});
|
|
@@ -1101,10 +1297,10 @@ var Auth = class {
|
|
|
1101
1297
|
verifyMagicLink(token) {
|
|
1102
1298
|
return this.serialize(async () => {
|
|
1103
1299
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1104
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1105
1300
|
const body = { token };
|
|
1106
1301
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1107
1302
|
const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
|
|
1303
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1108
1304
|
this.persist(data);
|
|
1109
1305
|
return data.user;
|
|
1110
1306
|
});
|
|
@@ -1125,10 +1321,10 @@ var Auth = class {
|
|
|
1125
1321
|
verifyEmailOtp(email, code) {
|
|
1126
1322
|
return this.serialize(async () => {
|
|
1127
1323
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1128
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1129
1324
|
const body = { email, code };
|
|
1130
1325
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1131
1326
|
const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
|
|
1327
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1132
1328
|
this.persist(data);
|
|
1133
1329
|
return data.user;
|
|
1134
1330
|
});
|
|
@@ -1284,7 +1480,7 @@ var Auth = class {
|
|
|
1284
1480
|
}
|
|
1285
1481
|
} catch {
|
|
1286
1482
|
}
|
|
1287
|
-
await this.wipeLocalIdentity();
|
|
1483
|
+
await this.wipeLocalIdentity("account_deleted");
|
|
1288
1484
|
return out;
|
|
1289
1485
|
}
|
|
1290
1486
|
// ============================================================
|
|
@@ -1352,7 +1548,6 @@ var Auth = class {
|
|
|
1352
1548
|
*/
|
|
1353
1549
|
consumeSsoFragment(callbackUrl) {
|
|
1354
1550
|
return this.serialize(async () => {
|
|
1355
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1356
1551
|
const hashIdx = callbackUrl.indexOf("#");
|
|
1357
1552
|
const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
|
|
1358
1553
|
const params = new URLSearchParams(fragment);
|
|
@@ -1366,14 +1561,13 @@ var Auth = class {
|
|
|
1366
1561
|
"/api/v1/auth-service/token/refresh",
|
|
1367
1562
|
{ refreshToken: refresh }
|
|
1368
1563
|
);
|
|
1369
|
-
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}
|
|
1564
|
+
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
|
|
1370
1565
|
const parsed = await res.json();
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
|
|
1375
|
-
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
|
|
1566
|
+
const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
|
|
1567
|
+
if (!rotated) {
|
|
1568
|
+
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
|
|
1376
1569
|
}
|
|
1570
|
+
const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
|
|
1377
1571
|
const claims = decodeJwtPayload(access);
|
|
1378
1572
|
const user = {
|
|
1379
1573
|
id: claims?.sub ?? "",
|
|
@@ -1382,6 +1576,7 @@ var Auth = class {
|
|
|
1382
1576
|
name: claims?.name ?? null,
|
|
1383
1577
|
isAnonymous: false
|
|
1384
1578
|
};
|
|
1579
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1385
1580
|
this.persist({
|
|
1386
1581
|
user,
|
|
1387
1582
|
tokens: {
|
|
@@ -1417,7 +1612,7 @@ var Auth = class {
|
|
|
1417
1612
|
signOut() {
|
|
1418
1613
|
return this.serialize(async () => {
|
|
1419
1614
|
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
1420
|
-
await this.wipeLocalIdentity();
|
|
1615
|
+
await this.wipeLocalIdentity("user");
|
|
1421
1616
|
if (refresh) {
|
|
1422
1617
|
void post(
|
|
1423
1618
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
@@ -1431,7 +1626,11 @@ var Auth = class {
|
|
|
1431
1626
|
serialize(op) {
|
|
1432
1627
|
const guarded = async () => {
|
|
1433
1628
|
await this.readyPromise;
|
|
1434
|
-
|
|
1629
|
+
try {
|
|
1630
|
+
return await op();
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
throw asAuthError(err);
|
|
1633
|
+
}
|
|
1435
1634
|
};
|
|
1436
1635
|
const next = this.inflight.then(guarded, guarded);
|
|
1437
1636
|
this.inflight = next.catch(() => void 0);
|
|
@@ -1456,6 +1655,7 @@ var Auth = class {
|
|
|
1456
1655
|
const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
|
|
1457
1656
|
if (!refreshSnapshot) return null;
|
|
1458
1657
|
this.refreshInflight = (async () => {
|
|
1658
|
+
let sent = refreshSnapshot;
|
|
1459
1659
|
try {
|
|
1460
1660
|
const current = this.hooks.storage.get(REFRESH_KEY);
|
|
1461
1661
|
if (current && current !== refreshSnapshot) {
|
|
@@ -1472,20 +1672,28 @@ var Auth = class {
|
|
|
1472
1672
|
}
|
|
1473
1673
|
}
|
|
1474
1674
|
}
|
|
1675
|
+
sent = current ?? refreshSnapshot;
|
|
1475
1676
|
const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
|
|
1476
|
-
refreshToken:
|
|
1677
|
+
refreshToken: sent
|
|
1477
1678
|
});
|
|
1679
|
+
if (!this.tokenStillCurrent(sent)) return null;
|
|
1478
1680
|
this.accessToken = data.tokens.accessToken;
|
|
1479
1681
|
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
1480
1682
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
1481
1683
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
1482
1684
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1685
|
+
if (this.user === null && isAuthApiResponse(data)) {
|
|
1686
|
+
this.user = data.user;
|
|
1687
|
+
this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
|
|
1688
|
+
this.hooks.onIdentityChange(data.user.id);
|
|
1689
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1690
|
+
}
|
|
1483
1691
|
this.refreshFailureCount = 0;
|
|
1484
1692
|
this.refreshCooldownUntil = 0;
|
|
1485
1693
|
return data.tokens.accessToken;
|
|
1486
1694
|
} catch (err) {
|
|
1487
|
-
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
1488
|
-
await this.wipeLocalIdentity().catch(() => void 0);
|
|
1695
|
+
if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
|
|
1696
|
+
await this.wipeLocalIdentity("session_expired").catch(() => void 0);
|
|
1489
1697
|
this.refreshFailureCount = 0;
|
|
1490
1698
|
this.refreshCooldownUntil = 0;
|
|
1491
1699
|
return null;
|
|
@@ -1599,12 +1807,13 @@ var Auth = class {
|
|
|
1599
1807
|
try {
|
|
1600
1808
|
parsed = await res.json();
|
|
1601
1809
|
} catch {
|
|
1602
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1810
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1603
1811
|
}
|
|
1604
1812
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
1605
1813
|
throw new AuthError(
|
|
1606
1814
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1607
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1815
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1816
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1608
1817
|
);
|
|
1609
1818
|
}
|
|
1610
1819
|
const t = parsed.data.tokens;
|
|
@@ -1638,12 +1847,13 @@ var Auth = class {
|
|
|
1638
1847
|
try {
|
|
1639
1848
|
parsed = await res.json();
|
|
1640
1849
|
} catch {
|
|
1641
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1850
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1642
1851
|
}
|
|
1643
1852
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1644
1853
|
throw new AuthError(
|
|
1645
1854
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1646
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1855
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1856
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1647
1857
|
);
|
|
1648
1858
|
}
|
|
1649
1859
|
return parsed.data;
|
|
@@ -1658,14 +1868,46 @@ var Auth = class {
|
|
|
1658
1868
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1659
1869
|
this.hooks.onIdentityChange(data.user.id);
|
|
1660
1870
|
this.startProactiveRefreshCron();
|
|
1871
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1661
1872
|
if (data.retiredAnonUserId) {
|
|
1662
1873
|
this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
|
|
1874
|
+
this.emitAuthState({
|
|
1875
|
+
event: "device_takeover",
|
|
1876
|
+
user: data.user,
|
|
1877
|
+
retiredAnonUserId: data.retiredAnonUserId
|
|
1878
|
+
});
|
|
1663
1879
|
}
|
|
1664
1880
|
if (data.reactivatedFromDeletion) {
|
|
1665
1881
|
this.fireDeletionCancelled(data.user.id);
|
|
1882
|
+
this.emitAuthState({ event: "deletion_cancelled", user: data.user });
|
|
1666
1883
|
}
|
|
1667
1884
|
}
|
|
1668
|
-
|
|
1885
|
+
/**
|
|
1886
|
+
* Drop the local session.
|
|
1887
|
+
*
|
|
1888
|
+
* `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
|
|
1889
|
+
* emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
|
|
1890
|
+
* on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
|
|
1891
|
+
* not a spurious logout/login pair.
|
|
1892
|
+
*
|
|
1893
|
+
* ⚠ Never call this before a fallible network request. A failed sign-in must
|
|
1894
|
+
* leave the caller exactly as it found them; for an anonymous session the
|
|
1895
|
+
* refresh token dropped here is the ONLY durable handle on the account, so a
|
|
1896
|
+
* pre-call wipe that is not followed by a successful `persist()` orphans the
|
|
1897
|
+
* account permanently (offline: deterministically). Capture the anon refresh
|
|
1898
|
+
* into a local, make the call, and wipe only after the response validates.
|
|
1899
|
+
*/
|
|
1900
|
+
/**
|
|
1901
|
+
* True when `sent` is still the refresh token on disk — i.e. nothing
|
|
1902
|
+
* replaced or cleared the session while a request carrying it was in
|
|
1903
|
+
* flight. Guards both directions of the refresh race: installing a rotated
|
|
1904
|
+
* token over a session that has since been signed out or replaced, and
|
|
1905
|
+
* wiping a fresh session because an OLD token was rejected.
|
|
1906
|
+
*/
|
|
1907
|
+
tokenStillCurrent(sent) {
|
|
1908
|
+
return this.hooks.storage.get(REFRESH_KEY) === sent;
|
|
1909
|
+
}
|
|
1910
|
+
async wipeLocalIdentity(reason = "replaced") {
|
|
1669
1911
|
this.user = null;
|
|
1670
1912
|
this.accessToken = null;
|
|
1671
1913
|
this.accessExpiresAt = 0;
|
|
@@ -1678,11 +1920,23 @@ var Auth = class {
|
|
|
1678
1920
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
1679
1921
|
this.stopProactiveRefreshCron();
|
|
1680
1922
|
await this.hooks.onAnonymousWipe();
|
|
1923
|
+
if (reason !== "replaced") {
|
|
1924
|
+
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
1925
|
+
}
|
|
1681
1926
|
}
|
|
1682
1927
|
};
|
|
1683
1928
|
function isDeadRefreshError(code) {
|
|
1684
1929
|
if (!code) return false;
|
|
1685
|
-
return code === "INVALID_REFRESH_TOKEN"
|
|
1930
|
+
return code === "INVALID_REFRESH_TOKEN";
|
|
1931
|
+
}
|
|
1932
|
+
function readRotatedTokens(data) {
|
|
1933
|
+
if (!data || typeof data !== "object") return null;
|
|
1934
|
+
const container = data.tokens;
|
|
1935
|
+
const t = container && typeof container === "object" ? container : data;
|
|
1936
|
+
if (typeof t.accessToken !== "string" || !t.accessToken) return null;
|
|
1937
|
+
if (typeof t.refreshToken !== "string" || !t.refreshToken) return null;
|
|
1938
|
+
if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
|
|
1939
|
+
return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
|
|
1686
1940
|
}
|
|
1687
1941
|
function decodeJwtPayload(token) {
|
|
1688
1942
|
try {
|
|
@@ -3326,10 +3580,16 @@ var SendoraSDK = class {
|
|
|
3326
3580
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
3327
3581
|
* the AsyncStorage writes so a caller who kills the app immediately
|
|
3328
3582
|
* after doesn't leave stale identity on disk.
|
|
3583
|
+
*
|
|
3584
|
+
* Signs out through `auth` rather than just clearing storage: `clearAll()`
|
|
3585
|
+
* removes the auth keys too, so doing it directly left `Auth` holding an
|
|
3586
|
+
* in-memory user and reporting itself signed in while its refresh token was
|
|
3587
|
+
* already gone — with the server-side session never revoked and no
|
|
3588
|
+
* `onAuthStateChanged` emission telling the app the session had ended.
|
|
3329
3589
|
*/
|
|
3330
3590
|
async reset() {
|
|
3591
|
+
await this.auth.signOut().catch(() => void 0);
|
|
3331
3592
|
this.userId = null;
|
|
3332
|
-
this.anonId = uuid();
|
|
3333
3593
|
this.sessionId = "";
|
|
3334
3594
|
await this.storage.clearAll();
|
|
3335
3595
|
this.storage.set(ANON_KEY, this.anonId);
|