@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/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.
211
+
212
+ ### Anonymous → identified (1.41.0, ADR-033 "One Slot, Two Verbs")
198
213
 
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).
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)
225
267
 
226
- Full reference + edge-case writeup: [/docs/quickstart-react-native#anon-existing-email](https://sendoracloud.com/docs/quickstart-react-native#anon-existing-email).
268
+ ```ts
269
+ await SendoraCloud.init({ apiKey, projectId: PROJECT_ID, expectedProjectId: PROJECT_ID });
270
+ ```
271
+
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,12 +292,20 @@ 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
 
246
300
  ## Stability
247
301
 
248
- This SDK is on the **1.x line**. Patch bumps (1.x.y → 1.x.z) are backwards-compatible; minor bumps may include opt-in feature additions. Breaking changes only on major bumps — always read the [CHANGELOG](./CHANGELOG.md) before upgrading.
302
+ This SDK is on the **1.x line**. Patch bumps (1.x.y → 1.x.z) are backwards-compatible; minor bumps may include opt-in feature additions. Breaking changes only on major bumps — always read `CHANGELOG.md`, which ships inside the package (`node_modules/@sendoracloud/sdk-react-native/CHANGELOG.md`), before upgrading.
303
+
304
+ ### Auth state is a transition stream, not a health signal
305
+
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.
307
+
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).
249
309
 
250
310
  ## JWT verification (custom backend)
251
311