@sendoracloud/sdk-web 3.22.0 → 3.24.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
@@ -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,40 +776,69 @@ 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 and said
796
+ * `expired` (or gave no reason: an older backend, `reuse`,
797
+ * `unknown`). Transient network failures and rate limits
798
+ * do NOT produce this.
799
+ * - `account_deleted` — `deleteAccount()` succeeded.
800
+ * - `revoked` / `evicted` / `deleted` — the server's own verdict from
801
+ * `error.details.endReason` (3.24.0+; `evicted` is
802
+ * reserved and not emitted by the backend yet).
803
+ * ⚠ Consumers MUST default-branch — later values are additive.
536
804
  */
537
- type AuthSignedOutReason = "user" | "session_expired" | "account_deleted";
805
+ type AuthSignedOutReason = "user" | "session_expired" | "account_deleted" | "revoked" | "evicted" | "deleted";
538
806
  /**
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).
807
+ * A single auth-state transition (3.12.0; ADR-033 members added in 3.24.0).
808
+ * Subscribe with `onAuthStateChanged` — one stream covering everything.
809
+ *
810
+ * - `signed_in` — a session was established or replaced. `cause`
811
+ * (3.24.0): `mint` | `sign_in` | `restore` (re-register
812
+ * push) | `link` (the legacy same-`sub` promotion
813
+ * `signUp()` / `loginSocial({link:true})` keep emitting).
814
+ * Absent on the subscribe-time replay and on a cross-tab
815
+ * re-hydrate.
816
+ * - `signed_out` — the local session is gone; `reason` says why.
817
+ * - `device_takeover` — LEGACY: an anonymous row was retired server-side. A
818
+ * 3.24.0 sign-in never requests that, so it fires only
819
+ * when the SERVER reports one (it will not for this SDK).
820
+ * - `deletion_cancelled`— a sign-in cancelled a pending self-service deletion.
821
+ * - `initial` — the settled boot state, exactly once per subscriber,
822
+ * INCLUDING `signed_out`, `unknown` and `withheld`.
823
+ * - `user_updated` — same `sub`, different user object (`link` / `unlink` /
824
+ * `email_verified` / `refresh` / `merge`). Never a
825
+ * `signed_in` — the identity did not change.
826
+ * - `guest_held` — a guest was moved into a held slot (`count` = slots
827
+ * now held).
828
+ * - `guest_released` — a held slot was emptied; `reason` names the exit.
829
+ * ⚠ `dead` = "the server said unavailable when the
830
+ * token was presented", NOT an erasure signal — say
831
+ * "unavailable", never "lost".
832
+ * - `session_withheld` — an in-process transition INTO `withheld` (a refresh
833
+ * answered with a mismatching `project` claim). Never
834
+ * replayed: a hydrate that lands in `withheld` emits
835
+ * `initial{status:'withheld'}` instead.
836
+ * ⚠ Consumers MUST default-branch on `event`.
542
837
  */
