@sendoracloud/sdk-react-native 1.40.0 → 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 CHANGED
@@ -1,5 +1,91 @@
1
1
  # Changelog
2
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
+
3
89
  ## 1.40.0 — signInAnonymously() no longer replaces a signed-in account; CHANGELOG ships in the package
4
90
 
5
91
  **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
- ### Anonymous → identified lifecycle
195
+ ### Auth status — `status()` is the only way to tell `withheld` from `signed_out`
196
196
 
197
- The SDK creates an anonymous user on first `init()`. Every `track()` / `identify()` attaches to that `user_id` until the user signs up or signs in. Two transitions exist; both preserve analytics continuity, but the second one requires one extra line of code from your app:
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
- **Case 1 — fresh signup.** `auth.signUp(email, password)` detects the anonymous refresh token and calls `/auth-service/upgrade` first, which flips `is_anonymous=false` + sets the password on the SAME row. The `user_id` is preserved — no funnel break. Falls back to a fresh `/signup` if the bound user is already identified (rare race).
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 { user } = await SendoraCloud.auth.signUp(email, password);
203
- // user.id === the same id the anon user had. Done.
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
- **Case 2 — email already exists.** `signUp()` throws `EmailAlreadyTakenError` without consuming the anon refresh. You fall back to `signIn()` — but `signIn()` wipes the local anon identity, so anon events stay orphaned on the backend unless you transfer them with `auth.merge(anonRefresh)`. Capture the anon refresh BEFORE the signIn:
234
+ `signUp()` over an identified session throws `AlreadyIdentifiedError` locally; an email already owned by
235
+ another account throws `EmailAlreadyTakenError`.
207
236
 
208
- ```ts
209
- try {
210
- const { user } = await SendoraCloud.auth.signUp(email, password);
211
- } catch (err) {
212
- if (!(err instanceof EmailAlreadyTakenError)) throw err;
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
- const anonRefresh = await SendoraCloud.auth.getRefreshToken();
215
- const { user } = await SendoraCloud.auth.signIn(email, password);
243
+ ### Held guests
216
244
 
217
- // Best-effort, silent-fail-tolerant — anon row may be already drained.
218
- if (anonRefresh) {
219
- SendoraCloud.auth.merge(anonRefresh).catch(() => undefined);
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
- **Why merge isn't automatic on signIn.** Two people sharing a device (browser kiosk, family iPad) would silently get their anon-session events combined with a third party's real account. Forcing the merge call into your app keeps the data-mixing decision explicit. Wrap `signIn()` with the snippet above if you want auto behaviour.
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
- Full reference + edge-case writeup: [/docs/quickstart-react-native#anon-existing-email](https://sendoracloud.com/docs/quickstart-react-native#anon-existing-email).
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`, `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.
306
+ `auth.onAuthStateChanged()` reports confirmed transitions only — `signed_in` (`cause`: `mint` | `sign_in` | `restore` | `link`), `signed_out` (reason `user` | `session_expired` | `account_deleted` | `revoked` | `evicted` | `deleted`), `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 abandon whatever session is current and mint a fresh anonymous one.
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