@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +56 -0
- package/dist/cjs/i18n/locales/en-US.json +3 -1
- package/dist/cjs/i18n/locales/es-ES.json +3 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +3 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +3 -1
- package/dist/cjs/session/accountDialogController.js +73 -4
- package/dist/cjs/session/refresh.js +39 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -0
- package/dist/esm/i18n/locales/en-US.json +3 -1
- package/dist/esm/i18n/locales/es-ES.json +3 -1
- package/dist/esm/i18n/locales/locales/en-US.json +3 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +3 -1
- package/dist/esm/session/accountDialogController.js +72 -4
- package/dist/esm/session/refresh.js +38 -4
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +28 -0
- package/dist/types/models/interfaces.d.ts +6 -1
- package/dist/types/session/accountDialogController.d.ts +50 -3
- package/dist/types/session/refresh.d.ts +14 -0
- package/package.json +1 -1
- package/src/HttpService.ts +57 -0
- package/src/__tests__/nearExpiryMintStorm.test.ts +165 -0
- package/src/i18n/locales/en-US.json +3 -1
- package/src/i18n/locales/es-ES.json +3 -1
- package/src/models/interfaces.ts +6 -1
- package/src/session/__tests__/accountDialogController.test.ts +105 -0
- package/src/session/__tests__/accountDialogShape.test.ts +1 -0
- package/src/session/accountDialogController.ts +87 -5
- package/src/session/refresh.ts +39 -4
|
@@ -297,8 +297,19 @@ function deriveSignInProgress(facts: SignInFlowFacts): SignInProgress {
|
|
|
297
297
|
|
|
298
298
|
/** Immutable snapshot consumed by `useSyncExternalStore`. */
|
|
299
299
|
export interface AccountDialogSnapshot {
|
|
300
|
-
/**
|
|
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
|
-
/**
|
|
709
|
-
|
|
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
|
|
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,
|
package/src/session/refresh.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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;
|