@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.cjs
CHANGED
|
@@ -202,7 +202,7 @@ var Storage = class {
|
|
|
202
202
|
// package.json
|
|
203
203
|
var package_default = {
|
|
204
204
|
name: "@sendoracloud/sdk-react-native",
|
|
205
|
-
version: "1.
|
|
205
|
+
version: "1.29.0",
|
|
206
206
|
description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
207
207
|
type: "module",
|
|
208
208
|
main: "./dist/index.cjs",
|
|
@@ -258,7 +258,7 @@ var package_default = {
|
|
|
258
258
|
scripts: {
|
|
259
259
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
260
260
|
typecheck: "tsc --noEmit",
|
|
261
|
-
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts"
|
|
261
|
+
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts"
|
|
262
262
|
},
|
|
263
263
|
devDependencies: {
|
|
264
264
|
"@types/react": "^19.2.16",
|
|
@@ -440,13 +440,88 @@ var CredentialInUseError = class extends Error {
|
|
|
440
440
|
this.name = "CredentialInUseError";
|
|
441
441
|
}
|
|
442
442
|
};
|
|
443
|
+
var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
|
|
444
|
+
"network",
|
|
445
|
+
"server",
|
|
446
|
+
"rate_limited",
|
|
447
|
+
"account_locked"
|
|
448
|
+
]);
|
|
449
|
+
function classifyAuthErrorKind(code, status) {
|
|
450
|
+
switch (code) {
|
|
451
|
+
case "NETWORK_ERROR":
|
|
452
|
+
case "NETWORK_TIMEOUT":
|
|
453
|
+
return "network";
|
|
454
|
+
case "PARSE_ERROR":
|
|
455
|
+
return "server";
|
|
456
|
+
case "RATE_LIMITED":
|
|
457
|
+
case "RATE_LIMIT":
|
|
458
|
+
case "RATE_LIMIT_EXCEEDED":
|
|
459
|
+
case "QUOTA_EXCEEDED":
|
|
460
|
+
return "rate_limited";
|
|
461
|
+
case "ACCOUNT_LOCKED":
|
|
462
|
+
return "account_locked";
|
|
463
|
+
case "CREDENTIAL_IN_USE":
|
|
464
|
+
return "credential_in_use";
|
|
465
|
+
case "NOT_ANONYMOUS":
|
|
466
|
+
case "FORBIDDEN_NON_ANONYMOUS":
|
|
467
|
+
return "already_identified";
|
|
468
|
+
case "PASSKEY_USER_CANCELLED":
|
|
469
|
+
case "GAME_CENTER_CANCELLED":
|
|
470
|
+
case "PLAY_GAMES_CANCELLED":
|
|
471
|
+
case "SSO_CANCELLED":
|
|
472
|
+
return "cancelled";
|
|
473
|
+
case "GAME_CENTER_UNAVAILABLE":
|
|
474
|
+
case "PLAY_GAMES_UNAVAILABLE":
|
|
475
|
+
case "ENTITLEMENT_ERROR":
|
|
476
|
+
case "tier_required":
|
|
477
|
+
return "config";
|
|
478
|
+
case "UNAUTHORIZED":
|
|
479
|
+
case "INVALID_CREDENTIALS":
|
|
480
|
+
case "INVALID_REFRESH_TOKEN":
|
|
481
|
+
case "EMAIL_OTP_INVALID":
|
|
482
|
+
case "MAGIC_LINK_INVALID":
|
|
483
|
+
case "PASSKEY_RP_MISMATCH":
|
|
484
|
+
case "NOT_SIGNED_IN":
|
|
485
|
+
return "invalid_credential";
|
|
486
|
+
default:
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
if (typeof status === "number") {
|
|
490
|
+
if (status === 429) return "rate_limited";
|
|
491
|
+
if (status >= 500) return "server";
|
|
492
|
+
if (status >= 400) return "invalid_credential";
|
|
493
|
+
}
|
|
494
|
+
return "unknown";
|
|
495
|
+
}
|
|
443
496
|
var AuthError = class extends Error {
|
|
444
|
-
constructor(code, message) {
|
|
497
|
+
constructor(code, message, opts) {
|
|
445
498
|
super(message);
|
|
446
499
|
this.name = "AuthError";
|
|
447
500
|
this.code = code;
|
|
501
|
+
this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
|
|
502
|
+
this.retryable = RETRYABLE_KINDS.has(this.kind);
|
|
503
|
+
this.retryAfterSeconds = opts?.retryAfterSeconds;
|
|
504
|
+
this.status = opts?.status;
|
|
448
505
|
}
|
|
449
506
|
};
|
|
507
|
+
function asAuthError(err) {
|
|
508
|
+
if (err instanceof AuthError) return err;
|
|
509
|
+
if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
|
|
510
|
+
return err;
|
|
511
|
+
}
|
|
512
|
+
const name = err?.name;
|
|
513
|
+
if (name === "NetworkTimeoutError") {
|
|
514
|
+
return new AuthError("NETWORK_TIMEOUT", err.message, { kind: "network" });
|
|
515
|
+
}
|
|
516
|
+
return new AuthError("UNKNOWN_ERROR", err instanceof Error ? err.message : String(err), {
|
|
517
|
+
kind: "unknown"
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
function readRetryAfterSeconds(parsed) {
|
|
521
|
+
const details = parsed?.error?.details;
|
|
522
|
+
const v = details?.retryAfterSeconds;
|
|
523
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
524
|
+
}
|
|
450
525
|
var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
|
|
451
526
|
async function fetchGameCenterPayloadNative() {
|
|
452
527
|
const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
|
|
@@ -511,6 +586,7 @@ var Auth = class {
|
|
|
511
586
|
this.refreshCooldownUntil = 0;
|
|
512
587
|
this.takeoverListeners = /* @__PURE__ */ new Set();
|
|
513
588
|
this.deletionCancelledListeners = /* @__PURE__ */ new Set();
|
|
589
|
+
this.authStateListeners = /* @__PURE__ */ new Set();
|
|
514
590
|
this.lastDeletionCancelled = null;
|
|
515
591
|
this.lastTakeover = null;
|
|
516
592
|
/**
|
|
@@ -521,6 +597,11 @@ var Auth = class {
|
|
|
521
597
|
* device ended with two user_ids + duplicate pushes (audit s58.203 fix).
|
|
522
598
|
*/
|
|
523
599
|
this.pendingAnonTakeover = null;
|
|
600
|
+
/**
|
|
601
|
+
* True once `hydrate()` has run. Distinguishes "no session restored YET"
|
|
602
|
+
* from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
|
|
603
|
+
*/
|
|
604
|
+
this.hydrated = false;
|
|
524
605
|
/**
|
|
525
606
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
526
607
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -575,9 +656,9 @@ var Auth = class {
|
|
|
575
656
|
this.hooks.storage.remove(USER_KEY);
|
|
576
657
|
this.hooks.storage.remove(TOKEN_KEY);
|
|
577
658
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
578
|
-
this.hooks.storage.remove(REFRESH_KEY);
|
|
579
659
|
}
|
|
580
660
|
}
|
|
661
|
+
this.hydrated = true;
|
|
581
662
|
this.resolveReady();
|
|
582
663
|
}
|
|
583
664
|
getCurrentUser() {
|
|
@@ -603,6 +684,52 @@ var Auth = class {
|
|
|
603
684
|
this.takeoverListeners.delete(listener);
|
|
604
685
|
};
|
|
605
686
|
}
|
|
687
|
+
/**
|
|
688
|
+
* Subscribe to every auth-state transition (1.28.0) — the single stream that
|
|
689
|
+
* answers "what happened to my session?". Firebase `onAuthStateChanged` /
|
|
690
|
+
* Supabase `onAuthStateChange` parity.
|
|
691
|
+
*
|
|
692
|
+
* The reason it exists: a session that dies in the BACKGROUND (the server
|
|
693
|
+
* rejected the stored refresh token) used to emit no signal whatsoever, so
|
|
694
|
+
* an app could not distinguish it from a deliberate sign-out and only
|
|
695
|
+
* noticed when `getAccessToken()` began returning null. That transition is
|
|
696
|
+
* now `{ event: "signed_out", reason: "session_expired" }`.
|
|
697
|
+
*
|
|
698
|
+
* Fires the CURRENT state immediately on subscribe once `init()` has
|
|
699
|
+
* hydrated (a `signed_in` when a session was restored from disk — including
|
|
700
|
+
* offline, since hydrate is disk-only), so a subscriber never misses the
|
|
701
|
+
* state it started in. Nothing is emitted for a signed-out cold start.
|
|
702
|
+
*
|
|
703
|
+
* A failed sign-in emits NOTHING: the session is unchanged, so there is no
|
|
704
|
+
* transition to report — the rejected promise is the whole story.
|
|
705
|
+
*
|
|
706
|
+
* Listeners are best-effort; a throwing listener is swallowed (dev-logged)
|
|
707
|
+
* and never affects auth state. Returns an unsubscribe function.
|
|
708
|
+
*/
|
|
709
|
+
onAuthStateChanged(listener) {
|
|
710
|
+
this.authStateListeners.add(listener);
|
|
711
|
+
if (this.hydrated && this.user) {
|
|
712
|
+
const user = this.user;
|
|
713
|
+
try {
|
|
714
|
+
listener({ event: "signed_in", user });
|
|
715
|
+
} catch (err) {
|
|
716
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return () => {
|
|
720
|
+
this.authStateListeners.delete(listener);
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/** Fan a state change out to subscribers. Never throws. */
|
|
724
|
+
emitAuthState(change) {
|
|
725
|
+
for (const listener of this.authStateListeners) {
|
|
726
|
+
try {
|
|
727
|
+
listener(change);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
606
733
|
/**
|
|
607
734
|
* Inspect the most recent device-takeover event observed by the
|
|
608
735
|
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
@@ -699,8 +826,27 @@ var Auth = class {
|
|
|
699
826
|
getAccessTokenSync() {
|
|
700
827
|
return this.accessToken;
|
|
701
828
|
}
|
|
829
|
+
/**
|
|
830
|
+
* Mint (or return) an anonymous session.
|
|
831
|
+
*
|
|
832
|
+
* Reuses the existing anonymous user when there already is one — the same
|
|
833
|
+
* short-circuit Firebase's `signInAnonymously` performs. Without it, an app
|
|
834
|
+
* that calls this defensively on every cold launch (the most natural thing
|
|
835
|
+
* to write) mints a fresh `user_id` each time, and `persist()` overwrites
|
|
836
|
+
* the stored refresh token — the previous anonymous account's only durable
|
|
837
|
+
* handle — with no takeover, no webhook and no state event. That is the same
|
|
838
|
+
* silent progress loss as a failed sign-in wiping the session, except it
|
|
839
|
+
* happens on a *healthy* network.
|
|
840
|
+
*
|
|
841
|
+
* Pass `forceNew: true` to deliberately abandon the current anonymous
|
|
842
|
+
* session and mint a separate one.
|
|
843
|
+
*/
|
|
702
844
|
signInAnonymously(opts) {
|
|
703
845
|
return this.serialize(async () => {
|
|
846
|
+
const existing = this.user;
|
|
847
|
+
if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
|
|
848
|
+
return existing;
|
|
849
|
+
}
|
|
704
850
|
const data = await this.callAuth("/api/v1/auth-service/anonymous", {
|
|
705
851
|
name: opts?.name,
|
|
706
852
|
metadata: opts?.metadata
|
|
@@ -749,7 +895,7 @@ var Auth = class {
|
|
|
749
895
|
throw new EmailAlreadyTakenError(err.message);
|
|
750
896
|
}
|
|
751
897
|
if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
|
|
752
|
-
await this.wipeLocalIdentity();
|
|
898
|
+
await this.wipeLocalIdentity("session_expired");
|
|
753
899
|
} else {
|
|
754
900
|
throw err;
|
|
755
901
|
}
|
|
@@ -790,10 +936,14 @@ var Auth = class {
|
|
|
790
936
|
try {
|
|
791
937
|
parsed = await res.json();
|
|
792
938
|
} catch {
|
|
793
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
939
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
794
940
|
}
|
|
795
941
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
796
|
-
throw new AuthError(
|
|
942
|
+
throw new AuthError(
|
|
943
|
+
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
944
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
945
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
946
|
+
);
|
|
797
947
|
}
|
|
798
948
|
const r = parsed.data;
|
|
799
949
|
if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
|
|
@@ -820,12 +970,7 @@ var Auth = class {
|
|
|
820
970
|
*/
|
|
821
971
|
loginSocial(input) {
|
|
822
972
|
return this.serialize(async () => {
|
|
823
|
-
|
|
824
|
-
if (this.user?.isAnonymous) {
|
|
825
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
826
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
827
|
-
}
|
|
828
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
973
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
829
974
|
const { link, ...socialInput } = input;
|
|
830
975
|
const payload = { ...socialInput };
|
|
831
976
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
@@ -840,14 +985,16 @@ var Auth = class {
|
|
|
840
985
|
try {
|
|
841
986
|
parsed = await res.json();
|
|
842
987
|
} catch {
|
|
843
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
988
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
844
989
|
}
|
|
845
990
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
846
991
|
throw new AuthError(
|
|
847
992
|
parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
|
|
848
|
-
parsed.error?.message ?? "Social login failed"
|
|
993
|
+
parsed.error?.message ?? "Social login failed",
|
|
994
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
849
995
|
);
|
|
850
996
|
}
|
|
997
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
851
998
|
this.persist(parsed.data);
|
|
852
999
|
return parsed.data.user;
|
|
853
1000
|
});
|
|
@@ -966,12 +1113,15 @@ var Auth = class {
|
|
|
966
1113
|
try {
|
|
967
1114
|
parsed = await res.json();
|
|
968
1115
|
} catch {
|
|
969
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1116
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
970
1117
|
}
|
|
971
1118
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
972
1119
|
const code = parsed.error?.code ?? `HTTP_${res.status}`;
|
|
973
1120
|
if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
|
|
974
|
-
throw new AuthError(code, parsed.error?.message ?? "Failed to link credential"
|
|
1121
|
+
throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
|
|
1122
|
+
status: res.status,
|
|
1123
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
1124
|
+
});
|
|
975
1125
|
}
|
|
976
1126
|
if (parsed.data.user) this.updateLocalUser(parsed.data.user);
|
|
977
1127
|
return parsed.data.user ?? this.user;
|
|
@@ -984,6 +1134,36 @@ var Auth = class {
|
|
|
984
1134
|
this.user = user;
|
|
985
1135
|
this.hooks.storage.set(USER_KEY, JSON.stringify(user));
|
|
986
1136
|
}
|
|
1137
|
+
/**
|
|
1138
|
+
* List the auth methods/identities linked to the current account (ADR-030
|
|
1139
|
+
* read side, s58.270). Returns every social/gaming identity on this account
|
|
1140
|
+
* plus a `hasPassword` flag — the cross-device / reinstall-durable source of
|
|
1141
|
+
* truth for a "Connected: Game Center · Google" UI (an on-device tracker
|
|
1142
|
+
* misses a link made on another device). Bearer-gated network read (NOT a
|
|
1143
|
+
* cache read — `getUserSync()` only holds the primary `signupMethod`/
|
|
1144
|
+
* `lastLoginMethod`). Resolves a FRESH access token first (mirrors
|
|
1145
|
+
* `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
|
|
1146
|
+
*/
|
|
1147
|
+
async listLinkedIdentities() {
|
|
1148
|
+
const token = await this.getAccessToken();
|
|
1149
|
+
if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before listing linked identities");
|
|
1150
|
+
const res = await getJson(
|
|
1151
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1152
|
+
"/api/v1/auth-service/me/identities",
|
|
1153
|
+
{ Authorization: `Bearer ${token}` }
|
|
1154
|
+
);
|
|
1155
|
+
if (!res || !res.ok) throw new AuthError("NETWORK_ERROR", "Failed to list linked identities");
|
|
1156
|
+
let parsed;
|
|
1157
|
+
try {
|
|
1158
|
+
parsed = await res.json();
|
|
1159
|
+
} catch {
|
|
1160
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
1161
|
+
}
|
|
1162
|
+
if (!parsed.success || !parsed.data) {
|
|
1163
|
+
throw new AuthError(parsed.error?.code ?? "IDENTITIES_FAILED", parsed.error?.message ?? "Failed to list linked identities");
|
|
1164
|
+
}
|
|
1165
|
+
return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
|
|
1166
|
+
}
|
|
987
1167
|
/**
|
|
988
1168
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
989
1169
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -994,12 +1174,7 @@ var Auth = class {
|
|
|
994
1174
|
gameIdentitySignIn(path, identityBody, link) {
|
|
995
1175
|
return this.serialize(async () => {
|
|
996
1176
|
const body = typeof identityBody === "function" ? await identityBody() : identityBody;
|
|
997
|
-
|
|
998
|
-
if (this.user?.isAnonymous) {
|
|
999
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
1000
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
1001
|
-
}
|
|
1002
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1177
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1003
1178
|
const payload = { ...body };
|
|
1004
1179
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1005
1180
|
if (link) payload.linkAnonymous = true;
|
|
@@ -1013,14 +1188,16 @@ var Auth = class {
|
|
|
1013
1188
|
try {
|
|
1014
1189
|
parsed = await res.json();
|
|
1015
1190
|
} catch {
|
|
1016
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1191
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1017
1192
|
}
|
|
1018
1193
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1019
1194
|
throw new AuthError(
|
|
1020
1195
|
parsed.error?.code ?? "GAME_LOGIN_FAILED",
|
|
1021
|
-
parsed.error?.message ?? "Game sign-in failed"
|
|
1196
|
+
parsed.error?.message ?? "Game sign-in failed",
|
|
1197
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1022
1198
|
);
|
|
1023
1199
|
}
|
|
1200
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1024
1201
|
this.persist(parsed.data);
|
|
1025
1202
|
return parsed.data.user;
|
|
1026
1203
|
});
|
|
@@ -1071,10 +1248,10 @@ var Auth = class {
|
|
|
1071
1248
|
verifyMagicLink(token) {
|
|
1072
1249
|
return this.serialize(async () => {
|
|
1073
1250
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1074
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1075
1251
|
const body = { token };
|
|
1076
1252
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1077
1253
|
const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
|
|
1254
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1078
1255
|
this.persist(data);
|
|
1079
1256
|
return data.user;
|
|
1080
1257
|
});
|
|
@@ -1095,10 +1272,10 @@ var Auth = class {
|
|
|
1095
1272
|
verifyEmailOtp(email, code) {
|
|
1096
1273
|
return this.serialize(async () => {
|
|
1097
1274
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1098
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1099
1275
|
const body = { email, code };
|
|
1100
1276
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1101
1277
|
const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
|
|
1278
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1102
1279
|
this.persist(data);
|
|
1103
1280
|
return data.user;
|
|
1104
1281
|
});
|
|
@@ -1254,7 +1431,7 @@ var Auth = class {
|
|
|
1254
1431
|
}
|
|
1255
1432
|
} catch {
|
|
1256
1433
|
}
|
|
1257
|
-
await this.wipeLocalIdentity();
|
|
1434
|
+
await this.wipeLocalIdentity("account_deleted");
|
|
1258
1435
|
return out;
|
|
1259
1436
|
}
|
|
1260
1437
|
// ============================================================
|
|
@@ -1322,7 +1499,6 @@ var Auth = class {
|
|
|
1322
1499
|
*/
|
|
1323
1500
|
consumeSsoFragment(callbackUrl) {
|
|
1324
1501
|
return this.serialize(async () => {
|
|
1325
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1326
1502
|
const hashIdx = callbackUrl.indexOf("#");
|
|
1327
1503
|
const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
|
|
1328
1504
|
const params = new URLSearchParams(fragment);
|
|
@@ -1336,14 +1512,13 @@ var Auth = class {
|
|
|
1336
1512
|
"/api/v1/auth-service/token/refresh",
|
|
1337
1513
|
{ refreshToken: refresh }
|
|
1338
1514
|
);
|
|
1339
|
-
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}
|
|
1515
|
+
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
|
|
1340
1516
|
const parsed = await res.json();
|
|
1341
|
-
const
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
|
|
1345
|
-
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
|
|
1517
|
+
const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
|
|
1518
|
+
if (!rotated) {
|
|
1519
|
+
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
|
|
1346
1520
|
}
|
|
1521
|
+
const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
|
|
1347
1522
|
const claims = decodeJwtPayload(access);
|
|
1348
1523
|
const user = {
|
|
1349
1524
|
id: claims?.sub ?? "",
|
|
@@ -1352,6 +1527,7 @@ var Auth = class {
|
|
|
1352
1527
|
name: claims?.name ?? null,
|
|
1353
1528
|
isAnonymous: false
|
|
1354
1529
|
};
|
|
1530
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1355
1531
|
this.persist({
|
|
1356
1532
|
user,
|
|
1357
1533
|
tokens: {
|
|
@@ -1387,7 +1563,7 @@ var Auth = class {
|
|
|
1387
1563
|
signOut() {
|
|
1388
1564
|
return this.serialize(async () => {
|
|
1389
1565
|
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
1390
|
-
await this.wipeLocalIdentity();
|
|
1566
|
+
await this.wipeLocalIdentity("user");
|
|
1391
1567
|
if (refresh) {
|
|
1392
1568
|
void post(
|
|
1393
1569
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
@@ -1401,7 +1577,11 @@ var Auth = class {
|
|
|
1401
1577
|
serialize(op) {
|
|
1402
1578
|
const guarded = async () => {
|
|
1403
1579
|
await this.readyPromise;
|
|
1404
|
-
|
|
1580
|
+
try {
|
|
1581
|
+
return await op();
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
throw asAuthError(err);
|
|
1584
|
+
}
|
|
1405
1585
|
};
|
|
1406
1586
|
const next = this.inflight.then(guarded, guarded);
|
|
1407
1587
|
this.inflight = next.catch(() => void 0);
|
|
@@ -1426,6 +1606,7 @@ var Auth = class {
|
|
|
1426
1606
|
const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
|
|
1427
1607
|
if (!refreshSnapshot) return null;
|
|
1428
1608
|
this.refreshInflight = (async () => {
|
|
1609
|
+
let sent = refreshSnapshot;
|
|
1429
1610
|
try {
|
|
1430
1611
|
const current = this.hooks.storage.get(REFRESH_KEY);
|
|
1431
1612
|
if (current && current !== refreshSnapshot) {
|
|
@@ -1442,20 +1623,28 @@ var Auth = class {
|
|
|
1442
1623
|
}
|
|
1443
1624
|
}
|
|
1444
1625
|
}
|
|
1626
|
+
sent = current ?? refreshSnapshot;
|
|
1445
1627
|
const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
|
|
1446
|
-
refreshToken:
|
|
1628
|
+
refreshToken: sent
|
|
1447
1629
|
});
|
|
1630
|
+
if (!this.tokenStillCurrent(sent)) return null;
|
|
1448
1631
|
this.accessToken = data.tokens.accessToken;
|
|
1449
1632
|
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
1450
1633
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
1451
1634
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
1452
1635
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1636
|
+
if (this.user === null && isAuthApiResponse(data)) {
|
|
1637
|
+
this.user = data.user;
|
|
1638
|
+
this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
|
|
1639
|
+
this.hooks.onIdentityChange(data.user.id);
|
|
1640
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1641
|
+
}
|
|
1453
1642
|
this.refreshFailureCount = 0;
|
|
1454
1643
|
this.refreshCooldownUntil = 0;
|
|
1455
1644
|
return data.tokens.accessToken;
|
|
1456
1645
|
} catch (err) {
|
|
1457
|
-
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
1458
|
-
await this.wipeLocalIdentity().catch(() => void 0);
|
|
1646
|
+
if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
|
|
1647
|
+
await this.wipeLocalIdentity("session_expired").catch(() => void 0);
|
|
1459
1648
|
this.refreshFailureCount = 0;
|
|
1460
1649
|
this.refreshCooldownUntil = 0;
|
|
1461
1650
|
return null;
|
|
@@ -1569,12 +1758,13 @@ var Auth = class {
|
|
|
1569
1758
|
try {
|
|
1570
1759
|
parsed = await res.json();
|
|
1571
1760
|
} catch {
|
|
1572
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1761
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1573
1762
|
}
|
|
1574
1763
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
1575
1764
|
throw new AuthError(
|
|
1576
1765
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1577
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1766
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1767
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1578
1768
|
);
|
|
1579
1769
|
}
|
|
1580
1770
|
const t = parsed.data.tokens;
|
|
@@ -1608,12 +1798,13 @@ var Auth = class {
|
|
|
1608
1798
|
try {
|
|
1609
1799
|
parsed = await res.json();
|
|
1610
1800
|
} catch {
|
|
1611
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1801
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1612
1802
|
}
|
|
1613
1803
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1614
1804
|
throw new AuthError(
|
|
1615
1805
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1616
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1806
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1807
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1617
1808
|
);
|
|
1618
1809
|
}
|
|
1619
1810
|
return parsed.data;
|
|
@@ -1628,14 +1819,46 @@ var Auth = class {
|
|
|
1628
1819
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1629
1820
|
this.hooks.onIdentityChange(data.user.id);
|
|
1630
1821
|
this.startProactiveRefreshCron();
|
|
1822
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1631
1823
|
if (data.retiredAnonUserId) {
|
|
1632
1824
|
this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
|
|
1825
|
+
this.emitAuthState({
|
|
1826
|
+
event: "device_takeover",
|
|
1827
|
+
user: data.user,
|
|
1828
|
+
retiredAnonUserId: data.retiredAnonUserId
|
|
1829
|
+
});
|
|
1633
1830
|
}
|
|
1634
1831
|
if (data.reactivatedFromDeletion) {
|
|
1635
1832
|
this.fireDeletionCancelled(data.user.id);
|
|
1833
|
+
this.emitAuthState({ event: "deletion_cancelled", user: data.user });
|
|
1636
1834
|
}
|
|
1637
1835
|
}
|
|
1638
|
-
|
|
1836
|
+
/**
|
|
1837
|
+
* Drop the local session.
|
|
1838
|
+
*
|
|
1839
|
+
* `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
|
|
1840
|
+
* emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
|
|
1841
|
+
* on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
|
|
1842
|
+
* not a spurious logout/login pair.
|
|
1843
|
+
*
|
|
1844
|
+
* ⚠ Never call this before a fallible network request. A failed sign-in must
|
|
1845
|
+
* leave the caller exactly as it found them; for an anonymous session the
|
|
1846
|
+
* refresh token dropped here is the ONLY durable handle on the account, so a
|
|
1847
|
+
* pre-call wipe that is not followed by a successful `persist()` orphans the
|
|
1848
|
+
* account permanently (offline: deterministically). Capture the anon refresh
|
|
1849
|
+
* into a local, make the call, and wipe only after the response validates.
|
|
1850
|
+
*/
|
|
1851
|
+
/**
|
|
1852
|
+
* True when `sent` is still the refresh token on disk — i.e. nothing
|
|
1853
|
+
* replaced or cleared the session while a request carrying it was in
|
|
1854
|
+
* flight. Guards both directions of the refresh race: installing a rotated
|
|
1855
|
+
* token over a session that has since been signed out or replaced, and
|
|
1856
|
+
* wiping a fresh session because an OLD token was rejected.
|
|
1857
|
+
*/
|
|
1858
|
+
tokenStillCurrent(sent) {
|
|
1859
|
+
return this.hooks.storage.get(REFRESH_KEY) === sent;
|
|
1860
|
+
}
|
|
1861
|
+
async wipeLocalIdentity(reason = "replaced") {
|
|
1639
1862
|
this.user = null;
|
|
1640
1863
|
this.accessToken = null;
|
|
1641
1864
|
this.accessExpiresAt = 0;
|
|
@@ -1648,11 +1871,23 @@ var Auth = class {
|
|
|
1648
1871
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
1649
1872
|
this.stopProactiveRefreshCron();
|
|
1650
1873
|
await this.hooks.onAnonymousWipe();
|
|
1874
|
+
if (reason !== "replaced") {
|
|
1875
|
+
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
1876
|
+
}
|
|
1651
1877
|
}
|
|
1652
1878
|
};
|
|
1653
1879
|
function isDeadRefreshError(code) {
|
|
1654
1880
|
if (!code) return false;
|
|
1655
|
-
return code === "INVALID_REFRESH_TOKEN"
|
|
1881
|
+
return code === "INVALID_REFRESH_TOKEN";
|
|
1882
|
+
}
|
|
1883
|
+
function readRotatedTokens(data) {
|
|
1884
|
+
if (!data || typeof data !== "object") return null;
|
|
1885
|
+
const container = data.tokens;
|
|
1886
|
+
const t = container && typeof container === "object" ? container : data;
|
|
1887
|
+
if (typeof t.accessToken !== "string" || !t.accessToken) return null;
|
|
1888
|
+
if (typeof t.refreshToken !== "string" || !t.refreshToken) return null;
|
|
1889
|
+
if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
|
|
1890
|
+
return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
|
|
1656
1891
|
}
|
|
1657
1892
|
function decodeJwtPayload(token) {
|
|
1658
1893
|
try {
|
|
@@ -3296,10 +3531,16 @@ var SendoraSDK = class {
|
|
|
3296
3531
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
3297
3532
|
* the AsyncStorage writes so a caller who kills the app immediately
|
|
3298
3533
|
* after doesn't leave stale identity on disk.
|
|
3534
|
+
*
|
|
3535
|
+
* Signs out through `auth` rather than just clearing storage: `clearAll()`
|
|
3536
|
+
* removes the auth keys too, so doing it directly left `Auth` holding an
|
|
3537
|
+
* in-memory user and reporting itself signed in while its refresh token was
|
|
3538
|
+
* already gone — with the server-side session never revoked and no
|
|
3539
|
+
* `onAuthStateChanged` emission telling the app the session had ended.
|
|
3299
3540
|
*/
|
|
3300
3541
|
async reset() {
|
|
3542
|
+
await this.auth.signOut().catch(() => void 0);
|
|
3301
3543
|
this.userId = null;
|
|
3302
|
-
this.anonId = uuid();
|
|
3303
3544
|
this.sessionId = "";
|
|
3304
3545
|
await this.storage.clearAll();
|
|
3305
3546
|
this.storage.set(ANON_KEY, this.anonId);
|