543
838
  type AuthStateChange = {
544
839
  event: "signed_in";
545
840
  user: AuthUser;
841
+ cause?: AuthSignedInCause;
546
842
  } | {
547
843
  event: "signed_out";
548
844
  user: null;
@@ -554,6 +850,30 @@ type AuthStateChange = {
554
850
  } | {
555
851
  event: "deletion_cancelled";
556
852
  user: AuthUser | null;
853
+ } | {
854
+ event: "initial";
855
+ status: AuthStatus;
856
+ user: AuthUser | null;
857
+ withheld?: WithheldInfo;
858
+ } | {
859
+ event: "user_updated";
860
+ user: AuthUser;
861
+ cause: AuthUserUpdatedCause;
862
+ } | {
863
+ event: "guest_held";
864
+ userId: string;
865
+ count: number;
866
+ } | {
867
+ event: "guest_released";
868
+ userId: string;
869
+ reason: GuestReleasedReason;
870
+ endReason?: RefreshEndReason;
871
+ } | {
872
+ event: "session_withheld";
873
+ reason: "project_mismatch";
874
+ expectedProjectId: string;
875
+ actualProjectId: string | null;
876
+ userId: string;
557
877
  };
558
878
  type AuthStateListener = (change: AuthStateChange) => void;
559
879
  declare class EmailAlreadyTakenError extends Error {
@@ -563,7 +883,8 @@ declare class EmailAlreadyTakenError extends Error {
563
883
  * `signUp()` was called on a session already signed in with an identity (not
564
884
  * anonymous). Creating a second account would orphan the current one — use
565
885
  * `linkEmailPassword()` / `linkGoogle()` / … (ADR-030) to add the credential
566
- * to THIS account, or sign out first.
886
+ * to THIS account, or sign out first. Since 3.24.0 this is decided LOCALLY from
887
+ * `status()`, with no round-trip.
567
888
  */
568
889
  declare class AlreadyIdentifiedError extends Error {
569
890
  constructor(message?: string);
@@ -599,21 +920,18 @@ declare class CredentialInUseError extends Error {
599
920
  });
600
921
  }
601
922
  /**
602
- * What a sign-in should do when the credential you present already belongs to
603
- * an account (3.14.0).
923
+ * What `loginSocial({ link: true, onCredentialInUse })` does when the credential
924
+ * you present already belongs to another account (3.14.0; re-based on ADR-033
925
+ * in 3.24.0).
604
926
  *
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.
927
+ * - `"adopt"` — fall through to a plain sign-in with the SAME credential: an
928
+ * explicit, consented SWITCH. From a guest the guest is HELD on this device
929
+ * (never deleted); from an identified session nothing is held.
930
+ * - `"reject"` / omitted — throw `CredentialInUseError`; nothing changes.
608
931
  *
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.
932
+ * ⚠ Since 3.24.0 NOTHING here reaches the server as a field: the collision is
933
+ * decided on the link route (409) and the policy is applied client-side.
934
+ * Nothing on the server can delete a guest as a side effect of a sign-in.
617
935
  */
618
936
  type CredentialCollisionPolicy = "adopt" | "reject";
619
937
  /**
@@ -624,6 +942,10 @@ type CredentialCollisionPolicy = "adopt" | "reject";
624
942
  * attempt never changes the local session.** An offline sign-in, a mistyped
625
943
  * OTP, a dismissed passkey sheet and a 429 all leave `getCurrentUser()` and the
626
944
  * stored refresh token exactly as they were.
945
+ *
946
+ * ⚠ ADR-033 deliberately does NOT widen this set: `SESSION_WITHHELD` and
947
+ * `PROJECT_MISMATCH` are `kind: "config"`; the held-guest refusals reuse the
948
+ * existing kinds (see `classifyAuthErrorKind`).
627
949
  */
628
950
  type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "recent_auth_required" | "already_identified" | "cancelled" | "config" | "unknown";
629
951
  declare class AuthError extends Error {
@@ -651,6 +973,31 @@ declare class AuthError extends Error {
651
973
  kind?: AuthErrorKind;
652
974
  });
653
975
  }
976
+ /**
977
+ * T == K (ADR-033 §3.3): every held slot is occupied and the verb would need
978
+ * one more. Thrown by `signInAnonymously({forceNew:true})` at the cap, and by
979
+ * a plain mint in `signed_out` when every slot is ineligible for auto-restore
980
+ * (each stamped `autoRestore:false` by `deleteAccount()`, or project-withheld)
981
+ * — the one boot corner that does not self-heal (§8-Q22). Nothing is mutated;
982
+ * the app must `restoreHeldGuest(id)` or `discardHeldGuest(id)`.
983
+ */
984
+ declare class HeldGuestLimitError extends AuthError {
985
+ readonly heldGuests: number;
986
+ constructor(heldGuests: number, message?: string);
987
+ }
988
+ /**
989
+ * The held slot's `project` claim (recorded at HOLD, or judged on the refresh
990
+ * response when it was unreadable at HOLD) disagrees with
991
+ * `expectedProjectId ?? config.projectId` (ADR-033 §3.5). A CLIENT-SIDE check:
992
+ * the server does not enforce it on refresh. The slot is KEPT, excluded from
993
+ * auto-restore, and still counts toward T.
994
+ */
995
+ declare class HeldGuestProjectMismatch extends AuthError {
996
+ readonly userId: string;
997
+ readonly expectedProjectId: string;
998
+ readonly actualProjectId: string | null;
999
+ constructor(userId: string, expectedProjectId: string, actualProjectId: string | null);
1000
+ }
654
1001
  /** One credential linked to an account (ADR-030 read side, s58.270). */
655
1002
  type LinkedIdentity = {
656
1003
  provider: string;
@@ -673,15 +1020,33 @@ type LinkedIdentitiesResponse = {
673
1020
  identities: LinkedIdentity[];
674
1021
  hasPassword: boolean;
675
1022
  };
1023
+ /** Hooks the parent SDK wires in (`index.ts`). */
1024
+ interface AuthHooks {
1025
+ /** ADR-033 [2c]: null the analytics `userId` IN MEMORY only — the persisted `user_id` is untouched. */
1026
+ onIdentityWithheld?: () => void;
1027
+ }
676
1028
  declare class Auth {
677
1029
  private http;
678
1030
  private storage;
1031
+ private store;
679
1032
  private analytics;
680
1033
  private flags;
681
1034
  private chatbot;
1035
+ private hooks;
1036
+ /** ADR-033 [2c]: the opt-in project guard. Validated by `index.ts` (UUID, equal to `projectId` when both set). */
1037
+ private readonly expectedProjectId;
1038
+ /** The project a held slot is judged against on RESTORE: `expectedProjectId ?? config.projectId`. */
1039
+ private readonly slotProjectAnchor;
1040
+ /** §8-Q1: restore the newest eligible held guest before minting (default ON). */
1041
+ private readonly autoRestoreHeldGuest;
1042
+ private state;
1043
+ /** The installed session, or null in `unknown` / `signed_out` / `withheld`. */
1044
+ private record;
682
1045
  private user;
683
1046
  private accessToken;
684
1047
  private accessExpiresAt;
1048
+ /** `withheld` only: what was refused, and the refresh token the discard CAS keys on. Never persisted. */
1049
+ private withheld;
685
1050
  /**
686
1051
  * Set once we have PROOF the clock runs fast: a token the server minted
687
1052
  * SECONDS AGO already reads past its own `exp`. A fresh token is never
@@ -699,6 +1064,14 @@ declare class Auth {
699
1064
  private clockSkewConfirmed;
700
1065
  private inflight;
701
1066
  private refreshInflight;
1067
+ /**
1068
+ * True while an identity-changing attempt (mint / sign-in / link / claim /
1069
+ * restore) is on the wire. The proactive cron does not START a refresh while
1070
+ * it is set (§3.3 scheduling rule 2); the attempt itself awaits any in-flight
1071
+ * refresh at start AND at commit (rule 1), which is what makes the sid-CAS a
1072
+ * backstop rather than the first line.
1073
+ */
1074
+ private attemptInFlight;
702
1075
  /**
703
1076
  * Wave 63 — refresh failure back-pressure (parity with sdk-react-
704
1077
  * native 1.1.0 Wave 62 fix). http.ts already times out at 10s; this
@@ -713,31 +1086,38 @@ declare class Auth {
713
1086
  private visibilityResetUnsub;
714
1087
  private proactiveRefreshTimer;
715
1088
  private proactiveRefreshCleanup;
1089
+ /** 3.24.0 (e): another tab wrote one of our auth keys → re-run HYDRATE through commit. */
1090
+ private storageEventUnsub;
1091
+ private rehydrateScheduled;
716
1092
  private takeoverListeners;
717
1093
  private lastTakeover;
718
1094
  private deletionCancelledListeners;
719
1095
  private lastDeletionCancelled;
720
1096
  private lastAnonRetirement;
721
1097
  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;
1098
+ private identityWithheldListeners;
732
1099
  /**
733
1100
  * True once the constructor has finished restoring from storage.
734
1101
  * Distinguishes "no session restored YET" from "genuinely signed out" for
735
1102
  * `onAuthStateChanged`'s replay-on-subscribe.
736
1103
  */
737
1104
  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. */
1105
+ constructor(config: SendoraConfig, http: HttpClient, storage: Storage, analytics: Analytics, flags: FeatureFlags, chatbot: Chatbot, hooks?: AuthHooks);
1106
+ /**
1107
+ * The settled client state — a closed enum. `unknown` ≠ `signed_out` and
1108
+ * `withheld` ≠ `signed_out`; this is the ONLY call that tells them apart
1109
+ * (`getCurrentUser()` is null and `isReady()` is true in all three).
1110
+ * ⚠ Default-branch: later values are additive.
1111
+ */
1112
+ status(): AuthStatus;
1113
+ /** Resolves after hydrate with the settled status — never waits on the network. May be `unknown` or `withheld`. */
1114
+ authStateReady(): Promise<AuthStatus>;
1115
+ /** Returns the cached current user or null if signed out (also null in `unknown` and `withheld`). */
740
1116
  getCurrentUser(): AuthUser | null;
1117
+ /** Alias of `getCurrentUser()` (parity with the native SDKs' synchronous verified-principal accessor). */
1118
+ getUserSync(): AuthUser | null;
1119
+ /** True once storage has been read. Always true after `init()` on web (hydrate is synchronous). */
1120
+ isReady(): boolean;
741
1121
  /**
742
1122
  * Register a callback fired when the backend retires an anon
743
1123
  * `user_id` during a signin on this tab. Use it to delete the
@@ -745,20 +1125,22 @@ declare class Auth {
745
1125
  * records are already cleaned up server-side. Returns an
746
1126
  * unsubscribe function.
747
1127
  *
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`.
1128
+ * ⚠ LEGACY after ADR-033 (3.24.0). This SDK never sends the guest hint
1129
+ * that triggers a server-side retirement, so for a 3.24.0 sign-in this
1130
+ * listener NEVER fires: the device's guest is HELD on this device instead
1131
+ * (`onAuthStateChanged` → `guest_held`, `getHeldGuests()`). It stays
1132
+ * registered and fires only when the SERVER reports a retirement, which
1133
+ * only a pre-3.24.0 sign-in can request.
752
1134
  *
753
1135
  * ⚠ A takeover means the anonymous account on this device was retired —
754
1136
  * 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.
1137
+ * SUCCESSFULLY. This listener is the only client-side signal for THAT
1138
+ * (legacy) path. On the native SDKs the same applied to the Game Center /
1139
+ * Play Games sign-ins, where an already-owned player identity was adopted.
759
1140
  *
760
1141
  * Local-only — survives webhook receiver downtime. For server-pipeline
761
- * cleanup also subscribe to the `auth.device_takeover` webhook.
1142
+ * cleanup also subscribe to the `auth.device_takeover` webhook (legacy
1143
+ * SDK sign-ins only) and `auth.user_merged` (the explicit `claimHeldGuest()`).
762
1144
  */
763
1145
  onDeviceTakeover(listener: DeviceTakeoverListener): () => void;
764
1146
  /**
@@ -776,26 +1158,45 @@ declare class Auth {
776
1158
  * rejected the stored refresh token) used to emit no signal whatsoever, so
777
1159
  * an app could not distinguish it from a deliberate sign-out and only
778
1160
  * noticed when `getAccessToken()` began returning null. That transition is
779
- * now `{ event: "signed_out", reason: "session_expired" }`.
1161
+ * now `{ event: "signed_out", reason: "session_expired" }` (or the server's
1162
+ * own `revoked` / `deleted` since 3.24.0).
780
1163
  *
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.
1164
+ * Replay on subscribe (3.24.0): `initial{status, user, withheld?}` is emitted
1165
+ * exactly once per subscriber, INCLUDING `signed_out`, `unknown` and
1166
+ * `withheld`; when a session is installed the pre-3.24.0 `signed_in{user}`
1167
+ * replay follows it (kept for ADR-023 — nothing a 3.12.0 subscriber relied on
1168
+ * is removed; it carries no `cause`).
784
1169
  *
785
1170
  * A failed sign-in emits NOTHING: the session is unchanged, so there is no
786
1171
  * transition to report — the rejected promise is the whole story.
787
1172
  *
1173
+ * ⚠ This is a TRANSITION stream, not a health or connectivity signal (3.23.0
1174
+ * doc fix, parity RN 1.40.0). A transient failure of the background token
1175
+ * refresh — offline, timeout, HTTP 429, 5xx, an unparseable response — emits
1176
+ * nothing: the stored refresh token is kept and the refresh retried with
1177
+ * backoff, so no state changed. "No event" means "no confirmed transition",
1178
+ * never "the session is fine". Only a server-REJECTED refresh token produces
1179
+ * `signed_out(session_expired)`. For liveness read `getAccessToken()` (null =
1180
+ * no usable token right now).
1181
+ *
788
1182
  * Listeners are best-effort; a throwing listener is swallowed and never
789
1183
  * affects auth state. Returns an unsubscribe function.
790
1184
  */
791
1185
  onAuthStateChanged(listener: AuthStateListener): () => void;
1186
+ /**
1187
+ * ADR-033 [2c] analytics quarantine hook: fires when a stored session is
1188
+ * WITHHELD (hydrate or refresh under a mismatching `expectedProjectId`), so
1189
+ * the parent SDK stops attributing events to that user IN MEMORY. The
1190
+ * persisted `user_id` is untouched. Returns an unsubscribe function.
1191
+ */
1192
+ onIdentityWithheld(listener: () => void): () => void;
792
1193
  /** Fan a state change out to subscribers. Never throws. */
793
1194
  private emitAuthState;
794
1195
  /**
795
1196
  * Internal — called from every identified-signin path with the
796
1197
  * `retiredAnonUserId` parsed off the backend response (or off
797
1198
  * the SSO redirect fragment). Snapshot + dispatch synchronously
798
- * since handlers run on the same event loop turn as persist().
1199
+ * since handlers run on the same event loop turn as the install.
799
1200
  *
800
1201
  * Validates UUID shape before dispatch. The value comes from a URL
801
1202
  * fragment / response body that — under XSS — could be controlled.
@@ -817,46 +1218,72 @@ declare class Auth {
817
1218
  * What the last sign-in did with this device's guest account, or null if no
818
1219
  * sign-in has happened this session (s58.278).
819
1220
  *
820
- * The one worth acting on is `"preserved"`: a guest account was presented,
821
- * was NOT retired, and is therefore still alive server-side — so "recover
822
- * your other account" is a real offer rather than a guess. Cleared on signOut.
1221
+ * ⚠ Since 3.24.0 a sign-in from this SDK never presents the guest, so the
1222
+ * server's answer is always `"none"` — the guest is HELD on this device and
1223
+ * answerable synchronously via `getHeldGuests()`. Kept for apps reading it;
1224
+ * recorded ONLY when the server states it. Cleared on signOut.
823
1225
  */
824
1226
  getLastAnonRetirement(): AnonRetirementOutcome | null;
825
1227
  private fireDeletionCancelled;
826
1228
  /**
827
- * Returns the stored anon refresh token iff the local subject is
828
- * currently anonymous. Used by every identified-session-mint path
829
- * so the backend can run device-takeover (s58.111+).
1229
+ * Record what the SERVER said about a sign-in response, after the install
1230
+ * commit: the guest outcome (only when stated — an older backend omits it,
1231
+ * which is not `none`), a cancelled deletion, and the legacy retirement id.
830
1232
  */
831
- private takeoverHint;
1233
+ private recordServerSignals;
832
1234
  /**
833
- * Returns a VALID (non-expired) access token, or null.
1235
+ * Apply one transaction, or drop it. Returns `true` when applied.
834
1236
  *
835
- * Hard contract (parity with sdk-react-native / sdk-ios / sdk-android
836
- * and the industry token-client pattern — Supabase
837
- * `_callRefreshToken`, Auth0 `getTokenSilently`, Firebase
838
- * `getIdToken`): NEVER returns a token past its `exp`. Callers attach
839
- * the result as a Bearer to arbitrary resource servers — including
840
- * third-party backends (PostgREST/Supabase) that have NO knowledge of
841
- * Sendora's own refresh grace window — so a token must be either
842
- * fresh or absent.
1237
+ * Everything that mutates identity happens here and nowhere else: the
1238
+ * in-memory slot (`state` / `record` / `user` / tokens / `withheld`), the
1239
+ * storage keys (via `SessionStore`), the proactive cron, the analytics /
1240
+ * flags / chatbot hooks, and the emitted `onAuthStateChanged` event. Public
1241
+ * methods only build proposals.
843
1242
  *
844
- * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
845
- * skew before exp) → return it. Fast path, no I/O.
846
- * 2. No cached token AND no refresh token in storage → null.
847
- * 3. Expired/near-expiry but a refresh chain exists:
848
- * a. A refresh is already inflight → AWAIT it and return the
849
- * fresh token. Single-flight: concurrent callers coalesce
850
- * onto one `/token/refresh`, nobody is handed a stale token.
851
- * The inflight fetch is AbortController-bounded (http.ts caps
852
- * `/auth-service/*` at 10s) so awaiting never hangs — that
853
- * bounded fetch, not the old stale-token return, is the real
854
- * cold-launch hang fix.
855
- * b. In backoff cooldown after a failed refresh → null (honest
856
- * "no usable token", not a provably-expired one; the
857
- * `visibilitychange` hook clears the cooldown on tab focus).
858
- * c. Otherwise → fire the refresh and await it.
859
- */
1243
+ * CAS (INV-2): an identity commit names the `sid` it was derived from
1244
+ * (`expectSid`); a token commit names the refresh token it sent
1245
+ * (`expectRefresh`). A result that no longer matches the current session is
1246
+ * DROPPED here — the caller then revokes its server copy (sign-in) or simply
1247
+ * returns (refresh), and the device is left exactly as found.
1248
+ */
1249
+ private commit;
1250
+ private commitInstall;
1251
+ private commitClear;
1252
+ private commitWithhold;
1253
+ /**
1254
+ * Read the disk and commit the state it describes. `boot` = the
1255
+ * constructor (emits nothing; subscribers get `initial`). `storage_event` =
1256
+ * another tab changed our keys (3.24.0 (e)); the same rules, applied as
1257
+ * transitions from the current in-memory state, CAS-safe because every
1258
+ * branch is a commit.
1259
+ */
1260
+ private hydrateFromDisk;
1261
+ /**
1262
+ * 3.24.0 (e) — the Web Locks cross-tab mutex is GONE (Supabase removed the
1263
+ * same lock in 2.107.0 after seven failure classes). Cross-tab coherence is
1264
+ * now: the token CAS (a rotation another tab performed is on disk and the
1265
+ * refresh sends what is on disk) + this re-hydrate on the `storage` event,
1266
+ * which fires in every tab EXCEPT the writer. Memory-mode has no disk and no
1267
+ * other tab can see it, so nothing to listen to.
1268
+ */
1269
+ private startStorageEventRehydrate;
1270
+ /** Detach the cross-tab listener (tests / `destroy()`). */
1271
+ stopStorageEventRehydrate(): void;
1272
+ /** The server session family id of the current session — the identity CAS key. `null` = no session (or unreadable). */
1273
+ private currentSid;
1274
+ /** The ProjectGuard (INV-15) — `match` whenever `expectedProjectId` is unset. */
1275
+ private judge;
1276
+ /** The JWT `project` claim as a string or explicit null; `null` when unreadable. */
1277
+ private projectClaim;
1278
+ private withheldInfo;
1279
+ private heldCount;
1280
+ /** The current guest as a held slot: its POST-refresh tokens and its `project` claim at HOLD. */
1281
+ private buildHeld;
1282
+ /** Fire-and-forget server-side revoke of a refresh token; the device state never waits on it. */
1283
+ private revokeFireAndForget;
1284
+ /** B15 — tell the server a guest was held. Silent 200 either way; the pending flag clears on success. */
1285
+ private reportHeld;
1286
+ private withheldError;
860
1287
  /**
861
1288
  * The cached-token guard, and the only place the two deadlines are combined.
862
1289
  * Both must agree: the locally-tracked deadline (immune to a constantly-wrong
@@ -892,65 +1319,203 @@ declare class Auth {
892
1319
  * reads live, guard holds) or genuinely fast (still dead, guard releases).
893
1320
  */
894
1321
  private refreshAndReconcile;
1322
+ /**
1323
+ * Returns a VALID (non-expired) access token, or null.
1324
+ *
1325
+ * Hard contract (parity with sdk-react-native / sdk-ios / sdk-android
1326
+ * and the industry token-client pattern — Supabase
1327
+ * `_callRefreshToken`, Auth0 `getTokenSilently`, Firebase
1328
+ * `getIdToken`): NEVER returns a token past its `exp`. Callers attach
1329
+ * the result as a Bearer to arbitrary resource servers — including
1330
+ * third-party backends (PostgREST/Supabase) that have NO knowledge of
1331
+ * Sendora's own refresh grace window — so a token must be either
1332
+ * fresh or absent.
1333
+ *
1334
+ * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
1335
+ * skew before exp) → return it. Fast path, no I/O.
1336
+ * 2. No cached token AND no refresh token in storage → null.
1337
+ * 3. Expired/near-expiry but a refresh chain exists:
1338
+ * a. A refresh is already inflight → AWAIT it and return the
1339
+ * fresh token. Single-flight: concurrent callers coalesce
1340
+ * onto one `/token/refresh`, nobody is handed a stale token.
1341
+ * b. In backoff cooldown after a failed refresh → null.
1342
+ * c. Otherwise → fire the refresh and await it.
1343
+ *
1344
+ * ⚠ `withheld` (3.24.0): always null — no refresh, no cron, no gap-fill.
1345
+ * The withheld record is not this project's session.
1346
+ */
895
1347
  getAccessToken(options?: {
896
1348
  forceRefresh?: boolean;
897
1349
  }): Promise<string | null>;
898
- /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
1350
+ /** Sync read — returns whatever's cached even if expired (null in `withheld`). Prefer the async variant. */
899
1351
  getAccessTokenSync(): string | null;
1352
+ /**
1353
+ * The stored refresh token, or null (always null in `withheld`). Kept for
1354
+ * integrators that journal it; since 3.24.0 the SDK journals a replaced guest
1355
+ * itself in a held slot (`getHeldGuests()`), so most callers no longer need it.
1356
+ */
1357
+ getRefreshToken(): string | null;
1358
+ /**
1359
+ * Hot-path token refresh. Single-flight via `refreshInflight` so a
1360
+ * burst of in-flight reads doesn't fan out into duplicate /refresh
1361
+ * round-trips. Returns null on any failure so callers can decide
1362
+ * whether to surface re-auth or proceed anonymously.
1363
+ *
1364
+ * Dead-token signal = `INVALID_REFRESH_TOKEN` ONLY (`isDeadRefreshError`);
1365
+ * a rate limit or a generic 401 is transient. The verdict is applied
1366
+ * through commit() with the sent token as the CAS key, so a refresh that
1367
+ * lands after a sign-out / switch can neither resurrect a session nor
1368
+ * wipe a newer one (§3.3 rows 20–26).
1369
+ *
1370
+ * ⚠ 3.24.0: no Web Locks. Each tab single-flights its own refresh; a sibling
1371
+ * tab's rotation reaches this tab through the `storage` event re-hydrate,
1372
+ * and the server's 60 s rotation grace covers the race where both tabs
1373
+ * present the same parent.
1374
+ */
1375
+ private refreshAccessToken;
1376
+ private performRefresh;
1377
+ /**
1378
+ * Present a HELD slot's refresh token (restore / claim / auto-restore — the
1379
+ * ONLY places a held token is ever refreshed; the cron never touches a slot).
1380
+ * Persists the rotation INTO the slot before anything else happens with it
1381
+ * (§3.3 "persist rotations before the next step"), via a per-slot CAS.
1382
+ *
1383
+ * Dead verdict handling is the §3.3 REFRESH_DEAD-held row: release the slot
1384
+ * ONLY for `endReason ∈ deleted | revoked | expired`; `reuse` or an ABSENT
1385
+ * `endReason` KEEPS the slot and surfaces a retryable `RESTORE_FAILED` — a
1386
+ * reuse verdict on a preserved-family guest does not prove it is gone.
1387
+ */
1388
+ private refreshHeld;
1389
+ /**
1390
+ * `unknown` → settled. The queue of INV-5: every session-installing verb
1391
+ * (MINT, SIGN-IN, LINK, RESTORE) waits here until the single gap-fill
1392
+ * refresh answers — success installs, `INVALID_REFRESH_TOKEN` clears to
1393
+ * `signed_out`; a transient failure retries after the backoff window (so an
1394
+ * offline cold start resolves when connectivity returns). Never executes the
1395
+ * verb's network call in `unknown`.
1396
+ */
1397
+ private settle;
1398
+ /**
1399
+ * Wave 63 — arm exponential backoff after a transient refresh
1400
+ * failure. Caps at 30s. getAccessToken consults
1401
+ * `refreshCooldownUntil` and returns null during the window
1402
+ * instead of queueing a fresh fetch into a network we already know
1403
+ * is stalled.
1404
+ */
1405
+ private armBackoff;
1406
+ /**
1407
+ * Wave 63 — clears backoff state. Called from the visibilitychange
1408
+ * listener so a user returning to a previously-backgrounded tab
1409
+ * gets an immediate fresh refresh attempt instead of waiting on
1410
+ * the cooldown timer.
1411
+ */
1412
+ private resetRefreshState;
1413
+ /**
1414
+ * Wave 63 — start visibilitychange listener. Browser equivalent of
1415
+ * RN AppState 'active'. Armed by every install commit alongside
1416
+ * `startProactiveRefreshCron`. Idempotent.
1417
+ */
1418
+ private startVisibilityRefreshReset;
1419
+ private stopVisibilityRefreshReset;
1420
+ /**
1421
+ * Proactive refresh (s58.72). Fires when access-token age crosses
1422
+ * ~80% of TTL — refresh becomes a scheduled event, never a 401-
1423
+ * driven race. Armed by every install commit (incl. hydrate — the Word
1424
+ * Hurdle cold-launch fix) and by the `unknown` commit (the single gap-fill),
1425
+ * stopped by every clear / withhold. Pauses while an identity attempt is on
1426
+ * the wire (§3.3 scheduling rule 2).
1427
+ */
1428
+ private startProactiveRefreshCron;
1429
+ private stopProactiveRefreshCron;
1430
+ private serialize;
1431
+ /**
1432
+ * Build the Authorization header for end-user-authenticated routes.
1433
+ *
1434
+ * Awaits `getAccessToken()` so a cold-start (re-hydrated from storage
1435
+ * with no cached access token) or an expired token transparently
1436
+ * refreshes via the stored refresh token before the call. Throws if there's
1437
+ * genuinely no usable session; in `withheld` the refusal is
1438
+ * `SESSION_WITHHELD` (no network — §3.3 row 12).
1439
+ */
1440
+ private bearerHeaders;
900
1441
  /**
901
1442
  * Mint (or return) an anonymous session.
902
1443
  *
903
- * Reuses the existing anonymous user when there already is one — the same
904
- * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
905
- * that calls this defensively on every page load (the most natural thing to
906
- * write) mints a fresh `user_id` each time, and `persist()` overwrites the
907
- * stored refresh token — the previous anonymous account's only durable
908
- * handle — with no takeover, no webhook and no state event. That is the same
909
- * silent progress loss as a failed sign-in wiping the session, except it
910
- * happens on a *healthy* network.
1444
+ * Reuses the existing session when there is one — the same short-circuit
1445
+ * Firebase's `signInAnonymously` performs, covering an IDENTIFIED session
1446
+ * too (3.23.0, RopoLabs A2): a defensive call on every page load returns
1447
+ * the current user whatever its kind; read `user.isAnonymous` to know which.
911
1448
  *
912
- * The guard is `cached user` AND `refresh token in storage`, both read INSIDE
913
- * the serialized op: the refresh token is what actually keeps the account
914
- * reachable (a cached user without one must still mint), and reading after
915
- * the chain has drained is what collapses concurrent calls onto a single
916
- * mint — the second call runs only once the first has persisted, so it takes
917
- * the reuse branch instead of minting a sibling.
1449
+ * 3.24.0 (ADR-033): MINT executes ONLY in `signed_out` (INV-5). In `unknown`
1450
+ * it waits for the gap-fill refresh to settle the status; in `withheld` it
1451
+ * rejects `SESSION_WITHHELD` (opt-in only — unlike the no-op over identified).
1452
+ * In `signed_out` a HELD guest is restored before anything is minted
1453
+ * (`autoRestoreHeldGuest`, default ON, §8-Q1) — visibly, as
1454
+ * `signed_in{cause:'restore'}` + `guest_released{restored}`; a dead held
1455
+ * guest is released (`guest_released{dead}`) and the next one tried, and a
1456
+ * fresh mint happens in the same call when none is left. The capacity bound
1457
+ * T = |held| + (current anonymous ? 1 : 0) ≤ 5 is enforced HERE: `forceNew`
1458
+ * at the cap throws `HeldGuestLimitError`; a plain mint at the cap restores.
918
1459
  *
919
- * Pass `forceNew: true` to deliberately abandon the current anonymous
920
- * session and mint a separate one.
1460
+ * Pass `forceNew: true` to deliberately abandon the current session —
1461
+ * whichever kind — and mint a separate anonymous one. A current GUEST is
1462
+ * HELD (never orphaned); a current identified session is replaced and its
1463
+ * refresh token revoked server-side.
1464
+ *
1465
+ * Every mint carries a per-attempt `mintKey` (B10): a retried mint after a
1466
+ * lost response returns the SAME guest instead of an orphan.
921
1467
  */
922
1468
  signInAnonymously(opts?: {
923
1469
  name?: string;
924
1470
  metadata?: Record<string, unknown>;
925
1471
  forceNew?: boolean;
926
1472
  }): Promise<AuthUser>;
927
- /**
928
- * Create an email/password account. Upgrade-first flow (matches
929
- * Firebase `linkWithCredential` / Auth0 anonymous-merge): if a
930
- * refresh token is present, attempt /upgrade first — backend
931
- * preserves user.id when the bound user is anonymous. On
932
- * non-anonymous-bound refresh / invalid-or-expired refresh, fall
933
- * back to fresh /signup. We don't gate on the cached
934
- * `this.user.isAnonymous` boolean — refresh-token presence is the
935
- * authoritative signal (cached user can be stale during cold
936
- * start / fast-refresh). Backend has source of truth.
937
- */
938
- signUp(email: string, password: string, opts?: {
939
- name?: string;
940
- metadata?: Record<string, unknown>;
941
- }): Promise<AuthUser>;
1473
+ /** Alias of `signInAnonymously()` — "make sure I have a session". */
1474
+ ensureSession(): Promise<AuthUser>;
1475
+ /** A slot is ineligible for auto-restore when its recorded project is a string that disagrees with the anchor (null = judged later). */
1476
+ private slotProjectMismatches;
1477
+ /** The fresh mint (row 6): `POST /anonymous` (+ `mintKey`) → ProjectGuard on the response → install. */
1478
+ private mintFresh;
1479
+ /**
1480
+ * ProjectGuard on a session minted by THIS process under THIS key (mint /
1481
+ * sign-in / promotion response): a mismatch installs nothing, revokes the
1482
+ * minted refresh fire-and-forget and rejects `PROJECT_MISMATCH` — the
1483
+ * wrong-key build fails loudly at first launch, never a relaunch lockout
1484
+ * loop (§8-Q14). An unreadable claim on a FRESH response is treated as a
1485
+ * mismatch here (the server emits it ALWAYS since s58.276).
1486
+ */
1487
+ private guardMinted;
1488
+ /**
1489
+ * The one switch path every credentialed sign-in runs through. The request
1490
+ * carries NO guest hint (INV-3). On success the previous GUEST (if any) is
1491
+ * HELD with its post-refresh tokens, a previous IDENTIFIED session with a
1492
+ * different sub has its refresh revoked (§8-Q12), and the new record is
1493
+ * installed through commit() keyed on the `sid` the attempt started from.
1494
+ * `mfaRequired` leaves the state untouched and returns the challenge.
1495
+ */
1496
+ private performSwitch;
942
1497
  /**
943
1498
  * Sign in with email + password. When the account has confirmed
944
1499
  * MFA, returns a `mfaRequired` discriminator instead of the user
945
1500
  * — caller must then call `challengeMfa(challengeToken, code)`
946
1501
  * with the TOTP / recovery code to mint a real session. Pre-MFA
947
1502
  * accounts get a normal `AuthUser` back.
1503
+ *
1504
+ * 3.24.0: a guest on this device is HELD, never retired (`guest_held`
1505
+ * then ONE `signed_in{cause:'sign_in'}`, no `signed_out`).
948
1506
  */
949
1507
  signIn(email: string, password: string): Promise<AuthUser | {
950
1508
  mfaRequired: true;
951
1509
  mfaChallengeToken: string;
952
1510
  userId: string;
953
1511
  }>;
1512
+ /**
1513
+ * Exchange the `mfaChallengeToken` from `signIn` for a real
1514
+ * session by submitting a 6-digit TOTP code or a 17-char recovery
1515
+ * code from the user's authenticator app. A wrong or expired code
1516
+ * leaves the current session in place so the user can retry.
1517
+ */
1518
+ challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
954
1519
  /**
955
1520
  * Verify an IdP-issued credential (authorization code OR id_token)
956
1521
  * and mint a Sendora session. Customer's app handles the IdP
@@ -964,13 +1529,18 @@ declare class Auth {
964
1529
  * by design — see backend service.ts)
965
1530
  * - `idToken` (Apple-native flow)
966
1531
  *
967
- * Apple's first-time prompt also yields name fields — pass via
968
- * `appleName: { firstName, lastName }`. Apple only sends them
969
- * once; re-sign-ins will return null name from the IdP.
1532
+ * `link: true` (ADR-025 → ADR-033): in `anonymous` AND `identified` the
1533
+ * call goes to `/me/link/social` on the current Bearer and ATTACHES or
1534
+ * errors — it never switches (INV-14). From a guest that is the same-`sub`
1535
+ * promotion (ONE `signed_in{cause:'link'}`); over an identified account a
1536
+ * new attach emits `user_updated{cause:'link'}`, the same identity is a quiet
1537
+ * 200 (`alreadyLinked`, no event, no revoke), a credential owned elsewhere
1538
+ * throws `CredentialInUseError` (or switches when `onCredentialInUse:
1539
+ * "adopt"` — the guest is then HELD), and a stale session throws
1540
+ * `RECENT_AUTH_REQUIRED` (kind `recent_auth_required`). In `signed_out` it
1541
+ * is a plain sign-in; in `unknown` it waits for the status to settle.
970
1542
  *
971
- * Returns the AuthUser on success. Throws AuthError on backend
972
- * refusal — most commonly the verified-email gate (Twitter,
973
- * unverified Discord, etc).
1543
+ * Without `link`, this is a plain SWITCH: the device's guest is HELD.
974
1544
  */
975
1545
  loginSocial(input: {
976
1546
  provider: "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
@@ -982,23 +1552,12 @@ declare class Auth {
982
1552
  firstName?: string;
983
1553
  lastName?: string;
984
1554
  };
985
- /**
986
- * ADR-025 link-in-place opt-in. When anonymous and `link: true`, an
987
- * anon→social upgrade KEEPS the same user id (sub) — promoted in place
988
- * (like Firebase linkWithCredential) instead of a device-takeover that
989
- * mints a new id. No effect off-anon or on a collision (identity already
990
- * owned → falls back to takeover/merge).
991
- */
1555
+ /** Client ROUTING hint (ADR-033 §4): anonymous | identified → `/me/link/social`; signed_out → plain sign-in; unknown → wait. Never sent to the server. */
992
1556
  link?: boolean;
993
- /**
994
- * What to do if this social identity (or its verified email) already
995
- * belongs to an account. **Omit it** — the default adopts when nothing
996
- * would be destroyed and refuses when a live anonymous session would be
997
- * deleted. See {@link CredentialCollisionPolicy}.
998
- */
1557
+ /** See {@link CredentialCollisionPolicy}. Applied client-side on a 409 from the link route; never sent to the server. */
999
1558
  onCredentialInUse?: CredentialCollisionPolicy;
1000
1559
  }): Promise<AuthUser>;
1001
- /** Convenience: Google authorization-code login. `link` = ADR-025 keep-the-sub on an anon upgrade. */
1560
+ /** Convenience: Google authorization-code login. `link` = attach to the current account (see `loginSocial`). */
1002
1561
  signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
1003
1562
  /** Convenience: GitHub authorization-code login. */
1004
1563
  signInWithGitHub(code: string, redirectUri: string): Promise<AuthUser>;
@@ -1016,7 +1575,7 @@ declare class Auth {
1016
1575
  firstName?: string;
1017
1576
  lastName?: string;
1018
1577
  };
1019
- /** ADR-025: keep the same user id on an anon→Apple upgrade. See `loginSocial`. */
1578
+ /** Attach to the current account instead of switching. See `loginSocial`. */
1020
1579
  link?: boolean;
1021
1580
  }): Promise<AuthUser>;
1022
1581
  /** Convenience: Microsoft Azure AD authorization-code login. */
@@ -1025,6 +1584,63 @@ declare class Auth {
1025
1584
  signInWithLinkedIn(code: string, redirectUri: string): Promise<AuthUser>;
1026
1585
  /** Convenience: Facebook Graph login. Refuses if email permission not granted. */
1027
1586
  signInWithFacebook(code: string, redirectUri: string): Promise<AuthUser>;
1587
+ /** Convenience: Discord OAuth2. Refuses if `verified !== true`. */
1588
+ signInWithDiscord(code: string, redirectUri: string): Promise<AuthUser>;
1589
+ /**
1590
+ * Typed sign-in (ADR-033 §3.5) — a plain SWITCH for every credential kind.
1591
+ * `previous` tells you what it replaced; `previous.held === true` means the
1592
+ * device's guest is in a held slot (`getHeldGuests()`), `previous.isAnonymous
1593
+ * === false` means an identified → identified switch (reset local save state).
1594
+ * `mfa_required` carries a `verify(code)` that completes the challenge.
1595
+ */
1596
+ signInWith(credential: AuthCredential): Promise<SignInResult>;
1597
+ /**
1598
+ * The one link path. Bearer `POST /me/link/*`; outcomes per the table:
1599
+ * - 200 `upgraded` (caller was anonymous) → ProjectGuard → install the
1600
+ * rotated pair, same `sub`. `legacyMode` decides the event: the legacy-
1601
+ * signature methods keep ONE `signed_in{cause:'link'}` (§8-Q18); the typed
1602
+ * verbs emit `user_updated{cause:'link'}`. Never both.
1603
+ * - 200 `alreadyLinked` → nothing: no event, no write, no revoke (B13).
1604
+ * - 200 attached (identified, no rotation) → `user_updated{cause:'link'}`.
1605
+ * - 409 → `credential_in_use{provider, collision}`, nothing mutated (B14).
1606
+ * - 403 `RECENT_AUTH_REQUIRED` → `recent_auth_required`, nothing mutated.
1607
+ */
1608
+ private performLink;
1609
+ /**
1610
+ * Typed link (ADR-033 §3.5): attach a credential to the CURRENT account —
1611
+ * from a guest this is the same-`sub` promotion (`user_updated{cause:'link'}`,
1612
+ * `upgraded: true`); from an identified account a new attach, a quiet
1613
+ * `alreadyLinked`, a typed `credential_in_use` (with `switchAccount()` bound
1614
+ * to this exact credential and `keepCurrent()`), or `recent_auth_required`.
1615
+ * In `signed_out` there is nothing to attach to, so it is a plain sign-in; in
1616
+ * `unknown` it waits for the status to settle. Linkable kinds: password,
1617
+ * the social providers, gameCenter, playGames.
1618
+ */
1619
+ link(credential: AuthCredential): Promise<LinkResult>;
1620
+ /** `signInWith()` for callers already inside the serialize chain (link() in signed_out). */
1621
+ private signInWithUnserialized;
1622
+ /**
1623
+ * Create an email/password account.
1624
+ *
1625
+ * 3.24.0 (ADR-033 INV-4) — routed by `status()`, locally:
1626
+ * - `anonymous` → `POST /me/link/email` on the current Bearer: the guest
1627
+ * is promoted IN PLACE (same `sub`), ONE
1628
+ * `signed_in{cause:'link'}`. A taken email throws
1629
+ * `EmailAlreadyTakenError` with nothing mutated.
1630
+ * - `signed_out` → `POST /signup` (a brand-new account).
1631
+ * - `identified` → `AlreadyIdentifiedError`, with NO round-trip.
1632
+ * - `unknown` → waits for the status to settle, then routes.
1633
+ * - `withheld` → `SESSION_WITHHELD`.
1634
+ * `/upgrade` is never called; its private dead-token list is gone with it
1635
+ * (a generic 401 there used to wipe the guest).
1636
+ *
1637
+ * ⚠ `opts.name` / `opts.metadata` reach the server only on the `/signup`
1638
+ * path — the link route's schema carries exactly `email` + `password`.
1639
+ */
1640
+ signUp(email: string, password: string, opts?: {
1641
+ name?: string;
1642
+ metadata?: Record<string, unknown>;
1643
+ }): Promise<AuthUser>;
1028
1644
  /** Link email + password to the current account (sub preserved). */
1029
1645
  linkEmailPassword(email: string, password: string): Promise<AuthUser>;
1030
1646
  /** Link an OAuth social identity (verified idToken or authorization code) to the current account. */
@@ -1055,15 +1671,122 @@ declare class Auth {
1055
1671
  lastName?: string;
1056
1672
  };
1057
1673
  }): Promise<AuthUser>;
