@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/dist/index.d.cts CHANGED
@@ -127,6 +127,31 @@ interface SendoraConfig {
127
127
  * either drop that and use this, or expect `<yours><ours>key`.
128
128
  */
129
129
  storagePrefix?: string;
130
+ /**
131
+ * ADR-033 project guard (opt-in). The UUID of the Sendora project this build
132
+ * is bound to — set it from the same constant you use for `projectId`. When
133
+ * set, a stored, refreshed or restored session whose JWT `project` claim
134
+ * names a DIFFERENT project (or the org-wide pool) is `withheld` instead of
135
+ * being served: `auth.status()` reports `"withheld"`, `getWithheld()` says
136
+ * why, every auth verb rejects `AuthError{kind:'config', code:'SESSION_WITHHELD'}`,
137
+ * and NOTHING is written to storage — relaunching with the matching key
138
+ * (or calling `auth.discardWithheld()`) is the exit. A freshly minted or
139
+ * signed-in session that disagrees is rejected (`PROJECT_MISMATCH`) and its
140
+ * token revoked, so a wrong-key build fails loudly on first launch instead
141
+ * of installing a foreign session. Validated at init: must be a UUID and,
142
+ * when `projectId` is also set, equal to it. Default off (no `withheld`).
143
+ */
144
+ expectedProjectId?: string;
145
+ /**
146
+ * ADR-033 held guests. When the device is signed out and holds a guest that
147
+ * a previous sign-in displaced (`auth.getHeldGuests()`), `ensureSession()` /
148
+ * `signInAnonymously()` restore the NEWEST eligible one instead of minting a
149
+ * fresh guest — visibly, as `signed_in{cause:'restore'}` +
150
+ * `guest_released{reason:'restored'}`. Default `true`. `false` mints while
151
+ * the device holds fewer than 5 guests (at the cap a plain mint still
152
+ * restores). Guests left behind by `deleteAccount()` are never auto-restored.
153
+ */
154
+ autoRestoreHeldGuest?: boolean;
130
155
  }
131
156
  /**
132
157
  * Async key/value adapter for encrypted-at-rest auth storage. Compatible with a
@@ -241,62 +266,94 @@ declare class Storage {
241
266
  set(key: string, value: string): void;
242
267
  remove(key: string): void;
243
268
  /**
244
- * Remove every sendora_*-prefixed key from both memory AND
245
- * AsyncStorage, plus every secure key from the adapter. Uses
246
- * getAllKeys() to find keys this process never cached (e.g. orphaned
247
- * writes from a prior SDK version). Falls back to cache enumeration
248
- * if the AsyncStorage backend doesn't expose getAllKeys.
269
+ * Clear this SDK's keys from memory, AsyncStorage and the secure adapter.
270
+ *
271
+ * **With `opts.keys` (ADR-033, the `reset()` path):** removes EXACTLY the
272
+ * listed bare keys of the active namespace — minus `opts.except` — from both
273
+ * stores. An exact list is what lets `Sendora.reset()` keep the held-guest
274
+ * slots by default (a guest's refresh token is its only credential, so
275
+ * sweeping them on logout would be deletion by assumption, s58.316), and it
276
+ * never touches a sibling `sendora_<env>_` keyspace that happens to share
277
+ * the prefix. Values kept in `except` stay in the cache too.
278
+ *
279
+ * **Without `opts` (legacy):** the pre-1.41 sweep — every prefixed
280
+ * AsyncStorage key (via `getAllKeys()`, falling back to the secure-key list)
281
+ * plus every secure key from the adapter.
249
282
  */
250
- clearAll(): Promise<void>;
283
+ clearAll(opts?: {
284
+ keys: readonly string[];
285
+ except?: readonly string[];
286
+ }): Promise<void>;
251
287
  }
252
288
 
253
289
  /**
254
- * Auth Service surface for the React Native SDK.
290
+ * Auth Service surface for the React Native SDK — ADR-033 wave 1 (1.41.0),
291
+ * "One Slot, Two Verbs".
255
292
  *
256
- * Mirrors @sendoracloud/sdk-web's `auth` module. Three flows:
257
- * signInAnonymously() — POST /auth-service/anonymous, mints a
258
- * session for a user with no email yet.
259
- * signUp(email, password) — if currently anonymous, calls
260
- * /auth-service/upgrade with the stored refresh token; same
261
- * user_id is preserved on success. Throws
262
- * EmailAlreadyTakenError on 409. Otherwise calls
263
- * /auth-service/signup as a fresh account.
264
- * signIn(email, password) — POST /auth-service/login.
293
+ * THE MECHANISM (ADR-033 §3). The SDK has exactly ONE identity record and
294
+ * exactly ONE function that writes it: `commit()`. Every public method is sugar
295
+ * for one event of the shared transition table
296
+ * (`packages/shared/src/auth-machine/transitions.ts`, replayed by
297
+ * `test/auth-machine-conformance.test.ts`), and nothing outside `commit()`
298
+ * assigns `this.user` / the tokens, writes the auth storage keys, arms or stops
299
+ * the refresh cron, or decides which `onAuthStateChanged` event fires (INV-1).
265
300
  *
266
- * HARD INVARIANT — a failed auth attempt leaves the caller exactly as
267
- * it found them. Every credentialed path captures the anonymous
268
- * refresh token into a local (for the device-takeover hint), performs
269
- * its network call, validates the response, and only THEN clears the
270
- * old identity and persists the new one. Clearing first would drop the
271
- * refresh token — for an anonymous user the ONLY durable handle on the
272
- * account — so any failure after that point (offline, 5xx, a mistyped
273
- * OTP, a cancelled IdP callback) would orphan the account permanently.
274
- * A track() fired during the round-trip therefore attaches to the
275
- * still-anonymous identity, which is correct: the user is not
276
- * identified yet, and the backend reattributes it via the takeover.
301
+ * - Proposal, then CAS (INV-2). A network call changes nothing until it
302
+ * succeeds, and its result is applied only if the session it was derived from
303
+ * is still current: identity-changing commits are keyed on the server session
304
+ * family id `sid` (stable across token rotation), token commits on the
305
+ * refresh token that was sent. A failed, abandoned or superseded attempt
306
+ * leaves the caller exactly as found — there is no "wipe before the network
307
+ * call" API left to misuse (the s58.271 class is unreachable).
308
+ * - Sign-in never carries the guest (INV-3). No request body built here ever
309
+ * contains `prevAnonRefreshToken`, `linkAnonymous` or `onCredentialInUse`.
310
+ * A sign-in from a guest HOLDS the guest on the device (its live tokens go
311
+ * into one of K=5 fixed secure slots `auth_held_0..4`) and switches; the
312
+ * server row survives.
313
+ * - Link is the only upgrade (INV-4). `signUp()`, `signInWith*({link:true})`,
314
+ * `loginSocial({link:true})`, `link*()` and `link()` all go to `/me/link/*`
315
+ * on the current Bearer session (same `sub`). `link:true` over an identified
316
+ * session attaches or returns a typed error — it never switches (INV-14).
317
+ * - Every session-installing event executes only from `signed_out` (INV-5);
318
+ * in `unknown` it queues until the gap-fill refresh answers; in
319
+ * `anonymous`/`identified` MINT returns the current user and the others link.
320
+ * - The project guard (INV-15) is a commit guard: with `init({expectedProjectId})`
321
+ * a stored/refreshed/restored session whose JWT `project` claim disagrees is
322
+ * `withheld` — a fifth status, zero storage writes, every verb rejecting
323
+ * `AuthError{kind:'config', code:'SESSION_WITHHELD'}`; a fresh mint that
324
+ * disagrees is rejected (`PROJECT_MISMATCH`) and its refresh revoked.
277
325
  *
278
- * All public auth ops are serialized through a single-flight chain
279
- * so a UI double-submit can't mint two anonymous users or interleave
280
- * a signIn + signOut.
326
+ * STORAGE. The four frozen keys (`auth_access_token`, `auth_access_expires`,
327
+ * `auth_refresh_token`, `auth_user`) keep their 1.x format and are written on
328
+ * EVERY install commit (dual-write) so a downgrade to 1.40.0 reads a valid
329
+ * session. `auth_session` is the single-record form written beside them
330
+ * (read-first on hydrate, write-forward from the legacy keys). The held slots
331
+ * are five LITERAL key names — never built dynamically — so they are in
332
+ * `SECURE_KEYS` and route through the app's Keychain/Keystore adapter. Tokens
333
+ * persist in AsyncStorage BY DEFAULT (OWASP MASVS-L1); the opt-in
334
+ * `secureStorage` adapter (1.18.0) moves the secure keys into the Keychain.
281
335
  *
282
- * Tokens (including the refresh token) persist in AsyncStorage BY
283
- * DEFAULT — unencrypted app-sandbox storage: isolated from other apps
284
- * by the OS sandbox, but readable with filesystem/backup access to a
285
- * jailbroken/rooted or unlocked device (OWASP MASVS-L1). Since 1.18.0 an
286
- * OPTIONAL `secureStorage` adapter (`Sendora.init({ secureStorage })`,
287
- * wired in index.ts) routes the SECURE_KEYS (refresh/access/user) through
288
- * an app-supplied Keychain / Keystore adapter, with transparent
289
- * plaintext→secure migration; omit it and behaviour is unchanged. The
290
- * `auth_refresh_token` storage key stays frozen for back-compat either way.
291
- *
292
- * Response payloads are validated non-empty before persisting so a
293
- * malformed/MITM'd response can't install an `id = ""` user.
294
- *
295
- * Access token expiry is tracked locally — getAccessToken() returns
296
- * null past expiry and triggers a single-flight refresh on the next
297
- * call.
336
+ * TOKEN LIVENESS is unchanged machinery (s58.281): two deadlines, the one-probe
337
+ * `clockSkewConfirmed` guard, single-flight refresh, 30 s-capped backoff. The
338
+ * token layer never decides identity except on the single server verdict
339
+ * `INVALID_REFRESH_TOKEN` (INV-6).
298
340
  */
299
341
 
