@oxyhq/core 20.1.0 → 21.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.
Files changed (70) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  3. package/dist/cjs/i18n/locales/en-US.json +19 -2
  4. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  5. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  7. package/dist/cjs/index.js +50 -16
  8. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  9. package/dist/cjs/session/SessionClient.js +361 -1
  10. package/dist/cjs/session/accountDialogController.js +121 -147
  11. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  12. package/dist/cjs/session/deviceDirectory.js +143 -0
  13. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  14. package/dist/cjs/session/projectSessionState.js +8 -1
  15. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/boot/sessionColdBoot.js +107 -8
  18. package/dist/esm/i18n/locales/en-US.json +19 -2
  19. package/dist/esm/i18n/locales/es-ES.json +19 -2
  20. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  21. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  22. package/dist/esm/index.js +32 -10
  23. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  24. package/dist/esm/session/SessionClient.js +362 -2
  25. package/dist/esm/session/accountDialogController.js +121 -147
  26. package/dist/esm/session/accountSwitchTargets.js +71 -0
  27. package/dist/esm/session/deviceDirectory.js +135 -0
  28. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  29. package/dist/esm/session/projectSessionState.js +8 -2
  30. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  33. package/dist/types/index.d.ts +8 -3
  34. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  35. package/dist/types/models/session.d.ts +11 -0
  36. package/dist/types/session/SessionClient.d.ts +202 -1
  37. package/dist/types/session/accountDialogController.d.ts +76 -64
  38. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  39. package/dist/types/session/deviceDirectory.d.ts +182 -0
  40. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  41. package/dist/types/session/projectSessionState.d.ts +29 -0
  42. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  43. package/package.json +3 -3
  44. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  45. package/src/boot/sessionColdBoot.ts +133 -9
  46. package/src/i18n/locales/en-US.json +19 -2
  47. package/src/i18n/locales/es-ES.json +19 -2
  48. package/src/index.ts +75 -18
  49. package/src/mixins/OxyServices.auth.ts +67 -5
  50. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  51. package/src/models/session.ts +11 -0
  52. package/src/session/SessionClient.ts +386 -1
  53. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  54. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  55. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  56. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  57. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  58. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  59. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  60. package/src/session/accountDialogController.ts +141 -179
  61. package/src/session/accountSwitchTargets.ts +87 -0
  62. package/src/session/deviceDirectory.ts +269 -0
  63. package/src/session/deviceSwitcherRows.ts +145 -0
  64. package/src/session/projectSessionState.ts +9 -3
  65. package/src/session/sharedDeviceCredential.ts +349 -0
  66. package/dist/cjs/session/accountProjection.js +0 -213
  67. package/dist/esm/session/accountProjection.js +0 -207
  68. package/dist/types/session/accountProjection.d.ts +0 -198
  69. package/src/session/__tests__/accountProjection.test.ts +0 -447
  70. package/src/session/accountProjection.ts +0 -354
@@ -20,15 +20,58 @@ class TestSessionClient extends SessionClient {
20
20
  }
21
21
  }
22
22
 
