@oxy.so/core 1.11.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -130,7 +130,7 @@ export { isSwitchTargetAccount, canSwitchIntoAccount, } from './session/accountS
130
130
  export { resolveAccountDelegationAccess } from './session/accountDelegationAccess';
131
131
  export type { AccountDelegationAccess, AccountDelegationNode, } from './session/accountDelegationAccess';
132
132
  export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
133
- export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, PopupWindowHandle, SignInFailureReason, SignInFlowPhase, SignInFlowState, SignInProgress, } from './session/accountDialogController';
133
+ export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, ContextChoiceOutcome, PopupWindowHandle, SignInFailureReason, SignInFlowPhase, SignInFlowState, SignInProgress, } from './session/accountDialogController';
134
134
  export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore';
135
135
  export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
136
136
  export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './session/sharedDeviceCredential';
@@ -97,6 +97,13 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
97
97
  */
98
98
  _serviceApiKey: string | null;
99
99
  _serviceApiSecret: string | null;
100
+ /**
101
+ * Whether this process can mint a service token by attesting what it is
102
+ * (ADR 0026), defined on the auth mixin. A host with no key pair that CAN
103
+ * attest is a first-party backend exactly like one with a key pair, and
104
+ * `getUsersByIds` must treat it as one.
105
+ */
106
+ _canUseWorkloadIdentity: () => Promise<boolean>;
100
107
  /**
101
108
  * Get profile by username.
102
109
  *
@@ -229,8 +236,10 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
229
236
  * caller, and returns the SAME public `{ data: PublicUserProfile[] }`
230
237
  * payload (canonical `name.displayName` + `_count`) in every case — no
231
238
  * viewer-specific fields. This method picks the path automatically:
232
- * - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
233
- * apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
239
+ * - **Service host (backend):** when `configureServiceAuth(apiKey,
240
+ * apiSecret)` has been called, OR the process can attest its workload
241
+ * identity (ADR 0026 — an ECS task with no key pair), the chunk is fetched
242
+ * via `makeServiceRequest`
234
243
  * (attaches `Authorization: Bearer <serviceToken>`). This is the
235
244
  * server-to-server feed/notification hydration path (e.g. Mention's
236
245
  * `PostHydrationService`) and is unchanged.
@@ -233,6 +233,16 @@ export interface AccountDialogSnapshot {
233
233
  * table of their own.
234
234
  */
235
235
  backView: AccountDialogView | null;
236
+ /**
237
+ * Whether THIS client is signed in: a bearer is planted.
238
+ *
239
+ * Not the same question as "does the directory list anyone". The directory is
240
+ * the DEVICE's, and a device can go on listing a shared identity (Commons, or a
241
+ * sibling app) after this app signed out. A host names its sign-in entry, and
242
+ * whether a listed account reads as the current one, from this — never from
243
+ * the directory's size.
244
+ */
245
+ hasSession: boolean;
236
246
  /**
237
247
  * The server-authoritative device directory — principals and the contexts
238
248
  * each may act as (ADR 0002) — or `null` before the first read.
@@ -259,6 +269,20 @@ export interface AccountDialogSnapshot {
259
269
  /** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
260
270
  commonsAvailability: CommonsAvailability;
261
271
  }
272
+ /**
273
+ * What {@link AccountDialogController.chooseContext} did with a chosen row.
274
+ *
275
+ * - `'current'` — the row is already the active account; nothing to do.
276
+ * - `'switched'` — the switch to the row happened (signed in; or signed out,
277
+ * after the silent sign-in landed on a different pair and the chosen one was
278
+ * then activated).
279
+ * - `'signing-in'` — signed out: the row started "Continue with Oxy", and the
280
+ * sign-in flow (its own view, its own completion) owns what happens next —
281
+ * including closing the dialog, through `onSignedIn`.
282
+ * - `'failed'` — the switch was attempted and failed (the reason is `error`).
283
+ * - `'busy'` — another device mutation is in flight; the press was dropped.
284
+ */
285
+ export type ContextChoiceOutcome = 'current' | 'switched' | 'signing-in' | 'failed' | 'busy';
262
286
  /** Construction options for {@link AccountDialogController}. */
