@oxyhq/core 7.1.0 → 8.0.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 +48 -24
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -6
- package/dist/cjs/i18n/locales/en-US.json +44 -1
- package/dist/cjs/i18n/locales/es-ES.json +44 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +45 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +45 -2
- package/dist/cjs/index.js +19 -16
- package/dist/cjs/server/index.js +1 -7
- package/dist/cjs/session/accountDialogController.js +88 -3
- package/dist/cjs/session/accountProjection.js +1 -1
- package/dist/cjs/session/projectSessionState.js +1 -1
- package/dist/cjs/session/sessionClientHost.js +1 -2
- package/dist/cjs/utils/accountUtils.js +1 -1
- package/dist/cjs/utils/oauthPkce.js +142 -0
- package/dist/cjs/utils/platform.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -6
- package/dist/esm/i18n/locales/en-US.json +44 -1
- package/dist/esm/i18n/locales/es-ES.json +44 -1
- package/dist/esm/i18n/locales/locales/en-US.json +45 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +45 -2
- package/dist/esm/index.js +11 -13
- package/dist/esm/server/index.js +0 -5
- package/dist/esm/session/accountDialogController.js +88 -3
- package/dist/esm/session/accountProjection.js +1 -1
- package/dist/esm/session/projectSessionState.js +1 -1
- package/dist/esm/session/sessionClientHost.js +1 -2
- package/dist/esm/utils/accountUtils.js +1 -1
- package/dist/esm/utils/oauthPkce.js +135 -0
- package/dist/esm/utils/platform.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +1 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.accounts.d.ts +13 -3
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +4 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +3 -2
- package/dist/types/models/interfaces.d.ts +4 -4
- package/dist/types/server/index.d.ts +0 -1
- package/dist/types/session/accountDialogController.d.ts +28 -1
- package/dist/types/session/accountProjection.d.ts +1 -1
- package/dist/types/session/projectSessionState.d.ts +1 -1
- package/dist/types/session/refresh.d.ts +4 -8
- package/dist/types/session/sessionClientHost.d.ts +1 -2
- package/dist/types/utils/accountUtils.d.ts +1 -1
- package/dist/types/utils/oauthPkce.d.ts +74 -0
- package/dist/types/utils/platform.d.ts +1 -1
- package/package.json +3 -3
- package/src/HttpService.ts +6 -6
- package/src/i18n/locales/en-US.json +45 -2
- package/src/i18n/locales/es-ES.json +45 -2
- package/src/index.ts +23 -16
- package/src/mixins/OxyServices.accounts.ts +12 -0
- package/src/mixins/OxyServices.connectedApps.ts +4 -0
- package/src/mixins/OxyServices.devices.ts +6 -5
- package/src/mixins/__tests__/accounts.test.ts +1 -1
- package/src/models/interfaces.ts +7 -5
- package/src/server/index.ts +0 -6
- package/src/session/__tests__/accountDialogController.test.ts +123 -0
- package/src/session/accountDialogController.ts +90 -3
- package/src/session/accountProjection.ts +1 -1
- package/src/session/projectSessionState.ts +1 -1
- package/src/session/refresh.ts +4 -8
- package/src/session/sessionClientHost.ts +1 -2
- package/src/utils/__tests__/coldBoot.test.ts +55 -65
- package/src/utils/__tests__/oauthPkce.test.ts +154 -0
- package/src/utils/accountUtils.ts +1 -1
- package/src/utils/oauthPkce.ts +189 -0
- package/src/utils/platform.ts +1 -1
- package/dist/cjs/utils/ssoBounce.js +0 -24
- package/dist/esm/utils/ssoBounce.js +0 -21
- package/dist/types/utils/ssoBounce.d.ts +0 -21
- package/src/utils/ssoBounce.ts +0 -22
|
@@ -41,6 +41,10 @@ export interface PublicApplication {
|
|
|
41
41
|
icon?: string;
|
|
42
42
|
/** Optional public website/homepage URL for the application. */
|
|
43
43
|
websiteUrl?: string;
|
|
44
|
+
/** Optional public privacy-policy URL, rendered as a legal link on the consent screen. */
|
|
45
|
+
privacyPolicyUrl?: string;
|
|
46
|
+
/** Optional public terms-of-service URL, rendered as a legal link on the consent screen. */
|
|
47
|
+
termsUrl?: string;
|
|
44
48
|
/** Application classification (set by Oxy platform staff). */
|
|
45
49
|
type: ApplicationType;
|
|
46
50
|
/** Whether the application is an officially endorsed Oxy application. */
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Device Methods Mixin
|
|
3
3
|
*/
|
|
4
4
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
5
|
+
import type { DeviceLinkedSession, DeviceLinkedSessionLogoutResponse } from '../models/interfaces';
|
|
5
6
|
|
|
6
7
|
export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
7
8
|
return class extends Base {
|
|
@@ -54,11 +55,11 @@ export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
54
55
|
* @param sessionId - The session ID
|
|
55
56
|
* @returns Array of device sessions
|
|
56
57
|
*/
|
|
57
|
-
async getDeviceSessions(sessionId: string): Promise<
|
|
58
|
+
async getDeviceSessions(sessionId: string): Promise<DeviceLinkedSession[]> {
|
|
58
59
|
try {
|
|
59
60
|
// Use makeRequest for consistent error handling and optional caching
|
|
60
61
|
// Cache disabled by default to ensure fresh session data
|
|
61
|
-
return await this.makeRequest<
|
|
62
|
+
return await this.makeRequest<DeviceLinkedSession[]>('GET', `/session/device/sessions/${sessionId}`, undefined, {
|
|
62
63
|
cache: false, // Don't cache sessions - always get fresh data
|
|
63
64
|
deduplicate: true, // Deduplicate concurrent requests for same sessionId
|
|
64
65
|
});
|
|
@@ -74,12 +75,12 @@ export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
74
75
|
* @param excludeCurrent - Whether to exclude the current session
|
|
75
76
|
* @returns Logout result
|
|
76
77
|
*/
|
|
77
|
-
async logoutAllDeviceSessions(sessionId: string, deviceId?: string, excludeCurrent?: boolean): Promise<
|
|
78
|
+
async logoutAllDeviceSessions(sessionId: string, deviceId?: string, excludeCurrent?: boolean): Promise<DeviceLinkedSessionLogoutResponse> {
|
|
78
79
|
try {
|
|
79
|
-
const urlParams:
|
|
80
|
+
const urlParams: Record<string, string> = {};
|
|
80
81
|
if (deviceId) urlParams.deviceId = deviceId;
|
|
81
82
|
if (excludeCurrent) urlParams.excludeCurrent = 'true';
|
|
82
|
-
return await this.makeRequest('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
|
|
83
|
+
return await this.makeRequest<DeviceLinkedSessionLogoutResponse>('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
|
|
83
84
|
} catch (error) {
|
|
84
85
|
throw this.handleError(error);
|
|
85
86
|
}
|
|
@@ -83,7 +83,7 @@ const appFixture: Application = {
|
|
|
83
83
|
isOfficial: true,
|
|
84
84
|
isInternal: false,
|
|
85
85
|
capabilities: [],
|
|
86
|
-
redirectUris: ['https://mention.earth/
|
|
86
|
+
redirectUris: ['https://mention.earth/oauth/callback'],
|
|
87
87
|
scopes: ['profile'],
|
|
88
88
|
createdByUserId: 'u1',
|
|
89
89
|
ownerAccountId: 'acc1',
|
package/src/models/interfaces.ts
CHANGED
|
@@ -645,8 +645,10 @@ export interface AssetUploadProgress {
|
|
|
645
645
|
error?: string;
|
|
646
646
|
}
|
|
647
647
|
|
|
648
|
-
// Device
|
|
649
|
-
|
|
648
|
+
// Device-linked session interfaces — the sessions that share one physical
|
|
649
|
+
// device (GET /session/device/sessions/:sessionId). Distinct from the
|
|
650
|
+
// server-authority `DeviceSession` Mongoose model / `DeviceSessionState`.
|
|
651
|
+
export interface DeviceLinkedSession {
|
|
650
652
|
sessionId: string;
|
|
651
653
|
deviceId: string;
|
|
652
654
|
deviceName: string;
|
|
@@ -658,12 +660,12 @@ export interface DeviceSession {
|
|
|
658
660
|
createdAt?: string;
|
|
659
661
|
}
|
|
660
662
|
|
|
661
|
-
export interface
|
|
663
|
+
export interface DeviceLinkedSessionsResponse {
|
|
662
664
|
deviceId: string;
|
|
663
|
-
sessions:
|
|
665
|
+
sessions: DeviceLinkedSession[];
|
|
664
666
|
}
|
|
665
667
|
|
|
666
|
-
export interface
|
|
668
|
+
export interface DeviceLinkedSessionLogoutResponse {
|
|
667
669
|
message: string;
|
|
668
670
|
deviceId: string;
|
|
669
671
|
sessionsTerminated: number;
|
package/src/server/index.ts
CHANGED
|
@@ -69,9 +69,3 @@ export { verifySecret } from './verifySecret';
|
|
|
69
69
|
// Pure host handling (no browser deps), so it is safe on the server subpath and
|
|
70
70
|
// lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
|
|
71
71
|
export { registrableApex } from '../utils/registrableApex';
|
|
72
|
-
|
|
73
|
-
// The single RP callback path the IdP redirects back to. A pure wire-contract
|
|
74
|
-
// constant (no browser deps at module top level), re-used server-side so the
|
|
75
|
-
// `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
|
|
76
|
-
// validates.
|
|
77
|
-
export { SSO_CALLBACK_PATH } from '../utils/ssoBounce';
|
|
@@ -65,6 +65,8 @@ function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
interface OxyMock {
|
|
68
|
+
getAccessToken: jest.Mock;
|
|
69
|
+
onTokensChanged: jest.Mock;
|
|
68
70
|
listAccounts: jest.Mock;
|
|
69
71
|
getUsersByIds: jest.Mock;
|
|
70
72
|
getFileDownloadUrl: jest.Mock;
|
|
@@ -73,10 +75,24 @@ interface OxyMock {
|
|
|
73
75
|
pollCommonsSignIn: jest.Mock;
|
|
74
76
|
claimSessionByToken: jest.Mock;
|
|
75
77
|
signInWithSharedIdentity: jest.Mock;
|
|
78
|
+
/**
|
|
79
|
+
* Test helper: set the current access token and fire every registered
|
|
80
|
+
* `onTokensChanged` listener (mirrors `OxyServices.setTokens`/`clearTokens`).
|
|
81
|
+
* With no listener yet registered (before `start()`), it just sets the token.
|
|
82
|
+
*/
|
|
83
|
+
emitTokenChange: (token: string | null) => void;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
function makeOxy(): OxyMock {
|
|
87
|
+
const tokenListeners = new Set<(token: string | null) => void>();
|
|
88
|
+
// Authenticated by default (mirrors a warm start with a planted bearer).
|
|
89
|
+
let currentToken: string | null = 'access-token';
|
|
79
90
|
return {
|
|
91
|
+
getAccessToken: jest.fn(() => currentToken),
|
|
92
|
+
onTokensChanged: jest.fn((listener: (token: string | null) => void) => {
|
|
93
|
+
tokenListeners.add(listener);
|
|
94
|
+
return () => tokenListeners.delete(listener);
|
|
95
|
+
}),
|
|
80
96
|
listAccounts: jest.fn().mockResolvedValue([]),
|
|
81
97
|
getUsersByIds: jest.fn().mockResolvedValue([]),
|
|
82
98
|
getFileDownloadUrl: jest.fn((id: string) => `https://cdn/${id}`),
|
|
@@ -85,9 +101,18 @@ function makeOxy(): OxyMock {
|
|
|
85
101
|
pollCommonsSignIn: jest.fn(),
|
|
86
102
|
claimSessionByToken: jest.fn(),
|
|
87
103
|
signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
|
|
104
|
+
emitTokenChange: (token: string | null) => {
|
|
105
|
+
currentToken = token;
|
|
106
|
+
for (const listener of tokenListeners) {
|
|
107
|
+
listener(token);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
88
110
|
};
|
|
89
111
|
}
|
|
90
112
|
|
|
113
|
+
/** Flush pending microtasks (a `start()`-triggered `refresh()` cannot be awaited directly). */
|
|
114
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
115
|
+
|
|
91
116
|
interface Harness {
|
|
92
117
|
controller: AccountDialogController;
|
|
93
118
|
oxy: OxyMock;
|
|
@@ -193,6 +218,104 @@ describe('AccountDialogController — account list', () => {
|
|
|
193
218
|
});
|
|
194
219
|
});
|
|
195
220
|
|
|
221
|
+
describe('AccountDialogController — auth-gated graph fetch (prod sign-out fix)', () => {
|
|
222
|
+
it('start() while signed out does NOT call the private listAccounts / getUsersByIds and does not error', async () => {
|
|
223
|
+
const { controller, oxy, sc } = makeHarness();
|
|
224
|
+
oxy.emitTokenChange(null); // cold boot: no bearer planted yet (no listeners registered pre-start)
|
|
225
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
226
|
+
|
|
227
|
+
controller.start();
|
|
228
|
+
await flush();
|
|
229
|
+
|
|
230
|
+
expect(oxy.listAccounts).not.toHaveBeenCalled();
|
|
231
|
+
expect(oxy.getUsersByIds).not.toHaveBeenCalled();
|
|
232
|
+
const snap = controller.getSnapshot();
|
|
233
|
+
expect(snap.error).toBeNull();
|
|
234
|
+
expect(snap.loading).toBe(false);
|
|
235
|
+
controller.destroy();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('refresh() while signed out re-projects device-only and skips the network call', async () => {
|
|
239
|
+
const { controller, oxy } = makeHarness();
|
|
240
|
+
oxy.emitTokenChange(null);
|
|
241
|
+
|
|
242
|
+
await controller.refresh();
|
|
243
|
+
|
|
244
|
+
expect(oxy.listAccounts).not.toHaveBeenCalled();
|
|
245
|
+
const snap = controller.getSnapshot();
|
|
246
|
+
expect(snap.loading).toBe(false);
|
|
247
|
+
expect(snap.error).toBeNull();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('start() while authenticated fetches the graph exactly once', async () => {
|
|
251
|
+
const { controller, oxy, sc } = makeHarness();
|
|
252
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
253
|
+
oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
|
|
254
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
|
|
255
|
+
|
|
256
|
+
controller.start();
|
|
257
|
+
await flush();
|
|
258
|
+
|
|
259
|
+
expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
|
|
260
|
+
expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
|
|
261
|
+
controller.destroy();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('fetches the graph once when the bearer is planted after a signed-out start', async () => {
|
|
265
|
+
const { controller, oxy } = makeHarness();
|
|
266
|
+
oxy.emitTokenChange(null);
|
|
267
|
+
controller.start();
|
|
268
|
+
await flush();
|
|
269
|
+
expect(oxy.listAccounts).not.toHaveBeenCalled();
|
|
270
|
+
|
|
271
|
+
// Cold-boot restore plants the token → onTokensChanged → single graph fetch.
|
|
272
|
+
oxy.emitTokenChange('access-token');
|
|
273
|
+
await flush();
|
|
274
|
+
expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
|
|
275
|
+
controller.destroy();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('drops the graph and re-projects device-only (no fetch) when the token is cleared', async () => {
|
|
279
|
+
const { controller, oxy, sc } = makeHarness();
|
|
280
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
281
|
+
oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
|
|
282
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
|
|
283
|
+
controller.start();
|
|
284
|
+
await flush();
|
|
285
|
+
expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
|
|
286
|
+
|
|
287
|
+
oxy.listAccounts.mockClear();
|
|
288
|
+
oxy.emitTokenChange(null); // a 401 cleared the bearer
|
|
289
|
+
await flush();
|
|
290
|
+
|
|
291
|
+
expect(oxy.listAccounts).not.toHaveBeenCalled();
|
|
292
|
+
// Graph-only org1 is gone; the device row survives.
|
|
293
|
+
expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1']);
|
|
294
|
+
controller.destroy();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('does not loop when listAccounts rejects — at most one call per refresh, no re-trigger on device changes', async () => {
|
|
298
|
+
const { controller, oxy, sc } = makeHarness();
|
|
299
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
300
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
301
|
+
oxy.listAccounts.mockRejectedValue(new Error('graph boom'));
|
|
302
|
+
|
|
303
|
+
controller.start();
|
|
304
|
+
await flush();
|
|
305
|
+
expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
|
|
306
|
+
expect(controller.getSnapshot().error).toBe('graph boom');
|
|
307
|
+
|
|
308
|
+
// A subsequent device-state push must NOT re-trigger the graph fetch (auth
|
|
309
|
+
// edge unchanged → reconcileAuth is a no-op → no storm).
|
|
310
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1', 2));
|
|
311
|
+
await flush();
|
|
312
|
+
expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
|
|
313
|
+
// Device row still rendered despite the graph failure.
|
|
314
|
+
expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1']);
|
|
315
|
+
controller.destroy();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
196
319
|
describe('AccountDialogController — switchTo (uniform switch)', () => {
|
|
197
320
|
it('uses SessionClient.switchAccount for an account already on the device', async () => {
|
|
198
321
|
const { controller, oxy, sc } = makeHarness();
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A framework-agnostic state machine + subscribe/getSnapshot store (the same
|
|
5
5
|
* pattern {@link SessionClient} uses — no React, no RN) that both
|
|
6
|
-
*
|
|
6
|
+
* every `OxyProvider` platform variant (Expo/RN and RN-Web)
|
|
7
7
|
* bind to via `useSyncExternalStore`, so the account chooser is ONE
|
|
8
8
|
* implementation across the ecosystem instead of the five drifting copies it
|
|
9
9
|
* replaces.
|
|
@@ -166,6 +166,9 @@ export class AccountDialogController {
|
|
|
166
166
|
|
|
167
167
|
// --- Store plumbing ---
|
|
168
168
|
private unsubscribeSession: (() => void) | null = null;
|
|
169
|
+
private unsubscribeTokens: (() => void) | null = null;
|
|
170
|
+
/** Last-observed SDK auth readiness (a planted bearer). Drives the fetch edge. */
|
|
171
|
+
private authed = false;
|
|
169
172
|
private started = false;
|
|
170
173
|
private refreshSeq = 0;
|
|
171
174
|
private snapshot: AccountDialogSnapshot;
|
|
@@ -212,13 +215,28 @@ export class AccountDialogController {
|
|
|
212
215
|
start(): void {
|
|
213
216
|
if (this.started) return;
|
|
214
217
|
this.started = true;
|
|
218
|
+
this.authed = this.isAuthenticated();
|
|
215
219
|
this.unsubscribeSession = this.sessionClient.subscribe(() => {
|
|
216
220
|
// A device-state change (switch / sign-out / sibling sign-in) can add or
|
|
217
|
-
// remove accounts — re-project immediately,
|
|
218
|
-
// account ids appeared.
|
|
221
|
+
// remove accounts — re-project immediately, refetch profiles when new
|
|
222
|
+
// account ids appeared, and reconcile the auth-readiness edge.
|
|
219
223
|
this.emit();
|
|
220
224
|
void this.ensureProfiles();
|
|
225
|
+
this.reconcileAuth();
|
|
221
226
|
});
|
|
227
|
+
// The access token is planted AFTER `SessionClient.applyState` fires its
|
|
228
|
+
// subscription (`applySync` calls `setTokens` only once `applyState`/notify
|
|
229
|
+
// has returned; `ensureActiveToken` plants it async later), so the
|
|
230
|
+
// device-state subscription alone cannot observe the signed-out → signed-in
|
|
231
|
+
// edge. Observe the SDK-canonical readiness signal directly — a change to
|
|
232
|
+
// `oxyServices.getAccessToken()`, the `hasAccessToken` term of
|
|
233
|
+
// `OxyContext.canUsePrivateApi`.
|
|
234
|
+
this.unsubscribeTokens = this.oxyServices.onTokensChanged(() => {
|
|
235
|
+
this.reconcileAuth();
|
|
236
|
+
});
|
|
237
|
+
// Initial projection is device-only. `refresh()` fetches the graph IFF a
|
|
238
|
+
// bearer is already planted (warm start); when signed out (cold boot before
|
|
239
|
+
// restore) it re-projects from device state and makes NO private call.
|
|
222
240
|
void this.refresh();
|
|
223
241
|
}
|
|
224
242
|
|
|
@@ -232,10 +250,60 @@ export class AccountDialogController {
|
|
|
232
250
|
this.unsubscribeSession();
|
|
233
251
|
this.unsubscribeSession = null;
|
|
234
252
|
}
|
|
253
|
+
if (this.unsubscribeTokens) {
|
|
254
|
+
this.unsubscribeTokens();
|
|
255
|
+
this.unsubscribeTokens = null;
|
|
256
|
+
}
|
|
235
257
|
this.clearPollTimer();
|
|
236
258
|
this.listeners.clear();
|
|
237
259
|
}
|
|
238
260
|
|
|
261
|
+
// =========================================================================
|
|
262
|
+
// Auth readiness (SDK-canonical — mirrors OxyContext.canUsePrivateApi)
|
|
263
|
+
// =========================================================================
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Whether a PRIVATE endpoint may be called right now. Mirrors the
|
|
267
|
+
* `hasAccessToken` term of `OxyContext.canUsePrivateApi`
|
|
268
|
+
* (`authResolved && isAuthenticated && tokenReady && hasAccessToken`, where
|
|
269
|
+
* `hasAccessToken = Boolean(oxyServices.getAccessToken())`): a planted bearer
|
|
270
|
+
* is the only term that decides whether a request carries auth — the other
|
|
271
|
+
* three are provider render-lifecycle gates with no headless equivalent.
|
|
272
|
+
*
|
|
273
|
+
* `listAccounts()` (`GET /accounts`) and `getUsersByIds()`
|
|
274
|
+
* (`POST /users/by-ids`) are private; calling either before cold-boot restore
|
|
275
|
+
* plants the token 401s → `HttpService` clears the bearer + emits
|
|
276
|
+
* `onTokensChanged(null)` → the app signs out. Every graph/profile fetch gates
|
|
277
|
+
* on this.
|
|
278
|
+
*/
|
|
279
|
+
private isAuthenticated(): boolean {
|
|
280
|
+
return Boolean(this.oxyServices.getAccessToken());
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Reconcile the account graph against the current auth-readiness edge. On the
|
|
285
|
+
* signed-out → signed-in edge fetch the graph ONCE; on signed-in → signed-out
|
|
286
|
+
* drop it and re-project device-only. A no-op when readiness is unchanged, so
|
|
287
|
+
* a burst of token events / device pushes cannot restart the fetch — and a
|
|
288
|
+
* failed `listAccounts()` never flips the edge, so it cannot re-trigger itself
|
|
289
|
+
* (no retry storm).
|
|
290
|
+
*/
|
|
291
|
+
private reconcileAuth(): void {
|
|
292
|
+
const authed = this.isAuthenticated();
|
|
293
|
+
if (authed === this.authed) return;
|
|
294
|
+
this.authed = authed;
|
|
295
|
+
if (authed) {
|
|
296
|
+
void this.refresh();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Signed out: the graph is no longer fetchable/switchable — drop it and
|
|
300
|
+
// re-project from the device session set alone.
|
|
301
|
+
this.graph = [];
|
|
302
|
+
this.error = null;
|
|
303
|
+
this.loading = false;
|
|
304
|
+
this.emit();
|
|
305
|
+
}
|
|
306
|
+
|
|
239
307
|
// =========================================================================
|
|
240
308
|
// View actions
|
|
241
309
|
// =========================================================================
|
|
@@ -269,6 +337,19 @@ export class AccountDialogController {
|
|
|
269
337
|
*/
|
|
270
338
|
async refresh(): Promise<void> {
|
|
271
339
|
const seq = ++this.refreshSeq;
|
|
340
|
+
|
|
341
|
+
// Never hit the private `listAccounts()` while signed out: at cold boot the
|
|
342
|
+
// bearer is not planted yet, so the call 401s → `HttpService` clears the
|
|
343
|
+
// token and signs the user out. Re-project from the device session set alone
|
|
344
|
+
// (`projectSwitchableAccounts` works from `SessionClient` state) and stop.
|
|
345
|
+
if (!this.isAuthenticated()) {
|
|
346
|
+
this.graph = [];
|
|
347
|
+
this.loading = false;
|
|
348
|
+
this.error = null;
|
|
349
|
+
this.emit();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
272
353
|
const hadAccounts = this.snapshot.accounts.length > 0;
|
|
273
354
|
this.loading = !hadAccounts;
|
|
274
355
|
this.error = null;
|
|
@@ -299,6 +380,8 @@ export class AccountDialogController {
|
|
|
299
380
|
* subscription so a newly-added device account gets a name/avatar.
|
|
300
381
|
*/
|
|
301
382
|
private async ensureProfiles(): Promise<void> {
|
|
383
|
+
// `getUsersByIds` is private — skip the whole path while signed out.
|
|
384
|
+
if (!this.isAuthenticated()) return;
|
|
302
385
|
const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
|
|
303
386
|
if (ids.every((id) => this.profilesById.has(id))) return;
|
|
304
387
|
await this.loadProfiles(this.refreshSeq);
|
|
@@ -306,6 +389,10 @@ export class AccountDialogController {
|
|
|
306
389
|
}
|
|
307
390
|
|
|
308
391
|
private async loadProfiles(seq: number): Promise<void> {
|
|
392
|
+
// `getUsersByIds` (`POST /users/by-ids`) is a private call — never issue it
|
|
393
|
+
// while signed out (the 401 → sign-out cascade). Callers already gate; this
|
|
394
|
+
// guards the network chokepoint too (e.g. the token was cleared mid-refresh).
|
|
395
|
+
if (!this.isAuthenticated()) return;
|
|
309
396
|
const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
|
|
310
397
|
if (ids.length === 0) return;
|
|
311
398
|
let profiles: User[] = [];
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* merging the device's server-authoritative session set (`DeviceSessionState`
|
|
6
6
|
* from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
|
|
7
7
|
* from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
|
|
8
|
-
* `@oxyhq/core` so `@oxyhq/services`
|
|
8
|
+
* `@oxyhq/core` so every `@oxyhq/services` platform variant — and
|
|
9
9
|
* `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
|
|
10
10
|
* diverge.
|
|
11
11
|
*
|
|
@@ -5,7 +5,7 @@ import type { User } from '../models/interfaces';
|
|
|
5
5
|
/**
|
|
6
6
|
* Pure projection helpers: `DeviceSessionState` (the device-scoped
|
|
7
7
|
* multi-account session-sync state produced by `SessionClient`) -> the
|
|
8
|
-
* shapes
|
|
8
|
+
* shapes `@oxyhq/services` consumers render today
|
|
9
9
|
* (`ClientSession[]`, an active session id, an active `User`).
|
|
10
10
|
*
|
|
11
11
|
* No I/O. The caller fetches profiles via
|
package/src/session/refresh.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unified token refresh — THE single refresh implementation for web + native.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `@oxyhq/services`'s `inSessionTokenRefresh.ts` (native shared-key). This
|
|
7
|
-
* module replaces both with ONE persisted-refresh-token rotation shared by
|
|
8
|
-
* every consumer:
|
|
4
|
+
* ONE persisted-refresh-token rotation shared by every consumer (it replaced
|
|
5
|
+
* the pre-device-first per-platform duplicates):
|
|
9
6
|
*
|
|
10
7
|
* - `refreshPersistedSession` — arm 1 rotates the stored refresh-token family
|
|
11
8
|
* (`POST /auth/refresh-token`), planting + persisting the rotated pair; arm 2
|
|
@@ -16,9 +13,8 @@
|
|
|
16
13
|
* - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
|
|
17
14
|
* `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
|
|
18
15
|
* dedup + cooldown (this module does NOT reimplement them).
|
|
19
|
-
* - `startTokenRefreshScheduler` — a proactive scheduler
|
|
20
|
-
*
|
|
21
|
-
* decoupled from any React / auth-sdk type: refreshes ~60s before `exp`,
|
|
16
|
+
* - `startTokenRefreshScheduler` — a proactive scheduler decoupled from any
|
|
17
|
+
* React type: refreshes ~60s before `exp`,
|
|
22
18
|
* re-arms on token change + web tab-focus, `.unref?.()`s its timer in Node.
|
|
23
19
|
*
|
|
24
20
|
* Framework-free; no module-level mutable state.
|
|
@@ -7,8 +7,7 @@ import type { SessionClientHost } from './SessionClient';
|
|
|
7
7
|
* `SessionClient` is host-agnostic: it only needs a REST + token surface.
|
|
8
8
|
* `OxyServices` already exposes all of that except `getCurrentAccountId`,
|
|
9
9
|
* which has no direct equivalent — the adapter holds a mutable ref set by
|
|
10
|
-
* the caller (`OxyContext` in `@oxyhq/services
|
|
11
|
-
* `@oxyhq/auth`) via `setCurrentAccountId`.
|
|
10
|
+
* the caller (`OxyContext` in `@oxyhq/services`) via `setCurrentAccountId`.
|
|
12
11
|
*
|
|
13
12
|
* Shared here (rather than duplicated per consumer) because it is entirely
|
|
14
13
|
* platform-agnostic: every method it calls exists identically on
|