@oxyhq/core 3.4.18 → 3.5.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.
@@ -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.
@@ -33,6 +33,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
33
33
  export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
34
34
  export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount, } from './mixins/OxyServices.managedAccounts';
35
35
  export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
36
+ export type { BulkFollowEntry, BulkFollowResult, } from './mixins/OxyServices.user';
36
37
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
37
38
  export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity';
38
39
  export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle';
@@ -5,6 +5,22 @@ import type { User, Notification, NotificationPreferences, UserPreferences, Sear
5
5
  import type { UserNameResponse, UserProfileUpdate } from '@oxyhq/contracts';
6
6
  import type { OxyServicesBase } from '../OxyServices.base';
7
7
  import { type PaginationParams } from '../utils/apiUtils';
8
+ /** Per-user outcome returned by `POST /users/follow/bulk`. */
9
+ export interface BulkFollowEntry {
10
+ /** The user ID that was processed. */
11
+ userId: string;
12
+ /** Whether the follow was applied (or already in place) without error. */
13
+ success: boolean;
14
+ /** Whether the caller was already following this user before the request. */
15
+ alreadyFollowing: boolean;
16
+ }
17
+ /** Response shape of `POST /users/follow/bulk`. */
18
+ export interface BulkFollowResult {
19
+ /** Per-user outcomes, in request order. */
20
+ results: BulkFollowEntry[];
21
+ /** Number of users newly followed by this request. */
22
+ followedCount: number;
23
+ }
8
24
  export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T): {
9
25
  new (...args: any[]): {
10
26
  /**
@@ -167,6 +183,15 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
167
183
  success: boolean;
168
184
  message: string;
169
185
  }>;
186
+ /**
187
+ * Follow multiple users in a single request.
188
+ *
189
+ * POSTs `/users/follow/bulk` with `{ userIds }` (server caps the batch at
190
+ * 200). Returns the per-user outcomes and the count of users newly
191
+ * followed. An empty `userIds` array resolves immediately with an empty
192
+ * result and performs no network call.
193
+ */
194
+ followUsers(userIds: string[]): Promise<BulkFollowResult>;
170
195
  /**
171
196
  * Unfollow a user
172
197
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.4.18",
3
+ "version": "3.5.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",
@@ -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
  }
@@ -993,6 +1018,10 @@ export class HttpService {
993
1018
  this.authRefreshHandler = handler;
994
1019
  }
995
1020
 
1021
+ setAccessTokenProvider(provider: AccessTokenProvider | null): void {
1022
+ this.accessTokenProvider = provider;
1023
+ }
1024
+
996
1025
  clearTokens(): void {
997
1026
  this.tokenStore.clearTokens();
998
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
  };
@@ -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
  });
package/src/index.ts CHANGED
@@ -61,6 +61,10 @@ export type {
61
61
  ContactDiscoveryMatch,
62
62
  ContactDiscoveryResponse,
63
63
  } from './mixins/OxyServices.contacts';
64
+ export type {
65
+ BulkFollowEntry,
66
+ BulkFollowResult,
67
+ } from './mixins/OxyServices.user';
64
68
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
65
69
 
66
70
  // ---------------------------------------------------------------------------
@@ -17,6 +17,24 @@ import { KeyManager } from '../crypto/keyManager';
17
17
  import { SignatureService } from '../crypto/signatureService';
18
18
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
19
19
 
20
+ /** Per-user outcome returned by `POST /users/follow/bulk`. */
21
+ export interface BulkFollowEntry {
22
+ /** The user ID that was processed. */
23
+ userId: string;
24
+ /** Whether the follow was applied (or already in place) without error. */
25
+ success: boolean;
26
+ /** Whether the caller was already following this user before the request. */
27
+ alreadyFollowing: boolean;
28
+ }
29
+
30
+ /** Response shape of `POST /users/follow/bulk`. */
31
+ export interface BulkFollowResult {
32
+ /** Per-user outcomes, in request order. */
33
+ results: BulkFollowEntry[];
34
+ /** Number of users newly followed by this request. */
35
+ followedCount: number;
36
+ }
37
+
20
38
  export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T) {
21
39
  return class extends Base {
22
40
  constructor(...args: any[]) {
@@ -404,6 +422,25 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
404
422
  }
405
423
  }
406
424
 
425
+ /**
426
+ * Follow multiple users in a single request.
427
+ *
428
+ * POSTs `/users/follow/bulk` with `{ userIds }` (server caps the batch at
429
+ * 200). Returns the per-user outcomes and the count of users newly
430
+ * followed. An empty `userIds` array resolves immediately with an empty
431
+ * result and performs no network call.
432
+ */
433
+ async followUsers(userIds: string[]): Promise<BulkFollowResult> {
434
+ if (userIds.length === 0) {
435
+ return { results: [], followedCount: 0 };
436
+ }
437
+ try {
438
+ return await this.makeRequest<BulkFollowResult>('POST', '/users/follow/bulk', { userIds }, { cache: false });
439
+ } catch (error) {
440
+ throw this.handleError(error);
441
+ }
442
+ }
443
+
407
444
  /**
408
445
  * Unfollow a user
409
446
  */