@sendoracloud/sdk-react-native 1.26.0 → 1.29.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 +288 -47
- package/dist/index.d.cts +178 -6
- package/dist/index.d.ts +178 -6
- package/dist/index.js +288 -47
- package/package.json +3 -3
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.29.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"
|
|
217
217
|
},
|
|
218
218
|
devDependencies: {
|
|
219
219
|
"@types/react": "^19.2.16",
|
|
@@ -395,13 +395,88 @@ var CredentialInUseError = class extends Error {
|
|
|
395
395
|
this.name = "CredentialInUseError";
|
|
396
396
|
}
|
|
397
397
|
};
|
|
398
|
+
var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
|
|
399
|
+
"network",
|
|
400
|
+
"server",
|
|
401
|
+
"rate_limited",
|
|
402
|
+
"account_locked"
|
|
403
|
+
]);
|
|
404
|
+
function classifyAuthErrorKind(code, status) {
|
|
405
|
+
switch (code) {
|
|
406
|
+
case "NETWORK_ERROR":
|
|
407
|
+
case "NETWORK_TIMEOUT":
|
|
408
|
+
return "network";
|
|
409
|
+
case "PARSE_ERROR":
|
|
410
|
+
return "server";
|
|
411
|
+
case "RATE_LIMITED":
|
|
412
|
+
case "RATE_LIMIT":
|
|
413
|
+
case "RATE_LIMIT_EXCEEDED":
|
|
414
|
+
case "QUOTA_EXCEEDED":
|
|
415
|
+
return "rate_limited";
|
|
416
|
+
case "ACCOUNT_LOCKED":
|
|
417
|
+
return "account_locked";
|
|
418
|
+
case "CREDENTIAL_IN_USE":
|
|
419
|
+
return "credential_in_use";
|
|
420
|
+
case "NOT_ANONYMOUS":
|
|
421
|
+
case "FORBIDDEN_NON_ANONYMOUS":
|
|
422
|
+
return "already_identified";
|
|
423
|
+
case "PASSKEY_USER_CANCELLED":
|
|
424
|
+
case "GAME_CENTER_CANCELLED":
|
|
425
|
+
case "PLAY_GAMES_CANCELLED":
|
|
426
|
+
case "SSO_CANCELLED":
|
|
427
|
+
return "cancelled";
|
|
428
|
+
case "GAME_CENTER_UNAVAILABLE":
|
|
429
|
+
case "PLAY_GAMES_UNAVAILABLE":
|
|
430
|
+
case "ENTITLEMENT_ERROR":
|
|
431
|
+
case "tier_required":
|
|
432
|
+
return "config";
|
|
433
|
+
case "UNAUTHORIZED":
|
|
434
|
+
case "INVALID_CREDENTIALS":
|
|
435
|
+
case "INVALID_REFRESH_TOKEN":
|
|
436
|
+
case "EMAIL_OTP_INVALID":
|
|
437
|
+
case "MAGIC_LINK_INVALID":
|
|
438
|
+
case "PASSKEY_RP_MISMATCH":
|
|
439
|
+
case "NOT_SIGNED_IN":
|
|
440
|
+
return "invalid_credential";
|
|
441
|
+
default:
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
if (typeof status === "number") {
|
|
445
|
+
if (status === 429) return "rate_limited";
|
|
446
|
+
if (status >= 500) return "server";
|
|
447
|
+
if (status >= 400) return "invalid_credential";
|
|
448
|
+
}
|
|
449
|
+
return "unknown";
|
|
450
|
+
}
|
|
398
451
|
var AuthError = class extends Error {
|
|
399
|
-
constructor(code, message) {
|
|
452
|
+
constructor(code, message, opts) {
|
|
400
453
|
super(message);
|
|
401
454
|
this.name = "AuthError";
|
|
402
455
|
this.code = code;
|
|
456
|
+
this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
|
|
457
|
+
this.retryable = RETRYABLE_KINDS.has(this.kind);
|
|
458
|
+
this.retryAfterSeconds = opts?.retryAfterSeconds;
|
|
459
|
+
this.status = opts?.status;
|
|
403
460
|
}
|
|
404
461
|
};
|
|
462
|
+
function asAuthError(err) {
|
|
463
|
+
if (err instanceof AuthError) return err;
|
|
464
|
+
if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
|
|
465
|
+
return err;
|
|
466
|
+
}
|
|
467
|
+
const name = err?.name;
|
|
468
|
+
if (name === "NetworkTimeoutError") {
|
|
469
|
+
return new AuthError("NETWORK_TIMEOUT", err.message, { kind: "network" });
|
|
470
|
+
}
|
|
471
|
+
return new AuthError("UNKNOWN_ERROR", err instanceof Error ? err.message : String(err), {
|
|
472
|
+
kind: "unknown"
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function readRetryAfterSeconds(parsed) {
|
|
476
|
+
const details = parsed?.error?.details;
|
|
477
|
+
const v = details?.retryAfterSeconds;
|
|
478
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
479
|
+
}
|
|
405
480
|
var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
|
|
406
481
|
async function fetchGameCenterPayloadNative() {
|
|
407
482
|
const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
|
|
@@ -466,6 +541,7 @@ var Auth = class {
|
|
|
466
541
|
this.refreshCooldownUntil = 0;
|
|
467
542
|
this.takeoverListeners = /* @__PURE__ */ new Set();
|
|
468
543
|
this.deletionCancelledListeners = /* @__PURE__ */ new Set();
|
|
544
|
+
this.authStateListeners = /* @__PURE__ */ new Set();
|
|
469
545
|
this.lastDeletionCancelled = null;
|
|
470
546
|
this.lastTakeover = null;
|
|
471
547
|
/**
|
|
@@ -476,6 +552,11 @@ var Auth = class {
|
|
|
476
552
|
* device ended with two user_ids + duplicate pushes (audit s58.203 fix).
|
|
477
553
|
*/
|
|
478
554
|
this.pendingAnonTakeover = null;
|
|
555
|
+
/**
|
|
556
|
+
* True once `hydrate()` has run. Distinguishes "no session restored YET"
|
|
557
|
+
* from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
|
|
558
|
+
*/
|
|
559
|
+
this.hydrated = false;
|
|
479
560
|
/**
|
|
480
561
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
481
562
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -530,9 +611,9 @@ var Auth = class {
|
|
|
530
611
|
this.hooks.storage.remove(USER_KEY);
|
|
531
612
|
this.hooks.storage.remove(TOKEN_KEY);
|
|
532
613
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
533
|
-
this.hooks.storage.remove(REFRESH_KEY);
|
|
534
614
|
}
|
|
535
615
|
}
|
|
616
|
+
this.hydrated = true;
|
|
536
617
|
this.resolveReady();
|
|
537
618
|
}
|
|
538
619
|
getCurrentUser() {
|
|
@@ -558,6 +639,52 @@ var Auth = class {
|
|
|
558
639
|
this.takeoverListeners.delete(listener);
|
|
559
640
|
};
|
|
560
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* Subscribe to every auth-state transition (1.28.0) — the single stream that
|
|
644
|
+
* answers "what happened to my session?". Firebase `onAuthStateChanged` /
|
|
645
|
+
* Supabase `onAuthStateChange` parity.
|
|
646
|
+
*
|
|
647
|
+
* The reason it exists: a session that dies in the BACKGROUND (the server
|
|
648
|
+
* rejected the stored refresh token) used to emit no signal whatsoever, so
|
|
649
|
+
* an app could not distinguish it from a deliberate sign-out and only
|
|
650
|
+
* noticed when `getAccessToken()` began returning null. That transition is
|
|
651
|
+
* now `{ event: "signed_out", reason: "session_expired" }`.
|
|
652
|
+
*
|
|
653
|
+
* Fires the CURRENT state immediately on subscribe once `init()` has
|
|
654
|
+
* hydrated (a `signed_in` when a session was restored from disk — including
|
|
655
|
+
* offline, since hydrate is disk-only), so a subscriber never misses the
|
|
656
|
+
* state it started in. Nothing is emitted for a signed-out cold start.
|
|
657
|
+
*
|
|
658
|
+
* A failed sign-in emits NOTHING: the session is unchanged, so there is no
|
|
659
|
+
* transition to report — the rejected promise is the whole story.
|
|
660
|
+
*
|
|
661
|
+
* Listeners are best-effort; a throwing listener is swallowed (dev-logged)
|
|
662
|
+
* and never affects auth state. Returns an unsubscribe function.
|
|
663
|
+
*/
|
|
664
|
+
onAuthStateChanged(listener) {
|
|
665
|
+
this.authStateListeners.add(listener);
|
|
666
|
+
if (this.hydrated && this.user) {
|
|
667
|
+
const user = this.user;
|
|
668
|
+
try {
|
|
669
|
+
listener({ event: "signed_in", user });
|
|
670
|
+
} catch (err) {
|
|
671
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return () => {
|
|
675
|
+
this.authStateListeners.delete(listener);
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
/** Fan a state change out to subscribers. Never throws. */
|
|
679
|
+
emitAuthState(change) {
|
|
680
|
+
for (const listener of this.authStateListeners) {
|
|
681
|
+
try {
|
|
682
|
+
listener(change);
|
|
683
|
+
} catch (err) {
|
|
684
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
561
688
|
/**
|
|
562
689
|
* Inspect the most recent device-takeover event observed by the
|
|
563
690
|
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
@@ -654,8 +781,27 @@ var Auth = class {
|
|
|
654
781
|
getAccessTokenSync() {
|
|
655
782
|
return this.accessToken;
|
|
656
783
|
}
|
|
784
|
+
/**
|
|
785
|
+
* Mint (or return) an anonymous session.
|
|
786
|
+
*
|
|
787
|
+
* Reuses the existing anonymous user when there already is one — the same
|
|
788
|
+
* short-circuit Firebase's `signInAnonymously` performs. Without it, an app
|
|
789
|
+
* that calls this defensively on every cold launch (the most natural thing
|
|
790
|
+
* to write) mints a fresh `user_id` each time, and `persist()` overwrites
|
|
791
|
+
* the stored refresh token — the previous anonymous account's only durable
|
|
792
|
+
* handle — with no takeover, no webhook and no state event. That is the same
|
|
793
|
+
* silent progress loss as a failed sign-in wiping the session, except it
|
|
794
|
+
* happens on a *healthy* network.
|
|
795
|
+
*
|
|
796
|
+
* Pass `forceNew: true` to deliberately abandon the current anonymous
|
|
797
|
+
* session and mint a separate one.
|
|
798
|
+
*/
|
|
657
799
|
signInAnonymously(opts) {
|
|
658
800
|
return this.serialize(async () => {
|
|
801
|
+
const existing = this.user;
|
|
802
|
+
if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
|
|
803
|
+
return existing;
|
|
804
|
+
}
|
|
659
805
|
const data = await this.callAuth("/api/v1/auth-service/anonymous", {
|
|
660
806
|
name: opts?.name,
|
|
661
807
|
metadata: opts?.metadata
|
|
@@ -704,7 +850,7 @@ var Auth = class {
|
|
|
704
850
|
throw new EmailAlreadyTakenError(err.message);
|
|
705
851
|
}
|
|
706
852
|
if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
|
|
707
|
-
await this.wipeLocalIdentity();
|
|
853
|
+
await this.wipeLocalIdentity("session_expired");
|
|
708
854
|
} else {
|
|
709
855
|
throw err;
|
|
710
856
|
}
|
|
@@ -745,10 +891,14 @@ var Auth = class {
|
|
|
745
891
|
try {
|
|
746
892
|
parsed = await res.json();
|
|
747
893
|
} catch {
|
|
748
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
894
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
749
895
|
}
|
|
750
896
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
751
|
-
throw new AuthError(
|
|
897
|
+
throw new AuthError(
|
|
898
|
+
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
899
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
900
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
901
|
+
);
|
|
752
902
|
}
|
|
753
903
|
const r = parsed.data;
|
|
754
904
|
if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
|
|
@@ -775,12 +925,7 @@ var Auth = class {
|
|
|
775
925
|
*/
|
|
776
926
|
loginSocial(input) {
|
|
777
927
|
return this.serialize(async () => {
|
|
778
|
-
|
|
779
|
-
if (this.user?.isAnonymous) {
|
|
780
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
781
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
782
|
-
}
|
|
783
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
928
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
784
929
|
const { link, ...socialInput } = input;
|
|
785
930
|
const payload = { ...socialInput };
|
|
786
931
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
@@ -795,14 +940,16 @@ var Auth = class {
|
|
|
795
940
|
try {
|
|
796
941
|
parsed = await res.json();
|
|
797
942
|
} catch {
|
|
798
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
943
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
799
944
|
}
|
|
800
945
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
801
946
|
throw new AuthError(
|
|
802
947
|
parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
|
|
803
|
-
parsed.error?.message ?? "Social login failed"
|
|
948
|
+
parsed.error?.message ?? "Social login failed",
|
|
949
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
804
950
|
);
|
|
805
951
|
}
|
|
952
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
806
953
|
this.persist(parsed.data);
|
|
807
954
|
return parsed.data.user;
|
|
808
955
|
});
|
|
@@ -921,12 +1068,15 @@ var Auth = class {
|
|
|
921
1068
|
try {
|
|
922
1069
|
parsed = await res.json();
|
|
923
1070
|
} catch {
|
|
924
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1071
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
925
1072
|
}
|
|
926
1073
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
927
1074
|
const code = parsed.error?.code ?? `HTTP_${res.status}`;
|
|
928
1075
|
if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
|
|
929
|
-
throw new AuthError(code, parsed.error?.message ?? "Failed to link credential"
|
|
1076
|
+
throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
|
|
1077
|
+
status: res.status,
|
|
1078
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
1079
|
+
});
|
|
930
1080
|
}
|
|
931
1081
|
if (parsed.data.user) this.updateLocalUser(parsed.data.user);
|
|
932
1082
|
return parsed.data.user ?? this.user;
|
|
@@ -939,6 +1089,36 @@ var Auth = class {
|
|
|
939
1089
|
this.user = user;
|
|
940
1090
|
this.hooks.storage.set(USER_KEY, JSON.stringify(user));
|
|
941
1091
|
}
|
|
1092
|
+
/**
|
|
1093
|
+
* List the auth methods/identities linked to the current account (ADR-030
|
|
1094
|
+
* read side, s58.270). Returns every social/gaming identity on this account
|
|
1095
|
+
* plus a `hasPassword` flag — the cross-device / reinstall-durable source of
|
|
1096
|
+
* truth for a "Connected: Game Center · Google" UI (an on-device tracker
|
|
1097
|
+
* misses a link made on another device). Bearer-gated network read (NOT a
|
|
1098
|
+
* cache read — `getUserSync()` only holds the primary `signupMethod`/
|
|
1099
|
+
* `lastLoginMethod`). Resolves a FRESH access token first (mirrors
|
|
1100
|
+
* `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
|
|
1101
|
+
*/
|
|
1102
|
+
async listLinkedIdentities() {
|
|
1103
|
+
const token = await this.getAccessToken();
|
|
1104
|
+
if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before listing linked identities");
|
|
1105
|
+
const res = await getJson(
|
|
1106
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1107
|
+
"/api/v1/auth-service/me/identities",
|
|
1108
|
+
{ Authorization: `Bearer ${token}` }
|
|
1109
|
+
);
|
|
1110
|
+
if (!res || !res.ok) throw new AuthError("NETWORK_ERROR", "Failed to list linked identities");
|
|
1111
|
+
let parsed;
|
|
1112
|
+
try {
|
|
1113
|
+
parsed = await res.json();
|
|
1114
|
+
} catch {
|
|
1115
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
1116
|
+
}
|
|
1117
|
+
if (!parsed.success || !parsed.data) {
|
|
1118
|
+
throw new AuthError(parsed.error?.code ?? "IDENTITIES_FAILED", parsed.error?.message ?? "Failed to list linked identities");
|
|
1119
|
+
}
|
|
1120
|
+
return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
|
|
1121
|
+
}
|
|
942
1122
|
/**
|
|
943
1123
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
944
1124
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -949,12 +1129,7 @@ var Auth = class {
|
|
|
949
1129
|
gameIdentitySignIn(path, identityBody, link) {
|
|
950
1130
|
return this.serialize(async () => {
|
|
951
1131
|
const body = typeof identityBody === "function" ? await identityBody() : identityBody;
|
|
952
|
-
|
|
953
|
-
if (this.user?.isAnonymous) {
|
|
954
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
955
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
956
|
-
}
|
|
957
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1132
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
958
1133
|
const payload = { ...body };
|
|
959
1134
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
960
1135
|
if (link) payload.linkAnonymous = true;
|
|
@@ -968,14 +1143,16 @@ var Auth = class {
|
|
|
968
1143
|
try {
|
|
969
1144
|
parsed = await res.json();
|
|
970
1145
|
} catch {
|
|
971
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1146
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
972
1147
|
}
|
|
973
1148
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
974
1149
|
throw new AuthError(
|
|
975
1150
|
parsed.error?.code ?? "GAME_LOGIN_FAILED",
|
|
976
|
-
parsed.error?.message ?? "Game sign-in failed"
|
|
1151
|
+
parsed.error?.message ?? "Game sign-in failed",
|
|
1152
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
977
1153
|
);
|
|
978
1154
|
}
|
|
1155
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
979
1156
|
this.persist(parsed.data);
|
|
980
1157
|
return parsed.data.user;
|
|
981
1158
|
});
|
|
@@ -1026,10 +1203,10 @@ var Auth = class {
|
|
|
1026
1203
|
verifyMagicLink(token) {
|
|
1027
1204
|
return this.serialize(async () => {
|
|
1028
1205
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1029
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1030
1206
|
const body = { token };
|
|
1031
1207
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1032
1208
|
const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
|
|
1209
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1033
1210
|
this.persist(data);
|
|
1034
1211
|
return data.user;
|
|
1035
1212
|
});
|
|
@@ -1050,10 +1227,10 @@ var Auth = class {
|
|
|
1050
1227
|
verifyEmailOtp(email, code) {
|
|
1051
1228
|
return this.serialize(async () => {
|
|
1052
1229
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1053
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1054
1230
|
const body = { email, code };
|
|
1055
1231
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1056
1232
|
const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
|
|
1233
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1057
1234
|
this.persist(data);
|
|
1058
1235
|
return data.user;
|
|
1059
1236
|
});
|
|
@@ -1209,7 +1386,7 @@ var Auth = class {
|
|
|
1209
1386
|
}
|
|
1210
1387
|
} catch {
|
|
1211
1388
|
}
|
|
1212
|
-
await this.wipeLocalIdentity();
|
|
1389
|
+
await this.wipeLocalIdentity("account_deleted");
|
|
1213
1390
|
return out;
|
|
1214
1391
|
}
|
|
1215
1392
|
// ============================================================
|
|
@@ -1277,7 +1454,6 @@ var Auth = class {
|
|
|
1277
1454
|
*/
|
|
1278
1455
|
consumeSsoFragment(callbackUrl) {
|
|
1279
1456
|
return this.serialize(async () => {
|
|
1280
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1281
1457
|
const hashIdx = callbackUrl.indexOf("#");
|
|
1282
1458
|
const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
|
|
1283
1459
|
const params = new URLSearchParams(fragment);
|
|
@@ -1291,14 +1467,13 @@ var Auth = class {
|
|
|
1291
1467
|
"/api/v1/auth-service/token/refresh",
|
|
1292
1468
|
{ refreshToken: refresh }
|
|
1293
1469
|
);
|
|
1294
|
-
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}
|
|
1470
|
+
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
|
|
1295
1471
|
const parsed = await res.json();
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
|
|
1300
|
-
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
|
|
1472
|
+
const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
|
|
1473
|
+
if (!rotated) {
|
|
1474
|
+
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
|
|
1301
1475
|
}
|
|
1476
|
+
const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
|
|
1302
1477
|
const claims = decodeJwtPayload(access);
|
|
1303
1478
|
const user = {
|
|
1304
1479
|
id: claims?.sub ?? "",
|
|
@@ -1307,6 +1482,7 @@ var Auth = class {
|
|
|
1307
1482
|
name: claims?.name ?? null,
|
|
1308
1483
|
isAnonymous: false
|
|
1309
1484
|
};
|
|
1485
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1310
1486
|
this.persist({
|
|
1311
1487
|
user,
|
|
1312
1488
|
tokens: {
|
|
@@ -1342,7 +1518,7 @@ var Auth = class {
|
|
|
1342
1518
|
signOut() {
|
|
1343
1519
|
return this.serialize(async () => {
|
|
1344
1520
|
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
1345
|
-
await this.wipeLocalIdentity();
|
|
1521
|
+
await this.wipeLocalIdentity("user");
|
|
1346
1522
|
if (refresh) {
|
|
1347
1523
|
void post(
|
|
1348
1524
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
@@ -1356,7 +1532,11 @@ var Auth = class {
|
|
|
1356
1532
|
serialize(op) {
|
|
1357
1533
|
const guarded = async () => {
|
|
1358
1534
|
await this.readyPromise;
|
|
1359
|
-
|
|
1535
|
+
try {
|
|
1536
|
+
return await op();
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
throw asAuthError(err);
|
|
1539
|
+
}
|
|
1360
1540
|
};
|
|
1361
1541
|
const next = this.inflight.then(guarded, guarded);
|
|
1362
1542
|
this.inflight = next.catch(() => void 0);
|
|
@@ -1381,6 +1561,7 @@ var Auth = class {
|
|
|
1381
1561
|
const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
|
|
1382
1562
|
if (!refreshSnapshot) return null;
|
|
1383
1563
|
this.refreshInflight = (async () => {
|
|
1564
|
+
let sent = refreshSnapshot;
|
|
1384
1565
|
try {
|
|
1385
1566
|
const current = this.hooks.storage.get(REFRESH_KEY);
|
|
1386
1567
|
if (current && current !== refreshSnapshot) {
|
|
@@ -1397,20 +1578,28 @@ var Auth = class {
|
|
|
1397
1578
|
}
|
|
1398
1579
|
}
|
|
1399
1580
|
}
|
|
1581
|
+
sent = current ?? refreshSnapshot;
|
|
1400
1582
|
const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
|
|
1401
|
-
refreshToken:
|
|
1583
|
+
refreshToken: sent
|
|
1402
1584
|
});
|
|
1585
|
+
if (!this.tokenStillCurrent(sent)) return null;
|
|
1403
1586
|
this.accessToken = data.tokens.accessToken;
|
|
1404
1587
|
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
1405
1588
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
1406
1589
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
1407
1590
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1591
|
+
if (this.user === null && isAuthApiResponse(data)) {
|
|
1592
|
+
this.user = data.user;
|
|
1593
|
+
this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
|
|
1594
|
+
this.hooks.onIdentityChange(data.user.id);
|
|
1595
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1596
|
+
}
|
|
1408
1597
|
this.refreshFailureCount = 0;
|
|
1409
1598
|
this.refreshCooldownUntil = 0;
|
|
1410
1599
|
return data.tokens.accessToken;
|
|
1411
1600
|
} catch (err) {
|
|
1412
|
-
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
1413
|
-
await this.wipeLocalIdentity().catch(() => void 0);
|
|
1601
|
+
if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
|
|
1602
|
+
await this.wipeLocalIdentity("session_expired").catch(() => void 0);
|
|
1414
1603
|
this.refreshFailureCount = 0;
|
|
1415
1604
|
this.refreshCooldownUntil = 0;
|
|
1416
1605
|
return null;
|
|
@@ -1524,12 +1713,13 @@ var Auth = class {
|
|
|
1524
1713
|
try {
|
|
1525
1714
|
parsed = await res.json();
|
|
1526
1715
|
} catch {
|
|
1527
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1716
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1528
1717
|
}
|
|
1529
1718
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
1530
1719
|
throw new AuthError(
|
|
1531
1720
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1532
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1721
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1722
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1533
1723
|
);
|
|
1534
1724
|
}
|
|
1535
1725
|
const t = parsed.data.tokens;
|
|
@@ -1563,12 +1753,13 @@ var Auth = class {
|
|
|
1563
1753
|
try {
|
|
1564
1754
|
parsed = await res.json();
|
|
1565
1755
|
} catch {
|
|
1566
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1756
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1567
1757
|
}
|
|
1568
1758
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1569
1759
|
throw new AuthError(
|
|
1570
1760
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1571
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1761
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1762
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1572
1763
|
);
|
|
1573
1764
|
}
|
|
1574
1765
|
return parsed.data;
|
|
@@ -1583,14 +1774,46 @@ var Auth = class {
|
|
|
1583
1774
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1584
1775
|
this.hooks.onIdentityChange(data.user.id);
|
|
1585
1776
|
this.startProactiveRefreshCron();
|
|
1777
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1586
1778
|
if (data.retiredAnonUserId) {
|
|
1587
1779
|
this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
|
|
1780
|
+
this.emitAuthState({
|
|
1781
|
+
event: "device_takeover",
|
|
1782
|
+
user: data.user,
|
|
1783
|
+
retiredAnonUserId: data.retiredAnonUserId
|
|
1784
|
+
});
|
|
1588
1785
|
}
|
|
1589
1786
|
if (data.reactivatedFromDeletion) {
|
|
1590
1787
|
this.fireDeletionCancelled(data.user.id);
|
|
1788
|
+
this.emitAuthState({ event: "deletion_cancelled", user: data.user });
|
|
1591
1789
|
}
|
|
1592
1790
|
}
|
|
1593
|
-
|
|
1791
|
+
/**
|
|
1792
|
+
* Drop the local session.
|
|
1793
|
+
*
|
|
1794
|
+
* `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
|
|
1795
|
+
* emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
|
|
1796
|
+
* on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
|
|
1797
|
+
* not a spurious logout/login pair.
|
|
1798
|
+
*
|
|
1799
|
+
* ⚠ Never call this before a fallible network request. A failed sign-in must
|
|
1800
|
+
* leave the caller exactly as it found them; for an anonymous session the
|
|
1801
|
+
* refresh token dropped here is the ONLY durable handle on the account, so a
|
|
1802
|
+
* pre-call wipe that is not followed by a successful `persist()` orphans the
|
|
1803
|
+
* account permanently (offline: deterministically). Capture the anon refresh
|
|
1804
|
+
* into a local, make the call, and wipe only after the response validates.
|
|
1805
|
+
*/
|
|
1806
|
+
/**
|
|
1807
|
+
* True when `sent` is still the refresh token on disk — i.e. nothing
|
|
1808
|
+
* replaced or cleared the session while a request carrying it was in
|
|
1809
|
+
* flight. Guards both directions of the refresh race: installing a rotated
|
|
1810
|
+
* token over a session that has since been signed out or replaced, and
|
|
1811
|
+
* wiping a fresh session because an OLD token was rejected.
|
|
1812
|
+
*/
|
|
1813
|
+
tokenStillCurrent(sent) {
|
|
1814
|
+
return this.hooks.storage.get(REFRESH_KEY) === sent;
|
|
1815
|
+
}
|
|
1816
|
+
async wipeLocalIdentity(reason = "replaced") {
|
|
1594
1817
|
this.user = null;
|
|
1595
1818
|
this.accessToken = null;
|
|
1596
1819
|
this.accessExpiresAt = 0;
|
|
@@ -1603,11 +1826,23 @@ var Auth = class {
|
|
|
1603
1826
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
1604
1827
|
this.stopProactiveRefreshCron();
|
|
1605
1828
|
await this.hooks.onAnonymousWipe();
|
|
1829
|
+
if (reason !== "replaced") {
|
|
1830
|
+
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
1831
|
+
}
|
|
1606
1832
|
}
|
|
1607
1833
|
};
|
|
1608
1834
|
function isDeadRefreshError(code) {
|
|
1609
1835
|
if (!code) return false;
|
|
1610
|
-
return code === "INVALID_REFRESH_TOKEN"
|
|
1836
|
+
return code === "INVALID_REFRESH_TOKEN";
|
|
1837
|
+
}
|
|
1838
|
+
function readRotatedTokens(data) {
|
|
1839
|
+
if (!data || typeof data !== "object") return null;
|
|
1840
|
+
const container = data.tokens;
|
|
1841
|
+
const t = container && typeof container === "object" ? container : data;
|
|
1842
|
+
if (typeof t.accessToken !== "string" || !t.accessToken) return null;
|
|
1843
|
+
if (typeof t.refreshToken !== "string" || !t.refreshToken) return null;
|
|
1844
|
+
if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
|
|
1845
|
+
return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
|
|
1611
1846
|
}
|
|
1612
1847
|
function decodeJwtPayload(token) {
|
|
1613
1848
|
try {
|
|
@@ -3256,10 +3491,16 @@ var SendoraSDK = class {
|
|
|
3256
3491
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
3257
3492
|
* the AsyncStorage writes so a caller who kills the app immediately
|
|
3258
3493
|
* after doesn't leave stale identity on disk.
|
|
3494
|
+
*
|
|
3495
|
+
* Signs out through `auth` rather than just clearing storage: `clearAll()`
|
|
3496
|
+
* removes the auth keys too, so doing it directly left `Auth` holding an
|
|
3497
|
+
* in-memory user and reporting itself signed in while its refresh token was
|
|
3498
|
+
* already gone — with the server-side session never revoked and no
|
|
3499
|
+
* `onAuthStateChanged` emission telling the app the session had ended.
|
|
3259
3500
|
*/
|
|
3260
3501
|
async reset() {
|
|
3502
|
+
await this.auth.signOut().catch(() => void 0);
|
|
3261
3503
|
this.userId = null;
|
|
3262
|
-
this.anonId = uuid();
|
|
3263
3504
|
this.sessionId = "";
|
|
3264
3505
|
await this.storage.clearAll();
|
|
3265
3506
|
this.storage.set(ANON_KEY, this.anonId);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Sendora Cloud React Native + Expo SDK
|
|
3
|
+
"version": "1.29.0",
|
|
4
|
+
"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`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
58
58
|
"typecheck": "tsc --noEmit",
|
|
59
|
-
"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"
|
|
59
|
+
"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"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/react": "^19.2.16",
|