@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.js
CHANGED
|
@@ -157,7 +157,7 @@ var Storage = class {
|
|
|
157
157
|
// package.json
|
|
158
158
|
var package_default = {
|
|
159
159
|
name: "@sendoracloud/sdk-react-native",
|
|
160
|
-
version: "1.
|
|
160
|
+
version: "1.30.0",
|
|
161
161
|
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`).",
|
|
162
162
|
type: "module",
|
|
163
163
|
main: "./dist/index.cjs",
|
|
@@ -213,7 +213,7 @@ var package_default = {
|
|
|
213
213
|
scripts: {
|
|
214
214
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
215
215
|
typecheck: "tsc --noEmit",
|
|
216
|
-
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"
|
|
216
|
+
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"
|
|
217
217
|
},
|
|
218
218
|
devDependencies: {
|
|
219
219
|
"@types/react": "^19.2.16",
|
|
@@ -390,18 +390,119 @@ var AlreadyIdentifiedError = class extends Error {
|
|
|
390
390
|
}
|
|
391
391
|
};
|
|
392
392
|
var CredentialInUseError = class extends Error {
|
|
393
|
-
constructor(message = "This credential is already linked to a different account") {
|
|
393
|
+
constructor(message = "This credential is already linked to a different account", opts) {
|
|
394
394
|
super(message);
|
|
395
|
+
/** Always `"CREDENTIAL_IN_USE"`. */
|
|
396
|
+
this.code = "CREDENTIAL_IN_USE";
|
|
397
|
+
/**
|
|
398
|
+
* Always `"credential_in_use"`. Carried so this can be handled in the same
|
|
399
|
+
* `switch (err.kind)` as every other auth failure.
|
|
400
|
+
*
|
|
401
|
+
* NOTE this class deliberately does NOT extend `AuthError` — widening
|
|
402
|
+
* `instanceof AuthError` to include it would silently re-route an upgrading
|
|
403
|
+
* app that checks `AuthError` before this class. The taxonomy fields are
|
|
404
|
+
* duplicated instead.
|
|
405
|
+
*/
|
|
406
|
+
this.kind = "credential_in_use";
|
|
407
|
+
/** Always false — a different account owns it; retrying changes nothing. */
|
|
408
|
+
this.retryable = false;
|
|
395
409
|
this.name = "CredentialInUseError";
|
|
410
|
+
this.provider = opts?.provider;
|
|
411
|
+
this.collision = opts?.collision;
|
|
412
|
+
this.status = opts?.status;
|
|
396
413
|
}
|
|
397
414
|
};
|
|
415
|
+
function readCollisionDetails(parsed) {
|
|
416
|
+
const details = parsed?.error?.details;
|
|
417
|
+
if (!details || typeof details !== "object") return {};
|
|
418
|
+
const d = details;
|
|
419
|
+
return {
|
|
420
|
+
provider: typeof d.provider === "string" ? d.provider : void 0,
|
|
421
|
+
collision: d.collision === "identity" || d.collision === "email" ? d.collision : void 0
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
|
|
425
|
+
"network",
|
|
426
|
+
"server",
|
|
427
|
+
"rate_limited",
|
|
428
|
+
"account_locked"
|
|
429
|
+
]);
|
|
430
|
+
function classifyAuthErrorKind(code, status) {
|
|
431
|
+
switch (code) {
|
|
432
|
+
case "NETWORK_ERROR":
|
|
433
|
+
case "NETWORK_TIMEOUT":
|
|
434
|
+
return "network";
|
|
435
|
+
case "PARSE_ERROR":
|
|
436
|
+
return "server";
|
|
437
|
+
case "RATE_LIMITED":
|
|
438
|
+
case "RATE_LIMIT":
|
|
439
|
+
case "RATE_LIMIT_EXCEEDED":
|
|
440
|
+
case "QUOTA_EXCEEDED":
|
|
441
|
+
return "rate_limited";
|
|
442
|
+
case "ACCOUNT_LOCKED":
|
|
443
|
+
return "account_locked";
|
|
444
|
+
case "CREDENTIAL_IN_USE":
|
|
445
|
+
return "credential_in_use";
|
|
446
|
+
case "NOT_ANONYMOUS":
|
|
447
|
+
case "FORBIDDEN_NON_ANONYMOUS":
|
|
448
|
+
return "already_identified";
|
|
449
|
+
case "PASSKEY_USER_CANCELLED":
|
|
450
|
+
case "GAME_CENTER_CANCELLED":
|
|
451
|
+
case "PLAY_GAMES_CANCELLED":
|
|
452
|
+
case "SSO_CANCELLED":
|
|
453
|
+
return "cancelled";
|
|
454
|
+
case "GAME_CENTER_UNAVAILABLE":
|
|
455
|
+
case "PLAY_GAMES_UNAVAILABLE":
|
|
456
|
+
case "ENTITLEMENT_ERROR":
|
|
457
|
+
case "tier_required":
|
|
458
|
+
return "config";
|
|
459
|
+
case "UNAUTHORIZED":
|
|
460
|
+
case "INVALID_CREDENTIALS":
|
|
461
|
+
case "INVALID_REFRESH_TOKEN":
|
|
462
|
+
case "EMAIL_OTP_INVALID":
|
|
463
|
+
case "MAGIC_LINK_INVALID":
|
|
464
|
+
case "PASSKEY_RP_MISMATCH":
|
|
465
|
+
case "NOT_SIGNED_IN":
|
|
466
|
+
return "invalid_credential";
|
|
467
|
+
default:
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
if (typeof status === "number") {
|
|
471
|
+
if (status === 429) return "rate_limited";
|
|
472
|
+
if (status >= 500) return "server";
|
|
473
|
+
if (status >= 400) return "invalid_credential";
|
|
474
|
+
}
|
|
475
|
+
return "unknown";
|
|
476
|
+
}
|
|
398
477
|
var AuthError = class extends Error {
|
|
399
|
-
constructor(code, message) {
|
|
478
|
+
constructor(code, message, opts) {
|
|
400
479
|
super(message);
|
|
401
480
|
this.name = "AuthError";
|
|
402
481
|
this.code = code;
|
|
482
|
+
this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
|
|
483
|
+
this.retryable = RETRYABLE_KINDS.has(this.kind);
|
|
484
|
+
this.retryAfterSeconds = opts?.retryAfterSeconds;
|
|
485
|
+
this.status = opts?.status;
|
|
403
486
|
}
|
|
404
487
|
};
|
|
488
|
+
function asAuthError(err) {
|
|
489
|
+
if (err instanceof AuthError) return err;
|
|
490
|
+
if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
|
|
491
|
+
return err;
|
|
492
|
+
}
|
|
493
|
+
const name = err?.name;
|
|
494
|
+
if (name === "NetworkTimeoutError") {
|
|
495
|
+
return new AuthError("NETWORK_TIMEOUT", err.message, { kind: "network" });
|
|
496
|
+
}
|
|
497
|
+
return new AuthError("UNKNOWN_ERROR", err instanceof Error ? err.message : String(err), {
|
|
498
|
+
kind: "unknown"
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
function readRetryAfterSeconds(parsed) {
|
|
502
|
+
const details = parsed?.error?.details;
|
|
503
|
+
const v = details?.retryAfterSeconds;
|
|
504
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
505
|
+
}
|
|
405
506
|
var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
|
|
406
507
|
async function fetchGameCenterPayloadNative() {
|
|
407
508
|
const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
|
|
@@ -466,6 +567,7 @@ var Auth = class {
|
|
|
466
567
|
this.refreshCooldownUntil = 0;
|
|
467
568
|
this.takeoverListeners = /* @__PURE__ */ new Set();
|
|
468
569
|
this.deletionCancelledListeners = /* @__PURE__ */ new Set();
|
|
570
|
+
this.authStateListeners = /* @__PURE__ */ new Set();
|
|
469
571
|
this.lastDeletionCancelled = null;
|
|
470
572
|
this.lastTakeover = null;
|
|
471
573
|
/**
|
|
@@ -476,6 +578,11 @@ var Auth = class {
|
|
|
476
578
|
* device ended with two user_ids + duplicate pushes (audit s58.203 fix).
|
|
477
579
|
*/
|
|
478
580
|
this.pendingAnonTakeover = null;
|
|
581
|
+
/**
|
|
582
|
+
* True once `hydrate()` has run. Distinguishes "no session restored YET"
|
|
583
|
+
* from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
|
|
584
|
+
*/
|
|
585
|
+
this.hydrated = false;
|
|
479
586
|
/**
|
|
480
587
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
481
588
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -530,9 +637,9 @@ var Auth = class {
|
|
|
530
637
|
this.hooks.storage.remove(USER_KEY);
|
|
531
638
|
this.hooks.storage.remove(TOKEN_KEY);
|
|
532
639
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
533
|
-
this.hooks.storage.remove(REFRESH_KEY);
|
|
534
640
|
}
|
|
535
641
|
}
|
|
642
|
+
this.hydrated = true;
|
|
536
643
|
this.resolveReady();
|
|
537
644
|
}
|
|
538
645
|
getCurrentUser() {
|
|
@@ -558,6 +665,52 @@ var Auth = class {
|
|
|
558
665
|
this.takeoverListeners.delete(listener);
|
|
559
666
|
};
|
|
560
667
|
}
|
|
668
|
+
/**
|
|
669
|
+
* Subscribe to every auth-state transition (1.28.0) — the single stream that
|
|
670
|
+
* answers "what happened to my session?". Firebase `onAuthStateChanged` /
|
|
671
|
+
* Supabase `onAuthStateChange` parity.
|
|
672
|
+
*
|
|
673
|
+
* The reason it exists: a session that dies in the BACKGROUND (the server
|
|
674
|
+
* rejected the stored refresh token) used to emit no signal whatsoever, so
|
|
675
|
+
* an app could not distinguish it from a deliberate sign-out and only
|
|
676
|
+
* noticed when `getAccessToken()` began returning null. That transition is
|
|
677
|
+
* now `{ event: "signed_out", reason: "session_expired" }`.
|
|
678
|
+
*
|
|
679
|
+
* Fires the CURRENT state immediately on subscribe once `init()` has
|
|
680
|
+
* hydrated (a `signed_in` when a session was restored from disk — including
|
|
681
|
+
* offline, since hydrate is disk-only), so a subscriber never misses the
|
|
682
|
+
* state it started in. Nothing is emitted for a signed-out cold start.
|
|
683
|
+
*
|
|
684
|
+
* A failed sign-in emits NOTHING: the session is unchanged, so there is no
|
|
685
|
+
* transition to report — the rejected promise is the whole story.
|
|
686
|
+
*
|
|
687
|
+
* Listeners are best-effort; a throwing listener is swallowed (dev-logged)
|
|
688
|
+
* and never affects auth state. Returns an unsubscribe function.
|
|
689
|
+
*/
|
|
690
|
+
onAuthStateChanged(listener) {
|
|
691
|
+
this.authStateListeners.add(listener);
|
|
692
|
+
if (this.hydrated && this.user) {
|
|
693
|
+
const user = this.user;
|
|
694
|
+
try {
|
|
695
|
+
listener({ event: "signed_in", user });
|
|
696
|
+
} catch (err) {
|
|
697
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return () => {
|
|
701
|
+
this.authStateListeners.delete(listener);
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/** Fan a state change out to subscribers. Never throws. */
|
|
705
|
+
emitAuthState(change) {
|
|
706
|
+
for (const listener of this.authStateListeners) {
|
|
707
|
+
try {
|
|
708
|
+
listener(change);
|
|
709
|
+
} catch (err) {
|
|
710
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
561
714
|
/**
|
|
562
715
|
* Inspect the most recent device-takeover event observed by the
|
|
563
716
|
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
@@ -654,8 +807,27 @@ var Auth = class {
|
|
|
654
807
|
getAccessTokenSync() {
|
|
655
808
|
return this.accessToken;
|
|
656
809
|
}
|
|
810
|
+
/**
|
|
811
|
+
* Mint (or return) an anonymous session.
|
|
812
|
+
*
|
|
813
|
+
* Reuses the existing anonymous user when there already is one — the same
|
|
814
|
+
* short-circuit Firebase's `signInAnonymously` performs. Without it, an app
|
|
815
|
+
* that calls this defensively on every cold launch (the most natural thing
|
|
816
|
+
* to write) mints a fresh `user_id` each time, and `persist()` overwrites
|
|
817
|
+
* the stored refresh token — the previous anonymous account's only durable
|
|
818
|
+
* handle — with no takeover, no webhook and no state event. That is the same
|
|
819
|
+
* silent progress loss as a failed sign-in wiping the session, except it
|
|
820
|
+
* happens on a *healthy* network.
|
|
821
|
+
*
|
|
822
|
+
* Pass `forceNew: true` to deliberately abandon the current anonymous
|
|
823
|
+
* session and mint a separate one.
|
|
824
|
+
*/
|
|
657
825
|
signInAnonymously(opts) {
|
|
658
826
|
return this.serialize(async () => {
|
|
827
|
+
const existing = this.user;
|
|
828
|
+
if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
|
|
829
|
+
return existing;
|
|
830
|
+
}
|
|
659
831
|
const data = await this.callAuth("/api/v1/auth-service/anonymous", {
|
|
660
832
|
name: opts?.name,
|
|
661
833
|
metadata: opts?.metadata
|
|
@@ -704,7 +876,7 @@ var Auth = class {
|
|
|
704
876
|
throw new EmailAlreadyTakenError(err.message);
|
|
705
877
|
}
|
|
706
878
|
if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
|
|
707
|
-
await this.wipeLocalIdentity();
|
|
879
|
+
await this.wipeLocalIdentity("session_expired");
|
|
708
880
|
} else {
|
|
709
881
|
throw err;
|
|
710
882
|
}
|
|
@@ -745,10 +917,14 @@ var Auth = class {
|
|
|
745
917
|
try {
|
|
746
918
|
parsed = await res.json();
|
|
747
919
|
} catch {
|
|
748
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
920
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
749
921
|
}
|
|
750
922
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
751
|
-
throw new AuthError(
|
|
923
|
+
throw new AuthError(
|
|
924
|
+
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
925
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
926
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
927
|
+
);
|
|
752
928
|
}
|
|
753
929
|
const r = parsed.data;
|
|
754
930
|
if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
|
|
@@ -775,16 +951,12 @@ var Auth = class {
|
|
|
775
951
|
*/
|
|
776
952
|
loginSocial(input) {
|
|
777
953
|
return this.serialize(async () => {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
781
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
782
|
-
}
|
|
783
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
784
|
-
const { link, ...socialInput } = input;
|
|
954
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
955
|
+
const { link, onCredentialInUse, ...socialInput } = input;
|
|
785
956
|
const payload = { ...socialInput };
|
|
786
957
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
787
958
|
if (link) payload.linkAnonymous = true;
|
|
959
|
+
if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
|
|
788
960
|
const res = await post(
|
|
789
961
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
790
962
|
"/api/v1/auth-service/login/social",
|
|
@@ -795,14 +967,22 @@ var Auth = class {
|
|
|
795
967
|
try {
|
|
796
968
|
parsed = await res.json();
|
|
797
969
|
} catch {
|
|
798
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
970
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
799
971
|
}
|
|
800
972
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
973
|
+
if (parsed.error?.code === "CREDENTIAL_IN_USE") {
|
|
974
|
+
throw new CredentialInUseError(parsed.error.message, {
|
|
975
|
+
...readCollisionDetails(parsed),
|
|
976
|
+
status: res.status
|
|
977
|
+
});
|
|
978
|
+
}
|
|
801
979
|
throw new AuthError(
|
|
802
980
|
parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
|
|
803
|
-
parsed.error?.message ?? "Social login failed"
|
|
981
|
+
parsed.error?.message ?? "Social login failed",
|
|
982
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
804
983
|
);
|
|
805
984
|
}
|
|
985
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
806
986
|
this.persist(parsed.data);
|
|
807
987
|
return parsed.data.user;
|
|
808
988
|
});
|
|
@@ -831,26 +1011,24 @@ var Auth = class {
|
|
|
831
1011
|
signInWithGameCenter(payloadOrOpts, maybeOpts) {
|
|
832
1012
|
const path = "/api/v1/auth-service/login/game-center";
|
|
833
1013
|
if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
|
|
834
|
-
return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts
|
|
1014
|
+
return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts);
|
|
835
1015
|
}
|
|
836
|
-
const link = payloadOrOpts?.link;
|
|
837
1016
|
return this.gameIdentitySignIn(
|
|
838
1017
|
path,
|
|
839
1018
|
async () => await fetchGameCenterPayloadNative(),
|
|
840
|
-
|
|
1019
|
+
payloadOrOpts
|
|
841
1020
|
);
|
|
842
1021
|
}
|
|
843
1022
|
signInWithPlayGames(payloadOrOpts, maybeOpts) {
|
|
844
1023
|
const path = "/api/v1/auth-service/login/play-games";
|
|
845
1024
|
if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
|
|
846
1025
|
const { serverAuthCode } = payloadOrOpts;
|
|
847
|
-
return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts
|
|
1026
|
+
return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts);
|
|
848
1027
|
}
|
|
849
|
-
const link = payloadOrOpts?.link;
|
|
850
1028
|
return this.gameIdentitySignIn(
|
|
851
1029
|
path,
|
|
852
1030
|
async () => await fetchPlayGamesAuthCodeNative(),
|
|
853
|
-
|
|
1031
|
+
payloadOrOpts
|
|
854
1032
|
);
|
|
855
1033
|
}
|
|
856
1034
|
// ============================================================
|
|
@@ -921,12 +1099,24 @@ var Auth = class {
|
|
|
921
1099
|
try {
|
|
922
1100
|
parsed = await res.json();
|
|
923
1101
|
} catch {
|
|
924
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1102
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
925
1103
|
}
|
|
926
1104
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
927
1105
|
const code = parsed.error?.code ?? `HTTP_${res.status}`;
|
|
928
|
-
if (code === "CREDENTIAL_IN_USE")
|
|
929
|
-
|
|
1106
|
+
if (code === "CREDENTIAL_IN_USE") {
|
|
1107
|
+
throw new CredentialInUseError(parsed.error?.message, {
|
|
1108
|
+
...readCollisionDetails(parsed),
|
|
1109
|
+
status: res.status
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
|
|
1113
|
+
status: res.status,
|
|
1114
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
|
|
1118
|
+
this.persist(parsed.data);
|
|
1119
|
+
return parsed.data.user;
|
|
930
1120
|
}
|
|
931
1121
|
if (parsed.data.user) this.updateLocalUser(parsed.data.user);
|
|
932
1122
|
return parsed.data.user ?? this.user;
|
|
@@ -976,18 +1166,16 @@ var Auth = class {
|
|
|
976
1166
|
* `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
|
|
977
1167
|
* centrally from `retiredAnonUserId`.
|
|
978
1168
|
*/
|
|
979
|
-
gameIdentitySignIn(path, identityBody,
|
|
1169
|
+
gameIdentitySignIn(path, identityBody, opts) {
|
|
1170
|
+
const link = opts?.link;
|
|
1171
|
+
const onCredentialInUse = opts?.onCredentialInUse;
|
|
980
1172
|
return this.serialize(async () => {
|
|
981
1173
|
const body = typeof identityBody === "function" ? await identityBody() : identityBody;
|
|
982
|
-
|
|
983
|
-
if (this.user?.isAnonymous) {
|
|
984
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
985
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
986
|
-
}
|
|
987
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1174
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
988
1175
|
const payload = { ...body };
|
|
989
1176
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
990
1177
|
if (link) payload.linkAnonymous = true;
|
|
1178
|
+
if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
|
|
991
1179
|
const res = await post(
|
|
992
1180
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
993
1181
|
path,
|
|
@@ -998,14 +1186,22 @@ var Auth = class {
|
|
|
998
1186
|
try {
|
|
999
1187
|
parsed = await res.json();
|
|
1000
1188
|
} catch {
|
|
1001
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1189
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1002
1190
|
}
|
|
1003
1191
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1192
|
+
if (parsed.error?.code === "CREDENTIAL_IN_USE") {
|
|
1193
|
+
throw new CredentialInUseError(parsed.error.message, {
|
|
1194
|
+
...readCollisionDetails(parsed),
|
|
1195
|
+
status: res.status
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1004
1198
|
throw new AuthError(
|
|
1005
1199
|
parsed.error?.code ?? "GAME_LOGIN_FAILED",
|
|
1006
|
-
parsed.error?.message ?? "Game sign-in failed"
|
|
1200
|
+
parsed.error?.message ?? "Game sign-in failed",
|
|
1201
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1007
1202
|
);
|
|
1008
1203
|
}
|
|
1204
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1009
1205
|
this.persist(parsed.data);
|
|
1010
1206
|
return parsed.data.user;
|
|
1011
1207
|
});
|
|
@@ -1056,10 +1252,10 @@ var Auth = class {
|
|
|
1056
1252
|
verifyMagicLink(token) {
|
|
1057
1253
|
return this.serialize(async () => {
|
|
1058
1254
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1059
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1060
1255
|
const body = { token };
|
|
1061
1256
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1062
1257
|
const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
|
|
1258
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1063
1259
|
this.persist(data);
|
|
1064
1260
|
return data.user;
|
|
1065
1261
|
});
|
|
@@ -1080,10 +1276,10 @@ var Auth = class {
|
|
|
1080
1276
|
verifyEmailOtp(email, code) {
|
|
1081
1277
|
return this.serialize(async () => {
|
|
1082
1278
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1083
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1084
1279
|
const body = { email, code };
|
|
1085
1280
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1086
1281
|
const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
|
|
1282
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1087
1283
|
this.persist(data);
|
|
1088
1284
|
return data.user;
|
|
1089
1285
|
});
|
|
@@ -1239,7 +1435,7 @@ var Auth = class {
|
|
|
1239
1435
|
}
|
|
1240
1436
|
} catch {
|
|
1241
1437
|
}
|
|
1242
|
-
await this.wipeLocalIdentity();
|
|
1438
|
+
await this.wipeLocalIdentity("account_deleted");
|
|
1243
1439
|
return out;
|
|
1244
1440
|
}
|
|
1245
1441
|
// ============================================================
|
|
@@ -1307,7 +1503,6 @@ var Auth = class {
|
|
|
1307
1503
|
*/
|
|
1308
1504
|
consumeSsoFragment(callbackUrl) {
|
|
1309
1505
|
return this.serialize(async () => {
|
|
1310
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1311
1506
|
const hashIdx = callbackUrl.indexOf("#");
|
|
1312
1507
|
const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
|
|
1313
1508
|
const params = new URLSearchParams(fragment);
|
|
@@ -1321,14 +1516,13 @@ var Auth = class {
|
|
|
1321
1516
|
"/api/v1/auth-service/token/refresh",
|
|
1322
1517
|
{ refreshToken: refresh }
|
|
1323
1518
|
);
|
|
1324
|
-
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}
|
|
1519
|
+
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
|
|
1325
1520
|
const parsed = await res.json();
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
|
|
1330
|
-
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
|
|
1521
|
+
const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
|
|
1522
|
+
if (!rotated) {
|
|
1523
|
+
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
|
|
1331
1524
|
}
|
|
1525
|
+
const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
|
|
1332
1526
|
const claims = decodeJwtPayload(access);
|
|
1333
1527
|
const user = {
|
|
1334
1528
|
id: claims?.sub ?? "",
|
|
@@ -1337,6 +1531,7 @@ var Auth = class {
|
|
|
1337
1531
|
name: claims?.name ?? null,
|
|
1338
1532
|
isAnonymous: false
|
|
1339
1533
|
};
|
|
1534
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1340
1535
|
this.persist({
|
|
1341
1536
|
user,
|
|
1342
1537
|
tokens: {
|
|
@@ -1372,7 +1567,7 @@ var Auth = class {
|
|
|
1372
1567
|
signOut() {
|
|
1373
1568
|
return this.serialize(async () => {
|
|
1374
1569
|
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
1375
|
-
await this.wipeLocalIdentity();
|
|
1570
|
+
await this.wipeLocalIdentity("user");
|
|
1376
1571
|
if (refresh) {
|
|
1377
1572
|
void post(
|
|
1378
1573
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
@@ -1386,7 +1581,11 @@ var Auth = class {
|
|
|
1386
1581
|
serialize(op) {
|
|
1387
1582
|
const guarded = async () => {
|
|
1388
1583
|
await this.readyPromise;
|
|
1389
|
-
|
|
1584
|
+
try {
|
|
1585
|
+
return await op();
|
|
1586
|
+
} catch (err) {
|
|
1587
|
+
throw asAuthError(err);
|
|
1588
|
+
}
|
|
1390
1589
|
};
|
|
1391
1590
|
const next = this.inflight.then(guarded, guarded);
|
|
1392
1591
|
this.inflight = next.catch(() => void 0);
|
|
@@ -1411,6 +1610,7 @@ var Auth = class {
|
|
|
1411
1610
|
const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
|
|
1412
1611
|
if (!refreshSnapshot) return null;
|
|
1413
1612
|
this.refreshInflight = (async () => {
|
|
1613
|
+
let sent = refreshSnapshot;
|
|
1414
1614
|
try {
|
|
1415
1615
|
const current = this.hooks.storage.get(REFRESH_KEY);
|
|
1416
1616
|
if (current && current !== refreshSnapshot) {
|
|
@@ -1427,20 +1627,28 @@ var Auth = class {
|
|
|
1427
1627
|
}
|
|
1428
1628
|
}
|
|
1429
1629
|
}
|
|
1630
|
+
sent = current ?? refreshSnapshot;
|
|
1430
1631
|
const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
|
|
1431
|
-
refreshToken:
|
|
1632
|
+
refreshToken: sent
|
|
1432
1633
|
});
|
|
1634
|
+
if (!this.tokenStillCurrent(sent)) return null;
|
|
1433
1635
|
this.accessToken = data.tokens.accessToken;
|
|
1434
1636
|
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
1435
1637
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
1436
1638
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
1437
1639
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1640
|
+
if (this.user === null && isAuthApiResponse(data)) {
|
|
1641
|
+
this.user = data.user;
|
|
1642
|
+
this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
|
|
1643
|
+
this.hooks.onIdentityChange(data.user.id);
|
|
1644
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1645
|
+
}
|
|
1438
1646
|
this.refreshFailureCount = 0;
|
|
1439
1647
|
this.refreshCooldownUntil = 0;
|
|
1440
1648
|
return data.tokens.accessToken;
|
|
1441
1649
|
} catch (err) {
|
|
1442
|
-
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
1443
|
-
await this.wipeLocalIdentity().catch(() => void 0);
|
|
1650
|
+
if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
|
|
1651
|
+
await this.wipeLocalIdentity("session_expired").catch(() => void 0);
|
|
1444
1652
|
this.refreshFailureCount = 0;
|
|
1445
1653
|
this.refreshCooldownUntil = 0;
|
|
1446
1654
|
return null;
|
|
@@ -1554,12 +1762,13 @@ var Auth = class {
|
|
|
1554
1762
|
try {
|
|
1555
1763
|
parsed = await res.json();
|
|
1556
1764
|
} catch {
|
|
1557
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1765
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1558
1766
|
}
|
|
1559
1767
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
1560
1768
|
throw new AuthError(
|
|
1561
1769
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1562
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1770
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1771
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1563
1772
|
);
|
|
1564
1773
|
}
|
|
1565
1774
|
const t = parsed.data.tokens;
|
|
@@ -1593,12 +1802,13 @@ var Auth = class {
|
|
|
1593
1802
|
try {
|
|
1594
1803
|
parsed = await res.json();
|
|
1595
1804
|
} catch {
|
|
1596
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1805
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1597
1806
|
}
|
|
1598
1807
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1599
1808
|
throw new AuthError(
|
|
1600
1809
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1601
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1810
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1811
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1602
1812
|
);
|
|
1603
1813
|
}
|
|
1604
1814
|
return parsed.data;
|
|
@@ -1613,14 +1823,46 @@ var Auth = class {
|
|
|
1613
1823
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1614
1824
|
this.hooks.onIdentityChange(data.user.id);
|
|
1615
1825
|
this.startProactiveRefreshCron();
|
|
1826
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1616
1827
|
if (data.retiredAnonUserId) {
|
|
1617
1828
|
this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
|
|
1829
|
+
this.emitAuthState({
|
|
1830
|
+
event: "device_takeover",
|
|
1831
|
+
user: data.user,
|
|
1832
|
+
retiredAnonUserId: data.retiredAnonUserId
|
|
1833
|
+
});
|
|
1618
1834
|
}
|
|
1619
1835
|
if (data.reactivatedFromDeletion) {
|
|
1620
1836
|
this.fireDeletionCancelled(data.user.id);
|
|
1837
|
+
this.emitAuthState({ event: "deletion_cancelled", user: data.user });
|
|
1621
1838
|
}
|
|
1622
1839
|
}
|
|
1623
|
-
|
|
1840
|
+
/**
|
|
1841
|
+
* Drop the local session.
|
|
1842
|
+
*
|
|
1843
|
+
* `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
|
|
1844
|
+
* emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
|
|
1845
|
+
* on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
|
|
1846
|
+
* not a spurious logout/login pair.
|
|
1847
|
+
*
|
|
1848
|
+
* ⚠ Never call this before a fallible network request. A failed sign-in must
|
|
1849
|
+
* leave the caller exactly as it found them; for an anonymous session the
|
|
1850
|
+
* refresh token dropped here is the ONLY durable handle on the account, so a
|
|
1851
|
+
* pre-call wipe that is not followed by a successful `persist()` orphans the
|
|
1852
|
+
* account permanently (offline: deterministically). Capture the anon refresh
|
|
1853
|
+
* into a local, make the call, and wipe only after the response validates.
|
|
1854
|
+
*/
|
|
1855
|
+
/**
|
|
1856
|
+
* True when `sent` is still the refresh token on disk — i.e. nothing
|
|
1857
|
+
* replaced or cleared the session while a request carrying it was in
|
|
1858
|
+
* flight. Guards both directions of the refresh race: installing a rotated
|
|
1859
|
+
* token over a session that has since been signed out or replaced, and
|
|
1860
|
+
* wiping a fresh session because an OLD token was rejected.
|
|
1861
|
+
*/
|
|
1862
|
+
tokenStillCurrent(sent) {
|
|
1863
|
+
return this.hooks.storage.get(REFRESH_KEY) === sent;
|
|
1864
|
+
}
|
|
1865
|
+
async wipeLocalIdentity(reason = "replaced") {
|
|
1624
1866
|
this.user = null;
|
|
1625
1867
|
this.accessToken = null;
|
|
1626
1868
|
this.accessExpiresAt = 0;
|
|
@@ -1633,11 +1875,23 @@ var Auth = class {
|
|
|
1633
1875
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
1634
1876
|
this.stopProactiveRefreshCron();
|
|
1635
1877
|
await this.hooks.onAnonymousWipe();
|
|
1878
|
+
if (reason !== "replaced") {
|
|
1879
|
+
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
1880
|
+
}
|
|
1636
1881
|
}
|
|
1637
1882
|
};
|
|
1638
1883
|
function isDeadRefreshError(code) {
|
|
1639
1884
|
if (!code) return false;
|
|
1640
|
-
return code === "INVALID_REFRESH_TOKEN"
|
|
1885
|
+
return code === "INVALID_REFRESH_TOKEN";
|
|
1886
|
+
}
|
|
1887
|
+
function readRotatedTokens(data) {
|
|
1888
|
+
if (!data || typeof data !== "object") return null;
|
|
1889
|
+
const container = data.tokens;
|
|
1890
|
+
const t = container && typeof container === "object" ? container : data;
|
|
1891
|
+
if (typeof t.accessToken !== "string" || !t.accessToken) return null;
|
|
1892
|
+
if (typeof t.refreshToken !== "string" || !t.refreshToken) return null;
|
|
1893
|
+
if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
|
|
1894
|
+
return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
|
|
1641
1895
|
}
|
|
1642
1896
|
function decodeJwtPayload(token) {
|
|
1643
1897
|
try {
|
|
@@ -3286,10 +3540,16 @@ var SendoraSDK = class {
|
|
|
3286
3540
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
3287
3541
|
* the AsyncStorage writes so a caller who kills the app immediately
|
|
3288
3542
|
* after doesn't leave stale identity on disk.
|
|
3543
|
+
*
|
|
3544
|
+
* Signs out through `auth` rather than just clearing storage: `clearAll()`
|
|
3545
|
+
* removes the auth keys too, so doing it directly left `Auth` holding an
|
|
3546
|
+
* in-memory user and reporting itself signed in while its refresh token was
|
|
3547
|
+
* already gone — with the server-side session never revoked and no
|
|
3548
|
+
* `onAuthStateChanged` emission telling the app the session had ended.
|
|
3289
3549
|
*/
|
|
3290
3550
|
async reset() {
|
|
3551
|
+
await this.auth.signOut().catch(() => void 0);
|
|
3291
3552
|
this.userId = null;
|
|
3292
|
-
this.anonId = uuid();
|
|
3293
3553
|
this.sessionId = "";
|
|
3294
3554
|
await this.storage.clearAll();
|
|
3295
3555
|
this.storage.set(ANON_KEY, this.anonId);
|