@sendoracloud/sdk-react-native 1.39.1 → 1.40.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,661 @@
1
+ # Changelog
2
+
3
+ ## 1.40.0 — signInAnonymously() no longer replaces a signed-in account; CHANGELOG ships in the package
4
+
5
+ **Behaviour change (reported by RopoLabs, verified against the shipped bundle).**
6
+ `signInAnonymously()` reused an existing *anonymous* session but fell straight through when
7
+ the current session was an identified account: it minted a guest and overwrote the
8
+ account's tokens locally, with no `signed_out` on `onAuthStateChanged` (the path never
9
+ wipes — it persists over the top). An app calling it defensively on a "looks signed out"
10
+ reading silently converted a paying account into a fresh guest. It now returns the current
11
+ session whatever its kind; `forceNew: true` is the only way to replace an identified
12
+ session, and it does so silently. Read `user.isAnonymous` to know which you got. The
13
+ account itself was never at risk server-side (recoverable by its credential) — the device
14
+ had just become a stranger to it. Same fix in web 3.23.0 / iOS 5.3.0 / Android 4.25.0.
15
+
16
+ - **`onAuthStateChanged` docs now say what it is not:** a transition stream, not a health
17
+ signal. A transient refresh failure (offline, timeout, 429, 5xx) emits nothing because
18
+ nothing changed; only a server-rejected refresh token produces
19
+ `signed_out(session_expired)`. Use `getAccessToken()` for liveness.
20
+ - **`CHANGELOG.md` is in the published tarball** — the README told you to read it before
21
+ upgrading and the file was not there. The `repository` field pointed at a repository
22
+ that does not exist and was removed (this SDK is developed in a private monorepo).
23
+ - `EventQueue.flush()`'s doc comment caught up with 1.39.1's head-of-line rotation.
24
+ - **Server-side, no SDK change:** `auth.deleteAccount()` now also erases the account's
25
+ events (including guest events merged onto it), profile, entities, push-token
26
+ registrations and SMS/email sends. It previously erased credentials and the account row
27
+ only. If your privacy page says Sendora erases a deleted user's analytics, it is true
28
+ from this backend release.
29
+
30
+ Guard: `test/anonymous-over-account.test.ts` (drives the real `Auth`: identified +
31
+ refresh → no mint / no wipe / token untouched; `forceNew` mints; anonymous reuse holds;
32
+ cached user without a refresh token still mints).
33
+
34
+ ## 1.39.1 — the event queue no longer outlives account deletion
35
+
36
+ **Follow-up to 1.39.0 (reported by RopoLabs/Word Hurdle).** The retrying queue added in
37
+ 1.39.0 kept events across `auth.deleteAccount()` — so anything queued on a slow connection
38
+ just before a confirmed deletion was delivered *after* it, still carrying the deleted
39
+ account's `userId` and anonymous id. For an app that honors App Store 5.1.1(v) / GDPR
40
+ erasure, that resurrected part of the account.
41
+
42
+ - The queue is now cleared when an account is deleted. A plain `signOut()` still keeps its
43
+ queued events (they legitimately belong to that signed-in session — only deletion clears).
44
+ - Also: a single event the server keeps rejecting no longer holds up everything behind it —
45
+ a repeatedly-failing event rotates to the back of the queue.
46
+
47
+ No API or config change. Other SDKs (web/iOS/Android) already cleared their queue on
48
+ deletion and were unaffected.
49
+
50
+ ## 1.39.0 — analytics events no longer leak an unhandled rejection; persisted retrying queue
51
+
52
+ **Bug fix (reported by RopoLabs/Word Hurdle, with a repro against the shipped bundle).** A slow
53
+ `/api/v1/events` request used to surface in your crash reporter as an unhandled promise rejection
54
+ (`Request timed out: POST /api/v1/events (30000ms)`) and the event was dropped. `sendEvent()` awaited
55
+ a transport that throws on its 30 s timeout, and every caller — including auto-track — discarded the
56
+ rejecting promise. Since auto-track fires `session.start`/`app.opened`/lifecycle events with no call
57
+ from your app, any app with users on slow networks saw the noise, and the dropped events biased
58
+ session/engagement metrics toward users on fast networks.
59
+
60
+ - Event delivery now runs through a **persisted, retrying queue**: a failed or slow send is retried
61
+ with backoff (up to 60 s), survives app restarts, and flushes when the app returns to the
62
+ foreground — so a slow network **delays** events instead of losing them. An event is dropped only
63
+ once the server accepts it, or permanently refuses it (a 4xx a retry can't fix).
64
+ - A failed event can **no longer surface as an unhandled rejection.** `sendEvent()` is synchronous and
65
+ never throws.
66
+ - No API change, no wire change, no configuration needed — upgrade and it's fixed. (A new internal
67
+ storage key `sendora_event_queue` holds unsent events; additive, ignored by older SDK versions.)
68
+
69
+ Other SDKs (web/iOS/Android) already had this queue design and were unaffected.
70
+
71
+ ## 1.36.0 — failure reporting, insecure-storage warnings, and the step-up error kind
72
+
73
+ ⚠⚠ **Three methods reported failure as success, and `request()` never throws on a non-2xx** — it
74
+ warns and returns the `Response`. So the discarded return value was the only place a refusal could
75
+ surface. `disableMfa()` is **step-up gated**, so the app said MFA was off while TOTP stayed armed
76
+ server-side; `revokeSession(id)` / `revokeAllSessions()` back a "sign out this device" button that
77
+ reported success on a device that was never signed out. All three now throw.
78
+
79
+ ⚠ **`unwrap` was dropping the HTTP status**, so every failure routed through it classified as kind
80
+ `unknown` — a 429 and a 503 both reporting as not-worth-retrying. It now carries `status` and
81
+ `retryAfterSeconds`. This also changes `enrollMfa` and `confirmMfa`, whose errors move from `unknown`
82
+ to a status-derived kind. That is the intended taxonomy and matches web/iOS/Android.
83
+
84
+ **New error kind: `recent_auth_required`** — the credential is valid; the authentication behind it is
85
+ too old to authorise a credential change (403 `RECENT_AUTH_REQUIRED`). Not a request for new input:
86
+ re-authenticate and retry the same call. It previously fell through to `invalid_credential`,
87
+ documented as "needs NEW input".
88
+
89
+ **Insecure storage is no longer silent.** Without an encrypted-storage module the SDK falls back to
90
+ `AsyncStorage`, which is plaintext on disk. That fallback used to happen quietly; it now warns, so a
91
+ production app cannot ship tokens in the clear without anyone having been told.
92
+
93
+ ⚠ React Native implements no passkey management, so the gated `DELETE /passkeys/:id` route has no
94
+ call site here.
95
+
96
+ ## 1.35.0 — unlink(provider): remove a linked sign-in method
97
+
98
+ Parity: RN 1.35.0 / web 3.19.0 / iOS 4.20.0 / Android 4.20.0. Additive.
99
+
100
+ The write half of `listLinkedIdentities()`. An app could already render
101
+ "Connected: Google · Game Center"; ``auth.unlink(provider)`` is what the Disconnect
102
+ button beside each entry calls.
103
+
104
+ **⚠ The server refuses to remove the last way into the account** — code
105
+ `LAST_CREDENTIAL`, kind ``"last_credential"``. An account with no credentials still
106
+ exists and still holds the user's data, but nothing can ever authenticate into
107
+ it again: no password to reset, no identity to present. That is permanent
108
+ lockout, not an inconvenience, and it is unrecoverable without manual database
109
+ surgery. A password counts as a credential, so an account with email+password
110
+ plus one social identity may drop the social one.
111
+
112
+ Supabase enforces the same floor (`unlinkIdentity` requires >= 2 identities);
113
+ Firebase's `unlink()` does not and will strip the last provider. We follow
114
+ Supabase, deliberately.
115
+
116
+ **Prefer preventing the tap to explaining the refusal.** Read
117
+ `listLinkedIdentities()` and disable Disconnect when
118
+ `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the backstop, not the UX.
119
+
120
+ An unlinked provider returns `NOT_FOUND` rather than reporting success — a
121
+ screen saying "disconnected" about a credential that is still attached tells
122
+ the user something untrue about their account.
123
+
124
+
125
+ <!-- 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). -->
126
+
127
+ ## 1.34.0 — getAccessToken() no longer returns a token past its own `exp`
128
+
129
+ Parity: web 3.18.0 / iOS 4.19.0 / Android 4.19.0. Customer-reported (Word
130
+ Hurdle), with a repro that runs against the shipped bundle.
131
+
132
+ **Fixed (HIGH — silent, up to a full token TTL).** `getAccessToken()`
133
+ documents a hard contract: it never returns a token past its `exp`. It enforced
134
+ a different value — a deadline computed as `Date.now() + expiresIn` at mint,
135
+ persisted, and restored verbatim. That deadline is deliberately
136
+ **skew-invariant** (a permanently wrong clock cancels on both sides, because
137
+ the deadline is written in the same frame it is later read in) but blind to a
138
+ clock that **moves**. Step the clock backwards between mint and read — an
139
+ iPhone acquiring automatic time after a long power-off, a restore, or a manual
140
+ set — and the tracked deadline is stale by exactly the size of the correction,
141
+ across relaunches. Observed on a physical device with a token **1929 seconds
142
+ past its `exp`**: every request 401'd while the app still rendered as signed
143
+ in, and nothing emitted `signed_out`, because the fast path returns before
144
+ reaching `persist()` or `wipeLocalIdentity()`, the only emitters.
145
+
146
+ The token's own `exp` is now required as well. **Requiring it alone would have
147
+ been worse than the bug** — a device whose clock is fast by more than the token
148
+ TTL reads every freshly-minted token as already expired, so every call
149
+ refreshes, forever (measured at 10 reads → 10 refreshes). A one-shot probe
150
+ bounds it: when a refresh performed *because* the two deadlines disagreed comes
151
+ back with a token that STILL reads expired, the clock is proven fast rather
152
+ than the deadline stale, and the `exp` guard is released for that process.
153
+ Never persisted — a stored "my clock is fast" belief would outlive the
154
+ correction that made it false.
155
+
156
+ Also fixed, both surfaced by the same report:
157
+
158
+ - The rotate short-circuit inside `refreshAccessToken()` returned a stored
159
+ token on a bare `> Date.now()` — **no safety margin and no `exp` check** — on
160
+ the one path meant to repair an expired token.
161
+ - The proactive-refresh cron reasoned only from the tracked deadline, so during
162
+ this window it never fired; and it had **no call site outside `persist()`**,
163
+ meaning a cold launch ran with no cron at all. It is now armed from
164
+ `hydrate()` too, and repairs rather than only anticipating.
165
+
166
+ **Added — `getAccessToken({ forceRefresh: true })`.** The supported way to say
167
+ "your tracked deadline is wrong". It skips the cache but not the single-flight
168
+ or the backoff cooldown, so force-refreshing on every 401 cannot turn a server
169
+ outage into a hot loop. Replaces reaching into the private `accessExpiresAt`
170
+ field, which is what the workaround for this bug required.
171
+
172
+ **Added — `Sendora.init({ storagePrefix })`.** Namespaces every on-device key
173
+ the SDK owns, for one codebase pointed at more than one project (staging beside
174
+ production, debug beside release). Omitted, behaviour is byte-identical to
175
+ prior releases. ⚠ Changing it signs the device out — nothing migrates between
176
+ prefixes. Applies to the `secureStorage` adapter too when set. `secureStorage`
177
+ alone does not solve this: `auth_access_expires` and the anon id stay in
178
+ AsyncStorage and still collide.
179
+
180
+ Tests: `test/token-expiry.test.ts` drives a controllable clock through correct
181
+ / constantly-fast / stepped-back, plus the opaque-token and `forceRefresh`
182
+ paths. Both controls encode what must NOT change. Mutation-proven 3/3 —
183
+ dropping the `exp` guard and moving the probe to mint time each resurrect the
184
+ bug; dropping the skew escape produces the 10-for-10 refresh loop.
185
+
186
+ ## 1.33.0 — getLastAnonRetirement(): did my guest account survive?
187
+
188
+ Parity: web 3.17.0 / iOS 4.18.0 / Android 4.18.0. Additive.
189
+
190
+ `retiredAnonUserId` is present-or-absent, and the absence covered two
191
+ situations needing opposite handling: the guest account was retired but not
192
+ named, versus nothing was retired and the guest is still alive and claimable.
193
+ Sign-in responses now carry `anonRetirement`, and `getLastAnonRetirement()`
194
+ returns the last value (cleared on sign-out): `retired` (row deleted, id
195
+ arrives via `onDeviceTakeover`), `preserved` (a guest token WAS sent and the
196
+ guest was NOT retired — that account still exists, so "recover your other
197
+ account" is a real offer), `none` (no guest token sent).
198
+
199
+ ⚠ Recorded ONLY when the server states it. An older backend omits the field
200
+ entirely, and treating that as `none` would assert a fact we were never told.
201
+
202
+ ## 1.32.0 — the credential-collision default is now safe by construction
203
+
204
+ Parity: web 3.16.0 / iOS 4.17.0 / Android 4.17.0. **Behaviour change to the
205
+ DEFAULT.**
206
+
207
+ `onCredentialInUse` shipped in 1.30.0 defaulting to `adopt` for compatibility.
208
+ That was the wrong call: the destructive path was what you got by *not* knowing
209
+ the option existed, and it succeeded silently. The default is now conditional
210
+ on whether adopting would destroy anything — a fresh install with no guest
211
+ session adopts (the reinstall-recovery path, never refused); a live guest
212
+ session is refused with `CREDENTIAL_IN_USE` so the app can ask the player.
213
+
214
+ ⚠ **If you rely on silent adopt** — you consume the device-takeover signal to
215
+ migrate progress on a returning player — pass `onCredentialInUse: "adopt"`
216
+ explicitly. The adopt path itself is unchanged and still fires the takeover
217
+ webhook.
218
+
219
+ ## 1.31.0 — onDeviceTakeover doc fix: the account-deleting paths were missing
220
+
221
+ Parity: web 3.15.0 / iOS 4.16.0 / Android 4.16.0. Documentation-only; no
222
+ behaviour change.
223
+
224
+ The doc comment on `onDeviceTakeover` enumerated where the listener fires and
225
+ **omitted the Game Center and Play Games paths — the only ones that DELETE an
226
+ account.** It does fire there, so this was purely a doc defect, but an
227
+ integrator reading that list would reasonably skip the one handler those paths
228
+ most need. A CI guard now asserts each SDK's doc block names its gaming path.
229
+
230
+ ## 1.30.0 — credential-collision policy + anonymous link promotion
231
+
232
+ Parity: web 3.14.0 / iOS 4.15.0 / Android 4.15.0.
233
+
234
+ - **`onCredentialInUse: "adopt" | "reject"`** on `signInWithGameCenter` /
235
+ `signInWithPlayGames` / `loginSocial`. Omitted = `adopt` = every prior
236
+ release. On a collision the sign-in ADOPTS the owning account and hard-deletes
237
+ this device's anonymous row — a 200, so neither the 1.28.0 wipe-ordering fix
238
+ nor "a failed sign-in costs nothing" ever covered it. `"reject"` throws
239
+ `CredentialInUseError` and leaves the session byte-identical. Note `link:
240
+ true` does NOT help: the collision branch ignores it.
241
+ - **`CredentialInUseError`** gained `.kind` / `.retryable` / `.code` /
242
+ `.status` plus `.provider` and `.collision`. Deliberately still not an
243
+ `AuthError` subclass — widening `instanceof` would silently re-route an
244
+ upgrading app.
245
+ - **Linking from an ANONYMOUS session now works and rotates the session.**
246
+ `linkGameCenter` / `linkPlayGames` / `linkSocial` were gated to identified
247
+ accounts; the backend now promotes an anonymous caller in place (sub
248
+ preserved) and returns rotated tokens, which the SDK installs — not optional,
249
+ since the old refresh token is revoked server-side.
250
+
251
+ ## 1.29.0 — token refresh actually works again + the remaining loss routes
252
+
253
+ Parity: web 3.13.0 / iOS 4.14.0 / Android 4.14.0.
254
+
255
+ - **⚠ `/token/refresh` response shape (CRITICAL, pre-existing).** The rotated
256
+ trio lives under `data.tokens`; **web, iOS and Android all read `data.*`** —
257
+ the flat shape the backend stopped sending in s58.76. Reading the wrong level
258
+ fails silently: refresh returns null forever, the session dies at
259
+ access-token expiry, and the app mints a fresh guest. A shared
260
+ `readRotatedTokens()` now accepts either level.
261
+ - **A generic 401 no longer wipes the session.** `isDeadRefreshError` accepted
262
+ `UNAUTHORIZED`/`HTTP_401` as a catch-all — which is exactly what the API-key
263
+ middleware returns for a rotated publishable key, so **rotating a `pk_` key
264
+ would have wiped every install's anonymous account at once.** Narrowed to
265
+ `INVALID_REFRESH_TOKEN`.
266
+ - **A corrupt user blob is now recoverable** — the refresh adopts the `user`
267
+ the backend returns, but only when there is none.
268
+ - **The refresh race, both directions** — a response landing after `signOut()`
269
+ no longer resurrects the session, and a stale rejection no longer wipes a
270
+ newer one. Both gate on `tokenStillCurrent(sent)`.
271
+ - **`Sendora.reset()` went through `auth.signOut()`** instead of clearing auth
272
+ keys behind `Auth`'s back.
273
+ - **`signIn` now carries `status` + `retryAfterSeconds`** — it is the only
274
+ route that produces `ACCOUNT_LOCKED`, so the cool-off countdown was
275
+ indistinguishable from a permanent lock.
276
+
277
+ ## 1.28.0 — a failed sign-in no longer destroys the account
278
+
279
+ **Fixed (HIGH — silent, permanent data loss).** `loginSocial` (and every
280
+ `signInWith*` wrapper), `signInWithGameCenter`, `signInWithPlayGames`,
281
+ `verifyMagicLink`, `verifyEmailOtp` and `consumeSsoFragment` cleared the local
282
+ session — *including the refresh token* — before their network call, and
283
+ restored nothing on failure. For an anonymous user that token is the only
284
+ durable handle on the account, so a failed attempt orphaned the account
285
+ permanently; with no connectivity it happened every time. `consumeSsoFragment`
286
+ wiped before even parsing the callback, so cancelling at the identity provider
287
+ also cost the account. All six now clear the old session only after the
288
+ response validates. **A failed auth attempt now leaves `getUserSync()` and the
289
+ stored refresh token exactly as they were.**
290
+
291
+ **Fixed.** A `429` from the background token refresh was treated as a
292
+ permanently dead refresh token and wiped the session; rate limits are transient
293
+ and now take the normal backoff path. A corrupt cached-user blob no longer
294
+ deletes the still-valid refresh token alongside it.
295
+
296
+ **Added.** `AuthError.kind` / `.retryable` / `.retryAfterSeconds` / `.status` —
297
+ a closed failure taxonomy (`network`, `server`, `rate_limited`,
298
+ `invalid_credential`, `account_locked`, `credential_in_use`,
299
+ `already_identified`, `cancelled`, `config`, `unknown`) to branch on instead of
300
+ matching raw codes. Existing `code` values are unchanged.
301
+
302
+ **Added.** `auth.onAuthStateChanged(listener)` — one stream for `signed_in`,
303
+ `signed_out` (`reason: "user" | "session_expired" | "account_deleted"`),
304
+ `device_takeover` and `deletion_cancelled`. A session that dies in the
305
+ background now reports `session_expired` instead of vanishing silently.
306
+ Replays the current state on subscribe. `onDeviceTakeover` /
307
+ `onDeletionCancelled` are unaffected.
308
+
309
+ ## 1.26.0 — `onDeletionCancelled` (account-restore signal)
310
+
311
+ New `auth.onDeletionCancelled(listener)` + `auth.getLastDeletionCancelled()` —
312
+ fires when a sign-in **cancelled a pending self-service account deletion** within
313
+ its grace window (the account is restored, same user_id). Mirrors
314
+ `onDeviceTakeover`: dispatched centrally from `persist()` reading a new
315
+ `reactivatedFromDeletion` flag the backend now returns on **every** sign-in
316
+ response (was password-login-only). Use it to show "your account deletion was
317
+ cancelled" + reconcile local state. Pairs with the new server-side
318
+ `auth.deletion_cancelled` / `auth.deletion_scheduled` webhooks (s58.269).
319
+ Additive, SDK-only (not in the golden wire contract).
320
+
321
+ ## 1.25.0 — identity linking on an identified session (ADR-030) + signUp() fix
322
+
323
+ **New: link a SECOND credential to an already-signed-in account, preserving the
324
+ same user id (sub)** — the cross-platform account-unification primitive. Unlike
325
+ `signUp()` / `loginSocial({link})` (which only preserve the sub from an
326
+ *anonymous* session), these work on an already-identified account:
327
+
328
+ - `auth.linkEmailPassword(email, password)` — attach email + password.
329
+ - `auth.linkGoogle({ idToken | code, redirectUri })` / `auth.linkApple({ … })` /
330
+ `auth.linkSocial({ provider, … })` — attach an OAuth identity.
331
+ - `auth.linkGameCenter(payload?)` / `auth.linkPlayGames(payload?)` — attach a
332
+ gaming identity (no-arg drives the native fetch via the gaming companion).
333
+
334
+ All require a signed-in session (Bearer), preserve the sub, do NOT rotate tokens,
335
+ and refresh the cached user in place. Collision (the credential already belongs to
336
+ another account) throws the new **`CredentialInUseError`** — Sendora never
337
+ auto-merges two real accounts. Use to make one account reachable across platforms
338
+ (e.g. a Game Center player on iOS links email/Google, then signs in on Android to
339
+ the SAME sub).
340
+
341
+ **Fix: `signUp()` on an already-identified session** now throws the new
342
+ **`AlreadyIdentifiedError`** instead of a misleading `EmailAlreadyTakenError`, and
343
+ no longer wipes the session or mints a duplicate account. (The backend now returns
344
+ a distinct `NOT_ANONYMOUS` code, ADR-030 §4.)
345
+
346
+ Additive + SDK-only surface (not in the golden wire contract); no frozen storage
347
+ key / header / route touched. Verified via `test/link.test.ts` (18 assertions) +
348
+ `tsc` + built `dist`.
349
+
350
+ ## 1.24.0 — `AuthUser.signupMethod` + `.lastLoginMethod`
351
+
352
+ The `AuthUser` object now carries two optional read-only fields: `signupMethod`
353
+ (how the account was first created, immutable) and `lastLoginMethod` (most recent
354
+ authentication). Free-form provider tokens: `password | anonymous | google |
355
+ apple | github | microsoft | linkedin | facebook | discord | gamecenter |
356
+ playgames | magic_link | email_otp | passkey | oidc | saml | scim | import`.
357
+ Populated by the backend on the login/signup/social/game response (s58.266, mig
358
+ 0094); flow through `persist()` unchanged, so `getUserSync()` / `getCurrentUser()`
359
+ expose them with **zero new plumbing**. Display-only (UI / routing) — never an
360
+ authorization signal. **Additive + tolerant**: `undefined` against a pre-s58.266
361
+ backend, `null` for an end-user row created before that backend shipped
362
+ (backfilled on their next sign-in); `isAuthApiResponse` type-checks them only when
363
+ present. No frozen storage key / header / wire-shape changed (ADR-023) — installed
364
+ apps on ≤1.23.1 keep working; upgrading is opt-in.
365
+
366
+ ## 1.23.1 — config plugin: fix Expo SDK 54 `expo prebuild` break (ESM→CJS import)
367
+
368
+ `app.plugin.js` now **default-imports** `@expo/config-plugins` and destructures at
369
+ runtime instead of a NAMED import. The named form (`import { …, AndroidConfig, … }`)
370
+ relied on Node's cjs-module-lexer statically surfacing every member of the
371
+ CommonJS package — under config-plugins 54 (Expo SDK 54) `AndroidConfig` is not,
372
+ so the import threw `SyntaxError: does not provide an export named 'AndroidConfig'`
373
+ at load time and aborted `expo prebuild`. Default-import is version-agnostic (the
374
+ default export is the CJS `module.exports`). **No behavior change** — identical
375
+ transforms, JS/native runtime untouched; only the deep-link config plugin's load
376
+ path is fixed. Regression-guarded in `test/plugin.test.ts` (rejects the named
377
+ form). Verified against the real `@expo/config-plugins` (named throws, default
378
+ resolves `AndroidConfig.Manifest.getMainApplicationOrThrow`). Reported by a
379
+ customer on Expo SDK 54; the companion `@sendoracloud/sdk-react-native-gaming`
380
+ 1.0.1 carries the same fix.
381
+
382
+ ## 1.23.0 — Game Center / Play Games no-arg sign-in (native in a companion package)
383
+
384
+ Adds the JS so 1.21.0's gaming sign-in can fetch the provider payload itself — no
385
+ app-supplied GameKit / Play Games code:
386
+
387
+ - **`auth.signInWithGameCenter()`** (no args) drives Game Center natively and posts
388
+ the identity payload.
389
+ - **`auth.signInWithPlayGames()`** (no args) drives Play Games v2 and posts the
390
+ `serverAuthCode`.
391
+
392
+ The native code (GameKit Swift / Play Games Kotlin + the gaming config plugin)
393
+ lives in an **optional companion package, `@sendoracloud/sdk-react-native-gaming`**
394
+ — so **non-gaming apps don't pull the Android `play-services-games-v2`
395
+ dependency**. Install the companion + its config plugin only for gaming sign-in.
396
+
397
+ This SDK stays pure JS. `src/native.ts` resolves the companion's native module
398
+ zero-dependency off `globalThis.expo.modules.SendoraGameAuth` (Metro-safe, CLAUDE
399
+ rule #1 — no expo import). The no-arg call is **optional at runtime**: without the
400
+ companion (or in Expo Go / bare RN) it throws a clear "install
401
+ @sendoracloud/sdk-react-native-gaming + Dev Client" error, and the
402
+ **explicit-payload overloads from 1.21.0 still work everywhere** —
403
+ `signInWithGameCenter(payload)` / `signInWithPlayGames({ serverAuthCode })`
404
+ unchanged and additive. Overload dispatch keys on `"publicKeyUrl" in arg` /
405
+ `"serverAuthCode" in arg`; the no-arg native fetch runs INSIDE the serialized op
406
+ (a cancelled sheet leaves the session untouched).
407
+
408
+ The 1.22.0 deep-link config plugin (`app.plugin.js`, `{ linkSubdomain }`) is
409
+ unchanged — gaming config moved out to the companion's own plugin.
410
+
411
+ Additive; no frozen storage key / header / wire-shape changed. JS overload +
412
+ native-fetch logic unit-tested (`test/game-auth.test.ts`). **On-device smoke (real
413
+ Game Center + Play Games accounts, via the companion) is the release gate.**
414
+
415
+ ## 1.22.0 — Expo config plugin (auto-wire deep-link associated domains)
416
+
417
+ Ships an Expo config plugin at the package root (`app.plugin.js`) so apps don't
418
+ hand-edit iOS entitlements / Android manifests for deep links. In app.json:
419
+ `["@sendoracloud/sdk-react-native", { "linkSubdomain": "wordhurdle" }]` →
420
+ `applinks:wordhurdle.sendoracloud.app` (iOS associated-domains) + an autoVerify
421
+ App Links intent-filter (Android). `linkDomains: [...]` adds BYO/custom hosts
422
+ (combines with `linkSubdomain`). **Opt-in + build-time** (runs at
423
+ `expo prebuild`) — never touches an already-built/installed app; both platforms
424
+ **MERGE + dedupe** (no clobber of existing domains; `createRunOncePlugin` guards
425
+ double-apply). ESM plugin → Expo SDK 50+. Pure transforms unit-tested
426
+ (`test/plugin.test.ts`). Additive; no runtime SDK / frozen-contract change.
427
+
428
+ ## 1.21.0 — Game Center + Play Games sign-in
429
+
430
+ `auth.signInWithGameCenter(payload, { link })` (Apple, iOS) and
431
+ `auth.signInWithPlayGames({ serverAuthCode }, { link })` (Google, Android) —
432
+ email-less, player-keyed sign-in. Forward the native provider payload (obtain it
433
+ via a GameKit / Play Games native module or the Sendora Expo plugin) to the new
434
+ `/auth-service/login/{game-center,play-games}` endpoints. Shares the anon
435
+ device-takeover + ADR-025 `link` (link-in-place, same user id preserved)
436
+ semantics with `loginSocial`. Additive; no frozen storage key / header /
437
+ wire-shape changed (`linkAnonymous`/`prevAnonRefreshToken` are optional backend
438
+ fields, not in the golden contract).
439
+
440
+ ## 1.20.0 — anon→social link-in-place (ADR-025)
441
+
442
+ `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`.
443
+
444
+ ## 1.19.0 — `getUserSync()` + `isReady()`
445
+
446
+ 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.
447
+
448
+ ## 1.18.0 — opt-in encrypted-at-rest storage (`secureStorage` adapter)
449
+
450
+ 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.
451
+
452
+ ## 1.17.0 — `getAnonymousIdSync()`
453
+
454
+ 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()`.
455
+
456
+ ## 1.16.0 — device app version + native build number
457
+
458
+ `device.appVersion` + native build number attached to the device context (surfaces on the dashboard Audience page).
459
+
460
+ ## 1.15.0 — appVersion for modern Expo (New Architecture)
461
+
462
+ 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.
463
+
464
+ ## 1.14.0 — ADR-023 SDK compatibility headers + schema marker
465
+
466
+ `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.
467
+
468
+ ## 1.13.0 — Expo New-Architecture device read (appVersion + model)
469
+
470
+ 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.
471
+
472
+ ## 1.12.0 — appVersion auto-reads Expo Constants
473
+
474
+ `device.appVersion` auto-detects on Expo via `NativeModules.ExponentConstants` with zero new dependency (superseded by 1.13.0/1.15.0 for New-Arch).
475
+
476
+ ## 1.11.0 — top-level `sessionId` on events
477
+
478
+ Events now carry a top-level `sessionId` (not just nested in properties), powering the Audience "Sessions & engagement" metrics for RN. iOS/Android parity.
479
+
480
+ ## 1.10.0 — appVersion auto-detect (Metro-safe)
481
+
482
+ `device.appVersion` resolves from config → `EXPO_PUBLIC_APP_VERSION` → global → "". Deliberately no `require()` of expo-constants / device-info (Metro-safe).
483
+
484
+ ## 1.9.0 — device/tech context (ADR-022)
485
+
486
+ Every event's `context.device` block: `{ type, os, osVersion, model, appVersion }`, built from RN's `Platform`. Powers the dashboard Analytics → Audience page.
487
+
488
+ ## 1.8.2 — `getAccessToken()` never returns an expired token
489
+
490
+ 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.
491
+
492
+ ## 1.8.1 — refresh-before-delete
493
+
494
+ `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.
495
+
496
+ ## 1.7.0 — self-service account deletion
497
+
498
+ `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.
499
+
500
+ ## 1.6.2 — MFA-from-anonymous device-takeover fix (audit s58.203 follow-up)
501
+
502
+ Fixes lost device-takeover when an MFA-enabled user signs in from an anonymous device:
503
+ - `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**.
504
+ - 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.
505
+
506
+ No public API change.
507
+
508
+ ## 1.6.1 — s58.203 audit P0: dead namespaces + anonymous push fixed
509
+
510
+ Two production-breaking fixes:
511
+ - **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.
512
+ - **`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`).
513
+
514
+ No public API change. Upgrade required for any app using those namespaces or registering push before sign-in.
515
+
516
+ ## 1.5.0 — Wave 73 unread support count
517
+
518
+ 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`.
519
+
520
+ Recommended cadence: poll on AppState `active` + every 60s while the app is active.
521
+
522
+ ```ts
523
+ useEffect(() => {
524
+ let cancelled = false;
525
+ const tick = async () => {
526
+ const n = await SendoraCloud.support.getUnreadCount("widget-uuid");
527
+ if (!cancelled) setBadge(n);
528
+ };
529
+ void tick();
530
+ const id = setInterval(tick, 60_000);
531
+ return () => { cancelled = true; clearInterval(id); };
532
+ }, []);
533
+ ```
534
+
535
+ ## 1.4.0 — Wave 69 in-app ticket history
536
+
537
+ 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.
538
+
539
+ Backend (chatbot module):
540
+ - `GET /widgets/:widgetId/my-tickets` — list (Bearer JWT auth).
541
+ - `GET /widgets/:widgetId/my-tickets/:ticketId` — single ticket + replies.
542
+ - `POST /widgets/:widgetId/my-tickets/:ticketId/replies` — customer reply. Auto-reopens resolved/closed tickets. Emits `support.in_app_reply` event.
543
+
544
+ JWT is passed in the URL hash fragment and scrubbed on load — never appears in server logs or referer.
545
+
546
+ ```tsx
547
+ import SendoraCloud from "@sendoracloud/sdk-react-native";
548
+ import { useTicketHistory } from "@sendoracloud/sdk-react-native/contact-widget";
549
+
550
+ const history = useTicketHistory(
551
+ "widget-uuid",
552
+ () => SendoraCloud.auth.getAccessToken(),
553
+ );
554
+
555
+ <Button title="My tickets" onPress={history.open} />
556
+ <history.View />
557
+ ```
558
+
559
+ 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.
560
+
561
+ ## 1.3.0 — Wave 68 contact widget identity handling
562
+
563
+ `<ContactWidget />` + `useContactWidget()` now accept:
564
+
565
+ - `prefillUserId` — Sendora `userId` (anon or identified) that gets posted with the ticket so it stitches to the same profile as analytics events.
566
+ - `lockEmail` — when true + `prefillEmail` is set, the email field is hidden + read-only.
567
+
568
+ `useContactWidget(widgetId, { user })` now accepts the SDK's `AuthUser` shorthand and auto-resolves prefill:
569
+
570
+ - identified user (`emailVerified && !isAnonymous`) → email locked + userId attached.
571
+ - anonymous SDK row → userId attached, email field shown for entry.
572
+ - no user → standard form.
573
+
574
+ 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.
575
+
576
+ ```tsx
577
+ import SendoraCloud from "@sendoracloud/sdk-react-native";
578
+ import { useContactWidget } from "@sendoracloud/sdk-react-native/contact-widget";
579
+
580
+ const user = SendoraCloud.auth.getCurrentUser();
581
+ const contact = useContactWidget("widget-uuid", { user });
582
+ ```
583
+
584
+ ## 1.2.0 — Wave 66 mobile Contact Widget
585
+
586
+ 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.
587
+
588
+ 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`.
589
+
590
+ `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.
591
+
592
+ **Mount**:
593
+ ```tsx
594
+ import { useContactWidget } from "@sendoracloud/sdk-react-native/contact-widget";
595
+
596
+ const contact = useContactWidget("widget-uuid", { theme: "auto" });
597
+
598
+ <Button title="Contact us" onPress={contact.open} />
599
+ <contact.View />
600
+ ```
601
+
602
+ Single source of truth = the worker route. UI / theme / copy updates = redeploy worker. Zero SDK release.
603
+
604
+ ## 1.1.0
605
+
606
+ **Cold-launch hang fix — architectural.**
607
+
608
+ 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.
609
+
610
+ **5-layer fix** (every layer below holds even if layers above misfire):
611
+
612
+ 1. **`http.ts` — AbortController + per-call timeout on every fetch.**
613
+ 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.
614
+
615
+ 2. **`auth.getAccessToken()` — stale-while-refreshing.**
616
+ 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.
617
+
618
+ 3. **`auth.refreshAccessToken()` — exponential backoff on transient failures.**
619
+ `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.
620
+
621
+ 4. **AppState foreground — reset refresh state.**
622
+ 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.
623
+
624
+ 5. **Permanent vs transient distinction.**
625
+ 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.
626
+
627
+ **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.
628
+
629
+ **Breaking changes:** None. `NetworkTimeoutError` is a new exported class; existing `instanceof Error` checks still work.
630
+
631
+ Closes Pulse News dogfooding bug report. Operator carry-over: npm publish + tag `sdk-react-native-v1.1.0`.
632
+
633
+ ## 1.0.0
634
+
635
+ **Breaking change.** Backend (s58.104) now exposes unprefixed alias routes and
636
+ resolves `orgId` from the API key server-side.
637
+
638
+ - **Removed** `orgId` from `SendoraConfig`. Backend derives the org from the
639
+ apiKey row server-side.
640
+ - Outgoing payloads (`/api/v1/events`, `/api/v1/push/tokens`, …) no longer
641
+ include the `orgId` field.
642
+ - `publicKey` remains a deprecated alias for `apiKey`.
643
+
644
+ ### Migration
645
+
646
+ Drop `orgId` from your `SendoraCloud.init({...})` call:
647
+
648
+ ```diff
649
+ await SendoraCloud.init({
650
+ apiKey: "pk_prod_...",
651
+ - orgId: "<ORG_ID>",
652
+ });
653
+ ```
654
+
655
+ All public method signatures (`identify`, `track`, `screen`,
656
+ `registerPushToken`, `links.*`, `auth.*`, `attribution.*`, push helpers, …)
657
+ are unchanged.
658
+
659
+ ## 0.20.x and earlier
660
+
661
+ See git history at https://github.com/sendoracloud/sdk-react-native/commits/main
package/README.md CHANGED
@@ -245,7 +245,13 @@ Full reference + edge-case writeup: [/docs/quickstart-react-native#anon-existing
245
245
 
246
246
  ## Stability
247
247
 
248
- This SDK is on the **1.x line**. Patch bumps (1.x.y → 1.x.z) are backwards-compatible; minor bumps may include opt-in feature additions. Breaking changes only on major bumps — always read the [CHANGELOG](./CHANGELOG.md) before upgrading.
248
+ This SDK is on the **1.x line**. Patch bumps (1.x.y → 1.x.z) are backwards-compatible; minor bumps may include opt-in feature additions. Breaking changes only on major bumps — always read `CHANGELOG.md`, which ships inside the package (`node_modules/@sendoracloud/sdk-react-native/CHANGELOG.md`), before upgrading.
249
+
250
+ ### Auth state is a transition stream, not a health signal
251
+
252
+ `auth.onAuthStateChanged()` reports confirmed transitions only — `signed_in`, `signed_out` (reason `user` | `session_expired` | `account_deleted`), `device_takeover`, `deletion_cancelled`. A transient failure of the background token refresh (offline, timeout, HTTP 429, 5xx) emits **nothing**: the stored refresh token is kept and the refresh retried with backoff, so no state changed. "No event" means "no confirmed transition", never "the session is fine". Only a server-rejected refresh token produces `signed_out(session_expired)`. For liveness, read `getAccessToken()` — `null` means no usable token right now.
253
+
254
+ `signInAnonymously()` returns the **current** session when one exists, anonymous or identified — it never replaces a signed-in account with a guest. Pass `forceNew: true` to deliberately abandon whatever session is current and mint a fresh anonymous one.
249
255
 
250
256
  ## JWT verification (custom backend)
251
257
 
package/dist/index.cjs CHANGED
@@ -228,7 +228,7 @@ var Storage = class {
228
228
  // package.json
229
229
  var package_default = {
230
230
  name: "@sendoracloud/sdk-react-native",
231
- version: "1.39.1",
231
+ version: "1.40.0",
232
232
  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`).",
233
233
  type: "module",
234
234
  main: "./dist/index.cjs",
@@ -250,6 +250,7 @@ var package_default = {
250
250
  "dist",
251
251
  "examples",
252
252
  "README.md",
253
+ "CHANGELOG.md",
253
254
  "LICENSE",
254
255
  "app.plugin.js",
255
256
  "plugin"
@@ -284,7 +285,7 @@ var package_default = {
284
285
  scripts: {
285
286
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
286
287
  typecheck: "tsc --noEmit",
287
- 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 && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts"
288
+ 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 && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts && tsx test/anonymous-over-account.test.ts"
288
289
  },
289
290
  devDependencies: {
290
291
  "@types/react": "^19.2.16",
@@ -295,10 +296,6 @@ var package_default = {
295
296
  typescript: "^5.0.0"
296
297
  },
297
298
  license: "MIT",
298
- repository: {
299
- type: "git",
300
- url: "https://github.com/sendoracloud/sdk-react-native"
301
- },
302
299
  homepage: "https://sendoracloud.com/sdks"
303
300
  };
304
301
 
@@ -798,6 +795,15 @@ var Auth = class {
798
795
  * A failed sign-in emits NOTHING: the session is unchanged, so there is no
799
796
  * transition to report — the rejected promise is the whole story.
800
797
  *
798
+ * ⚠ This is a TRANSITION stream, not a health or connectivity signal (1.40.0
799
+ * doc fix, reported by RopoLabs). A transient failure of the background
800
+ * token refresh — offline, timeout, HTTP 429, 5xx, an unparseable response —
801
+ * emits nothing: the stored refresh token is kept and the refresh retried
802
+ * with backoff, so no state changed. "No event" therefore means "no
803
+ * confirmed transition", never "the session is fine". Only a server-REJECTED
804
+ * refresh token produces `signed_out(session_expired)`. For liveness read
805
+ * `getAccessToken()` (null = no usable token right now).
806
+ *
801
807
  * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
802
808
  * and never affects auth state. Returns an unsubscribe function.
803
809
  */
@@ -1010,24 +1016,41 @@ var Auth = class {
1010
1016
  return this.accessToken;
1011
1017
  }
1012
1018
  /**
1013
- * Mint (or return) an anonymous session.
1014
- *
1015
- * Reuses the existing anonymous user when there already is one — the same
1016
- * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
1017
- * that calls this defensively on every cold launch (the most natural thing
1018
- * to write) mints a fresh `user_id` each time, and `persist()` overwrites
1019
- * the stored refresh token — the previous anonymous account's only durable
1020
- * handle — with no takeover, no webhook and no state event. That is the same
1021
- * silent progress loss as a failed sign-in wiping the session, except it
1022
- * happens on a *healthy* network.
1023
- *
1024
- * Pass `forceNew: true` to deliberately abandon the current anonymous
1025
- * session and mint a separate one.
1019
+ * Return the current session, minting an anonymous one only when there is
1020
+ * none.
1021
+ *
1022
+ * Reuses the existing session when there already is one — anonymous OR
1023
+ * identified. The anonymous half is the same short-circuit Firebase's
1024
+ * `signInAnonymously` performs. Without it, an app that calls this
1025
+ * defensively on every cold launch (the most natural thing to write) mints a
1026
+ * fresh `user_id` each time, and `persist()` overwrites the stored refresh
1027
+ * token — the previous anonymous account's only durable handle — with no
1028
+ * takeover, no webhook and no state event. That is the same silent progress
1029
+ * loss as a failed sign-in wiping the session, except it happens on a
1030
+ * *healthy* network.
1031
+ *
1032
+ * ⚠ The identified half closes the same hole from the other side (1.40.0,
1033
+ * reported by RopoLabs). Until then the guard was `existing.isAnonymous`, so
1034
+ * a plain `signInAnonymously()` over a LIVE ACCOUNT session fell through,
1035
+ * minted a guest and overwrote the account's tokens locally — with no
1036
+ * `signed_out`, because this path never wipes anything; it persists over the
1037
+ * top. An app that called it on a "looks signed out" reading silently turned
1038
+ * a paying account into a fresh guest, and its own session-death listener
1039
+ * never fired. The account itself survives (row + server session untouched,
1040
+ * recoverable by its credential) but the device is now a stranger to it.
1041
+ * Returning the identified session is a no-op for that caller and the honest
1042
+ * answer to "make sure I have a session" — read `user.isAnonymous` if you
1043
+ * need to know which kind you got.
1044
+ *
1045
+ * Pass `forceNew: true` to deliberately abandon the current session —
1046
+ * whichever kind — and mint a separate anonymous one. That is the ONLY way
1047
+ * this method replaces an identified session, and it does so silently (the
1048
+ * caller asked for it); `signOut()` first if you want a `signed_out`.
1026
1049
  */
1027
1050
  signInAnonymously(opts) {
1028
1051
  return this.serialize(async () => {
1029
1052
  const existing = this.user;
1030
- if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
1053
+ if (!opts?.forceNew && existing && this.hooks.storage.get(REFRESH_KEY)) {
1031
1054
  return existing;
1032
1055
  }
1033
1056
  const data = await this.callAuth("/api/v1/auth-service/anonymous", {
@@ -2924,11 +2947,15 @@ var EventQueue = class {
2924
2947
  }, ms);
2925
2948
  }
2926
2949
  /**
2927
- * Drain the queue FIFO. Removes an event only when the server ACCEPTED it (2xx)
2928
- * or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it — and stops
2929
- * draining — on a transient failure (timeout throw, network null, 408/429/5xx),
2930
- * then arms a capped-exponential-backoff retry. The entire post lives inside the
2931
- * try/catch, so `NetworkTimeoutError` can never surface as an unhandled rejection.
2950
+ * Drain the queue in order. Removes an event only when the server ACCEPTED it
2951
+ * (2xx) or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it —
2952
+ * and stops draining — on a transient failure (timeout throw, network null,
2953
+ * 408/429/5xx), then arms a capped-exponential-backoff retry. ⚠ Since 1.39.1 a
2954
+ * transiently-failing HEAD is first rotated to the BACK, so one poison event
2955
+ * cannot hold everything behind it; delivery order is therefore best-effort,
2956
+ * not strict FIFO (identity travels in each event body, not by slot). The
2957
+ * entire post lives inside the try/catch, so `NetworkTimeoutError` can never
2958
+ * surface as an unhandled rejection.
2932
2959
  */
2933
2960
  async flush() {
2934
2961
  if (this.flushing || this.q.length === 0) return;
package/dist/index.d.cts CHANGED
@@ -716,6 +716,15 @@ declare class Auth {
716
716
  * A failed sign-in emits NOTHING: the session is unchanged, so there is no
717
717
  * transition to report — the rejected promise is the whole story.
718
718
  *
719
+ * ⚠ This is a TRANSITION stream, not a health or connectivity signal (1.40.0
720
+ * doc fix, reported by RopoLabs). A transient failure of the background
721
+ * token refresh — offline, timeout, HTTP 429, 5xx, an unparseable response —
722
+ * emits nothing: the stored refresh token is kept and the refresh retried
723
+ * with backoff, so no state changed. "No event" therefore means "no
724
+ * confirmed transition", never "the session is fine". Only a server-REJECTED
725
+ * refresh token produces `signed_out(session_expired)`. For liveness read
726
+ * `getAccessToken()` (null = no usable token right now).
727
+ *
719
728
  * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
720
729
  * and never affects auth state. Returns an unsubscribe function.
721
730
  */
@@ -850,19 +859,36 @@ declare class Auth {
850
859
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
851
860
  getAccessTokenSync(): string | null;
852
861
  /**
853
- * Mint (or return) an anonymous session.
854
- *
855
- * Reuses the existing anonymous user when there already is one — the same
856
- * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
857
- * that calls this defensively on every cold launch (the most natural thing
858
- * to write) mints a fresh `user_id` each time, and `persist()` overwrites
859
- * the stored refresh token — the previous anonymous account's only durable
860
- * handle — with no takeover, no webhook and no state event. That is the same
861
- * silent progress loss as a failed sign-in wiping the session, except it
862
- * happens on a *healthy* network.
863
- *
864
- * Pass `forceNew: true` to deliberately abandon the current anonymous
865
- * session and mint a separate one.
862
+ * Return the current session, minting an anonymous one only when there is
863
+ * none.
864
+ *
865
+ * Reuses the existing session when there already is one — anonymous OR
866
+ * identified. The anonymous half is the same short-circuit Firebase's
867
+ * `signInAnonymously` performs. Without it, an app that calls this
868
+ * defensively on every cold launch (the most natural thing to write) mints a
869
+ * fresh `user_id` each time, and `persist()` overwrites the stored refresh
870
+ * token — the previous anonymous account's only durable handle — with no
871
+ * takeover, no webhook and no state event. That is the same silent progress
872
+ * loss as a failed sign-in wiping the session, except it happens on a
873
+ * *healthy* network.
874
+ *
875
+ * ⚠ The identified half closes the same hole from the other side (1.40.0,
876
+ * reported by RopoLabs). Until then the guard was `existing.isAnonymous`, so
877
+ * a plain `signInAnonymously()` over a LIVE ACCOUNT session fell through,
878
+ * minted a guest and overwrote the account's tokens locally — with no
879
+ * `signed_out`, because this path never wipes anything; it persists over the
880
+ * top. An app that called it on a "looks signed out" reading silently turned
881
+ * a paying account into a fresh guest, and its own session-death listener
882
+ * never fired. The account itself survives (row + server session untouched,
883
+ * recoverable by its credential) but the device is now a stranger to it.
884
+ * Returning the identified session is a no-op for that caller and the honest
885
+ * answer to "make sure I have a session" — read `user.isAnonymous` if you
886
+ * need to know which kind you got.
887
+ *
888
+ * Pass `forceNew: true` to deliberately abandon the current session —
889
+ * whichever kind — and mint a separate anonymous one. That is the ONLY way
890
+ * this method replaces an identified session, and it does so silently (the
891
+ * caller asked for it); `signOut()` first if you want a `signed_out`.
866
892
  */
867
893
  signInAnonymously(opts?: {
868
894
  name?: string;
package/dist/index.d.ts CHANGED
@@ -716,6 +716,15 @@ declare class Auth {
716
716
  * A failed sign-in emits NOTHING: the session is unchanged, so there is no
717
717
  * transition to report — the rejected promise is the whole story.
718
718
  *
719
+ * ⚠ This is a TRANSITION stream, not a health or connectivity signal (1.40.0
720
+ * doc fix, reported by RopoLabs). A transient failure of the background
721
+ * token refresh — offline, timeout, HTTP 429, 5xx, an unparseable response —
722
+ * emits nothing: the stored refresh token is kept and the refresh retried
723
+ * with backoff, so no state changed. "No event" therefore means "no
724
+ * confirmed transition", never "the session is fine". Only a server-REJECTED
725
+ * refresh token produces `signed_out(session_expired)`. For liveness read
726
+ * `getAccessToken()` (null = no usable token right now).
727
+ *
719
728
  * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
720
729
  * and never affects auth state. Returns an unsubscribe function.
721
730
  */
@@ -850,19 +859,36 @@ declare class Auth {
850
859
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
851
860
  getAccessTokenSync(): string | null;
852
861
  /**
853
- * Mint (or return) an anonymous session.
854
- *
855
- * Reuses the existing anonymous user when there already is one — the same
856
- * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
857
- * that calls this defensively on every cold launch (the most natural thing
858
- * to write) mints a fresh `user_id` each time, and `persist()` overwrites
859
- * the stored refresh token — the previous anonymous account's only durable
860
- * handle — with no takeover, no webhook and no state event. That is the same
861
- * silent progress loss as a failed sign-in wiping the session, except it
862
- * happens on a *healthy* network.
863
- *
864
- * Pass `forceNew: true` to deliberately abandon the current anonymous
865
- * session and mint a separate one.
862
+ * Return the current session, minting an anonymous one only when there is
863
+ * none.
864
+ *
865
+ * Reuses the existing session when there already is one — anonymous OR
866
+ * identified. The anonymous half is the same short-circuit Firebase's
867
+ * `signInAnonymously` performs. Without it, an app that calls this
868
+ * defensively on every cold launch (the most natural thing to write) mints a
869
+ * fresh `user_id` each time, and `persist()` overwrites the stored refresh
870
+ * token — the previous anonymous account's only durable handle — with no
871
+ * takeover, no webhook and no state event. That is the same silent progress
872
+ * loss as a failed sign-in wiping the session, except it happens on a
873
+ * *healthy* network.
874
+ *
875
+ * ⚠ The identified half closes the same hole from the other side (1.40.0,
876
+ * reported by RopoLabs). Until then the guard was `existing.isAnonymous`, so
877
+ * a plain `signInAnonymously()` over a LIVE ACCOUNT session fell through,
878
+ * minted a guest and overwrote the account's tokens locally — with no
879
+ * `signed_out`, because this path never wipes anything; it persists over the
880
+ * top. An app that called it on a "looks signed out" reading silently turned
881
+ * a paying account into a fresh guest, and its own session-death listener
882
+ * never fired. The account itself survives (row + server session untouched,
883
+ * recoverable by its credential) but the device is now a stranger to it.
884
+ * Returning the identified session is a no-op for that caller and the honest
885
+ * answer to "make sure I have a session" — read `user.isAnonymous` if you
886
+ * need to know which kind you got.
887
+ *
888
+ * Pass `forceNew: true` to deliberately abandon the current session —
889
+ * whichever kind — and mint a separate anonymous one. That is the ONLY way
890
+ * this method replaces an identified session, and it does so silently (the
891
+ * caller asked for it); `signOut()` first if you want a `signed_out`.
866
892
  */
867
893
  signInAnonymously(opts?: {
868
894
  name?: string;
package/dist/index.js CHANGED
@@ -183,7 +183,7 @@ var Storage = class {
183
183
  // package.json
184
184
  var package_default = {
185
185
  name: "@sendoracloud/sdk-react-native",
186
- version: "1.39.1",
186
+ version: "1.40.0",
187
187
  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`).",
188
188
  type: "module",
189
189
  main: "./dist/index.cjs",
@@ -205,6 +205,7 @@ var package_default = {
205
205
  "dist",
206
206
  "examples",
207
207
  "README.md",
208
+ "CHANGELOG.md",
208
209
  "LICENSE",
209
210
  "app.plugin.js",
210
211
  "plugin"
@@ -239,7 +240,7 @@ var package_default = {
239
240
  scripts: {
240
241
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
241
242
  typecheck: "tsc --noEmit",
242
- 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 && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts"
243
+ 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 && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts && tsx test/anonymous-over-account.test.ts"
243
244
  },
244
245
  devDependencies: {
245
246
  "@types/react": "^19.2.16",
@@ -250,10 +251,6 @@ var package_default = {
250
251
  typescript: "^5.0.0"
251
252
  },
252
253
  license: "MIT",
253
- repository: {
254
- type: "git",
255
- url: "https://github.com/sendoracloud/sdk-react-native"
256
- },
257
254
  homepage: "https://sendoracloud.com/sdks"
258
255
  };
259
256
 
@@ -753,6 +750,15 @@ var Auth = class {
753
750
  * A failed sign-in emits NOTHING: the session is unchanged, so there is no
754
751
  * transition to report — the rejected promise is the whole story.
755
752
  *
753
+ * ⚠ This is a TRANSITION stream, not a health or connectivity signal (1.40.0
754
+ * doc fix, reported by RopoLabs). A transient failure of the background
755
+ * token refresh — offline, timeout, HTTP 429, 5xx, an unparseable response —
756
+ * emits nothing: the stored refresh token is kept and the refresh retried
757
+ * with backoff, so no state changed. "No event" therefore means "no
758
+ * confirmed transition", never "the session is fine". Only a server-REJECTED
759
+ * refresh token produces `signed_out(session_expired)`. For liveness read
760
+ * `getAccessToken()` (null = no usable token right now).
761
+ *
756
762
  * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
757
763
  * and never affects auth state. Returns an unsubscribe function.
758
764
  */
@@ -965,24 +971,41 @@ var Auth = class {
965
971
  return this.accessToken;
966
972
  }
967
973
  /**
968
- * Mint (or return) an anonymous session.
969
- *
970
- * Reuses the existing anonymous user when there already is one — the same
971
- * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
972
- * that calls this defensively on every cold launch (the most natural thing
973
- * to write) mints a fresh `user_id` each time, and `persist()` overwrites
974
- * the stored refresh token — the previous anonymous account's only durable
975
- * handle — with no takeover, no webhook and no state event. That is the same
976
- * silent progress loss as a failed sign-in wiping the session, except it
977
- * happens on a *healthy* network.
978
- *
979
- * Pass `forceNew: true` to deliberately abandon the current anonymous
980
- * session and mint a separate one.
974
+ * Return the current session, minting an anonymous one only when there is
975
+ * none.
976
+ *
977
+ * Reuses the existing session when there already is one — anonymous OR
978
+ * identified. The anonymous half is the same short-circuit Firebase's
979
+ * `signInAnonymously` performs. Without it, an app that calls this
980
+ * defensively on every cold launch (the most natural thing to write) mints a
981
+ * fresh `user_id` each time, and `persist()` overwrites the stored refresh
982
+ * token — the previous anonymous account's only durable handle — with no
983
+ * takeover, no webhook and no state event. That is the same silent progress
984
+ * loss as a failed sign-in wiping the session, except it happens on a
985
+ * *healthy* network.
986
+ *
987
+ * ⚠ The identified half closes the same hole from the other side (1.40.0,
988
+ * reported by RopoLabs). Until then the guard was `existing.isAnonymous`, so
989
+ * a plain `signInAnonymously()` over a LIVE ACCOUNT session fell through,
990
+ * minted a guest and overwrote the account's tokens locally — with no
991
+ * `signed_out`, because this path never wipes anything; it persists over the
992
+ * top. An app that called it on a "looks signed out" reading silently turned
993
+ * a paying account into a fresh guest, and its own session-death listener
994
+ * never fired. The account itself survives (row + server session untouched,
995
+ * recoverable by its credential) but the device is now a stranger to it.
996
+ * Returning the identified session is a no-op for that caller and the honest
997
+ * answer to "make sure I have a session" — read `user.isAnonymous` if you
998
+ * need to know which kind you got.
999
+ *
1000
+ * Pass `forceNew: true` to deliberately abandon the current session —
1001
+ * whichever kind — and mint a separate anonymous one. That is the ONLY way
1002
+ * this method replaces an identified session, and it does so silently (the
1003
+ * caller asked for it); `signOut()` first if you want a `signed_out`.
981
1004
  */
982
1005
  signInAnonymously(opts) {
983
1006
  return this.serialize(async () => {
984
1007
  const existing = this.user;
985
- if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
1008
+ if (!opts?.forceNew && existing && this.hooks.storage.get(REFRESH_KEY)) {
986
1009
  return existing;
987
1010
  }
988
1011
  const data = await this.callAuth("/api/v1/auth-service/anonymous", {
@@ -2884,11 +2907,15 @@ var EventQueue = class {
2884
2907
  }, ms);
2885
2908
  }
2886
2909
  /**
2887
- * Drain the queue FIFO. Removes an event only when the server ACCEPTED it (2xx)
2888
- * or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it — and stops
2889
- * draining — on a transient failure (timeout throw, network null, 408/429/5xx),
2890
- * then arms a capped-exponential-backoff retry. The entire post lives inside the
2891
- * try/catch, so `NetworkTimeoutError` can never surface as an unhandled rejection.
2910
+ * Drain the queue in order. Removes an event only when the server ACCEPTED it
2911
+ * (2xx) or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it —
2912
+ * and stops draining — on a transient failure (timeout throw, network null,
2913
+ * 408/429/5xx), then arms a capped-exponential-backoff retry. ⚠ Since 1.39.1 a
2914
+ * transiently-failing HEAD is first rotated to the BACK, so one poison event
2915
+ * cannot hold everything behind it; delivery order is therefore best-effort,
2916
+ * not strict FIFO (identity travels in each event body, not by slot). The
2917
+ * entire post lives inside the try/catch, so `NetworkTimeoutError` can never
2918
+ * surface as an unhandled rejection.
2892
2919
  */
2893
2920
  async flush() {
2894
2921
  if (this.flushing || this.q.length === 0) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.39.1",
3
+ "version": "1.40.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",
@@ -22,6 +22,7 @@
22
22
  "dist",
23
23
  "examples",
24
24
  "README.md",
25
+ "CHANGELOG.md",
25
26
  "LICENSE",
26
27
  "app.plugin.js",
27
28
  "plugin"
@@ -56,7 +57,7 @@
56
57
  "scripts": {
57
58
  "build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
58
59
  "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 && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts"
60
+ "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 && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts && tsx test/anonymous-over-account.test.ts"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@types/react": "^19.2.16",
@@ -67,9 +68,5 @@
67
68
  "typescript": "^5.0.0"
68
69
  },
69
70
  "license": "MIT",
70
- "repository": {
71
- "type": "git",
72
- "url": "https://github.com/sendoracloud/sdk-react-native"
73
- },
74
71
  "homepage": "https://sendoracloud.com/sdks"
75
72
  }