263
287
  export interface AccountDialogControllerOptions {
264
288
  /** The API client. Source of graph accounts, profiles, and the sign-in methods. */
@@ -525,6 +549,27 @@ export declare class AccountDialogController {
525
549
  * device mutation is in flight ({@link isDeviceMutationInFlight}).
526
550
  */
527
551
  activateContext(contextId: string): Promise<boolean>;
552
+ /**
553
+ * A device account row was chosen — the one entry point for "use this
554
+ * account", whether or not anybody is signed in here.
555
+ *
556
+ * Signed in, it is a switch: the active row is already the answer
557
+ * (`'current'`), any other row is {@link activateContext}.
558
+ *
559
+ * Signed out, it is NOT a switch. The row is on the list because the DEVICE
560
+ * still holds that identity (Commons' shared identity, or a sibling app's
561
+ * session), not because this app does, so there is no bearer to activate a
562
+ * context with — and the active-row short-circuit used to read that stale
563
+ * "active" as "already signed in" and close the sheet on a signed-out app
564
+ * (OxyHQ/oxy#1375 item 20). Choosing the row is "Continue as @handle": the
565
+ * same {@link signInWithOxy} path as the "Continue with Oxy" button, which
566
+ * mints silently from the shared identity and otherwise falls back to the
567
+ * request. When that silent mint lands on a different pair from the one
568
+ * chosen (a device holding more than one person, or an organization row), the
569
+ * chosen row is then activated under the new bearer, so the press ends where
570
+ * it pointed.
571
+ */
572
+ chooseContext(contextId: string): Promise<ContextChoiceOutcome>;
528
573
  /**
529
574
  * Remove ONE `principal → account` pair, and only that pair.
530
575
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.11.0",
3
+ "version": "1.12.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
package/src/index.ts CHANGED
@@ -819,6 +819,7 @@ export type {
819
819
  AccountDialogSnapshot,
820
820
  AccountDialogView,
821
821
  CommonsAvailability,
822
+ ContextChoiceOutcome,
822
823
  PopupWindowHandle,
823
824
  SignInFailureReason,
824
825
  SignInFlowPhase,
@@ -155,6 +155,14 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
155
155
  declare _serviceApiKey: string | null;
156
156
  declare _serviceApiSecret: string | null;
157
157
 
158
+ /**
159
+ * Whether this process can mint a service token by attesting what it is
160
+ * (ADR 0026), defined on the auth mixin. A host with no key pair that CAN
161
+ * attest is a first-party backend exactly like one with a key pair, and
162
+ * `getUsersByIds` must treat it as one.
163
+ */
164
+ declare _canUseWorkloadIdentity: () => Promise<boolean>;
165
+
158
166
  /**
159
167
  * Get profile by username.
160
168
  *
@@ -464,8 +472,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
464
472
  * caller, and returns the SAME public `{ data: PublicUserProfile[] }`
465
473
  * payload (canonical `name.displayName` + `_count`) in every case — no
466
474
  * viewer-specific fields. This method picks the path automatically:
467
- * - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
468
- * apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
475
+ * - **Service host (backend):** when `configureServiceAuth(apiKey,
476
+ * apiSecret)` has been called, OR the process can attest its workload
477
+ * identity (ADR 0026 — an ECS task with no key pair), the chunk is fetched
478
+ * via `makeServiceRequest`
469
479
  * (attaches `Authorization: Bearer <serviceToken>`). This is the
470
480
  * server-to-server feed/notification hydration path (e.g. Mention's
471
481
  * `PostHydrationService`) and is unchanged.
@@ -500,10 +510,17 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
500
510
  chunks.push(uniqueIds.slice(i, i + USERS_BY_IDS_CHUNK_SIZE));
501
511
  }
502
512
 
503
- // A backend that called configureServiceAuth() uses the bearer-service
504
- // path; any other caller (browser / RN with a user session) uses the
505
- // user-bearer path.
506
- const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
513
+ // A backend uses the bearer-service path; any other caller (browser / RN
514
+ // with a user session) uses the user-bearer path. "A backend" means one
515
+ // that can get a service token at all — a key pair, OR a task role it can
516
+ // attest. Asking only about the key pair sent every attested backend down
517
+ // the user path with no bearer: an anonymous request, charged to the
518
+ // shared NAT address's per-IP budget and slowed by its 500ms penalty.
519
+ // Measured from Mention's task (2026-09-25): 520ms per anonymous chunk
520
+ // against 20ms with the service token, enough to put its 1.5s hydration
521
+ // deadline out of reach on every cache miss.
522
+ const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret)
523
+ || await this._canUseWorkloadIdentity();
507
524
 
508
525
  // Run chunks concurrently; a single chunk failure must not sink the rest.
509
526
  const settled = await Promise.all(
@@ -110,6 +110,33 @@ describe('OxyServices.getUsersByIds — dual-mode auth', () => {
110
110
  });
111
111
  });
112
112
 
113
+ describe('with an attestable workload identity and no key pair (ADR 0026 backend)', () => {
114
+ it('uses makeServiceRequest — an attested backend is still a backend', async () => {
115
+ jest
116
+ .spyOn(oxy as unknown as { _canUseWorkloadIdentity: () => Promise<boolean> }, '_canUseWorkloadIdentity')
117
+ .mockResolvedValue(true);
118
+ makeServiceRequestSpy.mockResolvedValueOnce([makeRawUser('a')]);
119
+
120
+ const result = await oxy.getUsersByIds(['a']);
121
+
122
+ expect(makeRequestSpy).not.toHaveBeenCalled();
123
+ expect(makeServiceRequestSpy).toHaveBeenCalledWith('POST', '/users/by-ids', { ids: ['a'] });
124
+ expect(result).toHaveLength(1);
125
+ });
126
+
127
+ it('stays on the user path when the host cannot attest', async () => {
128
+ jest
129
+ .spyOn(oxy as unknown as { _canUseWorkloadIdentity: () => Promise<boolean> }, '_canUseWorkloadIdentity')
130
+ .mockResolvedValue(false);
131
+ makeRequestSpy.mockResolvedValueOnce([makeRawUser('a')]);
132
+
133
+ await oxy.getUsersByIds(['a']);
134
+
135
+ expect(makeServiceRequestSpy).not.toHaveBeenCalled();
136
+ expect(makeRequestSpy).toHaveBeenCalledWith('POST', '/users/by-ids', { ids: ['a'] }, { cache: false });
137
+ });
138
+ });
139
+
113
140
  describe('chunking + resilience (applies to both modes)', () => {
114
141
  it('chunks at 100 ids per request on the user path and flattens results', async () => {
115
142
  const ids = Array.from({ length: 250 }, (_, i) => `id-${i}`);
@@ -2469,3 +2469,176 @@ it('createAccountDialogController returns an AccountDialogController instance',
2469
2469
  // Ensure the exported type surface is reachable at compile time for binders.
2470
2470
  const _typecheck: MinimalUserData | null = null;
2471
2471
  void _typecheck;
2472
+
2473
+ describe('AccountDialogController — choosing a device account row (OxyHQ/oxy#1375 items 20 and 21)', () => {
2474
+ /**
2475
+ * The device's directory as Commons leaves it: `qa` on the device and active,
2476
+ * plus (when `withOrg`) an organization `qa` also operates.
2477
+ */
2478
+ const deviceDirectory = (activeContextId: string | null, withOrg = false) => ({
2479
+ deviceId: 'device-1',
2480
+ revision: 4,
2481
+ activeContextId,
2482
+ updatedAt: 1_720_000_000_000,
2483
+ principals: [
2484
+ {
2485
+ id: 'p-qa',
2486
+ userId: 'qa',
2487
+ authuser: 0,
2488
+ user: { id: 'qa', username: 'qatest0925' },
2489
+ contexts: [
2490
+ {
2491
+ id: 'ctx-qa',
2492
+ accountId: 'qa',
2493
+ kind: 'personal' as const,
2494
+ relationship: 'self' as const,
2495
+ account: { id: 'qa', username: 'qatest0925' },
2496
+ onDevice: true,
2497
+ available: true,
2498
+ active: activeContextId === 'ctx-qa',
2499
+ lastUsedAt: null,
2500
+ },
2501
+ ...(withOrg
2502
+ ? [
2503
+ {
2504
+ id: 'ctx-qa-org',
2505
+ accountId: 'org',
2506
+ kind: 'organization' as const,
2507
+ relationship: 'owner' as const,
2508
+ account: { id: 'org', username: 'oxy' },
2509
+ onDevice: true,
2510
+ available: true,
2511
+ active: activeContextId === 'ctx-qa-org',
2512
+ lastUsedAt: null,
2513
+ },
2514
+ ]
2515
+ : []),
2516
+ ],
2517
+ },
2518
+ ],
2519
+ });
2520
+
2521
+ const sharedSession: SessionLoginResponse = {
2522
+ sessionId: 'sess-shared',
2523
+ deviceId: 'device-1',
2524
+ expiresAt: '2030-01-01T00:00:00Z',
2525
+ accessToken: 'access-shared',
2526
+ user: { id: 'qa', username: 'qatest0925', name: { displayName: 'QA' } },
2527
+ };
2528
+
2529
+ /**
2530
+ * A device that still lists `qa` (Commons holds the shared identity) while
2531
+ * THIS app holds no bearer — Mention after "Sign out".
2532
+ */
2533
+ async function signedOutDevice(options: { withOrg?: boolean } = {}) {
2534
+ const oxy = makeOxy();
2535
+ const urls: string[] = [];
2536
+ const sc = new TestSessionClient({
2537
+ makeRequest: jest.fn(async (_method: string, url: string) => {
2538
+ urls.push(url);
2539
+ if (url === '/session/device/directory') return deviceDirectory('ctx-qa', options.withOrg);
2540
+ if (url === '/session/device/activate') {
2541
+ return { directory: { ...deviceDirectory('ctx-qa-org', true), revision: 5 }, activeToken: null };
2542
+ }
2543
+ return undefined;
2544
+ }),
2545
+ getBaseURL: () => 'http://test.invalid',
2546
+ getAccessToken: () => oxy.getAccessToken(),
2547
+ getDeviceCredential: () => null,
2548
+ onTokensChanged: () => () => undefined,
2549
+ setTokens: jest.fn(),
2550
+ getCurrentAccountId: () => null,
2551
+ });
2552
+ // The directory this client read while it was still signed in.
2553
+ await sc.refreshDirectory();
2554
+ oxy.emitTokenChange(null);
2555
+ const commitSession = jest.fn().mockResolvedValue(undefined);
2556
+ const onSignedIn = jest.fn();
2557
+ const controller = createAccountDialogController({
2558
+ oxyServices: oxy as unknown as OxyServices,
2559
+ sessionClient: sc,
2560
+ clientId: 'oxy_dk_test',
2561
+ commitSession,
2562
+ onSignedIn,
2563
+ });
2564
+ controller.start();
2565
+ await flush();
2566
+ urls.length = 0;
2567
+ return { controller, oxy, sc, urls, commitSession, onSignedIn };
2568
+ }
2569
+
2570
+ it('publishes that this client is signed out while the device still lists an active account', async () => {
2571
+ const { controller } = await signedOutDevice();
2572
+ const snap = controller.getSnapshot();
2573
+ expect(snap.hasSession).toBe(false);
2574
+ // The precondition of the bug: the row the sheet renders reads as "active".
2575
+ expect(snap.activeContext?.contextId).toBe('ctx-qa');
2576
+ controller.destroy();
2577
+ });
2578
+
2579
+ it('signed out, choosing the listed account signs in through the shared identity, like "Continue with Oxy"', async () => {
2580
+ const { controller, oxy, urls, commitSession, onSignedIn } = await signedOutDevice();
2581
+ oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
2582
+
2583
+ // It used to short-circuit on "already the active row" and report success,
2584
+ // closing the sheet on an app that was still signed out.
2585
+ expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
2586
+
2587
+ expect(oxy.signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
2588
+ expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
2589
+ expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'qa' }));
2590
+ expect(controller.getSnapshot().hasSession).toBe(true);
2591
+ expect(controller.getSnapshot().signIn.phase).toBe('completed');
2592
+ // The silent mint already landed on the chosen pair: no switch on top.
2593
+ expect(urls).not.toContain('/session/device/activate');
2594
+ controller.destroy();
2595
+ });
2596
+
2597
+ it('signed out with no shared identity, choosing the account starts the same request "Continue with Oxy" does', async () => {
2598
+ const { controller, oxy, onSignedIn } = await signedOutDevice();
2599
+ oxy.signInWithSharedIdentity.mockResolvedValue(null);
2600
+ oxy.startCommonsSignIn.mockResolvedValue({
2601
+ sessionToken: 'secret-tok',
2602
+ authorizeCode: 'AUTH-CODE',
2603
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
2604
+ expiresAt: Date.now() + 300_000,
2605
+ status: 'pending',
2606
+ });
2607
+
2608
+ expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
2609
+
2610
+ expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
2611
+ expect(controller.getSnapshot().view).toBe('qr');
2612
+ expect(controller.getSnapshot().signIn.phase).toBe('waiting');
2613
+ expect(onSignedIn).not.toHaveBeenCalled();
2614
+ controller.destroy();
2615
+ });
2616
+
2617
+ it('signed out, choosing an organization row signs in and then activates that row', async () => {
2618
+ const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
2619
+ oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
2620
+
2621
+ expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
2622
+
2623
+ expect(oxy.signInWithSharedIdentity).toHaveBeenCalledTimes(1);
2624
+ expect(urls).toContain('/session/device/activate');
2625
+ expect(controller.getSnapshot().activeContext?.contextId).toBe('ctx-qa-org');
2626
+ controller.destroy();
2627
+ });
2628
+
2629
+ it('signed in, the active row is "current" and any other row is a switch', async () => {
2630
+ const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
2631
+ oxy.emitTokenChange('access-token');
2632
+ await flush();
2633
+ urls.length = 0;
2634
+ expect(controller.getSnapshot().hasSession).toBe(true);
2635
+
2636
+ expect(await controller.chooseContext('ctx-qa')).toBe('current');
2637
+ expect(urls).not.toContain('/session/device/activate');
2638
+
2639
+ expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
2640
+ expect(urls).toContain('/session/device/activate');
2641
+ expect(oxy.signInWithSharedIdentity).not.toHaveBeenCalled();
2642
+ controller.destroy();
2643
+ });
2644
+ });
@@ -68,6 +68,7 @@ const SNAPSHOT_KEYS = [
68
68
  'commonsAvailability',
69
69
  'directory',
70
70
  'error',
71
+ 'hasSession',
71
72
  'loading',
72
73
  'removingContextId',
73
74
  'removingPrincipalId',
@@ -310,6 +310,16 @@ export interface AccountDialogSnapshot {
310
310
  * table of their own.
311
311
  */
312
312
  backView: AccountDialogView | null;
313
+ /**
314
+ * Whether THIS client is signed in: a bearer is planted.
315
+ *
316
+ * Not the same question as "does the directory list anyone". The directory is
317
+ * the DEVICE's, and a device can go on listing a shared identity (Commons, or a
318
+ * sibling app) after this app signed out. A host names its sign-in entry, and
319
+ * whether a listed account reads as the current one, from this — never from
320
+ * the directory's size.
321
+ */
322
+ hasSession: boolean;
313
323
  /**
314
324
  * The server-authoritative device directory — principals and the contexts
315
325
  * each may act as (ADR 0002) — or `null` before the first read.
@@ -337,6 +347,21 @@ export interface AccountDialogSnapshot {
337
347
  commonsAvailability: CommonsAvailability;
338
348
  }
339
349
 
350
+ /**
351
+ * What {@link AccountDialogController.chooseContext} did with a chosen row.
352
+ *
353
+ * - `'current'` — the row is already the active account; nothing to do.
354
+ * - `'switched'` — the switch to the row happened (signed in; or signed out,
355
+ * after the silent sign-in landed on a different pair and the chosen one was
356
+ * then activated).
357
+ * - `'signing-in'` — signed out: the row started "Continue with Oxy", and the
358
+ * sign-in flow (its own view, its own completion) owns what happens next —
359
+ * including closing the dialog, through `onSignedIn`.
360
+ * - `'failed'` — the switch was attempted and failed (the reason is `error`).
361
+ * - `'busy'` — another device mutation is in flight; the press was dropped.
362
+ */
363
+ export type ContextChoiceOutcome = 'current' | 'switched' | 'signing-in' | 'failed' | 'busy';
364
+
340
365
  /** Construction options for {@link AccountDialogController}. */
341
366
  export interface AccountDialogControllerOptions {
342
367
  /** The API client. Source of graph accounts, profiles, and the sign-in methods. */
@@ -880,6 +905,45 @@ export class AccountDialogController {
880
905
  );
881
906
  }
882
907
 
908
+ /**
909
+ * A device account row was chosen — the one entry point for "use this
910
+ * account", whether or not anybody is signed in here.
911
+ *
912
+ * Signed in, it is a switch: the active row is already the answer
913
+ * (`'current'`), any other row is {@link activateContext}.
914
+ *
915
+ * Signed out, it is NOT a switch. The row is on the list because the DEVICE
916
+ * still holds that identity (Commons' shared identity, or a sibling app's
917
+ * session), not because this app does, so there is no bearer to activate a
918
+ * context with — and the active-row short-circuit used to read that stale
919
+ * "active" as "already signed in" and close the sheet on a signed-out app
920
+ * (OxyHQ/oxy#1375 item 20). Choosing the row is "Continue as @handle": the
921
+ * same {@link signInWithOxy} path as the "Continue with Oxy" button, which
922
+ * mints silently from the shared identity and otherwise falls back to the
923
+ * request. When that silent mint lands on a different pair from the one
924
+ * chosen (a device holding more than one person, or an organization row), the
925
+ * chosen row is then activated under the new bearer, so the press ends where
926
+ * it pointed.
927
+ */
928
+ async chooseContext(contextId: string): Promise<ContextChoiceOutcome> {
929
+ if (this.isDeviceMutationInFlight()) return 'busy';
930
+ if (this.hasSession()) {
931
+ if (contextId === this.snapshot.activeContext?.contextId) return 'current';
932
+ return (await this.activateContext(contextId)) ? 'switched' : 'failed';
933
+ }
934
+ await this.signInWithOxy();
935
+ // Only a sign-in that finished here (the silent shared-identity mint) can be
936
+ // steered to the chosen row; a request still waiting on approval belongs to
937
+ // whoever approves it.
938
+ if (this.signIn.phase !== 'completed' || !this.hasSession()) return 'signing-in';
939
+ const directory = this.sessionClient.getDirectory();
940
+ const offered = directory?.principals.some((principal) =>
941
+ principal.contexts.some((context) => context.id === contextId),
942
+ );
943
+ if (!offered || resolveActiveContext(directory)?.contextId === contextId) return 'signing-in';
944
+ return (await this.activateContext(contextId)) ? 'switched' : 'signing-in';
945
+ }
946
+
883
947
  /**
884
948
  * Remove ONE `principal → account` pair, and only that pair.
885
949
  *
@@ -1780,6 +1844,7 @@ export class AccountDialogController {
1780
1844
  return {
1781
1845
  view,
1782
1846
  backView: backViewOf(view, hasSession),
1847
+ hasSession,
1783
1848
  directory,
1784
1849
  activeContext: resolveActiveContext(directory),
1785
1850
  loading: this.loading,