@sendoracloud/sdk-react-native 1.40.0 → 1.41.1
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 +112 -0
- package/README.md +76 -22
- package/dist/index.cjs +2241 -1001
- package/dist/index.d.cts +1012 -552
- package/dist/index.d.ts +1012 -552
- package/dist/index.js +2195 -961
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,117 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.41.1 — a dead session is `session_expired` again; the server's detail moves to `endReason`
|
|
4
|
+
|
|
5
|
+
**Fix to a 1.41.0 behaviour change (ADR-023: a new value is additive only for a NEW situation).**
|
|
6
|
+
A session that died on a server-rejected refresh was reported as `session_expired` through
|
|
7
|
+
1.40.0. 1.41.0 reported the server's own verdict as the reason instead (`revoked` / `deleted`),
|
|
8
|
+
which gave two old situations new names: "account deleted on another device" and "sessions
|
|
9
|
+
revoked". Adding a value to a union is additive for the compiler, not for a listener that
|
|
10
|
+
matched the old value. A listener that acted only on `session_expired` (the documented pattern, and
|
|
11
|
+
the RopoLabs template's session-death handler) silently ignored both, leaving the app on a
|
|
12
|
+
signed-in screen where every request fails. Reported by RopoLabs against the published
|
|
13
|
+
tarball before any production install.
|
|
14
|
+
|
|
15
|
+
- `signed_out.reason` is `session_expired` for EVERY server-rejected refresh, exactly as 1.40.0.
|
|
16
|
+
- New optional `signed_out.endReason`: the server's verdict (`revoked` | `deleted` | `expired` |
|
|
17
|
+
`reuse` | `unknown`; `evicted` reserved). Present only when the server sent one; absent on
|
|
18
|
+
`user` / `account_deleted` and from an older backend.
|
|
19
|
+
- `revoked` / `evicted` / `deleted` stay in `AuthSignedOutReason` so code written against 1.41.0
|
|
20
|
+
compiles; they are never emitted again. If you shipped 1.41.0 with a listener matching them,
|
|
21
|
+
it keeps compiling and stops firing — match `session_expired` (and read `endReason`).
|
|
22
|
+
- Nothing else changed: same storage, same wipe effects (no anon-id rotation on a session death),
|
|
23
|
+
same held-slot release rules.
|
|
24
|
+
|
|
25
|
+
Parity: web 3.24.1 / iOS 5.4.1 / Android 4.26.1. Guard: `test/auth-machine-conformance.test.ts`
|
|
26
|
+
`refresh_dead.current` (every endReason incl. `evicted` / `unknown` / absent → `session_expired` +
|
|
27
|
+
the exact `endReason`, key absent when the server sent none).
|
|
28
|
+
|
|
29
|
+
## 1.41.0 — sign-in no longer retires the guest: it is held on the device (ADR-033 wave 1)
|
|
30
|
+
|
|
31
|
+
**Two semantic changes, both strictly less destructive than before; everything else is additive
|
|
32
|
+
(MINOR under ADR-023 — no signature, storage key, event or status code removed).** Parity: web 3.24.0 /
|
|
33
|
+
iOS 5.4.0 / Android 4.26.0. Law: `docs/decisions/033-sdk-auth-mechanism.md` ("One Slot, Two Verbs").
|
|
34
|
+
|
|
35
|
+
1. **Sign-in no longer retires the guest — it is held on the device.** A sign-in from a guest session
|
|
36
|
+
(`signIn`, `loginSocial`, `signInWithGameCenter`/`PlayGames`, `challengeMfa`, `verifyMagicLink`,
|
|
37
|
+
`verifyEmailOtp`, `consumeSsoFragment`, `signInWith`) never sends `prevAnonRefreshToken`,
|
|
38
|
+
`linkAnonymous` or `onCredentialInUse` any more, so the server can never delete, adopt or remap the
|
|
39
|
+
guest as a side effect of a sign-in. Instead the guest's live tokens go into one of five fixed secure
|
|
40
|
+
slots on the device and the account is installed: `guest_held{userId,count,user}` then ONE
|
|
41
|
+
`signed_in{cause:'sign_in'}`, no `signed_out`. The guest's server row survives; `getHeldGuests()`
|
|
42
|
+
lists it; `onDeviceTakeover` / `getLastAnonRetirement()` keep working but no longer fire for this
|
|
43
|
+
SDK's own sign-ins (`anonRetirement` is `"none"`).
|
|
44
|
+
2. **`link:true` over an identified session attaches or errors — it never switches.**
|
|
45
|
+
`signInWith*({link:true})` / `loginSocial({link:true})` from an identified session go to
|
|
46
|
+
`/me/link/*`: an identity already on the account resolves quietly (no event, no revoke, no step-up),
|
|
47
|
+
one owned elsewhere throws `CredentialInUseError` (or switches when `onCredentialInUse:"adopt"`,
|
|
48
|
+
holding a guest if there was one), a stale session throws `RECENT_AUTH_REQUIRED`. From a guest it is
|
|
49
|
+
the same-`sub` promotion it always was (and still emits the legacy `signed_in{cause:'link'}`,
|
|
50
|
+
§8-Q18); in `signed_out` it is a plain sign-in. `signUp()` from a guest now calls `/me/link/email`
|
|
51
|
+
(never `/upgrade`); over an identified session it throws `AlreadyIdentifiedError` locally.
|
|
52
|
+
|
|
53
|
+
**New surface (all additive):**
|
|
54
|
+
- `auth.status(): "unknown" | "signed_out" | "anonymous" | "identified" | "withheld"` and
|
|
55
|
+
`auth.authStateReady()` (resolves after hydrate, never waits on the network). ⚠ **`status()` is the only
|
|
56
|
+
way to tell `withheld` (and `unknown`) from `signed_out`** — `getUserSync()` is `null` and `isReady()` is
|
|
57
|
+
`true` in all three. ⚠ **Consumers MUST default-branch on `AuthStatus`**: a later value ships opt-in-gated
|
|
58
|
+
or in a MAJOR.
|
|
59
|
+
- Events on `onAuthStateChanged`: `initial{status,user,withheld?}` (replayed once per subscriber, in every
|
|
60
|
+
status). With a live session (`anonymous` / `identified`) it is FOLLOWED by the same `signed_in{user}`
|
|
61
|
+
replay 1.28–1.40 sent (no `cause`) — kept, because removing an event is not a MINOR change (ADR-023);
|
|
62
|
+
`signed_out` / `unknown` / `withheld` get `initial` only,
|
|
63
|
+
`user_updated{user,cause}`, `guest_held`, `guest_released{userId,reason,endReason?}`,
|
|
64
|
+
`session_withheld`; `signed_in` gains `cause: 'mint'|'sign_in'|'restore'|'link'`;
|
|
65
|
+
`signed_out.reason` gains `revoked | evicted | deleted` (the server's own `endReason`).
|
|
66
|
+
- Held guests: `getHeldGuests()`, `getHeldGuest()`, `claimHeldGuest(id?)` (merge INTO the current
|
|
67
|
+
account — OPTIONAL and destructive; never call it from a backend that does not act on
|
|
68
|
+
`auth.user_merged`), `restoreHeldGuest(id?)`, `discardHeldGuest(id?)` (a real server delete). `id` may be
|
|
69
|
+
omitted only when exactly one guest is held (`AMBIGUOUS_HELD_GUEST` otherwise). Errors
|
|
70
|
+
`HeldGuestLimitError` (the device holds 5 guests), `HeldGuestProjectMismatch`.
|
|
71
|
+
- `init({ autoRestoreHeldGuest })` (default `true`): `ensureSession()` / `signInAnonymously()` in
|
|
72
|
+
`signed_out` restore the newest eligible held guest instead of minting — visibly
|
|
73
|
+
(`signed_in{cause:'restore'}` + `guest_released{restored}`); a dead slot is released and the next tried,
|
|
74
|
+
then it mints in the same call. `signInAnonymously({forceNew:true})` from a guest holds it; at the cap it
|
|
75
|
+
throws `HeldGuestLimitError` and mutates nothing.
|
|
76
|
+
- `init({ expectedProjectId })` (opt-in project guard): a stored/refreshed/restored session whose JWT
|
|
77
|
+
`project` claim disagrees is `withheld` — zero storage writes, every verb rejects
|
|
78
|
+
`AuthError{kind:'config', code:'SESSION_WITHHELD'}`, `getAccessToken*()`/`getRefreshToken()` return
|
|
79
|
+
`null`; exits are `discardWithheld()` (local only; keeps held guests, the queue and `user_id`) or a
|
|
80
|
+
relaunch with the matching key. A fresh mint/sign-in that disagrees rejects
|
|
81
|
+
`AuthError{kind:'config', code:'PROJECT_MISMATCH'}` and revokes the minted token.
|
|
82
|
+
`getWithheld()`, `Sendora.onIdentityWithheld(cb)`. Absent claim = unknown (never match); explicit
|
|
83
|
+
`null` = mismatch.
|
|
84
|
+
- Typed verbs: `auth.link(credential): LinkResult` (`done{upgraded,alreadyLinked}` |
|
|
85
|
+
`credential_in_use{provider,collision,switchAccount(),keepCurrent()}` | `recent_auth_required`) and
|
|
86
|
+
`auth.signInWith(credential): SignInResult` (`done{previous:{userId,isAnonymous,held},reactivated}` |
|
|
87
|
+
`mfa_required{verify(code)}`). `switchAccount()` is bound to the refused credential (same payload under
|
|
88
|
+
5 min; a Game Center credential is re-fetched after that and a changed player aborts
|
|
89
|
+
`CREDENTIAL_CHANGED`).
|
|
90
|
+
- `deleteAccount({ alsoDiscardHeldGuests })` → `{ ..., heldGuests, discardFailed }`. By default held guests
|
|
91
|
+
SURVIVE on device and server with auto-restore suppressed (explicit `restoreHeldGuest(id)` only).
|
|
92
|
+
- `Sendora.reset({ includeHeldGuests })`: `reset()` now KEEPS held guests (a guest's refresh token is its
|
|
93
|
+
only credential); the option releases them locally (`guest_released{reason:'reset'}`, no server delete).
|
|
94
|
+
The clear is an exact key list of the active namespace, no longer a `startsWith(prefix)` sweep.
|
|
95
|
+
- `merge(token)` (deprecated) routes a token that matches a held slot to `claimHeldGuest()`; otherwise the
|
|
96
|
+
raw `/merge` as before. `getRefreshToken()` kept (null in `withheld`).
|
|
97
|
+
- `POST /anonymous` carries a per-attempt `mintKey` (a retried mint after a crash returns the same guest).
|
|
98
|
+
- Bearer verbs (`enrollMfa`, `disableMfa`, `listMySessions`, `revokeSession`, `revokeAllSessions`,
|
|
99
|
+
`sendVerificationEmail`) now refresh an expired access token first (parity with web).
|
|
100
|
+
|
|
101
|
+
**On disk (additive, ADR-023):** the four frozen keys keep their format and are written on every install
|
|
102
|
+
(a 1.40.0 downgrade reads a valid session; held slots are simply ignored). New keys: `auth_session`
|
|
103
|
+
(single-record form, read first on hydrate, written forward from the frozen keys on the first 1.41 launch —
|
|
104
|
+
no network, same status), `auth_held_0..auth_held_4` (literal names, in `SECURE_KEYS`, so they route through
|
|
105
|
+
your `secureStorage` adapter), `auth_mint_key` (non-secret, removed on a successful mint).
|
|
106
|
+
`sendora_schema_version` → `"2"` (never bumped in a withheld hydrate).
|
|
107
|
+
The four frozen keys stay the authority on whether a session exists: if a downgraded 1.40.x signs out
|
|
108
|
+
(it clears only the four keys), the next 1.41 launch discards the leftover `auth_session` instead of
|
|
109
|
+
reinstalling it.
|
|
110
|
+
|
|
111
|
+
Guards: `test/auth-machine-conformance.test.ts` replays the shared ADR-033 table (36 rows + 19 project-verdict
|
|
112
|
+
cases from `packages/shared`); `test/held-guests.test.ts` (ADR §5 tests 1–13, the refresh-during-native-sheet
|
|
113
|
+
interleaving, dual-write/downgrade, the B15 held report, `merge()` routing). Mutation record in `CLAUDE.md`.
|
|
114
|
+
|
|
3
115
|
## 1.40.0 — signInAnonymously() no longer replaces a signed-in account; CHANGELOG ships in the package
|
|
4
116
|
|
|
5
117
|
**Behaviour change (reported by RopoLabs, verified against the shipped bundle).**
|
package/README.md
CHANGED
|
@@ -192,38 +192,90 @@ Full paste-ready recipes live in [`examples/links-share.tsx`](./examples/links-s
|
|
|
192
192
|
|
|
193
193
|
`SendoraCloud.auth` covers anonymous sign-in, email + password, magic link, email OTP, TOTP MFA, recovery codes, OIDC / SAML SSO, Sign in with Apple, Google, Microsoft, LinkedIn, Facebook, Discord. See [auth quickstart](https://sendoracloud.com/docs/quickstart-react-native#auth).
|
|
194
194
|
|
|
195
|
-
###
|
|
195
|
+
### Auth status — `status()` is the only way to tell `withheld` from `signed_out`
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
```ts
|
|
198
|
+
await SendoraCloud.auth.authStateReady(); // resolves after the disk hydrate; never waits on the network
|
|
199
|
+
switch (SendoraCloud.auth.status()) {
|
|
200
|
+
case "anonymous": /* a guest session */ break;
|
|
201
|
+
case "identified": /* a signed-in account */ break;
|
|
202
|
+
case "signed_out": /* no session */ break;
|
|
203
|
+
case "unknown": /* a refresh token exists but the user is not known yet */ break;
|
|
204
|
+
case "withheld": /* the stored session belongs to another project — see below */ break;
|
|
205
|
+
default: /* ⚠ REQUIRED: a later SDK may add a status (opt-in gated or in a MAJOR) */ break;
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`getUserSync()` is `null` and `isReady()` is `true` in `signed_out`, `unknown` AND `withheld` — only
|
|
210
|
+
`status()` tells them apart. Always keep a `default` branch.
|
|
198
211
|
|
|
199
|
-
|
|
212
|
+
### Anonymous → identified (1.41.0, ADR-033 "One Slot, Two Verbs")
|
|
213
|
+
|
|
214
|
+
The SDK creates an anonymous (guest) user on first `ensureSession()` / `signInAnonymously()`. From there
|
|
215
|
+
there are exactly two verbs:
|
|
216
|
+
|
|
217
|
+
**Link — the only upgrade (same `user.id`).** `signUp(email, password)` from a guest attaches the email to
|
|
218
|
+
the SAME row (`/me/link/email`) — the id is preserved, no funnel break. `signInWith*({ link: true })`,
|
|
219
|
+
`loginSocial({ link: true })`, `link*()` and the typed `auth.link(credential)` do the same for a social /
|
|
220
|
+
Game Center / Play Games identity. Over an already-identified session `link:true` **attaches or errors —
|
|
221
|
+
it never switches accounts**:
|
|
200
222
|
|
|
201
223
|
```ts
|
|
202
|
-
const
|
|
203
|
-
|
|
224
|
+
const r = await SendoraCloud.auth.link({ kind: "gameCenter", ...payload });
|
|
225
|
+
switch (r.status) {
|
|
226
|
+
case "done": /* r.upgraded (guest promoted) / r.alreadyLinked (nothing changed) */ break;
|
|
227
|
+
case "credential_in_use": // owned by another account — nothing changed
|
|
228
|
+
// await r.switchAccount() → sign in to that account (bound to THIS credential); or r.keepCurrent()
|
|
229
|
+
break;
|
|
230
|
+
case "recent_auth_required": /* re-authenticate, then retry */ break;
|
|
231
|
+
}
|
|
204
232
|
```
|
|
205
233
|
|
|
206
|
-
|
|
234
|
+
`signUp()` over an identified session throws `AlreadyIdentifiedError` locally; an email already owned by
|
|
235
|
+
another account throws `EmailAlreadyTakenError`.
|
|
207
236
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
237
|
+
**Sign in — a switch that HOLDS the guest.** `signIn()`, `loginSocial()`, `signInWithGameCenter()` /
|
|
238
|
+
`PlayGames()`, magic link, email OTP, SSO and `auth.signInWith(credential)` never delete, adopt or merge
|
|
239
|
+
the guest any more. The guest's live tokens are **held on the device** (up to 5) and the account is
|
|
240
|
+
installed: `guest_held{userId,count}` then ONE `signed_in{cause:'sign_in'}` (no `signed_out`). The guest's
|
|
241
|
+
server row survives.
|
|
213
242
|
|
|
214
|
-
|
|
215
|
-
const { user } = await SendoraCloud.auth.signIn(email, password);
|
|
243
|
+
### Held guests
|
|
216
244
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
245
|
+
```ts
|
|
246
|
+
const held = SendoraCloud.auth.getHeldGuests(); // newest first: [{ userId, heldAt, ... }]
|
|
247
|
+
await SendoraCloud.auth.restoreHeldGuest(id); // sign back in AS the guest (current account is held if it was a guest)
|
|
248
|
+
await SendoraCloud.auth.discardHeldGuest(id); // delete the guest server-side (real erasure)
|
|
249
|
+
await SendoraCloud.auth.claimHeldGuest(id); // OPTIONAL + destructive: merge the guest INTO the current account
|
|
222
250
|
```
|
|
223
251
|
|
|
224
|
-
|
|
252
|
+
- `id` may be omitted only when exactly one guest is held (else `AMBIGUOUS_HELD_GUEST`).
|
|
253
|
+
- `claimHeldGuest()` reassigns Sendora-side data and deletes the guest row (fires `auth.user_merged`); it
|
|
254
|
+
does not touch YOUR backend's data keyed on the guest id. **Never call it from an app whose backend does
|
|
255
|
+
not act on `auth.user_merged`** — most apps should restore or discard instead.
|
|
256
|
+
- `init({ autoRestoreHeldGuest })` (default `true`): `ensureSession()` in `signed_out` restores the newest
|
|
257
|
+
held guest instead of minting, visibly (`signed_in{cause:'restore'}` + `guest_released{reason:'restored'}`).
|
|
258
|
+
A dead slot is released (`guest_released{reason:'dead'}`) and the next tried, then it mints.
|
|
259
|
+
- `signInAnonymously({ forceNew: true })` from a guest holds it; with 5 held it throws `HeldGuestLimitError`.
|
|
260
|
+
- `deleteAccount()` keeps held guests (never auto-restored afterwards);
|
|
261
|
+
`deleteAccount({ alsoDiscardHeldGuests: true })` deletes them too.
|
|
262
|
+
- `SendoraCloud.reset()` keeps held guests (a guest's refresh token is its only credential);
|
|
263
|
+
`reset({ includeHeldGuests: true })` releases them locally (no server delete).
|
|
264
|
+
- The deprecated `merge(token)` routes a token matching a held guest to `claimHeldGuest()`.
|
|
265
|
+
|
|
266
|
+
### Project guard (`expectedProjectId`, opt-in)
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
await SendoraCloud.init({ apiKey, projectId: PROJECT_ID, expectedProjectId: PROJECT_ID });
|
|
270
|
+
```
|
|
225
271
|
|
|
226
|
-
|
|
272
|
+
A stored, refreshed or restored session whose JWT `project` claim names a different project is
|
|
273
|
+
**`withheld`**: nothing is written to storage, every auth verb rejects
|
|
274
|
+
`AuthError{ kind: 'config', code: 'SESSION_WITHHELD' }`, `getAccessToken()` returns `null`, and
|
|
275
|
+
`auth.getWithheld()` says whose session it was. Exits: relaunch with the matching key, or
|
|
276
|
+
`auth.discardWithheld()` (clears the session only — held guests, the event queue and the analytics id are
|
|
277
|
+
kept). A freshly minted / signed-in session that disagrees is rejected with `PROJECT_MISMATCH` and revoked.
|
|
278
|
+
Subscribe with `SendoraCloud.onIdentityWithheld(cb)` to record the old user id before discarding.
|
|
227
279
|
|
|
228
280
|
## Config
|
|
229
281
|
|
|
@@ -240,6 +292,8 @@ Full reference + edge-case writeup: [/docs/quickstart-react-native#anon-existing
|
|
|
240
292
|
androidPackageName?: string;
|
|
241
293
|
linkDomain?: string; // Custom share host (e.g. "pulse.link"). Defaults to Sendora's.
|
|
242
294
|
debug?: boolean;
|
|
295
|
+
expectedProjectId?: string; // ADR-033 project guard (opt-in) — see "Project guard".
|
|
296
|
+
autoRestoreHeldGuest?: boolean; // Default true — see "Held guests".
|
|
243
297
|
}
|
|
244
298
|
```
|
|
245
299
|
|
|
@@ -249,9 +303,9 @@ This SDK is on the **1.x line**. Patch bumps (1.x.y → 1.x.z) are backwards-com
|
|
|
249
303
|
|
|
250
304
|
### Auth state is a transition stream, not a health signal
|
|
251
305
|
|
|
252
|
-
`auth.onAuthStateChanged()` reports confirmed transitions only — `signed_in
|
|
306
|
+
`auth.onAuthStateChanged()` reports confirmed transitions only — `signed_in` (`cause`: `mint` | `sign_in` | `restore` | `link`), `signed_out` (reason `user` | `session_expired` | `account_deleted`; a session the server killed is always `session_expired`, with the server's verdict in `endReason` — `revoked` | `deleted` | `expired` | `reuse` | `unknown` — since 1.41.1), `user_updated`, `guest_held`, `guest_released`, `session_withheld`, `device_takeover`, `deletion_cancelled`. Each subscriber first receives one `initial{status, user}` (1.41.0); with a live session it is followed by the same `signed_in{user}` replay 1.28–1.40 sent (no `cause`), so existing listeners keep working. New code: read `initial` and treat a cause-less `signed_in` right after it as the replay. 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
307
|
|
|
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
|
|
308
|
+
`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 leave the current session and mint a fresh anonymous one (a guest left this way is held, not lost).
|
|
255
309
|
|
|
256
310
|
## JWT verification (custom backend)
|
|
257
311
|
|