@sendoracloud/sdk-react-native 1.40.0 → 1.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts 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,30 +758,52 @@ 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. EVERY
765
+ * dead verdict, whatever the server's detail: read the
766
+ * event's `endReason` (`revoked` / `deleted` / `expired` /
767
+ * `reuse` / `unknown`) for that.
547
768
  * - `account_deleted` — `deleteAccount()` succeeded.
769
+ * - `revoked` / `evicted` / `deleted` — ⚠ emitted by 1.41.0 ONLY, never since
770
+ * 1.41.1. 1.41.0 put the server's `endReason` here, which
771
+ * renamed "account deleted elsewhere" / "sessions revoked"
772
+ * (both `session_expired` through 1.40.0) and silently
773
+ * broke listeners matching `session_expired`. Kept in the
774
+ * type so code written against 1.41.0 still compiles.
775
+ *
776
+ * Transient network failures and rate limits never produce any of these.
777
+ * Consumers default-branch (§3.5 NORMATIVE).
548
778
  */
549
- type AuthSignedOutReason = "user" | "session_expired" | "account_deleted";
779
+ type AuthSignedOutReason = "user" | "session_expired" | "account_deleted" | "revoked" | "evicted" | "deleted";
550
780
  /**
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).
781
+ * Why a `signed_in` fired (ADR-033 §3.5). `restore` ⇒ re-register push (the
782
+ * held guest's push tokens were erased if it had been deleted elsewhere);
783
+ * `link` = the legacy same-`sub` promotion the legacy-signature methods
784
+ * (`signInWith*({link:true})`, `loginSocial({link:true})`, `signUp`) keep
785
+ * emitting (§8-Q18). Absent on a gap-fill recovery from `unknown` (1.29.0).
786
+ */
787
+ type AuthSignedInCause = "mint" | "sign_in" | "restore" | "link";
788
+ /** What changed the user object WITHOUT changing the `sub`. */
789
+ type AuthUserUpdatedCause = "link" | "unlink" | "email_verified" | "refresh" | "merge";
790
+ /**
791
+ * A single auth-state transition. Subscribe with `onAuthStateChanged` — one
792
+ * stream covering everything. The first four members are the pre-1.41.0
793
+ * contract verbatim (`signed_in` gaining an OPTIONAL `cause`); the rest are
794
+ * additive (ADR-033). ⚠ Default-branch on `event`.
554
795
  */
