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