@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 +661 -0
- package/README.md +7 -1
- package/dist/index.cjs +52 -25
- package/dist/index.d.cts +39 -13
- package/dist/index.d.ts +39 -13
- package/dist/index.js +52 -25
- package/package.json +3 -6
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
|
|
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.
|
|
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
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
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
|
|
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
|
|
2928
|
-
* or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it —
|
|
2929
|
-
* draining — on a transient failure (timeout throw, network null,
|
|
2930
|
-
* then arms a capped-exponential-backoff retry.
|
|
2931
|
-
*
|
|
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
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
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
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
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
|
|
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
|
|
2888
|
-
* or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it —
|
|
2889
|
-
* draining — on a transient failure (timeout throw, network null,
|
|
2890
|
-
* then arms a capped-exponential-backoff retry.
|
|
2891
|
-
*
|
|
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.
|
|
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
|
}
|