@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.
@@ -113,6 +113,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60000;
113
113
  * expired so the request carries a fresh bearer rather than racing the clock.
114
114
  */
115
115
  const TOKEN_REFRESH_LEAD_SECONDS = 60;
116
+ /**
117
+ * Whether `token` is a JWT that is still valid but already inside the refresh
118
+ * lead window. `false` for an expired, opaque or no-`exp` token.
119
+ */
120
+ function isInsideRefreshLeadWindow(token) {
121
+ try {
122
+ const decoded = jwtDecode(token);
123
+ if (typeof decoded.exp !== 'number') {
124
+ return false;
125
+ }
126
+ const now = Math.floor(Date.now() / 1000);
127
+ // `<=`: the proactive scheduler fires at exactly `exp - lead`.
128
+ return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
116
134
  /**
117
135
  * Soft ceiling on the number of live entries in the identity-scoped GET
118
136
  * response cache. Crossing it does NOT evict anything (the {@link TTLCache}
@@ -234,6 +252,27 @@ export class HttpService {
234
252
  * it, and there is exactly one timestamp to reason about.
235
253
  */
236
254
  this.lastRefreshWasRateLimited = false;
255
+ /**
256
+ * The access token the last SUCCESSFUL refresh handed back while it was still
257
+ * inside the refresh lead window, or `null`.
258
+ *
259
+ * The device mint (`POST /session/device/token`) answers with the session's
260
+ * STORED access token for as long as that token has not expired — it only
261
+ * mints a new one once the old one is past `exp`. A client that re-mints
262
+ * {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
263
+ * token back, and because that counts as a success (no cooldown), every
264
+ * following request re-ran the preflight and minted again: one mint per
265
+ * request for the last minute of every token, which exhausted the server's
266
+ * 30/min mint budget. The 429 that followed imposed a 60s cooldown that
267
+ * straddled the token's real expiry, so the next request went out with no
268
+ * bearer, drew a 401, found the refresh cooling down, and signed the user out.
269
+ *
270
+ * Remembering the answer ends that: while the current token IS this value and
271
+ * has not yet expired, the server has already said it has nothing fresher, so
272
+ * the preflight sends the still-valid token instead of asking again. Once the
273
+ * token crosses `exp` the preflight asks once more and the server rotates.
274
+ */
275
+ this.noFresherTokenThan = null;
237
276
  this.authRefreshHandler = null;
238
277
  this.accessTokenProvider = null;
239
278
  this.deviceSecretMintInFlight = null;
@@ -1025,6 +1064,12 @@ export class HttpService {
1025
1064
  const currentTime = Math.floor(Date.now() / 1000);
1026
1065
  // If the token expires within the refresh lead window, refresh it.
1027
1066
  if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
1067
+ // The server already answered this token's refresh with this very token:
1068
+ // it has nothing fresher until `exp`. Use it rather than asking again
1069
+ // (see `noFresherTokenThan`).
1070
+ if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
1071
+ return `Bearer ${accessToken}`;
1072
+ }
1028
1073
  const refreshed = await this.refreshAccessToken('preflight');
1029
1074
  if (refreshed)
1030
1075
  return `Bearer ${refreshed}`;
@@ -1077,6 +1122,7 @@ export class HttpService {
1077
1122
  this.tokenStore.setTokens(newToken);
1078
1123
  this.notifyTokenChange();
1079
1124
  }
1125
+ this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
1080
1126
  // A success clears the failure timestamp so the next refresh is never
1081
1127
  // throttled by a stale cooldown.
1082
1128
  this.lastRefreshFailureAt = 0;
@@ -1110,6 +1156,16 @@ export class HttpService {
1110
1156
  noteRefreshRateLimited() {
1111
1157
  this.lastRefreshWasRateLimited = true;
1112
1158
  }
1159
+ /**
1160
+ * Whether the server has already answered a refresh of the CURRENT access
1161
+ * token with that same, still-valid token — i.e. it has nothing fresher to
1162
+ * give until the token's `exp`. The proactive scheduler reads this so it waits
1163
+ * for expiry instead of re-asking at its 1s floor.
1164
+ */
1165
+ isAwaitingCurrentTokenExpiry() {
1166
+ const token = this.tokenStore.getAccessToken();
1167
+ return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
1168
+ }
1113
1169
  /**
1114
1170
  * Whether the CURRENT stored access token is already past its `exp`. Drives
1115
1171
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -1850,6 +1850,7 @@
1850
1850
  "switch": {
1851
1851
  "title": "Switch account",
1852
1852
  "count": "{{count}} accounts",
1853
+ "countOne": "{{count}} account",
1853
1854
  "empty": "Accounts you own or share"
1854
1855
  }
1855
1856
  },
@@ -1970,7 +1971,8 @@
1970
1971
  "loading": "Loading sessions…",
1971
1972
  "empty": "No active sessions",
1972
1973
  "signOutAllOnThisDevice": "Sign out of all other devices",
1973
- "signOutAllSubtitle": "End {{count}} other device session(s)"
1974
+ "signOutAllSubtitle": "End {{count}} other device session(s)",
1975
+ "otherSession": "Another session"
1974
1976
  },
1975
1977
  "confirms": {
1976
1978
  "removeDeviceTitle": "Remove device",
@@ -1850,6 +1850,7 @@
1850
1850
  "switch": {
1851
1851
  "title": "Cambiar de cuenta",
1852
1852
  "count": "{{count}} cuentas",
1853
+ "countOne": "{{count}} cuenta",
1853
1854
  "empty": "Cuentas que posees o compartes"
1854
1855
  }
1855
1856
  },
@@ -1970,7 +1971,8 @@
1970
1971
  "loading": "Cargando sesiones…",
1971
1972
  "empty": "No hay sesiones activas",
1972
1973
  "signOutAllOnThisDevice": "Cerrar sesión en los demás dispositivos",
1973
- "signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos"
1974
+ "signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos",
1975
+ "otherSession": "Otra sesión"
1974
1976
  },
1975
1977
  "confirms": {
1976
1978
  "removeDeviceTitle": "Eliminar dispositivo",
@@ -1850,6 +1850,7 @@
1850
1850
  "switch": {
1851
1851
  "title": "Switch account",
1852
1852
  "count": "{{count}} accounts",
1853
+ "countOne": "{{count}} account",
1853
1854
  "empty": "Accounts you own or share"
1854
1855
  }
1855
1856
  },
@@ -1970,7 +1971,8 @@
1970
1971
  "loading": "Loading sessions…",
1971
1972
  "empty": "No active sessions",
1972
1973
  "signOutAllOnThisDevice": "Sign out of all other devices",
1973
- "signOutAllSubtitle": "End {{count}} other device session(s)"
1974
+ "signOutAllSubtitle": "End {{count}} other device session(s)",
1975
+ "otherSession": "Another session"
1974
1976
  },
1975
1977
  "confirms": {
1976
1978
  "removeDeviceTitle": "Remove device",
@@ -1850,6 +1850,7 @@
1850
1850
  "switch": {
1851
1851
  "title": "Cambiar de cuenta",
1852
1852
  "count": "{{count}} cuentas",
1853
+ "countOne": "{{count}} cuenta",
1853
1854
  "empty": "Cuentas que posees o compartes"
1854
1855
  }
1855
1856
  },
@@ -1970,7 +1971,8 @@
1970
1971
  "loading": "Cargando sesiones…",
1971
1972
  "empty": "No hay sesiones activas",
1972
1973
  "signOutAllOnThisDevice": "Cerrar sesión en los demás dispositivos",
1973
- "signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos"
1974
+ "signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos",
1975
+ "otherSession": "Otra sesión"
1974
1976
  },
1975
1977
  "confirms": {
1976
1978
  "removeDeviceTitle": "Eliminar dispositivo",
@@ -329,16 +329,25 @@ export class AccountDialogController {
329
329
  }
330
330
  // Signed out. The directory is bearer-read and `SessionClient` drops the one
331
331
  // it holds when the device empties, so there is nothing to clear here — only
332
- // the in-flight bookkeeping, which no longer describes anything.
332
+ // the in-flight bookkeeping, which no longer describes anything. The account
333
+ // menu described the account that just left, so it gives way to the entry.
333
334
  this.error = null;
334
335
  this.loading = false;
336
+ this.view = this.resolveView(this.view);
335
337
  this.emit();
336
338
  }
337
339
  // =========================================================================
338
340
  // View actions
339
341
  // =========================================================================
340
- /** Set the dialog view directly. */
341
- setView(view) {
342
+ /**
343
+ * Set the dialog view directly.
344
+ *
345
+ * `'accounts'` is only honoured with a session. Signed out it lands on the
346
+ * sign-in entry instead — the menu it would open holds "Switch account",
347
+ * storage and "Sign out" for an account that does not exist.
348
+ */
349
+ setView(requested) {
350
+ const view = this.resolveView(requested);
342
351
  if (this.view === view)
343
352
  return;
344
353
  this.view = view;
@@ -351,6 +360,37 @@ export class AccountDialogController {
351
360
  }
352
361
  this.emit();
353
362
  }
363
+ /**
364
+ * Go back one step: `signup` and `qr` return to the sign-in entry, and the
365
+ * entry returns to the account menu when somebody is signed in.
366
+ *
367
+ * Returns `false` — changing nothing — when the current view is the dialog's
368
+ * first one, so the host closes the dialog instead. Leaving an active
369
+ * request withdraws it; otherwise its authorize code stays approvable until
370
+ * it expires.
371
+ */
372
+ back() {
373
+ const target = backViewOf(this.view, this.hasSession());
374
+ if (target === null)
375
+ return false;
376
+ if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
377
+ this.cancelSignIn();
378
+ }
379
+ this.setView(target);
380
+ return true;
381
+ }
382
+ /** `'accounts'` needs a session; without one the sign-in entry stands in for it. */
383
+ resolveView(view) {
384
+ return view === 'accounts' && !this.hasSession() ? 'signin' : view;
385
+ }
386
+ /**
387
+ * Whether an account is signed in here — the precondition of the account
388
+ * menu. The bearer, not the directory: the directory is bearer-read, and the
389
+ * menu's content (hero, storage, sign-out) is all about the bearer's account.
390
+ */
391
+ hasSession() {
392
+ return this.isAuthenticated();
393
+ }
354
394
  /** Switch to the "add account" view (the sign-in entry chooser). */
355
395
  add() {
356
396
  this.setView('add');
@@ -1285,8 +1325,13 @@ export class AccountDialogController {
1285
1325
  }
1286
1326
  computeSnapshot() {
1287
1327
  const directory = this.sessionClient.getDirectory();
1328
+ const hasSession = this.hasSession();
1329
+ // Resolved here as well as in `setView`: the controller is constructed on
1330
+ // `'accounts'` before anyone knows whether a bearer exists.
1331
+ const view = this.resolveView(this.view);
1288
1332
  return {
1289
- view: this.view,
1333
+ view,
1334
+ backView: backViewOf(view, hasSession),
1290
1335
  directory,
1291
1336
  activeContext: resolveActiveContext(directory),
1292
1337
  loading: this.loading,
@@ -1311,6 +1356,29 @@ export class AccountDialogController {
1311
1356
  }
1312
1357
  }
1313
1358
  }
1359
+ /**
1360
+ * The view one step back from `view`, or `null` when `view` is the dialog's
1361
+ * first view.
1362
+ *
1363
+ * The sign-in entry is `'add'` ("add another account") when somebody is signed
1364
+ * in and `'signin'` when nobody is; `signup` and `qr` are reached FROM that
1365
+ * entry, so they return to it. The entry itself returns to the account menu —
1366
+ * which exists only with a session. This used to be a host-side
1367
+ * "back = accounts" that assumed a signed-in origin, so a signed-out Back from
1368
+ * "Create your account" opened a menu holding "Sign out" for nobody.
1369
+ */
1370
+ export function backViewOf(view, hasSession) {
1371
+ switch (view) {
1372
+ case 'signup':
1373
+ case 'qr':
1374
+ return hasSession ? 'add' : 'signin';
1375
+ case 'add':
1376
+ return hasSession ? 'accounts' : null;
1377
+ case 'signin':
1378
+ case 'accounts':
1379
+ return null;
1380
+ }
1381
+ }
1314
1382
  /** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
1315
1383
  export function createAccountDialogController(options) {
1316
1384
  return new AccountDialogController(options);
@@ -31,6 +31,20 @@ const MIN_SCHEDULE_DELAY_MS = 1000;
31
31
  */
32
32
  const MIN_FAILURE_BACKOFF_MS = 5000;
33
33
  const MAX_FAILURE_BACKOFF_MS = 5 * 60000;
34
+ /**
35
+ * How long past `exp` to re-mint when a successful refresh handed back a token
36
+ * that is STILL inside the lead window.
37
+ *
38
+ * The device mint returns the session's stored access token until that token
39
+ * has actually expired, so asking again before `exp` only returns the same
40
+ * token. Re-arming from expiry in that state computed a negative delay, hit the
41
+ * {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
42
+ * minute of every token — 30+ mints, the server's whole per-minute budget, and
43
+ * the 429 that followed straddled the real expiry. Waiting until just past
44
+ * `exp` asks exactly once more, when the server will rotate. The margin covers
45
+ * the server's strict `exp < now` comparison and a little clock skew.
46
+ */
47
+ export const REMINT_AFTER_EXPIRY_MS = 2000;
34
48
  /**
35
49
  * Arm 1 — the device-secret mint, run under the owning client's PROCESS-WIDE
36
50
  * single-flight (`httpService.runSingleFlightDeviceSecretMint`).
@@ -326,12 +340,26 @@ export function startTokenRefreshScheduler(oxy) {
326
340
  if (expSeconds === null) {
327
341
  return;
328
342
  }
329
- armTimer(expSeconds * 1000 - Date.now() - TOKEN_REFRESH_LEAD_MS);
343
+ const untilExpiryMs = expSeconds * 1000 - Date.now();
344
+ // The server already answered this token's refresh with this very token:
345
+ // asking again before `exp` only gets it back again. Ask just after expiry,
346
+ // when the server rotates (see REMINT_AFTER_EXPIRY_MS).
347
+ if (untilExpiryMs > 0 && oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
348
+ armTimer(untilExpiryMs + REMINT_AFTER_EXPIRY_MS);
349
+ return;
350
+ }
351
+ armTimer(untilExpiryMs - TOKEN_REFRESH_LEAD_MS);
330
352
  };
331
353
  const runRefresh = () => {
332
354
  // Clear any pending timer up front so an out-of-band trigger (focus) plus
333
355
  // a fired timer can never double-run.
334
356
  clearTimer();
357
+ // Another lane (a request-time preflight) already asked, and the server
358
+ // answered with this same token: re-arm for just past expiry instead.
359
+ if (oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
360
+ scheduleFromExpiry();
361
+ return;
362
+ }
335
363
  void oxy.httpService.refreshAccessToken('preflight')
336
364
  .then((token) => Boolean(token))
337
365
  .catch(() => false)
@@ -374,10 +402,16 @@ export function startTokenRefreshScheduler(oxy) {
374
402
  schedule();
375
403
  }
376
404
  };
377
- const unsubscribeTokens = oxy.onTokensChanged(() => {
378
- if (!disposed) {
379
- schedule();
405
+ // Re-arm only on a token that actually CHANGED. The mint plants the token it
406
+ // returns even when that is the token already held, and treating the repeat
407
+ // as new re-armed from an in-lead-window expiry, i.e. at the 1s floor.
408
+ let lastSeenToken = oxy.getAccessToken();
409
+ const unsubscribeTokens = oxy.onTokensChanged((token) => {
410
+ if (disposed || token === lastSeenToken) {
411
+ return;
380
412
  }
413
+ lastSeenToken = token;
414
+ schedule();
381
415
  });
382
416
  let removeFocusListener = null;
383
417
  if (typeof document !== 'undefined') {