@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +23 -1
- package/dist/cjs/OxyServices.base.js +2 -3
- package/dist/cjs/mixins/OxyServices.user.js +19 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +23 -1
- package/dist/esm/OxyServices.base.js +2 -3
- package/dist/esm/mixins/OxyServices.user.js +19 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +4 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +25 -0
- package/package.json +1 -1
- package/src/HttpService.ts +30 -1
- package/src/OxyServices.base.ts +2 -3
- package/src/__tests__/linkedClient.test.ts +103 -13
- package/src/index.ts +4 -0
- package/src/mixins/OxyServices.user.ts +37 -0
|
@@ -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/dist/types/index.d.ts
CHANGED
|
@@ -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
package/src/HttpService.ts
CHANGED
|
@@ -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.
|
|
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();
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -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('
|
|
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()).
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
202
|
+
await linked.client.get('profile/settings/me');
|
|
110
203
|
|
|
111
|
-
|
|
112
|
-
|
|
204
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
205
|
+
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://api.mention.earth/profile/settings/me');
|
|
113
206
|
|
|
114
|
-
|
|
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
|
*/
|