@oxyhq/core 3.8.1 → 3.9.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/README.md +10 -0
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +18 -4
- package/dist/cjs/OxyServices.base.js +15 -1
- package/dist/cjs/mixins/OxyServices.applications.js +69 -6
- package/dist/cjs/mixins/OxyServices.assets.js +16 -3
- package/dist/cjs/mixins/OxyServices.features.js +47 -10
- package/dist/cjs/mixins/OxyServices.managedAccounts.js +29 -3
- package/dist/cjs/mixins/OxyServices.privacy.js +34 -8
- package/dist/cjs/mixins/OxyServices.topics.js +5 -1
- package/dist/cjs/mixins/OxyServices.user.js +11 -2
- package/dist/cjs/mixins/OxyServices.workspaces.js +38 -3
- package/dist/cjs/utils/cache.js +9 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +18 -4
- package/dist/esm/OxyServices.base.js +15 -1
- package/dist/esm/mixins/OxyServices.applications.js +69 -6
- package/dist/esm/mixins/OxyServices.assets.js +16 -3
- package/dist/esm/mixins/OxyServices.features.js +47 -10
- package/dist/esm/mixins/OxyServices.managedAccounts.js +29 -3
- package/dist/esm/mixins/OxyServices.privacy.js +34 -8
- package/dist/esm/mixins/OxyServices.topics.js +5 -1
- package/dist/esm/mixins/OxyServices.user.js +11 -2
- package/dist/esm/mixins/OxyServices.workspaces.js +38 -3
- package/dist/esm/utils/cache.js +9 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +9 -0
- package/dist/types/OxyServices.base.d.ts +12 -0
- package/dist/types/mixins/OxyServices.applications.d.ts +26 -0
- package/dist/types/mixins/OxyServices.features.d.ts +27 -6
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +16 -1
- package/dist/types/mixins/OxyServices.privacy.d.ts +22 -4
- package/dist/types/mixins/OxyServices.user.d.ts +8 -1
- package/dist/types/mixins/OxyServices.workspaces.d.ts +12 -0
- package/dist/types/models/interfaces.d.ts +12 -0
- package/dist/types/utils/cache.d.ts +4 -1
- package/package.json +1 -4
- package/src/HttpService.ts +28 -4
- package/src/OxyServices.base.ts +15 -1
- package/src/__tests__/httpServiceCache.test.ts +68 -0
- package/src/__tests__/linkedClient.test.ts +61 -0
- package/src/mixins/OxyServices.applications.ts +71 -6
- package/src/mixins/OxyServices.assets.ts +16 -3
- package/src/mixins/OxyServices.features.ts +47 -10
- package/src/mixins/OxyServices.managedAccounts.ts +29 -3
- package/src/mixins/OxyServices.privacy.ts +34 -8
- package/src/mixins/OxyServices.topics.ts +5 -1
- package/src/mixins/OxyServices.user.ts +11 -2
- package/src/mixins/OxyServices.workspaces.ts +39 -3
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +147 -0
- package/src/models/interfaces.ts +13 -1
- package/src/utils/cache.ts +9 -2
- package/dist/cjs/mixins/OxyServices.popup.js +0 -263
- package/dist/esm/mixins/OxyServices.popup.js +0 -261
- package/dist/types/mixins/OxyServices.popup.d.ts +0 -170
|
@@ -140,6 +140,9 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
140
140
|
data,
|
|
141
141
|
{ cache: false },
|
|
142
142
|
);
|
|
143
|
+
// Bust the cached workspace list so the new workspace appears on the
|
|
144
|
+
// next `getWorkspaces()` read within the TTL window.
|
|
145
|
+
this.clearCacheEntry('GET:/workspaces');
|
|
143
146
|
return res.workspace;
|
|
144
147
|
} catch (error) {
|
|
145
148
|
throw this.handleError(error);
|
|
@@ -180,6 +183,9 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
180
183
|
data,
|
|
181
184
|
{ cache: false },
|
|
182
185
|
);
|
|
186
|
+
// Bust the cached detail and list — both surface workspace fields.
|
|
187
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}`);
|
|
188
|
+
this.clearCacheEntry('GET:/workspaces');
|
|
183
189
|
return res.workspace;
|
|
184
190
|
} catch (error) {
|
|
185
191
|
throw this.handleError(error);
|
|
@@ -192,12 +198,17 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
192
198
|
*/
|
|
193
199
|
async deleteWorkspace(workspaceId: string): Promise<WorkspaceSuccessResult> {
|
|
194
200
|
try {
|
|
195
|
-
|
|
201
|
+
const result = await this.makeRequest<WorkspaceSuccessResult>(
|
|
196
202
|
'DELETE',
|
|
197
203
|
`/workspaces/${encodeURIComponent(workspaceId)}`,
|
|
198
204
|
undefined,
|
|
199
205
|
{ cache: false },
|
|
200
206
|
);
|
|
207
|
+
// Bust every cached representation of the deleted workspace.
|
|
208
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}`);
|
|
209
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}/members`);
|
|
210
|
+
this.clearCacheEntry('GET:/workspaces');
|
|
211
|
+
return result;
|
|
201
212
|
} catch (error) {
|
|
202
213
|
throw this.handleError(error);
|
|
203
214
|
}
|
|
@@ -239,6 +250,7 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
239
250
|
data,
|
|
240
251
|
{ cache: false },
|
|
241
252
|
);
|
|
253
|
+
this._invalidateWorkspaceMembership(workspaceId);
|
|
242
254
|
return res.member;
|
|
243
255
|
} catch (error) {
|
|
244
256
|
throw this.handleError(error);
|
|
@@ -263,6 +275,7 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
263
275
|
data,
|
|
264
276
|
{ cache: false },
|
|
265
277
|
);
|
|
278
|
+
this._invalidateWorkspaceMembership(workspaceId);
|
|
266
279
|
return res.member;
|
|
267
280
|
} catch (error) {
|
|
268
281
|
throw this.handleError(error);
|
|
@@ -279,12 +292,14 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
279
292
|
memberId: string,
|
|
280
293
|
): Promise<WorkspaceSuccessResult> {
|
|
281
294
|
try {
|
|
282
|
-
|
|
295
|
+
const result = await this.makeRequest<WorkspaceSuccessResult>(
|
|
283
296
|
'DELETE',
|
|
284
297
|
`/workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`,
|
|
285
298
|
undefined,
|
|
286
299
|
{ cache: false },
|
|
287
300
|
);
|
|
301
|
+
this._invalidateWorkspaceMembership(workspaceId);
|
|
302
|
+
return result;
|
|
288
303
|
} catch (error) {
|
|
289
304
|
throw this.handleError(error);
|
|
290
305
|
}
|
|
@@ -301,15 +316,36 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
301
316
|
data: TransferWorkspaceOwnershipInput,
|
|
302
317
|
): Promise<WorkspaceSuccessResult> {
|
|
303
318
|
try {
|
|
304
|
-
|
|
319
|
+
const result = await this.makeRequest<WorkspaceSuccessResult>(
|
|
305
320
|
'POST',
|
|
306
321
|
`/workspaces/${encodeURIComponent(workspaceId)}/transfer-ownership`,
|
|
307
322
|
data,
|
|
308
323
|
{ cache: false },
|
|
309
324
|
);
|
|
325
|
+
// Ownership change alters roles in the member list AND the detail, and
|
|
326
|
+
// can change which workspaces the caller "owns" in the list view.
|
|
327
|
+
this._invalidateWorkspaceMembership(workspaceId);
|
|
328
|
+
this.clearCacheEntry('GET:/workspaces');
|
|
329
|
+
return result;
|
|
310
330
|
} catch (error) {
|
|
311
331
|
throw this.handleError(error);
|
|
312
332
|
}
|
|
313
333
|
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Bust the cached member list and detail for a workspace after a membership
|
|
337
|
+
* mutation. The member list (`getWorkspaceMembers`) and the detail
|
|
338
|
+
* (`getWorkspace`, which can embed member counts) both go stale when the
|
|
339
|
+
* member set or a member's role changes.
|
|
340
|
+
*
|
|
341
|
+
* Internal helper (leading underscore); not part of the supported public
|
|
342
|
+
* surface. Public rather than `private` because mixins compose into an
|
|
343
|
+
* exported anonymous class, where TypeScript cannot represent a private
|
|
344
|
+
* member in the emitted declaration file (TS4094).
|
|
345
|
+
*/
|
|
346
|
+
_invalidateWorkspaceMembership(workspaceId: string): void {
|
|
347
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}/members`);
|
|
348
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}`);
|
|
349
|
+
}
|
|
314
350
|
};
|
|
315
351
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy / list cache-invalidation tests.
|
|
3
|
+
*
|
|
4
|
+
* The privacy reads cache their GET responses (identity-scoped):
|
|
5
|
+
* - `getBlockedUsers()` → `GET:/privacy/blocked` (~1 min TTL)
|
|
6
|
+
* - `getRestrictedUsers()` → `GET:/privacy/restricted` (~1 min TTL)
|
|
7
|
+
* - `getPrivacySettings(id)`→ `GET:/privacy/<id>/privacy` (~2 min TTL)
|
|
8
|
+
*
|
|
9
|
+
* Each corresponding write MUST invalidate the matching cached GET, otherwise a
|
|
10
|
+
* consumer that re-reads within the TTL window observes the STALE pre-write
|
|
11
|
+
* value (mirrors the follow/unfollow follow-status invalidation contract).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { OxyServices } from '../../OxyServices';
|
|
15
|
+
|
|
16
|
+
/** Build a non-verified JWT whose payload decodes to the given claims. */
|
|
17
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
18
|
+
const b64url = (obj: Record<string, unknown>): string =>
|
|
19
|
+
Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
20
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
21
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
|
|
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
|
+
describe('privacy cache invalidation', () => {
|
|
33
|
+
let originalFetch: typeof globalThis.fetch;
|
|
34
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
35
|
+
let oxy: OxyServices;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
originalFetch = globalThis.fetch;
|
|
39
|
+
fetchMock = jest.fn();
|
|
40
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
41
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
42
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
globalThis.fetch = originalFetch;
|
|
47
|
+
jest.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('busts the cached blocked list after blockUser', async () => {
|
|
51
|
+
// 1) Warm the cache: empty blocked list.
|
|
52
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
|
53
|
+
expect(await oxy.getBlockedUsers()).toEqual([]);
|
|
54
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
55
|
+
|
|
56
|
+
// A second read within the TTL is a cache hit (no extra network call).
|
|
57
|
+
await oxy.getBlockedUsers();
|
|
58
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
59
|
+
|
|
60
|
+
// 2) Block a user — must invalidate the cached list.
|
|
61
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
62
|
+
await oxy.blockUser('target-1');
|
|
63
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
64
|
+
|
|
65
|
+
// 3) Re-read MUST re-fetch and observe the new entry.
|
|
66
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ blockedId: 'target-1' }]));
|
|
67
|
+
const after = await oxy.getBlockedUsers();
|
|
68
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
69
|
+
expect(after).toEqual([{ blockedId: 'target-1' }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('busts the cached blocked list after unblockUser', async () => {
|
|
73
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ blockedId: 'target-1' }]));
|
|
74
|
+
await oxy.getBlockedUsers();
|
|
75
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
76
|
+
|
|
77
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
78
|
+
await oxy.unblockUser('target-1');
|
|
79
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
80
|
+
|
|
81
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
|
82
|
+
expect(await oxy.getBlockedUsers()).toEqual([]);
|
|
83
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('busts the cached restricted list after restrictUser / unrestrictUser', async () => {
|
|
87
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
|
88
|
+
await oxy.getRestrictedUsers();
|
|
89
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
90
|
+
|
|
91
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
92
|
+
await oxy.restrictUser('target-2');
|
|
93
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
94
|
+
|
|
95
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ restrictedId: 'target-2' }]));
|
|
96
|
+
expect(await oxy.getRestrictedUsers()).toEqual([{ restrictedId: 'target-2' }]);
|
|
97
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
98
|
+
|
|
99
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
100
|
+
await oxy.unrestrictUser('target-2');
|
|
101
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
102
|
+
|
|
103
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
|
104
|
+
expect(await oxy.getRestrictedUsers()).toEqual([]);
|
|
105
|
+
expect(fetchMock).toHaveBeenCalledTimes(5);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('busts the cached privacy settings (same id) after updatePrivacySettings', async () => {
|
|
109
|
+
// Warm the settings cache for an explicit id (avoids a getCurrentUser call).
|
|
110
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: false }));
|
|
111
|
+
expect(await oxy.getPrivacySettings('me')).toEqual({ isPrivateAccount: false });
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
113
|
+
|
|
114
|
+
// Cache hit on the second read.
|
|
115
|
+
await oxy.getPrivacySettings('me');
|
|
116
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
117
|
+
|
|
118
|
+
// Update — must invalidate `GET:/privacy/me/privacy`.
|
|
119
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
|
|
120
|
+
await oxy.updatePrivacySettings({ isPrivateAccount: true }, 'me');
|
|
121
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
122
|
+
|
|
123
|
+
// Re-read MUST re-fetch and observe the new value.
|
|
124
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
|
|
125
|
+
const after = await oxy.getPrivacySettings('me');
|
|
126
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
127
|
+
expect(after).toEqual({ isPrivateAccount: true });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('invalidates the exact logical keys on block/restrict/settings writes', async () => {
|
|
131
|
+
const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
|
|
132
|
+
|
|
133
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
134
|
+
await oxy.blockUser('u1');
|
|
135
|
+
expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/blocked');
|
|
136
|
+
|
|
137
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
138
|
+
await oxy.restrictUser('u2');
|
|
139
|
+
expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/restricted');
|
|
140
|
+
|
|
141
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
|
|
142
|
+
await oxy.updatePrivacySettings({ isPrivateAccount: true }, 'me');
|
|
143
|
+
expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/me/privacy');
|
|
144
|
+
|
|
145
|
+
clearSpy.mockRestore();
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/models/interfaces.ts
CHANGED
|
@@ -29,8 +29,20 @@ export interface OxyConfig {
|
|
|
29
29
|
*/
|
|
30
30
|
clientId?: string;
|
|
31
31
|
// Performance & caching options
|
|
32
|
+
/**
|
|
33
|
+
* Enable the per-instance GET response cache. Defaults to `true` (5-minute
|
|
34
|
+
* TTL). Set to `false` to disable caching entirely for this instance — GET
|
|
35
|
+
* responses are then never stored and never served from cache, so every read
|
|
36
|
+
* hits the network. Useful for a linked backend client where another layer
|
|
37
|
+
* (e.g. React Query) is the single cache authority and the SDK's own cache
|
|
38
|
+
* would otherwise serve stale data after a write.
|
|
39
|
+
*/
|
|
32
40
|
enableCache?: boolean;
|
|
33
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Cache TTL in milliseconds (default: 5 minutes). A value `<= 0` disables the
|
|
43
|
+
* per-instance GET response cache, equivalent to `enableCache: false`.
|
|
44
|
+
*/
|
|
45
|
+
cacheTTL?: number;
|
|
34
46
|
enableRequestDeduplication?: boolean;
|
|
35
47
|
enableRetry?: boolean;
|
|
36
48
|
maxRetries?: number;
|
package/src/utils/cache.ts
CHANGED
|
@@ -91,11 +91,18 @@ export class TTLCache<T> {
|
|
|
91
91
|
* Set a value in cache
|
|
92
92
|
* @param key Cache key
|
|
93
93
|
* @param data Data to cache
|
|
94
|
-
* @param ttl Optional TTL override (uses default if not provided)
|
|
94
|
+
* @param ttl Optional TTL override (uses default if not provided). An
|
|
95
|
+
* explicit `0` or negative value is honored as "already expired" — the
|
|
96
|
+
* entry is stored with `expiresAt <= now`, so the next `get`/`has` treats
|
|
97
|
+
* it as a miss — rather than silently falling back to the default TTL.
|
|
95
98
|
*/
|
|
96
99
|
set(key: string, data: T, ttl?: number): void {
|
|
97
100
|
const now = Date.now();
|
|
98
|
-
|
|
101
|
+
// Distinguish "no override provided" (undefined → use default) from an
|
|
102
|
+
// explicit `0`/negative (do not cache). `ttl || this.defaultTTL` collapsed
|
|
103
|
+
// both into the default, making `cacheTTL:0` impossible to honor.
|
|
104
|
+
const effectiveTTL = ttl === undefined ? this.defaultTTL : ttl;
|
|
105
|
+
const expiresAt = now + effectiveTTL;
|
|
99
106
|
this.cache.set(key, { data, timestamp: now, expiresAt });
|
|
100
107
|
}
|
|
101
108
|
|
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.OxyServicesPopupAuthMixin = OxyServicesPopupAuthMixin;
|
|
4
|
-
exports.PopupAuthMixin = OxyServicesPopupAuthMixin;
|
|
5
|
-
const OxyServices_errors_1 = require("../OxyServices.errors");
|
|
6
|
-
const debugUtils_1 = require("../shared/utils/debugUtils");
|
|
7
|
-
const debug = (0, debugUtils_1.createDebugLogger)("PopupAuth");
|
|
8
|
-
/**
|
|
9
|
-
* Cross-domain browser auth helpers.
|
|
10
|
-
*
|
|
11
|
-
* Popup sign-in is intentionally fail-closed in the clean session model because
|
|
12
|
-
* the historical implementation required bearer-token callback URLs. FedCM,
|
|
13
|
-
* redirect SSO, and silent iframe SSO are the supported browser paths.
|
|
14
|
-
*/
|
|
15
|
-
function OxyServicesPopupAuthMixin(Base) {
|
|
16
|
-
var _a;
|
|
17
|
-
return _a = class extends Base {
|
|
18
|
-
constructor(...args) {
|
|
19
|
-
super(...args);
|
|
20
|
-
}
|
|
21
|
-
/** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
|
|
22
|
-
resolveAuthUrl() {
|
|
23
|
-
return (this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL);
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Removed popup sign-in. Closes a caller-supplied popup handle and throws.
|
|
27
|
-
*/
|
|
28
|
-
async signInWithPopup(options = {}) {
|
|
29
|
-
if (typeof window === "undefined") {
|
|
30
|
-
throw new OxyServices_errors_1.OxyAuthenticationError("Popup authentication requires browser environment");
|
|
31
|
-
}
|
|
32
|
-
if (options.popup && !options.popup.closed) {
|
|
33
|
-
options.popup.close();
|
|
34
|
-
}
|
|
35
|
-
throw new OxyServices_errors_1.OxyAuthenticationError("Popup authentication has been removed because it required access-token callback URLs. Use FedCM or redirect authentication.");
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Removed popup signup. Closes a caller-supplied popup handle and throws.
|
|
39
|
-
*/
|
|
40
|
-
async signUpWithPopup(options = {}) {
|
|
41
|
-
return this.signInWithPopup({ ...options, mode: "signup" });
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Silent sign-in using hidden iframe
|
|
45
|
-
*
|
|
46
|
-
* Attempts to automatically re-authenticate the user without any UI.
|
|
47
|
-
* This is what enables seamless SSO across all Oxy domains.
|
|
48
|
-
*
|
|
49
|
-
* How it works:
|
|
50
|
-
* 1. Creates hidden iframe pointing to auth.oxy.so/silent-auth
|
|
51
|
-
* 2. If user has valid session at auth.oxy.so, it exchanges an opaque SSO code
|
|
52
|
-
* 3. If not, iframe responds with null (no error thrown)
|
|
53
|
-
*
|
|
54
|
-
* This should be called on app startup to check for existing sessions.
|
|
55
|
-
*
|
|
56
|
-
* @param options - Silent auth options
|
|
57
|
-
* @returns Session if user is signed in, null otherwise
|
|
58
|
-
*
|
|
59
|
-
* @example
|
|
60
|
-
* ```typescript
|
|
61
|
-
* useEffect(() => {
|
|
62
|
-
* const checkAuth = async () => {
|
|
63
|
-
* const session = await oxyServices.silentSignIn();
|
|
64
|
-
* if (session) {
|
|
65
|
-
* setUser(session.user);
|
|
66
|
-
* }
|
|
67
|
-
* };
|
|
68
|
-
* checkAuth();
|
|
69
|
-
* }, []);
|
|
70
|
-
* ```
|
|
71
|
-
*/
|
|
72
|
-
async silentSignIn(options = {}) {
|
|
73
|
-
if (typeof window === "undefined") {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
const timeout = options.timeout || this.constructor.SILENT_TIMEOUT;
|
|
77
|
-
const nonce = this.generateNonce();
|
|
78
|
-
const clientId = window.location.origin;
|
|
79
|
-
// Resolve the IdP origin for the iframe. An explicit per-apex override (the
|
|
80
|
-
// durable cross-domain reload path — see `SilentAuthOptions.authWebUrlOverride`)
|
|
81
|
-
// wins over the instance's configured central auth URL. The SAME origin is
|
|
82
|
-
// handed to `waitForIframeAuth` so the postMessage origin check matches the
|
|
83
|
-
// exact host the iframe was loaded from.
|
|
84
|
-
const authOrigin = options.authWebUrlOverride && options.authWebUrlOverride.length > 0
|
|
85
|
-
? options.authWebUrlOverride
|
|
86
|
-
: this.resolveAuthUrl();
|
|
87
|
-
const iframe = document.createElement("iframe");
|
|
88
|
-
iframe.style.display = "none";
|
|
89
|
-
iframe.style.position = "absolute";
|
|
90
|
-
iframe.style.width = "0";
|
|
91
|
-
iframe.style.height = "0";
|
|
92
|
-
iframe.style.border = "none";
|
|
93
|
-
const silentUrl = `${authOrigin}/auth/silent?` +
|
|
94
|
-
`client_id=${encodeURIComponent(clientId)}&` +
|
|
95
|
-
`nonce=${nonce}`;
|
|
96
|
-
iframe.src = silentUrl;
|
|
97
|
-
document.body.appendChild(iframe);
|
|
98
|
-
try {
|
|
99
|
-
const session = await this.waitForIframeAuth(iframe, timeout, authOrigin);
|
|
100
|
-
// Bail early on incomplete responses. The iframe contract requires
|
|
101
|
-
// both an access token and a session id; anything less is unusable.
|
|
102
|
-
// Returning `null` here (without installing the token) prevents a
|
|
103
|
-
// stale credential from being committed to HttpService when the
|
|
104
|
-
// user is actually signed out — that pattern caused a `getCurrentUser`
|
|
105
|
-
// -> 401 -> token-clear loop in consumer apps because callers gated
|
|
106
|
-
// on `session?.user` and never installed the user via
|
|
107
|
-
// `handleAuthSuccess`, while HttpService quietly held the token.
|
|
108
|
-
const accessToken = session
|
|
109
|
-
? session.accessToken
|
|
110
|
-
: undefined;
|
|
111
|
-
if (!session || !accessToken || !session.sessionId) {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
// Snapshot the previous token so we can roll back if the user
|
|
115
|
-
// lookup below fails — this avoids leaving a half-committed session
|
|
116
|
-
// (token installed, user missing) which would let the next
|
|
117
|
-
// authenticated request 401 with no way to recover.
|
|
118
|
-
const previousAccessToken = this.httpService.getAccessToken();
|
|
119
|
-
this.httpService.setTokens(accessToken);
|
|
120
|
-
// The iframe typically returns `{ sessionId, accessToken }` without
|
|
121
|
-
// user data. Fetch the user explicitly so callers receive a
|
|
122
|
-
// fully-formed session and never need a second `/users/me` round
|
|
123
|
-
// trip. If this fails the session is unusable — revert the token
|
|
124
|
-
// and return null so the caller treats this exactly like a
|
|
125
|
-
// missing-session response.
|
|
126
|
-
if (!session.user) {
|
|
127
|
-
try {
|
|
128
|
-
const userData = await this.makeRequest("GET", `/session/user/${session.sessionId}`, undefined, { cache: false, retry: false });
|
|
129
|
-
if (!userData) {
|
|
130
|
-
throw new Error("Empty user response");
|
|
131
|
-
}
|
|
132
|
-
session.user = userData;
|
|
133
|
-
}
|
|
134
|
-
catch (userError) {
|
|
135
|
-
debug.warn("silentSignIn: failed to fetch user data, rolling back token", userError);
|
|
136
|
-
if (previousAccessToken) {
|
|
137
|
-
this.httpService.setTokens(previousAccessToken);
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
this.httpService.clearTokens();
|
|
141
|
-
}
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return session;
|
|
146
|
-
}
|
|
147
|
-
catch (error) {
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
finally {
|
|
151
|
-
document.body.removeChild(iframe);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Open a blank, centered popup window SYNCHRONOUSLY.
|
|
156
|
-
*
|
|
157
|
-
* Kept only so legacy callers can pass a handle to the removed popup method,
|
|
158
|
-
* which closes it before throwing. New auth code should use FedCM or redirect.
|
|
159
|
-
*/
|
|
160
|
-
openBlankPopup(width, height) {
|
|
161
|
-
if (typeof window === "undefined") {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
const ctor = this.constructor;
|
|
165
|
-
const w = width ?? ctor.POPUP_WIDTH;
|
|
166
|
-
const h = height ?? ctor.POPUP_HEIGHT;
|
|
167
|
-
return this.openCenteredPopup("about:blank", "Oxy Sign In", w, h);
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Open a centered popup window
|
|
171
|
-
*
|
|
172
|
-
* @private
|
|
173
|
-
*/
|
|
174
|
-
openCenteredPopup(url, title, width, height) {
|
|
175
|
-
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
176
|
-
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
177
|
-
const features = [
|
|
178
|
-
`width=${width}`,
|
|
179
|
-
`height=${height}`,
|
|
180
|
-
`left=${left}`,
|
|
181
|
-
`top=${top}`,
|
|
182
|
-
"toolbar=no",
|
|
183
|
-
"menubar=no",
|
|
184
|
-
"scrollbars=yes",
|
|
185
|
-
"resizable=yes",
|
|
186
|
-
"status=no",
|
|
187
|
-
"location=no",
|
|
188
|
-
].join(",");
|
|
189
|
-
return window.open(url, title, features);
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Wait for authentication response from iframe
|
|
193
|
-
*
|
|
194
|
-
* @private
|
|
195
|
-
*/
|
|
196
|
-
async waitForIframeAuth(iframe, timeout, expectedOrigin) {
|
|
197
|
-
return new Promise((resolve) => {
|
|
198
|
-
const timeoutId = setTimeout(() => {
|
|
199
|
-
cleanup();
|
|
200
|
-
resolve(null); // Silent failure - don't throw
|
|
201
|
-
}, timeout);
|
|
202
|
-
const messageHandler = (event) => {
|
|
203
|
-
// Verify origin against the EXACT host the iframe was loaded from
|
|
204
|
-
// (`expectedOrigin`). For the per-apex durable-restore path this is
|
|
205
|
-
// `auth.<rp-apex>`, not the instance's central `resolveAuthUrl()` — so
|
|
206
|
-
// we must honour the caller-supplied origin, never re-derive it here.
|
|
207
|
-
if (event.origin !== expectedOrigin) {
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const { type, session } = event.data;
|
|
211
|
-
if (type !== "oxy_silent_auth") {
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
cleanup();
|
|
215
|
-
resolve(session || null);
|
|
216
|
-
};
|
|
217
|
-
// Fail-fast on a load failure. When the per-apex `/auth/silent` host is
|
|
218
|
-
// unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
|
|
219
|
-
// network drops, the iframe never posts a message — without this handler
|
|
220
|
-
// the silent restore would block for the FULL `timeout` (dead latency in
|
|
221
|
-
// the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
|
|
222
|
-
// so resolve `null` immediately and let the next cold-boot step run. The
|
|
223
|
-
// success path posts a message and is handled above; these only catch the
|
|
224
|
-
// no-message failure modes.
|
|
225
|
-
const failFast = () => {
|
|
226
|
-
cleanup();
|
|
227
|
-
resolve(null);
|
|
228
|
-
};
|
|
229
|
-
iframe.onerror = failFast;
|
|
230
|
-
iframe.onabort = failFast;
|
|
231
|
-
const cleanup = () => {
|
|
232
|
-
clearTimeout(timeoutId);
|
|
233
|
-
iframe.onerror = null;
|
|
234
|
-
iframe.onabort = null;
|
|
235
|
-
window.removeEventListener("message", messageHandler);
|
|
236
|
-
};
|
|
237
|
-
window.addEventListener("message", messageHandler);
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* Generate nonce for replay attack prevention
|
|
242
|
-
*
|
|
243
|
-
* @private
|
|
244
|
-
*/
|
|
245
|
-
generateNonce() {
|
|
246
|
-
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
247
|
-
return crypto.randomUUID();
|
|
248
|
-
}
|
|
249
|
-
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
250
|
-
const bytes = new Uint8Array(16);
|
|
251
|
-
crypto.getRandomValues(bytes);
|
|
252
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
253
|
-
}
|
|
254
|
-
throw new Error("No secure random source available for nonce generation");
|
|
255
|
-
}
|
|
256
|
-
},
|
|
257
|
-
_a.DEFAULT_AUTH_URL = "https://auth.oxy.so",
|
|
258
|
-
_a.POPUP_WIDTH = 500,
|
|
259
|
-
_a.POPUP_HEIGHT = 700,
|
|
260
|
-
_a.SILENT_TIMEOUT = 5000 // 5 seconds
|
|
261
|
-
,
|
|
262
|
-
_a;
|
|
263
|
-
}
|