23
- function host(): SessionClientHost {
23
+ /**
24
+ * A valid, empty directory for the host below.
25
+ *
26
+ * `refresh()` reads `GET /session/device/directory` now, so the shared host has
27
+ * to answer it with something the contract accepts — otherwise every test in
28
+ * this file logs "discarded invalid device directory" and the ones asserting on
29
+ * the logger fail for a reason that has nothing to do with their subject.
30
+ *
31
+ * Its `revision` sits deliberately ahead of every state revision these tests
32
+ * use, so the directory-settle re-read (which has its own suite) never fires
33
+ * here and each test's request count stays about the thing it is testing.
34
+ */
35
+ const EMPTY_DIRECTORY = {
36
+ deviceId: 'device-1',
37
+ revision: 9_000,
38
+ activeContextId: null,
39
+ principals: [],
40
+ updatedAt: 1_720_000_000_000,
41
+ };
42
+
43
+ interface RecordingHost {
44
+ host: SessionClientHost;
45
+ /** Every path the client has requested, in order. */
46
+ urls: string[];
47
+ /** Make the next and every subsequent directory read reject with `error`. */
48
+ failDirectory: (error: unknown) => void;
49
+ }
50
+
51
+ function host(): RecordingHost {
52
+ const urls: string[] = [];
53
+ let directoryError: unknown = null;
24
54
  return {
25
- makeRequest: jest.fn(),
26
- getBaseURL: () => 'http://test.invalid',
27
- getAccessToken: () => 'token',
28
- getDeviceCredential: () => null,
29
- onTokensChanged: () => () => undefined,
30
- setTokens: jest.fn(),
31
- getCurrentAccountId: () => null,
55
+ urls,
56
+ failDirectory: (error: unknown) => {
57
+ directoryError = error;
58
+ },
59
+ host: {
60
+ makeRequest: jest.fn(async (_method: string, url: string) => {
61
+ urls.push(url);
62
+ if (url === '/session/device/directory') {
63
+ if (directoryError !== null) throw directoryError;
64
+ return EMPTY_DIRECTORY;
65
+ }
66
+ return undefined;
67
+ }),
68
+ getBaseURL: () => 'http://test.invalid',
69
+ getAccessToken: () => 'token',
70
+ getDeviceCredential: () => null,
71
+ onTokensChanged: () => () => undefined,
72
+ setTokens: jest.fn(),
73
+ getCurrentAccountId: () => null,
74
+ },
32
75
  };
33
76
  }
34
77
 
@@ -79,7 +122,6 @@ interface OxyMock {
79
122
  startCommonsSignIn: jest.Mock;
80
123
  deliverCommonsSignIn: jest.Mock;
81
124
  pollCommonsSignIn: jest.Mock;
82
- deliverCommonsSignIn: jest.Mock;
83
125
  denyCommonsSignIn: jest.Mock;
84
126
  claimSessionByToken: jest.Mock;
85
127
  signInWithSharedIdentity: jest.Mock;
@@ -107,7 +149,6 @@ function makeOxy(): OxyMock {
107
149
  getFileDownloadUrl: jest.fn((id: string) => `https://cdn/${id}`),
108
150
  switchToAccount: jest.fn(),
109
151
  startCommonsSignIn: jest.fn(),
110
- deliverCommonsSignIn: jest.fn().mockResolvedValue({ delivered: false, targets: 0 }),
111
152
  pollCommonsSignIn: jest.fn(),
112
153
  // Default: no capable Commons installation is registered. A NORMAL outcome
113
154
  // that resolves the primary route to QR — never an error.
@@ -133,6 +174,10 @@ interface Harness {
133
174
  sc: TestSessionClient;
134
175
  commitSession: jest.Mock;
135
176
  onSignedIn: jest.Mock;
177
+ /** Every path the session client has requested, in order. */
178
+ urls: string[];
179
+ /** Make every directory read reject with `error`. */
180
+ failDirectory: (error: unknown) => void;
136
181
  }
137
182
 
138
183
  function makeHarness(
@@ -146,7 +191,8 @@ function makeHarness(
146
191
  }> = {},
147
192
  ): Harness {
148
193
  const oxy = makeOxy();
149
- const sc = new TestSessionClient(host());
194
+ const recording = host();
195
+ const sc = new TestSessionClient(recording.host);
150
196
  const commitSession = jest.fn().mockResolvedValue(undefined);
151
197
  const onSignedIn = jest.fn();
152
198
  const controller = createAccountDialogController({
@@ -162,18 +208,32 @@ function makeHarness(
162
208
  openUrl: over.openUrl,
163
209
  canOpenApp: over.canOpenApp,
164
210
  });
165
- return { controller, oxy, sc, commitSession, onSignedIn };
211
+ return {
212
+ controller,
213
+ oxy,
214
+ sc,
215
+ commitSession,
216
+ onSignedIn,
217
+ urls: recording.urls,
218
+ failDirectory: recording.failDirectory,
219
+ };
166
220
  }
167
221
 
222
+ /** How many times the device directory has been read. */
223
+ const directoryReads = (urls: string[]): number =>
224
+ urls.filter((url) => url === '/session/device/directory').length;
225
+
168
226
  describe('AccountDialogController — initial + views', () => {
169
227
  it('starts on the accounts view with an empty list and idle sign-in', () => {
170
228
  const { controller } = makeHarness();
171
229
  const snap = controller.getSnapshot();
172
230
  expect(snap.view).toBe('accounts');
173
- expect(snap.accounts).toEqual([]);
174
- expect(snap.activeAccountId).toBeNull();
231
+ expect(snap.directory).toBeNull();
232
+ expect(snap.activeContext).toBeNull();
175
233
  expect(snap.loading).toBe(false);
176
- expect(snap.switchingAccountId).toBeNull();
234
+ expect(snap.activatingContextId).toBeNull();
235
+ expect(snap.removingContextId).toBeNull();
236
+ expect(snap.removingPrincipalId).toBeNull();
177
237
  expect(snap.signIn.phase).toBe('idle');
178
238
  expect(snap.signIn.route).toBeNull();
179
239
  expect(snap.signIn.routeFailed).toBe(false);
@@ -220,314 +280,110 @@ describe('AccountDialogController — initial + views', () => {
220
280
  });
221
281
  });
222
282
 
223
- describe('AccountDialogController — account list', () => {
224
- it('refresh loads graph + profiles and projects the unified list', async () => {
225
- const { controller, oxy, sc } = makeHarness();
226
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
227
- oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
228
- oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
229
-
230
- await controller.refresh();
231
-
232
- const snap = controller.getSnapshot();
233
- expect(oxy.getUsersByIds).toHaveBeenCalledWith(['a1', 'org1']);
234
- expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
235
- expect(snap.activeAccountId).toBe('a1');
236
- expect(snap.accounts[0].isCurrent).toBe(true);
237
- expect(snap.accounts[1].onDevice).toBe(false);
238
- expect(snap.loading).toBe(false);
239
- });
240
-
241
- it('start subscribes to SessionClient so a device-state change re-projects', async () => {
242
- const { controller, oxy, sc } = makeHarness();
243
- oxy.getUsersByIds.mockResolvedValue([user('a1')]);
244
- controller.start();
245
- await Promise.resolve();
246
- await Promise.resolve();
247
-
248
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
249
- // The subscription re-projects synchronously from the new device state.
250
- expect(controller.getSnapshot().activeAccountId).toBe('a1');
251
- controller.destroy();
252
- });
253
-
254
- it('keeps device rows and surfaces the error when listAccounts fails', async () => {
255
- const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
256
- const { controller, oxy, sc } = makeHarness();
257
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
258
- oxy.getUsersByIds.mockResolvedValue([user('a1')]);
259
- oxy.listAccounts.mockRejectedValue(new Error('graph boom'));
260
-
261
- await controller.refresh();
262
-
263
- const snap = controller.getSnapshot();
264
- expect(snap.error).toBe('graph boom');
265
- expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1']);
266
- // A genuine (non-401) failure STILL warns.
267
- expect(warnSpy).toHaveBeenCalledWith(
268
- '[AccountDialogController] listAccounts failed',
269
- { component: 'AccountDialogController' },
270
- expect.any(Error),
271
- );
272
- warnSpy.mockRestore();
273
- });
274
-
275
- it('treats a 401 from listAccounts as the signed-out edge — debug, no surfaced error, no warn', async () => {
276
- const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
277
- const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
278
- const { controller, oxy, sc } = makeHarness();
279
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
280
- oxy.getUsersByIds.mockResolvedValue([user('a1')]);
281
- // A stale/revoked bearer 401s: an EXPECTED signed-out outcome, not a failure.
282
- oxy.listAccounts.mockRejectedValue(
283
- Object.assign(new Error('Invalid or missing authorization header'), { status: 401 }),
284
- );
285
-
286
- await controller.refresh();
287
-
288
- const snap = controller.getSnapshot();
289
- // Never surface an error for a normal signed-out state; device rows still render.
290
- expect(snap.error).toBeNull();
291
- expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1']);
292
- expect(warnSpy).not.toHaveBeenCalled();
293
- expect(debugSpy).toHaveBeenCalledWith(
294
- '[AccountDialogController] listAccounts unauthorized (signed out)',
295
- { component: 'AccountDialogController' },
296
- expect.objectContaining({ status: 401 }),
297
- );
298
- warnSpy.mockRestore();
299
- debugSpy.mockRestore();
300
- });
301
- });
302
-
303
- describe('AccountDialogController — auth-gated graph fetch (prod sign-out fix)', () => {
304
- it('start() while signed out does NOT call the private listAccounts / getUsersByIds and does not error', async () => {
305
- const { controller, oxy, sc } = makeHarness();
306
- oxy.emitTokenChange(null); // cold boot: no bearer planted yet (no listeners registered pre-start)
283
+ describe('AccountDialogController — auth-gated directory read', () => {
284
+ it('start() while signed out makes no directory call and does not error', async () => {
285
+ const { controller, oxy, sc, urls } = makeHarness();
286
+ // Cold boot: no bearer planted yet (no listeners registered pre-start).
287
+ oxy.emitTokenChange(null);
307
288
  sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
308
289
 
309
290
  controller.start();
310
291
  await flush();
311
292
 
312
- expect(oxy.listAccounts).not.toHaveBeenCalled();
313
- expect(oxy.getUsersByIds).not.toHaveBeenCalled();
293
+ // The directory read is a PRIVATE call. Issuing it before the cold-boot
294
+ // restore plants the bearer 401s, which clears the token and signs the user
295
+ // out — the failure this gate exists for.
296
+ expect(urls).not.toContain('/session/device/directory');
314
297
  const snap = controller.getSnapshot();
315
298
  expect(snap.error).toBeNull();
316
299
  expect(snap.loading).toBe(false);
317
300
  controller.destroy();
318
301
  });
319
302
 
320
- it('refresh() while signed out re-projects device-only and skips the network call', async () => {
321
- const { controller, oxy } = makeHarness();
322
- oxy.emitTokenChange(null);
323
-
324
- await controller.refresh();
325
-
326
- expect(oxy.listAccounts).not.toHaveBeenCalled();
327
- const snap = controller.getSnapshot();
328
- expect(snap.loading).toBe(false);
329
- expect(snap.error).toBeNull();
330
- });
331
-
332
- it('start() while authenticated fetches the graph exactly once', async () => {
333
- const { controller, oxy, sc } = makeHarness();
303
+ it('start() while authenticated reads the directory exactly once', async () => {
304
+ const { controller, sc, urls } = makeHarness();
334
305
  sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
335
- oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
336
- oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
337
306
 
338
307
  controller.start();
339
308
  await flush();
340
309
 
341
- expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
342
- expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
310
+ expect(directoryReads(urls)).toBe(1);
343
311
  controller.destroy();
344
312
  });
345
313
 
346
- it('fetches the graph once when the bearer is planted after a signed-out start', async () => {
347
- const { controller, oxy } = makeHarness();
314
+ it('reads the directory once when the bearer is planted after a signed-out start', async () => {
315
+ const { controller, oxy, urls } = makeHarness();
348
316
  oxy.emitTokenChange(null);
349
317
  controller.start();
350
318
  await flush();
351
- expect(oxy.listAccounts).not.toHaveBeenCalled();
319
+ expect(urls).not.toContain('/session/device/directory');
352
320
 
353
- // Cold-boot restore plants the token → onTokensChanged → single graph fetch.
321
+ // Cold-boot restore plants the token → onTokensChanged → one read.
354
322
  oxy.emitTokenChange('access-token');
355
323
  await flush();
356
- expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
324
+ expect(directoryReads(urls)).toBe(1);
357
325
  controller.destroy();
358
326
  });
359
327
 
360
- it('drops the graph and re-projects device-only (no fetch) when the token is cleared', async () => {
328
+ it('never reconstructs the account graph no listAccounts, no getUsersByIds', async () => {
361
329
  const { controller, oxy, sc } = makeHarness();
362
330
  sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
363
- oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
364
- oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
331
+
365
332
  controller.start();
366
333
  await flush();
367
- expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
368
-
369
- oxy.listAccounts.mockClear();
370
- oxy.emitTokenChange(null); // a 401 cleared the bearer
334
+ // A device change used to refetch profiles for any newly-seen account id.
335
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a2', 2));
371
336
  await flush();
372
337
 
338
+ // ADR 0002's whole point: the client holds ONE caller's account graph and
339
+ // cannot enumerate another principal's, so a switcher assembled from it is
340
+ // one person's answer presented as the device's. The directory carries the
341
+ // rows AND the display metadata, so neither private call is made at all.
373
342
  expect(oxy.listAccounts).not.toHaveBeenCalled();
374
- // Graph-only org1 is gone; the device row survives.
375
- expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1']);
343
+ expect(oxy.getUsersByIds).not.toHaveBeenCalled();
376
344
  controller.destroy();
377
345
  });
378
346
 
379
- it('does not loop when listAccounts rejects — at most one call per refresh, no re-trigger on device changes', async () => {
380
- const { controller, oxy, sc } = makeHarness();
347
+ it('does not loop when the directory read rejects — one call per refresh, no re-trigger on device changes', async () => {
348
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
349
+ const { controller, sc, urls, failDirectory } = makeHarness();
350
+ failDirectory(new Error('directory boom'));
381
351
  sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
382
- oxy.getUsersByIds.mockResolvedValue([user('a1')]);
383
- oxy.listAccounts.mockRejectedValue(new Error('graph boom'));
384
352
 
385
353
  controller.start();
386
354
  await flush();
387
- expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
388
- expect(controller.getSnapshot().error).toBe('graph boom');
355
+ expect(directoryReads(urls)).toBe(1);
356
+ expect(controller.getSnapshot().error).toBe('directory boom');
389
357
 
390
- // A subsequent device-state push must NOT re-trigger the graph fetch (auth
391
- // edge unchanged → reconcileAuth is a no-op → no storm).
358
+ // A subsequent device push must not re-trigger it (the auth edge did not
359
+ // move`reconcileAuth` is a no-op → no storm).
392
360
  sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1', 2));
393
361
  await flush();
394
- expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
395
- // Device row still rendered despite the graph failure.
396
- expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1']);
362
+ expect(directoryReads(urls)).toBe(1);
397
363
  controller.destroy();
398
- });
399
- });
400
-
401
- describe('AccountDialogController — switchTo (uniform switch)', () => {
402
- it('uses SessionClient.switchAccount for an account already on the device', async () => {
403
- const { controller, oxy, sc } = makeHarness();
404
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
405
- const switchSpy = jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
406
- oxy.getUsersByIds.mockResolvedValue([user('a1'), user('a2')]);
407
-
408
- await controller.switchTo('a2');
409
-
410
- expect(switchSpy).toHaveBeenCalledWith('a2');
411
- expect(oxy.switchToAccount).not.toHaveBeenCalled();
412
- expect(controller.getSnapshot().switchingAccountId).toBeNull();
413
- });
414
-
415
- it('mints via oxyServices.switchToAccount + commitSession on first entry into a graph account', async () => {
416
- const { controller, oxy, sc, commitSession } = makeHarness();
417
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
418
- const switchSpy = jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
419
- oxy.switchToAccount.mockResolvedValue({
420
- sessionId: 'sess-org',
421
- deviceId: 'device-1',
422
- expiresAt: '2030-01-01T00:00:00Z',
423
- accessToken: 'access-org',
424
- user: user('org1'),
425
- });
426
- oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
427
-
428
- await controller.switchTo('org1');
429
-
430
- expect(oxy.switchToAccount).toHaveBeenCalledWith('org1');
431
- expect(switchSpy).not.toHaveBeenCalled();
432
- expect(commitSession).toHaveBeenCalledTimes(1);
433
- expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
434
- });
435
-
436
- it('commits a graph switch via the IN-PLACE commitSwitchedSession — never commitSession', async () => {
437
- // An account switch must use commitSwitchedSession (in-place) and NEVER
438
- // commitSession when both funnels are wired.
439
- const oxy = makeOxy();
440
- const sc = new TestSessionClient(host());
441
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
442
- jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
443
- oxy.switchToAccount.mockResolvedValue({
444
- sessionId: 'sess-org',
445
- deviceId: 'device-1',
446
- expiresAt: '2030-01-01T00:00:00Z',
447
- accessToken: 'access-org',
448
- user: user('org1'),
449
- });
450
- const commitSession = jest.fn().mockResolvedValue(undefined);
451
- const commitSwitchedSession = jest.fn().mockResolvedValue(undefined);
452
- const controller = new AccountDialogController({
453
- oxyServices: oxy as unknown as OxyServices,
454
- sessionClient: sc,
455
- clientId: 'oxy_dk_test',
456
- commitSession,
457
- commitSwitchedSession,
458
- });
459
-
460
- await controller.switchTo('org1');
461
-
462
- expect(commitSwitchedSession).toHaveBeenCalledTimes(1);
463
- expect(commitSwitchedSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
464
- expect(commitSession).not.toHaveBeenCalled();
465
- });
466
-
467
- it('surfaces a failed switch as snapshot.error instead of silently no-op\'ing', async () => {
468
- // PROBLEM 1: switching into an account the server refuses (e.g. a 403 for a
469
- // personal-kind target) must tell the user why — the error is recorded on
470
- // the snapshot, the switch does not throw, and switchingAccountId resets.
471
- const { controller, oxy, sc } = makeHarness();
472
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
473
- oxy.switchToAccount.mockRejectedValue(new Error('Cannot switch into a personal account'));
474
-
475
- await expect(controller.switchTo('org1')).resolves.toBe(false);
476
-
477
- const snap = controller.getSnapshot();
478
- expect(snap.error).toBe('Cannot switch into a personal account');
479
- expect(snap.switchingAccountId).toBeNull();
480
- });
481
-
482
- it('falls back to SessionClient.registerAndActivate when no commitSession is supplied', async () => {
483
- const oxy = makeOxy();
484
- const sc = new TestSessionClient(host());
485
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
486
- const registerSpy = jest.spyOn(sc, 'registerAndActivate').mockResolvedValue(undefined);
487
- oxy.switchToAccount.mockResolvedValue({
488
- sessionId: 'sess-org',
489
- deviceId: 'device-1',
490
- expiresAt: '2030-01-01T00:00:00Z',
491
- accessToken: 'access-org',
492
- user: user('org1'),
493
- });
494
- const controller = new AccountDialogController({
495
- oxyServices: oxy as unknown as OxyServices,
496
- sessionClient: sc,
497
- clientId: 'oxy_dk_test',
498
- });
499
-
500
- await controller.switchTo('org1');
501
- expect(registerSpy).toHaveBeenCalledWith('org1');
364
+ warnSpy.mockRestore();
502
365
  });
503
366
 
504
- it('ignores a concurrent switch while one is in flight', async () => {
505
- const { controller, oxy, sc } = makeHarness();
506
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
507
- let release: () => void = () => undefined;
508
- jest.spyOn(sc, 'switchAccount').mockImplementation(
509
- () => new Promise<void>((resolve) => { release = resolve; }),
367
+ it('treats a 401 from the directory read as the signed-out edge — debug, no surfaced error, no warn', async () => {
368
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
369
+ const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
370
+ const { controller, failDirectory } = makeHarness();
371
+ failDirectory(
372
+ Object.assign(new Error('Invalid or missing authorization header'), { status: 401 }),
510
373
  );
511
374
 
512
- const first = controller.switchTo('a2');
513
- expect(controller.getSnapshot().switchingAccountId).toBe('a2');
514
- await controller.switchTo('a1'); // ignored — a switch is in flight
515
- expect(sc.switchAccount).toHaveBeenCalledTimes(1);
516
-
517
- release();
518
- await first;
519
- });
520
-
521
- it('returns true when the switch succeeds even if the post-switch refresh fails', async () => {
522
- const { controller, oxy, sc } = makeHarness();
523
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
524
- jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
525
- oxy.listAccounts.mockRejectedValue(new Error('network down'));
526
-
527
- await expect(controller.switchTo('a2')).resolves.toBe(true);
375
+ await controller.refresh();
528
376
 
529
- expect(controller.getSnapshot().error).toBe('network down');
530
- expect(controller.getSnapshot().switchingAccountId).toBeNull();
377
+ // A signed-out device is a normal state, not a warning.
378
+ expect(controller.getSnapshot().error).toBeNull();
379
+ expect(warnSpy).not.toHaveBeenCalled();
380
+ expect(debugSpy).toHaveBeenCalledWith(
381
+ '[AccountDialogController] directory unauthorized (signed out)',
382
+ { component: 'AccountDialogController' },
383
+ expect.objectContaining({ status: 401 }),
384
+ );
385
+ warnSpy.mockRestore();
386
+ debugSpy.mockRestore();
531
387
  });
532
388
  });
533
389
 
@@ -871,7 +727,7 @@ describe('AccountDialogController — Commons availability (canOpenApp)', () =>
871
727
  oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
872
728
  const controller = new AccountDialogController({
873
729
  oxyServices: oxy as unknown as OxyServices,
874
- sessionClient: new TestSessionClient(host()),
730
+ sessionClient: new TestSessionClient(host().host),
875
731
  clientId: 'oxy_dk_test',
876
732
  pollIntervalMs: 1000,
877
733
  openUrl: opts.openUrl,
@@ -995,7 +851,7 @@ describe('AccountDialogController — /auth-session socket (instant QR wake)', (
995
851
  const commitSession = jest.fn().mockResolvedValue(undefined);
996
852
  const controller = new AccountDialogController({
997
853
  oxyServices: oxy as unknown as OxyServices,
998
- sessionClient: new TestSessionClient(host()),
854
+ sessionClient: new TestSessionClient(host().host),
999
855
  clientId: 'oxy_dk_test',
1000
856
  commitSession,
1001
857
  socketFactory: factory as unknown as SocketIOFactory,
@@ -1114,7 +970,7 @@ function makeDeliveryHarness(opts: {
1114
970
  if (opts.authenticated === false) oxy.emitTokenChange(null);
1115
971
  const controller = new AccountDialogController({
1116
972
  oxyServices: oxy as unknown as OxyServices,
1117
- sessionClient: new TestSessionClient(host()),
973
+ sessionClient: new TestSessionClient(host().host),
1118
974
  clientId: 'oxy_dk_test',
1119
975
  // Every real consumer wires the provider's commit funnel; without it the
1120
976
  // controller falls back to `SessionClient.registerAndActivate`, which opens
@@ -1408,7 +1264,7 @@ describe('AccountDialogController — automatic delivery selection (#691 phase 5
1408
1264
  oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
1409
1265
  const controller = new AccountDialogController({
1410
1266
  oxyServices: oxy as unknown as OxyServices,
1411
- sessionClient: new TestSessionClient(host()),
1267
+ sessionClient: new TestSessionClient(host().host),
1412
1268
  clientId: 'oxy_dk_test',
1413
1269
  pollIntervalMs: 1000,
1414
1270
  platform: 'desktop',
@@ -1656,7 +1512,7 @@ describe('AccountDialogController — cancellation converges (#691 phase 5)', ()
1656
1512
  oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
1657
1513
  const controller = new AccountDialogController({
1658
1514
  oxyServices: oxy as unknown as OxyServices,
1659
- sessionClient: new TestSessionClient(host()),
1515
+ sessionClient: new TestSessionClient(host().host),
1660
1516
  clientId: 'oxy_dk_test',
1661
1517
  pollIntervalMs: 1000,
1662
1518
  platform: 'desktop',
@@ -1772,7 +1628,7 @@ describe('AccountDialogController — cancellation converges (#691 phase 5)', ()
1772
1628
  });
1773
1629
  const controller = new AccountDialogController({
1774
1630
  oxyServices: oxy as unknown as OxyServices,
1775
- sessionClient: new TestSessionClient(host()),
1631
+ sessionClient: new TestSessionClient(host().host),
1776
1632
  clientId: 'oxy_dk_test',
1777
1633
  pollIntervalMs: 1000,
1778
1634
  platform: 'desktop',
@@ -1813,6 +1669,283 @@ describe('AccountDialogController — lifecycle', () => {
1813
1669
  });
1814
1670
  });
1815
1671
 
1672
+ describe('AccountDialogController — the directory (ADR 0002)', () => {
1673
+ /** A two-principal device where `org` is reachable through BOTH people. */
1674
+ const sharedDirectory = (revision: number, activeContextId: string | null) => ({
1675
+ deviceId: 'device-1',
1676
+ revision,
1677
+ activeContextId,
1678
+ updatedAt: 1_720_000_000_000,
1679
+ principals: [
1680
+ {
1681
+ id: 'p-nate',
1682
+ userId: 'nate',
1683
+ authuser: 0,
1684
+ user: { id: 'nate', username: 'nate' },
1685
+ contexts: [
1686
+ {
1687
+ id: 'ctx-nate-org',
1688
+ accountId: 'org',
1689
+ kind: 'organization' as const,
1690
+ relationship: 'owner' as const,
1691
+ account: { id: 'org', username: 'oxy' },
1692
+ onDevice: true,
1693
+ available: true,
1694
+ active: activeContextId === 'ctx-nate-org',
1695
+ lastUsedAt: null,
1696
+ },
1697
+ ],
1698
+ },
1699
+ {
1700
+ id: 'p-alice',
1701
+ userId: 'alice',
1702
+ authuser: 1,
1703
+ user: { id: 'alice', username: 'alice' },
1704
+ contexts: [
1705
+ {
1706
+ id: 'ctx-alice-org',
1707
+ accountId: 'org',
1708
+ kind: 'organization' as const,
1709
+ relationship: 'member' as const,
1710
+ account: { id: 'org', username: 'oxy' },
1711
+ onDevice: true,
1712
+ available: true,
1713
+ active: activeContextId === 'ctx-alice-org',
1714
+ lastUsedAt: null,
1715
+ },
1716
+ ],
1717
+ },
1718
+ ],
1719
+ });
1720
+
1721
+ function directoryHarness(activeContextId: string | null) {
1722
+ const oxy = makeOxy();
1723
+ const urls: string[] = [];
1724
+ const bodies: unknown[] = [];
1725
+ const sc = new TestSessionClient({
1726
+ makeRequest: jest.fn(async (_method: string, url: string, data?: unknown) => {
1727
+ urls.push(url);
1728
+ bodies.push(data);
1729
+ if (url === '/session/device/directory') return sharedDirectory(5, activeContextId);
1730
+ if (url === '/session/device/activate') {
1731
+ return { directory: sharedDirectory(6, 'ctx-alice-org'), activeToken: null };
1732
+ }
1733
+ if (url === '/session/device/signout') {
1734
+ // The context-aware removals answer with their OWN contract — the
1735
+ // directory AND the flat state, because a removal elects a
1736
+ // replacement active context and both halves move together.
1737
+ return {
1738
+ directory: sharedDirectory(7, null),
1739
+ state: state([{ accountId: 'org', sessionId: 's-org' }], 'org', 7),
1740
+ activeToken: null,
1741
+ };
1742
+ }
1743
+ return undefined;
1744
+ }),
1745
+ getBaseURL: () => 'http://test.invalid',
1746
+ getAccessToken: () => 'token',
1747
+ getDeviceCredential: () => null,
1748
+ onTokensChanged: () => () => undefined,
1749
+ setTokens: jest.fn(),
1750
+ getCurrentAccountId: () => null,
1751
+ });
1752
+ const controller = createAccountDialogController({
1753
+ oxyServices: oxy as unknown as OxyServices,
1754
+ sessionClient: sc,
1755
+ clientId: 'oxy_dk_test',
1756
+ commitSession: jest.fn().mockResolvedValue(undefined),
1757
+ onSignedIn: jest.fn(),
1758
+ });
1759
+ return { controller, oxy, sc, urls, bodies };
1760
+ }
1761
+
1762
+ it('publishes the directory and the active context on the snapshot', async () => {
1763
+ const { controller, oxy } = directoryHarness('ctx-alice-org');
1764
+ oxy.listAccounts.mockResolvedValue([]);
1765
+ oxy.getUsersByIds.mockResolvedValue([]);
1766
+
1767
+ await controller.refresh();
1768
+
1769
+ const snap = controller.getSnapshot();
1770
+ expect(snap.directory?.revision).toBe(5);
1771
+ // The flat `accounts` list cannot express this: it is keyed by account id,
1772
+ // so ONE of these two routes to `org` would be all a consumer ever saw.
1773
+ expect(snap.directory?.principals.map((p) => p.userId)).toEqual(['nate', 'alice']);
1774
+ expect(snap.activeContext?.contextId).toBe('ctx-alice-org');
1775
+ expect(snap.activeContext?.actor.userId).toBe('alice');
1776
+ expect(snap.activeContext?.subject.accountId).toBe('org');
1777
+ expect(snap.activeContext?.isDelegated).toBe(true);
1778
+ });
1779
+
1780
+ it('activateContext POSTs the contextId and reports the switch in flight', async () => {
1781
+ const { controller, oxy, urls, bodies } = directoryHarness('ctx-nate-org');
1782
+ oxy.listAccounts.mockResolvedValue([]);
1783
+ oxy.getUsersByIds.mockResolvedValue([]);
1784
+ await controller.refresh();
1785
+
1786
+ const inFlight: Array<string | null> = [];
1787
+ controller.subscribe((s) => inFlight.push(s.activatingContextId));
1788
+
1789
+ expect(await controller.activateContext('ctx-alice-org')).toBe(true);
1790
+
1791
+ expect(urls).toContain('/session/device/activate');
1792
+ expect(bodies[urls.indexOf('/session/device/activate')]).toEqual({ contextId: 'ctx-alice-org' });
1793
+ // Reported while running, cleared after — a row can show a spinner.
1794
+ expect(inFlight).toContain('ctx-alice-org');
1795
+ expect(controller.getSnapshot().activatingContextId).toBeNull();
1796
+ expect(controller.getSnapshot().activeContext?.actor.userId).toBe('alice');
1797
+ });
1798
+
1799
+ it('refuses a second activation while one is in flight', async () => {
1800
+ const { controller, oxy } = directoryHarness('ctx-nate-org');
1801
+ oxy.listAccounts.mockResolvedValue([]);
1802
+ oxy.getUsersByIds.mockResolvedValue([]);
1803
+ await controller.refresh();
1804
+
1805
+ const first = controller.activateContext('ctx-alice-org');
1806
+ expect(await controller.activateContext('ctx-nate-org')).toBe(false);
1807
+ expect(await first).toBe(true);
1808
+ });
1809
+
1810
+ it('surfaces an activation failure as a dialog error without wedging the flag', async () => {
1811
+ const { controller, oxy, sc } = directoryHarness('ctx-nate-org');
1812
+ oxy.listAccounts.mockResolvedValue([]);
1813
+ oxy.getUsersByIds.mockResolvedValue([]);
1814
+ await controller.refresh();
1815
+ // A stale context id is an ordinary outcome, not a bug: the server 404s and
1816
+ // heals the row away.
1817
+ jest.spyOn(sc, 'activateContext').mockRejectedValue(new Error('Context not on this device'));
1818
+
1819
+ expect(await controller.activateContext('ctx-gone')).toBe(false);
1820
+
1821
+ expect(controller.getSnapshot().error).toBe('Context not on this device');
1822
+ expect(controller.getSnapshot().activatingContextId).toBeNull();
1823
+ });
1824
+
1825
+ it('keeps the directory it already holds when a later read fails, and says so', async () => {
1826
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
1827
+ const { controller, failDirectory } = makeHarness();
1828
+
1829
+ await controller.refresh();
1830
+ expect(controller.getSnapshot().directory?.revision).toBe(9_000);
1831
+
1832
+ failDirectory(new Error('boom'));
1833
+ await controller.refresh();
1834
+
1835
+ // Degraded, not blank. A transient outage must not empty a switcher the
1836
+ // user is looking at — but it must not pretend either, so the error is on
1837
+ // the snapshot for the surface to render.
1838
+ const snap = controller.getSnapshot();
1839
+ expect(snap.directory?.revision).toBe(9_000);
1840
+ expect(snap.error).toBe('boom');
1841
+ expect(snap.loading).toBe(false);
1842
+ expect(warnSpy).toHaveBeenCalledWith(
1843
+ '[AccountDialogController] directory refresh failed',
1844
+ { component: 'AccountDialogController' },
1845
+ expect.any(Error),
1846
+ );
1847
+ warnSpy.mockRestore();
1848
+ });
1849
+
1850
+ it('spins only while there is nothing to show', async () => {
1851
+ const { controller } = makeHarness();
1852
+ const loading: boolean[] = [];
1853
+ controller.subscribe((s) => loading.push(s.loading));
1854
+
1855
+ // First read: nothing held, so the switcher has nothing to render.
1856
+ await controller.refresh();
1857
+ expect(loading).toContain(true);
1858
+ expect(controller.getSnapshot().loading).toBe(false);
1859
+
1860
+ // Second read, behind a directory already on screen: refreshed in place,
1861
+ // never blanked back to a spinner.
1862
+ loading.length = 0;
1863
+ await controller.refresh();
1864
+ expect(loading).not.toContain(true);
1865
+ });
1866
+
1867
+ it('removes ONE route to a shared account, naming the pair and not the account', async () => {
1868
+ const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
1869
+ await controller.refresh();
1870
+
1871
+ const inFlight: Array<string | null> = [];
1872
+ controller.subscribe((s) => inFlight.push(s.removingContextId));
1873
+
1874
+ expect(await controller.signOutContext('ctx-alice-org')).toBe(true);
1875
+
1876
+ const index = urls.indexOf('/session/device/signout');
1877
+ expect(index).toBeGreaterThanOrEqual(0);
1878
+ // `{ contextId }`, never `{ accountId }`. Both people reach `org` here, and
1879
+ // an account id would revoke Nate's route as a side effect of Alice tidying
1880
+ // her own list.
1881
+ expect(bodies[index]).toEqual({ contextId: 'ctx-alice-org' });
1882
+ expect(inFlight).toContain('ctx-alice-org');
1883
+ expect(controller.getSnapshot().removingContextId).toBeNull();
1884
+ });
1885
+
1886
+ it('removes ONE PERSON through the principal id, not through their contexts', async () => {
1887
+ const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
1888
+ await controller.refresh();
1889
+
1890
+ const inFlight: Array<string | null> = [];
1891
+ controller.subscribe((s) => inFlight.push(s.removingPrincipalId));
1892
+
1893
+ expect(await controller.signOutPrincipal('p-alice')).toBe(true);
1894
+
1895
+ const index = urls.indexOf('/session/device/signout');
1896
+ // `{ principalId }` — one call, not a loop over that person's contexts, and
1897
+ // emphatically not the context endpoint: pointing this at `{ contextId }`
1898
+ // would drop one pair and leave the person on the device.
1899
+ expect(bodies[index]).toEqual({ principalId: 'p-alice' });
1900
+ expect(urls.filter((u) => u === '/session/device/signout')).toHaveLength(1);
1901
+ expect(inFlight).toContain('p-alice');
1902
+ expect(controller.getSnapshot().removingPrincipalId).toBeNull();
1903
+ });
1904
+
1905
+ it('refuses a second removal while one is in flight, of either kind', async () => {
1906
+ const { controller, sc } = directoryHarness('ctx-nate-org');
1907
+ await controller.refresh();
1908
+ let release: () => void = () => undefined;
1909
+ jest.spyOn(sc, 'signOutContext').mockImplementation(
1910
+ () => new Promise<void>((resolve) => { release = resolve; }),
1911
+ );
1912
+ const principalSpy = jest.spyOn(sc, 'signOutPrincipal').mockResolvedValue(undefined);
1913
+
1914
+ const first = controller.signOutContext('ctx-alice-org');
1915
+ // The two removals share one gate: they mutate the same device and a
1916
+ // concurrent pair would race for which replacement context ends up active.
1917
+ expect(await controller.signOutPrincipal('p-alice')).toBe(false);
1918
+ expect(principalSpy).not.toHaveBeenCalled();
1919
+
1920
+ release();
1921
+ expect(await first).toBe(true);
1922
+ });
1923
+
1924
+ it('surfaces a failed removal without wedging the flag', async () => {
1925
+ const { controller, sc } = directoryHarness('ctx-nate-org');
1926
+ await controller.refresh();
1927
+ // A context id is not stable across a removal, so a stale one is ordinary:
1928
+ // the server refuses and the next read simply does not offer it.
1929
+ jest.spyOn(sc, 'signOutContext').mockRejectedValue(new Error('Context not on this device'));
1930
+
1931
+ expect(await controller.signOutContext('ctx-gone')).toBe(false);
1932
+
1933
+ expect(controller.getSnapshot().error).toBe('Context not on this device');
1934
+ expect(controller.getSnapshot().removingContextId).toBeNull();
1935
+ });
1936
+
1937
+ it('makes no directory call while signed out', async () => {
1938
+ const { controller, oxy, urls } = directoryHarness(null);
1939
+ oxy.getAccessToken.mockReturnValue(null);
1940
+ oxy.listAccounts.mockResolvedValue([]);
1941
+
1942
+ await controller.refresh();
1943
+
1944
+ expect(urls).not.toContain('/session/device/directory');
1945
+ expect(controller.getSnapshot().directory).toBeNull();
1946
+ });
1947
+ });
1948
+
1816
1949
  it('createAccountDialogController returns an AccountDialogController instance', () => {
1817
1950
  const { controller } = makeHarness();
1818
1951
  expect(controller).toBeInstanceOf(AccountDialogController);