@oxyhq/core 5.4.1 → 5.4.3
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/mixins/OxyServices.user.js +30 -8
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.user.js +30 -8
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.user.d.ts +32 -6
- package/package.json +2 -2
- package/src/mixins/OxyServices.user.ts +30 -8
- package/src/mixins/__tests__/userReadCacheBypass.test.ts +121 -0
|
@@ -60,9 +60,22 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
60
60
|
_serviceApiKey: string | null;
|
|
61
61
|
_serviceApiSecret: string | null;
|
|
62
62
|
/**
|
|
63
|
-
* Get profile by username
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
* Get profile by username.
|
|
64
|
+
*
|
|
65
|
+
* @param username - The profile's username.
|
|
66
|
+
* @param options.cache - Defaults to `true` (5-minute TTL), matching prior
|
|
67
|
+
* behavior. Pass `{ cache: false }` to force a registry-fresh read: the
|
|
68
|
+
* request bypasses BOTH the cache lookup and the post-fetch cache write
|
|
69
|
+
* (see {@link HttpService.request}'s `cache` handling), so it neither
|
|
70
|
+
* serves nor overwrites any entry already cached for this key — a
|
|
71
|
+
* previously cached response (if one exists) is left in place until its
|
|
72
|
+
* own TTL expires or is explicitly invalidated elsewhere. Use this when a
|
|
73
|
+
* caller must observe a just-written change (e.g. a privacy/consent flag)
|
|
74
|
+
* that would otherwise be masked by the TTL window.
|
|
75
|
+
*/
|
|
76
|
+
getProfileByUsername(username: string, options?: {
|
|
77
|
+
cache?: boolean;
|
|
78
|
+
}): Promise<User>;
|
|
66
79
|
/**
|
|
67
80
|
* Lightweight username lookup for login flows.
|
|
68
81
|
* Returns minimal public info: exists, color, avatar, name.displayName.
|
|
@@ -134,9 +147,22 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
134
147
|
*/
|
|
135
148
|
getSimilarProfiles(userId: string, limit?: number): Promise<User[]>;
|
|
136
149
|
/**
|
|
137
|
-
* Get user by ID
|
|
138
|
-
|
|
139
|
-
|
|
150
|
+
* Get user by ID.
|
|
151
|
+
*
|
|
152
|
+
* @param userId - The target user's id.
|
|
153
|
+
* @param options.cache - Defaults to `true` (5-minute TTL), matching prior
|
|
154
|
+
* behavior. Pass `{ cache: false }` to force a registry-fresh read: the
|
|
155
|
+
* request bypasses BOTH the cache lookup and the post-fetch cache write
|
|
156
|
+
* (see {@link HttpService.request}'s `cache` handling), so it neither
|
|
157
|
+
* serves nor overwrites any entry already cached for this key — a
|
|
158
|
+
* previously cached response (if one exists) is left in place until its
|
|
159
|
+
* own TTL expires or is explicitly invalidated elsewhere. Use this when a
|
|
160
|
+
* caller must observe a just-written change (e.g. a privacy/consent flag)
|
|
161
|
+
* that would otherwise be masked by the TTL window.
|
|
162
|
+
*/
|
|
163
|
+
getUserById(userId: string, options?: {
|
|
164
|
+
cache?: boolean;
|
|
165
|
+
}): Promise<User>;
|
|
140
166
|
/**
|
|
141
167
|
* Fetch many users by id in one round-trip per chunk.
|
|
142
168
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.3",
|
|
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",
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
|
-
"@oxyhq/contracts": "^0.
|
|
97
|
+
"@oxyhq/contracts": "^0.8.0",
|
|
98
98
|
"@oxyhq/protocol": "^0.1.1",
|
|
99
99
|
"bip39": "^3.1.0",
|
|
100
100
|
"buffer": "^6.0.3",
|
|
@@ -101,12 +101,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
101
101
|
declare _serviceApiSecret: string | null;
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* Get profile by username
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
* Get profile by username.
|
|
105
|
+
*
|
|
106
|
+
* @param username - The profile's username.
|
|
107
|
+
* @param options.cache - Defaults to `true` (5-minute TTL), matching prior
|
|
108
|
+
* behavior. Pass `{ cache: false }` to force a registry-fresh read: the
|
|
109
|
+
* request bypasses BOTH the cache lookup and the post-fetch cache write
|
|
110
|
+
* (see {@link HttpService.request}'s `cache` handling), so it neither
|
|
111
|
+
* serves nor overwrites any entry already cached for this key — a
|
|
112
|
+
* previously cached response (if one exists) is left in place until its
|
|
113
|
+
* own TTL expires or is explicitly invalidated elsewhere. Use this when a
|
|
114
|
+
* caller must observe a just-written change (e.g. a privacy/consent flag)
|
|
115
|
+
* that would otherwise be masked by the TTL window.
|
|
116
|
+
*/
|
|
117
|
+
async getProfileByUsername(username: string, options?: { cache?: boolean }): Promise<User> {
|
|
107
118
|
try {
|
|
108
119
|
const user = await this.makeRequest<User>('GET', `/profiles/username/${username}`, undefined, {
|
|
109
|
-
cache: true,
|
|
120
|
+
cache: options?.cache ?? true,
|
|
110
121
|
cacheTTL: 5 * 60 * 1000, // 5 minutes cache for profiles
|
|
111
122
|
});
|
|
112
123
|
return normalizeUserIdentity(user);
|
|
@@ -335,12 +346,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
335
346
|
}
|
|
336
347
|
|
|
337
348
|
/**
|
|
338
|
-
* Get user by ID
|
|
339
|
-
|
|
340
|
-
|
|
349
|
+
* Get user by ID.
|
|
350
|
+
*
|
|
351
|
+
* @param userId - The target user's id.
|
|
352
|
+
* @param options.cache - Defaults to `true` (5-minute TTL), matching prior
|
|
353
|
+
* behavior. Pass `{ cache: false }` to force a registry-fresh read: the
|
|
354
|
+
* request bypasses BOTH the cache lookup and the post-fetch cache write
|
|
355
|
+
* (see {@link HttpService.request}'s `cache` handling), so it neither
|
|
356
|
+
* serves nor overwrites any entry already cached for this key — a
|
|
357
|
+
* previously cached response (if one exists) is left in place until its
|
|
358
|
+
* own TTL expires or is explicitly invalidated elsewhere. Use this when a
|
|
359
|
+
* caller must observe a just-written change (e.g. a privacy/consent flag)
|
|
360
|
+
* that would otherwise be masked by the TTL window.
|
|
361
|
+
*/
|
|
362
|
+
async getUserById(userId: string, options?: { cache?: boolean }): Promise<User> {
|
|
341
363
|
try {
|
|
342
364
|
const user = await this.makeRequest<User>('GET', `/users/${userId}`, undefined, {
|
|
343
|
-
cache: true,
|
|
365
|
+
cache: options?.cache ?? true,
|
|
344
366
|
cacheTTL: 5 * 60 * 1000, // 5 minutes cache
|
|
345
367
|
});
|
|
346
368
|
return normalizeUserIdentity(user);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-call cache-bypass tests for `getUserById` / `getProfileByUsername`.
|
|
3
|
+
*
|
|
4
|
+
* Both mixin methods cache their GET response for 5 minutes via
|
|
5
|
+
* `HttpService`'s identity-scoped TTL cache. A consumer that just wrote a
|
|
6
|
+
* setting readable through one of these endpoints (e.g. Mention's federation
|
|
7
|
+
* consent flag) needs a way to force a registry-fresh read instead of
|
|
8
|
+
* silently getting served the pre-write snapshot for up to 5 minutes.
|
|
9
|
+
*
|
|
10
|
+
* These tests pin down that:
|
|
11
|
+
* - default behavior is UNCHANGED: a second call within the TTL is served
|
|
12
|
+
* from cache (no second network call),
|
|
13
|
+
* - `{ cache: false }` always hits the network, even immediately after a
|
|
14
|
+
* cached call, and never overwrites the still-live cached entry — a
|
|
15
|
+
* subsequent default-cache call keeps serving the ORIGINAL cached value.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { OxyServices } from '../../OxyServices';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a non-verified JWT whose payload decodes to the given claims.
|
|
22
|
+
* `jwtDecode` only base64url-decodes the middle segment (no signature check).
|
|
23
|
+
*/
|
|
24
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
25
|
+
const b64url = (obj: Record<string, unknown>): string =>
|
|
26
|
+
Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
27
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
28
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
|
|
32
|
+
function jsonResponse(data: unknown): Response {
|
|
33
|
+
return new Response(JSON.stringify({ data }), {
|
|
34
|
+
status: 200,
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('getUserById / getProfileByUsername cache bypass', () => {
|
|
40
|
+
let originalFetch: typeof globalThis.fetch;
|
|
41
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
42
|
+
let oxy: OxyServices;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
originalFetch = globalThis.fetch;
|
|
46
|
+
fetchMock = jest.fn();
|
|
47
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
48
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
49
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
globalThis.fetch = originalFetch;
|
|
54
|
+
jest.clearAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('getUserById', () => {
|
|
58
|
+
it('default call: a second read within the TTL is served from cache', async () => {
|
|
59
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice' }));
|
|
60
|
+
const first = await oxy.getUserById('user-1');
|
|
61
|
+
expect(first.username).toBe('alice');
|
|
62
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
63
|
+
|
|
64
|
+
const second = await oxy.getUserById('user-1');
|
|
65
|
+
expect(second.username).toBe('alice');
|
|
66
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('{ cache: false } always hits the network, even right after a cached call', async () => {
|
|
70
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice' }));
|
|
71
|
+
await oxy.getUserById('user-1');
|
|
72
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
73
|
+
|
|
74
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice-renamed' }));
|
|
75
|
+
const fresh = await oxy.getUserById('user-1', { cache: false });
|
|
76
|
+
expect(fresh.username).toBe('alice-renamed');
|
|
77
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('{ cache: false } does not overwrite the still-live cached entry', async () => {
|
|
81
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice' }));
|
|
82
|
+
await oxy.getUserById('user-1');
|
|
83
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
84
|
+
|
|
85
|
+
// Bypass read observes server-side truth that has since changed...
|
|
86
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice-renamed' }));
|
|
87
|
+
await oxy.getUserById('user-1', { cache: false });
|
|
88
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
89
|
+
|
|
90
|
+
// ...but a plain cached read afterward still serves the ORIGINAL cached
|
|
91
|
+
// value — the bypass call never wrote to the cache slot.
|
|
92
|
+
const cached = await oxy.getUserById('user-1');
|
|
93
|
+
expect(cached.username).toBe('alice');
|
|
94
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('getProfileByUsername', () => {
|
|
99
|
+
it('default call: a second read within the TTL is served from cache', async () => {
|
|
100
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-2', username: 'bob' }));
|
|
101
|
+
const first = await oxy.getProfileByUsername('bob');
|
|
102
|
+
expect(first.id).toBe('user-2');
|
|
103
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
104
|
+
|
|
105
|
+
const second = await oxy.getProfileByUsername('bob');
|
|
106
|
+
expect(second.id).toBe('user-2');
|
|
107
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('{ cache: false } always hits the network, even right after a cached call', async () => {
|
|
111
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-2', username: 'bob', bio: 'old' }));
|
|
112
|
+
await oxy.getProfileByUsername('bob');
|
|
113
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
114
|
+
|
|
115
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-2', username: 'bob', bio: 'new' }));
|
|
116
|
+
const fresh = await oxy.getProfileByUsername('bob', { cache: false });
|
|
117
|
+
expect((fresh as unknown as { bio: string }).bio).toBe('new');
|
|
118
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|