@sendoracloud/sdk-react-native 1.32.0 → 1.34.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 CHANGED
@@ -48,7 +48,7 @@ var import_react_native_get_random_values = require("react-native-get-random-val
48
48
  var import_react_native5 = require("react-native");
49
49
 
50
50
  // src/storage.ts
51
- var PREFIX = "sendora_";
51
+ var DEFAULT_PREFIX = "sendora_";
52
52
  var asyncStoragePromise = null;
53
53
  async function getAsyncStorage() {
54
54
  if (asyncStoragePromise) return asyncStoragePromise;
@@ -71,6 +71,12 @@ var Storage = class {
71
71
  this.hydrated = false;
72
72
  this.secure = opts.secureAdapter ?? null;
73
73
  this.secureKeys = new Set(opts.secureKeys ?? []);
74
+ this.prefix = opts.prefix ?? DEFAULT_PREFIX;
75
+ this.securePrefix = opts.prefix ?? "";
76
+ }
77
+ /** The adapter-facing name for a key. Bare by default; see `prefix`. */
78
+ secureName(key) {
79
+ return this.securePrefix + key;
74
80
  }
75
81
  isSecure(key) {
76
82
  return this.secure !== null && this.secureKeys.has(key);
@@ -91,7 +97,7 @@ var Storage = class {
91
97
  await this.hydrateSecureKey(k, store);
92
98
  } else if (store) {
93
99
  try {
94
- const v = await store.getItem(PREFIX + k);
100
+ const v = await store.getItem(this.prefix + k);
95
101
  if (v !== null) this.cache.set(k, v);
96
102
  } catch {
97
103
  }
@@ -102,18 +108,18 @@ var Storage = class {
102
108
  /** Read a secure key: adapter first, else migrate legacy plaintext. Never throws. */
103
109
  async hydrateSecureKey(k, store) {
104
110
  try {
105
- const secureVal = await this.secure.getItem(k);
111
+ const secureVal = await this.secure.getItem(this.secureName(k));
106
112
  if (secureVal !== null) {
107
113
  this.cache.set(k, secureVal);
108
114
  return;
109
115
  }
110
116
  if (store) {
111
- const legacy = await store.getItem(PREFIX + k).catch(() => null);
117
+ const legacy = await store.getItem(this.prefix + k).catch(() => null);
112
118
  if (legacy !== null) {
113
119
  this.cache.set(k, legacy);
114
120
  try {
115
- await this.secure.setItem(k, legacy);
116
- await store.removeItem(PREFIX + k).catch(() => {
121
+ await this.secure.setItem(this.secureName(k), legacy);
122
+ await store.removeItem(this.prefix + k).catch(() => {
117
123
  });
118
124
  } catch {
119
125
  }
@@ -122,7 +128,7 @@ var Storage = class {
122
128
  } catch {
123
129
  if (store) {
124
130
  try {
125
- const legacy = await store.getItem(PREFIX + k);
131
+ const legacy = await store.getItem(this.prefix + k);
126
132
  if (legacy !== null) this.cache.set(k, legacy);
127
133
  } catch {
128
134
  }
@@ -135,27 +141,27 @@ var Storage = class {
135
141
  set(key, value) {
136
142
  this.cache.set(key, value);
137
143
  if (this.isSecure(key)) {
138
- void this.secure.setItem(key, value).catch(() => {
144
+ void this.secure.setItem(this.secureName(key), value).catch(() => {
139
145
  void getAsyncStorage().then((store) => {
140
- if (store) store.setItem(PREFIX + key, value).catch(() => {
146
+ if (store) store.setItem(this.prefix + key, value).catch(() => {
141
147
  });
142
148
  });
143
149
  });
144
150
  return;
145
151
  }
146
152
  void getAsyncStorage().then((store) => {
147
- if (store) store.setItem(PREFIX + key, value).catch(() => {
153
+ if (store) store.setItem(this.prefix + key, value).catch(() => {
148
154
  });
149
155
  });
150
156
  }
151
157
  remove(key) {
152
158
  this.cache.delete(key);
153
159
  if (this.isSecure(key)) {
154
- void this.secure.removeItem(key).catch(() => {
160
+ void this.secure.removeItem(this.secureName(key)).catch(() => {
155
161
  });
156
162
  }
157
163
  void getAsyncStorage().then((store) => {
158
- if (store) store.removeItem(PREFIX + key).catch(() => {
164
+ if (store) store.removeItem(this.prefix + key).catch(() => {
159
165
  });
160
166
  });
161
167
  }
@@ -171,7 +177,7 @@ var Storage = class {
171
177
  if (this.secure) {
172
178
  for (const k of this.secureKeys) {
173
179
  try {
174
- await this.secure.removeItem(k);
180
+ await this.secure.removeItem(this.secureName(k));
175
181
  } catch {
176
182
  }
177
183
  }
@@ -182,13 +188,13 @@ var Storage = class {
182
188
  if (typeof store.getAllKeys === "function") {
183
189
  try {
184
190
  const all = await store.getAllKeys();
185
- keys = all.filter((k) => k.startsWith(PREFIX));
191
+ keys = all.filter((k) => k.startsWith(this.prefix));
186
192
  } catch {
187
193
  keys = [];
188
194
  }
189
195
  }
190
196
  if (keys.length === 0) {
191
- keys = Array.from(this.secureKeys).map((k) => PREFIX + k);
197
+ keys = Array.from(this.secureKeys).map((k) => this.prefix + k);
192
198
  }
193
199
  for (const k of keys) {
194
200
  try {
@@ -202,7 +208,7 @@ var Storage = class {
202
208
  // package.json
203
209
  var package_default = {
204
210
  name: "@sendoracloud/sdk-react-native",
205
- version: "1.32.0",
211
+ version: "1.34.0",
206
212
  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
213
  type: "module",
208
214
  main: "./dist/index.cjs",
@@ -258,7 +264,7 @@ var package_default = {
258
264
  scripts: {
259
265
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
260
266
  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 && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts"
267
+ test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts"
262
268
  },
263
269
  devDependencies: {
264
270
  "@types/react": "^19.2.16",
@@ -595,6 +601,27 @@ var Auth = class {
595
601
  this.user = null;
596
602
  this.accessToken = null;
597
603
  this.accessExpiresAt = 0;
604
+ /**
605
+ * Set once we have PROOF that this device's clock runs fast, which
606
+ * releases the `exp` guard in `canServeCachedToken`.
607
+ *
608
+ * The proof is specific and cannot be faked by a stale deadline: a token
609
+ * the server minted *seconds ago* reads as already past its own `exp`.
610
+ * A freshly-issued token is never genuinely expired, so the only
611
+ * explanation is the clock we measured it with.
612
+ *
613
+ * ⚠ NEVER PERSIST THIS. It is re-learned for the cost of one refresh per
614
+ * process, and a persisted "my clock is fast" belief would outlive the
615
+ * clock correction that made it false — which is precisely the stale-frame
616
+ * bug this whole guard exists to close, moved one level up.
617
+ *
618
+ * ⚠ Without this escape the `exp` guard alone is a REFRESH LOOP on any
619
+ * device whose clock is fast by more than the token TTL (default 60 min):
620
+ * every freshly-minted token would read as already expired, so every
621
+ * `getAccessToken()` would refresh, forever. That is why the fix is not
622
+ * simply "require `exp` too".
623
+ */
624
+ this.clockSkewConfirmed = false;
598
625
  this.inflight = Promise.resolve();
599
626
  this.refreshInflight = null;
600
627
  /**
@@ -614,6 +641,7 @@ var Auth = class {
614
641
  this.deletionCancelledListeners = /* @__PURE__ */ new Set();
615
642
  this.authStateListeners = /* @__PURE__ */ new Set();
616
643
  this.lastDeletionCancelled = null;
644
+ this.lastAnonRetirement = null;
617
645
  this.lastTakeover = null;
618
646
  /**
619
647
  * Anon refresh token captured at `signIn()` time, stashed keyed to the
@@ -685,6 +713,7 @@ var Auth = class {
685
713
  }
686
714
  }
687
715
  this.hydrated = true;
716
+ if (this.user && this.hooks.storage.get(REFRESH_KEY)) this.startProactiveRefreshCron();
688
717
  this.resolveReady();
689
718
  }
690
719
  getCurrentUser() {
@@ -807,6 +836,19 @@ var Auth = class {
807
836
  getLastDeletionCancelled() {
808
837
  return this.lastDeletionCancelled;
809
838
  }
839
+ /**
840
+ * What the last sign-in did with this device's guest account, or null if no
841
+ * sign-in has happened this session (s58.278).
842
+ *
843
+ * The one worth acting on is `"preserved"`: a guest account was presented,
844
+ * was NOT retired, and is therefore still alive server-side — so "recover
845
+ * your other account" is a real offer rather than a guess. `"retired"` means
846
+ * migrate from `onDeviceTakeover`'s id; `"none"` means there was nothing to
847
+ * reconcile. Cleared on signOut.
848
+ */
849
+ getLastAnonRetirement() {
850
+ return this.lastAnonRetirement;
851
+ }
810
852
  /** Internal — fan out deletion-cancelled to subscribers + cache the latest. */
811
853
  fireDeletionCancelled(userId) {
812
854
  const evt = { userId, at: /* @__PURE__ */ new Date() };
@@ -833,7 +875,11 @@ var Auth = class {
833
875
  *
834
876
  * Resolution order:
835
877
  * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
836
- * skew before exp) → return it. Fast path, no I/O.
878
+ * before BOTH the tracked deadline and the token's own `exp`) →
879
+ * return it. Fast path, no I/O. See `canServeCachedToken` for why
880
+ * both are required and when the second is released.
881
+ * Pass `{ forceRefresh: true }` to skip this step when the caller
882
+ * knows the tracked deadline is wrong.
837
883
  * 2. No cached token AND no refresh token in storage → genuinely
838
884
  * signed out → null.
839
885
  * 3. Expired/near-expiry but a refresh chain exists:
@@ -851,14 +897,85 @@ var Auth = class {
851
897
  * the cooldown for an immediate retry on resume.
852
898
  * c. Otherwise → fire the refresh and await it.
853
899
  */
854
- async getAccessToken() {
855
- if (this.accessToken && this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
900
+ /**
901
+ * The cached-token guard, and the single place the two deadlines are
902
+ * combined. Both must agree before a cached token is served:
903
+ *
904
+ * 1. the locally-tracked deadline (`Date.now() + expiresIn` at mint) —
905
+ * immune to a constantly-wrong clock, blind to a clock that MOVES;
906
+ * 2. the token's own `exp` — immune to a clock that moves, wrong by the
907
+ * full offset on a constantly-wrong clock.
908
+ *
909
+ * Guard 2 is released once `clockSkewConfirmed` proves the second failure
910
+ * mode is the one we are actually in.
911
+ *
912
+ * The gap this closes: `accessExpiresAt` is written in the device's clock
913
+ * frame at mint and PERSISTED. Step the clock backwards between mint and
914
+ * read — an iPhone acquiring automatic time after a long power-off, a
915
+ * restore, or a manual set — and the deadline is stale by the size of the
916
+ * correction, surviving relaunches. The SDK then serves a provably dead
917
+ * token, every request 401s, and because the fast path returns before
918
+ * reaching `persist()` or `wipeLocalIdentity()` nothing emits
919
+ * `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
920
+ */
921
+ canServeToken(token, expiresAt, nowMs) {
922
+ if (!token || !expiresAt) return false;
923
+ if (nowMs >= expiresAt - REFRESH_SAFETY_MS) return false;
924
+ if (this.clockSkewConfirmed) return true;
925
+ return accessTokenExpIsFuture(token, nowMs);
926
+ }
927
+ canServeCachedToken(nowMs) {
928
+ return this.canServeToken(this.accessToken, this.accessExpiresAt, nowMs);
929
+ }
930
+ /** Guard 1 alone: does the locally-tracked deadline still claim life? */
931
+ trackedDeadlineAlive(nowMs) {
932
+ return !!this.accessToken && !!this.accessExpiresAt && nowMs < this.accessExpiresAt - REFRESH_SAFETY_MS;
933
+ }
934
+ /**
935
+ * Refresh — and when the two deadlines disagree, let the token that comes
936
+ * back say which one was lying.
937
+ *
938
+ * ⚠ THE TIMING IS THE WHOLE MECHANISM, and the obvious placement is wrong.
939
+ * Learning at every mint site looks equivalent and is not: in the reported
940
+ * failure the token was MINTED while the clock was fast and only read after
941
+ * the correction, so a mint-time probe records "clock is fast", and the flag
942
+ * then waves through the very token the guard exists to refuse. Probing here
943
+ * — only on a refresh we performed BECAUSE `exp` contradicted a tracked
944
+ * deadline that still claimed life — separates the two cases, because by now
945
+ * the clock has either been corrected (fresh token reads live → stale
946
+ * deadline, guard holds) or is genuinely fast (fresh token still reads dead
947
+ * → release the guard, one probe, no loop).
948
+ */
949
+ async refreshAndReconcile(nowMs) {
950
+ const reconciling = this.deadlinesDisagree(nowMs);
951
+ const token = await this.refreshAccessToken();
952
+ if (reconciling && token) this.learnClockSkewFrom(token, Date.now());
953
+ return token;
954
+ }
955
+ /**
956
+ * Record whether a token the server issued MOMENTS AGO already reads as
957
+ * expired. It cannot genuinely be — so a `false` here is proof the device
958
+ * clock runs fast, and releases the `exp` guard for this process.
959
+ *
960
+ * Call this at every site that installs a server-issued token, and only
961
+ * with a token that just arrived. Handing it a cached token would let a
962
+ * stale deadline confirm itself.
963
+ */
964
+ learnClockSkewFrom(freshToken, nowMs) {
965
+ if (!accessTokenExpIsFuture(freshToken, nowMs)) this.clockSkewConfirmed = true;
966
+ }
967
+ /** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
968
+ deadlinesDisagree(nowMs) {
969
+ return this.trackedDeadlineAlive(nowMs) && !this.canServeCachedToken(nowMs);
970
+ }
971
+ async getAccessToken(options) {
972
+ if (!options?.forceRefresh && this.canServeCachedToken(Date.now())) {
856
973
  return this.accessToken;
857
974
  }
858
975
  if (!this.hooks.storage.get(REFRESH_KEY)) return null;
859
976
  if (this.refreshInflight) return this.refreshInflight;
860
977
  if (Date.now() < this.refreshCooldownUntil) return null;
861
- return this.refreshAccessToken();
978
+ return this.refreshAndReconcile(Date.now());
862
979
  }
863
980
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
864
981
  getAccessTokenSync() {
@@ -1675,7 +1792,7 @@ var Auth = class {
1675
1792
  const freshExpiresStr = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
1676
1793
  if (freshAccess && freshExpiresStr) {
1677
1794
  const freshExpires = Number(freshExpiresStr);
1678
- if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
1795
+ if (Number.isFinite(freshExpires) && this.canServeToken(freshAccess, freshExpires, Date.now())) {
1679
1796
  this.accessToken = freshAccess;
1680
1797
  this.accessExpiresAt = freshExpires;
1681
1798
  this.refreshFailureCount = 0;
@@ -1742,6 +1859,10 @@ var Auth = class {
1742
1859
  const tick = async () => {
1743
1860
  if (!this.accessToken || !this.accessExpiresAt) return;
1744
1861
  const now = Date.now();
1862
+ if (!this.canServeCachedToken(now)) {
1863
+ await this.refreshAndReconcile(now);
1864
+ return;
1865
+ }
1745
1866
  const lifetimeMs = this.accessExpiresAt - now;
1746
1867
  if (lifetimeMs <= 0) return;
1747
1868
  const guessOriginalMs = Math.max(lifetimeMs, 5 * 6e4);
@@ -1889,6 +2010,7 @@ var Auth = class {
1889
2010
  retiredAnonUserId: data.retiredAnonUserId
1890
2011
  });
1891
2012
  }
2013
+ if (data.anonRetirement) this.lastAnonRetirement = data.anonRetirement;
1892
2014
  if (data.reactivatedFromDeletion) {
1893
2015
  this.fireDeletionCancelled(data.user.id);
1894
2016
  this.emitAuthState({ event: "deletion_cancelled", user: data.user });
@@ -1923,8 +2045,10 @@ var Auth = class {
1923
2045
  this.user = null;
1924
2046
  this.accessToken = null;
1925
2047
  this.accessExpiresAt = 0;
2048
+ this.clockSkewConfirmed = false;
1926
2049
  this.lastTakeover = null;
1927
2050
  this.lastDeletionCancelled = null;
2051
+ this.lastAnonRetirement = null;
1928
2052
  this.pendingAnonTakeover = null;
1929
2053
  this.hooks.storage.remove(USER_KEY);
1930
2054
  this.hooks.storage.remove(TOKEN_KEY);
@@ -1950,6 +2074,13 @@ function readRotatedTokens(data) {
1950
2074
  if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
1951
2075
  return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
1952
2076
  }
2077
+ function accessTokenExpIsFuture(token, nowMs) {
2078
+ const claims = decodeJwtPayload(token);
2079
+ if (!claims) return true;
2080
+ const exp = claims.exp;
2081
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return true;
2082
+ return nowMs < exp * 1e3;
2083
+ }
1953
2084
  function decodeJwtPayload(token) {
1954
2085
  try {
1955
2086
  const parts = token.split(".");
@@ -2639,6 +2770,16 @@ var AUTH_HYDRATE_KEYS = [
2639
2770
  ];
2640
2771
  var SECURE_KEYS = ["auth_refresh_token", "auth_access_token", "auth_user"];
2641
2772
  var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
2773
+ var STORAGE_PREFIX_RE = /^[A-Za-z0-9_.-]{1,64}$/;
2774
+ function normalizeStoragePrefix(prefix) {
2775
+ if (prefix === void 0) return void 0;
2776
+ if (typeof prefix !== "string" || !STORAGE_PREFIX_RE.test(prefix)) {
2777
+ throw new Error(
2778
+ `[sendora] init: storagePrefix must be 1-64 chars of A-Z a-z 0-9 _ . - (got ${JSON.stringify(prefix)}). Changing it signs the device out, so it is validated rather than ignored.`
2779
+ );
2780
+ }
2781
+ return prefix;
2782
+ }
2642
2783
  var MAX_PUSH_TOKEN_BYTES = 4096;
2643
2784
  var MAX_PUSH_METADATA_BYTES = 4096;
2644
2785
  var MAX_USER_ID_LEN = 256;
@@ -2812,8 +2953,13 @@ var SendoraSDK = class {
2812
2953
  `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
2813
2954
  );
2814
2955
  }
2815
- if (config.secureStorage) {
2816
- this.storage = new Storage({ secureAdapter: config.secureStorage, secureKeys: SECURE_KEYS });
2956
+ if (config.secureStorage || config.storagePrefix !== void 0) {
2957
+ const prefix = normalizeStoragePrefix(config.storagePrefix);
2958
+ this.storage = new Storage({
2959
+ secureAdapter: config.secureStorage,
2960
+ secureKeys: SECURE_KEYS,
2961
+ prefix
2962
+ });
2817
2963
  }
2818
2964
  await this.storage.hydrate([
2819
2965
  ANON_KEY,
package/dist/index.d.cts CHANGED
@@ -104,6 +104,29 @@ interface SendoraConfig {
104
104
  * threat model. It is opt-in: adding it never changes an app that doesn't pass it.
105
105
  */
106
106
  secureStorage?: SecureStorageAdapter;
107
+ /**
108
+ * Namespace every on-device key this SDK owns. Omit for the historical
109
+ * `sendora_` prefix — the default is byte-identical to prior releases.
110
+ *
111
+ * For apps that point one codebase at more than one Sendora project
112
+ * (staging vs production, a debug build beside a release build). Without
113
+ * it, both environments share `sendora_*` on the same device: whichever
114
+ * launched last owns the session, and a debug sign-in can evict the
115
+ * release build's. A custom `secureStorage` adapter only fixes the half
116
+ * that goes through the adapter — `auth_access_expires` and the anon id
117
+ * stay in AsyncStorage and still collide.
118
+ *
119
+ * ⚠ CHANGING THIS SIGNS THE DEVICE OUT. Keys are the session's only
120
+ * durable handle, and the SDK does not migrate between prefixes — a new
121
+ * prefix reads an empty store and mints a fresh anonymous user. Pick one
122
+ * per environment before shipping and never change it afterwards. This is
123
+ * the same frozen-key rule as ADR-023, delegated to you.
124
+ *
125
+ * ⚠ Applies to the `secureStorage` adapter too when set (adapter keys are
126
+ * bare by default). If you already namespace inside your own adapter,
127
+ * either drop that and use this, or expect `<yours><ours>key`.
128
+ */
129
+ storagePrefix?: string;
107
130
  }
108
131
  /**
109
132
  * Async key/value adapter for encrypted-at-rest auth storage. Compatible with a
@@ -178,13 +201,30 @@ type StorageOptions = {
178
201
  secureAdapter?: SecureStorageAdapter | null;
179
202
  /** Keys routed through `secureAdapter` when it is present. Everything else stays in AsyncStorage. */
180
203
  secureKeys?: Iterable<string>;
204
+ /**
205
+ * Namespace for every key this SDK owns. Omit for the default `sendora_`.
206
+ *
207
+ * ⚠ Asymmetric by necessity, and the asymmetry is what keeps upgrades
208
+ * safe. When omitted, AsyncStorage keys keep the historical `sendora_`
209
+ * prefix and secure-adapter keys stay BARE — exactly what shipped before
210
+ * this option existed, so no installed app loses its session on upgrade.
211
+ * When supplied, the prefix applies to BOTH stores, because the whole
212
+ * point is that two environments sharing one device share nothing.
213
+ */
214
+ prefix?: string;
181
215
  };
182
216
  declare class Storage {
183
217
  private cache;
184
218
  private hydrated;
185
219
  private readonly secure;
186
220
  private readonly secureKeys;
221
+ /** Applied to AsyncStorage keys. */
222
+ private readonly prefix;
223
+ /** Applied to secure-adapter keys — empty unless a custom prefix was set. */
224
+ private readonly securePrefix;
187
225
  constructor(opts?: StorageOptions);
226
+ /** The adapter-facing name for a key. Bare by default; see `prefix`. */
227
+ private secureName;
188
228
  private isSecure;
189
229
  /**
190
230
  * Populate the memory cache from persistent storage. Must be awaited
@@ -298,6 +338,8 @@ interface GameCenterPayload {
298
338
  teamPlayerId: string;
299
339
  bundleId: string;
300
340
  }
341
+ /** @see AuthApiResponse.anonRetirement */
342
+ type AnonRetirementOutcome = "retired" | "preserved" | "none";
301
343
  declare class EmailAlreadyTakenError extends Error {
302
344
  constructor(message?: string);
303
345
  }
@@ -515,6 +557,27 @@ declare class Auth {
515
557
  private user;
516
558
  private accessToken;
517
559
  private accessExpiresAt;
560
+ /**
561
+ * Set once we have PROOF that this device's clock runs fast, which
562
+ * releases the `exp` guard in `canServeCachedToken`.
563
+ *
564
+ * The proof is specific and cannot be faked by a stale deadline: a token
565
+ * the server minted *seconds ago* reads as already past its own `exp`.
566
+ * A freshly-issued token is never genuinely expired, so the only
567
+ * explanation is the clock we measured it with.
568
+ *
569
+ * ⚠ NEVER PERSIST THIS. It is re-learned for the cost of one refresh per
570
+ * process, and a persisted "my clock is fast" belief would outlive the
571
+ * clock correction that made it false — which is precisely the stale-frame
572
+ * bug this whole guard exists to close, moved one level up.
573
+ *
574
+ * ⚠ Without this escape the `exp` guard alone is a REFRESH LOOP on any
575
+ * device whose clock is fast by more than the token TTL (default 60 min):
576
+ * every freshly-minted token would read as already expired, so every
577
+ * `getAccessToken()` would refresh, forever. That is why the fix is not
578
+ * simply "require `exp` too".
579
+ */
580
+ private clockSkewConfirmed;
518
581
  private inflight;
519
582
  private refreshInflight;
520
583
  /**
@@ -534,6 +597,7 @@ declare class Auth {
534
597
  private deletionCancelledListeners;
535
598
  private authStateListeners;
536
599
  private lastDeletionCancelled;
600
+ private lastAnonRetirement;
537
601
  private lastTakeover;
538
602
  /**
539
603
  * Anon refresh token captured at `signIn()` time, stashed keyed to the
@@ -656,6 +720,17 @@ declare class Auth {
656
720
  onDeletionCancelled(listener: (evt: DeletionCancelledEvent) => void): () => void;
657
721
  /** The most recent deletion-cancelled event this session, or null. Cleared on signOut. */
658
722
  getLastDeletionCancelled(): DeletionCancelledEvent | null;
723
+ /**
724
+ * What the last sign-in did with this device's guest account, or null if no
725
+ * sign-in has happened this session (s58.278).
726
+ *
727
+ * The one worth acting on is `"preserved"`: a guest account was presented,
728
+ * was NOT retired, and is therefore still alive server-side — so "recover
729
+ * your other account" is a real offer rather than a guess. `"retired"` means
730
+ * migrate from `onDeviceTakeover`'s id; `"none"` means there was nothing to
731
+ * reconcile. Cleared on signOut.
732
+ */
733
+ getLastAnonRetirement(): AnonRetirementOutcome | null;
659
734
  /** Internal — fan out deletion-cancelled to subscribers + cache the latest. */
660
735
  private fireDeletionCancelled;
661
736
  /**
@@ -672,7 +747,11 @@ declare class Auth {
672
747
  *
673
748
  * Resolution order:
674
749
  * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
675
- * skew before exp) → return it. Fast path, no I/O.
750
+ * before BOTH the tracked deadline and the token's own `exp`) →
751
+ * return it. Fast path, no I/O. See `canServeCachedToken` for why
752
+ * both are required and when the second is released.
753
+ * Pass `{ forceRefresh: true }` to skip this step when the caller
754
+ * knows the tracked deadline is wrong.
676
755
  * 2. No cached token AND no refresh token in storage → genuinely
677
756
  * signed out → null.
678
757
  * 3. Expired/near-expiry but a refresh chain exists:
@@ -690,7 +769,62 @@ declare class Auth {
690
769
  * the cooldown for an immediate retry on resume.
691
770
  * c. Otherwise → fire the refresh and await it.
692
771
  */
693
- getAccessToken(): Promise<string | null>;
772
+ /**
773
+ * The cached-token guard, and the single place the two deadlines are
774
+ * combined. Both must agree before a cached token is served:
775
+ *
776
+ * 1. the locally-tracked deadline (`Date.now() + expiresIn` at mint) —
777
+ * immune to a constantly-wrong clock, blind to a clock that MOVES;
778
+ * 2. the token's own `exp` — immune to a clock that moves, wrong by the
779
+ * full offset on a constantly-wrong clock.
780
+ *
781
+ * Guard 2 is released once `clockSkewConfirmed` proves the second failure
782
+ * mode is the one we are actually in.
783
+ *
784
+ * The gap this closes: `accessExpiresAt` is written in the device's clock
785
+ * frame at mint and PERSISTED. Step the clock backwards between mint and
786
+ * read — an iPhone acquiring automatic time after a long power-off, a
787
+ * restore, or a manual set — and the deadline is stale by the size of the
788
+ * correction, surviving relaunches. The SDK then serves a provably dead
789
+ * token, every request 401s, and because the fast path returns before
790
+ * reaching `persist()` or `wipeLocalIdentity()` nothing emits
791
+ * `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
792
+ */
793
+ private canServeToken;
794
+ private canServeCachedToken;
795
+ /** Guard 1 alone: does the locally-tracked deadline still claim life? */
796
+ private trackedDeadlineAlive;
797
+ /**
798
+ * Refresh — and when the two deadlines disagree, let the token that comes
799
+ * back say which one was lying.
800
+ *
801
+ * ⚠ THE TIMING IS THE WHOLE MECHANISM, and the obvious placement is wrong.
802
+ * Learning at every mint site looks equivalent and is not: in the reported
803
+ * failure the token was MINTED while the clock was fast and only read after
804
+ * the correction, so a mint-time probe records "clock is fast", and the flag
805
+ * then waves through the very token the guard exists to refuse. Probing here
806
+ * — only on a refresh we performed BECAUSE `exp` contradicted a tracked
807
+ * deadline that still claimed life — separates the two cases, because by now
808
+ * the clock has either been corrected (fresh token reads live → stale
809
+ * deadline, guard holds) or is genuinely fast (fresh token still reads dead
810
+ * → release the guard, one probe, no loop).
811
+ */
812
+ private refreshAndReconcile;
813
+ /**
814
+ * Record whether a token the server issued MOMENTS AGO already reads as
815
+ * expired. It cannot genuinely be — so a `false` here is proof the device
816
+ * clock runs fast, and releases the `exp` guard for this process.
817
+ *
818
+ * Call this at every site that installs a server-issued token, and only
819
+ * with a token that just arrived. Handing it a cached token would let a
820
+ * stale deadline confirm itself.
821
+ */
822
+ private learnClockSkewFrom;
823
+ /** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
824
+ private deadlinesDisagree;
825
+ getAccessToken(options?: {
826
+ forceRefresh?: boolean;
827
+ }): Promise<string | null>;
694
828
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
695
829
  getAccessTokenSync(): string | null;
696
830
  /**
@@ -1904,4 +2038,4 @@ declare class SendoraSDK {
1904
2038
  }
1905
2039
  declare const SendoraCloud: SendoraSDK;
1906
2040
 
1907
- export { AlreadyIdentifiedError, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
2041
+ export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
package/dist/index.d.ts CHANGED
@@ -104,6 +104,29 @@ interface SendoraConfig {
104
104
  * threat model. It is opt-in: adding it never changes an app that doesn't pass it.
105
105
  */
106
106
  secureStorage?: SecureStorageAdapter;
107
+ /**
108
+ * Namespace every on-device key this SDK owns. Omit for the historical
109
+ * `sendora_` prefix — the default is byte-identical to prior releases.
110
+ *
111
+ * For apps that point one codebase at more than one Sendora project
112
+ * (staging vs production, a debug build beside a release build). Without
113
+ * it, both environments share `sendora_*` on the same device: whichever
114
+ * launched last owns the session, and a debug sign-in can evict the
115
+ * release build's. A custom `secureStorage` adapter only fixes the half
116
+ * that goes through the adapter — `auth_access_expires` and the anon id
117
+ * stay in AsyncStorage and still collide.
118
+ *
119
+ * ⚠ CHANGING THIS SIGNS THE DEVICE OUT. Keys are the session's only
120
+ * durable handle, and the SDK does not migrate between prefixes — a new
121
+ * prefix reads an empty store and mints a fresh anonymous user. Pick one
122
+ * per environment before shipping and never change it afterwards. This is
123
+ * the same frozen-key rule as ADR-023, delegated to you.
124
+ *
125
+ * ⚠ Applies to the `secureStorage` adapter too when set (adapter keys are
126
+ * bare by default). If you already namespace inside your own adapter,
127
+ * either drop that and use this, or expect `<yours><ours>key`.
128
+ */
129
+ storagePrefix?: string;
107
130
  }
108
131
  /**
109
132
  * Async key/value adapter for encrypted-at-rest auth storage. Compatible with a
@@ -178,13 +201,30 @@ type StorageOptions = {
178
201
  secureAdapter?: SecureStorageAdapter | null;
179
202
  /** Keys routed through `secureAdapter` when it is present. Everything else stays in AsyncStorage. */
180
203
  secureKeys?: Iterable<string>;
204
+ /**
205
+ * Namespace for every key this SDK owns. Omit for the default `sendora_`.
206
+ *
207
+ * ⚠ Asymmetric by necessity, and the asymmetry is what keeps upgrades
208
+ * safe. When omitted, AsyncStorage keys keep the historical `sendora_`
209
+ * prefix and secure-adapter keys stay BARE — exactly what shipped before
210
+ * this option existed, so no installed app loses its session on upgrade.
211
+ * When supplied, the prefix applies to BOTH stores, because the whole
212
+ * point is that two environments sharing one device share nothing.
213
+ */
214
+ prefix?: string;
181
215
  };
182
216
  declare class Storage {
183
217
  private cache;
184
218
  private hydrated;
185
219
  private readonly secure;
186
220
  private readonly secureKeys;
221
+ /** Applied to AsyncStorage keys. */
222
+ private readonly prefix;
223
+ /** Applied to secure-adapter keys — empty unless a custom prefix was set. */
224
+ private readonly securePrefix;
187
225
  constructor(opts?: StorageOptions);
226
+ /** The adapter-facing name for a key. Bare by default; see `prefix`. */
227
+ private secureName;
188
228
  private isSecure;
189
229
  /**
190
230
  * Populate the memory cache from persistent storage. Must be awaited
@@ -298,6 +338,8 @@ interface GameCenterPayload {
298
338
  teamPlayerId: string;
299
339
  bundleId: string;
300
340
  }
341
+ /** @see AuthApiResponse.anonRetirement */
342
+ type AnonRetirementOutcome = "retired" | "preserved" | "none";
301
343
  declare class EmailAlreadyTakenError extends Error {
302
344
  constructor(message?: string);
303
345
  }
@@ -515,6 +557,27 @@ declare class Auth {
515
557
  private user;
516
558
  private accessToken;
517
559
  private accessExpiresAt;
560
+ /**
561
+ * Set once we have PROOF that this device's clock runs fast, which
562
+ * releases the `exp` guard in `canServeCachedToken`.
563
+ *
564
+ * The proof is specific and cannot be faked by a stale deadline: a token
565
+ * the server minted *seconds ago* reads as already past its own `exp`.
566
+ * A freshly-issued token is never genuinely expired, so the only
567
+ * explanation is the clock we measured it with.
568
+ *
569
+ * ⚠ NEVER PERSIST THIS. It is re-learned for the cost of one refresh per
570
+ * process, and a persisted "my clock is fast" belief would outlive the
571
+ * clock correction that made it false — which is precisely the stale-frame
572
+ * bug this whole guard exists to close, moved one level up.
573
+ *
574
+ * ⚠ Without this escape the `exp` guard alone is a REFRESH LOOP on any
575
+ * device whose clock is fast by more than the token TTL (default 60 min):
576
+ * every freshly-minted token would read as already expired, so every
577
+ * `getAccessToken()` would refresh, forever. That is why the fix is not
578
+ * simply "require `exp` too".
579
+ */
580
+ private clockSkewConfirmed;
518
581
  private inflight;
519
582
  private refreshInflight;
520
583
  /**
@@ -534,6 +597,7 @@ declare class Auth {
534
597
  private deletionCancelledListeners;
535
598
  private authStateListeners;
536
599
  private lastDeletionCancelled;
600
+ private lastAnonRetirement;
537
601
  private lastTakeover;
538
602
  /**
539
603
  * Anon refresh token captured at `signIn()` time, stashed keyed to the
@@ -656,6 +720,17 @@ declare class Auth {
656
720
  onDeletionCancelled(listener: (evt: DeletionCancelledEvent) => void): () => void;
657
721
  /** The most recent deletion-cancelled event this session, or null. Cleared on signOut. */
658
722
  getLastDeletionCancelled(): DeletionCancelledEvent | null;
723
+ /**
724
+ * What the last sign-in did with this device's guest account, or null if no
725
+ * sign-in has happened this session (s58.278).
726
+ *
727
+ * The one worth acting on is `"preserved"`: a guest account was presented,
728
+ * was NOT retired, and is therefore still alive server-side — so "recover
729
+ * your other account" is a real offer rather than a guess. `"retired"` means
730
+ * migrate from `onDeviceTakeover`'s id; `"none"` means there was nothing to
731
+ * reconcile. Cleared on signOut.
732
+ */
733
+ getLastAnonRetirement(): AnonRetirementOutcome | null;
659
734
  /** Internal — fan out deletion-cancelled to subscribers + cache the latest. */
660
735
  private fireDeletionCancelled;
661
736
  /**
@@ -672,7 +747,11 @@ declare class Auth {
672
747
  *
673
748
  * Resolution order:
674
749
  * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
675
- * skew before exp) → return it. Fast path, no I/O.
750
+ * before BOTH the tracked deadline and the token's own `exp`) →
751
+ * return it. Fast path, no I/O. See `canServeCachedToken` for why
752
+ * both are required and when the second is released.
753
+ * Pass `{ forceRefresh: true }` to skip this step when the caller
754
+ * knows the tracked deadline is wrong.
676
755
  * 2. No cached token AND no refresh token in storage → genuinely
677
756
  * signed out → null.
678
757
  * 3. Expired/near-expiry but a refresh chain exists:
@@ -690,7 +769,62 @@ declare class Auth {
690
769
  * the cooldown for an immediate retry on resume.
691
770
  * c. Otherwise → fire the refresh and await it.
692
771
  */
693
- getAccessToken(): Promise<string | null>;
772
+ /**
773
+ * The cached-token guard, and the single place the two deadlines are
774
+ * combined. Both must agree before a cached token is served:
775
+ *
776
+ * 1. the locally-tracked deadline (`Date.now() + expiresIn` at mint) —
777
+ * immune to a constantly-wrong clock, blind to a clock that MOVES;
778
+ * 2. the token's own `exp` — immune to a clock that moves, wrong by the
779
+ * full offset on a constantly-wrong clock.
780
+ *
781
+ * Guard 2 is released once `clockSkewConfirmed` proves the second failure
782
+ * mode is the one we are actually in.
783
+ *
784
+ * The gap this closes: `accessExpiresAt` is written in the device's clock
785
+ * frame at mint and PERSISTED. Step the clock backwards between mint and
786
+ * read — an iPhone acquiring automatic time after a long power-off, a
787
+ * restore, or a manual set — and the deadline is stale by the size of the
788
+ * correction, surviving relaunches. The SDK then serves a provably dead
789
+ * token, every request 401s, and because the fast path returns before
790
+ * reaching `persist()` or `wipeLocalIdentity()` nothing emits
791
+ * `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
792
+ */
793
+ private canServeToken;
794
+ private canServeCachedToken;
795
+ /** Guard 1 alone: does the locally-tracked deadline still claim life? */
796
+ private trackedDeadlineAlive;
797
+ /**
798
+ * Refresh — and when the two deadlines disagree, let the token that comes
799
+ * back say which one was lying.
800
+ *
801
+ * ⚠ THE TIMING IS THE WHOLE MECHANISM, and the obvious placement is wrong.
802
+ * Learning at every mint site looks equivalent and is not: in the reported
803
+ * failure the token was MINTED while the clock was fast and only read after
804
+ * the correction, so a mint-time probe records "clock is fast", and the flag
805
+ * then waves through the very token the guard exists to refuse. Probing here
806
+ * — only on a refresh we performed BECAUSE `exp` contradicted a tracked
807
+ * deadline that still claimed life — separates the two cases, because by now
808
+ * the clock has either been corrected (fresh token reads live → stale
809
+ * deadline, guard holds) or is genuinely fast (fresh token still reads dead
810
+ * → release the guard, one probe, no loop).
811
+ */
812
+ private refreshAndReconcile;
813
+ /**
814
+ * Record whether a token the server issued MOMENTS AGO already reads as
815
+ * expired. It cannot genuinely be — so a `false` here is proof the device
816
+ * clock runs fast, and releases the `exp` guard for this process.
817
+ *
818
+ * Call this at every site that installs a server-issued token, and only
819
+ * with a token that just arrived. Handing it a cached token would let a
820
+ * stale deadline confirm itself.
821
+ */
822
+ private learnClockSkewFrom;
823
+ /** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
824
+ private deadlinesDisagree;
825
+ getAccessToken(options?: {
826
+ forceRefresh?: boolean;
827
+ }): Promise<string | null>;
694
828
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
695
829
  getAccessTokenSync(): string | null;
696
830
  /**
@@ -1904,4 +2038,4 @@ declare class SendoraSDK {
1904
2038
  }
1905
2039
  declare const SendoraCloud: SendoraSDK;
1906
2040
 
1907
- export { AlreadyIdentifiedError, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
2041
+ export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import "react-native-get-random-values";
3
3
  import { Platform } from "react-native";
4
4
 
5
5
  // src/storage.ts
6
- var PREFIX = "sendora_";
6
+ var DEFAULT_PREFIX = "sendora_";
7
7
  var asyncStoragePromise = null;
8
8
  async function getAsyncStorage() {
9
9
  if (asyncStoragePromise) return asyncStoragePromise;
@@ -26,6 +26,12 @@ var Storage = class {
26
26
  this.hydrated = false;
27
27
  this.secure = opts.secureAdapter ?? null;
28
28
  this.secureKeys = new Set(opts.secureKeys ?? []);
29
+ this.prefix = opts.prefix ?? DEFAULT_PREFIX;
30
+ this.securePrefix = opts.prefix ?? "";
31
+ }
32
+ /** The adapter-facing name for a key. Bare by default; see `prefix`. */
33
+ secureName(key) {
34
+ return this.securePrefix + key;
29
35
  }
30
36
  isSecure(key) {
31
37
  return this.secure !== null && this.secureKeys.has(key);
@@ -46,7 +52,7 @@ var Storage = class {
46
52
  await this.hydrateSecureKey(k, store);
47
53
  } else if (store) {
48
54
  try {
49
- const v = await store.getItem(PREFIX + k);
55
+ const v = await store.getItem(this.prefix + k);
50
56
  if (v !== null) this.cache.set(k, v);
51
57
  } catch {
52
58
  }
@@ -57,18 +63,18 @@ var Storage = class {
57
63
  /** Read a secure key: adapter first, else migrate legacy plaintext. Never throws. */
58
64
  async hydrateSecureKey(k, store) {
59
65
  try {
60
- const secureVal = await this.secure.getItem(k);
66
+ const secureVal = await this.secure.getItem(this.secureName(k));
61
67
  if (secureVal !== null) {
62
68
  this.cache.set(k, secureVal);
63
69
  return;
64
70
  }
65
71
  if (store) {
66
- const legacy = await store.getItem(PREFIX + k).catch(() => null);
72
+ const legacy = await store.getItem(this.prefix + k).catch(() => null);
67
73
  if (legacy !== null) {
68
74
  this.cache.set(k, legacy);
69
75
  try {
70
- await this.secure.setItem(k, legacy);
71
- await store.removeItem(PREFIX + k).catch(() => {
76
+ await this.secure.setItem(this.secureName(k), legacy);
77
+ await store.removeItem(this.prefix + k).catch(() => {
72
78
  });
73
79
  } catch {
74
80
  }
@@ -77,7 +83,7 @@ var Storage = class {
77
83
  } catch {
78
84
  if (store) {
79
85
  try {
80
- const legacy = await store.getItem(PREFIX + k);
86
+ const legacy = await store.getItem(this.prefix + k);
81
87
  if (legacy !== null) this.cache.set(k, legacy);
82
88
  } catch {
83
89
  }
@@ -90,27 +96,27 @@ var Storage = class {
90
96
  set(key, value) {
91
97
  this.cache.set(key, value);
92
98
  if (this.isSecure(key)) {
93
- void this.secure.setItem(key, value).catch(() => {
99
+ void this.secure.setItem(this.secureName(key), value).catch(() => {
94
100
  void getAsyncStorage().then((store) => {
95
- if (store) store.setItem(PREFIX + key, value).catch(() => {
101
+ if (store) store.setItem(this.prefix + key, value).catch(() => {
96
102
  });
97
103
  });
98
104
  });
99
105
  return;
100
106
  }
101
107
  void getAsyncStorage().then((store) => {
102
- if (store) store.setItem(PREFIX + key, value).catch(() => {
108
+ if (store) store.setItem(this.prefix + key, value).catch(() => {
103
109
  });
104
110
  });
105
111
  }
106
112
  remove(key) {
107
113
  this.cache.delete(key);
108
114
  if (this.isSecure(key)) {
109
- void this.secure.removeItem(key).catch(() => {
115
+ void this.secure.removeItem(this.secureName(key)).catch(() => {
110
116
  });
111
117
  }
112
118
  void getAsyncStorage().then((store) => {
113
- if (store) store.removeItem(PREFIX + key).catch(() => {
119
+ if (store) store.removeItem(this.prefix + key).catch(() => {
114
120
  });
115
121
  });
116
122
  }
@@ -126,7 +132,7 @@ var Storage = class {
126
132
  if (this.secure) {
127
133
  for (const k of this.secureKeys) {
128
134
  try {
129
- await this.secure.removeItem(k);
135
+ await this.secure.removeItem(this.secureName(k));
130
136
  } catch {
131
137
  }
132
138
  }
@@ -137,13 +143,13 @@ var Storage = class {
137
143
  if (typeof store.getAllKeys === "function") {
138
144
  try {
139
145
  const all = await store.getAllKeys();
140
- keys = all.filter((k) => k.startsWith(PREFIX));
146
+ keys = all.filter((k) => k.startsWith(this.prefix));
141
147
  } catch {
142
148
  keys = [];
143
149
  }
144
150
  }
145
151
  if (keys.length === 0) {
146
- keys = Array.from(this.secureKeys).map((k) => PREFIX + k);
152
+ keys = Array.from(this.secureKeys).map((k) => this.prefix + k);
147
153
  }
148
154
  for (const k of keys) {
149
155
  try {
@@ -157,7 +163,7 @@ var Storage = class {
157
163
  // package.json
158
164
  var package_default = {
159
165
  name: "@sendoracloud/sdk-react-native",
160
- version: "1.32.0",
166
+ version: "1.34.0",
161
167
  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
168
  type: "module",
163
169
  main: "./dist/index.cjs",
@@ -213,7 +219,7 @@ var package_default = {
213
219
  scripts: {
214
220
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
215
221
  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 && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts"
222
+ test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts"
217
223
  },
218
224
  devDependencies: {
219
225
  "@types/react": "^19.2.16",
@@ -550,6 +556,27 @@ var Auth = class {
550
556
  this.user = null;
551
557
  this.accessToken = null;
552
558
  this.accessExpiresAt = 0;
559
+ /**
560
+ * Set once we have PROOF that this device's clock runs fast, which
561
+ * releases the `exp` guard in `canServeCachedToken`.
562
+ *
563
+ * The proof is specific and cannot be faked by a stale deadline: a token
564
+ * the server minted *seconds ago* reads as already past its own `exp`.
565
+ * A freshly-issued token is never genuinely expired, so the only
566
+ * explanation is the clock we measured it with.
567
+ *
568
+ * ⚠ NEVER PERSIST THIS. It is re-learned for the cost of one refresh per
569
+ * process, and a persisted "my clock is fast" belief would outlive the
570
+ * clock correction that made it false — which is precisely the stale-frame
571
+ * bug this whole guard exists to close, moved one level up.
572
+ *
573
+ * ⚠ Without this escape the `exp` guard alone is a REFRESH LOOP on any
574
+ * device whose clock is fast by more than the token TTL (default 60 min):
575
+ * every freshly-minted token would read as already expired, so every
576
+ * `getAccessToken()` would refresh, forever. That is why the fix is not
577
+ * simply "require `exp` too".
578
+ */
579
+ this.clockSkewConfirmed = false;
553
580
  this.inflight = Promise.resolve();
554
581
  this.refreshInflight = null;
555
582
  /**
@@ -569,6 +596,7 @@ var Auth = class {
569
596
  this.deletionCancelledListeners = /* @__PURE__ */ new Set();
570
597
  this.authStateListeners = /* @__PURE__ */ new Set();
571
598
  this.lastDeletionCancelled = null;
599
+ this.lastAnonRetirement = null;
572
600
  this.lastTakeover = null;
573
601
  /**
574
602
  * Anon refresh token captured at `signIn()` time, stashed keyed to the
@@ -640,6 +668,7 @@ var Auth = class {
640
668
  }
641
669
  }
642
670
  this.hydrated = true;
671
+ if (this.user && this.hooks.storage.get(REFRESH_KEY)) this.startProactiveRefreshCron();
643
672
  this.resolveReady();
644
673
  }
645
674
  getCurrentUser() {
@@ -762,6 +791,19 @@ var Auth = class {
762
791
  getLastDeletionCancelled() {
763
792
  return this.lastDeletionCancelled;
764
793
  }
794
+ /**
795
+ * What the last sign-in did with this device's guest account, or null if no
796
+ * sign-in has happened this session (s58.278).
797
+ *
798
+ * The one worth acting on is `"preserved"`: a guest account was presented,
799
+ * was NOT retired, and is therefore still alive server-side — so "recover
800
+ * your other account" is a real offer rather than a guess. `"retired"` means
801
+ * migrate from `onDeviceTakeover`'s id; `"none"` means there was nothing to
802
+ * reconcile. Cleared on signOut.
803
+ */
804
+ getLastAnonRetirement() {
805
+ return this.lastAnonRetirement;
806
+ }
765
807
  /** Internal — fan out deletion-cancelled to subscribers + cache the latest. */
766
808
  fireDeletionCancelled(userId) {
767
809
  const evt = { userId, at: /* @__PURE__ */ new Date() };
@@ -788,7 +830,11 @@ var Auth = class {
788
830
  *
789
831
  * Resolution order:
790
832
  * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
791
- * skew before exp) → return it. Fast path, no I/O.
833
+ * before BOTH the tracked deadline and the token's own `exp`) →
834
+ * return it. Fast path, no I/O. See `canServeCachedToken` for why
835
+ * both are required and when the second is released.
836
+ * Pass `{ forceRefresh: true }` to skip this step when the caller
837
+ * knows the tracked deadline is wrong.
792
838
  * 2. No cached token AND no refresh token in storage → genuinely
793
839
  * signed out → null.
794
840
  * 3. Expired/near-expiry but a refresh chain exists:
@@ -806,14 +852,85 @@ var Auth = class {
806
852
  * the cooldown for an immediate retry on resume.
807
853
  * c. Otherwise → fire the refresh and await it.
808
854
  */
809
- async getAccessToken() {
810
- if (this.accessToken && this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
855
+ /**
856
+ * The cached-token guard, and the single place the two deadlines are
857
+ * combined. Both must agree before a cached token is served:
858
+ *
859
+ * 1. the locally-tracked deadline (`Date.now() + expiresIn` at mint) —
860
+ * immune to a constantly-wrong clock, blind to a clock that MOVES;
861
+ * 2. the token's own `exp` — immune to a clock that moves, wrong by the
862
+ * full offset on a constantly-wrong clock.
863
+ *
864
+ * Guard 2 is released once `clockSkewConfirmed` proves the second failure
865
+ * mode is the one we are actually in.
866
+ *
867
+ * The gap this closes: `accessExpiresAt` is written in the device's clock
868
+ * frame at mint and PERSISTED. Step the clock backwards between mint and
869
+ * read — an iPhone acquiring automatic time after a long power-off, a
870
+ * restore, or a manual set — and the deadline is stale by the size of the
871
+ * correction, surviving relaunches. The SDK then serves a provably dead
872
+ * token, every request 401s, and because the fast path returns before
873
+ * reaching `persist()` or `wipeLocalIdentity()` nothing emits
874
+ * `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
875
+ */
876
+ canServeToken(token, expiresAt, nowMs) {
877
+ if (!token || !expiresAt) return false;
878
+ if (nowMs >= expiresAt - REFRESH_SAFETY_MS) return false;
879
+ if (this.clockSkewConfirmed) return true;
880
+ return accessTokenExpIsFuture(token, nowMs);
881
+ }
882
+ canServeCachedToken(nowMs) {
883
+ return this.canServeToken(this.accessToken, this.accessExpiresAt, nowMs);
884
+ }
885
+ /** Guard 1 alone: does the locally-tracked deadline still claim life? */
886
+ trackedDeadlineAlive(nowMs) {
887
+ return !!this.accessToken && !!this.accessExpiresAt && nowMs < this.accessExpiresAt - REFRESH_SAFETY_MS;
888
+ }
889
+ /**
890
+ * Refresh — and when the two deadlines disagree, let the token that comes
891
+ * back say which one was lying.
892
+ *
893
+ * ⚠ THE TIMING IS THE WHOLE MECHANISM, and the obvious placement is wrong.
894
+ * Learning at every mint site looks equivalent and is not: in the reported
895
+ * failure the token was MINTED while the clock was fast and only read after
896
+ * the correction, so a mint-time probe records "clock is fast", and the flag
897
+ * then waves through the very token the guard exists to refuse. Probing here
898
+ * — only on a refresh we performed BECAUSE `exp` contradicted a tracked
899
+ * deadline that still claimed life — separates the two cases, because by now
900
+ * the clock has either been corrected (fresh token reads live → stale
901
+ * deadline, guard holds) or is genuinely fast (fresh token still reads dead
902
+ * → release the guard, one probe, no loop).
903
+ */
904
+ async refreshAndReconcile(nowMs) {
905
+ const reconciling = this.deadlinesDisagree(nowMs);
906
+ const token = await this.refreshAccessToken();
907
+ if (reconciling && token) this.learnClockSkewFrom(token, Date.now());
908
+ return token;
909
+ }
910
+ /**
911
+ * Record whether a token the server issued MOMENTS AGO already reads as
912
+ * expired. It cannot genuinely be — so a `false` here is proof the device
913
+ * clock runs fast, and releases the `exp` guard for this process.
914
+ *
915
+ * Call this at every site that installs a server-issued token, and only
916
+ * with a token that just arrived. Handing it a cached token would let a
917
+ * stale deadline confirm itself.
918
+ */
919
+ learnClockSkewFrom(freshToken, nowMs) {
920
+ if (!accessTokenExpIsFuture(freshToken, nowMs)) this.clockSkewConfirmed = true;
921
+ }
922
+ /** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
923
+ deadlinesDisagree(nowMs) {
924
+ return this.trackedDeadlineAlive(nowMs) && !this.canServeCachedToken(nowMs);
925
+ }
926
+ async getAccessToken(options) {
927
+ if (!options?.forceRefresh && this.canServeCachedToken(Date.now())) {
811
928
  return this.accessToken;
812
929
  }
813
930
  if (!this.hooks.storage.get(REFRESH_KEY)) return null;
814
931
  if (this.refreshInflight) return this.refreshInflight;
815
932
  if (Date.now() < this.refreshCooldownUntil) return null;
816
- return this.refreshAccessToken();
933
+ return this.refreshAndReconcile(Date.now());
817
934
  }
818
935
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
819
936
  getAccessTokenSync() {
@@ -1630,7 +1747,7 @@ var Auth = class {
1630
1747
  const freshExpiresStr = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
1631
1748
  if (freshAccess && freshExpiresStr) {
1632
1749
  const freshExpires = Number(freshExpiresStr);
1633
- if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
1750
+ if (Number.isFinite(freshExpires) && this.canServeToken(freshAccess, freshExpires, Date.now())) {
1634
1751
  this.accessToken = freshAccess;
1635
1752
  this.accessExpiresAt = freshExpires;
1636
1753
  this.refreshFailureCount = 0;
@@ -1697,6 +1814,10 @@ var Auth = class {
1697
1814
  const tick = async () => {
1698
1815
  if (!this.accessToken || !this.accessExpiresAt) return;
1699
1816
  const now = Date.now();
1817
+ if (!this.canServeCachedToken(now)) {
1818
+ await this.refreshAndReconcile(now);
1819
+ return;
1820
+ }
1700
1821
  const lifetimeMs = this.accessExpiresAt - now;
1701
1822
  if (lifetimeMs <= 0) return;
1702
1823
  const guessOriginalMs = Math.max(lifetimeMs, 5 * 6e4);
@@ -1844,6 +1965,7 @@ var Auth = class {
1844
1965
  retiredAnonUserId: data.retiredAnonUserId
1845
1966
  });
1846
1967
  }
1968
+ if (data.anonRetirement) this.lastAnonRetirement = data.anonRetirement;
1847
1969
  if (data.reactivatedFromDeletion) {
1848
1970
  this.fireDeletionCancelled(data.user.id);
1849
1971
  this.emitAuthState({ event: "deletion_cancelled", user: data.user });
@@ -1878,8 +2000,10 @@ var Auth = class {
1878
2000
  this.user = null;
1879
2001
  this.accessToken = null;
1880
2002
  this.accessExpiresAt = 0;
2003
+ this.clockSkewConfirmed = false;
1881
2004
  this.lastTakeover = null;
1882
2005
  this.lastDeletionCancelled = null;
2006
+ this.lastAnonRetirement = null;
1883
2007
  this.pendingAnonTakeover = null;
1884
2008
  this.hooks.storage.remove(USER_KEY);
1885
2009
  this.hooks.storage.remove(TOKEN_KEY);
@@ -1905,6 +2029,13 @@ function readRotatedTokens(data) {
1905
2029
  if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
1906
2030
  return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
1907
2031
  }
2032
+ function accessTokenExpIsFuture(token, nowMs) {
2033
+ const claims = decodeJwtPayload(token);
2034
+ if (!claims) return true;
2035
+ const exp = claims.exp;
2036
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return true;
2037
+ return nowMs < exp * 1e3;
2038
+ }
1908
2039
  function decodeJwtPayload(token) {
1909
2040
  try {
1910
2041
  const parts = token.split(".");
@@ -2599,6 +2730,16 @@ var AUTH_HYDRATE_KEYS = [
2599
2730
  ];
2600
2731
  var SECURE_KEYS = ["auth_refresh_token", "auth_access_token", "auth_user"];
2601
2732
  var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
2733
+ var STORAGE_PREFIX_RE = /^[A-Za-z0-9_.-]{1,64}$/;
2734
+ function normalizeStoragePrefix(prefix) {
2735
+ if (prefix === void 0) return void 0;
2736
+ if (typeof prefix !== "string" || !STORAGE_PREFIX_RE.test(prefix)) {
2737
+ throw new Error(
2738
+ `[sendora] init: storagePrefix must be 1-64 chars of A-Z a-z 0-9 _ . - (got ${JSON.stringify(prefix)}). Changing it signs the device out, so it is validated rather than ignored.`
2739
+ );
2740
+ }
2741
+ return prefix;
2742
+ }
2602
2743
  var MAX_PUSH_TOKEN_BYTES = 4096;
2603
2744
  var MAX_PUSH_METADATA_BYTES = 4096;
2604
2745
  var MAX_USER_ID_LEN = 256;
@@ -2772,8 +2913,13 @@ var SendoraSDK = class {
2772
2913
  `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
2773
2914
  );
2774
2915
  }
2775
- if (config.secureStorage) {
2776
- this.storage = new Storage({ secureAdapter: config.secureStorage, secureKeys: SECURE_KEYS });
2916
+ if (config.secureStorage || config.storagePrefix !== void 0) {
2917
+ const prefix = normalizeStoragePrefix(config.storagePrefix);
2918
+ this.storage = new Storage({
2919
+ secureAdapter: config.secureStorage,
2920
+ secureKeys: SECURE_KEYS,
2921
+ prefix
2922
+ });
2777
2923
  }
2778
2924
  await this.storage.hydrate([
2779
2925
  ANON_KEY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — 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",
@@ -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 && tsx test/session-preservation.test.ts && tsx test/credential-collision.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 && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/react": "^19.2.16",