@oxyhq/core 3.16.1 → 3.17.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.
Files changed (41) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +8 -1
  3. package/dist/cjs/OxyServices.base.js +25 -0
  4. package/dist/esm/.tsbuildinfo +1 -1
  5. package/dist/esm/HttpService.js +8 -1
  6. package/dist/esm/OxyServices.base.js +25 -0
  7. package/dist/types/.tsbuildinfo +1 -1
  8. package/dist/types/OxyServices.base.d.ts +11 -0
  9. package/dist/types/index.d.ts +1 -0
  10. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  11. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  12. package/dist/types/mixins/OxyServices.applications.d.ts +1 -0
  13. package/dist/types/mixins/OxyServices.assets.d.ts +1 -0
  14. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  15. package/dist/types/mixins/OxyServices.civic.d.ts +1 -0
  16. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  17. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  18. package/dist/types/mixins/OxyServices.features.d.ts +1 -0
  19. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  20. package/dist/types/mixins/OxyServices.identity.d.ts +1 -0
  21. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  22. package/dist/types/mixins/OxyServices.links.d.ts +1 -0
  23. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  24. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  25. package/dist/types/mixins/OxyServices.nodes.d.ts +1 -0
  26. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  27. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  28. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  29. package/dist/types/mixins/OxyServices.reputation.d.ts +1 -0
  30. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  31. package/dist/types/mixins/OxyServices.silent.d.ts +1 -0
  32. package/dist/types/mixins/OxyServices.sso.d.ts +1 -0
  33. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  34. package/dist/types/mixins/OxyServices.user.d.ts +1 -0
  35. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  36. package/dist/types/mixins/OxyServices.workspaces.d.ts +1 -0
  37. package/package.json +1 -1
  38. package/src/HttpService.ts +8 -1
  39. package/src/OxyServices.base.ts +25 -0
  40. package/src/__tests__/inSessionRefresh.test.ts +193 -0
  41. package/src/index.ts +4 -0
