@sendoracloud/sdk-web 3.23.0 → 3.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -61,6 +61,30 @@ interface SendoraConfig {
61
61
  * silently no-op'd and tokens still hit localStorage. It is now real.
62
62
  */
63
63
  tokenStore?: "local" | "memory";
64
+ /**
65
+ * ADR-033 wave 1 (3.24.0) — OPT-IN project guard. A UUID; must equal
66
+ * `projectId` when both are set (validated at `init()`, throws otherwise).
67
+ * When set, a stored or refreshed session whose JWT `project` claim is a
68
+ * different string or an explicit `null` (the org-wide pool) is WITHHELD:
69
+ * `auth.status()` reports `"withheld"`, `getAccessToken()` is null, every
70
+ * sign-in / link / mint verb rejects `AuthError{kind:'config',
71
+ * code:'SESSION_WITHHELD'}`, and NOTHING is written to storage — a re-init
72
+ * with the matching id, or `auth.discardWithheld()`, are the two exits. A
73
+ * session minted by THIS process under the wrong id rejects
74
+ * `PROJECT_MISMATCH` and is revoked, so a wrong-key build fails loudly at
75
+ * first launch instead of looping. Absent = guard off (every token matches).
76
+ * `status()` is the only call that tells `withheld` from `signed_out`.
77
+ */
78
+ expectedProjectId?: string;
79
+ /**
80
+ * ADR-033 wave 1 (3.24.0) — default `true`: in `signed_out`, `ensureSession()`
81
+ * / `signInAnonymously()` RESTORE the newest eligible held guest (visibly:
82
+ * `signed_in{cause:'restore'}` + `guest_released{restored}`) instead of
83
+ * minting. `false` mints while fewer than 5 guests are held (at the cap a
84
+ * plain mint still restores). A guest stamped `autoRestore:false` by
85
+ * `deleteAccount()` is never auto-restored either way.
86
+ */
87
+ autoRestoreHeldGuest?: boolean;
64
88
  /** Idle threshold (ms) for session expiry; default 30 minutes. */
65
89
  sessionIdleMs?: number;
66
90
  /** Event queue flush interval in ms (default 5000) */
@@ -209,6 +233,14 @@ declare class Storage {
209
233
  get(key: string): string | null;
210
234
  set(key: string, value: string): void;
211
235
  remove(key: string): void;
236
+ /**
237
+ * True when values reach `localStorage` (and therefore other tabs). False
238
+ * under `tokenStore: "memory"` or when localStorage is unavailable — then
239
+ * there is no disk for a `storage` event to describe (ADR-033 wave 1 (e)).
240
+ */
241
+ isPersistent(): boolean;
242
+ /** Strip this instance's namespace off a raw localStorage key; `null` when the key is not ours. */
243
+ relativeKey(fullKey: string): string | null;
212
244
  /** Remove every key matching a prefix (relative, without the sendora_ namespace). */
213
245
  clearPrefix(prefix: string): void;
214
246
  private checkLocalStorage;
@@ -327,10 +359,18 @@ declare class Analytics {
327
359
  * unchanged.
328
360
  */
329
361
  reset(rotateAnonymousId?: boolean): void;
362
+ /**
363
+ * ADR-033 [2c] analytics quarantine: the stored session was WITHHELD (its
364
+ * JWT `project` claim disagrees with `expectedProjectId`), so stop
365
+ * attributing events to that user — IN MEMORY ONLY. The persisted `user_id`
366
+ * is deliberately untouched: a withheld hydrate performs zero storage writes
367
+ * so a re-init with the matching id (or a rollback) sees identical bytes.
368
+ */
369
+ withholdIdentity(): void;
330
370
  /**
331
371
  * Drop any pending events without flushing. Used by Auth on
332
- * cross-account signin to ensure the prior identity's queued
333
- * events don't surface under the next user.
372
+ * `deleteAccount()` (the s58.353 rule) so a deleted identity's queued
373
+ * events are not POSTed after the account is gone.
334
374
  */
335
375
  dropQueue(): void;
336
376
  flush(): Promise<void>;
@@ -439,40 +479,64 @@ declare class Chatbot {
439
479
  }
440
480
 
441
481
  /**
442
- * Auth Service surface for the web SDK.
482
+ * ADR-033 wave 1 (3.24.0) — the SDK's OWN copy of the auth-machine vocabulary.
443
483
  *
444
- * Three flows:
445
- * signInAnonymously() — mint a session with no email yet.
446
- * signUp(email, password) — if currently anonymous, upgrades
447
- * the same row (preserves user_id);
448
- * otherwise creates a fresh account.
449
- * signIn(email, password) — log in to an existing account.
484
+ * `packages/shared/src/auth-machine/` is the law (states, events, the transition
485
+ * table, the result unions); this file mirrors the shapes a running SDK needs,
486
+ * because the published package has zero runtime dependencies and cannot import
487
+ * `@sendora/shared`. `test/auth-machine-conformance.test.ts` loads the shared
488
+ * conformance JSON and asserts these constants EQUAL the fixture's, so a drift
489
+ * between the two is red, not a comment somebody has to notice.
450
490
  *
451
- * Identity ordering — a failed auth attempt leaves the caller exactly
452
- * as it found them. Every credentialed path captures the anonymous
453
- * refresh token into a local, makes the call, validates the response,
454
- * and only THEN clears the outgoing identity and persists the new one.
455
- * The stored refresh token is the only durable handle on an anonymous
456
- * account, so a clear that isn't followed by a successful persist
457
- * strands it permanently. (Cost of the ordering: a track() fired during
458
- * the round-trip attaches to the prior identity — correct, the user
459
- * isn't identified until the response says so, and the backend
460
- * reattributes those events via the device takeover.) All public auth
461
- * operations are serialized through a single-flight chain so a
462
- * double-submit can't mint two anonymous users or interleave a
463
- * signIn + signOut.
464
- *
465
- * Tokens are validated non-empty before persisting (rejects malformed
466
- * responses that would otherwise install an `id = ""` user). Access
467
- * token expiry is tracked locally; getAccessToken() returns null past
468
- * expiry and triggers a single-flight refresh on the next call.
491
+ * ⚠ ADDITIVE ONLY (ADR-023 §1.1). Never remove a member, never make an optional
492
+ * field required, never narrow a type — an installed app runs a frozen SDK
493
+ * forever. Consumers MUST default-branch on every closed union here (§3.5
494
+ * NORMATIVE): a later additive member must not crash a `switch`.
495
+ */
496
+ /**
497
+ * The five client states. `unknown` ≠ `signed_out` and `withheld` ≠ `signed_out`
498
+ * — `status()` is the only call that tells them apart, because `getUserSync()`
499
+ * is `null` and `isReady()` is `true` in all three.
469
500
  *
470
- * Storage is localStorage by default — XSS-readable. Hosts that need
471
- * stricter isolation can pass an in-memory storage adapter via the
472
- * `tokenStore: "memory"` config flag, which keeps tokens off disk
473
- * (session lost on page reload).
501
+ * - `unknown` hydrated with a refresh token but no readable user (or an
502
+ * unreadable `project` claim under `expectedProjectId`). MINT is
503
+ * QUEUED here, never run; the single gap-fill refresh settles it.
504
+ * - `signed_out` no refresh token. The ONLY state in which MINT executes.
505
+ * - `anonymous` refresh token + `user.isAnonymous === true`.
506
+ * - `identified` refresh token + `user.isAnonymous === false`.
507
+ * - `withheld` a stored session refused because its JWT `project` claim
508
+ * disagrees with `init({expectedProjectId})`. A pure function of
509
+ * (disk record, config); never persisted.
474
510
  */
475
-
511
+ declare const AUTH_STATES: readonly ["unknown", "signed_out", "anonymous", "identified", "withheld"];
512
+ type AuthStatus = (typeof AUTH_STATES)[number];
513
+ /** K — the held-guest capacity (§3.2, §8-Q19). FIXED slots, bound at MINT: T = |held| + (current anonymous ? 1 : 0) ≤ K. */
514
+ declare const HELD_GUEST_SLOTS = 5;
515
+ /** The ONLY server code that means "this refresh token is dead" (INV-6). A project mismatch is NEVER mapped onto it (INV-15). */
516
+ declare const DEAD_CODES: readonly ["INVALID_REFRESH_TOKEN"];
517
+ /** `error.details.endReason` on an `INVALID_REFRESH_TOKEN` rejection (B11). */
518
+ declare const REFRESH_END_REASONS: readonly ["expired", "revoked", "evicted", "deleted", "reuse", "unknown"];
519
+ type RefreshEndReason = (typeof REFRESH_END_REASONS)[number];
520
+ /**
521
+ * A HELD slot is released on a dead verdict ONLY for these end reasons (§3.3
522
+ * REFRESH_DEAD-held row). On `reuse` or an ABSENT `endReason` the slot is KEPT
523
+ * and a retryable `RESTORE_FAILED` surfaces instead — a reuse verdict on a
524
+ * preserved-family guest does not prove the guest is gone (s58.316).
525
+ */
526
+ declare const RELEASE_HELD_ON_END_REASONS: readonly ["deleted", "revoked", "expired"];
527
+ /** The refusal code when a destructive/identity held-guest verb is called with no `id` while 2+ guests are held (§3.5). */
528
+ declare const AMBIGUOUS_HELD_GUEST: "AMBIGUOUS_HELD_GUEST";
529
+ /**
530
+ * Every verb family that `withheld` refuses with
531
+ * `AuthError{kind:'config', code:'SESSION_WITHHELD', retryable:false}` — no
532
+ * network, no storage write (§3.2). Mirrors the shared `REJECTED_IN_WITHHELD`.
533
+ */
534
+ declare const REJECTED_IN_WITHHELD: readonly ["MINT", "SIGN_IN", "LINK", "CLAIM", "RESTORE", "DISCARD", "DELETE", "SESSION_VERB"];
535
+ /** The two project-guard refusals REUSE the closed `config` kind (§3.5 [2c]); `AuthErrorKind` is NOT widened. */
536
+ declare const AUTH_WITHHELD_CODES: {
537
+ readonly SESSION_WITHHELD: "SESSION_WITHHELD";
538
+ readonly PROJECT_MISMATCH: "PROJECT_MISMATCH";
539
+ };
476
540
  interface AuthUser {
477
541
  id: string;
478
542
  email: string | null;
@@ -491,7 +555,210 @@ interface AuthUser {
491
555
  */
492
556
  signupMethod?: string | null;
493
557
  lastLoginMethod?: string | null;
558
+ /**
559
+ * The account's project pool (ADR-033 B17) — the same value as the JWT
560
+ * `project` claim. A string, an EXPLICIT `null` for the org-wide pool, or
561
+ * absent against a pre-B17 backend. Never read absent as null. sdk-web 3.24.0+.
562
+ */
563
+ project?: string | null;
564
+ }
565
+ type SocialCredentialProvider = "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
566
+ type Credential = {
567
+ kind: "password";
568
+ email: string;
569
+ password: string;
570
+ } | {
571
+ kind: SocialCredentialProvider;
572
+ code?: string;
573
+ idToken?: string;
574
+ redirectUri?: string;
575
+ codeVerifier?: string;
576
+ appleName?: {
577
+ firstName?: string;
578
+ lastName?: string;
579
+ };
580
+ } | {
581
+ kind: "gameCenter";
582
+ publicKeyUrl: string;
583
+ signature: string;
584
+ salt: string;
585
+ timestamp: number;
586
+ teamPlayerId: string;
587
+ bundleId: string;
588
+ } | {
589
+ kind: "playGames";
590
+ serverAuthCode: string;
591
+ } | {
592
+ kind: "magicLink";
593
+ token: string;
594
+ } | {
595
+ kind: "emailOtp";
596
+ email: string;
597
+ code: string;
598
+ }
599
+ /** The WebAuthn assertion as the SDK's own encoder produces it; forwarded to `passkeys/authenticate/finish`. */
600
+ | {
601
+ kind: "passkey";
602
+ assertion: Record<string, unknown>;
603
+ userId?: string | null;
604
+ } | {
605
+ kind: "mfa";
606
+ challengeToken: string;
607
+ code: string;
608
+ };
609
+ /** Alias — the DOM lib also declares a global `Credential`; import this name when the collision bites. */
610
+ type AuthCredential = Credential;
611
+ type CredentialKind = Credential["kind"];
612
+ /**
613
+ * `previous` is what the SWITCH replaced. `held: true` ⇔ the previous slot was
614
+ * anonymous and is now in a held slot (§3.1.1). `previous.isAnonymous === false`
615
+ * ⇒ an identified → identified switch: the app must skip any merge and reset
616
+ * local save state. `reactivated` surfaces a cancelled pending self-service
617
+ * deletion (s58.269).
618
+ */
619
+ type SignInResult = {
620
+ status: "done";
621
+ user: AuthUser;
622
+ previous: {
623
+ userId: string;
624
+ isAnonymous: boolean;
625
+ held: boolean;
626
+ } | null;
627
+ reactivated: boolean;
628
+ } | {
629
+ status: "mfa_required";
630
+ challengeToken: string;
631
+ /** Completes the challenge. The MFA context lives on the attempt, never in a nullable field cleared by hand (§3.2). */
632
+ verify(code: string): Promise<SignInResult>;
633
+ };
634
+ type LinkCollision = "identity" | "email";
635
+ /**
636
+ * Three outcomes, nothing mutated on the two refusals (INV-12, B2).
637
+ *
638
+ * - `done{alreadyLinked:true}` — the same identity is already on this account:
639
+ * a quiet 200, NO event, NO revoke of other sessions, NO step-up (B13).
640
+ * - `done{attached}` — `upgraded` is true when the caller was anonymous (same
641
+ * `sub`, rotated pair); false on an identified attach (no rotation).
642
+ * - `credential_in_use` — owned by another account (409, B14 payload).
643
+ * `switchAccount()` is the ONLY path from a refused link to a sign-in and is
644
+ * BOUND to the refused credential (INV-14): the same payload while it is
645
+ * under 5 min old; after that web has no native re-fetch, so it rejects
646
+ * `CREDENTIAL_STALE` and the app re-collects the credential.
647
+ * - `recent_auth_required` — the credential is fine, the session is stale
648
+ * (403). State unchanged. There is deliberately NO `reauthenticate()`
649
+ * closure that accepts the credential being linked (Q8).
650
+ */
651
+ type LinkResult = {
652
+ status: "done";
653
+ user: AuthUser;
654
+ upgraded: boolean;
655
+ alreadyLinked: boolean;
656
+ } | {
657
+ status: "credential_in_use";
658
+ provider: string;
659
+ collision: LinkCollision;
660
+ switchAccount(): Promise<SignInResult>;
661
+ keepCurrent(): void;
662
+ } | {
663
+ status: "recent_auth_required";
664
+ };
665
+ /**
666
+ * ONE held slot as persisted at `auth_held_<i>`. Written ONLY by a HOLD, by a
667
+ * per-slot REFRESH_OK routed by token, or by DELETE stamping `autoRestore:false`.
668
+ * ⚠ NO access token and NO user blob: restore/claim always refresh first.
669
+ */
670
+ interface HeldGuest {
671
+ v: 1;
672
+ userId: string;
673
+ sid: string;
674
+ refreshToken: string;
675
+ tokenAtHold: string;
676
+ heldAt: number;
677
+ project: string | null;
678
+ autoRestore: boolean;
679
+ }
680
+ /** What `getHeldGuests()` returns — the slot MINUS its tokens (§3.5: "never exposes tokens"). */
681
+ type HeldGuestSummary = Pick<HeldGuest, "userId" | "heldAt" | "autoRestore" | "project">;
682
+ /** `guest_released.reason` — every exit from a slot names one. `dead` is NOT an erasure signal. */
683
+ type GuestReleasedReason = "claimed" | "restored" | "discarded" | "dead" | "reset";
684
+ /** What `getWithheld()` returns while `status() === "withheld"`. Never exposes tokens. */
685
+ interface WithheldInfo {
686
+ reason: "project_mismatch";
687
+ expectedProjectId: string;
688
+ actualProjectId: string | null;
689
+ userId: string;
690
+ isAnonymous: boolean;
494
691
  }
692
+ /**
693
+ * Why a `signed_in` fired. `restore` ⇒ the app must re-register push (the held
694
+ * guest's push tokens were erased if it had been deleted elsewhere); `link` =
695
+ * the legacy same-`sub` promotion the legacy-signature methods
696
+ * (`loginSocial({link:true})`, `signInWith*({link:true})`, `signUp`) keep
697
+ * emitting (§8-Q18, ADR-023). Optional on the wire: a replayed `signed_in` and a
698
+ * cross-tab re-hydrate carry none.
699
+ */
700
+ type AuthSignedInCause = "mint" | "sign_in" | "restore" | "link";
701
+ /** What changed the user object without changing the `sub`. */
702
+ type AuthUserUpdatedCause = "link" | "unlink" | "email_verified" | "refresh" | "merge";
703
+ /** The server's own counters from `POST /merge` (s58.317: real numbers, not placeholders). */
704
+ interface MergeStats {
705
+ mergedUserId: string;
706
+ deletedAnonymousId: string;
707
+ eventsReassigned: number;
708
+ profilesReassigned: number;
709
+ entitiesReassigned: number;
710
+ pushTokensReassigned: number;
711
+ }
712
+ /** `deleteAccount()` result — the pre-wave fields plus the additive `heldGuests` count (§3.5). */
713
+ interface DeleteAccountResult {
714
+ status: "purged" | "pending";
715
+ scheduledPurgeAt: string | null;
716
+ graceDays: number;
717
+ /** Held guests still on this device after the delete (each stamped `autoRestore:false`). */
718
+ heldGuests: number;
719
+ /** Under `alsoDiscardHeldGuests:true`: the guest ids whose `/guest/discard` FAILED (still held). Absent otherwise. */
720
+ discardFailed?: string[];
721
+ }
722
+
723
+ /**
724
+ * Auth Service surface for the web SDK — ADR-033 wave 1 (3.24.0): "One Slot,
725
+ * Two Verbs". Full design: `docs/decisions/033-sdk-auth-mechanism.md`.
726
+ *
727
+ * THE MECHANISM IN FOUR SENTENCES.
728
+ * 1. Sign-in never carries the guest. A sign-in from a guest device HOLDS the
729
+ * guest in one of K = 5 fixed on-device slots (`auth_held_0..4`) and
730
+ * switches; the server row survives. No request body of this SDK ever
731
+ * contains `prevAnonRefreshToken`, `linkAnonymous` or `onCredentialInUse`
732
+ * (INV-3) — the legacy device-takeover path is unreachable from here.
733
+ * 2. Link is the only upgrade. `signUp()`, `loginSocial({link:true})`,
734
+ * `link*()` and the typed `link()` go to `/me/link/*` on the current
735
+ * Bearer session (same `sub`); a collision is a typed value, never a merge.
736
+ * 3. One slot, one writer. `commit()` below is the ONLY code that assigns
737
+ * `user`/tokens, writes the frozen keys + `auth_session`, arms/stops the
738
+ * cron, decides the emitted event and tells analytics the wipe reason.
739
+ * Every public method computes a PROPOSAL (a network call that changes
740
+ * nothing) and hands it to `commit()`, which applies it only if the
741
+ * session it was derived from is still current — `sid` for identity
742
+ * commits, the sent refresh token for token commits (INV-2).
743
+ * 4. Boot is observable. `status()` is `unknown | signed_out | anonymous |
744
+ * identified | withheld`; `authStateReady()` resolves after hydrate and
745
+ * never waits on the network.
746
+ *
747
+ * The transition table lives as DATA in `packages/shared/src/auth-machine/`
748
+ * and `test/auth-machine-conformance.test.ts` replays every row against this
749
+ * file (INV-11). Storage key names live in `auth-storage.ts` and nowhere else
750
+ * (INV-1); the ProjectGuard is the verbatim `project-verdict.ts` (INV-15).
751
+ *
752
+ * Token liveness machinery (two-deadline guard, `clockSkewConfirmed`, single-
753
+ * flight, capped backoff, `visibilitychange` reset) is kept verbatim from
754
+ * 3.18.0 — it never decides identity except through the single dead verdict
755
+ * `INVALID_REFRESH_TOKEN` (INV-6).
756
+ *
757
+ * Storage is localStorage by default — XSS-readable. Hosts that need stricter
758
+ * isolation pass `tokenStore: "memory"`, which keeps every auth key (incl. the
759
+ * held slots) in a JS `Map` for the life of the tab.
760
+ */
761
+
495
762
  interface AuthTokens {
496
763
  accessToken: string;
497
764
  refreshToken: string;
@@ -509,44 +776,80 @@ interface DeviceTakeoverEvent {
509
776
  at: Date;
510
777
  }
511
778
  type DeviceTakeoverListener = (evt: DeviceTakeoverEvent) => void;
779
+ /** @see AuthApiResponse.anonRetirement */
780
+ type AnonRetirementOutcome = "retired" | "preserved" | "none";
512
781
  /**
513
782
  * Detail handed to `onDeletionCancelled` subscribers (s58.269). Fires once
514
783
  * per sign-in that cancelled a pending self-service deletion within grace —
515
784
  * the account is restored with the SAME user_id.
516
785
  */
517
- /** @see AuthApiResponse.anonRetirement */
518
- type AnonRetirementOutcome = "retired" | "preserved" | "none";
519
786
  interface DeletionCancelledEvent {
520
787
  userId: string;
521
788
  at: Date;
522
789
  }
523
790
  type DeletionCancelledListener = (evt: DeletionCancelledEvent) => void;
524
791
  /**
525
- * Why the local session ended (3.12.0). Before this, a session that died in
526
- * the background emitted NOTHING — an app could not tell a deliberate sign-out
527
- * apart from an expired refresh token, and only discovered the difference when
528
- * `getAccessToken()` started returning null.
792
+ * Why the local session ended (3.12.0; widened additively in 3.24.0 — ADR-033 B11).
529
793
  *
530
- * - `user` — the app called `signOut()`.
531
- * - `session_expired` — the server rejected the stored refresh token (revoked,
532
- * expired, rotated away). Transient network failures and
533
- * rate limits do NOT produce this.
534
- * - `account_deleted` — a self-service account deletion succeeded (the native
535
- * SDKs' `deleteAccount()`; reserved here for parity).
794
+ * - `user` — the app called `signOut()` / `discardWithheld()`.
795
+ * - `session_expired` — the server rejected the stored refresh token. EVERY
796
+ * dead verdict, whatever the server's detail: read the
797
+ * event's `endReason` (`revoked` / `deleted` / `expired` /
798
+ * `reuse` / `unknown`) for that. Transient network
799
+ * failures and rate limits do NOT produce this.
800
+ * - `account_deleted` — `deleteAccount()` succeeded.
801
+ * - `revoked` / `evicted` / `deleted` — ⚠ emitted by 3.24.0 ONLY, never since
802
+ * 3.24.1. 3.24.0 put the server's `endReason` here, which
803
+ * renamed "account deleted elsewhere" / "sessions revoked"
804
+ * (both `session_expired` through 3.23.0) and silently
805
+ * broke listeners matching `session_expired`. Kept in the
806
+ * type so code written against 3.24.0 still compiles.
807
+ * ⚠ Consumers MUST default-branch — later values are additive.
536
808
  */
537
- type AuthSignedOutReason = "user" | "session_expired" | "account_deleted";
809
+ type AuthSignedOutReason = "user" | "session_expired" | "account_deleted" | "revoked" | "evicted" | "deleted";
538
810
  /**
539
- * A single auth-state transition (3.12.0). Subscribe with
540
- * `onAuthStateChanged` — one stream covering everything that used to need
541
- * separate listeners (or had none at all).
811
+ * A single auth-state transition (3.12.0; ADR-033 members added in 3.24.0).
812
+ * Subscribe with `onAuthStateChanged` — one stream covering everything.
813
+ *
814
+ * - `signed_in` — a session was established or replaced. `cause`
815
+ * (3.24.0): `mint` | `sign_in` | `restore` (re-register
816
+ * push) | `link` (the legacy same-`sub` promotion
817
+ * `signUp()` / `loginSocial({link:true})` keep emitting).
818
+ * Absent on the subscribe-time replay and on a cross-tab
819
+ * re-hydrate.
820
+ * - `signed_out` — the local session is gone; `reason` says why.
821
+ * `endReason` (3.24.1) = the server's own verdict on a
822
+ * dead refresh token; absent otherwise.
823
+ * - `device_takeover` — LEGACY: an anonymous row was retired server-side. A
824
+ * 3.24.0 sign-in never requests that, so it fires only
825
+ * when the SERVER reports one (it will not for this SDK).
826
+ * - `deletion_cancelled`— a sign-in cancelled a pending self-service deletion.
827
+ * - `initial` — the settled boot state, exactly once per subscriber,
828
+ * INCLUDING `signed_out`, `unknown` and `withheld`.
829
+ * - `user_updated` — same `sub`, different user object (`link` / `unlink` /
830
+ * `email_verified` / `refresh` / `merge`). Never a
831
+ * `signed_in` — the identity did not change.
832
+ * - `guest_held` — a guest was moved into a held slot (`count` = slots
833
+ * now held).
834
+ * - `guest_released` — a held slot was emptied; `reason` names the exit.
835
+ * ⚠ `dead` = "the server said unavailable when the
836
+ * token was presented", NOT an erasure signal — say
837
+ * "unavailable", never "lost".
838
+ * - `session_withheld` — an in-process transition INTO `withheld` (a refresh
839
+ * answered with a mismatching `project` claim). Never
840
+ * replayed: a hydrate that lands in `withheld` emits
841
+ * `initial{status:'withheld'}` instead.
842
+ * ⚠ Consumers MUST default-branch on `event`.
542
843
  */
543
844
  type AuthStateChange = {
544
845
  event: "signed_in";
545
846
  user: AuthUser;
847
+ cause?: AuthSignedInCause;
546
848
  } | {
547
849
  event: "signed_out";
548
850
  user: null;
549
851
  reason: AuthSignedOutReason;
852
+ endReason?: RefreshEndReason;
550
853
  } | {
551
854
  event: "device_takeover";
552
855
  user: AuthUser | null;
@@ -554,6 +857,30 @@ type AuthStateChange = {
554
857
  } | {
555
858
  event: "deletion_cancelled";
556
859
  user: AuthUser | null;
860
+ } | {
861
+ event: "initial";
862
+ status: AuthStatus;
863
+ user: AuthUser | null;
864
+ withheld?: WithheldInfo;
865
+ } | {
866
+ event: "user_updated";
867
+ user: AuthUser;
868
+ cause: AuthUserUpdatedCause;
869
+ } | {
870
+ event: "guest_held";
871
+ userId: string;
872
+ count: number;
873
+ } | {
874
+ event: "guest_released";
875
+ userId: string;
876
+ reason: GuestReleasedReason;
877
+ endReason?: RefreshEndReason;
878
+ } | {
879
+ event: "session_withheld";
880
+ reason: "project_mismatch";
881
+ expectedProjectId: string;
882
+ actualProjectId: string | null;
883
+ userId: string;
557
884
  };
558
885
  type AuthStateListener = (change: AuthStateChange) => void;
559
886
  declare class EmailAlreadyTakenError extends Error {
@@ -563,7 +890,8 @@ declare class EmailAlreadyTakenError extends Error {
563
890
  * `signUp()` was called on a session already signed in with an identity (not
564
891
  * anonymous). Creating a second account would orphan the current one — use
565
892
  * `linkEmailPassword()` / `linkGoogle()` / … (ADR-030) to add the credential
566
- * to THIS account, or sign out first.
893
+ * to THIS account, or sign out first. Since 3.24.0 this is decided LOCALLY from
894
+ * `status()`, with no round-trip.
567
895
  */
568
896
  declare class AlreadyIdentifiedError extends Error {
569
897
  constructor(message?: string);
@@ -599,21 +927,18 @@ declare class CredentialInUseError extends Error {
599
927
  });
600
928
  }
601
929
  /**
602
- * What a sign-in should do when the credential you present already belongs to
603
- * an account (3.14.0).
930
+ * What `loginSocial({ link: true, onCredentialInUse })` does when the credential
931
+ * you present already belongs to another account (3.14.0; re-based on ADR-033
932
+ * in 3.24.0).
604
933
  *
605
- * **You usually want the default — omit this.** It adopts the other account
606
- * silently when nothing would be destroyed, and refuses when a live anonymous
607
- * session would be deleted.
934
+ * - `"adopt"` — fall through to a plain sign-in with the SAME credential: an
935
+ * explicit, consented SWITCH. From a guest the guest is HELD on this device
936
+ * (never deleted); from an identified session nothing is held.
937
+ * - `"reject"` / omitted — throw `CredentialInUseError`; nothing changes.
608
938
  *
609
- * - `"adopt"` — always adopt, even when it retires this browser's anonymous
610
- * account (its row is deleted server-side). Pre-3.14.0 behaviour.
611
- * - `"reject"` — always throw `CredentialInUseError`, even on a harmless
612
- * collision.
613
- *
614
- * Mirrors Firebase `linkWithCredential` (`auth/credential-already-in-use`) and
615
- * Supabase `linkIdentity` (`identity_already_exists`), both of which fail
616
- * rather than adopt. `"reject"` blocks the switch; it does not merge.
939
+ * ⚠ Since 3.24.0 NOTHING here reaches the server as a field: the collision is
940
+ * decided on the link route (409) and the policy is applied client-side.
941
+ * Nothing on the server can delete a guest as a side effect of a sign-in.
617
942
  */
618
943
  type CredentialCollisionPolicy = "adopt" | "reject";
619
944
  /**
@@ -624,6 +949,10 @@ type CredentialCollisionPolicy = "adopt" | "reject";
624
949
  * attempt never changes the local session.** An offline sign-in, a mistyped
625
950
  * OTP, a dismissed passkey sheet and a 429 all leave `getCurrentUser()` and the
626
951
  * stored refresh token exactly as they were.
952
+ *
953
+ * ⚠ ADR-033 deliberately does NOT widen this set: `SESSION_WITHHELD` and
954
+ * `PROJECT_MISMATCH` are `kind: "config"`; the held-guest refusals reuse the
955
+ * existing kinds (see `classifyAuthErrorKind`).
627
956
  */
628
957
  type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "recent_auth_required" | "already_identified" | "cancelled" | "config" | "unknown";
629
958
  declare class AuthError extends Error {
@@ -651,6 +980,31 @@ declare class AuthError extends Error {
651
980
  kind?: AuthErrorKind;
652
981
  });
653
982
  }
983
+ /**
984
+ * T == K (ADR-033 §3.3): every held slot is occupied and the verb would need
985
+ * one more. Thrown by `signInAnonymously({forceNew:true})` at the cap, and by
986
+ * a plain mint in `signed_out` when every slot is ineligible for auto-restore
987
+ * (each stamped `autoRestore:false` by `deleteAccount()`, or project-withheld)
988
+ * — the one boot corner that does not self-heal (§8-Q22). Nothing is mutated;
989
+ * the app must `restoreHeldGuest(id)` or `discardHeldGuest(id)`.
990
+ */
991
+ declare class HeldGuestLimitError extends AuthError {
992
+ readonly heldGuests: number;
993
+ constructor(heldGuests: number, message?: string);
994
+ }
995
+ /**
996
+ * The held slot's `project` claim (recorded at HOLD, or judged on the refresh
997
+ * response when it was unreadable at HOLD) disagrees with
998
+ * `expectedProjectId ?? config.projectId` (ADR-033 §3.5). A CLIENT-SIDE check:
999
+ * the server does not enforce it on refresh. The slot is KEPT, excluded from
1000
+ * auto-restore, and still counts toward T.
1001
+ */
1002
+ declare class HeldGuestProjectMismatch extends AuthError {
1003
+ readonly userId: string;
1004
+ readonly expectedProjectId: string;
1005
+ readonly actualProjectId: string | null;
1006
+ constructor(userId: string, expectedProjectId: string, actualProjectId: string | null);
1007
+ }
654
1008
  /** One credential linked to an account (ADR-030 read side, s58.270). */
655
1009
  type LinkedIdentity = {
656
1010
  provider: string;
@@ -673,15 +1027,33 @@ type LinkedIdentitiesResponse = {
673
1027
  identities: LinkedIdentity[];
674
1028
  hasPassword: boolean;
675
1029
  };
1030
+ /** Hooks the parent SDK wires in (`index.ts`). */
1031
+ interface AuthHooks {
1032
+ /** ADR-033 [2c]: null the analytics `userId` IN MEMORY only — the persisted `user_id` is untouched. */
1033
+ onIdentityWithheld?: () => void;
1034
+ }
676
1035
  declare class Auth {
677
1036
  private http;
678
1037
  private storage;
1038
+ private store;
679
1039
  private analytics;
680
1040
  private flags;
681
1041
  private chatbot;
1042
+ private hooks;
1043
+ /** ADR-033 [2c]: the opt-in project guard. Validated by `index.ts` (UUID, equal to `projectId` when both set). */
1044
+ private readonly expectedProjectId;
1045
+ /** The project a held slot is judged against on RESTORE: `expectedProjectId ?? config.projectId`. */
1046
+ private readonly slotProjectAnchor;
1047
+ /** §8-Q1: restore the newest eligible held guest before minting (default ON). */
1048
+ private readonly autoRestoreHeldGuest;
1049
+ private state;
1050
+ /** The installed session, or null in `unknown` / `signed_out` / `withheld`. */
1051
+ private record;
682
1052
  private user;
683
1053
  private accessToken;
684
1054
  private accessExpiresAt;
1055
+ /** `withheld` only: what was refused, and the refresh token the discard CAS keys on. Never persisted. */
1056
+ private withheld;
685
1057
  /**
686
1058
  * Set once we have PROOF the clock runs fast: a token the server minted
687
1059
  * SECONDS AGO already reads past its own `exp`. A fresh token is never
@@ -699,6 +1071,14 @@ declare class Auth {
699
1071
  private clockSkewConfirmed;
700
1072
  private inflight;
701
1073
  private refreshInflight;
1074
+ /**
1075
+ * True while an identity-changing attempt (mint / sign-in / link / claim /
1076
+ * restore) is on the wire. The proactive cron does not START a refresh while
1077
+ * it is set (§3.3 scheduling rule 2); the attempt itself awaits any in-flight
1078
+ * refresh at start AND at commit (rule 1), which is what makes the sid-CAS a
1079
+ * backstop rather than the first line.
1080
+ */
1081
+ private attemptInFlight;
702
1082
  /**
703
1083
  * Wave 63 — refresh failure back-pressure (parity with sdk-react-
704
1084
  * native 1.1.0 Wave 62 fix). http.ts already times out at 10s; this
@@ -713,31 +1093,38 @@ declare class Auth {
713
1093
  private visibilityResetUnsub;
714
1094
  private proactiveRefreshTimer;
715
1095
  private proactiveRefreshCleanup;
1096
+ /** 3.24.0 (e): another tab wrote one of our auth keys → re-run HYDRATE through commit. */
1097
+ private storageEventUnsub;
1098
+ private rehydrateScheduled;
716
1099
  private takeoverListeners;
717
1100
  private lastTakeover;
718
1101
  private deletionCancelledListeners;
719
1102
  private lastDeletionCancelled;
720
1103
  private lastAnonRetirement;
721
1104
  private authStateListeners;
722
- /**
723
- * Anon refresh token captured at `signIn()` time, stashed keyed to the
724
- * issued `mfaChallengeToken` so the later `challengeMfa()` can forward it
725
- * for device-takeover (s58.111). The MFA branch returns without persisting,
726
- * so the anon identity has to survive the whole challenge round-trip —
727
- * clearing it at signIn() time both orphaned the anon row (two user_ids on
728
- * one device, duplicate pushes) and, for an abandoned challenge, left the
729
- * account with no local handle at all.
730
- */
731
- private pendingAnonTakeover;
1105
+ private identityWithheldListeners;
732
1106
  /**
733
1107
  * True once the constructor has finished restoring from storage.
734
1108
  * Distinguishes "no session restored YET" from "genuinely signed out" for
735
1109
  * `onAuthStateChanged`'s replay-on-subscribe.
736
1110
  */
737
1111
  private hydrated;
738
- constructor(_config: SendoraConfig, http: HttpClient, storage: Storage, analytics: Analytics, flags: FeatureFlags, chatbot: Chatbot);
739
- /** Returns the cached current user or null if signed out. */
1112
+ constructor(config: SendoraConfig, http: HttpClient, storage: Storage, analytics: Analytics, flags: FeatureFlags, chatbot: Chatbot, hooks?: AuthHooks);
1113
+ /**
1114
+ * The settled client state — a closed enum. `unknown` ≠ `signed_out` and
1115
+ * `withheld` ≠ `signed_out`; this is the ONLY call that tells them apart
1116
+ * (`getCurrentUser()` is null and `isReady()` is true in all three).
1117
+ * ⚠ Default-branch: later values are additive.
1118
+ */
1119
+ status(): AuthStatus;
1120
+ /** Resolves after hydrate with the settled status — never waits on the network. May be `unknown` or `withheld`. */
1121
+ authStateReady(): Promise<AuthStatus>;
1122
+ /** Returns the cached current user or null if signed out (also null in `unknown` and `withheld`). */
740
1123
  getCurrentUser(): AuthUser | null;
1124
+ /** Alias of `getCurrentUser()` (parity with the native SDKs' synchronous verified-principal accessor). */
1125
+ getUserSync(): AuthUser | null;
1126
+ /** True once storage has been read. Always true after `init()` on web (hydrate is synchronous). */
1127
+ isReady(): boolean;
741
1128
  /**
742
1129
  * Register a callback fired when the backend retires an anon
743
1130
  * `user_id` during a signin on this tab. Use it to delete the
@@ -745,20 +1132,22 @@ declare class Auth {
745
1132
  * records are already cleaned up server-side. Returns an
746
1133
  * unsubscribe function.
747
1134
  *
748
- * Fires on every identified-signin path: signIn, loginSocial,
749
- * verifyMagicLink, verifyEmailOtp, challengeMfa, signInWithPasskey,
750
- * consumeSsoTokenFromUrl — fired centrally from `persist()` whenever the
751
- * response carries `retiredAnonUserId`.
1135
+ * ⚠ LEGACY after ADR-033 (3.24.0). This SDK never sends the guest hint
1136
+ * that triggers a server-side retirement, so for a 3.24.0 sign-in this
1137
+ * listener NEVER fires: the device's guest is HELD on this device instead
1138
+ * (`onAuthStateChanged` → `guest_held`, `getHeldGuests()`). It stays
1139
+ * registered and fires only when the SERVER reports a retirement, which
1140
+ * only a pre-3.24.0 sign-in can request.
752
1141
  *
753
1142
  * ⚠ A takeover means the anonymous account on this device was retired —
754
1143
  * its row is DELETED server-side — and the call still resolved
755
- * SUCCESSFULLY. This listener is the only client-side signal. On the
756
- * native SDKs the same applies to the Game Center / Play Games sign-ins,
757
- * where an already-owned player identity is adopted; pass
758
- * `onCredentialInUse: "reject"` if you would rather it not happen.
1144
+ * SUCCESSFULLY. This listener is the only client-side signal for THAT
1145
+ * (legacy) path. On the native SDKs the same applied to the Game Center /
1146
+ * Play Games sign-ins, where an already-owned player identity was adopted.
759
1147
  *
760
1148
  * Local-only — survives webhook receiver downtime. For server-pipeline
761
- * cleanup also subscribe to the `auth.device_takeover` webhook.
1149
+ * cleanup also subscribe to the `auth.device_takeover` webhook (legacy
1150
+ * SDK sign-ins only) and `auth.user_merged` (the explicit `claimHeldGuest()`).
762
1151
  */
763
1152
  onDeviceTakeover(listener: DeviceTakeoverListener): () => void;
764
1153
  /**
@@ -776,11 +1165,14 @@ declare class Auth {
776
1165
  * rejected the stored refresh token) used to emit no signal whatsoever, so
777
1166
  * an app could not distinguish it from a deliberate sign-out and only
778
1167
  * noticed when `getAccessToken()` began returning null. That transition is
779
- * now `{ event: "signed_out", reason: "session_expired" }`.
1168
+ * now `{ event: "signed_out", reason: "session_expired" }`, with the server's
1169
+ * own verdict in `endReason` (3.24.1; 3.24.0 alone put it in `reason`).
780
1170
  *
781
- * Fires the CURRENT state immediately on subscribe when a session was
782
- * restored from storage (`signed_in`), so a subscriber never misses the
783
- * state it started in. Nothing is emitted for a signed-out cold start.
1171
+ * Replay on subscribe (3.24.0): `initial{status, user, withheld?}` is emitted
1172
+ * exactly once per subscriber, INCLUDING `signed_out`, `unknown` and
1173
+ * `withheld`; when a session is installed the pre-3.24.0 `signed_in{user}`
1174
+ * replay follows it (kept for ADR-023 — nothing a 3.12.0 subscriber relied on
1175
+ * is removed; it carries no `cause`).
784
1176
  *
785
1177
  * A failed sign-in emits NOTHING: the session is unchanged, so there is no
786
1178
  * transition to report — the rejected promise is the whole story.
@@ -798,13 +1190,20 @@ declare class Auth {
798
1190
  * affects auth state. Returns an unsubscribe function.
799
1191
  */
800
1192
  onAuthStateChanged(listener: AuthStateListener): () => void;
1193
+ /**
1194
+ * ADR-033 [2c] analytics quarantine hook: fires when a stored session is
1195
+ * WITHHELD (hydrate or refresh under a mismatching `expectedProjectId`), so
1196
+ * the parent SDK stops attributing events to that user IN MEMORY. The
1197
+ * persisted `user_id` is untouched. Returns an unsubscribe function.
1198
+ */
1199
+ onIdentityWithheld(listener: () => void): () => void;
801
1200
  /** Fan a state change out to subscribers. Never throws. */
802
1201
  private emitAuthState;
803
1202
  /**
804
1203
  * Internal — called from every identified-signin path with the
805
1204
  * `retiredAnonUserId` parsed off the backend response (or off
806
1205
  * the SSO redirect fragment). Snapshot + dispatch synchronously
807
- * since handlers run on the same event loop turn as persist().
1206
+ * since handlers run on the same event loop turn as the install.
808
1207
  *
809
1208
  * Validates UUID shape before dispatch. The value comes from a URL
810
1209
  * fragment / response body that — under XSS — could be controlled.
@@ -826,46 +1225,72 @@ declare class Auth {
826
1225
  * What the last sign-in did with this device's guest account, or null if no
827
1226
  * sign-in has happened this session (s58.278).
828
1227
  *
829
- * The one worth acting on is `"preserved"`: a guest account was presented,
830
- * was NOT retired, and is therefore still alive server-side — so "recover
831
- * your other account" is a real offer rather than a guess. Cleared on signOut.
1228
+ * ⚠ Since 3.24.0 a sign-in from this SDK never presents the guest, so the
1229
+ * server's answer is always `"none"` — the guest is HELD on this device and
1230
+ * answerable synchronously via `getHeldGuests()`. Kept for apps reading it;
1231
+ * recorded ONLY when the server states it. Cleared on signOut.
832
1232
  */
833
1233
  getLastAnonRetirement(): AnonRetirementOutcome | null;
834
1234
  private fireDeletionCancelled;
835
1235
  /**
836
- * Returns the stored anon refresh token iff the local subject is
837
- * currently anonymous. Used by every identified-session-mint path
838
- * so the backend can run device-takeover (s58.111+).
1236
+ * Record what the SERVER said about a sign-in response, after the install
1237
+ * commit: the guest outcome (only when stated — an older backend omits it,
1238
+ * which is not `none`), a cancelled deletion, and the legacy retirement id.
839
1239
  */
840
- private takeoverHint;
1240
+ private recordServerSignals;
841
1241
  /**
842
- * Returns a VALID (non-expired) access token, or null.
1242
+ * Apply one transaction, or drop it. Returns `true` when applied.
843
1243
  *
844
- * Hard contract (parity with sdk-react-native / sdk-ios / sdk-android
845
- * and the industry token-client pattern — Supabase
846
- * `_callRefreshToken`, Auth0 `getTokenSilently`, Firebase
847
- * `getIdToken`): NEVER returns a token past its `exp`. Callers attach
848
- * the result as a Bearer to arbitrary resource servers — including
849
- * third-party backends (PostgREST/Supabase) that have NO knowledge of
850
- * Sendora's own refresh grace window — so a token must be either
851
- * fresh or absent.
1244
+ * Everything that mutates identity happens here and nowhere else: the
1245
+ * in-memory slot (`state` / `record` / `user` / tokens / `withheld`), the
1246
+ * storage keys (via `SessionStore`), the proactive cron, the analytics /
1247
+ * flags / chatbot hooks, and the emitted `onAuthStateChanged` event. Public
1248
+ * methods only build proposals.
852
1249
  *
853
- * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
854
- * skew before exp) → return it. Fast path, no I/O.
855
- * 2. No cached token AND no refresh token in storage → null.
856
- * 3. Expired/near-expiry but a refresh chain exists:
857
- * a. A refresh is already inflight → AWAIT it and return the
858
- * fresh token. Single-flight: concurrent callers coalesce
859
- * onto one `/token/refresh`, nobody is handed a stale token.
860
- * The inflight fetch is AbortController-bounded (http.ts caps
861
- * `/auth-service/*` at 10s) so awaiting never hangs — that
862
- * bounded fetch, not the old stale-token return, is the real
863
- * cold-launch hang fix.
864
- * b. In backoff cooldown after a failed refresh → null (honest
865
- * "no usable token", not a provably-expired one; the
866
- * `visibilitychange` hook clears the cooldown on tab focus).
867
- * c. Otherwise → fire the refresh and await it.
868
- */
1250
+ * CAS (INV-2): an identity commit names the `sid` it was derived from
1251
+ * (`expectSid`); a token commit names the refresh token it sent
1252
+ * (`expectRefresh`). A result that no longer matches the current session is
1253
+ * DROPPED here — the caller then revokes its server copy (sign-in) or simply
1254
+ * returns (refresh), and the device is left exactly as found.
1255
+ */
1256
+ private commit;
1257
+ private commitInstall;
1258
+ private commitClear;
1259
+ private commitWithhold;
1260
+ /**
1261
+ * Read the disk and commit the state it describes. `boot` = the
1262
+ * constructor (emits nothing; subscribers get `initial`). `storage_event` =
1263
+ * another tab changed our keys (3.24.0 (e)); the same rules, applied as
1264
+ * transitions from the current in-memory state, CAS-safe because every
1265
+ * branch is a commit.
1266
+ */
1267
+ private hydrateFromDisk;
1268
+ /**
1269
+ * 3.24.0 (e) — the Web Locks cross-tab mutex is GONE (Supabase removed the
1270
+ * same lock in 2.107.0 after seven failure classes). Cross-tab coherence is
1271
+ * now: the token CAS (a rotation another tab performed is on disk and the
1272
+ * refresh sends what is on disk) + this re-hydrate on the `storage` event,
1273
+ * which fires in every tab EXCEPT the writer. Memory-mode has no disk and no
1274
+ * other tab can see it, so nothing to listen to.
1275
+ */
1276
+ private startStorageEventRehydrate;
1277
+ /** Detach the cross-tab listener (tests / `destroy()`). */
1278
+ stopStorageEventRehydrate(): void;
1279
+ /** The server session family id of the current session — the identity CAS key. `null` = no session (or unreadable). */
1280
+ private currentSid;
1281
+ /** The ProjectGuard (INV-15) — `match` whenever `expectedProjectId` is unset. */
1282
+ private judge;
1283
+ /** The JWT `project` claim as a string or explicit null; `null` when unreadable. */
1284
+ private projectClaim;
1285
+ private withheldInfo;
1286
+ private heldCount;
1287
+ /** The current guest as a held slot: its POST-refresh tokens and its `project` claim at HOLD. */
1288
+ private buildHeld;
1289
+ /** Fire-and-forget server-side revoke of a refresh token; the device state never waits on it. */
1290
+ private revokeFireAndForget;
1291
+ /** B15 — tell the server a guest was held. Silent 200 either way; the pending flag clears on success. */
1292
+ private reportHeld;
1293
+ private withheldError;
869
1294
  /**
870
1295
  * The cached-token guard, and the only place the two deadlines are combined.
871
1296
  * Both must agree: the locally-tracked deadline (immune to a constantly-wrong
@@ -901,77 +1326,203 @@ declare class Auth {
901
1326
  * reads live, guard holds) or genuinely fast (still dead, guard releases).
902
1327
  */
903
1328
  private refreshAndReconcile;
1329
+ /**
1330
+ * Returns a VALID (non-expired) access token, or null.
1331
+ *
1332
+ * Hard contract (parity with sdk-react-native / sdk-ios / sdk-android
1333
+ * and the industry token-client pattern — Supabase
1334
+ * `_callRefreshToken`, Auth0 `getTokenSilently`, Firebase
1335
+ * `getIdToken`): NEVER returns a token past its `exp`. Callers attach
1336
+ * the result as a Bearer to arbitrary resource servers — including
1337
+ * third-party backends (PostgREST/Supabase) that have NO knowledge of
1338
+ * Sendora's own refresh grace window — so a token must be either
1339
+ * fresh or absent.
1340
+ *
1341
+ * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
1342
+ * skew before exp) → return it. Fast path, no I/O.
1343
+ * 2. No cached token AND no refresh token in storage → null.
1344
+ * 3. Expired/near-expiry but a refresh chain exists:
1345
+ * a. A refresh is already inflight → AWAIT it and return the
1346
+ * fresh token. Single-flight: concurrent callers coalesce
1347
+ * onto one `/token/refresh`, nobody is handed a stale token.
1348
+ * b. In backoff cooldown after a failed refresh → null.
1349
+ * c. Otherwise → fire the refresh and await it.
1350
+ *
1351
+ * ⚠ `withheld` (3.24.0): always null — no refresh, no cron, no gap-fill.
1352
+ * The withheld record is not this project's session.
1353
+ */
904
1354
  getAccessToken(options?: {
905
1355
  forceRefresh?: boolean;
906
1356
  }): Promise<string | null>;
907
- /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
1357
+ /** Sync read — returns whatever's cached even if expired (null in `withheld`). Prefer the async variant. */
908
1358
  getAccessTokenSync(): string | null;
909
1359
  /**
910
- * Mint (or return) an anonymous session.
1360
+ * The stored refresh token, or null (always null in `withheld`). Kept for
1361
+ * integrators that journal it; since 3.24.0 the SDK journals a replaced guest
1362
+ * itself in a held slot (`getHeldGuests()`), so most callers no longer need it.
1363
+ */
1364
+ getRefreshToken(): string | null;
1365
+ /**
1366
+ * Hot-path token refresh. Single-flight via `refreshInflight` so a
1367
+ * burst of in-flight reads doesn't fan out into duplicate /refresh
1368
+ * round-trips. Returns null on any failure so callers can decide
1369
+ * whether to surface re-auth or proceed anonymously.
1370
+ *
1371
+ * Dead-token signal = `INVALID_REFRESH_TOKEN` ONLY (`isDeadRefreshError`);
1372
+ * a rate limit or a generic 401 is transient. The verdict is applied
1373
+ * through commit() with the sent token as the CAS key, so a refresh that
1374
+ * lands after a sign-out / switch can neither resurrect a session nor
1375
+ * wipe a newer one (§3.3 rows 20–26).
1376
+ *
1377
+ * ⚠ 3.24.0: no Web Locks. Each tab single-flights its own refresh; a sibling
1378
+ * tab's rotation reaches this tab through the `storage` event re-hydrate,
1379
+ * and the server's 60 s rotation grace covers the race where both tabs
1380
+ * present the same parent.
1381
+ */
1382
+ private refreshAccessToken;
1383
+ private performRefresh;
1384
+ /**
1385
+ * Present a HELD slot's refresh token (restore / claim / auto-restore — the
1386
+ * ONLY places a held token is ever refreshed; the cron never touches a slot).
1387
+ * Persists the rotation INTO the slot before anything else happens with it
1388
+ * (§3.3 "persist rotations before the next step"), via a per-slot CAS.
1389
+ *
1390
+ * Dead verdict handling is the §3.3 REFRESH_DEAD-held row: release the slot
1391
+ * ONLY for `endReason ∈ deleted | revoked | expired`; `reuse` or an ABSENT
1392
+ * `endReason` KEEPS the slot and surfaces a retryable `RESTORE_FAILED` — a
1393
+ * reuse verdict on a preserved-family guest does not prove it is gone.
1394
+ */
1395
+ private refreshHeld;
1396
+ /**
1397
+ * `unknown` → settled. The queue of INV-5: every session-installing verb
1398
+ * (MINT, SIGN-IN, LINK, RESTORE) waits here until the single gap-fill
1399
+ * refresh answers — success installs, `INVALID_REFRESH_TOKEN` clears to
1400
+ * `signed_out`; a transient failure retries after the backoff window (so an
1401
+ * offline cold start resolves when connectivity returns). Never executes the
1402
+ * verb's network call in `unknown`.
1403
+ */
1404
+ private settle;
1405
+ /**
1406
+ * Wave 63 — arm exponential backoff after a transient refresh
1407
+ * failure. Caps at 30s. getAccessToken consults
1408
+ * `refreshCooldownUntil` and returns null during the window
1409
+ * instead of queueing a fresh fetch into a network we already know
1410
+ * is stalled.
1411
+ */
1412
+ private armBackoff;
1413
+ /**
1414
+ * Wave 63 — clears backoff state. Called from the visibilitychange
1415
+ * listener so a user returning to a previously-backgrounded tab
1416
+ * gets an immediate fresh refresh attempt instead of waiting on
1417
+ * the cooldown timer.
1418
+ */
1419
+ private resetRefreshState;
1420
+ /**
1421
+ * Wave 63 — start visibilitychange listener. Browser equivalent of
1422
+ * RN AppState 'active'. Armed by every install commit alongside
1423
+ * `startProactiveRefreshCron`. Idempotent.
1424
+ */
1425
+ private startVisibilityRefreshReset;
1426
+ private stopVisibilityRefreshReset;
1427
+ /**
1428
+ * Proactive refresh (s58.72). Fires when access-token age crosses
1429
+ * ~80% of TTL — refresh becomes a scheduled event, never a 401-
1430
+ * driven race. Armed by every install commit (incl. hydrate — the Word
1431
+ * Hurdle cold-launch fix) and by the `unknown` commit (the single gap-fill),
1432
+ * stopped by every clear / withhold. Pauses while an identity attempt is on
1433
+ * the wire (§3.3 scheduling rule 2).
1434
+ */
1435
+ private startProactiveRefreshCron;
1436
+ private stopProactiveRefreshCron;
1437
+ private serialize;
1438
+ /**
1439
+ * Build the Authorization header for end-user-authenticated routes.
911
1440
  *
912
- * Reuses the existing anonymous user when there already is one — the same
913
- * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
914
- * that calls this defensively on every page load (the most natural thing to
915
- * write) mints a fresh `user_id` each time, and `persist()` overwrites the
916
- * stored refresh token — the previous anonymous account's only durable
917
- * handle — with no takeover, no webhook and no state event. That is the same
918
- * silent progress loss as a failed sign-in wiping the session, except it
919
- * happens on a *healthy* network.
1441
+ * Awaits `getAccessToken()` so a cold-start (re-hydrated from storage
1442
+ * with no cached access token) or an expired token transparently
1443
+ * refreshes via the stored refresh token before the call. Throws if there's
1444
+ * genuinely no usable session; in `withheld` the refusal is
1445
+ * `SESSION_WITHHELD` (no network — §3.3 row 12).
1446
+ */
1447
+ private bearerHeaders;
1448
+ /**
1449
+ * Mint (or return) an anonymous session.
920
1450
  *
921
- * The guard is `cached user` AND `refresh token in storage`, both read INSIDE
922
- * the serialized op: the refresh token is what actually keeps the account
923
- * reachable (a cached user without one must still mint), and reading after
924
- * the chain has drained is what collapses concurrent calls onto a single
925
- * mint — the second call runs only once the first has persisted, so it takes
926
- * the reuse branch instead of minting a sibling.
1451
+ * Reuses the existing session when there is one — the same short-circuit
1452
+ * Firebase's `signInAnonymously` performs, covering an IDENTIFIED session
1453
+ * too (3.23.0, RopoLabs A2): a defensive call on every page load returns
1454
+ * the current user whatever its kind; read `user.isAnonymous` to know which.
927
1455
  *
928
- * ⚠ The guard also covers an IDENTIFIED session (3.23.0, reported by
929
- * RopoLabs on the RN SDK; same code here). Until then it was
930
- * `existing.isAnonymous`, so a plain `signInAnonymously()` over a live
931
- * account session fell through, minted a guest and overwrote the account's
932
- * tokens locally — with no `signed_out`, because this path never wipes, it
933
- * persists over the top. The account survives server-side (recoverable by
934
- * its credential) but the device is now a stranger to it. Returning the
935
- * identified session is a no-op for a defensive caller and the honest
936
- * answer to "make sure I have a session" — read `user.isAnonymous` to know
937
- * which kind you got.
1456
+ * 3.24.0 (ADR-033): MINT executes ONLY in `signed_out` (INV-5). In `unknown`
1457
+ * it waits for the gap-fill refresh to settle the status; in `withheld` it
1458
+ * rejects `SESSION_WITHHELD` (opt-in only — unlike the no-op over identified).
1459
+ * In `signed_out` a HELD guest is restored before anything is minted
1460
+ * (`autoRestoreHeldGuest`, default ON, §8-Q1) — visibly, as
1461
+ * `signed_in{cause:'restore'}` + `guest_released{restored}`; a dead held
1462
+ * guest is released (`guest_released{dead}`) and the next one tried, and a
1463
+ * fresh mint happens in the same call when none is left. The capacity bound
1464
+ * T = |held| + (current anonymous ? 1 : 0) ≤ 5 is enforced HERE: `forceNew`
1465
+ * at the cap throws `HeldGuestLimitError`; a plain mint at the cap restores.
938
1466
  *
939
1467
  * Pass `forceNew: true` to deliberately abandon the current session —
940
- * whichever kind — and mint a separate anonymous one. That is the only way
941
- * this replaces an identified session, and it does so silently.
1468
+ * whichever kind — and mint a separate anonymous one. A current GUEST is
1469
+ * HELD (never orphaned); a current identified session is replaced and its
1470
+ * refresh token revoked server-side.
1471
+ *
1472
+ * Every mint carries a per-attempt `mintKey` (B10): a retried mint after a
1473
+ * lost response returns the SAME guest instead of an orphan.
942
1474
  */
943
1475
  signInAnonymously(opts?: {
944
1476
  name?: string;
945
1477
  metadata?: Record<string, unknown>;
946
1478
  forceNew?: boolean;
947
1479
  }): Promise<AuthUser>;
948
- /**
949
- * Create an email/password account. Upgrade-first flow (matches
950
- * Firebase `linkWithCredential` / Auth0 anonymous-merge): if a
951
- * refresh token is present, attempt /upgrade first — backend
952
- * preserves user.id when the bound user is anonymous. On
953
- * non-anonymous-bound refresh / invalid-or-expired refresh, fall
954
- * back to fresh /signup. We don't gate on the cached
955
- * `this.user.isAnonymous` boolean — refresh-token presence is the
956
- * authoritative signal (cached user can be stale during cold
957
- * start / fast-refresh). Backend has source of truth.
958
- */
959
- signUp(email: string, password: string, opts?: {
960
- name?: string;
961
- metadata?: Record<string, unknown>;
962
- }): Promise<AuthUser>;
1480
+ /** Alias of `signInAnonymously()` — "make sure I have a session". */
1481
+ ensureSession(): Promise<AuthUser>;
1482
+ /** A slot is ineligible for auto-restore when its recorded project is a string that disagrees with the anchor (null = judged later). */
1483
+ private slotProjectMismatches;
1484
+ /** The fresh mint (row 6): `POST /anonymous` (+ `mintKey`) → ProjectGuard on the response → install. */
1485
+ private mintFresh;
1486
+ /**
1487
+ * ProjectGuard on a session minted by THIS process under THIS key (mint /
1488
+ * sign-in / promotion response): a mismatch installs nothing, revokes the
1489
+ * minted refresh fire-and-forget and rejects `PROJECT_MISMATCH` — the
1490
+ * wrong-key build fails loudly at first launch, never a relaunch lockout
1491
+ * loop (§8-Q14). An unreadable claim on a FRESH response is treated as a
1492
+ * mismatch here (the server emits it ALWAYS since s58.276).
1493
+ */
1494
+ private guardMinted;
1495
+ /**
1496
+ * The one switch path every credentialed sign-in runs through. The request
1497
+ * carries NO guest hint (INV-3). On success the previous GUEST (if any) is
1498
+ * HELD with its post-refresh tokens, a previous IDENTIFIED session with a
1499
+ * different sub has its refresh revoked (§8-Q12), and the new record is
1500
+ * installed through commit() keyed on the `sid` the attempt started from.
1501
+ * `mfaRequired` leaves the state untouched and returns the challenge.
1502
+ */
1503
+ private performSwitch;
963
1504
  /**
964
1505
  * Sign in with email + password. When the account has confirmed
965
1506
  * MFA, returns a `mfaRequired` discriminator instead of the user
966
1507
  * — caller must then call `challengeMfa(challengeToken, code)`
967
1508
  * with the TOTP / recovery code to mint a real session. Pre-MFA
968
1509
  * accounts get a normal `AuthUser` back.
1510
+ *
1511
+ * 3.24.0: a guest on this device is HELD, never retired (`guest_held`
1512
+ * then ONE `signed_in{cause:'sign_in'}`, no `signed_out`).
969
1513
  */
970
1514
  signIn(email: string, password: string): Promise<AuthUser | {
971
1515
  mfaRequired: true;
972
1516
  mfaChallengeToken: string;
973
1517
  userId: string;
974
1518
  }>;
1519
+ /**
1520
+ * Exchange the `mfaChallengeToken` from `signIn` for a real
1521
+ * session by submitting a 6-digit TOTP code or a 17-char recovery
1522
+ * code from the user's authenticator app. A wrong or expired code
1523
+ * leaves the current session in place so the user can retry.
1524
+ */
1525
+ challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
975
1526
  /**
976
1527
  * Verify an IdP-issued credential (authorization code OR id_token)
977
1528
  * and mint a Sendora session. Customer's app handles the IdP
@@ -985,13 +1536,18 @@ declare class Auth {
985
1536
  * by design — see backend service.ts)
986
1537
  * - `idToken` (Apple-native flow)
987
1538
  *
988
- * Apple's first-time prompt also yields name fields — pass via
989
- * `appleName: { firstName, lastName }`. Apple only sends them
990
- * once; re-sign-ins will return null name from the IdP.
1539
+ * `link: true` (ADR-025 → ADR-033): in `anonymous` AND `identified` the
1540
+ * call goes to `/me/link/social` on the current Bearer and ATTACHES or
1541
+ * errors — it never switches (INV-14). From a guest that is the same-`sub`
1542
+ * promotion (ONE `signed_in{cause:'link'}`); over an identified account a
1543
+ * new attach emits `user_updated{cause:'link'}`, the same identity is a quiet
1544
+ * 200 (`alreadyLinked`, no event, no revoke), a credential owned elsewhere
1545
+ * throws `CredentialInUseError` (or switches when `onCredentialInUse:
1546
+ * "adopt"` — the guest is then HELD), and a stale session throws
1547
+ * `RECENT_AUTH_REQUIRED` (kind `recent_auth_required`). In `signed_out` it
1548
+ * is a plain sign-in; in `unknown` it waits for the status to settle.
991
1549
  *
992
- * Returns the AuthUser on success. Throws AuthError on backend
993
- * refusal — most commonly the verified-email gate (Twitter,
994
- * unverified Discord, etc).
1550
+ * Without `link`, this is a plain SWITCH: the device's guest is HELD.
995
1551
  */
996
1552
  loginSocial(input: {
997
1553
  provider: "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
@@ -1003,23 +1559,12 @@ declare class Auth {
1003
1559
  firstName?: string;
1004
1560
  lastName?: string;
1005
1561
  };
1006
- /**
1007
- * ADR-025 link-in-place opt-in. When anonymous and `link: true`, an
1008
- * anon→social upgrade KEEPS the same user id (sub) — promoted in place
1009
- * (like Firebase linkWithCredential) instead of a device-takeover that
1010
- * mints a new id. No effect off-anon or on a collision (identity already
1011
- * owned → falls back to takeover/merge).
1012
- */
1562
+ /** Client ROUTING hint (ADR-033 §4): anonymous | identified → `/me/link/social`; signed_out → plain sign-in; unknown → wait. Never sent to the server. */
1013
1563
  link?: boolean;
1014
- /**
1015
- * What to do if this social identity (or its verified email) already
1016
- * belongs to an account. **Omit it** — the default adopts when nothing
1017
- * would be destroyed and refuses when a live anonymous session would be
1018
- * deleted. See {@link CredentialCollisionPolicy}.
1019
- */
1564
+ /** See {@link CredentialCollisionPolicy}. Applied client-side on a 409 from the link route; never sent to the server. */
1020
1565
  onCredentialInUse?: CredentialCollisionPolicy;
1021
1566
  }): Promise<AuthUser>;
1022
- /** Convenience: Google authorization-code login. `link` = ADR-025 keep-the-sub on an anon upgrade. */
1567
+ /** Convenience: Google authorization-code login. `link` = attach to the current account (see `loginSocial`). */
1023
1568
  signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
1024
1569
  /** Convenience: GitHub authorization-code login. */
1025
1570
  signInWithGitHub(code: string, redirectUri: string): Promise<AuthUser>;
@@ -1037,7 +1582,7 @@ declare class Auth {
1037
1582
  firstName?: string;
1038
1583
  lastName?: string;
1039
1584
  };
1040
- /** ADR-025: keep the same user id on an anon→Apple upgrade. See `loginSocial`. */
1585
+ /** Attach to the current account instead of switching. See `loginSocial`. */
1041
1586
  link?: boolean;
1042
1587
  }): Promise<AuthUser>;
1043
1588
  /** Convenience: Microsoft Azure AD authorization-code login. */
@@ -1046,6 +1591,63 @@ declare class Auth {
1046
1591
  signInWithLinkedIn(code: string, redirectUri: string): Promise<AuthUser>;
1047
1592
  /** Convenience: Facebook Graph login. Refuses if email permission not granted. */
1048
1593
  signInWithFacebook(code: string, redirectUri: string): Promise<AuthUser>;
1594
+ /** Convenience: Discord OAuth2. Refuses if `verified !== true`. */
1595
+ signInWithDiscord(code: string, redirectUri: string): Promise<AuthUser>;
1596
+ /**
1597
+ * Typed sign-in (ADR-033 §3.5) — a plain SWITCH for every credential kind.
1598
+ * `previous` tells you what it replaced; `previous.held === true` means the
1599
+ * device's guest is in a held slot (`getHeldGuests()`), `previous.isAnonymous
1600
+ * === false` means an identified → identified switch (reset local save state).
1601
+ * `mfa_required` carries a `verify(code)` that completes the challenge.
1602
+ */
1603
+ signInWith(credential: AuthCredential): Promise<SignInResult>;
1604
+ /**
1605
+ * The one link path. Bearer `POST /me/link/*`; outcomes per the table:
1606
+ * - 200 `upgraded` (caller was anonymous) → ProjectGuard → install the
1607
+ * rotated pair, same `sub`. `legacyMode` decides the event: the legacy-
1608
+ * signature methods keep ONE `signed_in{cause:'link'}` (§8-Q18); the typed
1609
+ * verbs emit `user_updated{cause:'link'}`. Never both.
1610
+ * - 200 `alreadyLinked` → nothing: no event, no write, no revoke (B13).
1611
+ * - 200 attached (identified, no rotation) → `user_updated{cause:'link'}`.
1612
+ * - 409 → `credential_in_use{provider, collision}`, nothing mutated (B14).
1613
+ * - 403 `RECENT_AUTH_REQUIRED` → `recent_auth_required`, nothing mutated.
1614
+ */
1615
+ private performLink;
1616
+ /**
1617
+ * Typed link (ADR-033 §3.5): attach a credential to the CURRENT account —
1618
+ * from a guest this is the same-`sub` promotion (`user_updated{cause:'link'}`,
1619
+ * `upgraded: true`); from an identified account a new attach, a quiet
1620
+ * `alreadyLinked`, a typed `credential_in_use` (with `switchAccount()` bound
1621
+ * to this exact credential and `keepCurrent()`), or `recent_auth_required`.
1622
+ * In `signed_out` there is nothing to attach to, so it is a plain sign-in; in
1623
+ * `unknown` it waits for the status to settle. Linkable kinds: password,
1624
+ * the social providers, gameCenter, playGames.
1625
+ */
1626
+ link(credential: AuthCredential): Promise<LinkResult>;
1627
+ /** `signInWith()` for callers already inside the serialize chain (link() in signed_out). */
1628
+ private signInWithUnserialized;
1629
+ /**
1630
+ * Create an email/password account.
1631
+ *
1632
+ * 3.24.0 (ADR-033 INV-4) — routed by `status()`, locally:
1633
+ * - `anonymous` → `POST /me/link/email` on the current Bearer: the guest
1634
+ * is promoted IN PLACE (same `sub`), ONE
1635
+ * `signed_in{cause:'link'}`. A taken email throws
1636
+ * `EmailAlreadyTakenError` with nothing mutated.
1637
+ * - `signed_out` → `POST /signup` (a brand-new account).
1638
+ * - `identified` → `AlreadyIdentifiedError`, with NO round-trip.
1639
+ * - `unknown` → waits for the status to settle, then routes.
1640
+ * - `withheld` → `SESSION_WITHHELD`.
1641
+ * `/upgrade` is never called; its private dead-token list is gone with it
1642
+ * (a generic 401 there used to wipe the guest).
1643
+ *
1644
+ * ⚠ `opts.name` / `opts.metadata` reach the server only on the `/signup`
1645
+ * path — the link route's schema carries exactly `email` + `password`.
1646
+ */
1647
+ signUp(email: string, password: string, opts?: {
1648
+ name?: string;
1649
+ metadata?: Record<string, unknown>;
1650
+ }): Promise<AuthUser>;
1049
1651
  /** Link email + password to the current account (sub preserved). */
1050
1652
  linkEmailPassword(email: string, password: string): Promise<AuthUser>;
1051
1653
  /** Link an OAuth social identity (verified idToken or authorization code) to the current account. */
@@ -1076,15 +1678,122 @@ declare class Auth {
1076
1678
  lastName?: string;
1077
1679
  };
1078
1680
  }): Promise<AuthUser>;
1681
+ /** The `link*()` family: typed `link()` surfaced through the pre-3.24.0 throw-on-refusal contract. */
1682
+ private linkLegacy;
1683
+ /**
1684
+ * Every guest held on this device, newest first. Never exposes tokens.
1685
+ * Best-effort: `dead` is observed ONLY when a held token is presented
1686
+ * (restore / claim / discard), so a guest the server has since deleted may
1687
+ * still be listed. `autoRestore:false` = stamped by `deleteAccount()`; only
1688
+ * an explicit `restoreHeldGuest(id)` revives it. There is NO expiry: neither
1689
+ * the SDK nor the server releases a held guest by age (s58.316).
1690
+ */
1691
+ getHeldGuests(): HeldGuestSummary[];
1692
+ /** The newest held guest, or null. */
1693
+ getHeldGuest(): HeldGuestSummary | null;
1694
+ /**
1695
+ * Resolve the slot a held-guest verb acts on. `id` may be omitted ONLY when
1696
+ * exactly one guest is held — with two or more it is `AMBIGUOUS_HELD_GUEST`
1697
+ * (the prepend-between-dialog-and-tap TOCTOU), nothing mutated.
1698
+ */
1699
+ private resolveSlot;
1700
+ /**
1701
+ * CLAIM (§3.3 row 29): fold a held guest INTO the current IDENTIFIED account
1702
+ * via `POST /merge` — Sendora-side reassignment of events / profiles /
1703
+ * entities / push tokens, then the guest row is hard-deleted
1704
+ * (`auth.user_merged`). OPTIONAL and IRREVERSIBLE (INV-8): an app whose
1705
+ * backend does not act on `auth.user_merged` MUST NOT call it. The held
1706
+ * token is refreshed first (repairs a spent parent inside the 60 s grace and
1707
+ * proves liveness; the rotation is persisted into the slot BEFORE the merge).
1708
+ * Emits `user_updated{cause:'merge'}` then `guest_released{claimed}`.
1709
+ */
1710
+ claimHeldGuest(id?: string): Promise<{
1711
+ user: AuthUser;
1712
+ stats: MergeStats;
1713
+ }>;
1079
1714
  /**
1080
- * Shared link executor. POSTs the verified credential with the caller's Bearer
1081
- * access token, then refreshes the cached user IN PLACE (no token rotation —
1082
- * the sub is unchanged). NOT serialized (mirrors sendVerificationEmail;
1083
- * getAccessToken() coalesces refresh single-flight internally).
1715
+ * RESTORE (§3.3 rows 30–31): sign back in AS the held guest, without
1716
+ * merging. Project check FIRST, client-side (`HeldGuestProjectMismatch`,
1717
+ * slot KEPT, no network); then the held token is refreshed; a dead guest
1718
+ * releases the slot and rejects `HELD_GUEST_GONE`. A current identified
1719
+ * session has its refresh revoked; a current GUEST is held in a free slot
1720
+ * first. Emits `signed_in{cause:'restore'}` (re-register push) then
1721
+ * `guest_released{restored}`.
1084
1722
  */
1085
- private linkCredential;
1086
- /** Refresh the cached user after a link — same sub, so only the user object + its storage copy change (no token/identity rotation). */
1087
- private updateLocalUser;
1723
+ restoreHeldGuest(id?: string): Promise<AuthUser>;
1724
+ /**
1725
+ * The restore body shared by `restoreHeldGuest()` and the MINT auto-restore.
1726
+ * Returns null when the slot was DEAD and has been released (the mint falls
1727
+ * through to the next slot / a fresh mint); throws `HeldGuestProjectMismatch`
1728
+ * when the slot belongs to another project (client-side, or judged on the
1729
+ * refresh response for a slot whose project was unreadable at HOLD).
1730
+ */
1731
+ private restoreSlot;
1732
+ /**
1733
+ * DISCARD (§3.3 row 32): a REAL server delete of a held guest — `POST
1734
+ * /guest/discard` with the guest's own refresh token (the only proof of
1735
+ * ownership), immediate purge, `auth.user_deleted{reason:'guest_discarded'}`.
1736
+ * The app is the requester (s58.316); the SDK never calls this on its own.
1737
+ * A 2xx or a 404 (already gone / not ours) releases the slot; a 5xx or a
1738
+ * network failure keeps it. Emits `guest_released{discarded}`.
1739
+ */
1740
+ discardHeldGuest(id?: string): Promise<void>;
1741
+ private discardSlot;
1742
+ /**
1743
+ * DEPRECATED alias (ADR-033 §3.5): merge a guest into the current account by
1744
+ * its refresh token. A token that matches a held slot (its current token OR
1745
+ * the one it had at HOLD) routes to `claimHeldGuest(thatId)` — a DESTRUCTIVE
1746
+ * claim (INV-8). Any other token is the raw `/merge` the integrator-journalled
1747
+ * path always was. Prefer `claimHeldGuest(id)`.
1748
+ */
1749
+ merge(anonymousRefreshToken: string): Promise<MergeStats>;
1750
+ /**
1751
+ * While `status() === "withheld"`: what was refused and why. `userId` lets a
1752
+ * `sub`-keyed backend record oldSub → newSub BEFORE `discardWithheld()`.
1753
+ * Never exposes tokens. Null in every other state.
1754
+ */
1755
+ getWithheld(): WithheldInfo | null;
1756
+ /**
1757
+ * Leave `withheld` (§3.3 row 14): clears exactly the stored session (the
1758
+ * four frozen keys + their `auth_session` mirror), revokes the withheld
1759
+ * refresh token server-side fire-and-forget like `signOut()` (§8-Q15), and
1760
+ * KEEPS the held slots, the event queue and the analytics ids. LOCAL ONLY —
1761
+ * never `/guest/discard`, never a server delete. Emits
1762
+ * `signed_out{reason:'user'}`. Resolves `false` (nothing done) in any other
1763
+ * state. `signOut()` in `withheld` does exactly this.
1764
+ */
1765
+ discardWithheld(): Promise<boolean>;
1766
+ private discardWithheldNow;
1767
+ /**
1768
+ * Sign out: wipe local identity FIRST so the user is logged out on
1769
+ * device even if the revoke network call hangs (airplane mode, 5xx,
1770
+ * circuit breaker open) — the one deliberate local-first exception, it has
1771
+ * a rollback. Revoke is fire-and-forget after the wipe. Keeps the held
1772
+ * guests (a guest's refresh token is its only credential — stranding it is
1773
+ * deletion by assumption, s58.316) and the event queue. In `withheld` this is
1774
+ * `discardWithheld()`. In `signed_out` it emits nothing (no transition).
1775
+ */
1776
+ signOut(): Promise<void>;
1777
+ /**
1778
+ * Delete the signed-in user's account (3.24.0 on web; parity with the
1779
+ * native SDKs). Honours the project's grace: `purged` = hard-deleted now,
1780
+ * `pending` = deactivated, hard-deleted at `scheduledPurgeAt` (cancellable by
1781
+ * signing back in). Either way the local session is cleared with
1782
+ * `signed_out{reason:'account_deleted'}`, the event queue is dropped
1783
+ * (s58.353) and the anonymous analytics id rotates.
1784
+ *
1785
+ * ⚠ Held guests SURVIVE on the device AND the server (the server has no
1786
+ * owner link; deleting A keys on A only). Each slot is stamped
1787
+ * `autoRestore:false`, so the next `ensureSession()` mints a fresh guest and
1788
+ * only an explicit `restoreHeldGuest(id)` revives one. Pass
1789
+ * `alsoDiscardHeldGuests: true` — the player's explicit ask — to run
1790
+ * `/guest/discard` on every slot after the 2xx; failures stay held and are
1791
+ * reported in `discardFailed`. Your privacy copy must say deleting an account
1792
+ * does not delete a guest saved on the device unless that option is used.
1793
+ */
1794
+ deleteAccount(opts?: {
1795
+ alsoDiscardHeldGuests?: boolean;
1796
+ }): Promise<DeleteAccountResult>;
1088
1797
  /**
1089
1798
  * List the auth methods/identities linked to the current account (ADR-030
1090
1799
  * read side, s58.270). Returns every social identity linked to this account
@@ -1115,14 +1824,6 @@ declare class Auth {
1115
1824
  * attached tells the user something untrue about their account.
1116
1825
  */
1117
1826
  unlink(provider: string): Promise<UnlinkResult>;
1118
- /** Convenience: Discord OAuth2. Refuses if `verified !== true`. */
1119
- signInWithDiscord(code: string, redirectUri: string): Promise<AuthUser>;
1120
- /**
1121
- * Exchange the `mfaChallengeToken` from `signIn` for a real
1122
- * session by submitting a 6-digit TOTP code or a 17-char recovery
1123
- * code from the user's authenticator app.
1124
- */
1125
- challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
1126
1827
  /**
1127
1828
  * Send a magic-link email to `email`. Caller's app should display
1128
1829
  * "check your inbox" copy after success. Always resolves
@@ -1138,10 +1839,19 @@ declare class Auth {
1138
1839
  sendEmailOtp(email: string): Promise<void>;
1139
1840
  /**
1140
1841
  * Verify a typed 6-digit code + the email it was sent to. Mints
1141
- * a session on success. First-time users are auto-created with
1142
- * `emailVerified=true`.
1842
+ * a session on success (a SWITCH: the device's guest is HELD). First-time
1843
+ * users are auto-created with `emailVerified=true`. A mistyped code leaves
1844
+ * the current session exactly as it was.
1143
1845
  */
1144
1846
  verifyEmailOtp(email: string, code: string): Promise<AuthUser>;
1847
+ /**
1848
+ * Verify a magic-link token (extracted from the URL query string of
1849
+ * the page the link points to) and mint a session (a SWITCH: the device's
1850
+ * guest is HELD). First-time users are auto-created with
1851
+ * `emailVerified=true`. An expired or already-consumed token leaves the
1852
+ * current session exactly as it was.
1853
+ */
1854
+ verifyMagicLink(token: string): Promise<AuthUser>;
1145
1855
  /**
1146
1856
  * Trigger a password-reset email. Backend always resolves 204 even
1147
1857
  * when the address is unknown — prevents account enumeration.
@@ -1168,12 +1878,6 @@ declare class Auth {
1168
1878
  * after signup.
1169
1879
  */
1170
1880
  sendVerificationEmail(): Promise<void>;
1171
- /**
1172
- * Verify a magic-link token (extracted from the URL query string of
1173
- * the page the link points to) and mint a session. First-time
1174
- * users are auto-created with `emailVerified=true`.
1175
- */
1176
- verifyMagicLink(token: string): Promise<AuthUser>;
1177
1881
  /**
1178
1882
  * Begin MFA enrollment. Returns the otpauth:// URL (render as a
1179
1883
  * QR code) + recovery codes (display + offer download — these are
@@ -1216,7 +1920,9 @@ declare class Auth {
1216
1920
  /**
1217
1921
  * Sign in with a passkey. Browser shows the credential picker UI;
1218
1922
  * `userId` is optional — when omitted, uses the resident-key flow
1219
- * so the user picks their account from the prompt.
1923
+ * so the user picks their account from the prompt. Three fallible steps
1924
+ * precede the session (start, the picker, finish) and each leaves the
1925
+ * current session exactly as it was; the success is a SWITCH (guest HELD).
1220
1926
  */
1221
1927
  signInWithPasskey(userId?: string | null): Promise<AuthUser>;
1222
1928
  listPasskeys(): Promise<Array<{
@@ -1260,6 +1966,9 @@ declare class Auth {
1260
1966
  * redirects through the Sendora callback back to `returnTo` with
1261
1967
  * a `#sendora_oidc_token=...` URL fragment. Call
1262
1968
  * `consumeSsoTokenFromUrl()` on the returnTo page to mint a session.
1969
+ *
1970
+ * 3.24.0: no guest hint travels with the start request (INV-3) — the guest
1971
+ * is HELD on this device when the fragment is consumed.
1263
1972
  */
1264
1973
  startSso(returnTo: string): Promise<{
1265
1974
  authorizationUrl: string;
@@ -1290,113 +1999,17 @@ declare class Auth {
1290
1999
  * success, null when no fragment present, throws on
1291
2000
  * `sendora_oidc_error=...`. Call this once on the returnTo page
1292
2001
  * (typically from a top-level effect / page-load hook).
1293
- */
1294
- consumeSsoTokenFromUrl(): Promise<AuthUser | null>;
1295
- /**
1296
- * Build the Authorization header for end-user-authenticated routes.
1297
2002
  *
1298
- * Awaits `getAccessToken()` so a cold-start (re-hydrated from storage
1299
- * with no cached access token) or an expired token transparently
1300
- * refreshes via the stored refresh token before the call — matching
1301
- * `sendVerificationEmail`. Throws if there's genuinely no usable
1302
- * session; the caller should handle this as an unauthenticated state.
2003
+ * 3.24.0 (e): the install goes through `commit()` like every other sign-in
2004
+ * (it was hand-installed before) — so it is a SWITCH: the device's guest is
2005
+ * HELD, the CAS applies, the cron is armed, and `signed_in{cause:'sign_in'}`
2006
+ * is the emission. The exchange happens FIRST; a stale bookmark or a crafted
2007
+ * `#sendora_oidc_token=junk` link leaves the current session untouched.
1303
2008
  */
1304
- private bearerHeaders;
1305
- /**
1306
- * Sign out: wipe local identity FIRST so the user is logged out on
1307
- * device even if the revoke network call hangs (airplane mode, 5xx,
1308
- * circuit breaker open). Revoke is fire-and-forget after the wipe.
1309
- */
1310
- signOut(): Promise<void>;
1311
- private serialize;
1312
- /**
1313
- * Hot-path token refresh. Single-flight via `refreshInflight` so a
1314
- * burst of in-flight reads doesn't fan out into duplicate /refresh
1315
- * round-trips. Returns null on any failure so callers can decide
1316
- * whether to surface re-auth or proceed anonymously.
1317
- *
1318
- * s58.46 — on a dead-token signal (INVALID_REFRESH_TOKEN /
1319
- * UNAUTHORIZED / HTTP_401) we WIPE the stored refresh token.
1320
- * Pre-s58.46 we returned null but kept the dead token in
1321
- * storage, so the next caller re-tried the same token forever
1322
- * (Pulse News iOS hit /refresh ~1×/s for hours). The wipe forces
1323
- * the next op to fall through to /anonymous or surface the signed-
1324
- * out state to the host app instead of looping. A rate limit is NOT
1325
- * such a signal — see `isDeadRefreshError`.
1326
- */
1327
- private refreshAccessToken;
1328
- /**
1329
- * Wave 63 — arm exponential backoff after a transient refresh
1330
- * failure. Caps at 30s. getAccessToken consults
1331
- * `refreshCooldownUntil` and returns stale cached during the window
1332
- * instead of queueing a fresh fetch into a network we already know
1333
- * is stalled.
1334
- */
1335
- private armBackoff;
1336
- /**
1337
- * Wave 63 — clears backoff state. Called from the visibilitychange
1338
- * listener so a user returning to a previously-backgrounded tab
1339
- * gets an immediate fresh refresh attempt instead of waiting on
1340
- * the cooldown timer.
1341
- */
1342
- private resetRefreshState;
1343
- /**
1344
- * Wave 63 — start visibilitychange listener. Browser equivalent of
1345
- * RN AppState 'active'. Called from `persist()` (after signin /
1346
- * signup) alongside `startProactiveRefreshCron`. Idempotent.
1347
- */
1348
- private startVisibilityRefreshReset;
1349
- private stopVisibilityRefreshReset;
1350
- /**
1351
- * Proactive refresh (s58.72). Fires when access-token age crosses
1352
- * ~80% of TTL — refresh becomes a scheduled event, never a 401-
1353
- * driven race. Started by `init()` and cleared by `signOut()`.
1354
- * Internally uses the same `refreshAccessToken()` path so Web Locks
1355
- * coordinates across tabs.
1356
- */
1357
- private startProactiveRefreshCron;
1358
- private stopProactiveRefreshCron;
1359
- private persist;
1360
- /**
1361
- * Clear the outgoing identity immediately before a CONFIRMED `persist()`.
1362
- * Flushes first so the prior identity's queued events reach the backend
1363
- * before `dropQueue()` discards them. No-op when already signed out.
1364
- *
1365
- * `"replaced"` suppresses the signed_out emission — subscribers see one
1366
- * `signed_in`, not a spurious logout/login pair.
1367
- */
1368
- private replaceLocalIdentity;
1369
- /**
1370
- * Wipe everything tied to the current user identity. The anonymous id is
1371
- * regenerated by analytics.reset() ONLY at a person-boundary (reason "user" /
1372
- * "account_deleted") so two people on a shared device don't merge; on a
1373
- * sign-in/upgrade ("replaced") or session death ("session_expired") it SURVIVES
1374
- * (same person — pre-auth analytics link to them). Chat session is reset so
1375
- * cross-account chat history can't bleed on a shared device. Pending analytics
1376
- * events are dropped — they're tied to the prior identity and shouldn't surface
1377
- * under the next.
1378
- *
1379
- * `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
1380
- * emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
1381
- * on a SUCCESSFUL sign-in) emits nothing.
1382
- *
1383
- * ⚠ Never call this before a fallible network request. A failed sign-in must
1384
- * leave the caller exactly as it found them; for an anonymous session the
1385
- * refresh token dropped here is the ONLY durable handle on the account, so a
1386
- * pre-call wipe that is not followed by a successful `persist()` orphans the
1387
- * account permanently (offline: deterministically). Capture the anon refresh
1388
- * into a local, make the call, and wipe only after the response validates.
1389
- */
1390
- /**
1391
- * True when `sent` is still the refresh token in storage — i.e. nothing
1392
- * replaced or cleared the session while a request carrying it was in
1393
- * flight. Guards both directions of the refresh race: installing a rotated
1394
- * token over a session that has since been signed out or replaced, and
1395
- * wiping a fresh session because an OLD token was rejected.
1396
- */
1397
- private tokenStillCurrent;
1398
- private wipeLocalIdentity;
2009
+ consumeSsoTokenFromUrl(): Promise<AuthUser | null>;
1399
2010
  }
2011
+ /** Re-exported for tests / advanced integrators: the literal held-slot keys this SDK writes (`auth_held_0..4`). */
2012
+ declare const AUTH_HELD_SLOT_KEYS: readonly ["auth_held_0", "auth_held_1", "auth_held_2", "auth_held_3", "auth_held_4"];
1400
2013
 
1401
2014
  /**
1402
2015
  * Browser Web Push (RFC 8030) helper.
@@ -1785,6 +2398,18 @@ declare class KnowledgeBase {
1785
2398
  }): Promise<KbArticle[]>;
1786
2399
  }
1787
2400
 
2401
+ /**
2402
+ * ⚠ VERBATIM PORT of `packages/shared/src/auth-machine/project-verdict.ts` (ADR-033 B8 / INV-15).
2403
+ *
2404
+ * The published SDK has ZERO runtime dependencies and cannot import `@sendora/shared`, so the
2405
+ * ProjectGuard is copied here byte-for-byte from the first `export` onwards. The test
2406
+ * `test/project-verdict-parity.test.ts` reads both files and fails on ANY divergence below the
2407
+ * banner, so a fix to the shared verdict that is not mirrored here is red, not silent.
2408
+ * Edit the shared file first, then copy; never edit this one by hand.
2409
+ */
2410
+ type ProjectVerdict = "match" | "mismatch" | "unknown";
2411
+ declare function projectVerdict(expectedProjectId: string | undefined, accessJwt: string | null | undefined): ProjectVerdict;
2412
+
1788
2413
  /**
1789
2414
  * Return the URL if it is http/https; otherwise undefined.
1790
2415
  * Rejects javascript:, data:, vbscript:, and other script-bearing schemes.
@@ -1869,9 +2494,33 @@ declare class SendoraCloud {
1869
2494
  track(eventType: string, properties?: Record<string, unknown>): void;
1870
2495
  page(properties?: Record<string, unknown>): void;
1871
2496
  identify(userId: string, traits?: UserTraits, opts?: IdentifyOptions): void;
1872
- reset(): void;
2497
+ /**
2498
+ * Reset the analytics identity (rotates the anonymous id), feature-flag
2499
+ * context and the chat session. Unchanged since 3.x.
2500
+ *
2501
+ * ⚠ NOT an auth verb on web, and NOT a sign-out: the auth session, its four
2502
+ * stored keys and the held guests are untouched — call `auth.signOut()` to
2503
+ * sign out. (The ADR-033 RESET row describes the React Native SDK, where
2504
+ * `reset()` is the documented logout call; on web it never was, and making
2505
+ * it sign users out would be a behaviour change a MINOR cannot ship —
2506
+ * ADR-023.)
2507
+ *
2508
+ * `opts.includeHeldGuests` (3.24.0) is accepted for API parity with the
2509
+ * native SDKs and is a no-op on auth state here: held guests are released
2510
+ * only by `auth.claimHeldGuest` / `restoreHeldGuest` / `discardHeldGuest`.
2511
+ */
2512
+ reset(opts?: {
2513
+ includeHeldGuests?: boolean;
2514
+ }): void;
2515
+ /**
2516
+ * ADR-033 [2c]: fires when a stored session is WITHHELD (its JWT `project`
2517
+ * claim disagrees with `init({expectedProjectId})`). The SDK has already
2518
+ * stopped attributing analytics to that user in memory; use this to do the
2519
+ * same in your own code. Returns an unsubscribe function.
2520
+ */
2521
+ onIdentityWithheld(listener: () => void): () => void;
1873
2522
  flush(): Promise<void>;
1874
2523
  destroy(): void;
1875
2524
  }
1876
2525
 
1877
- export { AlreadyIdentifiedError, Analytics, type AnonRetirementOutcome, type Audience, type AudienceRule, type AudienceRuleGroup, Audiences, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, Chatbot, Consent, type CreateAudienceInput, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, FeatureFlags, type FlagValue, type IdentifyOptions, type InAppMessage, InAppMessaging, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, SendoraCloud, type SendoraConfig, type SurveyConfig, type SurveyQuestion, Surveys, type TrackEvent, type UnlinkResult, type UpdateAudienceInput, type UserTraits, ValidationError, WebPush, SendoraCloud as default, extractShortcodeFromUrl, safeUrl, sanitizeHtml };
2526
+ export { AMBIGUOUS_HELD_GUEST, AUTH_HELD_SLOT_KEYS, AUTH_STATES, AUTH_WITHHELD_CODES, AlreadyIdentifiedError, Analytics, type AnonRetirementOutcome, type Audience, type AudienceRule, type AudienceRuleGroup, Audiences, Auth, type AuthCredential, AuthError, type AuthErrorKind, type AuthHooks, type AuthSignedInCause, type AuthSignedOutReason, type AuthStateChange, type AuthStatus, type AuthTokens, type AuthUser, type AuthUserUpdatedCause, Chatbot, Consent, type CreateAudienceInput, type Credential, type CredentialCollisionPolicy, CredentialInUseError, type CredentialKind, DEAD_CODES, type DeleteAccountResult, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, FeatureFlags, type FlagValue, type GuestReleasedReason, HELD_GUEST_SLOTS, HeldGuestLimitError, HeldGuestProjectMismatch, type HeldGuestSummary, type IdentifyOptions, type InAppMessage, InAppMessaging, type LinkCollision, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkResult, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type MergeStats, type ProjectVerdict, REFRESH_END_REASONS, REJECTED_IN_WITHHELD, RELEASE_HELD_ON_END_REASONS, type RefreshEndReason, SendoraCloud, type SendoraConfig, type SignInResult, type SocialCredentialProvider, type SurveyConfig, type SurveyQuestion, Surveys, type TrackEvent, type UnlinkResult, type UpdateAudienceInput, type UserTraits, ValidationError, WebPush, type WithheldInfo, SendoraCloud as default, extractShortcodeFromUrl, projectVerdict, safeUrl, sanitizeHtml };