555
796
  type AuthStateChange = {
556
797
  event: "signed_in";
557
798
  user: AuthUser;
558
- } | {
799
+ cause?: AuthSignedInCause;
800
+ }
801
+ /** `endReason` (1.41.1) = the server's own verdict on a dead refresh token; absent on `user` / `account_deleted` and from an older backend. */
802
+ | {
559
803
  event: "signed_out";
560
804
  user: null;
561
805
  reason: AuthSignedOutReason;
806
+ endReason?: RefreshEndReason;
562
807
  } | {
563
808
  event: "device_takeover";
564
809
  user: AuthUser | null;
@@ -566,12 +811,47 @@ type AuthStateChange = {
566
811
  } | {
567
812
  event: "deletion_cancelled";
568
813
  user: AuthUser | null;
814
+ }
815
+ /** The settled boot state, exactly once per subscriber — INCLUDING `signed_out`, `unknown` and `withheld`. */
816
+ | {
817
+ event: "initial";
818
+ status: AuthStatus;
819
+ user: AuthUser | null;
820
+ withheld?: WithheldInfo;
821
+ }
822
+ /** Same `sub`, different user object (a link attach, an unlink, a claim, a refresh that changed the user). */
823
+ | {
824
+ event: "user_updated";
825
+ user: AuthUser;
826
+ cause: AuthUserUpdatedCause;
827
+ }
828
+ /** A guest was moved into a held slot (`count` = guests held after this one). */
829
+ | {
830
+ event: "guest_held";
831
+ userId: string;
832
+ count: number;
833
+ user: AuthUser;
834
+ }
835
+ /** A held slot was emptied. ⚠ `dead` = "unavailable when its token was presented", NOT an erasure signal. */
836
+ | {
837
+ event: "guest_released";
838
+ userId: string;
839
+ reason: GuestReleasedReason;
840
+ endReason?: RefreshEndReason;
841
+ }
842
+ /** An in-process transition INTO `withheld` (a refresh answered with a mismatching claim). Never replayed. */
843
+ | {
844
+ event: "session_withheld";
845
+ reason: "project_mismatch";
846
+ expectedProjectId: string;
847
+ actualProjectId: string | null;
848
+ userId: string;
569
849
  };
570
850
  type AuthStateListener = (change: AuthStateChange) => void;
571
851
  /**
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.
852
+ * The reasons the clear funnel can be entered with. `replaced` is kept on the
853
+ * type for compatibility with hooks written against 1.38–1.40 but is no longer
854
+ * produced: a successful sign-in HOLDS the previous guest instead of wiping it.
575
855
  */
576
856
  type WipeReason = AuthSignedOutReason | "replaced";
577
857
  declare class Auth {
@@ -579,6 +859,16 @@ declare class Auth {
579
859
  private user;
580
860
  private accessToken;
581
861
  private accessExpiresAt;
862
+ /** The in-memory mirror of the K held slots (index = slot number). Written only by commit(). */
863
+ private held;
864
+ /** Non-null ⇔ `status() === "withheld"` (ADR-033 [2c]). Never persisted. */
865
+ private withheld;
866
+ /** The refresh token the withheld record carried — the CAS key of `discardWithheld()`. */
867
+ private withheldRefresh;
868
+ /** B15: held guests whose `POST /guest/held` report has not yet returned 2xx. Persisted in `auth_session`. */
869
+ private heldReportsPending;
870
+ /** The in-flight identity-changing proposal; the proactive cron pauses while it is set. */
871
+ private attempt;
582
872
  /**
583
873
  * Set once we have PROOF that this device's clock runs fast, which
584
874
  * releases the `exp` guard in `canServeCachedToken`.
@@ -603,15 +893,10 @@ declare class Auth {
603
893
  private inflight;
604
894
  private refreshInflight;
605
895
  /**
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.
896
+ * Wave 62 — refresh failure back-pressure. Consecutive network-timeout
897
+ * failures don't wipe identity (refresh may still be valid; iOS NSURLSession
898
+ * was just stalled) but do back off so the next caller doesn't immediately
899
+ * fire another fetch that will also time out. AppState foreground clears it.
615
900
  */
616
901
  private refreshFailureCount;
617
902
  private refreshCooldownUntil;
@@ -621,185 +906,132 @@ declare class Auth {
621
906
  private lastDeletionCancelled;
622
907
  private lastAnonRetirement;
623
908
  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
909
  /**
633
910
  * Resolves when the parent SDK has finished its async init() —
634
911
  * AsyncStorage hydrated + auth.hydrate() restored the cached
635
912
  * session. All public auth methods await this internally so a
636
913
  * 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).
914
+ * signup UI doesn't hit the cold-start race.
644
915
  */
645
916
  private readyPromise;
646
917
  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
- */
918
+ /** True once `hydrate()` has run. Gates `onAuthStateChanged`'s replay-on-subscribe. */
651
919
  private hydrated;
652
920
  constructor(hooks: AuthHooks);
653
921
  /**
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.
922
+ * Restore the session from storage. Called by the parent SDK during
923
+ * init() after Storage.hydrate() has run. Disk-only, never waits on the
924
+ * network; resolves the internal ready gate so queued auth calls unblock.
925
+ *
926
+ * Reads `auth_session` FIRST, then the four frozen keys. When the frozen keys
927
+ * carry a refresh token that differs from the record's, the FROZEN keys win:
928
+ * every SDK version writes them, so after a 1.41 → 1.40 → 1.41 round trip
929
+ * they are the fresher copy and the stale `auth_session` must not resurrect a
930
+ * rotated-away token. The record is then written forward from them.
931
+ *
932
+ * Coherence (INV-9): user ∧ refresh → judged by the project guard
933
+ * (match → installed; mismatch → `withheld`, ZERO writes; unknown → stay
934
+ * `unknown`, one gap-fill refresh re-judges it); refresh ∧ ¬user → `unknown`
935
+ * (LIMBO_A — never `signed_out`, never a mint; the refresh token is the only
936
+ * thing that can recover the account, 1.28.0); user ∧ ¬refresh → the stale
937
+ * blob is cleared → `signed_out`. Held slots: a slot whose `userId` equals the
938
+ * current user is dropped (current wins); a slot that fails to parse is KEPT
939
+ * and ignored, never cleared (P11).
670
940
  */
671
941
  hydrate(): void;
942
+ /** Read `auth_session` + the four frozen keys and reconcile them (see `hydrate()`). */
943
+ private readDiskSession;
944
+ /** Load the K slots into the in-memory mirror; dedupe against the current user (current wins); keep unparseable slots untouched. */
945
+ private hydrateHeldSlots;
946
+ /** Parse one slot off disk. `null` for an empty OR unparseable slot (the latter is left on disk, P11). */
947
+ private readSlot;
948
+ /**
949
+ * The closed boot/identity state. Synchronous, never waits on the network.
950
+ *
951
+ * - `unknown` — not hydrated yet, OR a refresh token is on disk but the
952
+ * user is not readable / not yet judged (the gap-fill
953
+ * refresh settles it). Session-installing verbs QUEUE here.
954
+ * - `signed_out` — no refresh token. The only state a mint executes in.
955
+ * - `anonymous` / `identified` — a live session of that kind.
956
+ * - `withheld` — a stored session refused by `init({expectedProjectId})`.
957
+ *
958
+ * ⚠ This is the ONLY way to tell `withheld` and `unknown` from `signed_out`:
959
+ * `getUserSync()` is `null` and `isReady()` is `true` in all three. Consumers
960
+ * MUST default-branch — a later value ships opt-in-gated or in a MAJOR.
961
+ */
962
+ status(): AuthStatus;
963
+ /** Resolves after hydrate with the settled status — possibly `unknown` or `withheld`. Never waits on the network. */
964
+ authStateReady(): Promise<AuthStatus>;
672
965
  getCurrentUser(): AuthUser | null;
966
+ /** Alias of `getCurrentUser()` gated on hydration (parity with `Sendora.getUserSync()`). */
967
+ getUserSync(): AuthUser | null;
968
+ /** True once `hydrate()` has run. `true` in `withheld` and `unknown` too — read `status()`. */
969
+ isReady(): boolean;
673
970
  /**
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`.
971
+ * Subscribe to device-takeover events (s58.111+). LEGACY after ADR-033: a
972
+ * wave-1 SDK never sends the guest hint, so the server never retires a guest
973
+ * for its sign-ins and this fires only if the SERVER reports a retirement.
974
+ * Kept callable; replace your handler with `onAuthStateChanged` +
975
+ * `guest_held` / `getHeldGuests()`. Returns an unsubscribe function.
685
976
  *
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.
695
- *
696
- * Listeners are best-effort: errors thrown inside them are swallowed
697
- * + logged in dev mode. Auth state never depends on listener success.
977
+ * Historically the account-DELETING paths were the gaming sign-ins —
978
+ * `signInWithGameCenter` / `signInWithPlayGames` (and `loginSocial`) adopting
979
+ * an existing account retired this device's guest row while the call itself
980
+ * resolved successfully. On 1.41.0 those calls HOLD the guest instead; a
981
+ * takeover can only be reported for a sign-in that reached the legacy path.
698
982
  */
699
983
  onDeviceTakeover(listener: (evt: DeviceTakeoverEvent) => void): () => void;
700
984
  /**
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.
704
- *
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" }`.
985
+ * Subscribe to every auth-state transition — the single stream that answers
986
+ * "what happened to my session?". Firebase `onAuthStateChanged` / Supabase
987
+ * `onAuthStateChange` parity.
710
988
  *
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.
989
+ * Replays the SETTLED boot state exactly once on subscribe, once `init()` has
990
+ * hydrated, as `initial{status,user,withheld?}` — including `signed_out`,
991
+ * `unknown` and `withheld`, so a subscriber can never confuse "no session"
992
+ * with "not hydrated yet". With a live session (`anonymous` / `identified`)
993
+ * `initial` is FOLLOWED by the legacy `signed_in{user}` replay that
994
+ * 1.28.0–1.40.0 sent (kept — ADR-023); new code should read `initial` and
995
+ * ignore a `signed_in` that carries no `cause` right after it.
715
996
  *
716
997
  * A failed sign-in emits NOTHING: the session is unchanged, so there is no
717
998
  * transition to report — the rejected promise is the whole story.
718
999
  *
719
- * ⚠ This is a TRANSITION stream, not a health or connectivity signal (1.40.0
720
- * doc fix, reported by RopoLabs). A transient failure of the background
721
- * token refresh — offline, timeout, HTTP 429, 5xx, an unparseable response —
722
- * emits nothing: the stored refresh token is kept and the refresh retried
723
- * with backoff, so no state changed. "No event" therefore means "no
724
- * confirmed transition", never "the session is fine". Only a server-REJECTED
725
- * refresh token produces `signed_out(session_expired)`. For liveness read
726
- * `getAccessToken()` (null = no usable token right now).
1000
+ * ⚠ This is a TRANSITION stream, not a health or connectivity signal. A
1001
+ * transient failure of the background token refresh (offline, timeout, 429,
1002
+ * 5xx) emits nothing: the stored refresh token is kept and the refresh retried
1003
+ * with backoff. Only a server-REJECTED refresh token produces `signed_out`
1004
+ * (reason `session_expired`, or the server's own `revoked` / `deleted`). For
1005
+ * liveness read `getAccessToken()` (null = no usable token right now).
727
1006
  *
728
- * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
729
- * and never affects auth state. Returns an unsubscribe function.
1007
+ * Replacing a guest with an account emits `guest_held` then ONE `signed_in`
1008
+ * — no `signed_out`. Listeners are best-effort; a throwing listener is
1009
+ * swallowed (dev-logged). Returns an unsubscribe function.
730
1010
  */
731
1011
  onAuthStateChanged(listener: AuthStateListener): () => void;
732
1012
  /** Fan a state change out to subscribers. Never throws. */
733
1013
  private emitAuthState;
734
- /**
735
- * Inspect the most recent device-takeover event observed by the
736
- * SDK. Cleared on signOut. Returns null when no takeover has
737
- * happened during this session — useful for late subscribers or
738
- * post-sign-in side-effects (e.g. app-launch hydrate).
739
- */
1014
+ /** The most recent device-takeover event observed, or null. Cleared on signOut. */
740
1015
  getLastDeviceTakeover(): DeviceTakeoverEvent | null;
741
- /** Internal — fan out to subscribers + cache the latest. */
742
1016
  private fireDeviceTakeover;
743
1017
  /**
744
1018
  * Subscribe to deletion-cancelled events (s58.269): fires when a sign-in
745
1019
  * cancelled a pending self-service account deletion within its grace window
746
- * (the account is restored, same user_id). Use it to show "your account
747
- * deletion was cancelled" + reconcile local state. Fires from signIn /
748
- * loginSocial / signInWithGameCenter / signInWithPlayGames. Returns an
749
- * unsubscribe function; listener errors are swallowed (best-effort).
1020
+ * (the account is restored, same user_id). Also surfaced as
1021
+ * `SignInResult.reactivated`. Returns an unsubscribe function.
750
1022
  */
751
1023
  onDeletionCancelled(listener: (evt: DeletionCancelledEvent) => void): () => void;
752
1024
  /** The most recent deletion-cancelled event this session, or null. Cleared on signOut. */
753
1025
  getLastDeletionCancelled(): DeletionCancelledEvent | null;
754
1026
  /**
755
- * What the last sign-in did with this device's guest account, or null if no
756
- * sign-in has happened this session (s58.278).
757
- *
758
- * The one worth acting on is `"preserved"`: a guest account was presented,
759
- * was NOT retired, and is therefore still alive server-side — so "recover
760
- * your other account" is a real offer rather than a guess. `"retired"` means
761
- * migrate from `onDeviceTakeover`'s id; `"none"` means there was nothing to
762
- * reconcile. Cleared on signOut.
1027
+ * What the last sign-in did with this device's guest account (s58.278).
1028
+ * LEGACY: a wave-1 SDK never presents a guest token, so a current backend
1029
+ * answers `"none"` for every sign-in. Read `getHeldGuests()` instead — the
1030
+ * guest is on this device, held. Recorded only when the server states the
1031
+ * field. Cleared on signOut.
763
1032
  */
764
1033
  getLastAnonRetirement(): AnonRetirementOutcome | null;
765
- /** Internal — fan out deletion-cancelled to subscribers + cache the latest. */
766
1034
  private fireDeletionCancelled;
767
- /**
768
- * Returns a VALID (non-expired) access token, or null.
769
- *
770
- * Hard contract (matches sdk-ios / sdk-android, and the industry
771
- * token-client pattern — Supabase `_callRefreshToken`, Auth0
772
- * `getTokenSilently`, Firebase `getIdToken`): this method NEVER
773
- * returns a token past its `exp`. Callers attach the result as a
774
- * Bearer to arbitrary resource servers — including third-party
775
- * backends (PostgREST/Supabase) that have NO knowledge of Sendora's
776
- * own refresh grace window — so a token must be either fresh or
777
- * absent. A token expired by hours is useless and yields a 401.
778
- *
779
- * Resolution order:
780
- * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
781
- * before BOTH the tracked deadline and the token's own `exp`) →
782
- * return it. Fast path, no I/O. See `canServeCachedToken` for why
783
- * both are required and when the second is released.
784
- * Pass `{ forceRefresh: true }` to skip this step when the caller
785
- * knows the tracked deadline is wrong.
786
- * 2. No cached token AND no refresh token in storage → genuinely
787
- * signed out → null.
788
- * 3. Expired/near-expiry but a refresh chain exists:
789
- * a. A refresh is already inflight → AWAIT it and return the
790
- * fresh token. Concurrent callers coalesce onto the single
791
- * in-flight `/token/refresh` (single-flight) — nobody is
792
- * handed a stale token. The inflight fetch is
793
- * AbortController-bounded (http.ts caps `/auth-service/*` at
794
- * 10s), so awaiting can NEVER hang the JS bridge. That
795
- * bounded-fetch guarantee — NOT the old stale-token return —
796
- * is what actually fixed the Wave-62 cold-launch hang.
797
- * b. A recent refresh failed and we're in backoff cooldown →
798
- * null. Honest "no usable token right now" rather than a
799
- * provably-expired one; the AppState foreground hook clears
800
- * the cooldown for an immediate retry on resume.
801
- * c. Otherwise → fire the refresh and await it.
802
- */
803
1035
  /**
804
1036
  * The cached-token guard, and the single place the two deadlines are
805
1037
  * combined. Both must agree before a cached token is served:
@@ -810,16 +1042,8 @@ declare class Auth {
810
1042
  * full offset on a constantly-wrong clock.
811
1043
  *
812
1044
  * Guard 2 is released once `clockSkewConfirmed` proves the second failure
813
- * mode is the one we are actually in.
814
- *
815
- * The gap this closes: `accessExpiresAt` is written in the device's clock
816
- * frame at mint and PERSISTED. Step the clock backwards between mint and
817
- * read — an iPhone acquiring automatic time after a long power-off, a
818
- * restore, or a manual set — and the deadline is stale by the size of the
819
- * correction, surviving relaunches. The SDK then serves a provably dead
820
- * token, every request 401s, and because the fast path returns before
821
- * reaching `persist()` or `wipeLocalIdentity()` nothing emits
822
- * `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
1045
+ * mode is the one we are actually in. ⚠ NEVER derive one deadline from the
1046
+ * other (`accessExpiresAt = exp * 1000` throws away skew-invariance).
823
1047
  */
824
1048
  private canServeToken;
825
1049
  private canServeCachedToken;
@@ -835,84 +1059,102 @@ declare class Auth {
835
1059
  * the correction, so a mint-time probe records "clock is fast", and the flag
836
1060
  * then waves through the very token the guard exists to refuse. Probing here
837
1061
  * — only on a refresh we performed BECAUSE `exp` contradicted a tracked
838
- * deadline that still claimed life — separates the two cases, because by now
839
- * the clock has either been corrected (fresh token reads live → stale
840
- * deadline, guard holds) or is genuinely fast (fresh token still reads dead
841
- * → release the guard, one probe, no loop).
1062
+ * deadline that still claimed life — separates the two cases: one probe, no loop.
842
1063
  */
843
1064
  private refreshAndReconcile;
844
- /**
845
- * Record whether a token the server issued MOMENTS AGO already reads as
846
- * expired. It cannot genuinely be — so a `false` here is proof the device
847
- * clock runs fast, and releases the `exp` guard for this process.
848
- *
849
- * Call this at every site that installs a server-issued token, and only
850
- * with a token that just arrived. Handing it a cached token would let a
851
- * stale deadline confirm itself.
852
- */
1065
+ /** A token the server issued MOMENTS AGO already reads as expired ⇒ the clock runs fast; release the `exp` guard for this process. */
853
1066
  private learnClockSkewFrom;
854
1067
  /** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
855
1068
  private deadlinesDisagree;
1069
+ /**
1070
+ * Returns a VALID (non-expired) access token, or null.
1071
+ *
1072
+ * Hard contract (matches sdk-ios / sdk-android, and the industry
1073
+ * token-client pattern — Supabase `_callRefreshToken`, Auth0
1074
+ * `getTokenSilently`, Firebase `getIdToken`): this method NEVER returns a
1075
+ * token past its `exp`. Callers attach the result as a Bearer to arbitrary
1076
+ * resource servers that know nothing of Sendora's refresh grace, so a token
1077
+ * must be either fresh or absent.
1078
+ *
1079
+ * Resolution order:
1080
+ * 0. `withheld` → null, no refresh (ADR-033 [2c]: the refused session is
1081
+ * never served and never refreshed by this process).
1082
+ * 1. Cached token with comfortable life left (>= 30 s before BOTH the
1083
+ * tracked deadline and the token's own `exp`) → return it. Fast path.
1084
+ * `{ forceRefresh: true }` skips it (the caller has out-of-band evidence
1085
+ * the tracked deadline is wrong); it does NOT bypass the single-flight or
1086
+ * the backoff cooldown.
1087
+ * 2. No refresh token in storage → genuinely signed out → null.
1088
+ * 3. Expired/near-expiry but a refresh chain exists: a refresh already
1089
+ * inflight → await it (single-flight, AbortController-bounded, can never
1090
+ * hang the bridge); in backoff cooldown → null (honest "no token now");
1091
+ * else fire the refresh and await it.
1092
+ */
856
1093
  getAccessToken(options?: {
857
1094
  forceRefresh?: boolean;
858
1095
  }): Promise<string | null>;
859
- /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
1096
+ /** Sync read — returns whatever's cached even if expired (`null` in `withheld`). Prefer the async variant. */
860
1097
  getAccessTokenSync(): string | null;
1098
+ /**
1099
+ * The currently-stored refresh token, or null when signed out (and in
1100
+ * `withheld`). Kept for integrators that journal the guest token for the
1101
+ * deprecated `merge()`; since 1.41.0 the guest is HELD on the device instead —
1102
+ * read `getHeldGuests()`.
1103
+ */
1104
+ getRefreshToken(): string | null;
861
1105
  /**
862
1106
  * Return the current session, minting an anonymous one only when there is
863
1107
  * none.
864
1108
  *
865
- * Reuses the existing session when there already is one — anonymous OR
866
- * identified. The anonymous half is the same short-circuit Firebase's
867
- * `signInAnonymously` performs. Without it, an app that calls this
868
- * defensively on every cold launch (the most natural thing to write) mints a
869
- * fresh `user_id` each time, and `persist()` overwrites the stored refresh
870
- * token — the previous anonymous account's only durable handle — with no
871
- * takeover, no webhook and no state event. That is the same silent progress
872
- * loss as a failed sign-in wiping the session, except it happens on a
873
- * *healthy* network.
874
- *
875
- * ⚠ The identified half closes the same hole from the other side (1.40.0,
876
- * reported by RopoLabs). Until then the guard was `existing.isAnonymous`, so
877
- * a plain `signInAnonymously()` over a LIVE ACCOUNT session fell through,
878
- * minted a guest and overwrote the account's tokens locally — with no
879
- * `signed_out`, because this path never wipes anything; it persists over the
880
- * top. An app that called it on a "looks signed out" reading silently turned
881
- * a paying account into a fresh guest, and its own session-death listener
882
- * never fired. The account itself survives (row + server session untouched,
883
- * recoverable by its credential) but the device is now a stranger to it.
884
- * Returning the identified session is a no-op for that caller and the honest
885
- * answer to "make sure I have a session" — read `user.isAnonymous` if you
886
- * need to know which kind you got.
887
- *
888
- * Pass `forceNew: true` to deliberately abandon the current session —
889
- * whichever kind — and mint a separate anonymous one. That is the ONLY way
890
- * this method replaces an identified session, and it does so silently (the
891
- * caller asked for it); `signOut()` first if you want a `signed_out`.
1109
+ * - `anonymous` / `identified` → returned as-is, no network (the 1.29 / 1.40.0
1110
+ * guards as one row). Read `user.isAnonymous` to know which kind you got.
1111
+ * - `signed_out` → with `autoRestoreHeldGuest` on (default) and an eligible
1112
+ * held guest on the device, the NEWEST one is restored instead of minting —
1113
+ * visibly: `signed_in{cause:'restore'}` + `guest_released{reason:'restored'}`.
1114
+ * A dead slot is released (`guest_released{dead}`) and the next tried; none
1115
+ * left → a fresh mint in the same call. Otherwise `POST /anonymous` →
1116
+ * `signed_in{cause:'mint'}`.
1117
+ * - `unknown` → queued until the gap-fill refresh settles the status.
1118
+ * - `withheld` → rejects `AuthError{kind:'config', code:'SESSION_WITHHELD'}`.
1119
+ *
1120
+ * `forceNew: true` abandons the current session and mints a separate guest:
1121
+ * from a guest the current guest is HELD (never deleted); from an identified
1122
+ * session it is replaced silently (its refresh token revoked). At the held
1123
+ * capacity (K=5) it throws `HeldGuestLimitError` and mutates nothing.
892
1124
  */
893
1125
  signInAnonymously(opts?: {
894
1126
  name?: string;
895
1127
  metadata?: Record<string, unknown>;
896
1128
  forceNew?: boolean;
897
1129
  }): Promise<AuthUser>;
898
- /**
899
- * Create an email/password account.
900
- *
901
- * Industry-standard upgrade-first flow (matches Firebase
902
- * `linkWithCredential`, Auth0 anonymous-merge, Supabase
903
- * `linkIdentity`): when a refresh token is present in storage we
904
- * try `/auth-service/upgrade` first — backend validates the
905
- * refresh, checks the bound user is anonymous, and promotes in
906
- * place preserving `user.id`. If the bound user is NOT anonymous
907
- * (already identified, expired refresh, etc.), backend rejects
908
- * with FORBIDDEN_NON_ANONYMOUS / INVALID_REFRESH and we fall back
909
- * to a fresh /signup.
910
- *
911
- * Why we don't gate on the cached `this.user.isAnonymous`
912
- * boolean: the in-memory user can be stale during cold start or
913
- * fast-refresh races. The refresh token in storage is the
914
- * authoritative signal — if it's there, the upgrade path is
915
- * worth attempting; backend has the source of truth.
1130
+ /** Alias of `signInAnonymously()` — "make sure I have a session". */
1131
+ ensureSession(): Promise<AuthUser>;
1132
+ /** Row 6 (and row 11's second half): `POST /anonymous` → ProjectGuard on the RESPONSE → install. */
1133
+ private mintFresh;
1134
+ /**
1135
+ * Row 7 — one auto-restore attempt for `signInAnonymously()`. Returns the
1136
+ * restored user, or `null` when the slot was dead and released (the caller
1137
+ * tries the next slot, then mints). A transient failure propagates.
1138
+ */
1139
+ private restoreSlotForMint;
1140
+ /** The per-attempt B10 key: reuse the persisted one (a crash-retry) or mint a new one and persist it until MINT_OK. */
1141
+ private currentMintKey;
1142
+ /**
1143
+ * Create an email/password account — routed by `status()`:
1144
+ *
1145
+ * - `anonymous` → `POST /me/link/email` on the current Bearer session: the
1146
+ * guest is promoted IN PLACE (same `user.id`), the rotated
1147
+ * pair is installed, `signed_in{cause:'link'}` fires. A
1148
+ * taken email throws `EmailAlreadyTakenError` and changes
1149
+ * nothing.
1150
+ * - `signed_out` → `POST /signup`, a fresh account.
1151
+ * - `identified` → `AlreadyIdentifiedError`, decided LOCALLY (no round-trip,
1152
+ * nothing minted — use `linkEmailPassword()` instead).
1153
+ * - `unknown` → waits for the status to settle, then routes as above.
1154
+ * - `withheld` → rejects `SESSION_WITHHELD`.
1155
+ *
1156
+ * Never calls `/upgrade` (ADR-033 INV-4): a generic 401 there used to wipe
1157
+ * the guest, and the "dead token → wipe → fresh signup" branch is gone.
916
1158
  */
917
1159
  signUp(email: string, password: string, opts?: {
918
1160
  name?: string;
@@ -922,6 +1164,10 @@ declare class Auth {
922
1164
  * Sign in with email + password. When the user has confirmed MFA,
923
1165
  * returns `{mfaRequired, mfaChallengeToken}` instead of an
924
1166
  * `AuthUser`; caller must follow up with `challengeMfa()`.
1167
+ *
1168
+ * A sign-in from a guest HOLDS the guest on this device (`guest_held`, then
1169
+ * ONE `signed_in{cause:'sign_in'}`); the guest's server row survives. The
1170
+ * request never carries the guest's refresh token (ADR-033 INV-3).
925
1171
  */
926
1172
  signIn(email: string, password: string): Promise<AuthUser | {
927
1173
  mfaRequired: true;
@@ -932,11 +1178,18 @@ declare class Auth {
932
1178
  * Verify an IdP-issued credential (authorization code OR id_token)
933
1179
  * and mint a Sendora session. Customer's app handles the IdP
934
1180
  * dance — typically via expo-auth-session / react-native-app-auth
935
- * — then hands the result here. See web SDK for full provider
936
- * matrix; mirrors that surface 1:1.
1181
+ * — then hands the result here. Mirrors the web SDK's surface 1:1.
1182
+ *
1183
+ * `link: true` (ADR-033 INV-14) — attach-or-error, never a silent switch:
1184
+ * from a guest the account is promoted IN PLACE (same `user.id`,
1185
+ * `signed_in{cause:'link'}`); over an identified session the identity is
1186
+ * attached (`user_updated{cause:'link'}`), an identity already on the account
1187
+ * resolves quietly, one owned elsewhere throws `CredentialInUseError`
1188
+ * (or switches when `onCredentialInUse: "adopt"`), a stale session throws
1189
+ * `RECENT_AUTH_REQUIRED`. In `signed_out` it is a plain sign-in.
937
1190
  */
938
1191
  loginSocial(input: {
939
- provider: "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
1192
+ provider: SocialCredentialProvider;
940
1193
  code?: string;
941
1194
  idToken?: string;
942
1195
  redirectUri?: string;
@@ -945,21 +1198,7 @@ declare class Auth {
945
1198
  firstName?: string;
946
1199
  lastName?: string;
947
1200
  };
948
- /**
949
- * ADR-025 link-in-place opt-in. When this device is anonymous and you set
950
- * `link: true`, an anon→social upgrade KEEPS the same user id (sub) — the
951
- * anon account is promoted in place (like Firebase linkWithCredential)
952
- * instead of a device-takeover that mints a new id. No effect when the
953
- * device isn't anonymous, or when the social identity already belongs to
954
- * another account (collision → falls back to takeover/merge).
955
- */
956
1201
  link?: boolean;
957
- /**
958
- * What to do if this social identity (or its verified email) already
959
- * belongs to an account. **Omit it** — the default adopts when nothing
960
- * would be destroyed and refuses when a live guest session would be
961
- * deleted. See {@link CredentialCollisionPolicy}.
962
- */
963
1202
  onCredentialInUse?: CredentialCollisionPolicy;
964
1203
  }): Promise<AuthUser>;
965
1204
  signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
@@ -972,7 +1211,7 @@ declare class Auth {
972
1211
  firstName?: string;
973
1212
  lastName?: string;
974
1213
  };
975
- /** ADR-025: keep the same user id on an anon→Apple upgrade. See `loginSocial`. */
1214
+ /** Attach-or-error (ADR-033 INV-14). See `loginSocial`. */
976
1215
  link?: boolean;
977
1216
  }): Promise<AuthUser>;
978
1217
  signInWithMicrosoft(code: string, redirectUri: string): Promise<AuthUser>;
@@ -984,15 +1223,18 @@ declare class Auth {
984
1223
  *
985
1224
  * **No-arg (1.23.0, recommended):** `signInWithGameCenter()` drives Game
986
1225
  * Center natively via the `@sendoracloud/sdk-react-native-gaming` companion
987
- * (`fetchItems(forIdentityVerificationSignature:)`, base64 signature/salt,
988
- * `teamPlayerID`, bundle id) and posts the result. Needs that companion + its
989
- * config plugin `{ gameCenter: true }` + a Dev Client — NOT Expo Go.
1226
+ * and posts the result. Needs that companion + its config plugin
1227
+ * `{ gameCenter: true }` + a Dev Client — NOT Expo Go.
990
1228
  *
991
1229
  * **Explicit:** `signInWithGameCenter(payload)` posts a payload you obtained
992
1230
  * from your OWN GameKit module (works anywhere, incl. bare RN).
993
1231
  *
994
- * `link: true` KEEPS the same user id when upgrading an anonymous device
995
- * (ADR-025 link-in-place); no effect off-anon or on a collision.
1232
+ * `link: true` = attach-or-error (ADR-033 INV-14): promotes a guest in place
1233
+ * (same `user.id`); over an identified session attaches, resolves quietly when
1234
+ * the identity is already on the account, throws `CredentialInUseError` when
1235
+ * it is owned elsewhere (pass `onCredentialInUse: "adopt"` to switch — the
1236
+ * guest is HELD on the device, never deleted). A plain call switches accounts
1237
+ * and holds the previous guest.
996
1238
  */
997
1239
  signInWithGameCenter(opts?: GameSignInOptions): Promise<AuthUser>;
998
1240
  signInWithGameCenter(payload: GameCenterPayload, opts?: GameSignInOptions): Promise<AuthUser>;
@@ -1000,26 +1242,79 @@ declare class Auth {
1000
1242
  * Google Play Games sign-in (email-less, Android-only).
1001
1243
  *
1002
1244
  * **No-arg (1.23.0, recommended):** `signInWithPlayGames()` drives Play Games
1003
- * natively via the `@sendoracloud/sdk-react-native-gaming` companion
1004
- * (`requestServerSideAccess(webClientId, false)`, web client id read from the
1005
- * config-plugin manifest meta-data) and posts the `serverAuthCode`. Needs that
1006
- * companion + its plugin `{ playGamesWebClientId, playGamesProjectId }` + a Dev
1007
- * Client — NOT Expo Go.
1245
+ * natively via the companion and posts the `serverAuthCode`. Needs the
1246
+ * companion + its plugin + a Dev Client — NOT Expo Go.
1008
1247
  *
1009
1248
  * **Explicit:** `signInWithPlayGames({ serverAuthCode })` posts a code you
1010
1249
  * obtained from your OWN Play Games module.
1011
1250
  *
1012
- * `link: true` = ADR-025 link-in-place (same user id on an anon upgrade).
1251
+ * `link: true` = attach-or-error (ADR-033 INV-14); see `signInWithGameCenter`.
1013
1252
  */
1014
1253
  signInWithPlayGames(opts?: GameSignInOptions): Promise<AuthUser>;
1015
1254
  signInWithPlayGames(payload: {
1016
1255
  serverAuthCode: string;
1017
1256
  }, opts?: GameSignInOptions): Promise<AuthUser>;
1257
+ /**
1258
+ * Shared gaming-identity sign-in. Resolves the credential FIRST (for the
1259
+ * no-arg native path this triggers the Game Center / Play Games sheet inside
1260
+ * the serialized op — a cancelled sheet leaves the session untouched), then
1261
+ * routes by `link` and `status()` exactly like `loginSocial`.
1262
+ */
1263
+ private gameIdentitySignIn;
1264
+ /**
1265
+ * The legacy-signature LINK (`signInWith*({link:true})`, `loginSocial({link:true})`)
1266
+ * in `anonymous` / `identified` (ADR-033 rows 15–16, §8-Q18):
1267
+ * - a promotion / attach resolves with the user (`signed_in{cause:'link'}` on
1268
+ * the same-`sub` promotion — preserved from 1.30–1.40; `user_updated` on an
1269
+ * identified attach; NOTHING on `alreadyLinked`);
1270
+ * - 409 → `CredentialInUseError`, or with `onCredentialInUse: "adopt"` the
1271
+ * explicit switch to the owning account (the guest is HELD);
1272
+ * - 403 → `AuthError` `RECENT_AUTH_REQUIRED` (kind `recent_auth_required`).
1273
+ */
1274
+ private legacyLink;
1275
+ /**
1276
+ * Exchange the MFA challenge token from `signIn` for a real session by
1277
+ * submitting a TOTP or recovery code. A wrong code throws and preserves the
1278
+ * current session for a retry; success holds the guest (if any) and installs
1279
+ * the account exactly like a direct sign-in.
1280
+ */
1281
+ challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
1282
+ /**
1283
+ * Plain sign-in / SWITCH with a typed credential. Never links: from a guest
1284
+ * the guest is HELD (`previous.held === true`); from an identified account
1285
+ * the previous session is replaced and its refresh token revoked
1286
+ * (`previous.isAnonymous === false` ⇒ skip `mergeAnon`, reset local save
1287
+ * state). The request never carries a guest credential (INV-3). MFA returns
1288
+ * `{status:'mfa_required', verify(code)}`.
1289
+ */
1290
+ signInWith(credential: Credential): Promise<SignInResult>;
1291
+ /**
1292
+ * Attach a credential to the CURRENT session — the ONLY upgrade (INV-4).
1293
+ * From a guest: promoted in place (same `user.id`, `upgraded: true`,
1294
+ * `user_updated{cause:'link'}`). From an identified account: attached
1295
+ * (`user_updated`), or `alreadyLinked: true` with no event. Owned elsewhere →
1296
+ * `{status:'credential_in_use', switchAccount(), keepCurrent()}` with nothing
1297
+ * mutated; stale session → `{status:'recent_auth_required'}`. Throws
1298
+ * `NOT_SIGNED_IN` in `signed_out` (nothing to attach to) and
1299
+ * `UNSUPPORTED_LINK_CREDENTIAL` for a kind that cannot be attached
1300
+ * (`magicLink`, `emailOtp`, `passkey`, `mfa`).
1301
+ */
1302
+ link(credential: Credential): Promise<LinkResult>;
1303
+ /**
1304
+ * The ONLY path from a refused link to a sign-in, BOUND to the refused
1305
+ * credential (INV-14): the same payload is reused while under 5 minutes old;
1306
+ * older than that, a Game Center / Play Games credential is re-fetched
1307
+ * natively and the Game Center `teamPlayerId` compared — a different player
1308
+ * aborts with `CREDENTIAL_CHANGED` and nothing is mutated. (A Play Games
1309
+ * server auth code carries no player id, so a re-fetched code is used as-is.)
1310
+ */
1311
+ private switchAccount;
1312
+ private toSignInResult;
1018
1313
  /** Link email + password to the current account (sub preserved). */
1019
1314
  linkEmailPassword(email: string, password: string): Promise<AuthUser>;
1020
1315
  /** Link an OAuth social identity (verified idToken or code) to the current account. */
1021
1316
  linkSocial(input: {
1022
- provider: "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
1317
+ provider: SocialCredentialProvider;
1023
1318
  code?: string;
1024
1319
  idToken?: string;
1025
1320
  redirectUri?: string;
@@ -1059,76 +1354,39 @@ declare class Auth {
1059
1354
  serverAuthCode: string;
1060
1355
  }): Promise<AuthUser>;
1061
1356
  /**
1062
- * Shared link executor. POSTs the verified credential with the caller's
1063
- * end-user Bearer token, then refreshes the cached user object IN PLACE (no
1064
- * token rotation — the sub is unchanged). Deliberately NOT wrapped in
1065
- * serialize(): getAccessToken() serializes internally, so re-entering would
1066
- * deadlock — same non-serialized posture as deleteAccount().
1357
+ * Shared `link*()` executor — the LINK row with the `link*()` emission
1358
+ * (`user_updated{cause:'link'}`, never `signed_in`). A 409 throws
1359
+ * `CredentialInUseError`; a 403 throws `RECENT_AUTH_REQUIRED`; no session →
1360
+ * `NOT_SIGNED_IN` with no network call.
1067
1361
  */
1068
1362
  private linkCredential;
1069
- /**
1070
- * Refresh the cached user after a link — the sub is unchanged, so only the
1071
- * user object + its storage copy are updated (no token/identity rotation).
1072
- */
1073
- private updateLocalUser;
1074
1363
  /**
1075
1364
  * List the auth methods/identities linked to the current account (ADR-030
1076
- * read side, s58.270). Returns every social/gaming identity on this account
1077
- * plus a `hasPassword` flag — the cross-device / reinstall-durable source of
1078
- * truth for a "Connected: Game Center · Google" UI (an on-device tracker
1079
- * misses a link made on another device). Bearer-gated network read (NOT a
1080
- * cache read — `getUserSync()` only holds the primary `signupMethod`/
1081
- * `lastLoginMethod`). Resolves a FRESH access token first (mirrors
1082
- * `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
1365
+ * read side, s58.270): every social/gaming identity plus a `hasPassword`
1366
+ * flag — the cross-device / reinstall-durable source of truth for a
1367
+ * "Connected: Game Center · Google" UI. Bearer-gated network read that
1368
+ * resolves a FRESH access token first. Firebase `user.providerData` /
1369
+ * Supabase `user.identities` parity.
1083
1370
  */
1084
1371
  listLinkedIdentities(): Promise<LinkedIdentitiesResponse>;
1085
1372
  /**
1086
- * Remove one linked provider from the signed-in account.
1087
- *
1088
- * The write half of `listLinkedIdentities()` — what a "Disconnect" button
1089
- * next to each entry on a Connected Accounts screen calls.
1373
+ * Remove one linked provider from the signed-in account — the write half of
1374
+ * `listLinkedIdentities()`.
1090
1375
  *
1091
1376
  * ⚠ The server REFUSES to remove the last way into the account, with
1092
- * `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
1093
- * account with no credentials still exists and still holds the user's data,
1094
- * but nothing can ever authenticate into it again — so this is a permanent
1095
- * lockout, not an inconvenience. A password counts as a credential.
1096
- *
1097
- * Prefer to prevent the tap rather than explain the refusal: read
1098
- * `listLinkedIdentities()` and disable Disconnect when
1099
- * `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
1100
- * backstop, not the UX.
1377
+ * `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"` — a
1378
+ * permanent lockout, not an inconvenience. Prefer preventing the tap: disable
1379
+ * Disconnect when `identities.length + (hasPassword ? 1 : 0) <= 1`.
1101
1380
  *
1102
1381
  * Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
1103
- * provider is not linked to this account. Removing an already-absent
1104
- * provider is NOT treated as success — a silent no-op would let a buggy
1105
- * screen report "disconnected" for a credential that is still attached.
1382
+ * provider is not linked (an absent provider is NOT reported as removed).
1106
1383
  */
1107
1384
  unlink(provider: string): Promise<UnlinkResult>;
1108
- /**
1109
- * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
1110
- * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
1111
- * identity, forwards it as `prevAnonRefreshToken`, and opts into
1112
- * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
1113
- * centrally from `retiredAnonUserId`.
1114
- */
1115
- private gameIdentitySignIn;
1116
- /**
1117
- * Read the stored anon refresh token if (and only if) the local
1118
- * subject is currently anonymous. Used by every identified-session
1119
- * mint path so the backend can run device-takeover (s58.111 +
1120
- * s58.112). Returns `undefined` when not anonymous — never forward
1121
- * an identified user's own refresh token.
1122
- */
1123
- private readPrevAnonRefreshToken;
1124
- /**
1125
- * Exchange the MFA challenge token from `signIn` for a real session
1126
- * by submitting a TOTP or recovery code from the user's authenticator.
1127
- */
1128
- challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
1129
1385
  sendMagicLink(email: string): Promise<void>;
1386
+ /** A magic-link token is routinely expired or already consumed (mail scanners pre-fetch, users tap twice); a rejection never costs the session. */
1130
1387
  verifyMagicLink(token: string): Promise<AuthUser>;
1131
1388
  sendEmailOtp(email: string): Promise<void>;
1389
+ /** Mistyping a 6-digit code is the most common outcome of this flow; the session survives for the retry. */
1132
1390
  verifyEmailOtp(email: string, code: string): Promise<AuthUser>;
1133
1391
  /** Trigger password-reset email. Resolves on 204 even when address is unknown (anti-enumeration). */
1134
1392
  requestPasswordReset(email: string): Promise<void>;
@@ -1138,6 +1396,81 @@ declare class Auth {
1138
1396
  verifyEmail(token: string): Promise<void>;
1139
1397
  /** Re-send the email-verification email for the currently-signed-in user. */
1140
1398
  sendVerificationEmail(): Promise<void>;
1399
+ /**
1400
+ * The guests held on this device, newest first (`heldAt` desc). Never exposes
1401
+ * tokens. Best-effort: a guest may have been deleted server-side since it was
1402
+ * held — that is only discovered when its token is presented (restore /
1403
+ * claim / discard), as `guest_released{reason:'dead'}`.
1404
+ *
1405
+ * `autoRestore === false` ⇔ the slot was stamped by `deleteAccount()`: only an
1406
+ * explicit `restoreHeldGuest(id)` revives it. `project` is the guest's JWT
1407
+ * `project` claim at HOLD (`null` when it was unreadable).
1408
+ */
1409
+ getHeldGuests(): HeldGuestSummary[];
1410
+ /** The newest held guest, or null. */
1411
+ getHeldGuest(): {
1412
+ userId: string;
1413
+ heldAt: number;
1414
+ } | null;
1415
+ /**
1416
+ * Fold a held guest INTO the current identified account (`POST /merge`):
1417
+ * reassigns its events, profile, entities and push tokens, DELETES the guest
1418
+ * row and fires `auth.user_merged`. Irreversible, Sendora-side only — it does
1419
+ * not touch app data keyed on the guest's `sub`. ⚠ OPTIONAL: an app whose
1420
+ * backend does not act on `auth.user_merged` MUST NOT call it.
1421
+ *
1422
+ * Identified only. `id` may be omitted only when exactly one guest is held
1423
+ * (else `AMBIGUOUS_HELD_GUEST`). The held token is refreshed FIRST (proves
1424
+ * liveness, repairs a spent parent inside the 60 s grace) and the rotation is
1425
+ * persisted into the slot BEFORE the merge; a dead guest releases the slot
1426
+ * (`guest_released{dead}`) and rejects `HELD_GUEST_GONE`. Emits
1427
+ * `user_updated{cause:'merge'}` + `guest_released{reason:'claimed'}`.
1428
+ */
1429
+ claimHeldGuest(id?: string): Promise<{
1430
+ user: AuthUser;
1431
+ stats: MergeStats;
1432
+ }>;
1433
+ /**
1434
+ * Sign back in AS a held guest without merging (the RESTORE row). From
1435
+ * `signed_out` or an identified account (whose refresh token is revoked
1436
+ * fire-and-forget); from a guest the CURRENT guest is held first (its slot is
1437
+ * written before the chosen one is released, so there is never a window with
1438
+ * zero handles to either). `id` may be omitted only when exactly one guest is
1439
+ * held. Client-side project check FIRST: a slot whose recorded `project`
1440
+ * mismatches `expectedProjectId ?? config.projectId` throws
1441
+ * `HeldGuestProjectMismatch` with no network and the slot kept. A dead guest
1442
+ * releases the slot and rejects `HELD_GUEST_GONE`. Emits
1443
+ * `signed_in{cause:'restore'}` (re-register push) + `guest_released{restored}`.
1444
+ */
1445
+ restoreHeldGuest(id?: string): Promise<AuthUser>;
1446
+ /**
1447
+ * DELETE a held guest on the server (`POST /guest/discard` — a real erasure:
1448
+ * data → credentials → row → `auth.user_deleted{reason:'guest_discarded'}`)
1449
+ * and release its slot. The app is the requester on behalf of its player
1450
+ * (s58.316); the SDK never calls this on its own. `id` may be omitted only
1451
+ * when exactly one guest is held. A 2xx or a 404 (already gone) releases the
1452
+ * slot; any other failure keeps it. Emits `guest_released{reason:'discarded'}`.
1453
+ */
1454
+ discardHeldGuest(id?: string): Promise<void>;
1455
+ /** Shared body of `discardHeldGuest()` and `deleteAccount({alsoDiscardHeldGuests})`. */
1456
+ private discardSlot;
1457
+ /**
1458
+ * While `status() === "withheld"`: why the stored session was refused. Never
1459
+ * exposes tokens. `userId` lets a `sub`-keyed backend record oldSub → newSub
1460
+ * before `discardWithheld()`. `null` in every other state.
1461
+ */
1462
+ getWithheld(): WithheldInfo | null;
1463
+ /**
1464
+ * Resolve `withheld` — the local counterpart of `signOut()` in that state
1465
+ * (and what `signOut()` does there). Precondition `status() === "withheld"`
1466
+ * (else resolves `false`, nothing mutated). CAS on the withheld refresh token;
1467
+ * clears the session keys only; fire-and-forget `/token/revoke` like
1468
+ * `signOut()`; LOCAL ONLY — never `/guest/discard`, never a server delete;
1469
+ * KEEPS held slots, the event queue and the persisted `user_id`. Emits
1470
+ * `signed_out{reason:'user'}`. The other exit is relaunching with the matching
1471
+ * `expectedProjectId`.
1472
+ */
1473
+ discardWithheld(): Promise<boolean>;
1141
1474
  enrollMfa(): Promise<{
1142
1475
  secret: string;
1143
1476
  otpauthUrl: string;
@@ -1145,14 +1478,10 @@ declare class Auth {
1145
1478
  }>;
1146
1479
  confirmMfa(code: string): Promise<boolean>;
1147
1480
  /**
1148
- * Disable MFA.
1149
- *
1150
- * ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
1151
- * `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
1152
- *
1153
- * ⚠ Discarded its response until s58.335, and `request()` never throws on a
1154
- * non-2xx (it warns and returns the Response), so the refusal had nowhere to
1155
- * surface — the app reported MFA as off while TOTP stayed enrolled.
1481
+ * Disable MFA. ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with
1482
+ * 403 `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`. The response is
1483
+ * inspected (s58.335): `request()` never throws on a non-2xx, so a discarded
1484
+ * return would report the refusal as success.
1156
1485
  */
1157
1486
  disableMfa(): Promise<void>;
1158
1487
  listMySessions(): Promise<Array<{
@@ -1162,174 +1491,236 @@ declare class Auth {
1162
1491
  lastUsedAt: string | null;
1163
1492
  createdAt: string;
1164
1493
  }>>;
1165
- /**
1166
- * Revoke one of the caller's own sessions ("sign out that device").
1167
- * ⚠ Discarded its response until s58.335 — a "sign out this device" button
1168
- * that reports success on a failure is the one place a user acts on the claim
1169
- * and then stops worrying.
1170
- */
1494
+ /** Revoke one of the caller's own sessions ("sign out that device"). Inspects the response (s58.335). */
1171
1495
  revokeSession(sessionId: string): Promise<void>;
1172
- /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1496
+ /** Revoke every session for the caller. Inspects the response (s58.335). */
1173
1497
  revokeAllSessions(): Promise<void>;
1174
1498
  /**
1175
1499
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
1176
- * Honors the project's configured grace period:
1177
- * - `"purged"` — hard-deleted immediately (grace = 0).
1178
- * - `"pending"` — deactivated + all sessions revoked now; hard-deleted at
1179
- * `scheduledPurgeAt`. The user can CANCEL by signing back in before then.
1180
- * Either outcome wipes local identity (the server has already revoked the
1181
- * session). Requires a signed-in user. Throws on a non-2xx response.
1182
- *
1183
- * Resolves a FRESH access token via `getAccessToken()` first (refreshing if
1184
- * the cached one expired) — unlike the other Bearer calls this is a one-shot
1185
- * destructive action, so a 401 from a stale token would silently strand the
1186
- * user with an undeleted account. A stale `this.accessToken` was the cause of
1187
- * the 401s seen in prod when a user tapped "delete" after the app sat idle.
1188
- */
1189
- deleteAccount(): Promise<{
1500
+ * Honors the project's configured grace period: `"purged"` (hard-deleted
1501
+ * now) or `"pending"` (deactivated + sessions revoked now, hard-deleted at
1502
+ * `scheduledPurgeAt`; cancellable by signing back in). Resolves a FRESH access
1503
+ * token first — a stale token's 401 would silently strand the user with an
1504
+ * undeleted account.
1505
+ *
1506
+ * Held guests (ADR-033): deleting an account never touches a held guest, on
1507
+ * the device or the server. By default they SURVIVE with auto-restore
1508
+ * suppressed (`autoRestore: false` — only an explicit `restoreHeldGuest(id)`
1509
+ * revives one; `ensureSession()` mints fresh). `alsoDiscardHeldGuests: true`
1510
+ * is the player's explicit ask to erase them too: each is discarded after the
1511
+ * 2xx, a slot that fails to discard stays held and is listed in
1512
+ * `discardFailed`. `heldGuests` = guests still held afterwards. The queued
1513
+ * analytics events are dropped (erasure completeness, 1.39.1).
1514
+ */
1515
+ deleteAccount(opts?: {
1516
+ alsoDiscardHeldGuests?: boolean;
1517
+ }): Promise<{
1190
1518
  status: "purged" | "pending";
1191
1519
  scheduledPurgeAt: string | null;
1192
1520
  graceDays: number;
1521
+ heldGuests: number;
1522
+ discardFailed: string[];
1193
1523
  }>;
1194
1524
  /**
1195
- * Start OIDC sign-in. Returns the IdP's authorization URL — caller
1196
- * opens it via React Native's `Linking.openURL()` (or
1197
- * `WebBrowser.openAuthSessionAsync` from `expo-web-browser` when
1198
- * inside Expo). The IdP redirects through Sendora's callback to the
1199
- * `returnTo` URL with `#sendora_oidc_token=...` in the fragment.
1200
- *
1201
- * On the customer's app side, `returnTo` MUST be a custom URL scheme
1202
- * registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
1203
- * (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
1204
- * call `consumeSsoFragment(url)` on the captured URL.
1525
+ * Start OIDC sign-in. Returns the IdP's authorization URL — caller opens it
1526
+ * via `Linking.openURL()` (or `WebBrowser.openAuthSessionAsync`). The IdP
1527
+ * redirects through Sendora's callback to the `returnTo` URL with
1528
+ * `#sendora_oidc_token=...` in the fragment; hand that URL to
1529
+ * `consumeSsoFragment(url)`. `returnTo` MUST be a custom URL scheme
1530
+ * registered in Info.plist / AndroidManifest. No guest hint travels (INV-3).
1205
1531
  */
1206
1532
  startSso(returnTo: string): Promise<{
1207
1533
  authorizationUrl: string;
1208
1534
  }>;
1209
- /**
1210
- * Kick off a SAML 2.0 SSO flow. Mirrors `startSso` for SAML —
1211
- * backend POST `/auth-service/sso/saml/start` returns the IdP
1212
- * AuthnRequest URL. Caller opens it with
1213
- * ASWebAuthenticationSession (iOS) / Custom Tabs (Android) /
1214
- * Linking, captures the callback URL via the standard
1215
- * `Linking.addEventListener` flow, then passes the captured URL to
1216
- * `consumeSsoFragment(url)`. consumeSsoFragment handles both OIDC
1217
- * and SAML fragments transparently.
1218
- */
1535
+ /** Kick off a SAML 2.0 SSO flow. Mirrors `startSso`; `consumeSsoFragment` handles both fragments. */
1219
1536
  startSaml(returnTo: string): Promise<{
1220
1537
  authorizationUrl: string;
1221
1538
  }>;
1222
1539
  /**
1223
- * Consume an OIDC callback URL captured via React Native's
1224
- * `Linking.addEventListener('url', ...)`. Reads the fragment for
1225
- * `sendora_oidc_token`, exchanges it for a session, and persists.
1226
- * Throws AuthError on `sendora_oidc_error=...` fragment.
1540
+ * Consume an OIDC/SAML callback URL captured via `Linking.addEventListener`.
1541
+ * Reads the fragment for `sendora_oidc_token` / `sendora_saml_token`,
1542
+ * exchanges it for a session via `/token/refresh`, and installs it as a
1543
+ * sign-in (holding a guest, if any). The callback is parsed and exchanged
1544
+ * FIRST — an IdP "Cancel" or a garbage fragment leaves the session untouched.
1227
1545
  */
1228
1546
  consumeSsoFragment(callbackUrl: string): Promise<AuthUser>;
1547
+ /** Populate a user object from access-token claims when a response carries none (pre-B17 `/token/refresh`). */
1548
+ private userFromClaims;
1549
+ /**
1550
+ * Build the Authorization header for end-user-authenticated routes. Awaits
1551
+ * `getAccessToken()` (parity with sdk-web since 1.41.0), so a cold start with
1552
+ * no cached access token or an expired token transparently refreshes first.
1553
+ * Throws `NOT_SIGNED_IN` when there is genuinely no usable session.
1554
+ */
1229
1555
  private bearerHeaders;
1230
1556
  private unwrap;
1231
1557
  /**
1232
- * Sign out: wipe local identity FIRST so the user is logged out on
1233
- * device even if the revoke network call hangs (airplane mode, 5xx).
1234
- * Revoke is fire-and-forget after the wipe.
1558
+ * Sign out: clear the local session FIRST so the user is logged out on
1559
+ * device even if the revoke network call hangs (airplane mode, 5xx); revoke
1560
+ * is fire-and-forget after the clear. Rotates the analytics anon id (a
1561
+ * person boundary). KEEPS held guests — a guest's refresh token is its only
1562
+ * credential, so wiping the slots on logout would be deletion by assumption
1563
+ * (use `discardHeldGuest()` / `reset({includeHeldGuests:true})` deliberately).
1564
+ * In `withheld` this is `discardWithheld()`.
1235
1565
  */
1236
1566
  signOut(): Promise<void>;
1237
- private serialize;
1238
1567
  /**
1239
- * Hot-path token refresh. Single-flight via `refreshInflight` so a
1240
- * UI batch firing 10 simultaneous reads never produces 10 /refresh
1241
- * round-trips. Returns null on any failure so callers can decide
1242
- * whether to surface a sign-in prompt or proceed anonymously.
1568
+ * @deprecated Transfer every event + trait from an anonymous user to the
1569
+ * currently-signed-in (target) user, then delete the anonymous row.
1243
1570
  *
1244
- * s58.46 — on a `INVALID_REFRESH_TOKEN` (new backend error code) or
1245
- * HTTP 401 we WIPE the stored refresh token. Pre-s58.46 we caught
1246
- * the error and returned null but kept the stored token intact, so
1247
- * the very next caller would re-attempt the same dead token in an
1248
- * infinite loop (Pulse News iOS hammered /refresh ~1×/s for hours).
1249
- * The wipe forces the next op to fall through to /anonymous or
1250
- * surface NOT_SIGNED_IN to the host app instead of looping.
1251
- */
1252
- private refreshAccessToken;
1571
+ * Since 1.41.0 the guest you signed in over is HELD on this device, so a
1572
+ * token that matches a held slot (its current refresh token OR the token it
1573
+ * was held with) routes to `claimHeldGuest(thatGuest)` — a DESTRUCTIVE claim.
1574
+ * Any other token takes the raw `/merge` path as before. Prefer
1575
+ * `claimHeldGuest(id)`; never call either from a backend that does not act
1576
+ * on `auth.user_merged`.
1577
+ */
1578
+ merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<MergeStats>;
1579
+ /** `POST /merge` — bypasses `callAuth`'s `isAuthApiResponse` gate (`/merge` returns `{ ...stats, tokens, csrfToken }`, no `user`). */
1580
+ private postMerge;
1581
+ private http;
1582
+ /** ADR-033 [2c]: in `withheld` every verb in REJECTED_IN_WITHHELD rejects with no network and no write. */
1583
+ private rejectIfWithheld;
1584
+ /** True in `anonymous` / `identified` — a live session a LINK can attach to. */
1585
+ private hasSession;
1586
+ private serialize;
1253
1587
  /**
1254
- * Wave 62 — AppState resume hook. Clears stuck backoff so a user
1255
- * who backgrounded the app during a network blip + returns now
1256
- * gets an immediate fresh refresh attempt instead of waiting on
1257
- * the cooldown timer.
1258
- *
1259
- * Called from the AppState 'active' listener registered in
1260
- * startProactiveRefreshCron. Idempotent + cheap — three field
1261
- * writes; no I/O.
1588
+ * INV-5 — a session-installing verb in `unknown` QUEUES until the status
1589
+ * settles: the single gap-fill refresh either installs the identity or
1590
+ * reports the token dead (`signed_out`). Offline it keeps waiting with the
1591
+ * refresh backoff (never minting, never wiping). Ends in `withheld` → rejects.
1592
+ */
1593
+ private awaitSettled;
1594
+ /** An identity-changing attempt awaits any in-flight refresh at start AND at commit (ADR-033 §3.3 scheduling rule 1). */
1595
+ private awaitRefreshQuiet;
1596
+ private snapshotIdentity;
1597
+ /** The identity CAS (INV-2): `sid` when both sides know it, else the `sub`; a signed-out side never matches a live one. */
1598
+ private identityMatches;
1599
+ /** The current session's JWT `project` claim (string | null), or undefined when unreadable. */
1600
+ private claimProject;
1601
+ /**
1602
+ * The ProjectGuard (INV-15) as `commit()` applies it: the shared
1603
+ * `projectVerdict()` over the access JWT, and — when the claim is unreadable
1604
+ * — the B17 `user.project` field as the second source. Absent on both = `unknown`.
1605
+ */
1606
+ private verdictFor;
1607
+ private heldCount;
1608
+ private heldSorted;
1609
+ /** Slot indices eligible for auto-restore, newest first (ADR-033 §3.3 "eligible"). */
1610
+ private eligibleHeldSlots;
1611
+ private freeSlotIndex;
1612
+ /** The `id` rule shared by claim / restore / discard: omit it ONLY when exactly one guest is held. */
1613
+ private resolveSlot;
1614
+ private doSignInDone;
1615
+ /**
1616
+ * The SIGN_IN / SWITCH row (ADR-033 §3.3 row 18). The body carries NO guest
1617
+ * hint; a success HOLDS a previous guest (post-refresh tokens) and installs
1618
+ * the new session under the identity CAS; any failure leaves the caller
1619
+ * exactly as found.
1620
+ */
1621
+ private doSignIn;
1622
+ /** 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). */
1623
+ private installSignIn;
1624
+ /**
1625
+ * The LINK / LINK-UPGRADE rows (ADR-033 §3.3 rows 15–16) on the current
1626
+ * Bearer session. 200 with a rotated pair (`upgraded`) → ProjectGuard →
1627
+ * install (same `sub`); 200 attached → user updated in place; 200
1628
+ * `alreadyLinked` → nothing; 409 / 403 → typed outcomes, nothing mutated.
1629
+ */
1630
+ private doLink;
1631
+ /**
1632
+ * Present a held guest's refresh token to `/token/refresh` — the ONLY way a
1633
+ * held token is ever refreshed (the proactive cron never touches a slot).
1634
+ * The rotation is persisted INTO THE SLOT under a per-slot CAS before anything
1635
+ * else happens; a dead verdict releases the slot only for `endReason ∈
1636
+ * deleted|revoked|expired` (`reuse`/absent keep it: `kept`).
1637
+ */
1638
+ private refreshHeld;
1639
+ /** The RESTORE row's install half: ProjectGuard on the refresh response, install BEFORE releasing the slot. */
1640
+ private installRestored;
1641
+ /**
1642
+ * Hot-path token refresh. Single-flight via `refreshInflight` so a UI batch
1643
+ * firing 10 simultaneous reads never produces 10 `/refresh` round-trips.
1644
+ * Returns null on any failure so callers can decide whether to surface a
1645
+ * sign-in prompt or proceed anonymously.
1646
+ *
1647
+ * The result is applied ONLY if the token that was sent is still the one on
1648
+ * disk (token CAS, INV-2): a refresh landing after `signOut()` must not
1649
+ * resurrect the session, and an OLD token's rejection must not wipe a newer
1650
+ * one. A rotation is not a transition; the user object is adopted when the
1651
+ * SDK has none (gap-fill from `unknown`, 1.29.0) and re-judged by the project
1652
+ * guard before install (a mismatch → `withheld`, INV-15).
1262
1653
  */
1654
+ private refreshAccessToken;
1655
+ /** Wave 62 — AppState resume hook: clear stuck backoff so a returning user gets an immediate attempt. */
1263
1656
  private resetRefreshState;
1264
1657
  /**
1265
- * Proactive refresh (s58.72). Fires when access-token age crosses
1266
- * ~80% of TTL. Pairs with the AppState foreground listener so a
1267
- * backgrounded app coming back to active state immediately runs
1268
- * a tick — much better UX than waiting 60s for the next interval.
1269
- * Stopped on signOut() / wipeLocalIdentity().
1658
+ * Proactive refresh (s58.72). Fires when access-token age crosses ~80% of
1659
+ * TTL, repairs a stale deadline the combined guard can see, and performs the
1660
+ * single gap-fill refresh for an `unknown` record. Armed ONLY by `commit()`
1661
+ * (install / hydrate), stopped by the clear and withhold paths. ⚠ Never
1662
+ * starts a refresh while an identity attempt is pending (ADR-033 §3.3
1663
+ * scheduling rule 2) and never refreshes a held guest.
1270
1664
  */
1271
1665
  private proactiveRefreshTimer;
1272
1666
  private proactiveRefreshAppStateUnsub;
1273
1667
  private startProactiveRefreshCron;
1274
1668
  private stopProactiveRefreshCron;
1275
- /**
1276
- * Returns the currently-stored refresh token (or null when signed out).
1277
- * Primary use: capture the anonymous refresh token BEFORE calling
1278
- * `signIn()` so it can be transferred via `merge()` after the existing
1279
- * account is authenticated. See the "Anonymous → identified" section
1280
- * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
1281
- */
1282
- getRefreshToken(): string | null;
1283
- /**
1284
- * Transfer every event + trait from an anonymous user to the
1285
- * currently-signed-in (target) user, then delete the anonymous row.
1286
- * `targetRefreshToken` defaults to the SDK's currently-stored refresh
1287
- * token — call this AFTER a successful `signIn()` so the storage
1288
- * already holds the target's refresh.
1289
- *
1290
- * Throws AuthError when:
1291
- * - `anonymousRefreshToken` is invalid / expired / revoked,
1292
- * - the bound source row is not anonymous (CONFLICT — use upgrade),
1293
- * - the target is anonymous (CONFLICT — sign in first),
1294
- * - both tokens resolve to the same user (CONFLICT — no-op).
1295
- *
1296
- * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
1297
- */
1298
- merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<{
1299
- mergedUserId: string;
1300
- deletedAnonymousId: string;
1301
- eventsReassigned: number;
1302
- profilesReassigned: number;
1303
- profilesDropped: number;
1304
- entitiesReassigned: number;
1305
- entitiesDropped: number;
1306
- }>;
1669
+ private revokeFireAndForget;
1670
+ /** B15: report a held guest, fire-and-forget; the pending flag is cleared only on a 2xx and retried on hydrate. */
1671
+ private reportHeldFireAndForget;
1672
+ private retryPendingHeldReports;
1673
+ /** `Sendora.reset({includeHeldGuests:true})`: release every slot locally (NO server delete), via commit. */
1674
+ releaseAllHeldGuests(): void;
1307
1675
  private callAuth;
1308
- private persist;
1309
- /**
1310
- * Drop the local session.
1311
- *
1312
- * `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
1313
- * emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
1314
- * on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
1315
- * not a spurious logout/login pair.
1316
- *
1317
- * ⚠ Never call this before a fallible network request. A failed sign-in must
1318
- * leave the caller exactly as it found them; for an anonymous session the
1319
- * refresh token dropped here is the ONLY durable handle on the account, so a
1320
- * pre-call wipe that is not followed by a successful `persist()` orphans the
1321
- * account permanently (offline: deterministically). Capture the anon refresh
1322
- * into a local, make the call, and wipe only after the response validates.
1323
- */
1676
+ private commit;
1677
+ /** `commit()` for the clear path, awaiting the parent's `onAnonymousWipe` hook before resolving (the emission has already fired). */
1678
+ private commitAsync;
1679
+ private pendingWipeHook;
1680
+ private commitInstall;
1681
+ private commitClear;
1324
1682
  /**
1325
- * True when `sent` is still the refresh token on disk — i.e. nothing
1326
- * replaced or cleared the session while a request carrying it was in
1327
- * flight. Guards both directions of the refresh race: installing a rotated
1328
- * token over a session that has since been signed out or replaced, and
1329
- * wiping a fresh session because an OLD token was rejected.
1683
+ * The parent-SDK half of a clear: rotate the analytics anon id at a person
1684
+ * boundary (`"user"` / `"account_deleted"`), drop the queue on deletion, null
1685
+ * the analytics user id. Kept as its own method so its reasons stay pinned.
1330
1686
  */
1331
- private tokenStillCurrent;
1332
1687
  private wipeLocalIdentity;
1688
+ private commitUser;
1689
+ private commitRelease;
1690
+ /** Row 21 — a refresh of the CURRENT token answered with a mismatching claim: persist the rotation (token CAS) and withhold. */
1691
+ private commitWithhold;
1692
+ /** Enter `withheld`: in-memory only (ZERO storage writes), no cron, analytics quarantined; emits `session_withheld` only for an in-process transition. */
1693
+ private enterWithheld;
1694
+ /** Row 14 — `discardWithheld()` / `signOut()` in `withheld`: LOCAL ONLY, keeps held slots + queue + `user_id`. */
1695
+ private commitDiscardWithheld;
1696
+ /** A rotated pair for the current session when no user is known to install (pre-B17 gap-fill). Token CAS. */
1697
+ private commitTokens;
1698
+ /** Row 22 — CAS PER SLOT: re-read the slot at write time; if it no longer holds `sent`, DROP. */
1699
+ private commitSlotTokens;
1700
+ /** RESTORE row: a `null` slot project judged on the refresh response records the claim it saw. */
1701
+ private commitSlotProject;
1702
+ private commitHeldReport;
1703
+ /** Dual-write: the four frozen keys (downgrade-readable) + the `auth_session` record. */
1704
+ private writeSessionRecord;
1705
+ /** Hydrate's write-forward: ONLY the `auth_session` record (the frozen keys already hold the session untouched). */
1706
+ private writeSessionRecordOnly;
1707
+ /** The rotated pair only (the user is unchanged) — frozen keys + the record's token fields. */
1708
+ private writeTokens;
1709
+ /** Clear EXACTLY the session keys (the four frozen + the record). Held slots, the queue and `user_id` are never touched here. */
1710
+ private clearSessionKeys;
1711
+ /** Keep the B15 pending-report flags on disk even with no session (a stub record); remove the record when nothing is pending. */
1712
+ private persistPendingReports;
1713
+ private writeSlot;
1714
+ }
1715
+ /** What `/merge` (and `claimHeldGuest()`) moved. */
1716
+ interface MergeStats {
1717
+ mergedUserId: string;
1718
+ deletedAnonymousId: string;
1719
+ eventsReassigned: number;
1720
+ profilesReassigned: number;
1721
+ profilesDropped: number;
1722
+ entitiesReassigned: number;
1723
+ entitiesDropped: number;
1333
1724
  }
1334
1725
 
1335
1726
  /**
@@ -1616,6 +2007,56 @@ declare class Links {
1616
2007
  attachLinkingApi(opts?: AttachLinkingApiOptions): Promise<() => void>;
1617
2008
  }
1618
2009
 
2010
+ /**
2011
+ * ADR-033 INV-15 — `projectVerdict()`, the ProjectGuard every InstallTx is
2012
+ * judged by BEFORE any assignment (§3.3 `commit()` header).
2013
+ *
2014
+ * ⚠ VERBATIM PORT of `packages/shared/src/auth-machine/project-verdict.ts`
2015
+ * (ADR-033 B8). The published SDK cannot import the internal `@sendora/shared`
2016
+ * package at runtime, so the function lives here as a copy; the conformance
2017
+ * suite (`test/auth-machine-conformance.test.ts`) drives THIS copy over every
2018
+ * `projectVerdictCases` entry of the shared golden fixture, which is what keeps
2019
+ * the two from drifting.
2020
+ *
2021
+ * WHY A PURE FUNCTION. `withheld` (§3.2) is "a pure function of (disk record,
2022
+ * config), recomputed every hydrate, never persisted" — so a re-init with the
2023
+ * right key or a rollback needs no migration. That property only holds if the
2024
+ * verdict reads nothing but its two arguments: no `Date` (expiry is
2025
+ * irrelevant — a stale token still names its project truthfully), no network,
2026
+ * no storage, no `atob` (Hermes/JSC/browsers/Node disagree on its presence and
2027
+ * its handling of non-ASCII, and a verdict that depends on the host's `atob`
2028
+ * is a verdict that differs per platform).
2029
+ *
2030
+ * The three answers, and why absent ≠ match (§3.3, §8-Q13, TODO.md:52 superseded):
2031
+ *
2032
+ * expected unset → "match" (guard off)
2033
+ * token missing / undecodable / claim KEY absent → "unknown" (NOT match: a
2034
+ * pre-s58.276 token says nothing about its project; the SDK stays
2035
+ * `unknown` and lets the refresh answer — the server emits the claim
2036
+ * ALWAYS since s58.276, so one refresh settles it)
2037
+ * claim === null → "mismatch" (the server
2038
+ * emits an EXPLICIT null for the org-wide pool; under a project-bound
2039
+ * expectedProjectId an org-wide session is the wrong tenant)
2040
+ * claim is a string → equal ? "match" : "mismatch"
2041
+ *
2042
+ * ⚠ The guard is ADVISORY (a client consistency check against a wrong-key
2043
+ * build), not an authorization boundary, and it never produces a dead-token
2044
+ * verdict (INV-6/INV-15).
2045
+ */
2046
+ type ProjectVerdict = "match" | "mismatch" | "unknown";
2047
+ declare function projectVerdict(expectedProjectId: string | undefined, accessJwt: string | null | undefined): ProjectVerdict;
2048
+ /**
2049
+ * Decode a compact JWS payload (`header.payload.signature`) WITHOUT verifying
2050
+ * the signature. The verdict only needs to READ a claim from a token the SDK
2051
+ * already holds (the server verifies on every request); verification here
2052
+ * would need the JWKS, i.e. the network, which the verdict must not touch.
2053
+ *
2054
+ * Returns `null` for anything that is not a three-segment token whose second
2055
+ * segment base64url-decodes to a UTF-8 JSON object. Arrays, scalars and
2056
+ * malformed input are all "undecodable".
2057
+ */
2058
+ declare function decodeJwtPayload(token: string | null | undefined): Record<string, unknown> | null;
2059
+
1619
2060
  /**
1620
2061
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
1621
2062
  *
@@ -1651,6 +2092,8 @@ declare class SendoraSDK {
1651
2092
  private sessionId;
1652
2093
  private autoTrackCleanup;
1653
2094
  private eventQueue;
2095
+ /** ADR-033 [2c]: subscribers to `onIdentityWithheld`. */
2096
+ private withheldListeners;
1654
2097
  private engEnabled;
1655
2098
  private engScreen;
1656
2099
  private engAccumMs;
@@ -2142,17 +2585,34 @@ declare class SendoraSDK {
2142
2585
  private _chatbotSessionId;
2143
2586
  private chatbotSessionId;
2144
2587
  /**
2145
- * Clear identity + rotate the anonymous id. Call on logout. Awaits
2146
- * the AsyncStorage writes so a caller who kills the app immediately
2147
- * after doesn't leave stale identity on disk.
2148
- *
2149
- * Signs out through `auth` rather than just clearing storage: `clearAll()`
2150
- * removes the auth keys too, so doing it directly left `Auth` holding an
2151
- * in-memory user and reporting itself signed in while its refresh token was
2152
- * already gone — with the server-side session never revoked and no
2153
- * `onAuthStateChanged` emission telling the app the session had ended.
2588
+ * ADR-033 [2c]: subscribe to the project guard refusing a stored session
2589
+ * (`auth.status() === "withheld"`). Fires once per withhold with
2590
+ * `getWithheld()`'s payload, after the SDK has nulled its in-memory
2591
+ * analytics user id. Record `info.userId` (oldSub) in your backend before
2592
+ * calling `auth.discardWithheld()`. Returns an unsubscribe function.
2154
2593
  */
2155
- reset(): Promise<void>;
2594
+ onIdentityWithheld(cb: (info: WithheldInfo) => void): () => void;
2595
+ /**
2596
+ * Clear identity + rotate the anonymous id. Call on logout. Awaits
2597
+ * the storage writes so a caller who kills the app immediately after
2598
+ * doesn't leave stale identity on disk.
2599
+ *
2600
+ * Signs out through `auth` (so the server session is revoked and
2601
+ * `onAuthStateChanged` fires `signed_out`), drops the queued events, then
2602
+ * clears EXACTLY this SDK's keys in the active namespace.
2603
+ *
2604
+ * ⚠ KEEPS the held guests by default (ADR-033). A guest displaced by a
2605
+ * sign-in is held on the device (`auth.getHeldGuests()`), and its refresh
2606
+ * token is its only credential — wiping the slots on logout would strand a
2607
+ * guest whose progress the player may still want. Pass
2608
+ * `{ includeHeldGuests: true }` to release every slot too (locally only —
2609
+ * `guest_released{reason:'reset'}` per guest, NO server delete; use
2610
+ * `auth.discardHeldGuest(id)` for that). In `withheld` this resolves the
2611
+ * withheld session like `auth.discardWithheld()` and keeps the slots too.
2612
+ */
2613
+ reset(opts?: {
2614
+ includeHeldGuests?: boolean;
2615
+ }): Promise<void>;
2156
2616
  /** Tear down auto-track listeners. Call in test harnesses. */
2157
2617
  destroy(): void;
2158
2618
  /**
@@ -2172,4 +2632,4 @@ declare class SendoraSDK {
2172
2632
  }
2173
2633
  declare const SendoraCloud: SendoraSDK;
2174
2634
 
2175
- 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 };
2635
+ 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 };