@sendoracloud/sdk-web 3.23.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/README.md +71 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +956 -314
- package/dist/index.d.ts +956 -314
- package/dist/index.js +1 -1
- package/package.json +3 -2
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
|
-
*
|
|
333
|
-
* events
|
|
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
|
-
*
|
|
482
|
+
* ADR-033 wave 1 (3.24.0) — the SDK's OWN copy of the auth-machine vocabulary.
|
|
443
483
|
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
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
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
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
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
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
|
|
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
|
|
532
|
-
* expired
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
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).
|
|
540
|
-
* `onAuthStateChanged` — one stream covering everything
|
|
541
|
-
*
|
|
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
|
|
603
|
-
*
|
|
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
|
-
*
|
|
606
|
-
*
|
|
607
|
-
* session
|
|
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
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
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(
|
|
739
|
-
/**
|
|
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
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
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
|
|
756
|
-
* native SDKs the same
|
|
757
|
-
* where an already-owned player identity
|
|
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,11 +1158,14 @@ 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
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
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.
|
|
@@ -798,13 +1183,20 @@ declare class Auth {
|
|
|
798
1183
|
* affects auth state. Returns an unsubscribe function.
|
|
799
1184
|
*/
|
|
800
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;
|
|
801
1193
|
/** Fan a state change out to subscribers. Never throws. */
|
|
802
1194
|
private emitAuthState;
|
|
803
1195
|
/**
|
|
804
1196
|
* Internal — called from every identified-signin path with the
|
|
805
1197
|
* `retiredAnonUserId` parsed off the backend response (or off
|
|
806
1198
|
* the SSO redirect fragment). Snapshot + dispatch synchronously
|
|
807
|
-
* since handlers run on the same event loop turn as
|
|
1199
|
+
* since handlers run on the same event loop turn as the install.
|
|
808
1200
|
*
|
|
809
1201
|
* Validates UUID shape before dispatch. The value comes from a URL
|
|
810
1202
|
* fragment / response body that — under XSS — could be controlled.
|
|
@@ -826,46 +1218,72 @@ declare class Auth {
|
|
|
826
1218
|
* What the last sign-in did with this device's guest account, or null if no
|
|
827
1219
|
* sign-in has happened this session (s58.278).
|
|
828
1220
|
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
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.
|
|
832
1225
|
*/
|
|
833
1226
|
getLastAnonRetirement(): AnonRetirementOutcome | null;
|
|
834
1227
|
private fireDeletionCancelled;
|
|
835
1228
|
/**
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
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.
|
|
839
1232
|
*/
|
|
840
|
-
private
|
|
1233
|
+
private recordServerSignals;
|
|
841
1234
|
/**
|
|
842
|
-
*
|
|
1235
|
+
* Apply one transaction, or drop it. Returns `true` when applied.
|
|
843
1236
|
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
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.
|
|
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.
|
|
852
1242
|
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
|
|
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;
|
|
869
1287
|
/**
|
|
870
1288
|
* The cached-token guard, and the only place the two deadlines are combined.
|
|
871
1289
|
* Both must agree: the locally-tracked deadline (immune to a constantly-wrong
|
|
@@ -901,77 +1319,203 @@ declare class Auth {
|
|
|
901
1319
|
* reads live, guard holds) or genuinely fast (still dead, guard releases).
|
|
902
1320
|
*/
|
|
903
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
|
+
*/
|
|
904
1347
|
getAccessToken(options?: {
|
|
905
1348
|
forceRefresh?: boolean;
|
|
906
1349
|
}): Promise<string | null>;
|
|
907
|
-
/** 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. */
|
|
908
1351
|
getAccessTokenSync(): string | null;
|
|
909
1352
|
/**
|
|
910
|
-
*
|
|
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.
|
|
911
1433
|
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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;
|
|
1441
|
+
/**
|
|
1442
|
+
* Mint (or return) an anonymous session.
|
|
920
1443
|
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
* the
|
|
925
|
-
* mint — the second call runs only once the first has persisted, so it takes
|
|
926
|
-
* the reuse branch instead of minting a sibling.
|
|
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.
|
|
927
1448
|
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
* `
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
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.
|
|
938
1459
|
*
|
|
939
1460
|
* Pass `forceNew: true` to deliberately abandon the current session —
|
|
940
|
-
* whichever kind — and mint a separate anonymous one.
|
|
941
|
-
*
|
|
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.
|
|
942
1467
|
*/
|
|
943
1468
|
signInAnonymously(opts?: {
|
|
944
1469
|
name?: string;
|
|
945
1470
|
metadata?: Record<string, unknown>;
|
|
946
1471
|
forceNew?: boolean;
|
|
947
1472
|
}): Promise<AuthUser>;
|
|
948
|
-
/**
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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;
|
|
963
1497
|
/**
|
|
964
1498
|
* Sign in with email + password. When the account has confirmed
|
|
965
1499
|
* MFA, returns a `mfaRequired` discriminator instead of the user
|
|
966
1500
|
* — caller must then call `challengeMfa(challengeToken, code)`
|
|
967
1501
|
* with the TOTP / recovery code to mint a real session. Pre-MFA
|
|
968
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`).
|
|
969
1506
|
*/
|
|
970
1507
|
signIn(email: string, password: string): Promise<AuthUser | {
|
|
971
1508
|
mfaRequired: true;
|
|
972
1509
|
mfaChallengeToken: string;
|
|
973
1510
|
userId: string;
|
|
974
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>;
|
|
975
1519
|
/**
|
|
976
1520
|
* Verify an IdP-issued credential (authorization code OR id_token)
|
|
977
1521
|
* and mint a Sendora session. Customer's app handles the IdP
|
|
@@ -985,13 +1529,18 @@ declare class Auth {
|
|
|
985
1529
|
* by design — see backend service.ts)
|
|
986
1530
|
* - `idToken` (Apple-native flow)
|
|
987
1531
|
*
|
|
988
|
-
*
|
|
989
|
-
* `
|
|
990
|
-
*
|
|
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.
|
|
991
1542
|
*
|
|
992
|
-
*
|
|
993
|
-
* refusal — most commonly the verified-email gate (Twitter,
|
|
994
|
-
* unverified Discord, etc).
|
|
1543
|
+
* Without `link`, this is a plain SWITCH: the device's guest is HELD.
|
|
995
1544
|
*/
|
|
996
1545
|
loginSocial(input: {
|
|
997
1546
|
provider: "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
|
|
@@ -1003,23 +1552,12 @@ declare class Auth {
|
|
|
1003
1552
|
firstName?: string;
|
|
1004
1553
|
lastName?: string;
|
|
1005
1554
|
};
|
|
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
|
-
*/
|
|
1555
|
+
/** Client ROUTING hint (ADR-033 §4): anonymous | identified → `/me/link/social`; signed_out → plain sign-in; unknown → wait. Never sent to the server. */
|
|
1013
1556
|
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
|
-
*/
|
|
1557
|
+
/** See {@link CredentialCollisionPolicy}. Applied client-side on a 409 from the link route; never sent to the server. */
|
|
1020
1558
|
onCredentialInUse?: CredentialCollisionPolicy;
|
|
1021
1559
|
}): Promise<AuthUser>;
|
|
1022
|
-
/** Convenience: Google authorization-code login. `link` =
|
|
1560
|
+
/** Convenience: Google authorization-code login. `link` = attach to the current account (see `loginSocial`). */
|
|
1023
1561
|
signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
|
|
1024
1562
|
/** Convenience: GitHub authorization-code login. */
|
|
1025
1563
|
signInWithGitHub(code: string, redirectUri: string): Promise<AuthUser>;
|
|
@@ -1037,7 +1575,7 @@ declare class Auth {
|
|
|
1037
1575
|
firstName?: string;
|
|
1038
1576
|
lastName?: string;
|
|
1039
1577
|
};
|
|
1040
|
-
/**
|
|
1578
|
+
/** Attach to the current account instead of switching. See `loginSocial`. */
|
|
1041
1579
|
link?: boolean;
|
|
1042
1580
|
}): Promise<AuthUser>;
|
|
1043
1581
|
/** Convenience: Microsoft Azure AD authorization-code login. */
|
|
@@ -1046,6 +1584,63 @@ declare class Auth {
|
|
|
1046
1584
|
signInWithLinkedIn(code: string, redirectUri: string): Promise<AuthUser>;
|
|
1047
1585
|
/** Convenience: Facebook Graph login. Refuses if email permission not granted. */
|
|
1048
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>;
|
|
1049
1644
|
/** Link email + password to the current account (sub preserved). */
|
|
1050
1645
|
linkEmailPassword(email: string, password: string): Promise<AuthUser>;
|
|
1051
1646
|
/** Link an OAuth social identity (verified idToken or authorization code) to the current account. */
|
|
@@ -1076,15 +1671,122 @@ declare class Auth {
|
|
|
1076
1671
|
lastName?: string;
|
|
1077
1672
|
};
|
|
1078
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
|
+
}>;
|
|
1079
1707
|
/**
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
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}`.
|
|
1084
1715
|
*/
|
|
1085
|
-
|
|
1086
|
-
/**
|
|
1087
|
-
|
|
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>;
|
|
1088
1790
|
/**
|
|
1089
1791
|
* List the auth methods/identities linked to the current account (ADR-030
|
|
1090
1792
|
* read side, s58.270). Returns every social identity linked to this account
|
|
@@ -1115,14 +1817,6 @@ declare class Auth {
|
|
|
1115
1817
|
* attached tells the user something untrue about their account.
|
|
1116
1818
|
*/
|
|
1117
1819
|
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
1820
|
/**
|
|
1127
1821
|
* Send a magic-link email to `email`. Caller's app should display
|
|
1128
1822
|
* "check your inbox" copy after success. Always resolves
|
|
@@ -1138,10 +1832,19 @@ declare class Auth {
|
|
|
1138
1832
|
sendEmailOtp(email: string): Promise<void>;
|
|
1139
1833
|
/**
|
|
1140
1834
|
* Verify a typed 6-digit code + the email it was sent to. Mints
|
|
1141
|
-
* a session on success. First-time
|
|
1142
|
-
* `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.
|
|
1143
1838
|
*/
|
|
1144
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>;
|
|
1145
1848
|
/**
|
|
1146
1849
|
* Trigger a password-reset email. Backend always resolves 204 even
|
|
1147
1850
|
* when the address is unknown — prevents account enumeration.
|
|
@@ -1168,12 +1871,6 @@ declare class Auth {
|
|
|
1168
1871
|
* after signup.
|
|
1169
1872
|
*/
|
|
1170
1873
|
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
1874
|
/**
|
|
1178
1875
|
* Begin MFA enrollment. Returns the otpauth:// URL (render as a
|
|
1179
1876
|
* QR code) + recovery codes (display + offer download — these are
|
|
@@ -1216,7 +1913,9 @@ declare class Auth {
|
|
|
1216
1913
|
/**
|
|
1217
1914
|
* Sign in with a passkey. Browser shows the credential picker UI;
|
|
1218
1915
|
* `userId` is optional — when omitted, uses the resident-key flow
|
|
1219
|
-
* 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).
|
|
1220
1919
|
*/
|
|
1221
1920
|
signInWithPasskey(userId?: string | null): Promise<AuthUser>;
|
|
1222
1921
|
listPasskeys(): Promise<Array<{
|
|
@@ -1260,6 +1959,9 @@ declare class Auth {
|
|
|
1260
1959
|
* redirects through the Sendora callback back to `returnTo` with
|
|
1261
1960
|
* a `#sendora_oidc_token=...` URL fragment. Call
|
|
1262
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.
|
|
1263
1965
|
*/
|
|
1264
1966
|
startSso(returnTo: string): Promise<{
|
|
1265
1967
|
authorizationUrl: string;
|
|
@@ -1290,113 +1992,17 @@ declare class Auth {
|
|
|
1290
1992
|
* success, null when no fragment present, throws on
|
|
1291
1993
|
* `sendora_oidc_error=...`. Call this once on the returnTo page
|
|
1292
1994
|
* (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
1995
|
*
|
|
1298
|
-
*
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
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.
|
|
1303
2001
|
*/
|
|
1304
|
-
|
|
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;
|
|
2002
|
+
consumeSsoTokenFromUrl(): Promise<AuthUser | null>;
|
|
1399
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"];
|
|
1400
2006
|
|
|
1401
2007
|
/**
|
|
1402
2008
|
* Browser Web Push (RFC 8030) helper.
|
|
@@ -1785,6 +2391,18 @@ declare class KnowledgeBase {
|
|
|
1785
2391
|
}): Promise<KbArticle[]>;
|
|
1786
2392
|
}
|
|
1787
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
|
+
|
|
1788
2406
|
/**
|
|
1789
2407
|
* Return the URL if it is http/https; otherwise undefined.
|
|
1790
2408
|
* Rejects javascript:, data:, vbscript:, and other script-bearing schemes.
|
|
@@ -1869,9 +2487,33 @@ declare class SendoraCloud {
|
|
|
1869
2487
|
track(eventType: string, properties?: Record<string, unknown>): void;
|
|
1870
2488
|
page(properties?: Record<string, unknown>): void;
|
|
1871
2489
|
identify(userId: string, traits?: UserTraits, opts?: IdentifyOptions): void;
|
|
1872
|
-
|
|
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;
|
|
1873
2515
|
flush(): Promise<void>;
|
|
1874
2516
|
destroy(): void;
|
|
1875
2517
|
}
|
|
1876
2518
|
|
|
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 };
|
|
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 };
|