1674
+ /** The `link*()` family: typed `link()` surfaced through the pre-3.24.0 throw-on-refusal contract. */
1675
+ private linkLegacy;
1676
+ /**
1677
+ * Every guest held on this device, newest first. Never exposes tokens.
1678
+ * Best-effort: `dead` is observed ONLY when a held token is presented
1679
+ * (restore / claim / discard), so a guest the server has since deleted may
1680
+ * still be listed. `autoRestore:false` = stamped by `deleteAccount()`; only
1681
+ * an explicit `restoreHeldGuest(id)` revives it. There is NO expiry: neither
1682
+ * the SDK nor the server releases a held guest by age (s58.316).
1683
+ */
1684
+ getHeldGuests(): HeldGuestSummary[];
1685
+ /** The newest held guest, or null. */
1686
+ getHeldGuest(): HeldGuestSummary | null;
1687
+ /**
1688
+ * Resolve the slot a held-guest verb acts on. `id` may be omitted ONLY when
1689
+ * exactly one guest is held — with two or more it is `AMBIGUOUS_HELD_GUEST`
1690
+ * (the prepend-between-dialog-and-tap TOCTOU), nothing mutated.
1691
+ */
1692
+ private resolveSlot;
1693
+ /**
1694
+ * CLAIM (§3.3 row 29): fold a held guest INTO the current IDENTIFIED account
1695
+ * via `POST /merge` — Sendora-side reassignment of events / profiles /
1696
+ * entities / push tokens, then the guest row is hard-deleted
1697
+ * (`auth.user_merged`). OPTIONAL and IRREVERSIBLE (INV-8): an app whose
1698
+ * backend does not act on `auth.user_merged` MUST NOT call it. The held
1699
+ * token is refreshed first (repairs a spent parent inside the 60 s grace and
1700
+ * proves liveness; the rotation is persisted into the slot BEFORE the merge).
1701
+ * Emits `user_updated{cause:'merge'}` then `guest_released{claimed}`.
1702
+ */
1703
+ claimHeldGuest(id?: string): Promise<{
1704
+ user: AuthUser;
1705
+ stats: MergeStats;
1706
+ }>;
1058
1707
  /**
1059
- * Shared link executor. POSTs the verified credential with the caller's Bearer
1060
- * access token, then refreshes the cached user IN PLACE (no token rotation —
1061
- * the sub is unchanged). NOT serialized (mirrors sendVerificationEmail;
1062
- * getAccessToken() coalesces refresh single-flight internally).
1708
+ * RESTORE (§3.3 rows 30–31): sign back in AS the held guest, without
1709
+ * merging. Project check FIRST, client-side (`HeldGuestProjectMismatch`,
1710
+ * slot KEPT, no network); then the held token is refreshed; a dead guest
1711
+ * releases the slot and rejects `HELD_GUEST_GONE`. A current identified
1712
+ * session has its refresh revoked; a current GUEST is held in a free slot
1713
+ * first. Emits `signed_in{cause:'restore'}` (re-register push) then
1714
+ * `guest_released{restored}`.
1063
1715
  */