@@ -142,6 +142,17 @@ export declare class OxyServicesBase {
142
142
  * Get the raw access token (for constructing anchor URLs when needed)
143
143
  */
144
144
  getAccessToken(): string | null;
145
+ /**
146
+ * Decode the current access token and return its `exp` claim in SECONDS since
147
+ * the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
148
+ * the token is opaque/undecodable, or it carries no numeric `exp`.
149
+ *
150
+ * Exposed so `@oxyhq/services` can schedule a PROACTIVE in-session refresh a
151
+ * fixed lead before expiry without re-importing a JWT decoder (and without
152
+ * duplicating the `jwt-decode` dependency in the RN bundle). HttpService keeps
153
+ * the per-request preflight refresh; this powers the idle/background timer.
154
+ */
155
+ getAccessTokenExpiry(): number | null;
145
156
  /**
146
157
  * Set the acting-as identity for managed accounts.
147
158
  *
@@ -20,6 +20,7 @@ import './crypto/polyfill';
20
20
  export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
21
21
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
22
22
  export type { LinkedHttpClient } from './OxyServices.base';
23
+ export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
23
24
  export { AuthManager, createAuthManager } from './AuthManager';
24
25
  export type { StorageAdapter, AuthStateChangeCallback, AuthMethod, AuthManagerConfig, } from './AuthManager';
25
26
  export type { AuthManagerAccount, RestoreFromCookiesResult, RestoreFromCookiesOptions, SwitchAuthuserResult, } from './AuthManagerTypes';
@@ -54,6 +54,7 @@ export declare function OxyServicesAnalyticsMixin<T extends typeof OxyServicesBa
54
54
  getCurrentUserId(): string | null;
55
55
  hasValidToken(): boolean;
56
56
  getAccessToken(): string | null;
57
+ getAccessTokenExpiry(): number | null;
57
58
  setActingAs(userId: string | null): void;
58
59
  getActingAs(): string | null;
59
60
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -90,6 +90,7 @@ export declare function OxyServicesAppDataMixin<T extends typeof OxyServicesBase
90
90
  getCurrentUserId(): string | null;
91
91
  hasValidToken(): boolean;
92
92
  getAccessToken(): string | null;
93
+ getAccessTokenExpiry(): number | null;
93
94
  setActingAs(userId: string | null): void;
94
95
  getActingAs(): string | null;
95
96
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -475,6 +475,7 @@ export declare function OxyServicesApplicationsMixin<T extends typeof OxyService
475
475
  getCurrentUserId(): string | null;
476
476
  hasValidToken(): boolean;
477
477
  getAccessToken(): string | null;
478
+ getAccessTokenExpiry(): number | null;
478
479
  setActingAs(userId: string | null): void;
479
480
  getActingAs(): string | null;
480
481
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -134,6 +134,7 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
134
134
  getCurrentUserId(): string | null;
135
135
  hasValidToken(): boolean;
136
136
  getAccessToken(): string | null;
137
+ getAccessTokenExpiry(): number | null;
137
138
  setActingAs(userId: string | null): void;
138
139
  getActingAs(): string | null;
139
140
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -569,6 +569,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
569
569
  getCurrentUserId(): string | null;
570
570
  hasValidToken(): boolean;
571
571
  getAccessToken(): string | null;
572
+ getAccessTokenExpiry(): number | null;
572
573
  setActingAs(userId: string | null): void;
573
574
  getActingAs(): string | null;
574
575
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -492,6 +492,7 @@ export declare function OxyServicesCivicMixin<T extends typeof OxyServicesBase>(
492
492
  getCurrentUserId(): string | null;
493
493
  hasValidToken(): boolean;
494
494
  getAccessToken(): string | null;
495
+ getAccessTokenExpiry(): number | null;
495
496
  setActingAs(userId: string | null): void;
496
497
  getActingAs(): string | null;
497
498
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -82,6 +82,7 @@ export declare function OxyServicesContactsMixin<T extends typeof OxyServicesBas
82
82
  getCurrentUserId(): string | null;
83
83
  hasValidToken(): boolean;
84
84
  getAccessToken(): string | null;
85
+ getAccessTokenExpiry(): number | null;
85
86
  setActingAs(userId: string | null): void;
86
87
  getActingAs(): string | null;
87
88
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -84,6 +84,7 @@ export declare function OxyServicesDevicesMixin<T extends typeof OxyServicesBase
84
84
  getCurrentUserId(): string | null;
85
85
  hasValidToken(): boolean;
86
86
  getAccessToken(): string | null;
87
+ getAccessTokenExpiry(): number | null;
87
88
  setActingAs(userId: string | null): void;
88
89
  getActingAs(): string | null;
89
90
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -232,6 +232,7 @@ export declare function OxyServicesFeaturesMixin<T extends typeof OxyServicesBas
232
232
  getCurrentUserId(): string | null;
233
233
  hasValidToken(): boolean;
234
234
  getAccessToken(): string | null;
235
+ getAccessTokenExpiry(): number | null;
235
236
  setActingAs(userId: string | null): void;
236
237
  getActingAs(): string | null;
237
238
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -286,6 +286,7 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
286
286
  getCurrentUserId(): string | null;
287
287
  hasValidToken(): boolean;
288
288
  getAccessToken(): string | null;
289
+ getAccessTokenExpiry(): number | null;
289
290
  setActingAs(userId: string | null): void;
290
291
  getActingAs(): string | null;
291
292
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -229,6 +229,7 @@ export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBas
229
229
  getCurrentUserId(): string | null;
230
230
  hasValidToken(): boolean;
231
231
  getAccessToken(): string | null;
232
+ getAccessTokenExpiry(): number | null;
232
233
  setActingAs(userId: string | null): void;
233
234
  getActingAs(): string | null;
234
235
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -69,6 +69,7 @@ export declare function OxyServicesLanguageMixin<T extends typeof OxyServicesBas
69
69
  getCurrentUserId(): string | null;
70
70
  hasValidToken(): boolean;
71
71
  getAccessToken(): string | null;
72
+ getAccessTokenExpiry(): number | null;
72
73
  setActingAs(userId: string | null): void;
73
74
  getActingAs(): string | null;
74
75
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -82,6 +82,7 @@ export declare function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(
82
82
  getCurrentUserId(): string | null;
83
83
  hasValidToken(): boolean;
84
84
  getAccessToken(): string | null;
85
+ getAccessTokenExpiry(): number | null;
85
86
  setActingAs(userId: string | null): void;
86
87
  getActingAs(): string | null;
87
88
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -52,6 +52,7 @@ export declare function OxyServicesLocationMixin<T extends typeof OxyServicesBas
52
52
  getCurrentUserId(): string | null;
53
53
  hasValidToken(): boolean;
54
54
  getAccessToken(): string | null;
55
+ getAccessTokenExpiry(): number | null;
55
56
  setActingAs(userId: string | null): void;
56
57
  getActingAs(): string | null;
57
58
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -124,6 +124,7 @@ export declare function OxyServicesManagedAccountsMixin<T extends typeof OxyServ
124
124
  getCurrentUserId(): string | null;
125
125
  hasValidToken(): boolean;
126
126
  getAccessToken(): string | null;
127
+ getAccessTokenExpiry(): number | null;
127
128
  setActingAs(userId: string | null): void;
128
129
  getActingAs(): string | null;
129
130
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -222,6 +222,7 @@ export declare function OxyServicesNodesMixin<T extends typeof OxyServicesBase>(
222
222
  getCurrentUserId(): string | null;
223
223
  hasValidToken(): boolean;
224
224
  getAccessToken(): string | null;
225
+ getAccessTokenExpiry(): number | null;
225
226
  setActingAs(userId: string | null): void;
226
227
  getActingAs(): string | null;
227
228
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -99,6 +99,7 @@ export declare function OxyServicesPaymentMixin<T extends typeof OxyServicesBase
99
99
  getCurrentUserId(): string | null;
100
100
  hasValidToken(): boolean;
101
101
  getAccessToken(): string | null;
102
+ getAccessTokenExpiry(): number | null;
102
103
  setActingAs(userId: string | null): void;
103
104
  getActingAs(): string | null;
104
105
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -128,6 +128,7 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
128
128
  getCurrentUserId(): string | null;
129
129
  hasValidToken(): boolean;
130
130
  getAccessToken(): string | null;
131
+ getAccessTokenExpiry(): number | null;
131
132
  setActingAs(userId: string | null): void;
132
133
  getActingAs(): string | null;
133
134
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -71,6 +71,7 @@ export declare function OxyServicesRedirectAuthMixin<T extends typeof OxyService
71
71
  getCurrentUserId(): string | null;
72
72
  hasValidToken(): boolean;
73
73
  getAccessToken(): string | null;
74
+ getAccessTokenExpiry(): number | null;
74
75
  setActingAs(userId: string | null): void;
75
76
  getActingAs(): string | null;
76
77
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -417,6 +417,7 @@ export declare function OxyServicesReputationMixin<T extends typeof OxyServicesB
417
417
  getCurrentUserId(): string | null;
418
418
  hasValidToken(): boolean;
419
419
  getAccessToken(): string | null;
420
+ getAccessTokenExpiry(): number | null;
420
421
  setActingAs(userId: string | null): void;
421
422
  getActingAs(): string | null;
422
423
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -66,6 +66,7 @@ export declare function OxyServicesSecurityMixin<T extends typeof OxyServicesBas
66
66
  getCurrentUserId(): string | null;
67
67
  hasValidToken(): boolean;
68
68
  getAccessToken(): string | null;
69
+ getAccessTokenExpiry(): number | null;
69
70
  setActingAs(userId: string | null): void;
70
71
  getActingAs(): string | null;
71
72
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -109,6 +109,7 @@ export declare function OxyServicesSilentAuthMixin<T extends typeof OxyServicesB
109
109
  getCurrentUserId(): string | null;
110
110
  hasValidToken(): boolean;
111
111
  getAccessToken(): string | null;
112
+ getAccessTokenExpiry(): number | null;
112
113
  setActingAs(userId: string | null): void;
113
114
  getActingAs(): string | null;
114
115
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -94,6 +94,7 @@ export declare function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Ba
94
94
  getCurrentUserId(): string | null;
95
95
  hasValidToken(): boolean;
96
96
  getAccessToken(): string | null;
97
+ getAccessTokenExpiry(): number | null;
97
98
  setActingAs(userId: string | null): void;
98
99
  getActingAs(): string | null;
99
100
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -92,6 +92,7 @@ export declare function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>
92
92
  getCurrentUserId(): string | null;
93
93
  hasValidToken(): boolean;
94
94
  getAccessToken(): string | null;
95
+ getAccessTokenExpiry(): number | null;
95
96
  setActingAs(userId: string | null): void;
96
97
  getActingAs(): string | null;
97
98
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -363,6 +363,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
363
363
  getCurrentUserId(): string | null;
364
364
  hasValidToken(): boolean;
365
365
  getAccessToken(): string | null;
366
+ getAccessTokenExpiry(): number | null;
366
367
  setActingAs(userId: string | null): void;
367
368
  getActingAs(): string | null;
368
369
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -277,6 +277,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
277
277
  getCurrentUserId(): string | null;
278
278
  hasValidToken(): boolean;
279
279
  getAccessToken(): string | null;
280
+ getAccessTokenExpiry(): number | null;
280
281
  setActingAs(userId: string | null): void;
281
282
  getActingAs(): string | null;
282
283
  waitForAuth(timeoutMs?: number): Promise<boolean>;
@@ -198,6 +198,7 @@ export declare function OxyServicesWorkspacesMixin<T extends typeof OxyServicesB
198
198
  getCurrentUserId(): string | null;
199
199
  hasValidToken(): boolean;
200
200
  getAccessToken(): string | null;
201
+ getAccessTokenExpiry(): number | null;
201
202
  setActingAs(userId: string | null): void;
202
203
  getActingAs(): string | null;
203
204
  waitForAuth(timeoutMs?: number): Promise<boolean>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.16.1",
3
+ "version": "3.17.0",
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",
@@ -524,7 +524,14 @@ export class HttpService {
524
524
  if (response.status === 401 && !config._isAuthRetry) {
525
525
  const refreshed = await this.refreshAccessToken('response-401');
526
526
  if (refreshed) {
527
- return this.request<T>({ ...config, _isAuthRetry: true, retry: false });
527
+ // `deduplicate: false` is REQUIRED on the retry (mirrors the 403
528
+ // CSRF retry below). This re-issue runs while the ORIGINAL request
529
+ // is still in-flight under its dedupe key; the refreshed token is
530
+ // for the SAME user, so the identity-scoped key is UNCHANGED — a
531
+ // deduplicated retry would resolve to the still-pending original
532
+ // and await itself (deadlock). Opting the retry out of dedupe makes
533
+ // it a fresh request.
534
+ return this.request<T>({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
528
535
  }
529
536
  // Refresh failed or no token — clear tokens and stale CSRF
530
537
  this.tokenStore.clearTokens();
@@ -310,6 +310,31 @@ export class OxyServicesBase {
310
310
  return this.httpService.getAccessToken();
311
311
  }
312
312
 
313
+ /**
314
+ * Decode the current access token and return its `exp` claim in SECONDS since
315
+ * the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
316
+ * the token is opaque/undecodable, or it carries no numeric `exp`.
317
+ *
318
+ * Exposed so `@oxyhq/services` can schedule a PROACTIVE in-session refresh a
319
+ * fixed lead before expiry without re-importing a JWT decoder (and without
320
+ * duplicating the `jwt-decode` dependency in the RN bundle). HttpService keeps
321
+ * the per-request preflight refresh; this powers the idle/background timer.
322
+ */
323
+ public getAccessTokenExpiry(): number | null {
324
+ const token = this.httpService.getAccessToken();
325
+ if (!token) {
326
+ return null;
327
+ }
328
+ try {
329
+ const decoded = jwtDecode<JwtPayload>(token);
330
+ return typeof decoded.exp === 'number' ? decoded.exp : null;
331
+ } catch {
332
+ // A malformed / non-JWT token has no usable expiry — fall back to the
333
+ // reactive 401 refresh path instead of a scheduled one.
334
+ return null;
335
+ }
336
+ }
337
+
313
338
  /**
314
339
  * Set the acting-as identity for managed accounts.
315
340
  *
@@ -0,0 +1,193 @@
1
+ /**
2
+ * In-session access-token refresh plumbing.
3
+ *
4
+ * These guard the HttpService surface that `@oxyhq/services`' OxyContext now
5
+ * drives via `setAuthRefreshHandler`: a 401 re-mints through the handler and
6
+ * retries the ORIGINAL request with the fresh token; an unrecoverable refresh
7
+ * clears tokens and notifies listeners (the reconcile signal that flips the RN
8
+ * provider out of its zombie logged-in state); concurrent refreshes coalesce and
9
+ * a failed refresh is cooldown-guarded (no refresh storm). Plus the new
10
+ * `OxyServices.getAccessTokenExpiry()` helper the proactive scheduler reads.
11
+ */
12
+ import { HttpService } from '../HttpService';
13
+ import { OxyServices } from '../OxyServices';
14
+
15
+ interface FetchCall {
16
+ url: string;
17
+ init: RequestInit | undefined;
18
+ }
19
+
20
+ function createJwt(payload: Record<string, unknown>): string {
21
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
22
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
23
+ }
24
+
25
+ function jsonResponse(data: unknown): Response {
26
+ return new Response(JSON.stringify({ data }), {
27
+ status: 200,
28
+ headers: { 'content-type': 'application/json' },
29
+ });
30
+ }
31
+
32
+ function unauthorized(): Response {
33
+ return new Response(JSON.stringify({ error: 'token expired' }), {
34
+ status: 401,
35
+ statusText: 'Unauthorized',
36
+ headers: { 'content-type': 'application/json' },
37
+ });
38
+ }
39
+
40
+ function readHeaders(init: RequestInit | undefined): Record<string, string> {
41
+ const headers = init?.headers;
42
+ if (!headers) return {};
43
+ if (headers instanceof Headers) return Object.fromEntries(headers.entries());
44
+ if (Array.isArray(headers)) return Object.fromEntries(headers);
45
+ return headers as Record<string, string>;
46
+ }
47
+
48
+ const farFutureExp = (): number => Math.floor(Date.now() / 1000) + 3600;
49
+
50
+ describe('HttpService in-session refresh handler', () => {
51
+ const originalFetch = globalThis.fetch;
52
+
53
+ afterEach(() => {
54
+ globalThis.fetch = originalFetch;
55
+ jest.restoreAllMocks();
56
+ });
57
+
58
+ it('refreshes via the handler on a 401 and retries the original request with the fresh token', async () => {
59
+ const oldToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'old' });
60
+ const newToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'new' });
61
+ const calls: FetchCall[] = [];
62
+ let feedAttempts = 0;
63
+ globalThis.fetch = async (input, init) => {
64
+ const url = String(input);
65
+ calls.push({ url, init });
66
+ if (url.endsWith('/feed')) {
67
+ feedAttempts += 1;
68
+ if (feedAttempts === 1) return unauthorized();
69
+ }
70
+ return jsonResponse({ ok: true });
71
+ };
72
+
73
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
74
+ http.setTokens(oldToken);
75
+
76
+ let handlerCalls = 0;
77
+ const reasons: string[] = [];
78
+ http.setAuthRefreshHandler(async (reason) => {
79
+ handlerCalls += 1;
80
+ reasons.push(reason);
81
+ // Simulate a silent arm planting the rotated token internally.
82
+ http.setTokens(newToken);
83
+ return newToken;
84
+ });
85
+
86
+ const result = await http.post('/feed', { cursor: 0 }, { retry: false });
87
+
88
+ expect(result).toEqual({ ok: true });
89
+ expect(handlerCalls).toBe(1);
90
+ expect(reasons).toEqual(['response-401']);
91
+
92
+ const feedCalls = calls.filter((call) => call.url.endsWith('/feed'));
93
+ expect(feedCalls).toHaveLength(2);
94
+ // The original request is retried carrying the FRESH bearer.
95
+ expect(readHeaders(feedCalls[1].init).Authorization).toBe(`Bearer ${newToken}`);
96
+ expect(http.getAccessToken()).toBe(newToken);
97
+ });
98
+
99
+ it('clears tokens and notifies listeners when the handler cannot refresh on a 401 (reconcile signal)', async () => {
100
+ globalThis.fetch = async (input) => {
101
+ const url = String(input);
102
+ if (url.endsWith('/feed')) return unauthorized();
103
+ return jsonResponse({ ok: true });
104
+ };
105
+
106
+ const oxy = new OxyServices({ baseURL: 'https://api.mention.earth' });
107
+ oxy.setTokens(createJwt({ userId: 'u', exp: farFutureExp() }));
108
+
109
+ const tokenEvents: Array<string | null> = [];
110
+ oxy.onTokensChanged((token) => tokenEvents.push(token));
111
+
112
+ oxy.getClient().setAuthRefreshHandler(async () => null);
113
+
114
+ await expect(oxy.getClient().post('/feed', {}, { retry: false })).rejects.toBeDefined();
115
+
116
+ // The dead session reconciles: token cleared + a null change emitted (which
117
+ // OxyContext's `handleTokenChange(null)` turns into a local sign-out).
118
+ expect(oxy.getAccessToken()).toBeNull();
119
+ expect(tokenEvents).toContain(null);
120
+ });
121
+
122
+ it('coalesces concurrent refreshes into a single handler invocation', async () => {
123
+ globalThis.fetch = async () => jsonResponse({ ok: true });
124
+ const newToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'fresh' });
125
+
126
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
127
+ http.setTokens(createJwt({ userId: 'u', exp: farFutureExp() }));
128
+
129
+ let handlerCalls = 0;
130
+ http.setAuthRefreshHandler(async () => {
131
+ handlerCalls += 1;
132
+ await new Promise((resolve) => setTimeout(resolve, 10));
133
+ http.setTokens(newToken);
134
+ return newToken;
135
+ });
136
+
137
+ const [a, b, c] = await Promise.all([
138
+ http.refreshAccessToken('preflight'),
139
+ http.refreshAccessToken('preflight'),
140
+ http.refreshAccessToken('preflight'),
141
+ ]);
142
+
143
+ expect(handlerCalls).toBe(1);
144
+ expect([a, b, c]).toEqual([newToken, newToken, newToken]);
145
+ });
146
+
147
+ it('cooldown-guards a failed refresh so it does not storm the handler', async () => {
148
+ globalThis.fetch = async () => jsonResponse({ ok: true });
149
+
150
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
151
+ http.setTokens(createJwt({ userId: 'u', exp: farFutureExp() }));
152
+
153
+ let handlerCalls = 0;
154
+ http.setAuthRefreshHandler(async () => {
155
+ handlerCalls += 1;
156
+ return null;
157
+ });
158
+
159
+ const first = await http.refreshAccessToken('preflight');
160
+ const second = await http.refreshAccessToken('preflight');
161
+
162
+ expect(first).toBeNull();
163
+ expect(second).toBeNull();
164
+ // The second call is inside the post-failure cooldown → handler not re-run.
165
+ expect(handlerCalls).toBe(1);
166
+ });
167
+ });
168
+
169
+ describe('OxyServices.getAccessTokenExpiry', () => {
170
+ it('returns the JWT exp (seconds) of the current access token', () => {
171
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
172
+ const exp = Math.floor(Date.now() / 1000) + 1234;
173
+ oxy.setTokens(createJwt({ userId: 'u', exp }));
174
+ expect(oxy.getAccessTokenExpiry()).toBe(exp);
175
+ });
176
+
177
+ it('returns null when there is no access token', () => {
178
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
179
+ expect(oxy.getAccessTokenExpiry()).toBeNull();
180
+ });
181
+
182
+ it('returns null for an opaque / non-JWT token', () => {
183
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
184
+ oxy.setTokens('opaque-not-a-jwt');
185
+ expect(oxy.getAccessTokenExpiry()).toBeNull();
186
+ });
187
+
188
+ it('returns null for a JWT with no numeric exp claim', () => {
189
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
190
+ oxy.setTokens(createJwt({ userId: 'u' }));
191
+ expect(oxy.getAccessTokenExpiry()).toBeNull();
192
+ });
193
+ });
package/src/index.ts CHANGED
@@ -26,6 +26,10 @@ import './crypto/polyfill';
26
26
  export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
27
27
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
28
28
  export type { LinkedHttpClient } from './OxyServices.base';
29
+ // Auth-refresh handler surface — consumed by `@oxyhq/services`'s OxyContext to
30
+ // install an in-session access-token refresh handler on the owner HttpService
31
+ // (the linked-client refresh path delegates back to it).
32
+ export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
29
33
 
30
34
  // ---------------------------------------------------------------------------
31
35
  // Authentication