@oxy.so/core 1.9.0 → 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.
@@ -141,6 +141,27 @@ export declare class HttpService {
141
141
  * it, and there is exactly one timestamp to reason about.
142
142
  */
143
143
  private lastRefreshWasRateLimited;
144
+ /**
145
+ * The access token the last SUCCESSFUL refresh handed back while it was still
146
+ * inside the refresh lead window, or `null`.
147
+ *
148
+ * The device mint (`POST /session/device/token`) answers with the session's
149
+ * STORED access token for as long as that token has not expired — it only
150
+ * mints a new one once the old one is past `exp`. A client that re-mints
151
+ * {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
152
+ * token back, and because that counts as a success (no cooldown), every
153
+ * following request re-ran the preflight and minted again: one mint per
154
+ * request for the last minute of every token, which exhausted the server's
155
+ * 30/min mint budget. The 429 that followed imposed a 60s cooldown that
156
+ * straddled the token's real expiry, so the next request went out with no
157
+ * bearer, drew a 401, found the refresh cooling down, and signed the user out.
158
+ *
159
+ * Remembering the answer ends that: while the current token IS this value and
160
+ * has not yet expired, the server has already said it has nothing fresher, so
161
+ * the preflight sends the still-valid token instead of asking again. Once the
162
+ * token crosses `exp` the preflight asks once more and the server rotates.
163
+ */
164
+ private noFresherTokenThan;
144
165
  private authRefreshHandler;
145
166
  private accessTokenProvider;
146
167
  private deviceSecretMintInFlight;
@@ -309,6 +330,13 @@ export declare class HttpService {
309
330
  * Public because the handler lives in `session/refresh.ts`, not on this class.
310
331
  */
311
332
  noteRefreshRateLimited(): void;
333
+ /**
334
+ * Whether the server has already answered a refresh of the CURRENT access
335
+ * token with that same, still-valid token — i.e. it has nothing fresher to
336
+ * give until the token's `exp`. The proactive scheduler reads this so it waits
337
+ * for expiry instead of re-asking at its 1s floor.
338
+ */
339
+ isAwaitingCurrentTokenExpiry(): boolean;
312
340
  /**
313
341
  * Whether the CURRENT stored access token is already past its `exp`. Drives
314
342
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -33,6 +33,20 @@ import { type IdentityBinding } from './identitySession';
33
33
  * window — the scheduler just fires it during idle/background.
34
34
  */
35
35
  export declare const TOKEN_REFRESH_LEAD_MS = 60000;
36
+ /**
37
+ * How long past `exp` to re-mint when a successful refresh handed back a token
38
+ * that is STILL inside the lead window.
39
+ *
40
+ * The device mint returns the session's stored access token until that token
41
+ * has actually expired, so asking again before `exp` only returns the same
42
+ * token. Re-arming from expiry in that state computed a negative delay, hit the
43
+ * {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
44
+ * minute of every token — 30+ mints, the server's whole per-minute budget, and
45
+ * the 429 that followed straddled the real expiry. Waiting until just past
46
+ * `exp` asks exactly once more, when the server will rotate. The margin covers
47
+ * the server's strict `exp < now` comparison and a little clock skew.
48
+ */
49
+ export declare const REMINT_AFTER_EXPIRY_MS = 2000;
36
50
  export interface RefreshDeps {
37
51
  oxy: OxyServices;
38
52
  store: AuthStateStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -240,6 +240,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60_000;
240
240
  */
241
241
  const TOKEN_REFRESH_LEAD_SECONDS = 60;
242
242
 
243
+ /**
244
+ * Whether `token` is a JWT that is still valid but already inside the refresh
245
+ * lead window. `false` for an expired, opaque or no-`exp` token.
246
+ */
247
+ function isInsideRefreshLeadWindow(token: string): boolean {
248
+ try {
249
+ const decoded = jwtDecode<JwtPayload>(token);
250
+ if (typeof decoded.exp !== 'number') {
251
+ return false;
252
+ }
253
+ const now = Math.floor(Date.now() / 1000);
254
+ // `<=`: the proactive scheduler fires at exactly `exp - lead`.
255
+ return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
243
261
  /**
244
262
  * Soft ceiling on the number of live entries in the identity-scoped GET
245
263
  * response cache. Crossing it does NOT evict anything (the {@link TTLCache}
@@ -390,6 +408,27 @@ export class HttpService {
390
408
  * it, and there is exactly one timestamp to reason about.
391
409
  */
392
410
  private lastRefreshWasRateLimited = false;
411
+ /**
412
+ * The access token the last SUCCESSFUL refresh handed back while it was still
413
+ * inside the refresh lead window, or `null`.
414
+ *
415
+ * The device mint (`POST /session/device/token`) answers with the session's
416
+ * STORED access token for as long as that token has not expired — it only
417
+ * mints a new one once the old one is past `exp`. A client that re-mints
418
+ * {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
419
+ * token back, and because that counts as a success (no cooldown), every
420
+ * following request re-ran the preflight and minted again: one mint per
421
+ * request for the last minute of every token, which exhausted the server's
422
+ * 30/min mint budget. The 429 that followed imposed a 60s cooldown that
423
+ * straddled the token's real expiry, so the next request went out with no
424
+ * bearer, drew a 401, found the refresh cooling down, and signed the user out.
425
+ *
426
+ * Remembering the answer ends that: while the current token IS this value and
427
+ * has not yet expired, the server has already said it has nothing fresher, so
428
+ * the preflight sends the still-valid token instead of asking again. Once the
429
+ * token crosses `exp` the preflight asks once more and the server rotates.
430
+ */
431
+ private noFresherTokenThan: string | null = null;
393
432
  private authRefreshHandler: AuthRefreshHandler | null = null;
394
433
  private accessTokenProvider: AccessTokenProvider | null = null;
395
434
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
@@ -1324,6 +1363,12 @@ export class HttpService {
1324
1363
 
1325
1364
  // If the token expires within the refresh lead window, refresh it.
1326
1365
  if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
1366
+ // The server already answered this token's refresh with this very token:
1367
+ // it has nothing fresher until `exp`. Use it rather than asking again
1368
+ // (see `noFresherTokenThan`).
1369
+ if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
1370
+ return `Bearer ${accessToken}`;
1371
+ }
1327
1372
  const refreshed = await this.refreshAccessToken('preflight');
1328
1373
  if (refreshed) return `Bearer ${refreshed}`;
1329
1374
  if (decoded.exp > currentTime) {
@@ -1378,6 +1423,7 @@ export class HttpService {
1378
1423
  this.tokenStore.setTokens(newToken);
1379
1424
  this.notifyTokenChange();
1380
1425
  }
1426
+ this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
1381
1427
  // A success clears the failure timestamp so the next refresh is never
1382
1428
  // throttled by a stale cooldown.
1383
1429
  this.lastRefreshFailureAt = 0;
@@ -1414,6 +1460,17 @@ export class HttpService {
1414
1460
  this.lastRefreshWasRateLimited = true;
1415
1461
  }
1416
1462
 
1463
+ /**
1464
+ * Whether the server has already answered a refresh of the CURRENT access
1465
+ * token with that same, still-valid token — i.e. it has nothing fresher to
1466
+ * give until the token's `exp`. The proactive scheduler reads this so it waits
1467
+ * for expiry instead of re-asking at its 1s floor.
1468
+ */
1469
+ isAwaitingCurrentTokenExpiry(): boolean {
1470
+ const token = this.tokenStore.getAccessToken();
1471
+ return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
1472
+ }
1473
+
1417
1474
  /**
1418
1475
  * Whether the CURRENT stored access token is already past its `exp`. Drives
1419
1476
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The near-expiry mint storm that signed a busy app out (OxyHQ/Mention#1140).
3
+ *
4
+ * What the server does: `POST /session/device/token` answers with the session's
5
+ * STORED access token for as long as that token has not expired, and mints a
6
+ * new one only once it is past `exp`. The mint is budgeted at 30/min per device.
7
+ *
8
+ * What the client did with that: the scheduler and the request-time preflight
9
+ * both re-mint 60s before `exp`. The server handed the same token back, which
10
+ * counted as a success (no cooldown), so the scheduler re-armed at its 1s floor
11
+ * and every request re-ran the preflight: ~30 mints in the token's last minute,
12
+ * then a 429, whose 60s cooldown straddled the real expiry. The next request
13
+ * carried no bearer, drew a 401, found the refresh cooling down, and the
14
+ * HttpService cleared the token — which the provider reads as a sign-out.
15
+ *
16
+ * Production logs for the device showed exactly this: a burst of ~30 mints at
17
+ * ~1.4s intervals every ~16 minutes, a single mint ~78s later (the scheduler's
18
+ * 5+10+20+40s backoff through the 60s rate-limit cooldown), and a sign-out on
19
+ * the one cycle where the app was in active use across the expiry.
20
+ *
21
+ * This suite simulates that server against the real HttpService, refresh
22
+ * scheduler and 401 lane.
23
+ */
24
+ import { OxyServices } from '../OxyServices';
25
+ import { startTokenRefreshScheduler } from '../session/refresh';
26
+
27
+ const TOKEN_TTL_SECONDS = 15 * 60;
28
+ const MINT_BUDGET_PER_MINUTE = 30;
29
+
30
+ function createJwt(payload: Record<string, unknown>): string {
31
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
32
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
33
+ }
34
+
35
+ function expOf(token: string): number {
36
+ const [, payload] = token.split('.');
37
+ return (JSON.parse(Buffer.from(payload, 'base64url').toString()) as { exp: number }).exp;
38
+ }
39
+
40
+ /**
41
+ * The device mint as the server implements it, including its rate limiter.
42
+ *
43
+ * `rotateWithinSeconds` is how close to `exp` the stored token may be before the
44
+ * server rotates instead of handing it back: `0` is the server as it was (it
45
+ * rotated only a token already past `exp`), a positive value is the fixed one.
46
+ */
47
+ function createMintServer(rotateWithinSeconds: number): {
48
+ mint: () => { status: 200; token: string } | { status: 429 };
49
+ storedToken: () => string;
50
+ mints: number[];
51
+ rateLimited: number;
52
+ } {
53
+ let serial = 0;
54
+ const issue = (): string => {
55
+ serial += 1;
56
+ return createJwt({ userId: 'u', jti: `t${serial}`, exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS });
57
+ };
58
+ let stored = issue();
59
+ const mints: number[] = [];
60
+ const state = { rateLimited: 0 };
61
+ return {
62
+ mint: () => {
63
+ const now = Date.now();
64
+ if (mints.filter((at) => now - at < 60_000).length >= MINT_BUDGET_PER_MINUTE) {
65
+ state.rateLimited += 1;
66
+ return { status: 429 };
67
+ }
68
+ mints.push(now);
69
+ // `getAccessToken`: hand back the stored token unless it has expired.
70
+ if (expOf(stored) - Math.floor(now / 1000) < (rotateWithinSeconds > 0 ? rotateWithinSeconds : 0)) {
71
+ stored = issue();
72
+ }
73
+ return { status: 200, token: stored };
74
+ },
75
+ storedToken: () => stored,
76
+ mints,
77
+ get rateLimited() {
78
+ return state.rateLimited;
79
+ },
80
+ };
81
+ }
82
+
83
+ describe('a busy app across an access token expiry', () => {
84
+ const originalFetch = globalThis.fetch;
85
+
86
+ afterEach(() => {
87
+ globalThis.fetch = originalFetch;
88
+ jest.useRealTimers();
89
+ });
90
+
91
+ async function runBusyApp(rotateWithinSeconds: number): Promise<{
92
+ tokenEvents: Array<string | null>;
93
+ finalToken: string | null;
94
+ server: ReturnType<typeof createMintServer>;
95
+ }> {
96
+ jest.useFakeTimers({ now: new Date('2026-09-25T13:48:00Z') });
97
+ const server = createMintServer(rotateWithinSeconds);
98
+
99
+ // The app's own API: 401 without a live bearer, as any resource server does.
100
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
101
+ const headers = (init?.headers ?? {}) as Record<string, string>;
102
+ const bearer = headers.Authorization ?? headers.authorization;
103
+ const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : null;
104
+ if (!token || expOf(token) <= Math.floor(Date.now() / 1000)) {
105
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
106
+ status: 401,
107
+ headers: { 'content-type': 'application/json' },
108
+ });
109
+ }
110
+ return new Response(JSON.stringify({ data: { ok: true } }), {
111
+ status: 200,
112
+ headers: { 'content-type': 'application/json' },
113
+ });
114
+ }) as typeof fetch;
115
+
116
+ const oxy = new OxyServices({ baseURL: 'https://api.mention.earth', enableCache: false, enableRetry: false });
117
+ oxy.setTokens(server.storedToken());
118
+ oxy.httpService.setAuthRefreshHandler(async (reason) => {
119
+ const answer = server.mint();
120
+ if (answer.status === 429) {
121
+ // What `refreshDeviceSecretArm` does with a 429.
122
+ oxy.httpService.noteRefreshRateLimited();
123
+ return null;
124
+ }
125
+ oxy.setTokens(answer.token);
126
+ return answer.token;
127
+ });
128
+
129
+ const tokenEvents: Array<string | null> = [];
130
+ oxy.onTokensChanged((token) => tokenEvents.push(token));
131
+ const scheduler = startTokenRefreshScheduler(oxy);
132
+
133
+ // Twenty minutes of an app in use: a request every 700ms, straight across
134
+ // the first token's expiry at 14:03:00.
135
+ for (let elapsed = 0; elapsed < 20 * 60_000; elapsed += 700) {
136
+ await oxy.httpService.get('/feed').catch(() => undefined);
137
+ await jest.advanceTimersByTimeAsync(700);
138
+ }
139
+ scheduler.dispose();
140
+ return { tokenEvents, finalToken: oxy.getAccessToken(), server };
141
+ }
142
+
143
+ it('against the server that hands back an unexpired token: never cleared, never rate limited', async () => {
144
+ const { tokenEvents, finalToken, server } = await runBusyApp(0);
145
+
146
+ // Before the fix: ~30 mints of the same token, a 429, then the token was
147
+ // cleared (a run of `null` events) and the app was signed out.
148
+ expect(tokenEvents).not.toContain(null);
149
+ expect(finalToken).not.toBeNull();
150
+ expect(server.rateLimited).toBe(0);
151
+ // One rotation. The server still hands the expired token back for the
152
+ // second it expires in (it compares `exp < now`), which costs a few mints in
153
+ // that one second — nowhere near the budget.
154
+ expect(server.mints.length).toBeLessThan(MINT_BUDGET_PER_MINUTE / 3);
155
+ });
156
+
157
+ it('against the server that rotates a token near expiry: one mint per rotation', async () => {
158
+ const { tokenEvents, finalToken, server } = await runBusyApp(120);
159
+
160
+ expect(tokenEvents).not.toContain(null);
161
+ expect(finalToken).not.toBeNull();
162
+ expect(server.rateLimited).toBe(0);
163
+ expect(server.mints).toHaveLength(1);
164
+ });
165
+ });
@@ -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;