@sendoracloud/sdk-react-native 0.12.0 → 0.14.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
@@ -235,21 +235,41 @@ var Auth = class {
235
235
  this.resolveReady = res;
236
236
  });
237
237
  }
238
- /** Re-hydrate session from storage. Called by the parent SDK
239
- * during init() after Storage.hydrate() has run. Marks the
240
- * internal ready promise as resolved so queued auth calls
241
- * unblock. */
238
+ /**
239
+ * Re-hydrate session from storage. Called by the parent SDK during
240
+ * init() after Storage.hydrate() has run. Marks the internal ready
241
+ * promise as resolved so queued auth calls unblock.
242
+ *
243
+ * s58.47 — restore the user even when the cached ACCESS token is
244
+ * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
245
+ * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
246
+ * caused a cold-start regression: if the access token rotated out
247
+ * of storage (TTL elapsed, app killed between persists, partial
248
+ * write, AsyncStorage eviction) the user appeared signed out even
249
+ * though their refresh chain was still alive in storage. Hosts
250
+ * then called `signInAnonymously()` on every launch and minted a
251
+ * fresh anon user, fragmenting analytics across sessions
252
+ * (Pulse News symptom). With the refresh token present, the next
253
+ * `getAccessToken()` call will silently refresh + restore a valid
254
+ * access token without disturbing identity.
255
+ */
242
256
  hydrate() {
243
257
  const cachedUser = this.hooks.storage.get(USER_KEY);
244
258
  const cachedToken = this.hooks.storage.get(TOKEN_KEY);
259
+ const cachedRefresh = this.hooks.storage.get(REFRESH_KEY);
245
260
  const cachedExpires = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
246
- if (cachedUser && cachedToken) {
261
+ if (cachedUser && (cachedToken || cachedRefresh)) {
247
262
  try {
248
263
  const parsed = JSON.parse(cachedUser);
249
264
  if (typeof parsed.id !== "string" || parsed.id.length === 0) throw new Error("invalid cache");
250
265
  this.user = parsed;
251
- this.accessToken = cachedToken;
252
- this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
266
+ if (cachedToken) {
267
+ this.accessToken = cachedToken;
268
+ this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
269
+ } else {
270
+ this.accessToken = null;
271
+ this.accessExpiresAt = 0;
272
+ }
253
273
  this.hooks.onIdentityChange(this.user.id);
254
274
  } catch {
255
275
  this.hooks.storage.remove(USER_KEY);
@@ -719,6 +739,20 @@ var Auth = class {
719
739
  this.inflight = next.catch(() => void 0);
720
740
  return next;
721
741
  }
742
+ /**
743
+ * Hot-path token refresh. Single-flight via `refreshInflight` so a
744
+ * UI batch firing 10 simultaneous reads never produces 10 /refresh
745
+ * round-trips. Returns null on any failure so callers can decide
746
+ * whether to surface a sign-in prompt or proceed anonymously.
747
+ *
748
+ * s58.46 — on a `INVALID_REFRESH_TOKEN` (new backend error code) or
749
+ * HTTP 401 we WIPE the stored refresh token. Pre-s58.46 we caught
750
+ * the error and returned null but kept the stored token intact, so
751
+ * the very next caller would re-attempt the same dead token in an
752
+ * infinite loop (Pulse News iOS hammered /refresh ~1×/s for hours).
753
+ * The wipe forces the next op to fall through to /anonymous or
754
+ * surface NOT_SIGNED_IN to the host app instead of looping.
755
+ */
722
756
  async refreshAccessToken() {
723
757
  if (this.refreshInflight) return this.refreshInflight;
724
758
  const refresh = this.hooks.storage.get(REFRESH_KEY);
@@ -727,14 +761,18 @@ var Auth = class {
727
761
  try {
728
762
  const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
729
763
  refreshToken: refresh
730
- }).catch(() => null);
731
- if (!data) return null;
764
+ });
732
765
  this.accessToken = data.tokens.accessToken;
733
766
  this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
734
767
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
735
768
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
736
769
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
737
770
  return data.tokens.accessToken;
771
+ } catch (err) {
772
+ if (err instanceof AuthError && isDeadRefreshError(err.code)) {
773
+ await this.wipeLocalIdentity().catch(() => void 0);
774
+ }
775
+ return null;
738
776
  } finally {
739
777
  this.refreshInflight = null;
740
778
  }
@@ -785,6 +823,10 @@ var Auth = class {
785
823
  await this.hooks.onAnonymousWipe();
786
824
  }
787
825
  };
826
+ function isDeadRefreshError(code) {
827
+ if (!code) return false;
828
+ return code === "INVALID_REFRESH_TOKEN" || code === "HTTP_401" || code === "UNAUTHORIZED" || code === "RATE_LIMIT_EXCEEDED" || code === "RATE_LIMIT";
829
+ }
788
830
  function decodeJwtPayload(token) {
789
831
  try {
790
832
  const parts = token.split(".");
package/dist/index.d.cts CHANGED
@@ -122,10 +122,24 @@ declare class Auth {
122
122
  private readyPromise;
123
123
  private resolveReady;
124
124
  constructor(hooks: AuthHooks);
125
- /** Re-hydrate session from storage. Called by the parent SDK
126
- * during init() after Storage.hydrate() has run. Marks the
127
- * internal ready promise as resolved so queued auth calls
128
- * unblock. */
125
+ /**
126
+ * Re-hydrate session from storage. Called by the parent SDK during
127
+ * init() after Storage.hydrate() has run. Marks the internal ready
128
+ * promise as resolved so queued auth calls unblock.
129
+ *
130
+ * s58.47 — restore the user even when the cached ACCESS token is
131
+ * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
132
+ * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
133
+ * caused a cold-start regression: if the access token rotated out
134
+ * of storage (TTL elapsed, app killed between persists, partial
135
+ * write, AsyncStorage eviction) the user appeared signed out even
136
+ * though their refresh chain was still alive in storage. Hosts
137
+ * then called `signInAnonymously()` on every launch and minted a
138
+ * fresh anon user, fragmenting analytics across sessions
139
+ * (Pulse News symptom). With the refresh token present, the next
140
+ * `getAccessToken()` call will silently refresh + restore a valid
141
+ * access token without disturbing identity.
142
+ */
129
143
  hydrate(): void;
130
144
  getCurrentUser(): AuthUser | null;
131
145
  /**
@@ -270,6 +284,20 @@ declare class Auth {
270
284
  */
271
285
  signOut(): Promise<void>;
272
286
  private serialize;
287
+ /**
288
+ * Hot-path token refresh. Single-flight via `refreshInflight` so a
289
+ * UI batch firing 10 simultaneous reads never produces 10 /refresh
290
+ * round-trips. Returns null on any failure so callers can decide
291
+ * whether to surface a sign-in prompt or proceed anonymously.
292
+ *
293
+ * s58.46 — on a `INVALID_REFRESH_TOKEN` (new backend error code) or
294
+ * HTTP 401 we WIPE the stored refresh token. Pre-s58.46 we caught
295
+ * the error and returned null but kept the stored token intact, so
296
+ * the very next caller would re-attempt the same dead token in an
297
+ * infinite loop (Pulse News iOS hammered /refresh ~1×/s for hours).
298
+ * The wipe forces the next op to fall through to /anonymous or
299
+ * surface NOT_SIGNED_IN to the host app instead of looping.
300
+ */
273
301
  private refreshAccessToken;
274
302
  private callAuth;
275
303
  private persist;
package/dist/index.d.ts CHANGED
@@ -122,10 +122,24 @@ declare class Auth {
122
122
  private readyPromise;
123
123
  private resolveReady;
124
124
  constructor(hooks: AuthHooks);
125
- /** Re-hydrate session from storage. Called by the parent SDK
126
- * during init() after Storage.hydrate() has run. Marks the
127
- * internal ready promise as resolved so queued auth calls
128
- * unblock. */
125
+ /**
126
+ * Re-hydrate session from storage. Called by the parent SDK during
127
+ * init() after Storage.hydrate() has run. Marks the internal ready
128
+ * promise as resolved so queued auth calls unblock.
129
+ *
130
+ * s58.47 — restore the user even when the cached ACCESS token is
131
+ * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
132
+ * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
133
+ * caused a cold-start regression: if the access token rotated out
134
+ * of storage (TTL elapsed, app killed between persists, partial
135
+ * write, AsyncStorage eviction) the user appeared signed out even
136
+ * though their refresh chain was still alive in storage. Hosts
137
+ * then called `signInAnonymously()` on every launch and minted a
138
+ * fresh anon user, fragmenting analytics across sessions
139
+ * (Pulse News symptom). With the refresh token present, the next
140
+ * `getAccessToken()` call will silently refresh + restore a valid
141
+ * access token without disturbing identity.
142
+ */
129
143
  hydrate(): void;
130
144
  getCurrentUser(): AuthUser | null;
131
145
  /**
@@ -270,6 +284,20 @@ declare class Auth {
270
284
  */
271
285
  signOut(): Promise<void>;
272
286
  private serialize;
287
+ /**
288
+ * Hot-path token refresh. Single-flight via `refreshInflight` so a
289
+ * UI batch firing 10 simultaneous reads never produces 10 /refresh
290
+ * round-trips. Returns null on any failure so callers can decide
291
+ * whether to surface a sign-in prompt or proceed anonymously.
292
+ *
293
+ * s58.46 — on a `INVALID_REFRESH_TOKEN` (new backend error code) or
294
+ * HTTP 401 we WIPE the stored refresh token. Pre-s58.46 we caught
295
+ * the error and returned null but kept the stored token intact, so
296
+ * the very next caller would re-attempt the same dead token in an
297
+ * infinite loop (Pulse News iOS hammered /refresh ~1×/s for hours).
298
+ * The wipe forces the next op to fall through to /anonymous or
299
+ * surface NOT_SIGNED_IN to the host app instead of looping.
300
+ */
273
301
  private refreshAccessToken;
274
302
  private callAuth;
275
303
  private persist;
package/dist/index.js CHANGED
@@ -195,21 +195,41 @@ var Auth = class {
195
195
  this.resolveReady = res;
196
196
  });
197
197
  }
198
- /** Re-hydrate session from storage. Called by the parent SDK
199
- * during init() after Storage.hydrate() has run. Marks the
200
- * internal ready promise as resolved so queued auth calls
201
- * unblock. */
198
+ /**
199
+ * Re-hydrate session from storage. Called by the parent SDK during
200
+ * init() after Storage.hydrate() has run. Marks the internal ready
201
+ * promise as resolved so queued auth calls unblock.
202
+ *
203
+ * s58.47 — restore the user even when the cached ACCESS token is
204
+ * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
205
+ * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
206
+ * caused a cold-start regression: if the access token rotated out
207
+ * of storage (TTL elapsed, app killed between persists, partial
208
+ * write, AsyncStorage eviction) the user appeared signed out even
209
+ * though their refresh chain was still alive in storage. Hosts
210
+ * then called `signInAnonymously()` on every launch and minted a
211
+ * fresh anon user, fragmenting analytics across sessions
212
+ * (Pulse News symptom). With the refresh token present, the next
213
+ * `getAccessToken()` call will silently refresh + restore a valid
214
+ * access token without disturbing identity.
215
+ */
202
216
  hydrate() {
203
217
  const cachedUser = this.hooks.storage.get(USER_KEY);
204
218
  const cachedToken = this.hooks.storage.get(TOKEN_KEY);
219
+ const cachedRefresh = this.hooks.storage.get(REFRESH_KEY);
205
220
  const cachedExpires = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
206
- if (cachedUser && cachedToken) {
221
+ if (cachedUser && (cachedToken || cachedRefresh)) {
207
222
  try {
208
223
  const parsed = JSON.parse(cachedUser);
209
224
  if (typeof parsed.id !== "string" || parsed.id.length === 0) throw new Error("invalid cache");
210
225
  this.user = parsed;
211
- this.accessToken = cachedToken;
212
- this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
226
+ if (cachedToken) {
227
+ this.accessToken = cachedToken;
228
+ this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
229
+ } else {
230
+ this.accessToken = null;
231
+ this.accessExpiresAt = 0;
232
+ }
213
233
  this.hooks.onIdentityChange(this.user.id);
214
234
  } catch {
215
235
  this.hooks.storage.remove(USER_KEY);
@@ -679,6 +699,20 @@ var Auth = class {
679
699
  this.inflight = next.catch(() => void 0);
680
700
  return next;
681
701
  }
702
+ /**
703
+ * Hot-path token refresh. Single-flight via `refreshInflight` so a
704
+ * UI batch firing 10 simultaneous reads never produces 10 /refresh
705
+ * round-trips. Returns null on any failure so callers can decide
706
+ * whether to surface a sign-in prompt or proceed anonymously.
707
+ *
708
+ * s58.46 — on a `INVALID_REFRESH_TOKEN` (new backend error code) or
709
+ * HTTP 401 we WIPE the stored refresh token. Pre-s58.46 we caught
710
+ * the error and returned null but kept the stored token intact, so
711
+ * the very next caller would re-attempt the same dead token in an
712
+ * infinite loop (Pulse News iOS hammered /refresh ~1×/s for hours).
713
+ * The wipe forces the next op to fall through to /anonymous or
714
+ * surface NOT_SIGNED_IN to the host app instead of looping.
715
+ */
682
716
  async refreshAccessToken() {
683
717
  if (this.refreshInflight) return this.refreshInflight;
684
718
  const refresh = this.hooks.storage.get(REFRESH_KEY);
@@ -687,14 +721,18 @@ var Auth = class {
687
721
  try {
688
722
  const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
689
723
  refreshToken: refresh
690
- }).catch(() => null);
691
- if (!data) return null;
724
+ });
692
725
  this.accessToken = data.tokens.accessToken;
693
726
  this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
694
727
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
695
728
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
696
729
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
697
730
  return data.tokens.accessToken;
731
+ } catch (err) {
732
+ if (err instanceof AuthError && isDeadRefreshError(err.code)) {
733
+ await this.wipeLocalIdentity().catch(() => void 0);
734
+ }
735
+ return null;
698
736
  } finally {
699
737
  this.refreshInflight = null;
700
738
  }
@@ -745,6 +783,10 @@ var Auth = class {
745
783
  await this.hooks.onAnonymousWipe();
746
784
  }
747
785
  };
786
+ function isDeadRefreshError(code) {
787
+ if (!code) return false;
788
+ return code === "INVALID_REFRESH_TOKEN" || code === "HTTP_401" || code === "UNAUTHORIZED" || code === "RATE_LIMIT_EXCEEDED" || code === "RATE_LIMIT";
789
+ }
748
790
  function decodeJwtPayload(token) {
749
791
  try {
750
792
  const parts = token.split(".");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth. Expo Go compatible, no native modules beyond AsyncStorage.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",