@oxyhq/core 3.4.17 → 3.4.19

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.
@@ -15,6 +15,7 @@
15
15
  import type { OxyConfig } from './models/interfaces';
16
16
  export type AuthRefreshReason = 'preflight' | 'response-401';
17
17
  export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
18
+ export type AccessTokenProvider = () => string | null;
18
19
  export interface RequestOptions {
19
20
  cache?: boolean;
20
21
  cacheTTL?: number;
@@ -52,6 +53,7 @@ export declare class HttpService {
52
53
  private tokenRefreshPromise;
53
54
  private tokenRefreshCooldownUntil;
54
55
  private authRefreshHandler;
56
+ private accessTokenProvider;
55
57
  /**
56
58
  * Fan-out listeners notified on EVERY access-token change on this instance:
57
59
  * explicit `setTokens`, `clearTokens`, an AuthManager-owned refresh, and the
@@ -64,6 +66,7 @@ export declare class HttpService {
64
66
  private _actingAsUserId;
65
67
  private requestMetrics;
66
68
  constructor(config: OxyConfig);
69
+ private syncAccessTokenFromProvider;
67
70
  /**
68
71
  * Robust FormData detection that works in browser, React Native, and
69
72
  * Node.js polyfill environments.
@@ -190,6 +193,7 @@ export declare class HttpService {
190
193
  getActingAs(): string | null;
191
194
  setTokens(accessToken: string): void;
192
195
  setAuthRefreshHandler(handler: AuthRefreshHandler | null): void;
196
+ setAccessTokenProvider(provider: AccessTokenProvider | null): void;
193
197
  clearTokens(): void;
194
198
  /**
195
199
  * Subscribe to access-token changes on this instance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.4.17",
3
+ "version": "3.4.19",
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",
@@ -37,6 +37,7 @@ interface JwtPayload {
37
37
 
38
38
  export type AuthRefreshReason = 'preflight' | 'response-401';
39
39
  export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
40
+ export type AccessTokenProvider = () => string | null;
40
41
 
41
42
  /**
42
43
  * Structural type that captures the multipart-write surface every supported
@@ -171,6 +172,7 @@ export class HttpService {
171
172
  private tokenRefreshPromise: Promise<string | null> | null = null;
172
173
  private tokenRefreshCooldownUntil: number = 0;
173
174
  private authRefreshHandler: AuthRefreshHandler | null = null;
175
+ private accessTokenProvider: AccessTokenProvider | null = null;
174
176
 
175
177
  /**
176
178
  * Fan-out listeners notified on EVERY access-token change on this instance:
@@ -216,6 +218,29 @@ export class HttpService {
216
218
  );
217
219
  }
218
220
 
221
+ private syncAccessTokenFromProvider(): string | null {
222
+ if (!this.accessTokenProvider) {
223
+ return this.tokenStore.getAccessToken();
224
+ }
225
+
226
+ const providedToken = this.accessTokenProvider();
227
+ const currentToken = this.tokenStore.getAccessToken();
228
+
229
+ if (providedToken) {
230
+ if (providedToken !== currentToken) {
231
+ this.tokenStore.setTokens(providedToken);
232
+ this.notifyTokenChange();
233
+ }
234
+ return providedToken;
235
+ }
236
+
237
+ if (currentToken) {
238
+ this.clearTokens();
239
+ }
240
+
241
+ return null;
242
+ }
243
+
219
244
  /**
220
245
  * Robust FormData detection that works in browser, React Native, and
221
246
  * Node.js polyfill environments.
@@ -856,7 +881,7 @@ export class HttpService {
856
881
  * Get auth header with automatic token refresh
857
882
  */
858
883
  private async getAuthHeader(): Promise<string | null> {
859
- const accessToken = this.tokenStore.getAccessToken();
884
+ const accessToken = this.syncAccessTokenFromProvider();
860
885
  if (!accessToken) {
861
886
  return null;
862
887
  }
@@ -869,7 +894,10 @@ export class HttpService {
869
894
  if (decoded.exp && decoded.exp - currentTime < 60) {
870
895
  const refreshed = await this.refreshAccessToken('preflight');
871
896
  if (refreshed) return `Bearer ${refreshed}`;
872
- // Refresh failed — don't use the expired token (would cause 401 loop)
897
+ if (decoded.exp > currentTime) {
898
+ return `Bearer ${accessToken}`;
899
+ }
900
+ // Refresh failed — don't use an expired token (would cause 401 loop)
873
901
  return null;
874
902
  }
875
903
 
@@ -990,6 +1018,10 @@ export class HttpService {
990
1018
  this.authRefreshHandler = handler;
991
1019
  }
992
1020
 
1021
+ setAccessTokenProvider(provider: AccessTokenProvider | null): void {
1022
+ this.accessTokenProvider = provider;
1023
+ }
1024
+
993
1025
  clearTokens(): void {
994
1026
  this.tokenStore.clearTokens();
995
1027
  this.tokenStore.clearCsrfToken();
@@ -149,12 +149,10 @@ export class OxyServicesBase {
149
149
 
150
150
  syncToken(this.getAccessToken());
151
151
  const unsubscribe = this.onTokensChanged(syncToken);
152
+ client.setAccessTokenProvider(() => this.getAccessToken());
152
153
  client.setAuthRefreshHandler(async (reason: AuthRefreshReason) => {
153
154
  const refreshed = await this.httpService.refreshAccessToken(reason);
154
155
  if (!refreshed) {
155
- if (reason === 'response-401') {
156
- this.clearTokens();
157
- }
158
156
  return null;
159
157
  }
160
158
 
@@ -167,6 +165,7 @@ export class OxyServicesBase {
167
165
  dispose: () => {
168
166
  unsubscribe();
169
167
  client.setAuthRefreshHandler(null);
168
+ client.setAccessTokenProvider(null);
170
169
  client.clearTokens();
171
170
  },
172
171
  };
@@ -63,6 +63,74 @@ describe('HttpService CSRF behavior', () => {
63
63
  expect(headers['X-CSRF-Token']).toBeUndefined();
64
64
  });
65
65
 
66
+ it('keeps a valid near-expiry bearer token when preflight refresh cannot refresh', async () => {
67
+ const calls: FetchCall[] = [];
68
+ globalThis.fetch = async (input, init) => {
69
+ const url = String(input);
70
+ calls.push({ url, init });
71
+ return jsonResponse({ ok: true });
72
+ };
73
+
74
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
75
+ const accessToken = createJwt({
76
+ userId: 'user_1',
77
+ exp: Math.floor(Date.now() / 1000) + 30,
78
+ });
79
+ let refreshAttempts = 0;
80
+ http.setTokens(accessToken);
81
+ http.setAuthRefreshHandler(async () => {
82
+ refreshAttempts += 1;
83
+ return null;
84
+ });
85
+
86
+ await http.post('/posts', { text: 'hello' });
87
+
88
+ expect(refreshAttempts).toBe(1);
89
+ expect(calls).toHaveLength(1);
90
+ expect(calls[0].url).toBe('https://api.mention.earth/posts');
91
+ const headers = readHeaders(calls[0].init);
92
+ expect(headers.Authorization).toBe(`Bearer ${accessToken}`);
93
+ expect(headers['X-CSRF-Token']).toBeUndefined();
94
+ });
95
+
96
+ it('does not use an expired bearer token when preflight refresh cannot refresh', async () => {
97
+ const calls: FetchCall[] = [];
98
+ globalThis.fetch = async (input, init) => {
99
+ const url = String(input);
100
+ calls.push({ url, init });
101
+ if (url.endsWith('/csrf-token')) {
102
+ return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
103
+ status: 200,
104
+ headers: { 'content-type': 'application/json' },
105
+ });
106
+ }
107
+ return jsonResponse({ ok: true });
108
+ };
109
+
110
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
111
+ const accessToken = createJwt({
112
+ userId: 'user_1',
113
+ exp: Math.floor(Date.now() / 1000) - 10,
114
+ });
115
+ let refreshAttempts = 0;
116
+ http.setTokens(accessToken);
117
+ http.setAuthRefreshHandler(async () => {
118
+ refreshAttempts += 1;
119
+ return null;
120
+ });
121
+
122
+ await http.post('/posts', { text: 'hello' });
123
+
124
+ expect(refreshAttempts).toBe(1);
125
+ expect(calls.map((call) => call.url)).toEqual([
126
+ 'https://api.mention.earth/csrf-token',
127
+ 'https://api.mention.earth/posts',
128
+ ]);
129
+ const headers = readHeaders(calls[1].init);
130
+ expect(headers.Authorization).toBeUndefined();
131
+ expect(headers['X-CSRF-Token']).toBe('csrf_1');
132
+ });
133
+
66
134
  it('still fetches csrf-token for cookie-authenticated writes without bearer', async () => {
67
135
  const calls: FetchCall[] = [];
68
136
  globalThis.fetch = async (input, init) => {
@@ -1,9 +1,19 @@
1
1
  import { OxyServices } from '../OxyServices';
2
2
 
3
+ interface FetchCall {
4
+ url: string;
5
+ init: RequestInit | undefined;
6
+ }
7
+
3
8
  function createServices(): OxyServices {
4
9
  return new OxyServices({ baseURL: 'https://api.oxy.so' });
5
10
  }
6
11
 
12
+ function createJwt(payload: Record<string, unknown>): string {
13
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
14
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
15
+ }
16
+
7
17
  function jsonResponse(data: unknown): Response {
8
18
  return new Response(JSON.stringify({ data }), {
9
19
  status: 200,
@@ -11,7 +21,28 @@ function jsonResponse(data: unknown): Response {
11
21
  });
12
22
  }
13
23
 
24
+ function readHeaders(init: RequestInit | undefined): Record<string, string> {
25
+ const headers = init?.headers;
26
+ if (!headers) {
27
+ return {};
28
+ }
29
+ if (headers instanceof Headers) {
30
+ return Object.fromEntries(headers.entries());
31
+ }
32
+ if (Array.isArray(headers)) {
33
+ return Object.fromEntries(headers);
34
+ }
35
+ return headers as Record<string, string>;
36
+ }
37
+
14
38
  describe('OxyServices.createLinkedClient', () => {
39
+ const originalFetch = globalThis.fetch;
40
+
41
+ afterEach(() => {
42
+ globalThis.fetch = originalFetch;
43
+ jest.restoreAllMocks();
44
+ });
45
+
15
46
  it('mirrors token changes from the session owner', () => {
16
47
  const oxy = createServices();
17
48
  const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
@@ -56,7 +87,7 @@ describe('OxyServices.createLinkedClient', () => {
56
87
  linked.dispose();
57
88
  });
58
89
 
59
- it('clears the session owner when a linked response 401 cannot refresh', async () => {
90
+ it('keeps the session owner intact when a linked response 401 cannot refresh', async () => {
60
91
  const oxy = createServices();
61
92
  oxy.setTokens('stale_access');
62
93
  const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
@@ -64,9 +95,73 @@ describe('OxyServices.createLinkedClient', () => {
64
95
  const refreshed = await linked.client.refreshAccessToken('response-401');
65
96
 
66
97
  expect(refreshed).toBeNull();
67
- expect(oxy.getAccessToken()).toBeNull();
98
+ expect(oxy.getAccessToken()).toBe('stale_access');
99
+ expect(linked.client.getAccessToken()).toBe('stale_access');
100
+
101
+ linked.dispose();
102
+ });
103
+
104
+ it('resynchronizes from the session owner after a linked app 401 clears the local token', async () => {
105
+ const calls: FetchCall[] = [];
106
+ let queueWriteAttempts = 0;
107
+ globalThis.fetch = async (input, init) => {
108
+ const url = String(input);
109
+ calls.push({ url, init });
110
+
111
+ if (url.endsWith('/csrf-token')) {
112
+ return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
113
+ status: 200,
114
+ headers: { 'content-type': 'application/json' },
115
+ });
116
+ }
117
+
118
+ if (url.endsWith('/api/queue/current')) {
119
+ queueWriteAttempts += 1;
120
+ if (queueWriteAttempts === 1) {
121
+ return new Response(JSON.stringify({ error: 'MISSING_TOKEN' }), {
122
+ status: 401,
123
+ statusText: 'Unauthorized',
124
+ headers: { 'content-type': 'application/json' },
125
+ });
126
+ }
127
+ }
128
+
129
+ return jsonResponse({ ok: true });
130
+ };
131
+
132
+ const oxy = createServices();
133
+ const accessToken = createJwt({
134
+ userId: 'user_1',
135
+ exp: Math.floor(Date.now() / 1000) + 3600,
136
+ });
137
+ oxy.setTokens(accessToken);
138
+ oxy.getClient().setAuthRefreshHandler(async () => null);
139
+ const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
140
+
141
+ await expect(linked.client.put('/api/queue/current', { trackId: 'track_1' }, { retry: false })).rejects.toMatchObject({
142
+ message: 'MISSING_TOKEN',
143
+ status: 401,
144
+ });
145
+
146
+ expect(oxy.getAccessToken()).toBe(accessToken);
68
147
  expect(linked.client.getAccessToken()).toBeNull();
69
148
 
149
+ await linked.client.put('/api/queue/current', { trackId: 'track_2' });
150
+
151
+ expect(calls.map((call) => call.url)).toEqual([
152
+ 'https://api.syra.fm/api/queue/current',
153
+ 'https://api.syra.fm/api/queue/current',
154
+ ]);
155
+ expect(linked.client.getAccessToken()).toBe(accessToken);
156
+
157
+ const firstHeaders = readHeaders(calls[0]?.init);
158
+ expect(firstHeaders.Authorization).toBe(`Bearer ${accessToken}`);
159
+ expect(firstHeaders['X-CSRF-Token']).toBeUndefined();
160
+
161
+ const secondHeaders = readHeaders(calls[1]?.init);
162
+ expect(secondHeaders.Authorization).toBe(`Bearer ${accessToken}`);
163
+ expect(secondHeaders['X-CSRF-Token']).toBeUndefined();
164
+
70
165
  linked.dispose();
71
166
  });
72
167
 
@@ -98,22 +193,17 @@ describe('OxyServices.createLinkedClient', () => {
98
193
  });
99
194
 
100
195
  it('joins linked base URLs with relative paths that omit the leading slash', async () => {
101
- const originalFetch = globalThis.fetch;
102
196
  const fetchMock = jest.fn(async () => jsonResponse({ ok: true }));
103
197
  globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
104
198
 
105
- try {
106
- const oxy = createServices();
107
- const linked = oxy.createLinkedClient({ baseURL: 'https://api.mention.earth' });
199
+ const oxy = createServices();
200
+ const linked = oxy.createLinkedClient({ baseURL: 'https://api.mention.earth' });
108
201
 
109
- await linked.client.get('profile/settings/me');
202
+ await linked.client.get('profile/settings/me');
110
203
 
111
- expect(fetchMock).toHaveBeenCalledTimes(1);
112
- expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://api.mention.earth/profile/settings/me');
204
+ expect(fetchMock).toHaveBeenCalledTimes(1);
205
+ expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://api.mention.earth/profile/settings/me');
113
206
 
114
- linked.dispose();
115
- } finally {
116
- globalThis.fetch = originalFetch;
117
- }
207
+ linked.dispose();
118
208
  });
119
209
  });