342
+ /** K — the held-guest capacity (ADR-033 §3.2, §8-Q19). */
343
+ declare const HELD_GUEST_SLOTS = 5;
344
+ /**
345
+ * The five client states (ADR-033 §3.2). `unknown` ≠ `signed_out` and
346
+ * `withheld` ≠ `signed_out` — `status()` is the ONLY call that tells them
347
+ * apart, because `getUserSync()` is `null` and `isReady()` is `true` in all three.
348
+ *
349
+ * ⚠ NORMATIVE: consumers MUST default-branch on this value. `withheld` is new in
350
+ * 1.41.0; any LATER value ships opt-in-gated or in a MAJOR.
351
+ */
352
+ type AuthStatus = "unknown" | "signed_out" | "anonymous" | "identified" | "withheld";
353
+ /** `error.details.endReason` on an `INVALID_REFRESH_TOKEN` rejection (ADR-033 B11). */
354
+ type RefreshEndReason = "expired" | "revoked" | "evicted" | "deleted" | "reuse" | "unknown";
355
+ /** The `code` of the refusal when a destructive/identity held-guest verb omits `id` with 2+ guests held. */
356
+ declare const AMBIGUOUS_HELD_GUEST = "AMBIGUOUS_HELD_GUEST";
300
357
  interface AuthUser {
301
358
  id: string;
302
359
  email: string | null;
@@ -315,6 +372,12 @@ interface AuthUser {
315
372
  */
316
373
  signupMethod?: string | null;
317
374
  lastLoginMethod?: string | null;
375
+ /**
376
+ * The account's project pool (ADR-033 B17) — the same value as the JWT
377
+ * `project` claim. A string = the project; an EXPLICIT `null` = the org-wide
378
+ * pool; absent = a backend older than B17 (never read absent as null).
379
+ */
380
+ project?: string | null;
318
381
  }
319
382
  interface AuthTokens {
320
383
  accessToken: string;
@@ -340,6 +403,122 @@ interface GameCenterPayload {
340
403
  }
341
404
  /** @see AuthApiResponse.anonRetirement */
342
405
  type AnonRetirementOutcome = "retired" | "preserved" | "none";
406
+ type SocialCredentialProvider = "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
407
+ /**
408
+ * One closed union instead of nine method signatures. Each member carries
409
+ * exactly the wire fields of its sign-in / link route MINUS the guest hint
410
+ * (INV-3: there is no field to put a guest credential in).
411
+ */
412
+ type Credential = {
413
+ kind: "password";
414
+ email: string;
415
+ password: string;
416
+ } | {
417
+ kind: SocialCredentialProvider;
418
+ code?: string;
419
+ idToken?: string;
420
+ redirectUri?: string;
421
+ codeVerifier?: string;
422
+ appleName?: {
423
+ firstName?: string;
424
+ lastName?: string;
425
+ };
426
+ } | ({
427
+ kind: "gameCenter";
428
+ } & GameCenterPayload) | {
429
+ kind: "playGames";
430
+ serverAuthCode: string;
431
+ } | {
432
+ kind: "magicLink";
433
+ token: string;
434
+ } | {
435
+ kind: "emailOtp";
436
+ email: string;
437
+ code: string;
438
+ }
439
+ /**
440
+ * React Native ships no WebAuthn authenticator; the kind is accepted for
441
+ * parity with the shared union. `assertion` is forwarded as the
442
+ * `passkeys/authenticate/finish` body (`{ response, userId }`); an object
443
+ * without a `response` key is wrapped as the response itself.
444
+ */
445
+ | {
446
+ kind: "passkey";
447
+ assertion: Record<string, unknown>;
448
+ } | {
449
+ kind: "mfa";
450
+ challengeToken: string;
451
+ code: string;
452
+ };
453
+ type CredentialKind = Credential["kind"];
454
+ /**
455
+ * `previous` is what the SWITCH replaced. `held: true` ⇔ the previous slot was
456
+ * anonymous and is now in a held slot. `previous.isAnonymous === false` ⇒ an
457
+ * identified → identified switch: skip `mergeAnon` and reset local save state.
458
+ * `reactivated` surfaces a cancelled pending self-service deletion (s58.269).
459
+ */
460
+ type SignInResult = {
461
+ status: "done";
462
+ user: AuthUser;
463
+ previous: {
464
+ userId: string;
465
+ isAnonymous: boolean;
466
+ held: boolean;
467
+ } | null;
468
+ reactivated: boolean;
469
+ } | {
470
+ status: "mfa_required";
471
+ challengeToken: string;
472
+ /** Completes the challenge — a sign-in continuation on the same credential. */
473
+ verify(code: string): Promise<SignInResult>;
474
+ };
475
+ type LinkCollision = "identity" | "email";
476
+ /**
477
+ * Three outcomes, nothing mutated on the two refusals (INV-12, B2).
478
+ *
479
+ * - `done{alreadyLinked:true}` — a quiet 200: NO event, NO revoke, NO step-up (B13).
480
+ * - `done{upgraded}` — true when the caller was anonymous (same `sub`, rotated pair).
481
+ * - `credential_in_use` — owned by another account (409). `switchAccount()` is
482
+ * the ONLY path from a refused link to a sign-in and is BOUND to the refused
483
+ * credential (INV-14): same payload if under 5 min old, else re-fetched
484
+ * natively (Game Center / Play Games) and compared, aborting with
485
+ * `CREDENTIAL_CHANGED` on mismatch. From anonymous the guest is HELD; from
486
+ * identified `previous:{isAnonymous:false, held:false}`.
487
+ * - `recent_auth_required` — the credential is fine, the session is stale (403).
488
+ * Deliberately NO `reauthenticate(credential)` closure: the credential being
489
+ * linked must never serve as the re-auth.
490
+ */
491
+ type LinkResult = {
492
+ status: "done";
493
+ user: AuthUser;
494
+ upgraded: boolean;
495
+ alreadyLinked: boolean;
496
+ } | {
497
+ status: "credential_in_use";
498
+ provider: string;
499
+ collision: LinkCollision;
500
+ switchAccount(): Promise<SignInResult>;
501
+ keepCurrent(): void;
502
+ } | {
503
+ status: "recent_auth_required";
504
+ };
505
+ /** What `getHeldGuests()` returns — the slot MINUS its tokens (never exposes tokens). */
506
+ interface HeldGuestSummary {
507
+ userId: string;
508
+ heldAt: number;
509
+ autoRestore: boolean;
510
+ project: string | null;
511
+ }
512
+ /** `guest_released.reason` — every exit from a slot names one. */
513
+ type GuestReleasedReason = "claimed" | "restored" | "discarded" | "dead" | "reset";
514
+ /** What `getWithheld()` returns while `status() === "withheld"`. Never exposes tokens. */
515
+ interface WithheldInfo {
516
+ reason: "project_mismatch";
517
+ expectedProjectId: string;
518
+ actualProjectId: string | null;
519
+ userId: string;
520
+ isAnonymous: boolean;
521
+ }
343
522
  declare class EmailAlreadyTakenError extends Error {
344
523
  constructor(message?: string);
345
524
  }
@@ -349,6 +528,7 @@ declare class EmailAlreadyTakenError extends Error {
349
528
  * current one — use `linkEmailPassword()` / `linkGoogle()` / … (ADR-030) to
350
529
  * add the credential to THIS account, or sign out first. Distinct from
351
530
  * `EmailAlreadyTakenError`: the email isn't the problem, the live session is.
531
+ * Decided LOCALLY since 1.41.0 (no round-trip).
352
532
  */
353
533
  declare class AlreadyIdentifiedError extends Error {
354
534
  constructor(message?: string);
@@ -356,7 +536,8 @@ declare class AlreadyIdentifiedError extends Error {
356
536
  /**
357
537
  * A credential you tried to LINK (ADR-030) is already attached to a DIFFERENT
358
538
  * account. Sendora never auto-merges two real accounts — sign in to that
359
- * account instead, or link a different credential.
539
+ * account instead (`switchAccount()` on the typed `link()` result, or
540
+ * `onCredentialInUse: "adopt"`), or link a different credential.
360
541
  */
361
542
  declare class CredentialInUseError extends Error {
362
543
  /** Always `"CREDENTIAL_IN_USE"`. */
@@ -380,11 +561,11 @@ declare class CredentialInUseError extends Error {
380
561
  * already linked to another account; `"email"` = the provider's verified
381
562
  * email already belongs to one. Absent on older backends.
382
563
  */
383
- readonly collision?: "identity" | "email";
564
+ readonly collision?: LinkCollision;
384
565
  readonly status?: number;
385
566
  constructor(message?: string, opts?: {
386
567
  provider?: string;
387
- collision?: "identity" | "email";
568
+ collision?: LinkCollision;
388
569
  status?: number;
389
570
  });
390
571
  }
@@ -392,42 +573,36 @@ declare class CredentialInUseError extends Error {
392
573
  * What a sign-in should do when the credential you present already belongs to
393
574
  * an account.
394
575
  *
395
- * **You usually want the default — omit this.** Since 1.32.0 the default is
396
- * safe by construction: adopt the other account silently when nothing would be
397
- * destroyed, and refuse when something would be.
398
- *
399
- * - **Fresh install / no guest session** → nothing to lose, so it adopts. This
400
- * is the reinstall-recovery path: a Game Center / Play Games identity
401
- * survives a reinstall server-side, so the "collision" is usually the SAME
402
- * person's earlier account, and they get it back. Never refused.
403
- * - **Live guest session** → adopting would retire it (**its row is deleted**
404
- * and `onDeviceTakeover` fires). Refused with `CredentialInUseError` so you
405
- * can ask the player instead of the SDK guessing on their behalf.
406
- *
407
- * Override in either direction:
576
+ * **You usually want the default — omit this.** Since 1.41.0 a collision is
577
+ * only ever observed on a LINK (`link:true` from a guest or an identified
578
+ * session), where the server refuses with 409 `CREDENTIAL_IN_USE` and NOTHING
579
+ * is mutated: the guest account on this device is never deleted by a sign-in
580
+ * any more (ADR-033 INV-3). The question is only what to do next:
408
581
  *
409
- * - `"adopt"` — always adopt, even when it deletes the guest account. This is
410
- * what every release before 1.30.0 did unconditionally.
411
- * - `"reject"` — always throw on a collision, even a harmless one.
582
+ * - **omitted / `"reject"`** — throw `CredentialInUseError` so you can ask the
583
+ * player. The installed collision dialog keeps working unchanged.
584
+ * - **`"adopt"`** — fall through to a plain sign-in into the owning account
585
+ * (explicit consent). From a guest the guest is HELD on the device (see
586
+ * `getHeldGuests()`), not retired; from an identified session nothing is held.
412
587
  *
413
- * ⚠ A refusal blocks the switch; it does not merge the two accounts. To offer
414
- * "use my other account", catch the error and re-call with `"adopt"`, then
415
- * migrate your own data from `onDeviceTakeover`'s `retiredAnonUserId`.
588
+ * ⚠ A plain `signInWith*()` without `link:true` never sees a collision: with no
589
+ * guest hint on the wire there is nothing for the server to refuse — it signs
590
+ * into the owning account and holds the previous guest.
416
591
  */
417
592
  type CredentialCollisionPolicy = "adopt" | "reject";
418
593
  /** Options for the Game Center / Play Games sign-in methods. */
419
594
  type GameSignInOptions = {
420
595
  /**
421
- * ADR-025 link-in-place. When this device is anonymous and the player
422
- * identity is BRAND NEW, the anonymous account is promoted in place and the
423
- * user id (sub) is preserved. Has no effect on a collision — see
424
- * `onCredentialInUse` for that case.
596
+ * Attach-or-error (ADR-033 INV-14). In `anonymous` the guest is promoted in
597
+ * place (same user id); in `identified` the identity is attached to the
598
+ * current account — an identity already on it is a quiet no-op, one owned
599
+ * elsewhere throws `CredentialInUseError`, a stale session throws
600
+ * `RECENT_AUTH_REQUIRED`. In `signed_out` it is a plain sign-in.
425
601
  */
426
602
  link?: boolean;
427
603
  /**
428
- * What to do when the player identity already belongs to an account.
429
- * **Omit it** — the default refuses only when a live guest session would be
430
- * deleted. See {@link CredentialCollisionPolicy}.
604
+ * What to do when `link:true` finds the identity on ANOTHER account. Omit it
605
+ * to be asked (`CredentialInUseError`); see {@link CredentialCollisionPolicy}.
431
606
  */
432
607
  onCredentialInUse?: CredentialCollisionPolicy;
433
608
  };
@@ -459,10 +634,9 @@ type UnlinkResult = {
459
634
  * Closed set of auth failure categories (1.28.0). Branch on `kind` instead of
460
635
  * string-matching the ~20 raw `code`s the backend and transport can produce.
461
636
  *
462
- * Pairs with the hard guarantee this release introduces: **a failed auth
463
- * attempt never changes the local session.** An offline sign-in, a mistyped
464
- * OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
465
- * the stored refresh token exactly as they were.
637
+ * ⚠ CLOSED — mirrored by every SDK and compared by
638
+ * `packages/shared/src/auth-error-kind-parity.test.ts`. ADR-033's two project
639
+ * guard refusals REUSE `config` (`code: SESSION_WITHHELD | PROJECT_MISMATCH`).
466
640
  */
467
641
  type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "recent_auth_required" | "already_identified" | "cancelled" | "config" | "unknown";
468
642
  declare class AuthError extends Error {
@@ -484,12 +658,45 @@ declare class AuthError extends Error {
484
658
  readonly retryAfterSeconds?: number;
485
659
  /** HTTP status when the failure came from a response (absent when offline). */
486
660
  readonly status?: number;
661
+ /**
662
+ * ADR-033 B11: on an `INVALID_REFRESH_TOKEN` rejection, the server's own
663
+ * verdict (`expired | revoked | deleted | reuse | unknown`; `evicted`
664
+ * reserved). Absent on every other code and on an older backend.
665
+ */
666
+ readonly endReason?: RefreshEndReason;
487
667
  constructor(code: string, message: string, opts?: {
488
668
  status?: number;
489
669
  retryAfterSeconds?: number;
490
670
  kind?: AuthErrorKind;
671
+ retryable?: boolean;
672
+ endReason?: RefreshEndReason;
491
673
  });
492
674
  }
675
+ /**
676
+ * The device holds K=5 guests and the requested mint would exceed the bound
677
+ * T = |held| + (current anonymous ? 1 : 0) ≤ K (ADR-033 §3.3): thrown by
678
+ * `signInAnonymously({forceNew:true})` at the cap, and by a plain mint /
679
+ * `ensureSession()` in `signed_out` when every slot is ineligible for
680
+ * auto-restore (all stamped `autoRestore:false` by `deleteAccount()`, or
681
+ * project-withheld — §8-Q22). Nothing was mutated; the app must
682
+ * `restoreHeldGuest(id)` or `discardHeldGuest(id)` first.
683
+ */
684
+ declare class HeldGuestLimitError extends AuthError {
685
+ constructor(message?: string);
686
+ }
687
+ /**
688
+ * `restoreHeldGuest(id)` was refused CLIENT-SIDE: the slot's `project` claim
689
+ * recorded at HOLD (or on its refresh response) mismatches
690
+ * `expectedProjectId ?? config.projectId`. The slot is KEPT and excluded from
691
+ * auto-restore; no network call was made. Only `claimHeldGuest()` is
692
+ * server-guarded — the server does not enforce this on refresh.
693
+ */
694
+ declare class HeldGuestProjectMismatch extends AuthError {
695
+ readonly userId: string;
696
+ readonly expectedProjectId: string;
697
+ readonly actualProjectId: string | null;
698
+ constructor(userId: string, expectedProjectId: string, actualProjectId: string | null);
699
+ }
493
700
  interface AuthHooks {
494
701
  /** Storage shared with the parent SDK so re-hydration is consistent. */
495
702
  storage: Storage;
@@ -497,23 +704,39 @@ interface AuthHooks {
497
704
  * identity (analytics userId, push token registration, etc). */
498
705
  onIdentityChange: (userId: string | null) => void;
499
706
  /**
500
- * Called from `wipeLocalIdentity` whenever the local session is cleared. The
501
- * `reason` lets the parent decide whether to rotate the anonymous analytics id:
502
- * it rotates ONLY at a person-boundary ("user" = explicit signOut,
503
- * "account_deleted"), never on a successful sign-in/upgrade ("replaced") or an
504
- * involuntary session death ("session_expired") — those are the same person, and
505
- * the anon id must survive so pre-auth analytics link to them (Firebase-FID
506
- * semantics; the industry norm).
707
+ * Called from the clear funnel whenever the local session is cleared. The
708
+ * `reason` lets the parent decide whether to rotate the anonymous analytics
709
+ * id: it rotates ONLY at a person-boundary ("user" = explicit signOut,
710
+ * "account_deleted"), never on an involuntary session death
711
+ * ("session_expired" / the B11 reasons) — those are the same person, and the
712
+ * anon id must survive so pre-auth analytics link to them (Firebase-FID
713
+ * semantics). A successful sign-in or upgrade never calls this at all since
714
+ * 1.41.0: the guest is HELD, not wiped, and the anon id is untouched (s58.350).
507
715
  */
508
716
  onAnonymousWipe: (reason: WipeReason) => Promise<void>;
717
+ /**
718
+ * ADR-033 [2c] analytics quarantine hook: the stored session was refused by
719
+ * the project guard. The parent nulls its in-memory `userId` ONLY — the
720
+ * persisted `user_id` is untouched (zero storage writes in `withheld`).
721
+ */
722
+ onIdentityWithheld?: (info: WithheldInfo) => void;
509
723
  apiUrl: string;
510
724
  publicKey: string;
511
725
  debug: boolean;
726
+ /**
727
+ * Opt-in project guard (ADR-033 [2c]). Validated by `index.ts` (UUID; equal
728
+ * to `projectId` when both set). Produces the `withheld` status.
729
+ */
730
+ expectedProjectId?: string;
731
+ /** `config.projectId` — the fallback the held-slot project check compares against. */
732
+ projectId?: string;
733
+ /** Default true (§8-Q1): `ensureSession()` in `signed_out` restores the newest eligible held guest before minting. */
734
+ autoRestoreHeldGuest?: boolean;
512
735
  }
513
736
  /**
514
- * Detail handed to onDeviceTakeover subscribers. Fires once per
515
- * signIn / loginSocial / verifyMagicLink / verifyEmailOtp /
516
- * passkey-auth / SSO completion that retires an anonymous row.
737
+ * Detail handed to onDeviceTakeover subscribers. LEGACY (ADR-033): fires only
738
+ * when the SERVER reports a retirement, which this SDK never requests — a
739
+ * wave-1 sign-in carries no guest hint, so no guest is ever retired for it.
517
740
  */
518
741
  interface DeviceTakeoverEvent {
519
742
  /** The anonymous user_id Sendora just hard-deleted. */
@@ -535,26 +758,40 @@ interface DeletionCancelledEvent {
535
758
  at: Date;
536
759
  }
537
760
  /**
538
- * Why the local session ended (1.28.0). Before this, a session that died in
539
- * the background emitted NOTHING — an app could not tell a deliberate sign-out
540
- * apart from an expired refresh token, and only discovered the difference when
541
- * `getAccessToken()` started returning null.
761
+ * Why the local session ended.
542
762
  *
543
- * - `user` — the app called `signOut()`.
544
- * - `session_expired` — the server rejected the stored refresh token (revoked,
545
- * expired, rotated away). Transient network failures and
546
- * rate limits do NOT produce this.
763
+ * - `user` — the app called `signOut()` (or `discardWithheld()`).
764
+ * - `session_expired` — the server rejected the stored refresh token with no
765
+ * finer reason (or `endReason: expired | reuse | unknown`).
547
766
  * - `account_deleted` — `deleteAccount()` succeeded.
767
+ * - `revoked` / `evicted` / `deleted` — ADR-033 B11 (additive): the server's
768
+ * own `endReason` on the dead-token verdict. `evicted` is
769
+ * reserved and not emitted by the backend yet.
770
+ *
771
+ * Transient network failures and rate limits never produce any of these.
772
+ * Consumers default-branch (§3.5 NORMATIVE).
548
773
  */
549
- type AuthSignedOutReason = "user" | "session_expired" | "account_deleted";
774
+ type AuthSignedOutReason = "user" | "session_expired" | "account_deleted" | "revoked" | "evicted" | "deleted";
550
775
  /**
551
- * A single auth-state transition (1.28.0). Subscribe with
552
- * `onAuthStateChanged` — one stream covering everything that used to need
553
- * separate listeners (or had none at all).
776
+ * Why a `signed_in` fired (ADR-033 §3.5). `restore` ⇒ re-register push (the
777
+ * held guest's push tokens were erased if it had been deleted elsewhere);
778
+ * `link` = the legacy same-`sub` promotion the legacy-signature methods
779
+ * (`signInWith*({link:true})`, `loginSocial({link:true})`, `signUp`) keep
780
+ * emitting (§8-Q18). Absent on a gap-fill recovery from `unknown` (1.29.0).
781
+ */
782
+ type AuthSignedInCause = "mint" | "sign_in" | "restore" | "link";
783
+ /** What changed the user object WITHOUT changing the `sub`. */
784
+ type AuthUserUpdatedCause = "link" | "unlink" | "email_verified" | "refresh" | "merge";
785
+ /**
786
+ * A single auth-state transition. Subscribe with `onAuthStateChanged` — one
787
+ * stream covering everything. The first four members are the pre-1.41.0
788
+ * contract verbatim (`signed_in` gaining an OPTIONAL `cause`); the rest are
789
+ * additive (ADR-033). ⚠ Default-branch on `event`.
554
790
  */
555
791
  type AuthStateChange = {
556
792
  event: "signed_in";
557
793
  user: AuthUser;
794
+ cause?: AuthSignedInCause;
558
795
  } | {
559
796
  event: "signed_out";
560
797
  user: null;
@@ -566,12 +803,47 @@ type AuthStateChange = {
566
803
  } | {
567
804
  event: "deletion_cancelled";
568
805
  user: AuthUser | null;
806
+ }
807
+ /** The settled boot state, exactly once per subscriber — INCLUDING `signed_out`, `unknown` and `withheld`. */
808
+ | {
809
+ event: "initial";
810
+ status: AuthStatus;
811
+ user: AuthUser | null;
812
+ withheld?: WithheldInfo;
813
+ }
814
+ /** Same `sub`, different user object (a link attach, an unlink, a claim, a refresh that changed the user). */
815
+ | {
816
+ event: "user_updated";
817
+ user: AuthUser;
818
+ cause: AuthUserUpdatedCause;
819
+ }
820
+ /** A guest was moved into a held slot (`count` = guests held after this one). */
821
+ | {
822
+ event: "guest_held";
823
+ userId: string;
824
+ count: number;
825
+ user: AuthUser;
826
+ }
827
+ /** A held slot was emptied. ⚠ `dead` = "unavailable when its token was presented", NOT an erasure signal. */
828
+ | {
829
+ event: "guest_released";
830
+ userId: string;
831
+ reason: GuestReleasedReason;
832
+ endReason?: RefreshEndReason;
833
+ }
834
+ /** An in-process transition INTO `withheld` (a refresh answered with a mismatching claim). Never replayed. */
835
+ | {
836
+ event: "session_withheld";
837
+ reason: "project_mismatch";
838
+ expectedProjectId: string;
839
+ actualProjectId: string | null;
840
+ userId: string;
569
841
  };
570
842
  type AuthStateListener = (change: AuthStateChange) => void;
571
843
  /**
572
- * Internal marker for a wipe that is immediately followed by a `persist()` of
573
- * a NEW session (the sign-in success path). Not a sign-out — emitting one
574
- * would make every successful sign-in look like a logout to subscribers.
844
+ * The reasons the clear funnel can be entered with. `replaced` is kept on the
845
+ * type for compatibility with hooks written against 1.38–1.40 but is no longer
846
+ * produced: a successful sign-in HOLDS the previous guest instead of wiping it.
575
847
  */
576
848
  type WipeReason = AuthSignedOutReason | "replaced";
577
849
  declare class Auth {
@@ -579,6 +851,16 @@ declare class Auth {
579
851
  private user;
580
852
  private accessToken;
581
853
  private accessExpiresAt;
854
+ /** The in-memory mirror of the K held slots (index = slot number). Written only by commit(). */
855
+ private held;
856
+ /** Non-null ⇔ `status() === "withheld"` (ADR-033 [2c]). Never persisted. */
857
+ private withheld;
858
+ /** The refresh token the withheld record carried — the CAS key of `discardWithheld()`. */
859
+ private withheldRefresh;
860
+ /** B15: held guests whose `POST /guest/held` report has not yet returned 2xx. Persisted in `auth_session`. */
861
+ private heldReportsPending;
862
+ /** The in-flight identity-changing proposal; the proactive cron pauses while it is set. */
863
+ private attempt;
582
864
  /**
583
865
  * Set once we have PROOF that this device's clock runs fast, which
584
866
  * releases the `exp` guard in `canServeCachedToken`.
@@ -603,15 +885,10 @@ declare class Auth {
603
885
  private inflight;
604
886
  private refreshInflight;
605
887
  /**
606
- * Wave 62 — refresh failure back-pressure.
607
- *
608
- * Consecutive network-timeout failures don't wipe identity (refresh
609
- * may still be valid; iOS NSURLSession was just stalled) but do
610
- * back off so the next caller doesn't immediately fire another
611
- * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
612
- * the counter resets + retries are allowed. AppState foreground
613
- * also clears it so a user returning to the app gets an immediate
614
- * fresh attempt.
888
+ * Wave 62 — refresh failure back-pressure. Consecutive network-timeout
889
+ * failures don't wipe identity (refresh may still be valid; iOS NSURLSession
890
+ * was just stalled) but do back off so the next caller doesn't immediately
891
+ * fire another fetch that will also time out. AppState foreground clears it.
615
892
  */
616
893
  private refreshFailureCount;
617
894
  private refreshCooldownUntil;
@@ -621,176 +898,132 @@ declare class Auth {
621
898
  private lastDeletionCancelled;
622
899
  private lastAnonRetirement;
623
900
  private lastTakeover;
624
- /**
625
- * Anon refresh token captured at `signIn()` time, stashed keyed to the
626
- * issued `mfaChallengeToken` so the later `challengeMfa()` can forward it
627
- * for device-takeover (s58.111). Before this, the MFA branch wiped the anon
628
- * identity before the challenge resolved → the takeover hint was lost → one
629
- * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
630
- */
631
- private pendingAnonTakeover;
632
901
  /**
633
902
  * Resolves when the parent SDK has finished its async init() —
634
903
  * AsyncStorage hydrated + auth.hydrate() restored the cached
635
904
  * session. All public auth methods await this internally so a
636
905
  * customer who forgets `await sendora.init()` before showing the
637
- * signup UI doesn't hit the cold-start race where the upgrade
638
- * path's `storage.get(REFRESH_KEY)` returns null even though a
639
- * valid anonymous session is sitting on disk.
640
- *
641
- * Industry-standard "SDK gates on internal readiness" pattern
642
- * (Firebase Auth, Auth0, Clerk all do equivalent internal
643
- * gating).
906
+ * signup UI doesn't hit the cold-start race.
644
907
  */
645
908
  private readyPromise;
646
909
  private resolveReady;
647
- /**
648
- * True once `hydrate()` has run. Distinguishes "no session restored YET"
649
- * from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
650
- */
910
+ /** True once `hydrate()` has run. Gates `onAuthStateChanged`'s replay-on-subscribe. */
651
911
  private hydrated;
652
912
  constructor(hooks: AuthHooks);
653
913
  /**
654
- * Re-hydrate session from storage. Called by the parent SDK during
655
- * init() after Storage.hydrate() has run. Marks the internal ready
656
- * promise as resolved so queued auth calls unblock.
657
- *
658
- * s58.47 — restore the user even when the cached ACCESS token is
659
- * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
660
- * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
661
- * caused a cold-start regression: if the access token rotated out
662
- * of storage (TTL elapsed, app killed between persists, partial
663
- * write, AsyncStorage eviction) the user appeared signed out even
664
- * though their refresh chain was still alive in storage. Hosts
665
- * then called `signInAnonymously()` on every launch and minted a
666
- * fresh anon user, fragmenting analytics across sessions
667
- * (Pulse News symptom). With the refresh token present, the next
668
- * `getAccessToken()` call will silently refresh + restore a valid
669
- * access token without disturbing identity.
914
+ * Restore the session from storage. Called by the parent SDK during
915
+ * init() after Storage.hydrate() has run. Disk-only, never waits on the
916
+ * network; resolves the internal ready gate so queued auth calls unblock.
917
+ *
918
+ * Reads `auth_session` FIRST, then the four frozen keys. When the frozen keys
919
+ * carry a refresh token that differs from the record's, the FROZEN keys win:
920
+ * every SDK version writes them, so after a 1.41 → 1.40 → 1.41 round trip
921
+ * they are the fresher copy and the stale `auth_session` must not resurrect a
922
+ * rotated-away token. The record is then written forward from them.
923
+ *
924
+ * Coherence (INV-9): user ∧ refresh → judged by the project guard
925
+ * (match → installed; mismatch → `withheld`, ZERO writes; unknown → stay
926
+ * `unknown`, one gap-fill refresh re-judges it); refresh ∧ ¬user → `unknown`
927
+ * (LIMBO_A — never `signed_out`, never a mint; the refresh token is the only
928
+ * thing that can recover the account, 1.28.0); user ∧ ¬refresh → the stale
929
+ * blob is cleared → `signed_out`. Held slots: a slot whose `userId` equals the
930
+ * current user is dropped (current wins); a slot that fails to parse is KEPT
931
+ * and ignored, never cleared (P11).
670
932
  */
671
933
  hydrate(): void;
934
+ /** Read `auth_session` + the four frozen keys and reconcile them (see `hydrate()`). */
935
+ private readDiskSession;
936
+ /** Load the K slots into the in-memory mirror; dedupe against the current user (current wins); keep unparseable slots untouched. */
937
+ private hydrateHeldSlots;
938
+ /** Parse one slot off disk. `null` for an empty OR unparseable slot (the latter is left on disk, P11). */
939
+ private readSlot;
940
+ /**
941
+ * The closed boot/identity state. Synchronous, never waits on the network.
942
+ *
943
+ * - `unknown` — not hydrated yet, OR a refresh token is on disk but the
944
+ * user is not readable / not yet judged (the gap-fill
945
+ * refresh settles it). Session-installing verbs QUEUE here.
946
+ * - `signed_out` — no refresh token. The only state a mint executes in.
947
+ * - `anonymous` / `identified` — a live session of that kind.
948
+ * - `withheld` — a stored session refused by `init({expectedProjectId})`.
949
+ *
950
+ * ⚠ This is the ONLY way to tell `withheld` and `unknown` from `signed_out`:
951
+ * `getUserSync()` is `null` and `isReady()` is `true` in all three. Consumers
952
+ * MUST default-branch — a later value ships opt-in-gated or in a MAJOR.
953
+ */
954
+ status(): AuthStatus;
955
+ /** Resolves after hydrate with the settled status — possibly `unknown` or `withheld`. Never waits on the network. */
956
+ authStateReady(): Promise<AuthStatus>;
672
957
  getCurrentUser(): AuthUser | null;
958
+ /** Alias of `getCurrentUser()` gated on hydration (parity with `Sendora.getUserSync()`). */
959
+ getUserSync(): AuthUser | null;
960
+ /** True once `hydrate()` has run. `true` in `withheld` and `unknown` too — read `status()`. */
961
+ isReady(): boolean;
673
962
  /**
674
- * Subscribe to device-takeover events (s58.111+). Backend retires
675
- * the anonymous user_id whenever a previously-anon device signs in
676
- * to an identified account. Use this to clean up any local mirror
677
- * keyed by the anonymous id — your own users table, an analytics
678
- * cache, an audience filter — without needing a webhook receiver.
679
- *
680
- * Fires on EVERY identified sign-in path — `signIn`, `loginSocial`,
681
- * `signInWithGameCenter`, `signInWithPlayGames`, `challengeMfa`,
682
- * `verifyMagicLink`, `verifyEmailOtp`, passkey authentication, and SSO
683
- * completion (`consumeSsoFragment`) — because it is fired centrally from
684
- * `persist()` whenever the response carries `retiredAnonUserId`.
685
- *
686
- * ⚠ The two gaming paths are the ones that matter most here, and were
687
- * missing from this list before 1.31.0. When the presented player identity
688
- * already belongs to another account, the sign-in ADOPTS that account and
689
- * the anonymous account on this device is retired — its row is DELETED
690
- * server-side. This listener is the only client-side signal that happened;
691
- * the call itself resolves successfully. If you would rather it not happen
692
- * at all, pass `onCredentialInUse: "reject"`.
693
- *
694
- * Returns an unsubscribe function.
963
+ * Subscribe to device-takeover events (s58.111+). LEGACY after ADR-033: a
964
+ * wave-1 SDK never sends the guest hint, so the server never retires a guest
965
+ * for its sign-ins and this fires only if the SERVER reports a retirement.
966
+ * Kept callable; replace your handler with `onAuthStateChanged` +
967
+ * `guest_held` / `getHeldGuests()`. Returns an unsubscribe function.
695
968
  *
696
- * Listeners are best-effort: errors thrown inside them are swallowed
697
- * + logged in dev mode. Auth state never depends on listener success.
969
+ * Historically the account-DELETING paths were the gaming sign-ins —
970
+ * `signInWithGameCenter` / `signInWithPlayGames` (and `loginSocial`) adopting
971
+ * an existing account retired this device's guest row while the call itself
972
+ * resolved successfully. On 1.41.0 those calls HOLD the guest instead; a
973
+ * takeover can only be reported for a sign-in that reached the legacy path.
698
974
  */
699
975
  onDeviceTakeover(listener: (evt: DeviceTakeoverEvent) => void): () => void;
700
976
  /**
701
- * Subscribe to every auth-state transition (1.28.0) — the single stream that
702
- * answers "what happened to my session?". Firebase `onAuthStateChanged` /
703
- * Supabase `onAuthStateChange` parity.
977
+ * Subscribe to every auth-state transition — the single stream that answers
978
+ * "what happened to my session?". Firebase `onAuthStateChanged` / Supabase
979
+ * `onAuthStateChange` parity.
704
980
  *
705
- * The reason it exists: a session that dies in the BACKGROUND (the server
706
- * rejected the stored refresh token) used to emit no signal whatsoever, so
707
- * an app could not distinguish it from a deliberate sign-out and only
708
- * noticed when `getAccessToken()` began returning null. That transition is
709
- * now `{ event: "signed_out", reason: "session_expired" }`.
710
- *
711
- * Fires the CURRENT state immediately on subscribe once `init()` has
712
- * hydrated (a `signed_in` when a session was restored from disk — including
713
- * offline, since hydrate is disk-only), so a subscriber never misses the
714
- * state it started in. Nothing is emitted for a signed-out cold start.
981
+ * Replays the SETTLED boot state exactly once on subscribe, once `init()` has
982
+ * hydrated, as `initial{status,user,withheld?}` — including `signed_out`,
983
+ * `unknown` and `withheld`, so a subscriber can never confuse "no session"
984
+ * with "not hydrated yet". With a live session (`anonymous` / `identified`)
985
+ * `initial` is FOLLOWED by the legacy `signed_in{user}` replay that
986
+ * 1.28.0–1.40.0 sent (kept — ADR-023); new code should read `initial` and
987
+ * ignore a `signed_in` that carries no `cause` right after it.
715
988
  *
716
989
  * A failed sign-in emits NOTHING: the session is unchanged, so there is no
717
990
  * transition to report — the rejected promise is the whole story.
718
991
  *
719
- * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
720
- * and never affects auth state. Returns an unsubscribe function.
992
+ * ⚠ This is a TRANSITION stream, not a health or connectivity signal. A
993
+ * transient failure of the background token refresh (offline, timeout, 429,
994
+ * 5xx) emits nothing: the stored refresh token is kept and the refresh retried
995
+ * with backoff. Only a server-REJECTED refresh token produces `signed_out`
996
+ * (reason `session_expired`, or the server's own `revoked` / `deleted`). For
997
+ * liveness read `getAccessToken()` (null = no usable token right now).
998
+ *
999
+ * Replacing a guest with an account emits `guest_held` then ONE `signed_in`
1000
+ * — no `signed_out`. Listeners are best-effort; a throwing listener is
1001
+ * swallowed (dev-logged). Returns an unsubscribe function.
721
1002
  */
722
1003
  onAuthStateChanged(listener: AuthStateListener): () => void;
723
1004
  /** Fan a state change out to subscribers. Never throws. */
724
1005
  private emitAuthState;
725
- /**
726
- * Inspect the most recent device-takeover event observed by the
727
- * SDK. Cleared on signOut. Returns null when no takeover has
728
- * happened during this session — useful for late subscribers or
729
- * post-sign-in side-effects (e.g. app-launch hydrate).
730
- */
1006
+ /** The most recent device-takeover event observed, or null. Cleared on signOut. */
731
1007
  getLastDeviceTakeover(): DeviceTakeoverEvent | null;
732
- /** Internal — fan out to subscribers + cache the latest. */
733
1008
  private fireDeviceTakeover;
734
1009
  /**
735
1010
  * Subscribe to deletion-cancelled events (s58.269): fires when a sign-in
736
1011
  * cancelled a pending self-service account deletion within its grace window
737
- * (the account is restored, same user_id). Use it to show "your account
738
- * deletion was cancelled" + reconcile local state. Fires from signIn /
739
- * loginSocial / signInWithGameCenter / signInWithPlayGames. Returns an
740
- * unsubscribe function; listener errors are swallowed (best-effort).
1012
+ * (the account is restored, same user_id). Also surfaced as
1013
+ * `SignInResult.reactivated`. Returns an unsubscribe function.
741
1014
  */
742
1015
  onDeletionCancelled(listener: (evt: DeletionCancelledEvent) => void): () => void;
743
1016
  /** The most recent deletion-cancelled event this session, or null. Cleared on signOut. */
744
1017
  getLastDeletionCancelled(): DeletionCancelledEvent | null;
745
1018
  /**
746
- * What the last sign-in did with this device's guest account, or null if no
747
- * sign-in has happened this session (s58.278).
748
- *
749
- * The one worth acting on is `"preserved"`: a guest account was presented,
750
- * was NOT retired, and is therefore still alive server-side — so "recover
751
- * your other account" is a real offer rather than a guess. `"retired"` means
752
- * migrate from `onDeviceTakeover`'s id; `"none"` means there was nothing to
753
- * reconcile. Cleared on signOut.
1019
+ * What the last sign-in did with this device's guest account (s58.278).
1020
+ * LEGACY: a wave-1 SDK never presents a guest token, so a current backend
1021
+ * answers `"none"` for every sign-in. Read `getHeldGuests()` instead — the
1022
+ * guest is on this device, held. Recorded only when the server states the
1023
+ * field. Cleared on signOut.
754
1024
  */
755
1025
  getLastAnonRetirement(): AnonRetirementOutcome | null;
756
- /** Internal — fan out deletion-cancelled to subscribers + cache the latest. */
757
1026
  private fireDeletionCancelled;
758
- /**
759
- * Returns a VALID (non-expired) access token, or null.
760
- *
761
- * Hard contract (matches sdk-ios / sdk-android, and the industry
762
- * token-client pattern — Supabase `_callRefreshToken`, Auth0
763
- * `getTokenSilently`, Firebase `getIdToken`): this method NEVER
764
- * returns a token past its `exp`. Callers attach the result as a
765
- * Bearer to arbitrary resource servers — including third-party
766
- * backends (PostgREST/Supabase) that have NO knowledge of Sendora's
767
- * own refresh grace window — so a token must be either fresh or
768
- * absent. A token expired by hours is useless and yields a 401.
769
- *
770
- * Resolution order:
771
- * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
772
- * before BOTH the tracked deadline and the token's own `exp`) →
773
- * return it. Fast path, no I/O. See `canServeCachedToken` for why
774
- * both are required and when the second is released.
775
- * Pass `{ forceRefresh: true }` to skip this step when the caller
776
- * knows the tracked deadline is wrong.
777
- * 2. No cached token AND no refresh token in storage → genuinely
778
- * signed out → null.
779
- * 3. Expired/near-expiry but a refresh chain exists:
780
- * a. A refresh is already inflight → AWAIT it and return the
781
- * fresh token. Concurrent callers coalesce onto the single
782
- * in-flight `/token/refresh` (single-flight) — nobody is
783
- * handed a stale token. The inflight fetch is
784
- * AbortController-bounded (http.ts caps `/auth-service/*` at
785
- * 10s), so awaiting can NEVER hang the JS bridge. That
786
- * bounded-fetch guarantee — NOT the old stale-token return —
787
- * is what actually fixed the Wave-62 cold-launch hang.
788
- * b. A recent refresh failed and we're in backoff cooldown →
789
- * null. Honest "no usable token right now" rather than a
790
- * provably-expired one; the AppState foreground hook clears
791
- * the cooldown for an immediate retry on resume.
792
- * c. Otherwise → fire the refresh and await it.
793
- */
794
1027
  /**
795
1028
  * The cached-token guard, and the single place the two deadlines are
796
1029
  * combined. Both must agree before a cached token is served:
@@ -801,16 +1034,8 @@ declare class Auth {
801
1034
  * full offset on a constantly-wrong clock.
802
1035
  *
803
1036
  * Guard 2 is released once `clockSkewConfirmed` proves the second failure
804
- * mode is the one we are actually in.
805
- *
806
- * The gap this closes: `accessExpiresAt` is written in the device's clock
807
- * frame at mint and PERSISTED. Step the clock backwards between mint and
808
- * read — an iPhone acquiring automatic time after a long power-off, a
809
- * restore, or a manual set — and the deadline is stale by the size of the
810
- * correction, surviving relaunches. The SDK then serves a provably dead
811
- * token, every request 401s, and because the fast path returns before
812
- * reaching `persist()` or `wipeLocalIdentity()` nothing emits
813
- * `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
1037
+ * mode is the one we are actually in. ⚠ NEVER derive one deadline from the
1038
+ * other (`accessExpiresAt = exp * 1000` throws away skew-invariance).
814
1039
  */
815
1040
  private canServeToken;
816
1041
  private canServeCachedToken;
@@ -826,67 +1051,102 @@ declare class Auth {
826
1051
  * the correction, so a mint-time probe records "clock is fast", and the flag
827
1052
  * then waves through the very token the guard exists to refuse. Probing here
828
1053
  * — only on a refresh we performed BECAUSE `exp` contradicted a tracked
829
- * deadline that still claimed life — separates the two cases, because by now
830
- * the clock has either been corrected (fresh token reads live → stale
831
- * deadline, guard holds) or is genuinely fast (fresh token still reads dead
832
- * → release the guard, one probe, no loop).
1054
+ * deadline that still claimed life — separates the two cases: one probe, no loop.
833
1055
  */
834
1056
  private refreshAndReconcile;
835
- /**
836
- * Record whether a token the server issued MOMENTS AGO already reads as
837
- * expired. It cannot genuinely be — so a `false` here is proof the device
838
- * clock runs fast, and releases the `exp` guard for this process.
839
- *
840
- * Call this at every site that installs a server-issued token, and only
841
- * with a token that just arrived. Handing it a cached token would let a
842
- * stale deadline confirm itself.
843
- */
1057
+ /** A token the server issued MOMENTS AGO already reads as expired ⇒ the clock runs fast; release the `exp` guard for this process. */
844
1058
  private learnClockSkewFrom;
845
1059
  /** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
846
1060
  private deadlinesDisagree;
1061
+ /**
1062
+ * Returns a VALID (non-expired) access token, or null.
1063
+ *
1064
+ * Hard contract (matches sdk-ios / sdk-android, and the industry
1065
+ * token-client pattern — Supabase `_callRefreshToken`, Auth0
1066
+ * `getTokenSilently`, Firebase `getIdToken`): this method NEVER returns a
1067
+ * token past its `exp`. Callers attach the result as a Bearer to arbitrary
1068
+ * resource servers that know nothing of Sendora's refresh grace, so a token
1069
+ * must be either fresh or absent.
1070
+ *
1071
+ * Resolution order:
1072
+ * 0. `withheld` → null, no refresh (ADR-033 [2c]: the refused session is
1073
+ * never served and never refreshed by this process).
1074
+ * 1. Cached token with comfortable life left (>= 30 s before BOTH the
1075
+ * tracked deadline and the token's own `exp`) → return it. Fast path.
1076
+ * `{ forceRefresh: true }` skips it (the caller has out-of-band evidence
1077
+ * the tracked deadline is wrong); it does NOT bypass the single-flight or
1078
+ * the backoff cooldown.
1079
+ * 2. No refresh token in storage → genuinely signed out → null.
1080
+ * 3. Expired/near-expiry but a refresh chain exists: a refresh already
1081
+ * inflight → await it (single-flight, AbortController-bounded, can never
1082
+ * hang the bridge); in backoff cooldown → null (honest "no token now");
1083
+ * else fire the refresh and await it.
1084
+ */
847
1085
  getAccessToken(options?: {
848
1086
  forceRefresh?: boolean;
849
1087
  }): Promise<string | null>;
850
- /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
1088
+ /** Sync read — returns whatever's cached even if expired (`null` in `withheld`). Prefer the async variant. */
851
1089
  getAccessTokenSync(): string | null;
852
1090
  /**
853
- * Mint (or return) an anonymous session.
854
- *
855
- * Reuses the existing anonymous user when there already is one — the same
856
- * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
857
- * that calls this defensively on every cold launch (the most natural thing
858
- * to write) mints a fresh `user_id` each time, and `persist()` overwrites
859
- * the stored refresh token — the previous anonymous account's only durable
860
- * handle — with no takeover, no webhook and no state event. That is the same
861
- * silent progress loss as a failed sign-in wiping the session, except it
862
- * happens on a *healthy* network.
863
- *
864
- * Pass `forceNew: true` to deliberately abandon the current anonymous
865
- * session and mint a separate one.
1091
+ * The currently-stored refresh token, or null when signed out (and in
1092
+ * `withheld`). Kept for integrators that journal the guest token for the
1093
+ * deprecated `merge()`; since 1.41.0 the guest is HELD on the device instead —
1094
+ * read `getHeldGuests()`.
1095
+ */
1096
+ getRefreshToken(): string | null;
1097
+ /**
1098
+ * Return the current session, minting an anonymous one only when there is
1099
+ * none.
1100
+ *
1101
+ * - `anonymous` / `identified` → returned as-is, no network (the 1.29 / 1.40.0
1102
+ * guards as one row). Read `user.isAnonymous` to know which kind you got.
1103
+ * - `signed_out` → with `autoRestoreHeldGuest` on (default) and an eligible
1104
+ * held guest on the device, the NEWEST one is restored instead of minting —
1105
+ * visibly: `signed_in{cause:'restore'}` + `guest_released{reason:'restored'}`.
1106
+ * A dead slot is released (`guest_released{dead}`) and the next tried; none
1107
+ * left → a fresh mint in the same call. Otherwise `POST /anonymous` →
1108
+ * `signed_in{cause:'mint'}`.
1109
+ * - `unknown` → queued until the gap-fill refresh settles the status.
1110
+ * - `withheld` → rejects `AuthError{kind:'config', code:'SESSION_WITHHELD'}`.
1111
+ *
1112
+ * `forceNew: true` abandons the current session and mints a separate guest:
1113
+ * from a guest the current guest is HELD (never deleted); from an identified
1114
+ * session it is replaced silently (its refresh token revoked). At the held
1115
+ * capacity (K=5) it throws `HeldGuestLimitError` and mutates nothing.
866
1116
  */
867
1117
  signInAnonymously(opts?: {
868
1118
  name?: string;
869
1119
  metadata?: Record<string, unknown>;
870
1120
  forceNew?: boolean;
871
1121
  }): Promise<AuthUser>;
872
- /**
873
- * Create an email/password account.
874
- *
875
- * Industry-standard upgrade-first flow (matches Firebase
876
- * `linkWithCredential`, Auth0 anonymous-merge, Supabase
877
- * `linkIdentity`): when a refresh token is present in storage we
878
- * try `/auth-service/upgrade` first — backend validates the
879
- * refresh, checks the bound user is anonymous, and promotes in
880
- * place preserving `user.id`. If the bound user is NOT anonymous
881
- * (already identified, expired refresh, etc.), backend rejects
882
- * with FORBIDDEN_NON_ANONYMOUS / INVALID_REFRESH and we fall back
883
- * to a fresh /signup.
884
- *
885
- * Why we don't gate on the cached `this.user.isAnonymous`
886
- * boolean: the in-memory user can be stale during cold start or
887
- * fast-refresh races. The refresh token in storage is the
888
- * authoritative signal — if it's there, the upgrade path is
889
- * worth attempting; backend has the source of truth.
1122
+ /** Alias of `signInAnonymously()` — "make sure I have a session". */
1123
+ ensureSession(): Promise<AuthUser>;
1124
+ /** Row 6 (and row 11's second half): `POST /anonymous` → ProjectGuard on the RESPONSE → install. */
1125
+ private mintFresh;
1126
+ /**
1127
+ * Row 7 — one auto-restore attempt for `signInAnonymously()`. Returns the
1128
+ * restored user, or `null` when the slot was dead and released (the caller
1129
+ * tries the next slot, then mints). A transient failure propagates.
1130
+ */
1131
+ private restoreSlotForMint;
1132
+ /** The per-attempt B10 key: reuse the persisted one (a crash-retry) or mint a new one and persist it until MINT_OK. */
1133
+ private currentMintKey;
1134
+ /**
1135
+ * Create an email/password account — routed by `status()`:
1136
+ *
1137
+ * - `anonymous` → `POST /me/link/email` on the current Bearer session: the
1138
+ * guest is promoted IN PLACE (same `user.id`), the rotated
1139
+ * pair is installed, `signed_in{cause:'link'}` fires. A
1140
+ * taken email throws `EmailAlreadyTakenError` and changes
1141
+ * nothing.
1142
+ * - `signed_out` → `POST /signup`, a fresh account.
1143
+ * - `identified` → `AlreadyIdentifiedError`, decided LOCALLY (no round-trip,
1144
+ * nothing minted — use `linkEmailPassword()` instead).
1145
+ * - `unknown` → waits for the status to settle, then routes as above.
1146
+ * - `withheld` → rejects `SESSION_WITHHELD`.
1147
+ *
1148
+ * Never calls `/upgrade` (ADR-033 INV-4): a generic 401 there used to wipe
1149
+ * the guest, and the "dead token → wipe → fresh signup" branch is gone.
890
1150
  */
891
1151
  signUp(email: string, password: string, opts?: {
892
1152
  name?: string;
@@ -896,6 +1156,10 @@ declare class Auth {
896
1156
  * Sign in with email + password. When the user has confirmed MFA,
897
1157
  * returns `{mfaRequired, mfaChallengeToken}` instead of an
898
1158
  * `AuthUser`; caller must follow up with `challengeMfa()`.
1159
+ *
1160
+ * A sign-in from a guest HOLDS the guest on this device (`guest_held`, then
1161
+ * ONE `signed_in{cause:'sign_in'}`); the guest's server row survives. The
1162
+ * request never carries the guest's refresh token (ADR-033 INV-3).
899
1163
  */
900
1164
  signIn(email: string, password: string): Promise<AuthUser | {
901
1165
  mfaRequired: true;
@@ -906,11 +1170,18 @@ declare class Auth {
906
1170
  * Verify an IdP-issued credential (authorization code OR id_token)
907
1171
  * and mint a Sendora session. Customer's app handles the IdP
908
1172
  * dance — typically via expo-auth-session / react-native-app-auth
909
- * — then hands the result here. See web SDK for full provider
910
- * matrix; mirrors that surface 1:1.
1173
+ * — then hands the result here. Mirrors the web SDK's surface 1:1.
1174
+ *
1175
+ * `link: true` (ADR-033 INV-14) — attach-or-error, never a silent switch:
1176
+ * from a guest the account is promoted IN PLACE (same `user.id`,
1177
+ * `signed_in{cause:'link'}`); over an identified session the identity is
1178
+ * attached (`user_updated{cause:'link'}`), an identity already on the account
1179
+ * resolves quietly, one owned elsewhere throws `CredentialInUseError`
1180
+ * (or switches when `onCredentialInUse: "adopt"`), a stale session throws
1181
+ * `RECENT_AUTH_REQUIRED`. In `signed_out` it is a plain sign-in.
911
1182
  */
912
1183
  loginSocial(input: {
913
- provider: "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
1184
+ provider: SocialCredentialProvider;
914
1185
  code?: string;
915
1186
  idToken?: string;
916
1187
  redirectUri?: string;
@@ -919,21 +1190,7 @@ declare class Auth {
919
1190
  firstName?: string;
920
1191
  lastName?: string;
921
1192
  };
922
- /**
923
- * ADR-025 link-in-place opt-in. When this device is anonymous and you set
924
- * `link: true`, an anon→social upgrade KEEPS the same user id (sub) — the
925
- * anon account is promoted in place (like Firebase linkWithCredential)
926
- * instead of a device-takeover that mints a new id. No effect when the
927
- * device isn't anonymous, or when the social identity already belongs to
928
- * another account (collision → falls back to takeover/merge).
929
- */
930
1193
  link?: boolean;
931
- /**
932
- * What to do if this social identity (or its verified email) already
933
- * belongs to an account. **Omit it** — the default adopts when nothing
934
- * would be destroyed and refuses when a live guest session would be
935
- * deleted. See {@link CredentialCollisionPolicy}.
936
- */
937
1194
  onCredentialInUse?: CredentialCollisionPolicy;
938
1195
  }): Promise<AuthUser>;
939
1196
  signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
@@ -946,7 +1203,7 @@ declare class Auth {
946
1203
  firstName?: string;
947
1204
  lastName?: string;
948
1205
  };
949
- /** ADR-025: keep the same user id on an anon→Apple upgrade. See `loginSocial`. */
1206
+ /** Attach-or-error (ADR-033 INV-14). See `loginSocial`. */
950
1207
  link?: boolean;
951
1208
  }): Promise<AuthUser>;
952
1209
  signInWithMicrosoft(code: string, redirectUri: string): Promise<AuthUser>;
@@ -958,15 +1215,18 @@ declare class Auth {
958
1215
  *
959
1216
  * **No-arg (1.23.0, recommended):** `signInWithGameCenter()` drives Game
960
1217
  * Center natively via the `@sendoracloud/sdk-react-native-gaming` companion
961
- * (`fetchItems(forIdentityVerificationSignature:)`, base64 signature/salt,
962
- * `teamPlayerID`, bundle id) and posts the result. Needs that companion + its
963
- * config plugin `{ gameCenter: true }` + a Dev Client — NOT Expo Go.
1218
+ * and posts the result. Needs that companion + its config plugin
1219
+ * `{ gameCenter: true }` + a Dev Client — NOT Expo Go.
964
1220
  *
965
1221
  * **Explicit:** `signInWithGameCenter(payload)` posts a payload you obtained
966
1222
  * from your OWN GameKit module (works anywhere, incl. bare RN).
967
1223
  *
968
- * `link: true` KEEPS the same user id when upgrading an anonymous device
969
- * (ADR-025 link-in-place); no effect off-anon or on a collision.
1224
+ * `link: true` = attach-or-error (ADR-033 INV-14): promotes a guest in place
1225
+ * (same `user.id`); over an identified session attaches, resolves quietly when
1226
+ * the identity is already on the account, throws `CredentialInUseError` when
1227
+ * it is owned elsewhere (pass `onCredentialInUse: "adopt"` to switch — the
1228
+ * guest is HELD on the device, never deleted). A plain call switches accounts
1229
+ * and holds the previous guest.
970
1230
  */
971
1231
  signInWithGameCenter(opts?: GameSignInOptions): Promise<AuthUser>;
972
1232
  signInWithGameCenter(payload: GameCenterPayload, opts?: GameSignInOptions): Promise<AuthUser>;
@@ -974,26 +1234,79 @@ declare class Auth {
974
1234
  * Google Play Games sign-in (email-less, Android-only).
975
1235
  *
976
1236
  * **No-arg (1.23.0, recommended):** `signInWithPlayGames()` drives Play Games
977
- * natively via the `@sendoracloud/sdk-react-native-gaming` companion
978
- * (`requestServerSideAccess(webClientId, false)`, web client id read from the
979
- * config-plugin manifest meta-data) and posts the `serverAuthCode`. Needs that
980
- * companion + its plugin `{ playGamesWebClientId, playGamesProjectId }` + a Dev
981
- * Client — NOT Expo Go.
1237
+ * natively via the companion and posts the `serverAuthCode`. Needs the
1238
+ * companion + its plugin + a Dev Client — NOT Expo Go.
982
1239
  *
983
1240
  * **Explicit:** `signInWithPlayGames({ serverAuthCode })` posts a code you
984
1241
  * obtained from your OWN Play Games module.
985
1242
  *
986
- * `link: true` = ADR-025 link-in-place (same user id on an anon upgrade).
1243
+ * `link: true` = attach-or-error (ADR-033 INV-14); see `signInWithGameCenter`.
987
1244
  */
988
1245
  signInWithPlayGames(opts?: GameSignInOptions): Promise<AuthUser>;
989
1246
  signInWithPlayGames(payload: {
990
1247
  serverAuthCode: string;
991
1248
  }, opts?: GameSignInOptions): Promise<AuthUser>;
1249
+ /**
1250
+ * Shared gaming-identity sign-in. Resolves the credential FIRST (for the
1251
+ * no-arg native path this triggers the Game Center / Play Games sheet inside
1252
+ * the serialized op — a cancelled sheet leaves the session untouched), then
1253
+ * routes by `link` and `status()` exactly like `loginSocial`.
1254
+ */
1255
+ private gameIdentitySignIn;
1256
+ /**
1257
+ * The legacy-signature LINK (`signInWith*({link:true})`, `loginSocial({link:true})`)
1258
+ * in `anonymous` / `identified` (ADR-033 rows 15–16, §8-Q18):
1259
+ * - a promotion / attach resolves with the user (`signed_in{cause:'link'}` on
1260
+ * the same-`sub` promotion — preserved from 1.30–1.40; `user_updated` on an
1261
+ * identified attach; NOTHING on `alreadyLinked`);
1262
+ * - 409 → `CredentialInUseError`, or with `onCredentialInUse: "adopt"` the
1263
+ * explicit switch to the owning account (the guest is HELD);
1264
+ * - 403 → `AuthError` `RECENT_AUTH_REQUIRED` (kind `recent_auth_required`).
1265
+ */
1266
+ private legacyLink;
1267
+ /**
1268
+ * Exchange the MFA challenge token from `signIn` for a real session by
1269
+ * submitting a TOTP or recovery code. A wrong code throws and preserves the
1270
+ * current session for a retry; success holds the guest (if any) and installs
1271
+ * the account exactly like a direct sign-in.
1272
+ */
1273
+ challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
1274
+ /**
1275
+ * Plain sign-in / SWITCH with a typed credential. Never links: from a guest
1276
+ * the guest is HELD (`previous.held === true`); from an identified account
1277
+ * the previous session is replaced and its refresh token revoked
1278
+ * (`previous.isAnonymous === false` ⇒ skip `mergeAnon`, reset local save
1279
+ * state). The request never carries a guest credential (INV-3). MFA returns
1280
+ * `{status:'mfa_required', verify(code)}`.
1281
+ */
1282
+ signInWith(credential: Credential): Promise<SignInResult>;
1283
+ /**
1284
+ * Attach a credential to the CURRENT session — the ONLY upgrade (INV-4).
1285
+ * From a guest: promoted in place (same `user.id`, `upgraded: true`,
1286
+ * `user_updated{cause:'link'}`). From an identified account: attached
1287
+ * (`user_updated`), or `alreadyLinked: true` with no event. Owned elsewhere →
1288
+ * `{status:'credential_in_use', switchAccount(), keepCurrent()}` with nothing
1289
+ * mutated; stale session → `{status:'recent_auth_required'}`. Throws
1290
+ * `NOT_SIGNED_IN` in `signed_out` (nothing to attach to) and
1291
+ * `UNSUPPORTED_LINK_CREDENTIAL` for a kind that cannot be attached
1292
+ * (`magicLink`, `emailOtp`, `passkey`, `mfa`).
1293
+ */
1294
+ link(credential: Credential): Promise<LinkResult>;
1295
+ /**
1296
+ * The ONLY path from a refused link to a sign-in, BOUND to the refused
1297
+ * credential (INV-14): the same payload is reused while under 5 minutes old;
1298
+ * older than that, a Game Center / Play Games credential is re-fetched
1299
+ * natively and the Game Center `teamPlayerId` compared — a different player
1300
+ * aborts with `CREDENTIAL_CHANGED` and nothing is mutated. (A Play Games
1301
+ * server auth code carries no player id, so a re-fetched code is used as-is.)
1302
+ */
1303
+ private switchAccount;
1304
+ private toSignInResult;
992
1305
  /** Link email + password to the current account (sub preserved). */
993
1306
  linkEmailPassword(email: string, password: string): Promise<AuthUser>;
994
1307
  /** Link an OAuth social identity (verified idToken or code) to the current account. */
995
1308
  linkSocial(input: {
996
- provider: "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
1309
+ provider: SocialCredentialProvider;
997
1310
  code?: string;
998
1311
  idToken?: string;
999
1312
  redirectUri?: string;
@@ -1033,76 +1346,39 @@ declare class Auth {
1033
1346
  serverAuthCode: string;
1034
1347
  }): Promise<AuthUser>;
1035
1348
  /**
1036
- * Shared link executor. POSTs the verified credential with the caller's
1037
- * end-user Bearer token, then refreshes the cached user object IN PLACE (no
1038
- * token rotation — the sub is unchanged). Deliberately NOT wrapped in
1039
- * serialize(): getAccessToken() serializes internally, so re-entering would
1040
- * deadlock — same non-serialized posture as deleteAccount().
1349
+ * Shared `link*()` executor — the LINK row with the `link*()` emission
1350
+ * (`user_updated{cause:'link'}`, never `signed_in`). A 409 throws
1351
+ * `CredentialInUseError`; a 403 throws `RECENT_AUTH_REQUIRED`; no session →
1352
+ * `NOT_SIGNED_IN` with no network call.
1041
1353
  */
1042
1354
  private linkCredential;
1043
- /**
1044
- * Refresh the cached user after a link — the sub is unchanged, so only the
1045
- * user object + its storage copy are updated (no token/identity rotation).
1046
- */
1047
- private updateLocalUser;
1048
1355
  /**
1049
1356
  * List the auth methods/identities linked to the current account (ADR-030
1050
- * read side, s58.270). Returns every social/gaming identity on this account
1051
- * plus a `hasPassword` flag — the cross-device / reinstall-durable source of
1052
- * truth for a "Connected: Game Center · Google" UI (an on-device tracker
1053
- * misses a link made on another device). Bearer-gated network read (NOT a
1054
- * cache read — `getUserSync()` only holds the primary `signupMethod`/
1055
- * `lastLoginMethod`). Resolves a FRESH access token first (mirrors
1056
- * `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
1357
+ * read side, s58.270): every social/gaming identity plus a `hasPassword`
1358
+ * flag — the cross-device / reinstall-durable source of truth for a
1359
+ * "Connected: Game Center · Google" UI. Bearer-gated network read that
1360
+ * resolves a FRESH access token first. Firebase `user.providerData` /
1361
+ * Supabase `user.identities` parity.
1057
1362
  */
1058
1363
  listLinkedIdentities(): Promise<LinkedIdentitiesResponse>;
1059
1364
  /**
1060
- * Remove one linked provider from the signed-in account.
1061
- *
1062
- * The write half of `listLinkedIdentities()` — what a "Disconnect" button
1063
- * next to each entry on a Connected Accounts screen calls.
1365
+ * Remove one linked provider from the signed-in account — the write half of
1366
+ * `listLinkedIdentities()`.
1064
1367
  *
1065
1368
  * ⚠ The server REFUSES to remove the last way into the account, with
1066
- * `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
1067
- * account with no credentials still exists and still holds the user's data,
1068
- * but nothing can ever authenticate into it again — so this is a permanent
1069
- * lockout, not an inconvenience. A password counts as a credential.
1070
- *
1071
- * Prefer to prevent the tap rather than explain the refusal: read
1072
- * `listLinkedIdentities()` and disable Disconnect when
1073
- * `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
1074
- * backstop, not the UX.
1369
+ * `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"` — a
1370
+ * permanent lockout, not an inconvenience. Prefer preventing the tap: disable
1371
+ * Disconnect when `identities.length + (hasPassword ? 1 : 0) <= 1`.
1075
1372
  *
1076
1373
  * Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
1077
- * provider is not linked to this account. Removing an already-absent
1078
- * provider is NOT treated as success — a silent no-op would let a buggy
1079
- * screen report "disconnected" for a credential that is still attached.
1374
+ * provider is not linked (an absent provider is NOT reported as removed).
1080
1375
  */
1081
1376
  unlink(provider: string): Promise<UnlinkResult>;
1082
- /**
1083
- * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
1084
- * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
1085
- * identity, forwards it as `prevAnonRefreshToken`, and opts into
1086
- * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
1087
- * centrally from `retiredAnonUserId`.
1088
- */
1089
- private gameIdentitySignIn;
1090
- /**
1091
- * Read the stored anon refresh token if (and only if) the local
1092
- * subject is currently anonymous. Used by every identified-session
1093
- * mint path so the backend can run device-takeover (s58.111 +
1094
- * s58.112). Returns `undefined` when not anonymous — never forward
1095
- * an identified user's own refresh token.
1096
- */
1097
- private readPrevAnonRefreshToken;
1098
- /**
1099
- * Exchange the MFA challenge token from `signIn` for a real session
1100
- * by submitting a TOTP or recovery code from the user's authenticator.
1101
- */
1102
- challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
1103
1377
  sendMagicLink(email: string): Promise<void>;
1378
+ /** A magic-link token is routinely expired or already consumed (mail scanners pre-fetch, users tap twice); a rejection never costs the session. */
1104
1379
  verifyMagicLink(token: string): Promise<AuthUser>;
1105
1380
  sendEmailOtp(email: string): Promise<void>;
1381
+ /** Mistyping a 6-digit code is the most common outcome of this flow; the session survives for the retry. */
1106
1382
  verifyEmailOtp(email: string, code: string): Promise<AuthUser>;
1107
1383
  /** Trigger password-reset email. Resolves on 204 even when address is unknown (anti-enumeration). */
1108
1384
  requestPasswordReset(email: string): Promise<void>;
@@ -1112,6 +1388,81 @@ declare class Auth {
1112
1388
  verifyEmail(token: string): Promise<void>;
1113
1389
  /** Re-send the email-verification email for the currently-signed-in user. */
1114
1390
  sendVerificationEmail(): Promise<void>;
1391
+ /**
1392
+ * The guests held on this device, newest first (`heldAt` desc). Never exposes
1393
+ * tokens. Best-effort: a guest may have been deleted server-side since it was
1394
+ * held — that is only discovered when its token is presented (restore /
1395
+ * claim / discard), as `guest_released{reason:'dead'}`.
1396
+ *
1397
+ * `autoRestore === false` ⇔ the slot was stamped by `deleteAccount()`: only an
1398
+ * explicit `restoreHeldGuest(id)` revives it. `project` is the guest's JWT
1399
+ * `project` claim at HOLD (`null` when it was unreadable).
1400
+ */
1401
+ getHeldGuests(): HeldGuestSummary[];
1402
+ /** The newest held guest, or null. */
1403
+ getHeldGuest(): {
1404
+ userId: string;
1405
+ heldAt: number;
1406
+ } | null;
1407
+ /**
1408
+ * Fold a held guest INTO the current identified account (`POST /merge`):
1409
+ * reassigns its events, profile, entities and push tokens, DELETES the guest
1410
+ * row and fires `auth.user_merged`. Irreversible, Sendora-side only — it does
1411
+ * not touch app data keyed on the guest's `sub`. ⚠ OPTIONAL: an app whose
1412
+ * backend does not act on `auth.user_merged` MUST NOT call it.
1413
+ *
1414
+ * Identified only. `id` may be omitted only when exactly one guest is held
1415
+ * (else `AMBIGUOUS_HELD_GUEST`). The held token is refreshed FIRST (proves
1416
+ * liveness, repairs a spent parent inside the 60 s grace) and the rotation is
1417
+ * persisted into the slot BEFORE the merge; a dead guest releases the slot
1418
+ * (`guest_released{dead}`) and rejects `HELD_GUEST_GONE`. Emits
1419
+ * `user_updated{cause:'merge'}` + `guest_released{reason:'claimed'}`.
1420
+ */
1421
+ claimHeldGuest(id?: string): Promise<{
1422
+ user: AuthUser;
1423
+ stats: MergeStats;
1424
+ }>;
1425
+ /**
1426
+ * Sign back in AS a held guest without merging (the RESTORE row). From
1427
+ * `signed_out` or an identified account (whose refresh token is revoked
1428
+ * fire-and-forget); from a guest the CURRENT guest is held first (its slot is
1429
+ * written before the chosen one is released, so there is never a window with
1430
+ * zero handles to either). `id` may be omitted only when exactly one guest is
1431
+ * held. Client-side project check FIRST: a slot whose recorded `project`
1432
+ * mismatches `expectedProjectId ?? config.projectId` throws
1433
+ * `HeldGuestProjectMismatch` with no network and the slot kept. A dead guest
1434
+ * releases the slot and rejects `HELD_GUEST_GONE`. Emits
1435
+ * `signed_in{cause:'restore'}` (re-register push) + `guest_released{restored}`.
1436
+ */
1437
+ restoreHeldGuest(id?: string): Promise<AuthUser>;
1438
+ /**
1439
+ * DELETE a held guest on the server (`POST /guest/discard` — a real erasure:
1440
+ * data → credentials → row → `auth.user_deleted{reason:'guest_discarded'}`)
1441
+ * and release its slot. The app is the requester on behalf of its player
1442
+ * (s58.316); the SDK never calls this on its own. `id` may be omitted only
1443
+ * when exactly one guest is held. A 2xx or a 404 (already gone) releases the
1444
+ * slot; any other failure keeps it. Emits `guest_released{reason:'discarded'}`.
1445
+ */
1446
+ discardHeldGuest(id?: string): Promise<void>;
1447
+ /** Shared body of `discardHeldGuest()` and `deleteAccount({alsoDiscardHeldGuests})`. */
1448
+ private discardSlot;
1449
+ /**
1450
+ * While `status() === "withheld"`: why the stored session was refused. Never
1451
+ * exposes tokens. `userId` lets a `sub`-keyed backend record oldSub → newSub
1452
+ * before `discardWithheld()`. `null` in every other state.
1453
+ */
1454
+ getWithheld(): WithheldInfo | null;
1455
+ /**
1456
+ * Resolve `withheld` — the local counterpart of `signOut()` in that state
1457
+ * (and what `signOut()` does there). Precondition `status() === "withheld"`
1458
+ * (else resolves `false`, nothing mutated). CAS on the withheld refresh token;
1459
+ * clears the session keys only; fire-and-forget `/token/revoke` like
1460
+ * `signOut()`; LOCAL ONLY — never `/guest/discard`, never a server delete;
1461
+ * KEEPS held slots, the event queue and the persisted `user_id`. Emits
1462
+ * `signed_out{reason:'user'}`. The other exit is relaunching with the matching
1463
+ * `expectedProjectId`.
1464
+ */
1465
+ discardWithheld(): Promise<boolean>;
1115
1466
  enrollMfa(): Promise<{
1116
1467
  secret: string;
1117
1468
  otpauthUrl: string;
@@ -1119,14 +1470,10 @@ declare class Auth {
1119
1470
  }>;
1120
1471
  confirmMfa(code: string): Promise<boolean>;
1121
1472
  /**
1122
- * Disable MFA.
1123
- *
1124
- * ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
1125
- * `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
1126
- *
1127
- * ⚠ Discarded its response until s58.335, and `request()` never throws on a
1128
- * non-2xx (it warns and returns the Response), so the refusal had nowhere to
1129
- * surface — the app reported MFA as off while TOTP stayed enrolled.
1473
+ * Disable MFA. ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with
1474
+ * 403 `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`. The response is
1475
+ * inspected (s58.335): `request()` never throws on a non-2xx, so a discarded
1476
+ * return would report the refusal as success.
1130
1477
  */
1131
1478
  disableMfa(): Promise<void>;
1132
1479
  listMySessions(): Promise<Array<{
@@ -1136,174 +1483,236 @@ declare class Auth {
1136
1483
  lastUsedAt: string | null;
1137
1484
  createdAt: string;
1138
1485
  }>>;
1139
- /**
1140
- * Revoke one of the caller's own sessions ("sign out that device").
1141
- * ⚠ Discarded its response until s58.335 — a "sign out this device" button
1142
- * that reports success on a failure is the one place a user acts on the claim
1143
- * and then stops worrying.
1144
- */
1486
+ /** Revoke one of the caller's own sessions ("sign out that device"). Inspects the response (s58.335). */
1145
1487
  revokeSession(sessionId: string): Promise<void>;
1146
- /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1488
+ /** Revoke every session for the caller. Inspects the response (s58.335). */
1147
1489
  revokeAllSessions(): Promise<void>;
1148
1490
  /**
1149
1491
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
1150
- * Honors the project's configured grace period:
1151
- * - `"purged"` — hard-deleted immediately (grace = 0).
1152
- * - `"pending"` — deactivated + all sessions revoked now; hard-deleted at
1153
- * `scheduledPurgeAt`. The user can CANCEL by signing back in before then.
1154
- * Either outcome wipes local identity (the server has already revoked the
1155
- * session). Requires a signed-in user. Throws on a non-2xx response.
1156
- *
1157
- * Resolves a FRESH access token via `getAccessToken()` first (refreshing if
1158
- * the cached one expired) — unlike the other Bearer calls this is a one-shot
1159
- * destructive action, so a 401 from a stale token would silently strand the
1160
- * user with an undeleted account. A stale `this.accessToken` was the cause of
1161
- * the 401s seen in prod when a user tapped "delete" after the app sat idle.
1162
- */
1163
- deleteAccount(): Promise<{
1492
+ * Honors the project's configured grace period: `"purged"` (hard-deleted
1493
+ * now) or `"pending"` (deactivated + sessions revoked now, hard-deleted at
1494
+ * `scheduledPurgeAt`; cancellable by signing back in). Resolves a FRESH access
1495
+ * token first — a stale token's 401 would silently strand the user with an
1496
+ * undeleted account.
1497
+ *
1498
+ * Held guests (ADR-033): deleting an account never touches a held guest, on
1499
+ * the device or the server. By default they SURVIVE with auto-restore
1500
+ * suppressed (`autoRestore: false` — only an explicit `restoreHeldGuest(id)`
1501
+ * revives one; `ensureSession()` mints fresh). `alsoDiscardHeldGuests: true`
1502
+ * is the player's explicit ask to erase them too: each is discarded after the
1503
+ * 2xx, a slot that fails to discard stays held and is listed in
1504
+ * `discardFailed`. `heldGuests` = guests still held afterwards. The queued
1505
+ * analytics events are dropped (erasure completeness, 1.39.1).
1506
+ */
1507
+ deleteAccount(opts?: {
1508
+ alsoDiscardHeldGuests?: boolean;
1509
+ }): Promise<{
1164
1510
  status: "purged" | "pending";
1165
1511
  scheduledPurgeAt: string | null;
1166
1512
  graceDays: number;
1513
+ heldGuests: number;
1514
+ discardFailed: string[];
1167
1515
  }>;
1168
1516
  /**
1169
- * Start OIDC sign-in. Returns the IdP's authorization URL — caller
1170
- * opens it via React Native's `Linking.openURL()` (or
1171
- * `WebBrowser.openAuthSessionAsync` from `expo-web-browser` when
1172
- * inside Expo). The IdP redirects through Sendora's callback to the
1173
- * `returnTo` URL with `#sendora_oidc_token=...` in the fragment.
1174
- *
1175
- * On the customer's app side, `returnTo` MUST be a custom URL scheme
1176
- * registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
1177
- * (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
1178
- * call `consumeSsoFragment(url)` on the captured URL.
1517
+ * Start OIDC sign-in. Returns the IdP's authorization URL — caller opens it
1518
+ * via `Linking.openURL()` (or `WebBrowser.openAuthSessionAsync`). The IdP
1519
+ * redirects through Sendora's callback to the `returnTo` URL with
1520
+ * `#sendora_oidc_token=...` in the fragment; hand that URL to
1521
+ * `consumeSsoFragment(url)`. `returnTo` MUST be a custom URL scheme
1522
+ * registered in Info.plist / AndroidManifest. No guest hint travels (INV-3).
1179
1523
  */
1180
1524
  startSso(returnTo: string): Promise<{
1181
1525
  authorizationUrl: string;
1182
1526
  }>;
1183
- /**
1184
- * Kick off a SAML 2.0 SSO flow. Mirrors `startSso` for SAML —
1185
- * backend POST `/auth-service/sso/saml/start` returns the IdP
1186
- * AuthnRequest URL. Caller opens it with
1187
- * ASWebAuthenticationSession (iOS) / Custom Tabs (Android) /
1188
- * Linking, captures the callback URL via the standard
1189
- * `Linking.addEventListener` flow, then passes the captured URL to
1190
- * `consumeSsoFragment(url)`. consumeSsoFragment handles both OIDC
1191
- * and SAML fragments transparently.
1192
- */
1527
+ /** Kick off a SAML 2.0 SSO flow. Mirrors `startSso`; `consumeSsoFragment` handles both fragments. */
1193
1528
  startSaml(returnTo: string): Promise<{
1194
1529
  authorizationUrl: string;
1195
1530
  }>;
1196
1531
  /**
1197
- * Consume an OIDC callback URL captured via React Native's
1198
- * `Linking.addEventListener('url', ...)`. Reads the fragment for
1199
- * `sendora_oidc_token`, exchanges it for a session, and persists.
1200
- * Throws AuthError on `sendora_oidc_error=...` fragment.
1532
+ * Consume an OIDC/SAML callback URL captured via `Linking.addEventListener`.
1533
+ * Reads the fragment for `sendora_oidc_token` / `sendora_saml_token`,
1534
+ * exchanges it for a session via `/token/refresh`, and installs it as a
1535
+ * sign-in (holding a guest, if any). The callback is parsed and exchanged
1536
+ * FIRST — an IdP "Cancel" or a garbage fragment leaves the session untouched.
1201
1537
  */
1202
1538
  consumeSsoFragment(callbackUrl: string): Promise<AuthUser>;
1539
+ /** Populate a user object from access-token claims when a response carries none (pre-B17 `/token/refresh`). */
1540
+ private userFromClaims;
1541
+ /**
1542
+ * Build the Authorization header for end-user-authenticated routes. Awaits
1543
+ * `getAccessToken()` (parity with sdk-web since 1.41.0), so a cold start with
1544
+ * no cached access token or an expired token transparently refreshes first.
1545
+ * Throws `NOT_SIGNED_IN` when there is genuinely no usable session.
1546
+ */
1203
1547
  private bearerHeaders;
1204
1548
  private unwrap;
1205
1549
  /**
1206
- * Sign out: wipe local identity FIRST so the user is logged out on
1207
- * device even if the revoke network call hangs (airplane mode, 5xx).
1208
- * Revoke is fire-and-forget after the wipe.
1550
+ * Sign out: clear the local session FIRST so the user is logged out on
1551
+ * device even if the revoke network call hangs (airplane mode, 5xx); revoke
1552
+ * is fire-and-forget after the clear. Rotates the analytics anon id (a
1553
+ * person boundary). KEEPS held guests — a guest's refresh token is its only
1554
+ * credential, so wiping the slots on logout would be deletion by assumption
1555
+ * (use `discardHeldGuest()` / `reset({includeHeldGuests:true})` deliberately).
1556
+ * In `withheld` this is `discardWithheld()`.
1209
1557
  */
1210
1558
  signOut(): Promise<void>;
1211
- private serialize;
1212
1559
  /**
1213
- * Hot-path token refresh. Single-flight via `refreshInflight` so a
1214
- * UI batch firing 10 simultaneous reads never produces 10 /refresh
1215
- * round-trips. Returns null on any failure so callers can decide
1216
- * whether to surface a sign-in prompt or proceed anonymously.
1560
+ * @deprecated Transfer every event + trait from an anonymous user to the
1561
+ * currently-signed-in (target) user, then delete the anonymous row.
1217
1562
  *
1218
- * s58.46 — on a `INVALID_REFRESH_TOKEN` (new backend error code) or
1219
- * HTTP 401 we WIPE the stored refresh token. Pre-s58.46 we caught
1220
- * the error and returned null but kept the stored token intact, so
1221
- * the very next caller would re-attempt the same dead token in an
1222
- * infinite loop (Pulse News iOS hammered /refresh ~1×/s for hours).
1223
- * The wipe forces the next op to fall through to /anonymous or
1224
- * surface NOT_SIGNED_IN to the host app instead of looping.
1225
- */
1226
- private refreshAccessToken;
1563
+ * Since 1.41.0 the guest you signed in over is HELD on this device, so a
1564
+ * token that matches a held slot (its current refresh token OR the token it
1565
+ * was held with) routes to `claimHeldGuest(thatGuest)` — a DESTRUCTIVE claim.
1566
+ * Any other token takes the raw `/merge` path as before. Prefer
1567
+ * `claimHeldGuest(id)`; never call either from a backend that does not act
1568
+ * on `auth.user_merged`.
1569
+ */
1570
+ merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<MergeStats>;
1571
+ /** `POST /merge` — bypasses `callAuth`'s `isAuthApiResponse` gate (`/merge` returns `{ ...stats, tokens, csrfToken }`, no `user`). */
1572
+ private postMerge;
1573
+ private http;
1574
+ /** ADR-033 [2c]: in `withheld` every verb in REJECTED_IN_WITHHELD rejects with no network and no write. */
1575
+ private rejectIfWithheld;
1576
+ /** True in `anonymous` / `identified` — a live session a LINK can attach to. */
1577
+ private hasSession;
1578
+ private serialize;
1227
1579
  /**
1228
- * Wave 62 — AppState resume hook. Clears stuck backoff so a user
1229
- * who backgrounded the app during a network blip + returns now
1230
- * gets an immediate fresh refresh attempt instead of waiting on
1231
- * the cooldown timer.
1232
- *
1233
- * Called from the AppState 'active' listener registered in
1234
- * startProactiveRefreshCron. Idempotent + cheap — three field
1235
- * writes; no I/O.
1580
+ * INV-5 — a session-installing verb in `unknown` QUEUES until the status
1581
+ * settles: the single gap-fill refresh either installs the identity or
1582
+ * reports the token dead (`signed_out`). Offline it keeps waiting with the
1583
+ * refresh backoff (never minting, never wiping). Ends in `withheld` → rejects.
1584
+ */
1585
+ private awaitSettled;
1586
+ /** An identity-changing attempt awaits any in-flight refresh at start AND at commit (ADR-033 §3.3 scheduling rule 1). */
1587
+ private awaitRefreshQuiet;
1588
+ private snapshotIdentity;
1589
+ /** The identity CAS (INV-2): `sid` when both sides know it, else the `sub`; a signed-out side never matches a live one. */
1590
+ private identityMatches;
1591
+ /** The current session's JWT `project` claim (string | null), or undefined when unreadable. */
1592
+ private claimProject;
1593
+ /**
1594
+ * The ProjectGuard (INV-15) as `commit()` applies it: the shared
1595
+ * `projectVerdict()` over the access JWT, and — when the claim is unreadable
1596
+ * — the B17 `user.project` field as the second source. Absent on both = `unknown`.
1597
+ */
1598
+ private verdictFor;
1599
+ private heldCount;
1600
+ private heldSorted;
1601
+ /** Slot indices eligible for auto-restore, newest first (ADR-033 §3.3 "eligible"). */
1602
+ private eligibleHeldSlots;
1603
+ private freeSlotIndex;
1604
+ /** The `id` rule shared by claim / restore / discard: omit it ONLY when exactly one guest is held. */
1605
+ private resolveSlot;
1606
+ private doSignInDone;
1607
+ /**
1608
+ * The SIGN_IN / SWITCH row (ADR-033 §3.3 row 18). The body carries NO guest
1609
+ * hint; a success HOLDS a previous guest (post-refresh tokens) and installs
1610
+ * the new session under the identity CAS; any failure leaves the caller
1611
+ * exactly as found.
1612
+ */
1613
+ private doSignIn;
1614
+ /** Commit a sign-in response as the SIGN_IN row; a CAS drop revokes the dropped refresh so server session B is not left live (row 19). */
1615
+ private installSignIn;
1616
+ /**
1617
+ * The LINK / LINK-UPGRADE rows (ADR-033 §3.3 rows 15–16) on the current
1618
+ * Bearer session. 200 with a rotated pair (`upgraded`) → ProjectGuard →
1619
+ * install (same `sub`); 200 attached → user updated in place; 200
1620
+ * `alreadyLinked` → nothing; 409 / 403 → typed outcomes, nothing mutated.
1621
+ */
1622
+ private doLink;
1623
+ /**
1624
+ * Present a held guest's refresh token to `/token/refresh` — the ONLY way a
1625
+ * held token is ever refreshed (the proactive cron never touches a slot).
1626
+ * The rotation is persisted INTO THE SLOT under a per-slot CAS before anything
1627
+ * else happens; a dead verdict releases the slot only for `endReason ∈
1628
+ * deleted|revoked|expired` (`reuse`/absent keep it: `kept`).
1629
+ */
1630
+ private refreshHeld;
1631
+ /** The RESTORE row's install half: ProjectGuard on the refresh response, install BEFORE releasing the slot. */
1632
+ private installRestored;
1633
+ /**
1634
+ * Hot-path token refresh. Single-flight via `refreshInflight` so a UI batch
1635
+ * firing 10 simultaneous reads never produces 10 `/refresh` round-trips.
1636
+ * Returns null on any failure so callers can decide whether to surface a
1637
+ * sign-in prompt or proceed anonymously.
1638
+ *
1639
+ * The result is applied ONLY if the token that was sent is still the one on
1640
+ * disk (token CAS, INV-2): a refresh landing after `signOut()` must not
1641
+ * resurrect the session, and an OLD token's rejection must not wipe a newer
1642
+ * one. A rotation is not a transition; the user object is adopted when the
1643
+ * SDK has none (gap-fill from `unknown`, 1.29.0) and re-judged by the project
1644
+ * guard before install (a mismatch → `withheld`, INV-15).
1236
1645
  */
1646
+ private refreshAccessToken;
1647
+ /** Wave 62 — AppState resume hook: clear stuck backoff so a returning user gets an immediate attempt. */
1237
1648
  private resetRefreshState;
1238
1649
  /**
1239
- * Proactive refresh (s58.72). Fires when access-token age crosses
1240
- * ~80% of TTL. Pairs with the AppState foreground listener so a
1241
- * backgrounded app coming back to active state immediately runs
1242
- * a tick — much better UX than waiting 60s for the next interval.
1243
- * Stopped on signOut() / wipeLocalIdentity().
1650
+ * Proactive refresh (s58.72). Fires when access-token age crosses ~80% of
1651
+ * TTL, repairs a stale deadline the combined guard can see, and performs the
1652
+ * single gap-fill refresh for an `unknown` record. Armed ONLY by `commit()`
1653
+ * (install / hydrate), stopped by the clear and withhold paths. ⚠ Never
1654
+ * starts a refresh while an identity attempt is pending (ADR-033 §3.3
1655
+ * scheduling rule 2) and never refreshes a held guest.
1244
1656
  */
1245
1657
  private proactiveRefreshTimer;
1246
1658
  private proactiveRefreshAppStateUnsub;
1247
1659
  private startProactiveRefreshCron;
1248
1660
  private stopProactiveRefreshCron;
1249
- /**
1250
- * Returns the currently-stored refresh token (or null when signed out).
1251
- * Primary use: capture the anonymous refresh token BEFORE calling
1252
- * `signIn()` so it can be transferred via `merge()` after the existing
1253
- * account is authenticated. See the "Anonymous → identified" section
1254
- * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
1255
- */
1256
- getRefreshToken(): string | null;
1257
- /**
1258
- * Transfer every event + trait from an anonymous user to the
1259
- * currently-signed-in (target) user, then delete the anonymous row.
1260
- * `targetRefreshToken` defaults to the SDK's currently-stored refresh
1261
- * token — call this AFTER a successful `signIn()` so the storage
1262
- * already holds the target's refresh.
1263
- *
1264
- * Throws AuthError when:
1265
- * - `anonymousRefreshToken` is invalid / expired / revoked,
1266
- * - the bound source row is not anonymous (CONFLICT — use upgrade),
1267
- * - the target is anonymous (CONFLICT — sign in first),
1268
- * - both tokens resolve to the same user (CONFLICT — no-op).
1269
- *
1270
- * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
1271
- */
1272
- merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<{
1273
- mergedUserId: string;
1274
- deletedAnonymousId: string;
1275
- eventsReassigned: number;
1276
- profilesReassigned: number;
1277
- profilesDropped: number;
1278
- entitiesReassigned: number;
1279
- entitiesDropped: number;
1280
- }>;
1661
+ private revokeFireAndForget;
1662
+ /** B15: report a held guest, fire-and-forget; the pending flag is cleared only on a 2xx and retried on hydrate. */
1663
+ private reportHeldFireAndForget;
1664
+ private retryPendingHeldReports;
1665
+ /** `Sendora.reset({includeHeldGuests:true})`: release every slot locally (NO server delete), via commit. */
1666
+ releaseAllHeldGuests(): void;
1281
1667
  private callAuth;
1282
- private persist;
1283
- /**
1284
- * Drop the local session.
1285
- *
1286
- * `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
1287
- * emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
1288
- * on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
1289
- * not a spurious logout/login pair.
1290
- *
1291
- * ⚠ Never call this before a fallible network request. A failed sign-in must
1292
- * leave the caller exactly as it found them; for an anonymous session the
1293
- * refresh token dropped here is the ONLY durable handle on the account, so a
1294
- * pre-call wipe that is not followed by a successful `persist()` orphans the
1295
- * account permanently (offline: deterministically). Capture the anon refresh
1296
- * into a local, make the call, and wipe only after the response validates.
1297
- */
1668
+ private commit;
1669
+ /** `commit()` for the clear path, awaiting the parent's `onAnonymousWipe` hook before resolving (the emission has already fired). */
1670
+ private commitAsync;
1671
+ private pendingWipeHook;
1672
+ private commitInstall;
1673
+ private commitClear;
1298
1674
  /**
1299
- * True when `sent` is still the refresh token on disk — i.e. nothing
1300
- * replaced or cleared the session while a request carrying it was in
1301
- * flight. Guards both directions of the refresh race: installing a rotated
1302
- * token over a session that has since been signed out or replaced, and
1303
- * wiping a fresh session because an OLD token was rejected.
1675
+ * The parent-SDK half of a clear: rotate the analytics anon id at a person
1676
+ * boundary (`"user"` / `"account_deleted"`), drop the queue on deletion, null
1677
+ * the analytics user id. Kept as its own method so its reasons stay pinned.
1304
1678
  */
1305
- private tokenStillCurrent;
1306
1679
  private wipeLocalIdentity;
1680
+ private commitUser;
1681
+ private commitRelease;
1682
+ /** Row 21 — a refresh of the CURRENT token answered with a mismatching claim: persist the rotation (token CAS) and withhold. */
1683
+ private commitWithhold;
1684
+ /** Enter `withheld`: in-memory only (ZERO storage writes), no cron, analytics quarantined; emits `session_withheld` only for an in-process transition. */
1685
+ private enterWithheld;
1686
+ /** Row 14 — `discardWithheld()` / `signOut()` in `withheld`: LOCAL ONLY, keeps held slots + queue + `user_id`. */
1687
+ private commitDiscardWithheld;
1688
+ /** A rotated pair for the current session when no user is known to install (pre-B17 gap-fill). Token CAS. */
1689
+ private commitTokens;
1690
+ /** Row 22 — CAS PER SLOT: re-read the slot at write time; if it no longer holds `sent`, DROP. */
1691
+ private commitSlotTokens;
1692
+ /** RESTORE row: a `null` slot project judged on the refresh response records the claim it saw. */
1693
+ private commitSlotProject;
1694
+ private commitHeldReport;
1695
+ /** Dual-write: the four frozen keys (downgrade-readable) + the `auth_session` record. */
1696
+ private writeSessionRecord;
1697
+ /** Hydrate's write-forward: ONLY the `auth_session` record (the frozen keys already hold the session untouched). */
1698
+ private writeSessionRecordOnly;
1699
+ /** The rotated pair only (the user is unchanged) — frozen keys + the record's token fields. */
1700
+ private writeTokens;
1701
+ /** Clear EXACTLY the session keys (the four frozen + the record). Held slots, the queue and `user_id` are never touched here. */
1702
+ private clearSessionKeys;
1703
+ /** Keep the B15 pending-report flags on disk even with no session (a stub record); remove the record when nothing is pending. */
1704
+ private persistPendingReports;
1705
+ private writeSlot;
1706
+ }
1707
+ /** What `/merge` (and `claimHeldGuest()`) moved. */
1708
+ interface MergeStats {
1709
+ mergedUserId: string;
1710
+ deletedAnonymousId: string;
1711
+ eventsReassigned: number;
1712
+ profilesReassigned: number;
1713
+ profilesDropped: number;
1714
+ entitiesReassigned: number;
1715
+ entitiesDropped: number;
1307
1716
  }
1308
1717
 
1309
1718
  /**
@@ -1590,6 +1999,56 @@ declare class Links {
1590
1999
  attachLinkingApi(opts?: AttachLinkingApiOptions): Promise<() => void>;
1591
2000
  }
1592
2001
 
2002
+ /**
2003
+ * ADR-033 INV-15 — `projectVerdict()`, the ProjectGuard every InstallTx is
2004
+ * judged by BEFORE any assignment (§3.3 `commit()` header).
2005
+ *
2006
+ * ⚠ VERBATIM PORT of `packages/shared/src/auth-machine/project-verdict.ts`
2007
+ * (ADR-033 B8). The published SDK cannot import the internal `@sendora/shared`
2008
+ * package at runtime, so the function lives here as a copy; the conformance
2009
+ * suite (`test/auth-machine-conformance.test.ts`) drives THIS copy over every
2010
+ * `projectVerdictCases` entry of the shared golden fixture, which is what keeps
2011
+ * the two from drifting.
2012
+ *
2013
+ * WHY A PURE FUNCTION. `withheld` (§3.2) is "a pure function of (disk record,
2014
+ * config), recomputed every hydrate, never persisted" — so a re-init with the
2015
+ * right key or a rollback needs no migration. That property only holds if the
2016
+ * verdict reads nothing but its two arguments: no `Date` (expiry is
2017
+ * irrelevant — a stale token still names its project truthfully), no network,
2018
+ * no storage, no `atob` (Hermes/JSC/browsers/Node disagree on its presence and
2019
+ * its handling of non-ASCII, and a verdict that depends on the host's `atob`
2020
+ * is a verdict that differs per platform).
2021
+ *
2022
+ * The three answers, and why absent ≠ match (§3.3, §8-Q13, TODO.md:52 superseded):
2023
+ *
2024
+ * expected unset → "match" (guard off)
2025
+ * token missing / undecodable / claim KEY absent → "unknown" (NOT match: a
2026
+ * pre-s58.276 token says nothing about its project; the SDK stays
2027
+ * `unknown` and lets the refresh answer — the server emits the claim
2028
+ * ALWAYS since s58.276, so one refresh settles it)
2029
+ * claim === null → "mismatch" (the server
2030
+ * emits an EXPLICIT null for the org-wide pool; under a project-bound
2031
+ * expectedProjectId an org-wide session is the wrong tenant)
2032
+ * claim is a string → equal ? "match" : "mismatch"
2033
+ *
2034
+ * ⚠ The guard is ADVISORY (a client consistency check against a wrong-key
2035
+ * build), not an authorization boundary, and it never produces a dead-token
2036
+ * verdict (INV-6/INV-15).
2037
+ */
2038
+ type ProjectVerdict = "match" | "mismatch" | "unknown";
2039
+ declare function projectVerdict(expectedProjectId: string | undefined, accessJwt: string | null | undefined): ProjectVerdict;
2040
+ /**
2041
+ * Decode a compact JWS payload (`header.payload.signature`) WITHOUT verifying
2042
+ * the signature. The verdict only needs to READ a claim from a token the SDK
2043
+ * already holds (the server verifies on every request); verification here
2044
+ * would need the JWKS, i.e. the network, which the verdict must not touch.
2045
+ *
2046
+ * Returns `null` for anything that is not a three-segment token whose second
2047
+ * segment base64url-decodes to a UTF-8 JSON object. Arrays, scalars and
2048
+ * malformed input are all "undecodable".
2049
+ */
2050
+ declare function decodeJwtPayload(token: string | null | undefined): Record<string, unknown> | null;
2051
+
1593
2052
  /**
1594
2053
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
1595
2054
  *
@@ -1625,6 +2084,8 @@ declare class SendoraSDK {
1625
2084
  private sessionId;
1626
2085
  private autoTrackCleanup;
1627
2086
  private eventQueue;
2087
+ /** ADR-033 [2c]: subscribers to `onIdentityWithheld`. */
2088
+ private withheldListeners;
1628
2089
  private engEnabled;
1629
2090
  private engScreen;
1630
2091
  private engAccumMs;
@@ -2116,17 +2577,34 @@ declare class SendoraSDK {
2116
2577
  private _chatbotSessionId;
2117
2578
  private chatbotSessionId;
2118
2579
  /**
2119
- * Clear identity + rotate the anonymous id. Call on logout. Awaits
2120
- * the AsyncStorage writes so a caller who kills the app immediately
2121
- * after doesn't leave stale identity on disk.
2122
- *
2123
- * Signs out through `auth` rather than just clearing storage: `clearAll()`
2124
- * removes the auth keys too, so doing it directly left `Auth` holding an
2125
- * in-memory user and reporting itself signed in while its refresh token was
2126
- * already gone — with the server-side session never revoked and no
2127
- * `onAuthStateChanged` emission telling the app the session had ended.
2580
+ * ADR-033 [2c]: subscribe to the project guard refusing a stored session
2581
+ * (`auth.status() === "withheld"`). Fires once per withhold with
2582
+ * `getWithheld()`'s payload, after the SDK has nulled its in-memory
2583
+ * analytics user id. Record `info.userId` (oldSub) in your backend before
2584
+ * calling `auth.discardWithheld()`. Returns an unsubscribe function.
2128
2585
  */
2129
- reset(): Promise<void>;
2586
+ onIdentityWithheld(cb: (info: WithheldInfo) => void): () => void;
2587
+ /**
2588
+ * Clear identity + rotate the anonymous id. Call on logout. Awaits
2589
+ * the storage writes so a caller who kills the app immediately after
2590
+ * doesn't leave stale identity on disk.
2591
+ *
2592
+ * Signs out through `auth` (so the server session is revoked and
2593
+ * `onAuthStateChanged` fires `signed_out`), drops the queued events, then
2594
+ * clears EXACTLY this SDK's keys in the active namespace.
2595
+ *
2596
+ * ⚠ KEEPS the held guests by default (ADR-033). A guest displaced by a
2597
+ * sign-in is held on the device (`auth.getHeldGuests()`), and its refresh
2598
+ * token is its only credential — wiping the slots on logout would strand a
2599
+ * guest whose progress the player may still want. Pass
2600
+ * `{ includeHeldGuests: true }` to release every slot too (locally only —
2601
+ * `guest_released{reason:'reset'}` per guest, NO server delete; use
2602
+ * `auth.discardHeldGuest(id)` for that). In `withheld` this resolves the
2603
+ * withheld session like `auth.discardWithheld()` and keeps the slots too.
2604
+ */
2605
+ reset(opts?: {
2606
+ includeHeldGuests?: boolean;
2607
+ }): Promise<void>;
2130
2608
  /** Tear down auto-track listeners. Call in test harnesses. */
2131
2609
  destroy(): void;
2132
2610
  /**
@@ -2146,4 +2624,4 @@ declare class SendoraSDK {
2146
2624
  }
2147
2625
  declare const SendoraCloud: SendoraSDK;
2148
2626
 
2149
- export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, type UnlinkResult, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
2627
+ export { AMBIGUOUS_HELD_GUEST, AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedInCause, type AuthSignedOutReason, type AuthStateChange, type AuthStatus, type AuthTokens, type AuthUser, type AuthUserUpdatedCause, type Credential, type CredentialCollisionPolicy, CredentialInUseError, type CredentialKind, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameCenterPayload, type GameSignInOptions, type GuestReleasedReason, HELD_GUEST_SLOTS, HeldGuestLimitError, HeldGuestProjectMismatch, type HeldGuestSummary, type IdentifyTraits, type LinkCollision, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkResult, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type MergeStats, type ProjectVerdict, type PushTokenReceipt, type PushTokenRegistration, type RefreshEndReason, SendoraCloud, type SendoraConfig, type SignInResult, type SocialCredentialProvider, type TrackProperties, type UnlinkResult, type WithheldInfo, computeDeviceFingerprint, decodeJwtPayload, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer, projectVerdict };