@oxy.so/core 1.8.1 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -297,8 +297,19 @@ function deriveSignInProgress(facts: SignInFlowFacts): SignInProgress {
297
297
 
298
298
  /** Immutable snapshot consumed by `useSyncExternalStore`. */
299
299
  export interface AccountDialogSnapshot {
300
- /** The current view. */
300
+ /**
301
+ * The current view. Never `'accounts'` without a session: the account menu
302
+ * describes a signed-in account, so with no bearer the controller shows the
303
+ * sign-in entry (`'signin'`) in its place.
304
+ */
301
305
  view: AccountDialogView;
306
+ /**
307
+ * Where {@link AccountDialogController.back} leads from {@link view}, or
308
+ * `null` when `view` is the dialog's first view (a back there closes the
309
+ * dialog instead). Hosts show their back affordance from this, not from a
310
+ * table of their own.
311
+ */
312
+ backView: AccountDialogView | null;
302
313
  /**
303
314
  * The server-authoritative device directory — principals and the contexts
304
315
  * each may act as (ADR 0002) — or `null` before the first read.
@@ -695,9 +706,11 @@ export class AccountDialogController {
695
706
  }
696
707
  // Signed out. The directory is bearer-read and `SessionClient` drops the one
697
708
  // it holds when the device empties, so there is nothing to clear here — only
698
- // the in-flight bookkeeping, which no longer describes anything.
709
+ // the in-flight bookkeeping, which no longer describes anything. The account
710
+ // menu described the account that just left, so it gives way to the entry.
699
711
  this.error = null;
700
712
  this.loading = false;
713
+ this.view = this.resolveView(this.view);
701
714
  this.emit();
702
715
  }
703
716
 
@@ -705,8 +718,15 @@ export class AccountDialogController {
705
718
  // View actions
706
719
  // =========================================================================
707
720
 
708
- /** Set the dialog view directly. */
709
- setView(view: AccountDialogView): void {
721
+ /**
722
+ * Set the dialog view directly.
723
+ *
724
+ * `'accounts'` is only honoured with a session. Signed out it lands on the
725
+ * sign-in entry instead — the menu it would open holds "Switch account",
726
+ * storage and "Sign out" for an account that does not exist.
727
+ */
728
+ setView(requested: AccountDialogView): void {
729
+ const view = this.resolveView(requested);
710
730
  if (this.view === view) return;
711
731
  this.view = view;
712
732
  // A `'completed'` flow owns no timers, socket, popup, or token — it is only
@@ -719,6 +739,39 @@ export class AccountDialogController {
719
739
  this.emit();
720
740
  }
721
741
 
742
+ /**
743
+ * Go back one step: `signup` and `qr` return to the sign-in entry, and the
744
+ * entry returns to the account menu when somebody is signed in.
745
+ *
746
+ * Returns `false` — changing nothing — when the current view is the dialog's
747
+ * first one, so the host closes the dialog instead. Leaving an active
748
+ * request withdraws it; otherwise its authorize code stays approvable until
749
+ * it expires.
750
+ */
751
+ back(): boolean {
752
+ const target = backViewOf(this.view, this.hasSession());
753
+ if (target === null) return false;
754
+ if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
755
+ this.cancelSignIn();
756
+ }
757
+ this.setView(target);
758
+ return true;
759
+ }
760
+
761
+ /** `'accounts'` needs a session; without one the sign-in entry stands in for it. */
762
+ private resolveView(view: AccountDialogView): AccountDialogView {
763
+ return view === 'accounts' && !this.hasSession() ? 'signin' : view;
764
+ }
765
+
766
+ /**
767
+ * Whether an account is signed in here — the precondition of the account
768
+ * menu. The bearer, not the directory: the directory is bearer-read, and the
769
+ * menu's content (hero, storage, sign-out) is all about the bearer's account.
770
+ */
771
+ private hasSession(): boolean {
772
+ return this.isAuthenticated();
773
+ }
774
+
722
775
  /** Switch to the "add account" view (the sign-in entry chooser). */
723
776
  add(): void {
724
777
  this.setView('add');
@@ -1720,8 +1773,13 @@ export class AccountDialogController {
1720
1773
 
1721
1774
  private computeSnapshot(): AccountDialogSnapshot {
1722
1775
  const directory = this.sessionClient.getDirectory();
1776
+ const hasSession = this.hasSession();
1777
+ // Resolved here as well as in `setView`: the controller is constructed on
1778
+ // `'accounts'` before anyone knows whether a bearer exists.
1779
+ const view = this.resolveView(this.view);
1723
1780
  return {
1724
- view: this.view,
1781
+ view,
1782
+ backView: backViewOf(view, hasSession),
1725
1783
  directory,
1726
1784
  activeContext: resolveActiveContext(directory),
1727
1785
  loading: this.loading,
@@ -1747,6 +1805,30 @@ export class AccountDialogController {
1747
1805
  }
1748
1806
  }
1749
1807
 
1808
+ /**
1809
+ * The view one step back from `view`, or `null` when `view` is the dialog's
1810
+ * first view.
1811
+ *
1812
+ * The sign-in entry is `'add'` ("add another account") when somebody is signed
1813
+ * in and `'signin'` when nobody is; `signup` and `qr` are reached FROM that
1814
+ * entry, so they return to it. The entry itself returns to the account menu —
1815
+ * which exists only with a session. This used to be a host-side
1816
+ * "back = accounts" that assumed a signed-in origin, so a signed-out Back from
1817
+ * "Create your account" opened a menu holding "Sign out" for nobody.
1818
+ */
1819
+ export function backViewOf(view: AccountDialogView, hasSession: boolean): AccountDialogView | null {
1820
+ switch (view) {
1821
+ case 'signup':
1822
+ case 'qr':
1823
+ return hasSession ? 'add' : 'signin';
1824
+ case 'add':
1825
+ return hasSession ? 'accounts' : null;
1826
+ case 'signin':
1827
+ case 'accounts':
1828
+ return null;
1829
+ }
1830
+ }
1831
+
1750
1832
  /** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
1751
1833
  export function createAccountDialogController(
1752
1834
  options: AccountDialogControllerOptions,
@@ -63,6 +63,21 @@ const MIN_SCHEDULE_DELAY_MS = 1_000;
63
63
  const MIN_FAILURE_BACKOFF_MS = 5_000;
64
64
  const MAX_FAILURE_BACKOFF_MS = 5 * 60_000;
65
65
 
66
+ /**
67
+ * How long past `exp` to re-mint when a successful refresh handed back a token
68
+ * that is STILL inside the lead window.
69
+ *
70
+ * The device mint returns the session's stored access token until that token
71
+ * has actually expired, so asking again before `exp` only returns the same
72
+ * token. Re-arming from expiry in that state computed a negative delay, hit the
73
+ * {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
74
+ * minute of every token — 30+ mints, the server's whole per-minute budget, and
75
+ * the 429 that followed straddled the real expiry. Waiting until just past
76
+ * `exp` asks exactly once more, when the server will rotate. The margin covers
77
+ * the server's strict `exp < now` comparison and a little clock skew.
78
+ */
79
+ export const REMINT_AFTER_EXPIRY_MS = 2_000;
80
+
66
81
  export interface RefreshDeps {
67
82
  oxy: OxyServices;
68
83
  store: AuthStateStore;
@@ -447,13 +462,27 @@ export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedu
447
462
  if (expSeconds === null) {
448
463
  return;
449
464
  }
450
- armTimer(expSeconds * 1000 - Date.now() - TOKEN_REFRESH_LEAD_MS);
465
+ const untilExpiryMs = expSeconds * 1000 - Date.now();
466
+ // The server already answered this token's refresh with this very token:
467
+ // asking again before `exp` only gets it back again. Ask just after expiry,
468
+ // when the server rotates (see REMINT_AFTER_EXPIRY_MS).
469
+ if (untilExpiryMs > 0 && oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
470
+ armTimer(untilExpiryMs + REMINT_AFTER_EXPIRY_MS);
471
+ return;
472
+ }
473
+ armTimer(untilExpiryMs - TOKEN_REFRESH_LEAD_MS);
451
474
  };
452
475
 
453
476
  const runRefresh = (): void => {
454
477
  // Clear any pending timer up front so an out-of-band trigger (focus) plus
455
478
  // a fired timer can never double-run.
456
479
  clearTimer();
480
+ // Another lane (a request-time preflight) already asked, and the server
481
+ // answered with this same token: re-arm for just past expiry instead.
482
+ if (oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
483
+ scheduleFromExpiry();
484
+ return;
485
+ }
457
486
  void oxy.httpService.refreshAccessToken('preflight')
458
487
  .then((token) => Boolean(token))
459
488
  .catch(() => false)
@@ -498,10 +527,16 @@ export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedu
498
527
  }
499
528
  };
500
529
 
501
- const unsubscribeTokens = oxy.onTokensChanged(() => {
502
- if (!disposed) {
503
- schedule();
530
+ // Re-arm only on a token that actually CHANGED. The mint plants the token it
531
+ // returns even when that is the token already held, and treating the repeat
532
+ // as new re-armed from an in-lead-window expiry, i.e. at the 1s floor.
533
+ let lastSeenToken = oxy.getAccessToken();
534
+ const unsubscribeTokens = oxy.onTokensChanged((token) => {
535
+ if (disposed || token === lastSeenToken) {
536
+ return;
504
537
  }
538
+ lastSeenToken = token;
539
+ schedule();
505
540
  });
506
541
 
507
542
  let removeFocusListener: (() => void) | null = null;