@sendoracloud/sdk-react-native 1.27.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 +258 -47
- package/dist/index.d.cts +156 -6
- package/dist/index.d.ts +156 -6
- package/dist/index.js +258 -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;
|
|
@@ -979,12 +1129,7 @@ var Auth = class {
|
|
|
979
1129
|
gameIdentitySignIn(path, identityBody, link) {
|
|
980
1130
|
return this.serialize(async () => {
|
|
981
1131
|
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();
|
|
1132
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
988
1133
|
const payload = { ...body };
|
|
989
1134
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
990
1135
|
if (link) payload.linkAnonymous = true;
|
|
@@ -998,14 +1143,16 @@ var Auth = class {
|
|
|
998
1143
|
try {
|
|
999
1144
|
parsed = await res.json();
|
|
1000
1145
|
} catch {
|
|
1001
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1146
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1002
1147
|
}
|
|
1003
1148
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1004
1149
|
throw new AuthError(
|
|
1005
1150
|
parsed.error?.code ?? "GAME_LOGIN_FAILED",
|
|
1006
|
-
parsed.error?.message ?? "Game sign-in failed"
|
|
1151
|
+
parsed.error?.message ?? "Game sign-in failed",
|
|
1152
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1007
1153
|
);
|
|
1008
1154
|
}
|
|
1155
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1009
1156
|
this.persist(parsed.data);
|
|
1010
1157
|
return parsed.data.user;
|
|
1011
1158
|
});
|
|
@@ -1056,10 +1203,10 @@ var Auth = class {
|
|
|
1056
1203
|
verifyMagicLink(token) {
|
|
1057
1204
|
return this.serialize(async () => {
|
|
1058
1205
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1059
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1060
1206
|
const body = { token };
|
|
1061
1207
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1062
1208
|
const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
|
|
1209
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1063
1210
|
this.persist(data);
|
|
1064
1211
|
return data.user;
|
|
1065
1212
|
});
|
|
@@ -1080,10 +1227,10 @@ var Auth = class {
|
|
|
1080
1227
|
verifyEmailOtp(email, code) {
|
|
1081
1228
|
return this.serialize(async () => {
|
|
1082
1229
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1083
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1084
1230
|
const body = { email, code };
|
|
1085
1231
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1086
1232
|
const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
|
|
1233
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1087
1234
|
this.persist(data);
|
|
1088
1235
|
return data.user;
|
|
1089
1236
|
});
|
|
@@ -1239,7 +1386,7 @@ var Auth = class {
|
|
|
1239
1386
|
}
|
|
1240
1387
|
} catch {
|
|
1241
1388
|
}
|
|
1242
|
-
await this.wipeLocalIdentity();
|
|
1389
|
+
await this.wipeLocalIdentity("account_deleted");
|
|
1243
1390
|
return out;
|
|
1244
1391
|
}
|
|
1245
1392
|
// ============================================================
|
|
@@ -1307,7 +1454,6 @@ var Auth = class {
|
|
|
1307
1454
|
*/
|
|
1308
1455
|
consumeSsoFragment(callbackUrl) {
|
|
1309
1456
|
return this.serialize(async () => {
|
|
1310
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1311
1457
|
const hashIdx = callbackUrl.indexOf("#");
|
|
1312
1458
|
const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
|
|
1313
1459
|
const params = new URLSearchParams(fragment);
|
|
@@ -1321,14 +1467,13 @@ var Auth = class {
|
|
|
1321
1467
|
"/api/v1/auth-service/token/refresh",
|
|
1322
1468
|
{ refreshToken: refresh }
|
|
1323
1469
|
);
|
|
1324
|
-
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 });
|
|
1325
1471
|
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");
|
|
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 });
|
|
1331
1475
|
}
|
|
1476
|
+
const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
|
|
1332
1477
|
const claims = decodeJwtPayload(access);
|
|
1333
1478
|
const user = {
|
|
1334
1479
|
id: claims?.sub ?? "",
|
|
@@ -1337,6 +1482,7 @@ var Auth = class {
|
|
|
1337
1482
|
name: claims?.name ?? null,
|
|
1338
1483
|
isAnonymous: false
|
|
1339
1484
|
};
|
|
1485
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1340
1486
|
this.persist({
|
|
1341
1487
|
user,
|
|
1342
1488
|
tokens: {
|
|
@@ -1372,7 +1518,7 @@ var Auth = class {
|
|
|
1372
1518
|
signOut() {
|
|
1373
1519
|
return this.serialize(async () => {
|
|
1374
1520
|
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
1375
|
-
await this.wipeLocalIdentity();
|
|
1521
|
+
await this.wipeLocalIdentity("user");
|
|
1376
1522
|
if (refresh) {
|
|
1377
1523
|
void post(
|
|
1378
1524
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
@@ -1386,7 +1532,11 @@ var Auth = class {
|
|
|
1386
1532
|
serialize(op) {
|
|
1387
1533
|
const guarded = async () => {
|
|
1388
1534
|
await this.readyPromise;
|
|
1389
|
-
|
|
1535
|
+
try {
|
|
1536
|
+
return await op();
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
throw asAuthError(err);
|
|
1539
|
+
}
|
|
1390
1540
|
};
|
|
1391
1541
|
const next = this.inflight.then(guarded, guarded);
|
|
1392
1542
|
this.inflight = next.catch(() => void 0);
|
|
@@ -1411,6 +1561,7 @@ var Auth = class {
|
|
|
1411
1561
|
const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
|
|
1412
1562
|
if (!refreshSnapshot) return null;
|
|
1413
1563
|
this.refreshInflight = (async () => {
|
|
1564
|
+
let sent = refreshSnapshot;
|
|
1414
1565
|
try {
|
|
1415
1566
|
const current = this.hooks.storage.get(REFRESH_KEY);
|
|
1416
1567
|
if (current && current !== refreshSnapshot) {
|
|
@@ -1427,20 +1578,28 @@ var Auth = class {
|
|
|
1427
1578
|
}
|
|
1428
1579
|
}
|
|
1429
1580
|
}
|
|
1581
|
+
sent = current ?? refreshSnapshot;
|
|
1430
1582
|
const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
|
|
1431
|
-
refreshToken:
|
|
1583
|
+
refreshToken: sent
|
|
1432
1584
|
});
|
|
1585
|
+
if (!this.tokenStillCurrent(sent)) return null;
|
|
1433
1586
|
this.accessToken = data.tokens.accessToken;
|
|
1434
1587
|
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
1435
1588
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
1436
1589
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
1437
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
|
+
}
|
|
1438
1597
|
this.refreshFailureCount = 0;
|
|
1439
1598
|
this.refreshCooldownUntil = 0;
|
|
1440
1599
|
return data.tokens.accessToken;
|
|
1441
1600
|
} catch (err) {
|
|
1442
|
-
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
1443
|
-
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);
|
|
1444
1603
|
this.refreshFailureCount = 0;
|
|
1445
1604
|
this.refreshCooldownUntil = 0;
|
|
1446
1605
|
return null;
|
|
@@ -1554,12 +1713,13 @@ var Auth = class {
|
|
|
1554
1713
|
try {
|
|
1555
1714
|
parsed = await res.json();
|
|
1556
1715
|
} catch {
|
|
1557
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1716
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1558
1717
|
}
|
|
1559
1718
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
1560
1719
|
throw new AuthError(
|
|
1561
1720
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1562
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1721
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1722
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1563
1723
|
);
|
|
1564
1724
|
}
|
|
1565
1725
|
const t = parsed.data.tokens;
|
|
@@ -1593,12 +1753,13 @@ var Auth = class {
|
|
|
1593
1753
|
try {
|
|
1594
1754
|
parsed = await res.json();
|
|
1595
1755
|
} catch {
|
|
1596
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1756
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1597
1757
|
}
|
|
1598
1758
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1599
1759
|
throw new AuthError(
|
|
1600
1760
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1601
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1761
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1762
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1602
1763
|
);
|
|
1603
1764
|
}
|
|
1604
1765
|
return parsed.data;
|
|
@@ -1613,14 +1774,46 @@ var Auth = class {
|
|
|
1613
1774
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1614
1775
|
this.hooks.onIdentityChange(data.user.id);
|
|
1615
1776
|
this.startProactiveRefreshCron();
|
|
1777
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1616
1778
|
if (data.retiredAnonUserId) {
|
|
1617
1779
|
this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
|
|
1780
|
+
this.emitAuthState({
|
|
1781
|
+
event: "device_takeover",
|
|
1782
|
+
user: data.user,
|
|
1783
|
+
retiredAnonUserId: data.retiredAnonUserId
|
|
1784
|
+
});
|
|
1618
1785
|
}
|
|
1619
1786
|
if (data.reactivatedFromDeletion) {
|
|
1620
1787
|
this.fireDeletionCancelled(data.user.id);
|
|
1788
|
+
this.emitAuthState({ event: "deletion_cancelled", user: data.user });
|
|
1621
1789
|
}
|
|
1622
1790
|
}
|
|
1623
|
-
|
|
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") {
|
|
1624
1817
|
this.user = null;
|
|
1625
1818
|
this.accessToken = null;
|
|
1626
1819
|
this.accessExpiresAt = 0;
|
|
@@ -1633,11 +1826,23 @@ var Auth = class {
|
|
|
1633
1826
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
1634
1827
|
this.stopProactiveRefreshCron();
|
|
1635
1828
|
await this.hooks.onAnonymousWipe();
|
|
1829
|
+
if (reason !== "replaced") {
|
|
1830
|
+
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
1831
|
+
}
|
|
1636
1832
|
}
|
|
1637
1833
|
};
|
|
1638
1834
|
function isDeadRefreshError(code) {
|
|
1639
1835
|
if (!code) return false;
|
|
1640
|
-
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 };
|
|
1641
1846
|
}
|
|
1642
1847
|
function decodeJwtPayload(token) {
|
|
1643
1848
|
try {
|
|
@@ -3286,10 +3491,16 @@ var SendoraSDK = class {
|
|
|
3286
3491
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
3287
3492
|
* the AsyncStorage writes so a caller who kills the app immediately
|
|
3288
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.
|
|
3289
3500
|
*/
|
|
3290
3501
|
async reset() {
|
|
3502
|
+
await this.auth.signOut().catch(() => void 0);
|
|
3291
3503
|
this.userId = null;
|
|
3292
|
-
this.anonId = uuid();
|
|
3293
3504
|
this.sessionId = "";
|
|
3294
3505
|
await this.storage.clearAll();
|
|
3295
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",
|