@oxyhq/core 20.0.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 (94) hide show
  1. package/NOTICE +10 -9
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  4. package/dist/cjs/i18n/locales/en-US.json +19 -2
  5. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  7. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  8. package/dist/cjs/index.js +50 -16
  9. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  10. package/dist/cjs/mixins/OxyServices.chains.js +73 -0
  11. package/dist/cjs/mixins/OxyServices.store.js +266 -0
  12. package/dist/cjs/mixins/OxyServices.utility.js +159 -104
  13. package/dist/cjs/mixins/index.js +7 -0
  14. package/dist/cjs/server/rateLimit.js +15 -6
  15. package/dist/cjs/session/SessionClient.js +361 -1
  16. package/dist/cjs/session/accountDialogController.js +121 -147
  17. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  18. package/dist/cjs/session/deviceDirectory.js +143 -0
  19. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  20. package/dist/cjs/session/projectSessionState.js +8 -1
  21. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  22. package/dist/esm/.tsbuildinfo +1 -1
  23. package/dist/esm/boot/sessionColdBoot.js +107 -8
  24. package/dist/esm/i18n/locales/en-US.json +19 -2
  25. package/dist/esm/i18n/locales/es-ES.json +19 -2
  26. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  27. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  28. package/dist/esm/index.js +32 -10
  29. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  30. package/dist/esm/mixins/OxyServices.chains.js +70 -0
  31. package/dist/esm/mixins/OxyServices.store.js +263 -0
  32. package/dist/esm/mixins/OxyServices.utility.js +159 -104
  33. package/dist/esm/mixins/index.js +7 -0
  34. package/dist/esm/server/rateLimit.js +15 -6
  35. package/dist/esm/session/SessionClient.js +362 -2
  36. package/dist/esm/session/accountDialogController.js +121 -147
  37. package/dist/esm/session/accountSwitchTargets.js +71 -0
  38. package/dist/esm/session/deviceDirectory.js +135 -0
  39. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  40. package/dist/esm/session/projectSessionState.js +8 -2
  41. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  42. package/dist/types/.tsbuildinfo +1 -1
  43. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  44. package/dist/types/index.d.ts +15 -3
  45. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  46. package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
  47. package/dist/types/mixins/OxyServices.store.d.ts +334 -0
  48. package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
  49. package/dist/types/mixins/index.d.ts +3 -1
  50. package/dist/types/models/session.d.ts +11 -0
  51. package/dist/types/session/SessionClient.d.ts +202 -1
  52. package/dist/types/session/accountDialogController.d.ts +76 -64
  53. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  54. package/dist/types/session/deviceDirectory.d.ts +182 -0
  55. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  56. package/dist/types/session/projectSessionState.d.ts +29 -0
  57. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  58. package/package.json +3 -3
  59. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  60. package/src/boot/sessionColdBoot.ts +133 -9
  61. package/src/i18n/locales/en-US.json +19 -2
  62. package/src/i18n/locales/es-ES.json +19 -2
  63. package/src/index.ts +105 -18
  64. package/src/mixins/OxyServices.auth.ts +67 -5
  65. package/src/mixins/OxyServices.chains.ts +134 -0
  66. package/src/mixins/OxyServices.store.ts +585 -0
  67. package/src/mixins/OxyServices.utility.ts +161 -108
  68. package/src/mixins/__tests__/chains.test.ts +113 -0
  69. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  70. package/src/mixins/__tests__/store.test.ts +304 -0
  71. package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
  72. package/src/mixins/index.ts +9 -0
  73. package/src/models/session.ts +11 -0
  74. package/src/server/__tests__/rateLimit.test.ts +47 -0
  75. package/src/server/rateLimit.ts +18 -8
  76. package/src/session/SessionClient.ts +386 -1
  77. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  78. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  79. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  80. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  81. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  82. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  83. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  84. package/src/session/accountDialogController.ts +141 -179
  85. package/src/session/accountSwitchTargets.ts +87 -0
  86. package/src/session/deviceDirectory.ts +269 -0
  87. package/src/session/deviceSwitcherRows.ts +145 -0
  88. package/src/session/projectSessionState.ts +9 -3
  89. package/src/session/sharedDeviceCredential.ts +349 -0
  90. package/dist/cjs/session/accountProjection.js +0 -213
  91. package/dist/esm/session/accountProjection.js +0 -207
  92. package/dist/types/session/accountProjection.d.ts +0 -198
  93. package/src/session/__tests__/accountProjection.test.ts +0 -447
  94. package/src/session/accountProjection.ts +0 -354