1064
- private linkCredential;
1065
- /** Refresh the cached user after a link — same sub, so only the user object + its storage copy change (no token/identity rotation). */
1066
- private updateLocalUser;
1716
+ restoreHeldGuest(id?: string): Promise<AuthUser>;
1717
+ /**
1718
+ * The restore body shared by `restoreHeldGuest()` and the MINT auto-restore.
1719
+ * Returns null when the slot was DEAD and has been released (the mint falls
1720
+ * through to the next slot / a fresh mint); throws `HeldGuestProjectMismatch`
1721
+ * when the slot belongs to another project (client-side, or judged on the
1722
+ * refresh response for a slot whose project was unreadable at HOLD).
1723
+ */
1724
+ private restoreSlot;
1725
+ /**
1726
+ * DISCARD (§3.3 row 32): a REAL server delete of a held guest — `POST
1727
+ * /guest/discard` with the guest's own refresh token (the only proof of
1728
+ * ownership), immediate purge, `auth.user_deleted{reason:'guest_discarded'}`.
1729
+ * The app is the requester (s58.316); the SDK never calls this on its own.
1730
+ * A 2xx or a 404 (already gone / not ours) releases the slot; a 5xx or a
1731
+ * network failure keeps it. Emits `guest_released{discarded}`.
1732
+ */
1733
+ discardHeldGuest(id?: string): Promise<void>;
1734
+ private discardSlot;
1735
+ /**
1736
+ * DEPRECATED alias (ADR-033 §3.5): merge a guest into the current account by
1737
+ * its refresh token. A token that matches a held slot (its current token OR
1738
+ * the one it had at HOLD) routes to `claimHeldGuest(thatId)` — a DESTRUCTIVE
1739
+ * claim (INV-8). Any other token is the raw `/merge` the integrator-journalled
1740
+ * path always was. Prefer `claimHeldGuest(id)`.
1741
+ */
1742
+ merge(anonymousRefreshToken: string): Promise<MergeStats>;
1743
+ /**
1744
+ * While `status() === "withheld"`: what was refused and why. `userId` lets a
1745
+ * `sub`-keyed backend record oldSub → newSub BEFORE `discardWithheld()`.
1746
+ * Never exposes tokens. Null in every other state.
1747
+ */
1748
+ getWithheld(): WithheldInfo | null;
1749
+ /**
1750
+ * Leave `withheld` (§3.3 row 14): clears exactly the stored session (the
1751
+ * four frozen keys + their `auth_session` mirror), revokes the withheld
1752
+ * refresh token server-side fire-and-forget like `signOut()` (§8-Q15), and
1753
+ * KEEPS the held slots, the event queue and the analytics ids. LOCAL ONLY —
1754
+ * never `/guest/discard`, never a server delete. Emits
1755
+ * `signed_out{reason:'user'}`. Resolves `false` (nothing done) in any other
1756
+ * state. `signOut()` in `withheld` does exactly this.
1757
+ */
1758
+ discardWithheld(): Promise<boolean>;
1759
+ private discardWithheldNow;
1760
+ /**
1761
+ * Sign out: wipe local identity FIRST so the user is logged out on
1762
+ * device even if the revoke network call hangs (airplane mode, 5xx,
1763
+ * circuit breaker open) — the one deliberate local-first exception, it has
1764
+ * a rollback. Revoke is fire-and-forget after the wipe. Keeps the held
1765
+ * guests (a guest's refresh token is its only credential — stranding it is
1766
+ * deletion by assumption, s58.316) and the event queue. In `withheld` this is
1767
+ * `discardWithheld()`. In `signed_out` it emits nothing (no transition).
1768
+ */
1769
+ signOut(): Promise<void>;
1770
+ /**
1771
+ * Delete the signed-in user's account (3.24.0 on web; parity with the
1772
+ * native SDKs). Honours the project's grace: `purged` = hard-deleted now,
1773
+ * `pending` = deactivated, hard-deleted at `scheduledPurgeAt` (cancellable by
1774
+ * signing back in). Either way the local session is cleared with
1775
+ * `signed_out{reason:'account_deleted'}`, the event queue is dropped
1776
+ * (s58.353) and the anonymous analytics id rotates.
1777
+ *
1778
+ * ⚠ Held guests SURVIVE on the device AND the server (the server has no
1779
+ * owner link; deleting A keys on A only). Each slot is stamped
1780
+ * `autoRestore:false`, so the next `ensureSession()` mints a fresh guest and
1781
+ * only an explicit `restoreHeldGuest(id)` revives one. Pass
1782
+ * `alsoDiscardHeldGuests: true` — the player's explicit ask — to run
1783
+ * `/guest/discard` on every slot after the 2xx; failures stay held and are
1784
+ * reported in `discardFailed`. Your privacy copy must say deleting an account
1785
+ * does not delete a guest saved on the device unless that option is used.
1786
+ */
1787
+ deleteAccount(opts?: {
1788
+ alsoDiscardHeldGuests?: boolean;
1789
+ }): Promise<DeleteAccountResult>;
1067
1790
  /**
1068
1791
  * List the auth methods/identities linked to the current account (ADR-030
1069
1792
  * read side, s58.270). Returns every social identity linked to this account
@@ -1094,14 +1817,6 @@ declare class Auth {
1094
1817
  * attached tells the user something untrue about their account.
1095
1818
  */
