@sendoracloud/sdk-react-native 1.39.1 → 1.41.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/CHANGELOG.md ADDED
@@ -0,0 +1,747 @@
1
+ # Changelog
2
+
3
+ ## 1.41.0 — sign-in no longer retires the guest: it is held on the device (ADR-033 wave 1)
4
+
5
+ **Two semantic changes, both strictly less destructive than before; everything else is additive
6
+ (MINOR under ADR-023 — no signature, storage key, event or status code removed).** Parity: web 3.24.0 /
7
+ iOS 5.4.0 / Android 4.26.0. Law: `docs/decisions/033-sdk-auth-mechanism.md` ("One Slot, Two Verbs").
8
+
9
+ 1. **Sign-in no longer retires the guest — it is held on the device.** A sign-in from a guest session
10
+ (`signIn`, `loginSocial`, `signInWithGameCenter`/`PlayGames`, `challengeMfa`, `verifyMagicLink`,
11
+ `verifyEmailOtp`, `consumeSsoFragment`, `signInWith`) never sends `prevAnonRefreshToken`,
12
+ `linkAnonymous` or `onCredentialInUse` any more, so the server can never delete, adopt or remap the
13
+ guest as a side effect of a sign-in. Instead the guest's live tokens go into one of five fixed secure
14
+ slots on the device and the account is installed: `guest_held{userId,count,user}` then ONE
15
+ `signed_in{cause:'sign_in'}`, no `signed_out`. The guest's server row survives; `getHeldGuests()`
16
+ lists it; `onDeviceTakeover` / `getLastAnonRetirement()` keep working but no longer fire for this
17
+ SDK's own sign-ins (`anonRetirement` is `"none"`).
18
+ 2. **`link:true` over an identified session attaches or errors — it never switches.**
19
+ `signInWith*({link:true})` / `loginSocial({link:true})` from an identified session go to
20
+ `/me/link/*`: an identity already on the account resolves quietly (no event, no revoke, no step-up),
21
+ one owned elsewhere throws `CredentialInUseError` (or switches when `onCredentialInUse:"adopt"`,
22
+ holding a guest if there was one), a stale session throws `RECENT_AUTH_REQUIRED`. From a guest it is
23
+ the same-`sub` promotion it always was (and still emits the legacy `signed_in{cause:'link'}`,
24
+ §8-Q18); in `signed_out` it is a plain sign-in. `signUp()` from a guest now calls `/me/link/email`
25
+ (never `/upgrade`); over an identified session it throws `AlreadyIdentifiedError` locally.
26
+
27
+ **New surface (all additive):**
28
+ - `auth.status(): "unknown" | "signed_out" | "anonymous" | "identified" | "withheld"` and
29
+ `auth.authStateReady()` (resolves after hydrate, never waits on the network). ⚠ **`status()` is the only
30
+ way to tell `withheld` (and `unknown`) from `signed_out`** — `getUserSync()` is `null` and `isReady()` is
31
+ `true` in all three. ⚠ **Consumers MUST default-branch on `AuthStatus`**: a later value ships opt-in-gated
32
+ or in a MAJOR.
33
+ - Events on `onAuthStateChanged`: `initial{status,user,withheld?}` (replayed once per subscriber, in every
34
+ status). With a live session (`anonymous` / `identified`) it is FOLLOWED by the same `signed_in{user}`
35
+ replay 1.28–1.40 sent (no `cause`) — kept, because removing an event is not a MINOR change (ADR-023);
36
+ `signed_out` / `unknown` / `withheld` get `initial` only,
37
+ `user_updated{user,cause}`, `guest_held`, `guest_released{userId,reason,endReason?}`,
38
+ `session_withheld`; `signed_in` gains `cause: 'mint'|'sign_in'|'restore'|'link'`;
39
+ `signed_out.reason` gains `revoked | evicted | deleted` (the server's own `endReason`).
40
+ - Held guests: `getHeldGuests()`, `getHeldGuest()`, `claimHeldGuest(id?)` (merge INTO the current
41
+ account — OPTIONAL and destructive; never call it from a backend that does not act on
42
+ `auth.user_merged`), `restoreHeldGuest(id?)`, `discardHeldGuest(id?)` (a real server delete). `id` may be
43
+ omitted only when exactly one guest is held (`AMBIGUOUS_HELD_GUEST` otherwise). Errors
44
+ `HeldGuestLimitError` (the device holds 5 guests), `HeldGuestProjectMismatch`.
45
+ - `init({ autoRestoreHeldGuest })` (default `true`): `ensureSession()` / `signInAnonymously()` in
46
+ `signed_out` restore the newest eligible held guest instead of minting — visibly
47
+ (`signed_in{cause:'restore'}` + `guest_released{restored}`); a dead slot is released and the next tried,
48
+ then it mints in the same call. `signInAnonymously({forceNew:true})` from a guest holds it; at the cap it
49
+ throws `HeldGuestLimitError` and mutates nothing.
50
+ - `init({ expectedProjectId })` (opt-in project guard): a stored/refreshed/restored session whose JWT
51
+ `project` claim disagrees is `withheld` — zero storage writes, every verb rejects
52
+ `AuthError{kind:'config', code:'SESSION_WITHHELD'}`, `getAccessToken*()`/`getRefreshToken()` return
53
+ `null`; exits are `discardWithheld()` (local only; keeps held guests, the queue and `user_id`) or a
54
+ relaunch with the matching key. A fresh mint/sign-in that disagrees rejects
55
+ `AuthError{kind:'config', code:'PROJECT_MISMATCH'}` and revokes the minted token.
56
+ `getWithheld()`, `Sendora.onIdentityWithheld(cb)`. Absent claim = unknown (never match); explicit
57
+ `null` = mismatch.
58
+ - Typed verbs: `auth.link(credential): LinkResult` (`done{upgraded,alreadyLinked}` |
59
+ `credential_in_use{provider,collision,switchAccount(),keepCurrent()}` | `recent_auth_required`) and
60
+ `auth.signInWith(credential): SignInResult` (`done{previous:{userId,isAnonymous,held},reactivated}` |
61
+ `mfa_required{verify(code)}`). `switchAccount()` is bound to the refused credential (same payload under
62
+ 5 min; a Game Center credential is re-fetched after that and a changed player aborts
63
+ `CREDENTIAL_CHANGED`).
64
+ - `deleteAccount({ alsoDiscardHeldGuests })` → `{ ..., heldGuests, discardFailed }`. By default held guests
65
+ SURVIVE on device and server with auto-restore suppressed (explicit `restoreHeldGuest(id)` only).
66
+ - `Sendora.reset({ includeHeldGuests })`: `reset()` now KEEPS held guests (a guest's refresh token is its
67
+ only credential); the option releases them locally (`guest_released{reason:'reset'}`, no server delete).
68
+ The clear is an exact key list of the active namespace, no longer a `startsWith(prefix)` sweep.
69
+ - `merge(token)` (deprecated) routes a token that matches a held slot to `claimHeldGuest()`; otherwise the
70
+ raw `/merge` as before. `getRefreshToken()` kept (null in `withheld`).
71
+ - `POST /anonymous` carries a per-attempt `mintKey` (a retried mint after a crash returns the same guest).
72
+ - Bearer verbs (`enrollMfa`, `disableMfa`, `listMySessions`, `revokeSession`, `revokeAllSessions`,
73
+ `sendVerificationEmail`) now refresh an expired access token first (parity with web).
74
+
75
+ **On disk (additive, ADR-023):** the four frozen keys keep their format and are written on every install
76
+ (a 1.40.0 downgrade reads a valid session; held slots are simply ignored). New keys: `auth_session`
77
+ (single-record form, read first on hydrate, written forward from the frozen keys on the first 1.41 launch —
78
+ no network, same status), `auth_held_0..auth_held_4` (literal names, in `SECURE_KEYS`, so they route through
79
+ your `secureStorage` adapter), `auth_mint_key` (non-secret, removed on a successful mint).
80
+ `sendora_schema_version` → `"2"` (never bumped in a withheld hydrate).
81
+ The four frozen keys stay the authority on whether a session exists: if a downgraded 1.40.x signs out
82
+ (it clears only the four keys), the next 1.41 launch discards the leftover `auth_session` instead of
83
+ reinstalling it.
84
+
85
+ Guards: `test/auth-machine-conformance.test.ts` replays the shared ADR-033 table (36 rows + 19 project-verdict
86
+ cases from `packages/shared`); `test/held-guests.test.ts` (ADR §5 tests 1–13, the refresh-during-native-sheet
87
+ interleaving, dual-write/downgrade, the B15 held report, `merge()` routing). Mutation record in `CLAUDE.md`.
88
+
89
+ ## 1.40.0 — signInAnonymously() no longer replaces a signed-in account; CHANGELOG ships in the package
90
+
91
+ **Behaviour change (reported by RopoLabs, verified against the shipped bundle).**
92
+ `signInAnonymously()` reused an existing *anonymous* session but fell straight through when
93
+ the current session was an identified account: it minted a guest and overwrote the
94
+ account's tokens locally, with no `signed_out` on `onAuthStateChanged` (the path never
95
+ wipes — it persists over the top). An app calling it defensively on a "looks signed out"
96
+ reading silently converted a paying account into a fresh guest. It now returns the current
97
+ session whatever its kind; `forceNew: true` is the only way to replace an identified
98
+ session, and it does so silently. Read `user.isAnonymous` to know which you got. The
99
+ account itself was never at risk server-side (recoverable by its credential) — the device
100
+ had just become a stranger to it. Same fix in web 3.23.0 / iOS 5.3.0 / Android 4.25.0.
101
+
102
+ - **`onAuthStateChanged` docs now say what it is not:** a transition stream, not a health
103
+ signal. A transient refresh failure (offline, timeout, 429, 5xx) emits nothing because
104
+ nothing changed; only a server-rejected refresh token produces
105
+ `signed_out(session_expired)`. Use `getAccessToken()` for liveness.
106
+ - **`CHANGELOG.md` is in the published tarball** — the README told you to read it before
107
+ upgrading and the file was not there. The `repository` field pointed at a repository
108
+ that does not exist and was removed (this SDK is developed in a private monorepo).
109
+ - `EventQueue.flush()`'s doc comment caught up with 1.39.1's head-of-line rotation.
110
+ - **Server-side, no SDK change:** `auth.deleteAccount()` now also erases the account's
111
+ events (including guest events merged onto it), profile, entities, push-token
112
+ registrations and SMS/email sends. It previously erased credentials and the account row
113
+ only. If your privacy page says Sendora erases a deleted user's analytics, it is true
114
+ from this backend release.
115
+
116
+ Guard: `test/anonymous-over-account.test.ts` (drives the real `Auth`: identified +
117
+ refresh → no mint / no wipe / token untouched; `forceNew` mints; anonymous reuse holds;
118
+ cached user without a refresh token still mints).
119
+
120
+ ## 1.39.1 — the event queue no longer outlives account deletion
121
+
122
+ **Follow-up to 1.39.0 (reported by RopoLabs/Word Hurdle).** The retrying queue added in
123
+ 1.39.0 kept events across `auth.deleteAccount()` — so anything queued on a slow connection
124
+ just before a confirmed deletion was delivered *after* it, still carrying the deleted
125
+ account's `userId` and anonymous id. For an app that honors App Store 5.1.1(v) / GDPR
126
+ erasure, that resurrected part of the account.
127
+
128
+ - The queue is now cleared when an account is deleted. A plain `signOut()` still keeps its
129
+ queued events (they legitimately belong to that signed-in session — only deletion clears).
130
+ - Also: a single event the server keeps rejecting no longer holds up everything behind it —
131
+ a repeatedly-failing event rotates to the back of the queue.
132
+
133
+ No API or config change. Other SDKs (web/iOS/Android) already cleared their queue on
134
+ deletion and were unaffected.
135
+
136
+ ## 1.39.0 — analytics events no longer leak an unhandled rejection; persisted retrying queue
137
+
138
+ **Bug fix (reported by RopoLabs/Word Hurdle, with a repro against the shipped bundle).** A slow
139
+ `/api/v1/events` request used to surface in your crash reporter as an unhandled promise rejection
140
+ (`Request timed out: POST /api/v1/events (30000ms)`) and the event was dropped. `sendEvent()` awaited
141
+ a transport that throws on its 30 s timeout, and every caller — including auto-track — discarded the
142
+ rejecting promise. Since auto-track fires `session.start`/`app.opened`/lifecycle events with no call
143
+ from your app, any app with users on slow networks saw the noise, and the dropped events biased
144
+ session/engagement metrics toward users on fast networks.
145
+
146
+ - Event delivery now runs through a **persisted, retrying queue**: a failed or slow send is retried
147
+ with backoff (up to 60 s), survives app restarts, and flushes when the app returns to the
148
+ foreground — so a slow network **delays** events instead of losing them. An event is dropped only
149
+ once the server accepts it, or permanently refuses it (a 4xx a retry can't fix).
150
+ - A failed event can **no longer surface as an unhandled rejection.** `sendEvent()` is synchronous and
151
+ never throws.
152
+ - No API change, no wire change, no configuration needed — upgrade and it's fixed. (A new internal
153
+ storage key `sendora_event_queue` holds unsent events; additive, ignored by older SDK versions.)
154
+
155
+ Other SDKs (web/iOS/Android) already had this queue design and were unaffected.
156
+
157
+ ## 1.36.0 — failure reporting, insecure-storage warnings, and the step-up error kind
158
+
159
+ ⚠⚠ **Three methods reported failure as success, and `request()` never throws on a non-2xx** — it
160
+ warns and returns the `Response`. So the discarded return value was the only place a refusal could
161
+ surface. `disableMfa()` is **step-up gated**, so the app said MFA was off while TOTP stayed armed
162
+ server-side; `revokeSession(id)` / `revokeAllSessions()` back a "sign out this device" button that
163
+ reported success on a device that was never signed out. All three now throw.
164
+
165
+ ⚠ **`unwrap` was dropping the HTTP status**, so every failure routed through it classified as kind
166
+ `unknown` — a 429 and a 503 both reporting as not-worth-retrying. It now carries `status` and
167
+ `retryAfterSeconds`. This also changes `enrollMfa` and `confirmMfa`, whose errors move from `unknown`
168
+ to a status-derived kind. That is the intended taxonomy and matches web/iOS/Android.
169
+
170
+ **New error kind: `recent_auth_required`** — the credential is valid; the authentication behind it is
171
+ too old to authorise a credential change (403 `RECENT_AUTH_REQUIRED`). Not a request for new input:
172
+ re-authenticate and retry the same call. It previously fell through to `invalid_credential`,
173
+ documented as "needs NEW input".
174
+
175
+ **Insecure storage is no longer silent.** Without an encrypted-storage module the SDK falls back to
176
+ `AsyncStorage`, which is plaintext on disk. That fallback used to happen quietly; it now warns, so a
177
+ production app cannot ship tokens in the clear without anyone having been told.
178
+
179
+ ⚠ React Native implements no passkey management, so the gated `DELETE /passkeys/:id` route has no
180
+ call site here.
181
+
182
+ ## 1.35.0 — unlink(provider): remove a linked sign-in method
183
+
184
+ Parity: RN 1.35.0 / web 3.19.0 / iOS 4.20.0 / Android 4.20.0. Additive.
185
+
186
+ The write half of `listLinkedIdentities()`. An app could already render
187
+ "Connected: Google · Game Center"; ``auth.unlink(provider)`` is what the Disconnect
188
+ button beside each entry calls.
189
+
190
+ **⚠ The server refuses to remove the last way into the account** — code
191
+ `LAST_CREDENTIAL`, kind ``"last_credential"``. An account with no credentials still
192
+ exists and still holds the user's data, but nothing can ever authenticate into
193
+ it again: no password to reset, no identity to present. That is permanent
194
+ lockout, not an inconvenience, and it is unrecoverable without manual database
195
+ surgery. A password counts as a credential, so an account with email+password
196
+ plus one social identity may drop the social one.
197
+
198
+ Supabase enforces the same floor (`unlinkIdentity` requires >= 2 identities);
199
+ Firebase's `unlink()` does not and will strip the last provider. We follow
200
+ Supabase, deliberately.
201
+
202
+ **Prefer preventing the tap to explaining the refusal.** Read
203
+ `listLinkedIdentities()` and disable Disconnect when
204
+ `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the backstop, not the UX.
205
+
206
+ An unlinked provider returns `NOT_FOUND` rather than reporting success — a
207
+ screen saying "disconnected" about a credential that is still attached tells
208
+ the user something untrue about their account.
209
+
210
+
211
+ <!-- 1.7.0–1.19.0 reconciled from release history (per-version notes in CLAUDE.md + docs/STATUS.md); the changelog had drifted at 1.6.2. All additive; no frozen storage key / header / wire-shape changed (ADR-023). -->
212
+
213
+ ## 1.34.0 — getAccessToken() no longer returns a token past its own `exp`
214
+
215
+ Parity: web 3.18.0 / iOS 4.19.0 / Android 4.19.0. Customer-reported (Word
216
+ Hurdle), with a repro that runs against the shipped bundle.
217
+
218
+ **Fixed (HIGH — silent, up to a full token TTL).** `getAccessToken()`
219
+ documents a hard contract: it never returns a token past its `exp`. It enforced
220
+ a different value — a deadline computed as `Date.now() + expiresIn` at mint,
221
+ persisted, and restored verbatim. That deadline is deliberately
222
+ **skew-invariant** (a permanently wrong clock cancels on both sides, because
223
+ the deadline is written in the same frame it is later read in) but blind to a
224
+ clock that **moves**. Step the clock backwards between mint and read — an
225
+ iPhone acquiring automatic time after a long power-off, a restore, or a manual
226
+ set — and the tracked deadline is stale by exactly the size of the correction,
227
+ across relaunches. Observed on a physical device with a token **1929 seconds
228
+ past its `exp`**: every request 401'd while the app still rendered as signed
229
+ in, and nothing emitted `signed_out`, because the fast path returns before
230
+ reaching `persist()` or `wipeLocalIdentity()`, the only emitters.
231
+
232
+ The token's own `exp` is now required as well. **Requiring it alone would have
233
+ been worse than the bug** — a device whose clock is fast by more than the token
234
+ TTL reads every freshly-minted token as already expired, so every call
235
+ refreshes, forever (measured at 10 reads → 10 refreshes). A one-shot probe
236
+ bounds it: when a refresh performed *because* the two deadlines disagreed comes
237
+ back with a token that STILL reads expired, the clock is proven fast rather
238
+ than the deadline stale, and the `exp` guard is released for that process.
239
+ Never persisted — a stored "my clock is fast" belief would outlive the
240
+ correction that made it false.
241
+
242
+ Also fixed, both surfaced by the same report:
243
+
244
+ - The rotate short-circuit inside `refreshAccessToken()` returned a stored
245
+ token on a bare `> Date.now()` — **no safety margin and no `exp` check** — on
246
+ the one path meant to repair an expired token.
247
+ - The proactive-refresh cron reasoned only from the tracked deadline, so during
248
+ this window it never fired; and it had **no call site outside `persist()`**,
249
+ meaning a cold launch ran with no cron at all. It is now armed from
250
+ `hydrate()` too, and repairs rather than only anticipating.
251
+
252
+ **Added — `getAccessToken({ forceRefresh: true })`.** The supported way to say
253
+ "your tracked deadline is wrong". It skips the cache but not the single-flight
254
+ or the backoff cooldown, so force-refreshing on every 401 cannot turn a server
255
+ outage into a hot loop. Replaces reaching into the private `accessExpiresAt`
256
+ field, which is what the workaround for this bug required.
257
+
258
+ **Added — `Sendora.init({ storagePrefix })`.** Namespaces every on-device key
259
+ the SDK owns, for one codebase pointed at more than one project (staging beside
260
+ production, debug beside release). Omitted, behaviour is byte-identical to
261
+ prior releases. ⚠ Changing it signs the device out — nothing migrates between
262
+ prefixes. Applies to the `secureStorage` adapter too when set. `secureStorage`
263
+ alone does not solve this: `auth_access_expires` and the anon id stay in
264
+ AsyncStorage and still collide.
265
+
266
+ Tests: `test/token-expiry.test.ts` drives a controllable clock through correct
267
+ / constantly-fast / stepped-back, plus the opaque-token and `forceRefresh`
268
+ paths. Both controls encode what must NOT change. Mutation-proven 3/3 —
269
+ dropping the `exp` guard and moving the probe to mint time each resurrect the
270
+ bug; dropping the skew escape produces the 10-for-10 refresh loop.
271
+
272
+ ## 1.33.0 — getLastAnonRetirement(): did my guest account survive?
273
+
274
+ Parity: web 3.17.0 / iOS 4.18.0 / Android 4.18.0. Additive.
275
+
276
+ `retiredAnonUserId` is present-or-absent, and the absence covered two
277
+ situations needing opposite handling: the guest account was retired but not
278
+ named, versus nothing was retired and the guest is still alive and claimable.
279
+ Sign-in responses now carry `anonRetirement`, and `getLastAnonRetirement()`
280
+ returns the last value (cleared on sign-out): `retired` (row deleted, id
281
+ arrives via `onDeviceTakeover`), `preserved` (a guest token WAS sent and the
282
+ guest was NOT retired — that account still exists, so "recover your other
283
+ account" is a real offer), `none` (no guest token sent).
284
+
285
+ ⚠ Recorded ONLY when the server states it. An older backend omits the field
286
+ entirely, and treating that as `none` would assert a fact we were never told.
287
+
288
+ ## 1.32.0 — the credential-collision default is now safe by construction
289
+
290
+ Parity: web 3.16.0 / iOS 4.17.0 / Android 4.17.0. **Behaviour change to the
291
+ DEFAULT.**
292
+
293
+ `onCredentialInUse` shipped in 1.30.0 defaulting to `adopt` for compatibility.
294
+ That was the wrong call: the destructive path was what you got by *not* knowing
295
+ the option existed, and it succeeded silently. The default is now conditional
296
+ on whether adopting would destroy anything — a fresh install with no guest
297
+ session adopts (the reinstall-recovery path, never refused); a live guest
298
+ session is refused with `CREDENTIAL_IN_USE` so the app can ask the player.
299
+
300
+ ⚠ **If you rely on silent adopt** — you consume the device-takeover signal to
301
+ migrate progress on a returning player — pass `onCredentialInUse: "adopt"`
302
+ explicitly. The adopt path itself is unchanged and still fires the takeover
303
+ webhook.
304
+
305
+ ## 1.31.0 — onDeviceTakeover doc fix: the account-deleting paths were missing
306
+
307
+ Parity: web 3.15.0 / iOS 4.16.0 / Android 4.16.0. Documentation-only; no
308
+ behaviour change.
309
+
310
+ The doc comment on `onDeviceTakeover` enumerated where the listener fires and
311
+ **omitted the Game Center and Play Games paths — the only ones that DELETE an
312
+ account.** It does fire there, so this was purely a doc defect, but an
313
+ integrator reading that list would reasonably skip the one handler those paths
314
+ most need. A CI guard now asserts each SDK's doc block names its gaming path.
315
+
316
+ ## 1.30.0 — credential-collision policy + anonymous link promotion
317
+
318
+ Parity: web 3.14.0 / iOS 4.15.0 / Android 4.15.0.
319
+
320
+ - **`onCredentialInUse: "adopt" | "reject"`** on `signInWithGameCenter` /
321
+ `signInWithPlayGames` / `loginSocial`. Omitted = `adopt` = every prior
322
+ release. On a collision the sign-in ADOPTS the owning account and hard-deletes
323
+ this device's anonymous row — a 200, so neither the 1.28.0 wipe-ordering fix
324
+ nor "a failed sign-in costs nothing" ever covered it. `"reject"` throws
325
+ `CredentialInUseError` and leaves the session byte-identical. Note `link:
326
+ true` does NOT help: the collision branch ignores it.
327
+ - **`CredentialInUseError`** gained `.kind` / `.retryable` / `.code` /
328
+ `.status` plus `.provider` and `.collision`. Deliberately still not an
329
+ `AuthError` subclass — widening `instanceof` would silently re-route an
330
+ upgrading app.
331
+ - **Linking from an ANONYMOUS session now works and rotates the session.**
332
+ `linkGameCenter` / `linkPlayGames` / `linkSocial` were gated to identified
333
+ accounts; the backend now promotes an anonymous caller in place (sub
334
+ preserved) and returns rotated tokens, which the SDK installs — not optional,
335
+ since the old refresh token is revoked server-side.
336
+
337
+ ## 1.29.0 — token refresh actually works again + the remaining loss routes
338
+
339
+ Parity: web 3.13.0 / iOS 4.14.0 / Android 4.14.0.
340
+
341
+ - **⚠ `/token/refresh` response shape (CRITICAL, pre-existing).** The rotated
342
+ trio lives under `data.tokens`; **web, iOS and Android all read `data.*`** —
343
+ the flat shape the backend stopped sending in s58.76. Reading the wrong level
344
+ fails silently: refresh returns null forever, the session dies at
345
+ access-token expiry, and the app mints a fresh guest. A shared
346
+ `readRotatedTokens()` now accepts either level.
347
+ - **A generic 401 no longer wipes the session.** `isDeadRefreshError` accepted
348
+ `UNAUTHORIZED`/`HTTP_401` as a catch-all — which is exactly what the API-key
349
+ middleware returns for a rotated publishable key, so **rotating a `pk_` key
350
+ would have wiped every install's anonymous account at once.** Narrowed to
351
+ `INVALID_REFRESH_TOKEN`.
352
+ - **A corrupt user blob is now recoverable** — the refresh adopts the `user`
353
+ the backend returns, but only when there is none.
354
+ - **The refresh race, both directions** — a response landing after `signOut()`
355
+ no longer resurrects the session, and a stale rejection no longer wipes a
356
+ newer one. Both gate on `tokenStillCurrent(sent)`.
357
+ - **`Sendora.reset()` went through `auth.signOut()`** instead of clearing auth
358
+ keys behind `Auth`'s back.
359
+ - **`signIn` now carries `status` + `retryAfterSeconds`** — it is the only
360
+ route that produces `ACCOUNT_LOCKED`, so the cool-off countdown was
361
+ indistinguishable from a permanent lock.
362
+
363
+ ## 1.28.0 — a failed sign-in no longer destroys the account
364
+
365
+ **Fixed (HIGH — silent, permanent data loss).** `loginSocial` (and every
366
+ `signInWith*` wrapper), `signInWithGameCenter`, `signInWithPlayGames`,
367
+ `verifyMagicLink`, `verifyEmailOtp` and `consumeSsoFragment` cleared the local
368
+ session — *including the refresh token* — before their network call, and
369
+ restored nothing on failure. For an anonymous user that token is the only
370
+ durable handle on the account, so a failed attempt orphaned the account
371
+ permanently; with no connectivity it happened every time. `consumeSsoFragment`
372
+ wiped before even parsing the callback, so cancelling at the identity provider
373
+ also cost the account. All six now clear the old session only after the
374
+ response validates. **A failed auth attempt now leaves `getUserSync()` and the
375
+ stored refresh token exactly as they were.**
376
+
377
+ **Fixed.** A `429` from the background token refresh was treated as a
378
+ permanently dead refresh token and wiped the session; rate limits are transient
379
+ and now take the normal backoff path. A corrupt cached-user blob no longer
380
+ deletes the still-valid refresh token alongside it.
381
+
382
+ **Added.** `AuthError.kind` / `.retryable` / `.retryAfterSeconds` / `.status` —
383
+ a closed failure taxonomy (`network`, `server`, `rate_limited`,
384
+ `invalid_credential`, `account_locked`, `credential_in_use`,
385
+ `already_identified`, `cancelled`, `config`, `unknown`) to branch on instead of
386
+ matching raw codes. Existing `code` values are unchanged.
387
+
388
+ **Added.** `auth.onAuthStateChanged(listener)` — one stream for `signed_in`,
389
+ `signed_out` (`reason: "user" | "session_expired" | "account_deleted"`),
390
+ `device_takeover` and `deletion_cancelled`. A session that dies in the
391
+ background now reports `session_expired` instead of vanishing silently.
392
+ Replays the current state on subscribe. `onDeviceTakeover` /
393
+ `onDeletionCancelled` are unaffected.
394
+
395
+ ## 1.26.0 — `onDeletionCancelled` (account-restore signal)
396
+
397
+ New `auth.onDeletionCancelled(listener)` + `auth.getLastDeletionCancelled()` —
398
+ fires when a sign-in **cancelled a pending self-service account deletion** within
399
+ its grace window (the account is restored, same user_id). Mirrors
400
+ `onDeviceTakeover`: dispatched centrally from `persist()` reading a new
401
+ `reactivatedFromDeletion` flag the backend now returns on **every** sign-in
402
+ response (was password-login-only). Use it to show "your account deletion was
403
+ cancelled" + reconcile local state. Pairs with the new server-side
404
+ `auth.deletion_cancelled` / `auth.deletion_scheduled` webhooks (s58.269).
405
+ Additive, SDK-only (not in the golden wire contract).
406
+
407
+ ## 1.25.0 — identity linking on an identified session (ADR-030) + signUp() fix
408
+
409
+ **New: link a SECOND credential to an already-signed-in account, preserving the
410
+ same user id (sub)** — the cross-platform account-unification primitive. Unlike
411
+ `signUp()` / `loginSocial({link})` (which only preserve the sub from an
412
+ *anonymous* session), these work on an already-identified account:
413
+
414
+ - `auth.linkEmailPassword(email, password)` — attach email + password.
415
+ - `auth.linkGoogle({ idToken | code, redirectUri })` / `auth.linkApple({ … })` /
416
+ `auth.linkSocial({ provider, … })` — attach an OAuth identity.
417
+ - `auth.linkGameCenter(payload?)` / `auth.linkPlayGames(payload?)` — attach a
418
+ gaming identity (no-arg drives the native fetch via the gaming companion).
419
+
420
+ All require a signed-in session (Bearer), preserve the sub, do NOT rotate tokens,
421
+ and refresh the cached user in place. Collision (the credential already belongs to
422
+ another account) throws the new **`CredentialInUseError`** — Sendora never
423
+ auto-merges two real accounts. Use to make one account reachable across platforms
424
+ (e.g. a Game Center player on iOS links email/Google, then signs in on Android to
425
+ the SAME sub).
426
+
427
+ **Fix: `signUp()` on an already-identified session** now throws the new
428
+ **`AlreadyIdentifiedError`** instead of a misleading `EmailAlreadyTakenError`, and
429
+ no longer wipes the session or mints a duplicate account. (The backend now returns
430
+ a distinct `NOT_ANONYMOUS` code, ADR-030 §4.)
431
+
432
+ Additive + SDK-only surface (not in the golden wire contract); no frozen storage
433
+ key / header / route touched. Verified via `test/link.test.ts` (18 assertions) +
434
+ `tsc` + built `dist`.
435
+
436
+ ## 1.24.0 — `AuthUser.signupMethod` + `.lastLoginMethod`
437
+
438
+ The `AuthUser` object now carries two optional read-only fields: `signupMethod`
439
+ (how the account was first created, immutable) and `lastLoginMethod` (most recent
440
+ authentication). Free-form provider tokens: `password | anonymous | google |
441
+ apple | github | microsoft | linkedin | facebook | discord | gamecenter |
442
+ playgames | magic_link | email_otp | passkey | oidc | saml | scim | import`.
443
+ Populated by the backend on the login/signup/social/game response (s58.266, mig
444
+ 0094); flow through `persist()` unchanged, so `getUserSync()` / `getCurrentUser()`
445
+ expose them with **zero new plumbing**. Display-only (UI / routing) — never an
446
+ authorization signal. **Additive + tolerant**: `undefined` against a pre-s58.266
447
+ backend, `null` for an end-user row created before that backend shipped
448
+ (backfilled on their next sign-in); `isAuthApiResponse` type-checks them only when
449
+ present. No frozen storage key / header / wire-shape changed (ADR-023) — installed
450
+ apps on ≤1.23.1 keep working; upgrading is opt-in.
451
+
452
+ ## 1.23.1 — config plugin: fix Expo SDK 54 `expo prebuild` break (ESM→CJS import)
453
+
454
+ `app.plugin.js` now **default-imports** `@expo/config-plugins` and destructures at
455
+ runtime instead of a NAMED import. The named form (`import { …, AndroidConfig, … }`)
456
+ relied on Node's cjs-module-lexer statically surfacing every member of the
457
+ CommonJS package — under config-plugins 54 (Expo SDK 54) `AndroidConfig` is not,
458
+ so the import threw `SyntaxError: does not provide an export named 'AndroidConfig'`
459
+ at load time and aborted `expo prebuild`. Default-import is version-agnostic (the
460
+ default export is the CJS `module.exports`). **No behavior change** — identical
461
+ transforms, JS/native runtime untouched; only the deep-link config plugin's load
462
+ path is fixed. Regression-guarded in `test/plugin.test.ts` (rejects the named
463
+ form). Verified against the real `@expo/config-plugins` (named throws, default
464
+ resolves `AndroidConfig.Manifest.getMainApplicationOrThrow`). Reported by a
465
+ customer on Expo SDK 54; the companion `@sendoracloud/sdk-react-native-gaming`
466
+ 1.0.1 carries the same fix.
467
+
468
+ ## 1.23.0 — Game Center / Play Games no-arg sign-in (native in a companion package)
469
+
470
+ Adds the JS so 1.21.0's gaming sign-in can fetch the provider payload itself — no
471
+ app-supplied GameKit / Play Games code:
472
+
473
+ - **`auth.signInWithGameCenter()`** (no args) drives Game Center natively and posts
474
+ the identity payload.
475
+ - **`auth.signInWithPlayGames()`** (no args) drives Play Games v2 and posts the
476
+ `serverAuthCode`.
477
+
478
+ The native code (GameKit Swift / Play Games Kotlin + the gaming config plugin)
479
+ lives in an **optional companion package, `@sendoracloud/sdk-react-native-gaming`**
480
+ — so **non-gaming apps don't pull the Android `play-services-games-v2`
481
+ dependency**. Install the companion + its config plugin only for gaming sign-in.
482
+
483
+ This SDK stays pure JS. `src/native.ts` resolves the companion's native module
484
+ zero-dependency off `globalThis.expo.modules.SendoraGameAuth` (Metro-safe, CLAUDE
485
+ rule #1 — no expo import). The no-arg call is **optional at runtime**: without the
486
+ companion (or in Expo Go / bare RN) it throws a clear "install
487
+ @sendoracloud/sdk-react-native-gaming + Dev Client" error, and the
488
+ **explicit-payload overloads from 1.21.0 still work everywhere** —
489
+ `signInWithGameCenter(payload)` / `signInWithPlayGames({ serverAuthCode })`
490
+ unchanged and additive. Overload dispatch keys on `"publicKeyUrl" in arg` /
491
+ `"serverAuthCode" in arg`; the no-arg native fetch runs INSIDE the serialized op
492
+ (a cancelled sheet leaves the session untouched).
493
+
494
+ The 1.22.0 deep-link config plugin (`app.plugin.js`, `{ linkSubdomain }`) is
495
+ unchanged — gaming config moved out to the companion's own plugin.
496
+
497
+ Additive; no frozen storage key / header / wire-shape changed. JS overload +
498
+ native-fetch logic unit-tested (`test/game-auth.test.ts`). **On-device smoke (real
499
+ Game Center + Play Games accounts, via the companion) is the release gate.**
500
+
501
+ ## 1.22.0 — Expo config plugin (auto-wire deep-link associated domains)
502
+
503
+ Ships an Expo config plugin at the package root (`app.plugin.js`) so apps don't
504
+ hand-edit iOS entitlements / Android manifests for deep links. In app.json:
505
+ `["@sendoracloud/sdk-react-native", { "linkSubdomain": "wordhurdle" }]` →
506
+ `applinks:wordhurdle.sendoracloud.app` (iOS associated-domains) + an autoVerify
507
+ App Links intent-filter (Android). `linkDomains: [...]` adds BYO/custom hosts
508
+ (combines with `linkSubdomain`). **Opt-in + build-time** (runs at
509
+ `expo prebuild`) — never touches an already-built/installed app; both platforms
510
+ **MERGE + dedupe** (no clobber of existing domains; `createRunOncePlugin` guards
511
+ double-apply). ESM plugin → Expo SDK 50+. Pure transforms unit-tested
512
+ (`test/plugin.test.ts`). Additive; no runtime SDK / frozen-contract change.
513
+
514
+ ## 1.21.0 — Game Center + Play Games sign-in
515
+
516
+ `auth.signInWithGameCenter(payload, { link })` (Apple, iOS) and
517
+ `auth.signInWithPlayGames({ serverAuthCode }, { link })` (Google, Android) —
518
+ email-less, player-keyed sign-in. Forward the native provider payload (obtain it
519
+ via a GameKit / Play Games native module or the Sendora Expo plugin) to the new
520
+ `/auth-service/login/{game-center,play-games}` endpoints. Shares the anon
521
+ device-takeover + ADR-025 `link` (link-in-place, same user id preserved)
522
+ semantics with `loginSocial`. Additive; no frozen storage key / header /
523
+ wire-shape changed (`linkAnonymous`/`prevAnonRefreshToken` are optional backend
524
+ fields, not in the golden contract).
525
+
526
+ ## 1.20.0 — anon→social link-in-place (ADR-025)
527
+
528
+ `loginSocial` / `signInWithApple` / `signInWithGoogle` accept an opt-in `link?: boolean`. When the device is anonymous and `link: true`, an anon→social upgrade forwards `linkAnonymous` so the backend promotes the anon row **in place** — the user id (`sub`) is **preserved** (fires `auth.user_upgraded`) instead of a device-takeover that mints a new id (Firebase `linkWithCredential` parity). No effect off-anon, or on a collision (identity already owned / email taken → prior takeover/merge). Additive + opt-in; `link` absent/false = prior behaviour exactly. Design: `docs/decisions/025-anon-social-link-in-place.md`.
529
+
530
+ ## 1.19.0 — `getUserSync()` + `isReady()`
531
+
532
+ Synchronous, non-throwing accessors for the **verified auth principal** at cold start. `getUserSync(): AuthUser | null` returns the in-memory session hydrated during `init()` (`id` = the JWT `sub` — the durable server-verified key), zero I/O. `isReady(): boolean` distinguishes "still restoring the session" from "genuinely signed out". SDK-only; not in the golden wire contract.
533
+
534
+ ## 1.18.0 — opt-in encrypted-at-rest storage (`secureStorage` adapter)
535
+
536
+ Optional, default-off. `Sendora.init({ secureStorage })` takes an app-supplied Keychain/Keystore `SecureStorageAdapter`; the SECURE_KEYS (`auth_refresh_token`/`auth_access_token`/`auth_user`) route through it with transparent plaintext→secure dual-read migration (never signs anyone out). Graceful fallback to AsyncStorage on adapter failure. SDK never imports expo-secure-store (Supabase `auth.storage` model → Metro-safe). Omit it = exact prior behaviour.
537
+
538
+ ## 1.17.0 — `getAnonymousIdSync()`
539
+
540
+ Additive sync read of the anon id (`this.anonId || null`) — lets offline-first callers read-or-fallback at cold start without a try/catch around the throwing `getAnonymousId()`.
541
+
542
+ ## 1.16.0 — device app version + native build number
543
+
544
+ `device.appVersion` + native build number attached to the device context (surfaces on the dashboard Audience page).
545
+
546
+ ## 1.15.0 — appVersion for modern Expo (New Architecture)
547
+
548
+ Reads app version from the canonical modern source `ExpoApplication.nativeApplicationVersion` (fixes the 1.13.0 dead path on Expo SDK 50+). Hardened `expoModule()` resolution mirrors expo-modules-core's `requireOptionalNativeModule`. Still zero-dep / zero Metro risk.
549
+
550
+ ## 1.14.0 — ADR-023 SDK compatibility headers + schema marker
551
+
552
+ `X-Sendora-SDK-{Name,Version}` headers on every request + a local `sendora_schema_version=1` marker (forward hook for on-device migrations). Additive; backend ignores the headers today.
553
+
554
+ ## 1.13.0 — Expo New-Architecture device read (appVersion + model)
555
+
556
+ Reads Expo native modules off `globalThis.expo.modules[name]` (New-Arch registry) first, legacy `NativeModules` second — `device.model` ← `ExpoDevice.modelName` when expo-device is installed. No dependency, no Metro risk.
557
+
558
+ ## 1.12.0 — appVersion auto-reads Expo Constants
559
+
560
+ `device.appVersion` auto-detects on Expo via `NativeModules.ExponentConstants` with zero new dependency (superseded by 1.13.0/1.15.0 for New-Arch).
561
+
562
+ ## 1.11.0 — top-level `sessionId` on events
563
+
564
+ Events now carry a top-level `sessionId` (not just nested in properties), powering the Audience "Sessions & engagement" metrics for RN. iOS/Android parity.
565
+
566
+ ## 1.10.0 — appVersion auto-detect (Metro-safe)
567
+
568
+ `device.appVersion` resolves from config → `EXPO_PUBLIC_APP_VERSION` → global → "". Deliberately no `require()` of expo-constants / device-info (Metro-safe).
569
+
570
+ ## 1.9.0 — device/tech context (ADR-022)
571
+
572
+ Every event's `context.device` block: `{ type, os, osVersion, model, appVersion }`, built from RN's `Platform`. Powers the dashboard Analytics → Audience page.
573
+
574
+ ## 1.8.2 — `getAccessToken()` never returns an expired token
575
+
576
+ Hard contract (matches iOS/Android + Supabase/Auth0/Firebase token clients): a valid cached token if ≥30s life remains; else await the single-flight `/token/refresh` and return the FRESH token; `null` during post-failure backoff. Reverses the Wave-62 stale-while-refreshing return that could hand out a token past `exp`. The ≤10s AbortController bound (the real hang fix) is preserved.
577
+
578
+ ## 1.8.1 — refresh-before-delete
579
+
580
+ `auth.deleteAccount()` resolves a fresh access token via `getAccessToken()` before the `DELETE`, fixing 401s when a stale cached token was sent for the one-shot destructive call.
581
+
582
+ ## 1.7.0 — self-service account deletion
583
+
584
+ `auth.deleteAccount()` (Apple Guideline 5.1.1(v)) → `{ status: "purged" | "pending", scheduledPurgeAt, graceDays }`. Disables + revokes immediately; hard-deletes at `scheduledPurgeAt` unless the user signs back in within the per-project grace window. Wipes local identity on success.
585
+
586
+ ## 1.6.2 — MFA-from-anonymous device-takeover fix (audit s58.203 follow-up)
587
+
588
+ Fixes lost device-takeover when an MFA-enabled user signs in from an anonymous device:
589
+ - `signIn()` previously wiped the local anon identity **before** the network call, so when the response was an MFA challenge the anon refresh token was gone — `challengeMfa()` forwarded no `prevAnonRefreshToken`, the backend never retired the anon row / reassigned push tokens, and the device ended with **two user_ids + duplicate pushes**.
590
+ - Now: `signIn()` captures the anon refresh token, does NOT wipe before the call, and on an MFA-required response **stashes** it keyed to the `mfaChallengeToken`. `challengeMfa()` forwards the stashed token, and the anon identity is wiped **only after a successful mint** — a wrong/expired code preserves the anon session for retry. Mirrors the non-MFA `signIn()` ordering.
591
+
592
+ No public API change.
593
+
594
+ ## 1.6.1 — s58.203 audit P0: dead namespaces + anonymous push fixed
595
+
596
+ Two production-breaking fixes:
597
+ - **15 endpoint paths across 8 namespaces were missing the `/api/v1` prefix** → every call resolved to a 404. Affected: `push.trackOpen`, `push.liveActivities`, `push.geofences`, `attribution`, `support`, `consent`, `surveys`, `messages` (in-app), `flags`, `chatbot`. All now hit their real backend routes.
598
+ - **`registerPushToken` sent `userId: null` for anonymous users** → backend Zod rejected it (422), so anonymous devices could never register for push. Now omits `userId` when anonymous (matches `sendEvent`).
599
+
600
+ No public API change. Upgrade required for any app using those namespaces or registering push before sign-in.
601
+
602
+ ## 1.5.0 — Wave 73 unread support count
603
+
604
+ New `support.getUnreadCount(widgetId)` returns the number of tickets the signed-in user (anon or identified) has with new activity since their last view. Returns 0 when no SDK auth is on file. Backed by W72's `GET /widgets/:id/my-tickets/unread-count`.
605
+
606
+ Recommended cadence: poll on AppState `active` + every 60s while the app is active.
607
+
608
+ ```ts
609
+ useEffect(() => {
610
+ let cancelled = false;
611
+ const tick = async () => {
612
+ const n = await SendoraCloud.support.getUnreadCount("widget-uuid");
613
+ if (!cancelled) setBadge(n);
614
+ };
615
+ void tick();
616
+ const id = setInterval(tick, 60_000);
617
+ return () => { cancelled = true; clearInterval(id); };
618
+ }, []);
619
+ ```
620
+
621
+ ## 1.4.0 — Wave 69 in-app ticket history
622
+
623
+ New `<TicketHistory />` + `useTicketHistory(widgetId, getAccessToken, defaults?)` on the `/contact-widget` subpath. Opens the worker-hosted `/embed/tickets` view in a `<Modal>` + `<WebView>` so the submitter can see all their past tickets + reply inline.
624
+
625
+ Backend (chatbot module):
626
+ - `GET /widgets/:widgetId/my-tickets` — list (Bearer JWT auth).
627
+ - `GET /widgets/:widgetId/my-tickets/:ticketId` — single ticket + replies.
628
+ - `POST /widgets/:widgetId/my-tickets/:ticketId/replies` — customer reply. Auto-reopens resolved/closed tickets. Emits `support.in_app_reply` event.
629
+
630
+ JWT is passed in the URL hash fragment and scrubbed on load — never appears in server logs or referer.
631
+
632
+ ```tsx
633
+ import SendoraCloud from "@sendoracloud/sdk-react-native";
634
+ import { useTicketHistory } from "@sendoracloud/sdk-react-native/contact-widget";
635
+
636
+ const history = useTicketHistory(
637
+ "widget-uuid",
638
+ () => SendoraCloud.auth.getAccessToken(),
639
+ );
640
+
641
+ <Button title="My tickets" onPress={history.open} />
642
+ <history.View />
643
+ ```
644
+
645
+ Anonymous SDK rows + identified users both work — anon tickets are filtered by `user_id` only, identified users also pick up legacy tickets matched on email.
646
+
647
+ ## 1.3.0 — Wave 68 contact widget identity handling
648
+
649
+ `<ContactWidget />` + `useContactWidget()` now accept:
650
+
651
+ - `prefillUserId` — Sendora `userId` (anon or identified) that gets posted with the ticket so it stitches to the same profile as analytics events.
652
+ - `lockEmail` — when true + `prefillEmail` is set, the email field is hidden + read-only.
653
+
654
+ `useContactWidget(widgetId, { user })` now accepts the SDK's `AuthUser` shorthand and auto-resolves prefill:
655
+
656
+ - identified user (`emailVerified && !isAnonymous`) → email locked + userId attached.
657
+ - anonymous SDK row → userId attached, email field shown for entry.
658
+ - no user → standard form.
659
+
660
+ Backend gets `userId` stored on the new ticket row + the embed posts it for you. Hint only, not an auth boundary — see security note in `useContactWidget` docstring.
661
+
662
+ ```tsx
663
+ import SendoraCloud from "@sendoracloud/sdk-react-native";
664
+ import { useContactWidget } from "@sendoracloud/sdk-react-native/contact-widget";
665
+
666
+ const user = SendoraCloud.auth.getCurrentUser();
667
+ const contact = useContactWidget("widget-uuid", { user });
668
+ ```
669
+
670
+ ## 1.2.0 — Wave 66 mobile Contact Widget
671
+
672
+ New subpath import `@sendoracloud/sdk-react-native/contact-widget` ships `<ContactWidget />` + `useContactWidget()` hook. Opens the worker-hosted embed at `https://go.sendoracloud.com/embed/contact?widgetId=…` inside a `<Modal>` + `<WebView>`. The HTML form posts to `POST /widgets/:id/submit` server-side — same backend the web `widget.js` uses.
673
+
674
+ On submit success the embed page navigates to `sendora://close?ticketId=…&portalUrl=…`. `onShouldStartLoadWithRequest` intercepts the scheme, fires `onClose({ ticketId, portalUrl, submitted })`, host sets `visible=false`.
675
+
676
+ `react-native-webview` is an **optional** peer dep. Apps that don't use the contact widget aren't required to install it — Metro only resolves it when the subpath is imported.
677
+
678
+ **Mount**:
679
+ ```tsx
680
+ import { useContactWidget } from "@sendoracloud/sdk-react-native/contact-widget";
681
+
682
+ const contact = useContactWidget("widget-uuid", { theme: "auto" });
683
+
684
+ <Button title="Contact us" onPress={contact.open} />
685
+ <contact.View />
686
+ ```
687
+
688
+ Single source of truth = the worker route. UI / theme / copy updates = redeploy worker. Zero SDK release.
689
+
690
+ ## 1.1.0
691
+
692
+ **Cold-launch hang fix — architectural.**
693
+
694
+ Pulse News (1.0.5) reported `sdk.auth.getAccessToken()` hanging indefinitely on iOS cold launches from Universal Links. Root cause: iOS NSURLSession stalls up to ~70s on cold-URL-launch contention; SDK's single-flight `refreshInflight` shared one stuck promise across every concurrent caller, hanging the JS bridge until iOS WatchdogTermination fired. No internal timeout, no abort.
695
+
696
+ **5-layer fix** (every layer below holds even if layers above misfire):
697
+
698
+ 1. **`http.ts` — AbortController + per-call timeout on every fetch.**
699
+ Auth paths: 10s. Ingest paths: 30s. On expiry the AbortController fires + the underlying socket cancels (not orphaned waiting on NSURLSession 70s error). Throws typed `NetworkTimeoutError` so callers can distinguish from non-2xx.
700
+
701
+ 2. **`auth.getAccessToken()` — stale-while-refreshing.**
702
+ Expired cached token + refresh inflight → return stale cached anyway. Backend's 60s grace window accepts slightly-stale access tokens, so the caller's downstream RPC succeeds against grace while the in-flight refresh resolves out of band. Pre-1.1.0: every concurrent caller blocked on the same stuck inflight promise.
703
+
704
+ 3. **`auth.refreshAccessToken()` — exponential backoff on transient failures.**
705
+ `NetworkTimeoutError` and other non-dead-refresh errors increment `refreshFailureCount` + arm a cooldown (1s → 2s → 4s → ... capped 30s). `getAccessToken()` consults the cooldown + returns stale rather than firing fresh fetches into a stalled network.
706
+
707
+ 4. **AppState foreground — reset refresh state.**
708
+ When the app comes back to active, `resetRefreshState()` clears `refreshFailureCount + refreshCooldownUntil` so the user returning from a network blip gets an immediate fresh attempt, not a wait for the exponential timer.
709
+
710
+ 5. **Permanent vs transient distinction.**
711
+ Dead-refresh errors (backend rotated away, family revoked, session pruned) still wipe identity + stop the retry loop at the source. Transient errors (network timeout, 5xx, JSON parse) do NOT wipe — refresh token may still be valid.
712
+
713
+ **Caller migration:** None required. `getAccessToken()` still returns `string | null`. `withSendoraBearer`-style wrappers can keep their per-call timeout — the SDK now backs them up at multiple layers instead of trusting iOS to terminate.
714
+
715
+ **Breaking changes:** None. `NetworkTimeoutError` is a new exported class; existing `instanceof Error` checks still work.
716
+
717
+ Closes Pulse News dogfooding bug report. Operator carry-over: npm publish + tag `sdk-react-native-v1.1.0`.
718
+
719
+ ## 1.0.0
720
+
721
+ **Breaking change.** Backend (s58.104) now exposes unprefixed alias routes and
722
+ resolves `orgId` from the API key server-side.
723
+
724
+ - **Removed** `orgId` from `SendoraConfig`. Backend derives the org from the
725
+ apiKey row server-side.
726
+ - Outgoing payloads (`/api/v1/events`, `/api/v1/push/tokens`, …) no longer
727
+ include the `orgId` field.
728
+ - `publicKey` remains a deprecated alias for `apiKey`.
729
+
730
+ ### Migration
731
+
732
+ Drop `orgId` from your `SendoraCloud.init({...})` call:
733
+
734
+ ```diff
735
+ await SendoraCloud.init({
736
+ apiKey: "pk_prod_...",
737
+ - orgId: "<ORG_ID>",
738
+ });
739
+ ```
740
+
741
+ All public method signatures (`identify`, `track`, `screen`,
742
+ `registerPushToken`, `links.*`, `auth.*`, `attribution.*`, push helpers, …)
743
+ are unchanged.
744
+
745
+ ## 0.20.x and earlier
746
+
747
+ See git history at https://github.com/sendoracloud/sdk-react-native/commits/main