@sendoracloud/sdk-react-native 1.40.0 → 1.41.0

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