1096
1819
  unlink(provider: string): Promise<UnlinkResult>;
1097
- /** Convenience: Discord OAuth2. Refuses if `verified !== true`. */
1098
- signInWithDiscord(code: string, redirectUri: string): Promise<AuthUser>;
1099
- /**
1100
- * Exchange the `mfaChallengeToken` from `signIn` for a real
1101
- * session by submitting a 6-digit TOTP code or a 17-char recovery
1102
- * code from the user's authenticator app.
1103
- */
1104
- challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
1105
1820
  /**
1106
1821
  * Send a magic-link email to `email`. Caller's app should display
1107
1822
  * "check your inbox" copy after success. Always resolves
@@ -1117,10 +1832,19 @@ declare class Auth {
1117
1832
  sendEmailOtp(email: string): Promise<void>;
1118
1833
  /**
1119
1834
  * Verify a typed 6-digit code + the email it was sent to. Mints
1120
- * a session on success. First-time users are auto-created with
1121
- * `emailVerified=true`.
1835
+ * a session on success (a SWITCH: the device's guest is HELD). First-time
1836
+ * users are auto-created with `emailVerified=true`. A mistyped code leaves
1837
+ * the current session exactly as it was.
1122
1838
  */
1123
1839
  verifyEmailOtp(email: string, code: string): Promise<AuthUser>;
