@oxyhq/core 7.1.0 → 7.1.1

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.
@@ -131,6 +131,9 @@ export declare class AccountDialogController {
131
131
  private signInToken;
132
132
  private pollTimer;
133
133
  private unsubscribeSession;
134
+ private unsubscribeTokens;
135
+ /** Last-observed SDK auth readiness (a planted bearer). Drives the fetch edge. */
136
+ private authed;
134
137
  private started;
135
138
  private refreshSeq;
136
139
  private snapshot;
@@ -150,6 +153,30 @@ export declare class AccountDialogController {
150
153
  * active sign-in flow (timers). Idempotent.
151
154
  */
152
155
  destroy(): void;
156
+ /**
157
+ * Whether a PRIVATE endpoint may be called right now. Mirrors the
158
+ * `hasAccessToken` term of `OxyContext.canUsePrivateApi`
159
+ * (`authResolved && isAuthenticated && tokenReady && hasAccessToken`, where
160
+ * `hasAccessToken = Boolean(oxyServices.getAccessToken())`): a planted bearer
161
+ * is the only term that decides whether a request carries auth — the other
162
+ * three are provider render-lifecycle gates with no headless equivalent.
163
+ *
164
+ * `listAccounts()` (`GET /accounts`) and `getUsersByIds()`
165
+ * (`POST /users/by-ids`) are private; calling either before cold-boot restore
166
+ * plants the token 401s → `HttpService` clears the bearer + emits
167
+ * `onTokensChanged(null)` → the app signs out. Every graph/profile fetch gates
168
+ * on this.
169
+ */
170
+ private isAuthenticated;
171
+ /**
172
+ * Reconcile the account graph against the current auth-readiness edge. On the
173
+ * signed-out → signed-in edge fetch the graph ONCE; on signed-in → signed-out
174
+ * drop it and re-project device-only. A no-op when readiness is unchanged, so
175
+ * a burst of token events / device pushes cannot restart the fetch — and a
176
+ * failed `listAccounts()` never flips the edge, so it cannot re-trigger itself
177
+ * (no retry storm).
178
+ */
179
+ private reconcileAuth;
153
180
  /** Set the dialog view directly. */
154
181
  setView(view: AccountDialogView): void;
155
182
  /** Return to the account list and cancel any in-flight sign-in flow. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
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",
@@ -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();
@@ -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, and refetch profiles when new
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[] = [];