@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.
- package/NOTICE +10 -9
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/mixins/OxyServices.chains.js +73 -0
- package/dist/cjs/mixins/OxyServices.store.js +266 -0
- package/dist/cjs/mixins/OxyServices.utility.js +159 -104
- package/dist/cjs/mixins/index.js +7 -0
- package/dist/cjs/server/rateLimit.js +15 -6
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/mixins/OxyServices.chains.js +70 -0
- package/dist/esm/mixins/OxyServices.store.js +263 -0
- package/dist/esm/mixins/OxyServices.utility.js +159 -104
- package/dist/esm/mixins/index.js +7 -0
- package/dist/esm/server/rateLimit.js +15 -6
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +15 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
- package/dist/types/mixins/OxyServices.store.d.ts +334 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
- package/dist/types/mixins/index.d.ts +3 -1
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +105 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/OxyServices.chains.ts +134 -0
- package/src/mixins/OxyServices.store.ts +585 -0
- package/src/mixins/OxyServices.utility.ts +161 -108
- package/src/mixins/__tests__/chains.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/mixins/__tests__/store.test.ts +304 -0
- package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
- package/src/mixins/index.ts +9 -0
- package/src/models/session.ts +11 -0
- package/src/server/__tests__/rateLimit.test.ts +47 -0
- package/src/server/rateLimit.ts +18 -8
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
|
@@ -9,15 +9,16 @@
|
|
|
9
9
|
* replaces.
|
|
10
10
|
*
|
|
11
11
|
* The controller owns:
|
|
12
|
-
* - the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
* - `
|
|
18
|
-
* account
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
275
|
+
/** A human-readable directory error, or `null`. */
|
|
272
276
|
error: string | null;
|
|
273
|
-
/** The `
|
|
274
|
-
|
|
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
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
|
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
|
|
543
|
-
//
|
|
544
|
-
//
|
|
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
|
|
630
|
-
//
|
|
631
|
-
|
|
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
|
-
//
|
|
657
|
+
// The directory
|
|
667
658
|
// =========================================================================
|
|
668
659
|
|
|
669
660
|
/**
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
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
|
|
678
|
-
//
|
|
679
|
-
//
|
|
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
|
-
|
|
690
|
-
|
|
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
|
-
|
|
691
|
+
await this.sessionClient.refreshDirectory();
|
|
697
692
|
} catch (error) {
|
|
698
|
-
// A 401
|
|
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)
|
|
701
|
-
//
|
|
702
|
-
//
|
|
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]
|
|
699
|
+
logger.debug('[AccountDialogController] directory unauthorized (signed out)', { component: 'AccountDialogController' }, error);
|
|
707
700
|
} else {
|
|
708
701
|
this.error = errorMessage(error);
|
|
709
|
-
logger.warn('[AccountDialogController]
|
|
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
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
765
|
+
await this.sessionClient.signOutContext(contextId);
|
|
766
|
+
await this.refresh();
|
|
767
|
+
return true;
|
|
746
768
|
} catch (error) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
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
|
|
787
|
-
if (this.
|
|
788
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1360
|
-
|
|
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
|
|
1460
|
+
const directory = this.sessionClient.getDirectory();
|
|
1494
1461
|
return {
|
|
1495
1462
|
view: this.view,
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
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
|
+
}
|