1840
+ /**
1841
+ * Verify a magic-link token (extracted from the URL query string of
1842
+ * the page the link points to) and mint a session (a SWITCH: the device's
1843
+ * guest is HELD). First-time users are auto-created with
1844
+ * `emailVerified=true`. An expired or already-consumed token leaves the
1845
+ * current session exactly as it was.
1846
+ */
1847
+ verifyMagicLink(token: string): Promise<AuthUser>;
1124
1848
  /**
1125
1849
  * Trigger a password-reset email. Backend always resolves 204 even
1126
1850
  * when the address is unknown — prevents account enumeration.
@@ -1147,12 +1871,6 @@ declare class Auth {
1147
1871
  * after signup.
1148
1872
  */
1149
1873
  sendVerificationEmail(): Promise<void>;
1150
- /**
1151
- * Verify a magic-link token (extracted from the URL query string of
1152
- * the page the link points to) and mint a session. First-time
1153
- * users are auto-created with `emailVerified=true`.
1154
- */
1155
- verifyMagicLink(token: string): Promise<AuthUser>;
1156
1874
  /**
1157
1875
  * Begin MFA enrollment. Returns the otpauth:// URL (render as a
1158
1876
  * QR code) + recovery codes (display + offer download — these are
@@ -1195,7 +1913,9 @@ declare class Auth {
1195
1913
  /**
1196
1914
  * Sign in with a passkey. Browser shows the credential picker UI;
1197
1915
  * `userId` is optional — when omitted, uses the resident-key flow
1198
- * so the user picks their account from the prompt.
1916
+ * so the user picks their account from the prompt. Three fallible steps
1917
+ * precede the session (start, the picker, finish) and each leaves the
1918
+ * current session exactly as it was; the success is a SWITCH (guest HELD).
1199
1919
  */