@@ -9,15 +9,16 @@
9
9
  * replaces.
10
10
  *
11
11
  * The controller owns:
12
- * - the unified account list (via {@link projectSwitchableAccounts}), fetched
13
- * from `SessionClient` state `oxyServices.listAccounts()` and hydrated
14
- * with `oxyServices.getUsersByIds()`;
12
+ * - the device DIRECTORY (ADR 0002) — the server-authoritative read model of
13
+ * who is on this device and what each of them may act as, read through
14
+ * `SessionClient.refreshDirectory()`. It is not assembled here: the client
15
+ * holds one caller's account graph and cannot enumerate another principal's,
16
+ * so switchability is the server's answer and the controller only reads it;
15
17
  * - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
16
18
  * `signup`);
17
- * - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
18
- * account already on the device, `oxyServices.switchToAccount` to mint on
19
- * first entry into a graph account — reusing the existing SDK primitives, no
20
- * new switch path);
19
+ * - `activateContext` (the ADR 0002 switch, keyed on the `principal acting as
20
+ * account` pair) and the two removals an account id cannot name —
21
+ * `signOutContext` and `signOutPrincipal`;
21
22
  * - the "Sign in with Oxy" device flow (same-device shared-keychain via
22
23
  * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
23
24
  * via `startCommonsSignIn` → poll → `claimSessionByToken`);
@@ -55,6 +56,7 @@
55
56
  * teardown), never an `open` / `close` / `visible`.
56
57
  */
57
58
 
59
+ import type { DeviceDirectory } from '@oxyhq/contracts';
58
60
  import type { OxyServices } from '../OxyServices';
59
61
  import type { SessionLoginResponse, MinimalUserData } from '../models/session';
60
62
  import type { User } from '../models/interfaces';
@@ -63,12 +65,7 @@ import { extractErrorStatus } from '../utils/errorUtils';
63
65
  import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
64
66
  import type { SessionClient } from './SessionClient';
65
67
  import type { MinimalSocket, SocketIOFactory } from './socketLoader';
66
- import {
67
- projectSwitchableAccounts,
68
- switchableAccountIds,
69
- type SwitchableAccount,
70
- } from './accountProjection';
71
- import type { AccountNode } from '../mixins/OxyServices.accounts';
68
+ import { resolveActiveContext, type DeviceContext } from './deviceDirectory';
72
69
  import type { CommonsSignInHandle } from '../mixins/OxyServices.auth';