1200
1920
  signInWithPasskey(userId?: string | null): Promise<AuthUser>;
1201
1921
  listPasskeys(): Promise<Array<{
@@ -1239,6 +1959,9 @@ declare class Auth {
1239
1959
  * redirects through the Sendora callback back to `returnTo` with
1240
1960
  * a `#sendora_oidc_token=...` URL fragment. Call
1241
1961
  * `consumeSsoTokenFromUrl()` on the returnTo page to mint a session.
1962
+ *
1963
+ * 3.24.0: no guest hint travels with the start request (INV-3) — the guest
1964
+ * is HELD on this device when the fragment is consumed.
1242
1965
  */
1243
1966
  startSso(returnTo: string): Promise<{
1244
1967
  authorizationUrl: string;
@@ -1269,113 +1992,17 @@ declare class Auth {
1269
1992
  * success, null when no fragment present, throws on
1270
1993
  * `sendora_oidc_error=...`. Call this once on the returnTo page
1271
1994
  * (typically from a top-level effect / page-load hook).
1272
- */
1273
- consumeSsoTokenFromUrl(): Promise<AuthUser | null>;
1274
- /**
1275
- * Build the Authorization header for end-user-authenticated routes.
1276
1995
  *
1277
- * Awaits `getAccessToken()` so a cold-start (re-hydrated from storage
1278
- * with no cached access token) or an expired token transparently
1279
- * refreshes via the stored refresh token before the call — matching
1280
- * `sendVerificationEmail`. Throws if there's genuinely no usable
1281
- * session; the caller should handle this as an unauthenticated state.
1996
+ * 3.24.0 (e): the install goes through `commit()` like every other sign-in
1997
+ * (it was hand-installed before) — so it is a SWITCH: the device's guest is
1998
+ * HELD, the CAS applies, the cron is armed, and `signed_in{cause:'sign_in'}`
1999
+ * is the emission. The exchange happens FIRST; a stale bookmark or a crafted
2000
+ * `#sendora_oidc_token=junk` link leaves the current session untouched.
1282
2001
  */
1283
- private bearerHeaders;
1284
- /**
1285
- * Sign out: wipe local identity FIRST so the user is logged out on
1286
- * device even if the revoke network call hangs (airplane mode, 5xx,
1287
- * circuit breaker open). Revoke is fire-and-forget after the wipe.
1288
- */
1289
- signOut(): Promise<void>;
1290
- private serialize;
1291
- /**
1292
- * Hot-path token refresh. Single-flight via `refreshInflight` so a
1293
- * burst of in-flight reads doesn't fan out into duplicate /refresh
1294
- * round-trips. Returns null on any failure so callers can decide
1295
- * whether to surface re-auth or proceed anonymously.
1296
- *
1297
- * s58.46 — on a dead-token signal (INVALID_REFRESH_TOKEN /
1298
- * UNAUTHORIZED / HTTP_401) we WIPE the stored refresh token.
1299
- * Pre-s58.46 we returned null but kept the dead token in
1300
- * storage, so the next caller re-tried the same token forever
1301
- * (Pulse News iOS hit /refresh ~1×/s for hours). The wipe forces
1302
- * the next op to fall through to /anonymous or surface the signed-
1303
- * out state to the host app instead of looping. A rate limit is NOT
1304
- * such a signal — see `isDeadRefreshError`.
1305
- */
1306
- private refreshAccessToken;
1307
- /**
1308
- * Wave 63 — arm exponential backoff after a transient refresh
1309
- * failure. Caps at 30s. getAccessToken consults
1310
- * `refreshCooldownUntil` and returns stale cached during the window
1311
- * instead of queueing a fresh fetch into a network we already know
1312
- * is stalled.
1313
- */
1314
- private armBackoff;
1315
- /**
1316
- * Wave 63 — clears backoff state. Called from the visibilitychange
1317
- * listener so a user returning to a previously-backgrounded tab
1318
- * gets an immediate fresh refresh attempt instead of waiting on
1319
- * the cooldown timer.
1320
- */
1321
- private resetRefreshState;
1322
- /**
1323
- * Wave 63 — start visibilitychange listener. Browser equivalent of
1324
- * RN AppState 'active'. Called from `persist()` (after signin /
1325
- * signup) alongside `startProactiveRefreshCron`. Idempotent.
1326
- */
1327
- private startVisibilityRefreshReset;
1328
- private stopVisibilityRefreshReset;
1329
- /**
1330
- * Proactive refresh (s58.72). Fires when access-token age crosses
1331
- * ~80% of TTL — refresh becomes a scheduled event, never a 401-
1332
- * driven race. Started by `init()` and cleared by `signOut()`.
1333
- * Internally uses the same `refreshAccessToken()` path so Web Locks
1334
- * coordinates across tabs.
1335
- */
1336
- private startProactiveRefreshCron;
1337
- private stopProactiveRefreshCron;
1338
- private persist;
1339
- /**
1340
- * Clear the outgoing identity immediately before a CONFIRMED `persist()`.
1341
- * Flushes first so the prior identity's queued events reach the backend
1342
- * before `dropQueue()` discards them. No-op when already signed out.
1343
- *
1344
- * `"replaced"` suppresses the signed_out emission — subscribers see one
1345
- * `signed_in`, not a spurious logout/login pair.
1346
- */
1347
- private replaceLocalIdentity;
1348
- /**
1349
- * Wipe everything tied to the current user identity. The anonymous id is
1350
- * regenerated by analytics.reset() ONLY at a person-boundary (reason "user" /
1351
- * "account_deleted") so two people on a shared device don't merge; on a
1352
- * sign-in/upgrade ("replaced") or session death ("session_expired") it SURVIVES
1353
- * (same person — pre-auth analytics link to them). Chat session is reset so
1354
- * cross-account chat history can't bleed on a shared device. Pending analytics
1355
- * events are dropped — they're tied to the prior identity and shouldn't surface
1356
- * under the next.
1357
- *
1358
- * `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
1359
- * emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
1360
- * on a SUCCESSFUL sign-in) emits nothing.
1361
- *
1362
- * ⚠ Never call this before a fallible network request. A failed sign-in must
1363
- * leave the caller exactly as it found them; for an anonymous session the
1364
- * refresh token dropped here is the ONLY durable handle on the account, so a
1365
- * pre-call wipe that is not followed by a successful `persist()` orphans the
1366
- * account permanently (offline: deterministically). Capture the anon refresh
1367
- * into a local, make the call, and wipe only after the response validates.
1368
- */
1369
- /**
1370
- * True when `sent` is still the refresh token in storage — i.e. nothing
1371
- * replaced or cleared the session while a request carrying it was in
1372
- * flight. Guards both directions of the refresh race: installing a rotated
1373
- * token over a session that has since been signed out or replaced, and
1374
- * wiping a fresh session because an OLD token was rejected.
1375
- */
1376
- private tokenStillCurrent;
1377
- private wipeLocalIdentity;
2002
+ consumeSsoTokenFromUrl(): Promise<AuthUser | null>;
1378
2003
  }
2004
+ /** Re-exported for tests / advanced integrators: the literal held-slot keys this SDK writes (`auth_held_0..4`). */
2005
+ declare const AUTH_HELD_SLOT_KEYS: readonly ["auth_held_0", "auth_held_1", "auth_held_2", "auth_held_3", "auth_held_4"];
1379
2006
 
1380
2007
  /**
1381
2008
  * Browser Web Push (RFC 8030) helper.
@@ -1764,6 +2391,18 @@ declare class KnowledgeBase {
1764
2391
  }): Promise<KbArticle[]>;
1765
2392
  }
1766
2393
 
2394
+ /**
2395
+ * ⚠ VERBATIM PORT of `packages/shared/src/auth-machine/project-verdict.ts` (ADR-033 B8 / INV-15).
2396
+ *
2397
+ * The published SDK has ZERO runtime dependencies and cannot import `@sendora/shared`, so the
2398
+ * ProjectGuard is copied here byte-for-byte from the first `export` onwards. The test
2399
+ * `test/project-verdict-parity.test.ts` reads both files and fails on ANY divergence below the
2400
+ * banner, so a fix to the shared verdict that is not mirrored here is red, not silent.
2401
+ * Edit the shared file first, then copy; never edit this one by hand.
2402
+ */
2403
+ type ProjectVerdict = "match" | "mismatch" | "unknown";
2404
+ declare function projectVerdict(expectedProjectId: string | undefined, accessJwt: string | null | undefined): ProjectVerdict;
2405
+
1767
2406
  /**
1768
2407
  * Return the URL if it is http/https; otherwise undefined.
1769
2408
  * Rejects javascript:, data:, vbscript:, and other script-bearing schemes.
@@ -1848,9 +2487,33 @@ declare class SendoraCloud {
1848
2487
  track(eventType: string, properties?: Record<string, unknown>): void;
1849
2488
  page(properties?: Record<string, unknown>): void;
1850
2489
  identify(userId: string, traits?: UserTraits, opts?: IdentifyOptions): void;
1851
- reset(): void;
2490
+ /**
2491
+ * Reset the analytics identity (rotates the anonymous id), feature-flag
2492
+ * context and the chat session. Unchanged since 3.x.
2493
+ *
2494
+ * ⚠ NOT an auth verb on web, and NOT a sign-out: the auth session, its four
2495
+ * stored keys and the held guests are untouched — call `auth.signOut()` to
2496
+ * sign out. (The ADR-033 RESET row describes the React Native SDK, where
2497
+ * `reset()` is the documented logout call; on web it never was, and making
2498
+ * it sign users out would be a behaviour change a MINOR cannot ship —
2499
+ * ADR-023.)
2500
+ *
2501
+ * `opts.includeHeldGuests` (3.24.0) is accepted for API parity with the
2502
+ * native SDKs and is a no-op on auth state here: held guests are released
2503
+ * only by `auth.claimHeldGuest` / `restoreHeldGuest` / `discardHeldGuest`.
2504
+ */
2505
+ reset(opts?: {
2506
+ includeHeldGuests?: boolean;
2507
+ }): void;
2508
+ /**
2509
+ * ADR-033 [2c]: fires when a stored session is WITHHELD (its JWT `project`
2510
+ * claim disagrees with `init({expectedProjectId})`). The SDK has already
2511
+ * stopped attributing analytics to that user in memory; use this to do the
2512
+ * same in your own code. Returns an unsubscribe function.
2513
+ */
2514
+ onIdentityWithheld(listener: () => void): () => void;
1852
2515
  flush(): Promise<void>;
1853
2516
  destroy(): void;
1854
2517
  }
1855
2518
 
1856
- 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 };
2519
+ 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 };