73
70
  import {
74
71
  pushTargetsFromDelivery,
@@ -262,16 +259,27 @@ function deriveSignInProgress(facts: SignInFlowFacts): SignInProgress {
262
259
  export interface AccountDialogSnapshot {
263
260
  /** The current view. */
264
261
  view: AccountDialogView;
265
- /** The unified, deduped account list (device sign-ins ∪ graph accounts). */
266
- accounts: SwitchableAccount[];
267
- /** The currently-active account id, or `null` when signed out. */
268
- activeAccountId: string | null;
269
- /** `true` while the initial account-list fetch is in flight with no data yet. */
262
+ /**
263
+ * The server-authoritative device directory — principals and the contexts
264
+ * each may act as (ADR 0002) — or `null` before the first read.
265
+ *
266
+ * The ONE read model a switcher renders. The flat list this replaced was
267
+ * keyed by account id, so on a device holding two people it could show one
268
+ * route to a shared organization and never both.
269
+ */
270
+ directory: DeviceDirectory | null;
271
+ /** The active `principal acting as account` pair, actor and subject apart. */
272
+ activeContext: DeviceContext | null;
273
+ /** `true` while the first directory read is in flight with nothing to show. */
270
274
  loading: boolean;
271
- /** A human-readable account-list error, or `null`. */
275
+ /** A human-readable directory error, or `null`. */
272
276
  error: string | null;
273
- /** The `accountId` of an in-flight switch, or `null`. */
274
- switchingAccountId: string | null;
277
+ /** The `contextId` of an in-flight activation, or `null`. */
278
+ activatingContextId: string | null;
279
+ /** The `contextId` of an in-flight context removal, or `null`. */
280
+ removingContextId: string | null;
281
+ /** The `principalId` of an in-flight principal removal, or `null`. */
282
+ removingPrincipalId: string | null;
275
283
  /** The "Sign in with Oxy" device-flow state. */
276
284
  signIn: SignInFlowState;
277
285
  /** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
@@ -291,8 +299,6 @@ export interface AccountDialogControllerOptions {
291
299
  * server would reject.
292
300
  */
293
301
  clientId?: string | null;
294
- /** Locale for display-name resolution. */
295
- locale?: string;
296
302
  /**
297
303
  * Commit a freshly-authorized SIGN-IN session (device flow / shared identity)
298
304
  * into the host's session set — device-first registration + durable persist +
@@ -302,24 +308,12 @@ export interface AccountDialogControllerOptions {
302
308
  * `SessionClient.registerAndActivate` (registration + activation only — no
303
309
  * provider-side durable persist/hydration).
304
310
  *
305
- * This is the SIGN-IN commit: registers the session into the host's device
306
- * set with durable persist + profile hydration. An account SWITCH uses
307
- * {@link commitSwitchedSession} instead see below.
311
+ * Sign-in is the only thing that commits a session here. An account SWITCH
312
+ * used to mint one too, on first entry into a graph account; activation mints
313
+ * the delegated session SERVER-side and hands back a bearer, so there is no
314
+ * second commit funnel to keep in step with this one.
308
315
  */
309
316
  commitSession?: (session: SessionLoginResponse) => Promise<void>;
310
- /**
311
- * Commit a minted graph SWITCH session into the host's session set — same
312
- * device-first registration + durable persist + profile hydration as
313
- * {@link commitSession}, but IN-PLACE: it must NOT re-run sign-in side effects
314
- * that belong only to a fresh authorization (for example, a redundant full
315
- * device-set reconcile on switch). Cross-tab/app propagation of the switch
316
- * still happens instantly via the server's device-scoped `session_state` /
317
- * `session_accounts_changed` socket broadcast — no navigation required.
318
- *
319
- * When omitted the controller falls back to {@link commitSession} (if wired)
320
- * and then to `SessionClient.registerAndActivate`.
321
- */
322
- commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
323
317
  /** Notified after a completed sign-in (bearer planted + session committed). */
324
318
  onSignedIn?: (user: MinimalUserData) => void;
325
319
  /**
@@ -436,9 +430,7 @@ export class AccountDialogController {
436
430
  private readonly oxyServices: OxyServices;
437
431
  private readonly sessionClient: SessionClient;
438
432
  private readonly clientId: string | null;
439
- private readonly locale?: string;
440
433
  private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
441
- private readonly commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
442
434
  private readonly onSignedIn?: (user: MinimalUserData) => void;
443
435
  private readonly pollIntervalMs: number;
444
436
  private readonly openUrl?: (url: string) => void;
@@ -452,11 +444,11 @@ export class AccountDialogController {
452
444
 
453
445
  // --- Internal (unprojected) state ---
454
446
  private view: AccountDialogView = 'accounts';
455
- private graph: AccountNode[] = [];
456
- private profilesById = new Map<string, User>();
457
447
  private loading = false;
458
448
  private error: string | null = null;
459
- private switchingAccountId: string | null = null;
449
+ private activatingContextId: string | null = null;
450
+ private removingContextId: string | null = null;
451
+ private removingPrincipalId: string | null = null;
460
452
  private signIn: SignInFlowState = IDLE_SIGN_IN;
461
453
  private commonsAvailability: CommonsAvailability = 'unknown';
462
454
 
@@ -494,9 +486,7 @@ export class AccountDialogController {
494
486
  this.oxyServices = options.oxyServices;
495
487
  this.sessionClient = options.sessionClient;
496
488
  this.clientId = options.clientId ?? null;
497
- this.locale = options.locale;
498
489
  this.commitSession = options.commitSession;
499
- this.commitSwitchedSession = options.commitSwitchedSession;
500
490
  this.onSignedIn = options.onSignedIn;
501
491
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
502
492
  this.openUrl = options.openUrl;
@@ -539,11 +529,12 @@ export class AccountDialogController {
539
529
  this.started = true;
540
530
  this.authed = this.isAuthenticated();
541
531
  this.unsubscribeSession = this.sessionClient.subscribe(() => {
542
- // A device-state change (switch / sign-out / sibling sign-in) can add or
543
- // remove accounts re-project immediately, refetch profiles when new
544
- // account ids appeared, and reconcile the auth-readiness edge.
532
+ // A device change (switch / sign-out / sibling sign-in) re-reads the
533
+ // directory inside `SessionClient` before it notifies, so the snapshot
534
+ // this rebuilds is already the new one — publish it and reconcile the
535
+ // auth-readiness edge. No profile fetch: the directory carries the display
536
+ // metadata a row needs.
545
537
  this.emit();
546
- void this.ensureProfiles();
547
538
  this.reconcileAuth();
548
539
  });
549
540
  // The access token is planted AFTER `SessionClient.applyState` fires its
@@ -626,9 +617,9 @@ export class AccountDialogController {
626
617
  void this.refresh();
627
618
  return;
628
619
  }
629
- // Signed out: the graph is no longer fetchable/switchable drop it and
630
- // re-project from the device session set alone.
631
- this.graph = [];
620
+ // Signed out. The directory is bearer-read and `SessionClient` drops the one
621
+ // it holds when the device empties, so there is nothing to clear here — only
622
+ // the in-flight bookkeeping, which no longer describes anything.
632
623
  this.error = null;
633
624
  this.loading = false;
634
625
  this.emit();
@@ -663,164 +654,148 @@ export class AccountDialogController {
663
654
  }
664
655
 
665
656
  // =========================================================================
666
- // Account list
657
+ // The directory
667
658
  // =========================================================================
668
659
 
669
660
  /**
670
- * Reload the account graph and per-account profiles, then re-project. Safe to
671
- * call repeatedly; concurrent calls are reconciled by a sequence guard so a
672
- * slow earlier fetch never overwrites a newer result.
661
+ * Re-read `GET /session/device/directory`. Safe to call repeatedly;
662
+ * concurrent calls are reconciled by a sequence guard so a slow earlier read
663
+ * never overwrites a newer result.
664
+ *
665
+ * This is ONE request. It used to be three — the directory, plus
666
+ * `listAccounts()` and `getUsersByIds()` to rebuild the same tree client-side
667
+ * — and the reconstruction was not merely redundant: it enumerated the
668
+ * CALLER's account graph, which on a device holding two people is one
669
+ * person's answer presented as the device's.
673
670
  */
674
671
  async refresh(): Promise<void> {
675
672
  const seq = ++this.refreshSeq;
676
673
 
677
- // Never hit the private `listAccounts()` while signed out: at cold boot the
678
- // bearer is not planted yet, so the call 401s → `HttpService` clears the
679
- // token and signs the user out. Re-project from the device session set alone
680
- // (`projectSwitchableAccounts` works from `SessionClient` state) and stop.
674
+ // Never read the directory while signed out: at cold boot the bearer is not
675
+ // planted yet, so the call 401s → `HttpService` clears the token and signs
676
+ // the user out.
681
677
  if (!this.isAuthenticated()) {
682
- this.graph = [];
683
678
  this.loading = false;
684
679
  this.error = null;
685
680
  this.emit();
686
681
  return;
687
682
  }
688
683
 
689
- const hadAccounts = this.snapshot.accounts.length > 0;
690
- this.loading = !hadAccounts;
684
+ // Nothing to show yet is the only state worth a spinner; a re-read behind an
685
+ // already-rendered directory refreshes in place.
686
+ this.loading = this.sessionClient.getDirectory() === null;
691
687
  this.error = null;
692
688
  this.emit();
693
689
 
694
- let graph: AccountNode[] = this.graph;
695
690
  try {
696
- graph = await this.oxyServices.listAccounts();
691
+ await this.sessionClient.refreshDirectory();
697
692
  } catch (error) {
698
- // A 401 here is the EXPECTED signed-out edge, not a failure: the bearer was
693
+ // A 401 is the EXPECTED signed-out edge, not a failure: the bearer was
699
694
  // stale/revoked, so `HttpService` already cleared it and emitted
700
- // `onTokensChanged(null)`, which drops the graph via `reconcileAuth`. Log at
701
- // debug and leave the dialog error-free a signed-out device with zero
702
- // accounts is a normal state, not a warning. Any other error (network, 5xx,
703
- // malformed) IS unexpected: surface it and warn while keeping the prior graph
704
- // so device rows still render.
695
+ // `onTokensChanged(null)`. Any OTHER error (network, 5xx, malformed) IS
696
+ // unexpected surface it, and keep whatever directory is already held so
697
+ // an outage degrades rather than blanks the switcher.
705
698
  if (extractErrorStatus(error) === 401) {
706
- logger.debug('[AccountDialogController] listAccounts unauthorized (signed out)', { component: 'AccountDialogController' }, error);
699
+ logger.debug('[AccountDialogController] directory unauthorized (signed out)', { component: 'AccountDialogController' }, error);
707
700
  } else {
708
701
  this.error = errorMessage(error);
709
- logger.warn('[AccountDialogController] listAccounts failed', { component: 'AccountDialogController' }, error);
702
+ logger.warn('[AccountDialogController] directory refresh failed', { component: 'AccountDialogController' }, error);
710
703
  }
711
704
  }
712
705
  if (seq !== this.refreshSeq) return; // superseded by a newer refresh
713
706
 
714
- this.graph = graph;
715
- await this.loadProfiles(seq);
716
- if (seq !== this.refreshSeq) return;
717
-
718
707
  this.loading = false;
719
708
  this.emit();
720
709
  }
721
710
 
711
+ // =========================================================================
712
+ // Activation and the two removals (ADR 0002)
713
+ // =========================================================================
714
+
722
715
  /**
723
- * Fetch profiles for any account id (device set graph) not yet resolved.
724
- * Cheap no-op when everything is already hydrated used from the session
725
- * subscription so a newly-added device account gets a name/avatar.
716
+ * Activate one `principal acting as account` context the ADR 0002 switch,
717
+ * and the one that can express what an account id cannot: WHICH person's
718
+ * route to a shared organization to become.
719
+ *
720
+ * There is no on-device/graph fork. The directory has a row for a context the
721
+ * principal may act as but has never entered, and `POST /session/device/
722
+ * activate` reuses or mints the delegated session server-side, so one call
723
+ * covers both cases.
724
+ *
725
+ * A context id is not stable across a removal, so a stale one is an ordinary
726
+ * outcome rather than a bug: the server answers 404 or 403, heals the row, and
727
+ * the refresh below re-reads a directory that no longer offers it.
726
728
  */
727
- private async ensureProfiles(): Promise<void> {
728
- // `getUsersByIds` is private — skip the whole path while signed out.
729
- if (!this.isAuthenticated()) return;
730
- const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
731
- if (ids.every((id) => this.profilesById.has(id))) return;
732
- await this.loadProfiles(this.refreshSeq);
729
+ async activateContext(contextId: string): Promise<boolean> {
730
+ if (this.activatingContextId) return false;
731
+ this.activatingContextId = contextId;
732
+ this.error = null;
733
733
  this.emit();
734
+ try {
735
+ await this.sessionClient.activateContext(contextId);
736
+ await this.refresh();
737
+ return true;
738
+ } catch (error) {
739
+ this.error = errorMessage(error);
740
+ return false;
741
+ } finally {
742
+ this.activatingContextId = null;
743
+ this.emit();
744
+ }
734
745
  }
735
746
 
736
- private async loadProfiles(seq: number): Promise<void> {
737
- // `getUsersByIds` (`POST /users/by-ids`) is a private call never issue it
738
- // while signed out (the 401 → sign-out cascade). Callers already gate; this
739
- // guards the network chokepoint too (e.g. the token was cleared mid-refresh).
740
- if (!this.isAuthenticated()) return;
741
- const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
742
- if (ids.length === 0) return;
743
- let profiles: User[] = [];
747
+ /**
748
+ * Remove ONE `principal account` pair, and only that pair.
749
+ *
750
+ * Not the account across the device: the same organization reached through a
751
+ * second person is a different session with a different audit actor, and it
752
+ * stays. Routing this through `signOut({accountId})` would revoke that second
753
+ * person's access as a side effect of one person tidying their own list.
754
+ *
755
+ * The removed pair is not gone for good while the membership lives — the
756
+ * server offers it again on the next read, under a NEW id and at an unchanged
757
+ * revision — so nothing may hold a context id across this call.
758
+ */
759
+ async signOutContext(contextId: string): Promise<boolean> {
760
+ if (this.removingContextId || this.removingPrincipalId) return false;
761
+ this.removingContextId = contextId;
762
+ this.error = null;
763
+ this.emit();
744
764
  try {
745
- profiles = await this.oxyServices.getUsersByIds(ids);
765
+ await this.sessionClient.signOutContext(contextId);
766
+ await this.refresh();
767
+ return true;
746
768
  } catch (error) {
747
- // A 401 is the EXPECTED signed-out edge (stale/cleared bearer) — log at debug.
748
- // `getUsersByIds` already swallows per-chunk failures and returns `[]`, so any
749
- // OTHER error here is an unexpected total failure worth a warn. Either way keep
750
- // the prior profile map.
751
- if (extractErrorStatus(error) === 401) {
752
- logger.debug('[AccountDialogController] getUsersByIds unauthorized (signed out)', { component: 'AccountDialogController' }, error);
753
- } else {
754
- logger.warn('[AccountDialogController] getUsersByIds failed', { component: 'AccountDialogController' }, error);
755
- }
756
- return;
757
- }
758
- if (seq !== this.refreshSeq) return; // superseded
759
- const next = new Map(this.profilesById);
760
- for (const profile of profiles) {
761
- next.set(profile.id, profile);
769
+ this.error = errorMessage(error);
770
+ return false;
771
+ } finally {
772
+ this.removingContextId = null;
773
+ this.emit();
762
774
  }
763
- this.profilesById = next;
764
775
  }
765
776
 
766
- // =========================================================================
767
- // Switching (uniform switch model — reuses the existing SDK primitives)
768
- // =========================================================================
769
-
770
777
  /**
771
- * Switch the active account to `accountId`.
772
- *
773
- * Uniform switch model, mirroring the SDK's existing path — NOT a new switch
774
- * mechanism:
775
- * - already on this device → `SessionClient.switchAccount` (device-first
776
- * switch of `/session/device/switch`);
777
- * - a graph account not yet on the device (first entry) →
778
- * `oxyServices.switchToAccount` mints + plants a real session and the
779
- * server registers it into the device set, then it is committed
780
- * (`commitSession` when supplied, else `SessionClient.registerAndActivate`).
778
+ * Remove ONE PERSON and every context they reach — and nobody else's,
779
+ * including when another principal independently operates the same account.
781
780
  *
782
- * The resulting device-state change flows back through the `SessionClient`
783
- * subscription, which re-projects the active row. Concurrent switches are
784
- * ignored while one is in flight.
781
+ * A separate call from {@link signOutContext} because it is a separate
782
+ * question, not a loop over the first one: the server removes the principal
783
+ * and elects a replacement active context in one transition.
785
784
  */
786
- async switchTo(accountId: string): Promise<boolean> {
787
- if (this.switchingAccountId) return false;
788
- this.switchingAccountId = accountId;
785
+ async signOutPrincipal(principalId: string): Promise<boolean> {
786
+ if (this.removingContextId || this.removingPrincipalId) return false;
787
+ this.removingPrincipalId = principalId;
789
788
  this.error = null;
790
789
  this.emit();
791
790
  try {
792
- const state = this.sessionClient.getState();
793
- const onDevice = state?.accounts.some((account) => account.accountId === accountId) ?? false;
794
- if (onDevice) {
795
- await this.sessionClient.switchAccount(accountId);
796
- } else {
797
- const result = await this.oxyServices.switchToAccount(accountId);
798
- if (!result?.user || !result?.sessionId) {
799
- throw new Error('Account switch did not return a valid session');
800
- }
801
- await this.commitAuthorizedSession(
802
- {
803
- sessionId: result.sessionId,
804
- deviceId: result.deviceId,
805
- expiresAt: result.expiresAt,
806
- user: result.user,
807
- accessToken: result.accessToken,
808
- },
809
- result.user,
810
- // A switch is IN-PLACE: use the switch commit funnel (not sign-in).
811
- // Cross-tab/app propagation rides the server's `session_state` socket
812
- // broadcast, not a navigation.
813
- { fromSwitch: true },
814
- );
815
- }
816
- // Re-project + refetch immediately; the subscription also fires.
791
+ await this.sessionClient.signOutPrincipal(principalId);
817
792
  await this.refresh();
818
793
  return true;
819
794
  } catch (error) {
820
795
  this.error = errorMessage(error);
821
796
  return false;
822
797
  } finally {
823
- this.switchingAccountId = null;
798
+ this.removingPrincipalId = null;
824
799
  this.emit();
825
800
  }
826
801
  }
@@ -1346,21 +1321,13 @@ export class AccountDialogController {
1346
1321
  * Register a token-planted session into the device set. Prefers the
1347
1322
  * consumer's commit funnel (durable persist + hydration); falls back to
1348
1323
  * `SessionClient.registerAndActivate` (registration + activation only).
1349
- *
1350
- * A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel;
1351
- * a SIGN-IN uses `commitSession`. When the switch funnel is not wired it falls
1352
- * back to the sign-in funnel, then to `registerAndActivate`.
1353
1324
  */
1354
1325
  private async commitAuthorizedSession(
1355
1326
  session: SessionLoginResponse,
1356
1327
  user: MinimalUserData,
1357
- opts?: { fromSwitch?: boolean },
1358
1328
  ): Promise<void> {
1359
- const commit = opts?.fromSwitch
1360
- ? this.commitSwitchedSession ?? this.commitSession
1361
- : this.commitSession;
1362
- if (commit) {
1363
- await commit(session);
1329
+ if (this.commitSession) {
1330
+ await this.commitSession(session);
1364
1331
  } else {
1365
1332
  await this.sessionClient.registerAndActivate(user.id);
1366
1333
  }
@@ -1490,21 +1457,16 @@ export class AccountDialogController {
1490
1457
  }
1491
1458
 
1492
1459
  private computeSnapshot(): AccountDialogSnapshot {
1493
- const state = this.sessionClient.getState();
1460
+ const directory = this.sessionClient.getDirectory();
1494
1461
  return {
1495
1462
  view: this.view,
1496
- accounts: projectSwitchableAccounts({
1497
- state,
1498
- graph: this.graph,
1499
- profilesById: this.profilesById,
1500
- locale: this.locale,
1501
- resolveAvatarUrl: (avatar) =>
1502
- (avatar ? this.oxyServices.getFileDownloadUrl(avatar, 'thumb') : undefined),
1503
- }),
1504
- activeAccountId: state?.activeAccountId ?? null,
1463
+ directory,
1464
+ activeContext: resolveActiveContext(directory),
1505
1465
  loading: this.loading,
1506
1466
  error: this.error,
1507
- switchingAccountId: this.switchingAccountId,
1467
+ activatingContextId: this.activatingContextId,
1468
+ removingContextId: this.removingContextId,
1469
+ removingPrincipalId: this.removingPrincipalId,
1508
1470
  signIn: this.signIn,
1509
1471
  commonsAvailability: this.commonsAvailability,
1510
1472
  };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The two questions an account chooser asks of an account-graph node: is this
3
+ * kind switchable at all, and may THIS caller become it.
4
+ *
5
+ * Pure and I/O-free. They live here rather than beside the surfaces that ask
6
+ * them because there is more than one such surface — the Console's workspace
7
+ * tree, the Accounts app's managed-account rows — and a second enumeration of
8
+ * switch targets is a second place for the rule to go missing, which is
9
+ * precisely how the Console went on offering `channel` rows after the rule
10
+ * learned to drop them.
11
+ *
12
+ * The DEVICE switcher no longer asks anything here: it renders the server's
13
+ * device directory (ADR 0002, `deviceDirectory.ts`), whose `available` field is
14
+ * the server's own authorization verdict. These predicates answer a different
15
+ * question — one about the caller's account GRAPH, which is a list of accounts
16
+ * to manage, not a list of identities the device can become.
17
+ */
18
+
19
+ import { isActAsEligibleKind } from '@oxyhq/contracts';
20
+ import type {
21
+ AccountRelationship,
22
+ AccountKind,
23
+ AccountMember,
24
+ } from '../mixins/OxyServices.accounts';
25
+
26
+ /**
27
+ * Whether the caller can BECOME this account — the one question every account
28
+ * switcher asks, answered here so no surface has to re-derive it.
29
+ *
30
+ * Two independent grounds, either of which suffices:
31
+ *
32
+ * - **It is already the caller's own identity** (`relationship: 'self'`).
33
+ * `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
34
+ * is the HUMAN operator's personal account even while they are operating an
35
+ * org — never the operated account. Kind is irrelevant on this ground: the
36
+ * caller IS that account, so returning to it asks the server for nothing.
37
+ * - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
38
+ * the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
39
+ * on this ground is never a dead button.
40
+ *
41
+ * `isActAsEligibleKind` ALONE is not this question, and reaching for it
42
+ * directly is the mistake this function exists to prevent: it is false for
43
+ * `personal` as well as `channel`, so a switcher gated on it alone renders an
44
+ * empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
45
+ * this question either — it silently admits every kind invented after it was
46
+ * written, which is the same trap `isActAsEligibleKind` was introduced to close
47
+ * on the server.
48
+ *
49
+ * Takes a structural subset rather than a whole {@link AccountNode} so a caller
50
+ * holding an already-projected row can ask it too.
51
+ */
52
+ export function isSwitchTargetAccount(
53
+ node: { kind?: AccountKind | null; relationship?: AccountRelationship },
54
+ ): boolean {
55
+ return node.relationship === 'self' || isActAsEligibleKind(node.kind);
56
+ }
57
+
58
+ /**
59
+ * Whether the caller may switch INTO this account — the server-side
60
+ * `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
61
+ *
62
+ * `relationship: 'self'` always passes (returning to the caller's own personal
63
+ * account). Every other ground requires a switch-eligible kind AND
64
+ * `account:act_as` in the resolved membership permissions. When permissions are
65
+ * absent but the relationship is `owner`, the owner baseline is assumed — the
66
+ * API always resolves effective permissions for owned accounts, but test
67
+ * fixtures and stale rows may omit the membership blob.
68
+ */
69
+ export function canSwitchIntoAccount(
70
+ node: {
71
+ kind?: AccountKind | null;
72
+ relationship?: AccountRelationship;
73
+ callerMembership?: AccountMember | null;
74
+ },
75
+ ): boolean {
76
+ if (node.relationship === 'self') {
77
+ return true;
78
+ }
79
+ if (!isSwitchTargetAccount(node)) {
80
+ return false;
81
+ }
82
+ const permissions = node.callerMembership?.permissions;
83
+ if (permissions) {
84
+ return permissions.includes('account:act_as');
85
+ }
86
+ return node.relationship === 'owner';
87
+ }