@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`);
@@ -57,7 +58,7 @@
57
58
  import { logger } from '../logger/index.js';
58
59
  import { extractErrorStatus } from '../utils/errorUtils.js';
59
60
  import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
60
- import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
61
+ import { resolveActiveContext } from './deviceDirectory.js';
61
62
  import { pushTargetsFromDelivery, selectCommonsDelivery, } from '../utils/commonsDelivery.js';
62
63
  /**
63
64
  * Derive the surface-facing progress from the flow's real facts. Pure, total,
@@ -137,11 +138,11 @@ export class AccountDialogController {
137
138
  this.listeners = new Set();
138
139
  // --- Internal (unprojected) state ---
139
140
  this.view = 'accounts';
140
- this.graph = [];
141
- this.profilesById = new Map();
142
141
  this.loading = false;
143
142
  this.error = null;
144
- this.switchingAccountId = null;
143
+ this.activatingContextId = null;
144
+ this.removingContextId = null;
145
+ this.removingPrincipalId = null;
145
146
  this.signIn = IDLE_SIGN_IN;
146
147
  this.commonsAvailability = 'unknown';
147
148
  // --- Sign-in device-flow bookkeeping ---
@@ -174,9 +175,7 @@ export class AccountDialogController {
174
175
  this.oxyServices = options.oxyServices;
175
176
  this.sessionClient = options.sessionClient;
176
177
  this.clientId = options.clientId ?? null;
177
- this.locale = options.locale;
178
178
  this.commitSession = options.commitSession;
179
- this.commitSwitchedSession = options.commitSwitchedSession;
180
179
  this.onSignedIn = options.onSignedIn;
181
180
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
182
181
  this.openUrl = options.openUrl;
@@ -215,11 +214,12 @@ export class AccountDialogController {
215
214
  this.started = true;
216
215
  this.authed = this.isAuthenticated();
217
216
  this.unsubscribeSession = this.sessionClient.subscribe(() => {
218
- // A device-state change (switch / sign-out / sibling sign-in) can add or
219
- // remove accounts re-project immediately, refetch profiles when new
220
- // account ids appeared, and reconcile the auth-readiness edge.
217
+ // A device change (switch / sign-out / sibling sign-in) re-reads the
218
+ // directory inside `SessionClient` before it notifies, so the snapshot
219
+ // this rebuilds is already the new one — publish it and reconcile the
220
+ // auth-readiness edge. No profile fetch: the directory carries the display
221
+ // metadata a row needs.
221
222
  this.emit();
222
- void this.ensureProfiles();
223
223
  this.reconcileAuth();
224
224
  });
225
225
  // The access token is planted AFTER `SessionClient.applyState` fires its
@@ -299,9 +299,9 @@ export class AccountDialogController {
299
299
  void this.refresh();
300
300
  return;
301
301
  }
302
- // Signed out: the graph is no longer fetchable/switchable drop it and
303
- // re-project from the device session set alone.
304
- this.graph = [];
302
+ // Signed out. The directory is bearer-read and `SessionClient` drops the one
303
+ // it holds when the device empties, so there is nothing to clear here — only
304
+ // the in-flight bookkeeping, which no longer describes anything.
305
305
  this.error = null;
306
306
  this.loading = false;
307
307
  this.emit();
@@ -332,157 +332,142 @@ export class AccountDialogController {
332
332
  this.setView('signup');
333
333
  }
334
334
  // =========================================================================
335
- // Account list
335
+ // The directory
336
336
  // =========================================================================
337
337
  /**
338
- * Reload the account graph and per-account profiles, then re-project. Safe to
339
- * call repeatedly; concurrent calls are reconciled by a sequence guard so a
340
- * slow earlier fetch never overwrites a newer result.
338
+ * Re-read `GET /session/device/directory`. Safe to call repeatedly;
339
+ * concurrent calls are reconciled by a sequence guard so a slow earlier read
340
+ * never overwrites a newer result.
341
+ *
342
+ * This is ONE request. It used to be three — the directory, plus
343
+ * `listAccounts()` and `getUsersByIds()` to rebuild the same tree client-side
344
+ * — and the reconstruction was not merely redundant: it enumerated the
345
+ * CALLER's account graph, which on a device holding two people is one
346
+ * person's answer presented as the device's.
341
347
  */
342
348
  async refresh() {
343
349
  const seq = ++this.refreshSeq;
344
- // Never hit the private `listAccounts()` while signed out: at cold boot the
345
- // bearer is not planted yet, so the call 401s → `HttpService` clears the
346
- // token and signs the user out. Re-project from the device session set alone
347
- // (`projectSwitchableAccounts` works from `SessionClient` state) and stop.
350
+ // Never read the directory while signed out: at cold boot the bearer is not
351
+ // planted yet, so the call 401s → `HttpService` clears the token and signs
352
+ // the user out.
348
353
  if (!this.isAuthenticated()) {
349
- this.graph = [];
350
354
  this.loading = false;
351
355
  this.error = null;
352
356
  this.emit();
353
357
  return;
354
358
  }
355
- const hadAccounts = this.snapshot.accounts.length > 0;
356
- this.loading = !hadAccounts;
359
+ // Nothing to show yet is the only state worth a spinner; a re-read behind an
360
+ // already-rendered directory refreshes in place.
361
+ this.loading = this.sessionClient.getDirectory() === null;
357
362
  this.error = null;
358
363
  this.emit();
359
- let graph = this.graph;
360
364
  try {
361
- graph = await this.oxyServices.listAccounts();
365
+ await this.sessionClient.refreshDirectory();
362
366
  }
363
367
  catch (error) {
364
- // A 401 here is the EXPECTED signed-out edge, not a failure: the bearer was
368
+ // A 401 is the EXPECTED signed-out edge, not a failure: the bearer was
365
369
  // stale/revoked, so `HttpService` already cleared it and emitted
366
- // `onTokensChanged(null)`, which drops the graph via `reconcileAuth`. Log at
367
- // debug and leave the dialog error-free a signed-out device with zero
368
- // accounts is a normal state, not a warning. Any other error (network, 5xx,
369
- // malformed) IS unexpected: surface it and warn while keeping the prior graph
370
- // so device rows still render.
370
+ // `onTokensChanged(null)`. Any OTHER error (network, 5xx, malformed) IS
371
+ // unexpected surface it, and keep whatever directory is already held so
372
+ // an outage degrades rather than blanks the switcher.
371
373
  if (extractErrorStatus(error) === 401) {
372
- logger.debug('[AccountDialogController] listAccounts unauthorized (signed out)', { component: 'AccountDialogController' }, error);
374
+ logger.debug('[AccountDialogController] directory unauthorized (signed out)', { component: 'AccountDialogController' }, error);
373
375
  }
374
376
  else {
375
377
  this.error = errorMessage(error);
376
- logger.warn('[AccountDialogController] listAccounts failed', { component: 'AccountDialogController' }, error);
378
+ logger.warn('[AccountDialogController] directory refresh failed', { component: 'AccountDialogController' }, error);
377
379
  }
378
380
  }
379
381
  if (seq !== this.refreshSeq)
380
382
  return; // superseded by a newer refresh
381
- this.graph = graph;
382
- await this.loadProfiles(seq);
383
- if (seq !== this.refreshSeq)
384
- return;
385
383
  this.loading = false;
386
384
  this.emit();
387
385
  }
386
+ // =========================================================================
387
+ // Activation and the two removals (ADR 0002)
388
+ // =========================================================================
388
389
  /**
389
- * Fetch profiles for any account id (device set graph) not yet resolved.
390
- * Cheap no-op when everything is already hydrated used from the session
391
- * subscription so a newly-added device account gets a name/avatar.
390
+ * Activate one `principal acting as account` context the ADR 0002 switch,
391
+ * and the one that can express what an account id cannot: WHICH person's
392
+ * route to a shared organization to become.
393
+ *
394
+ * There is no on-device/graph fork. The directory has a row for a context the
395
+ * principal may act as but has never entered, and `POST /session/device/
396
+ * activate` reuses or mints the delegated session server-side, so one call
397
+ * covers both cases.
398
+ *
399
+ * A context id is not stable across a removal, so a stale one is an ordinary
400
+ * outcome rather than a bug: the server answers 404 or 403, heals the row, and
401
+ * the refresh below re-reads a directory that no longer offers it.
392
402
  */
393
- async ensureProfiles() {
394
- // `getUsersByIds` is private — skip the whole path while signed out.
395
- if (!this.isAuthenticated())
396
- return;
397
- const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
398
- if (ids.every((id) => this.profilesById.has(id)))
399
- return;
400
- await this.loadProfiles(this.refreshSeq);
403
+ async activateContext(contextId) {
404
+ if (this.activatingContextId)
405
+ return false;
406
+ this.activatingContextId = contextId;
407
+ this.error = null;
401
408
  this.emit();
402
- }
403
- async loadProfiles(seq) {
404
- // `getUsersByIds` (`POST /users/by-ids`) is a private call — never issue it
405
- // while signed out (the 401 → sign-out cascade). Callers already gate; this
406
- // guards the network chokepoint too (e.g. the token was cleared mid-refresh).
407
- if (!this.isAuthenticated())
408
- return;
409
- const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
410
- if (ids.length === 0)
411
- return;
412
- let profiles = [];
413
409
  try {
414
- profiles = await this.oxyServices.getUsersByIds(ids);
410
+ await this.sessionClient.activateContext(contextId);
411
+ await this.refresh();
412
+ return true;
415
413
  }
416
414
  catch (error) {
417
- // A 401 is the EXPECTED signed-out edge (stale/cleared bearer) — log at debug.
418
- // `getUsersByIds` already swallows per-chunk failures and returns `[]`, so any
419
- // OTHER error here is an unexpected total failure worth a warn. Either way keep
420
- // the prior profile map.
421
- if (extractErrorStatus(error) === 401) {
422
- logger.debug('[AccountDialogController] getUsersByIds unauthorized (signed out)', { component: 'AccountDialogController' }, error);
423
- }
424
- else {
425
- logger.warn('[AccountDialogController] getUsersByIds failed', { component: 'AccountDialogController' }, error);
426
- }
427
- return;
415
+ this.error = errorMessage(error);
416
+ return false;
428
417
  }
429
- if (seq !== this.refreshSeq)
430
- return; // superseded
431
- const next = new Map(this.profilesById);
432
- for (const profile of profiles) {
433
- next.set(profile.id, profile);
418
+ finally {
419
+ this.activatingContextId = null;
420
+ this.emit();
434
421
  }
435
- this.profilesById = next;
436
422
  }
437
- // =========================================================================
438
- // Switching (uniform switch model — reuses the existing SDK primitives)
439
- // =========================================================================
440
423
  /**
441
- * Switch the active account to `accountId`.
424
+ * Remove ONE `principal account` pair, and only that pair.
442
425
  *
443
- * Uniform switch model, mirroring the SDK's existing path NOT a new switch
444
- * mechanism:
445
- * - already on this device `SessionClient.switchAccount` (device-first
446
- * switch of `/session/device/switch`);
447
- * - a graph account not yet on the device (first entry) →
448
- * `oxyServices.switchToAccount` mints + plants a real session and the
449
- * server registers it into the device set, then it is committed
450
- * (`commitSession` when supplied, else `SessionClient.registerAndActivate`).
426
+ * Not the account across the device: the same organization reached through a
427
+ * second person is a different session with a different audit actor, and it
428
+ * stays. Routing this through `signOut({accountId})` would revoke that second
429
+ * person's access as a side effect of one person tidying their own list.
451
430
  *
452
- * The resulting device-state change flows back through the `SessionClient`
453
- * subscription, which re-projects the active row. Concurrent switches are
454
- * ignored while one is in flight.
431
+ * The removed pair is not gone for good while the membership lives — the
432
+ * server offers it again on the next read, under a NEW id and at an unchanged
433
+ * revision so nothing may hold a context id across this call.
455
434
  */
456
- async switchTo(accountId) {
457
- if (this.switchingAccountId)
435
+ async signOutContext(contextId) {
436
+ if (this.removingContextId || this.removingPrincipalId)
458
437
  return false;
459
- this.switchingAccountId = accountId;
438
+ this.removingContextId = contextId;
460
439
  this.error = null;
461
440
  this.emit();
462
441
  try {
463
- const state = this.sessionClient.getState();
464
- const onDevice = state?.accounts.some((account) => account.accountId === accountId) ?? false;
465
- if (onDevice) {
466
- await this.sessionClient.switchAccount(accountId);
467
- }
468
- else {
469
- const result = await this.oxyServices.switchToAccount(accountId);
470
- if (!result?.user || !result?.sessionId) {
471
- throw new Error('Account switch did not return a valid session');
472
- }
473
- await this.commitAuthorizedSession({
474
- sessionId: result.sessionId,
475
- deviceId: result.deviceId,
476
- expiresAt: result.expiresAt,
477
- user: result.user,
478
- accessToken: result.accessToken,
479
- }, result.user,
480
- // A switch is IN-PLACE: use the switch commit funnel (not sign-in).
481
- // Cross-tab/app propagation rides the server's `session_state` socket
482
- // broadcast, not a navigation.
483
- { fromSwitch: true });
484
- }
485
- // Re-project + refetch immediately; the subscription also fires.
442
+ await this.sessionClient.signOutContext(contextId);
443
+ await this.refresh();
444
+ return true;
445
+ }
446
+ catch (error) {
447
+ this.error = errorMessage(error);
448
+ return false;
449
+ }
450
+ finally {
451
+ this.removingContextId = null;
452
+ this.emit();
453
+ }
454
+ }
455
+ /**
456
+ * Remove ONE PERSON and every context they reach — and nobody else's,
457
+ * including when another principal independently operates the same account.
458
+ *
459
+ * A separate call from {@link signOutContext} because it is a separate
460
+ * question, not a loop over the first one: the server removes the principal
461
+ * and elects a replacement active context in one transition.
462
+ */
463
+ async signOutPrincipal(principalId) {
464
+ if (this.removingContextId || this.removingPrincipalId)
465
+ return false;
466
+ this.removingPrincipalId = principalId;
467
+ this.error = null;
468
+ this.emit();
469
+ try {
470
+ await this.sessionClient.signOutPrincipal(principalId);
486
471
  await this.refresh();
487
472
  return true;
488
473
  }
@@ -491,7 +476,7 @@ export class AccountDialogController {
491
476
  return false;
492
477
  }
493
478
  finally {
494
- this.switchingAccountId = null;
479
+ this.removingPrincipalId = null;
495
480
  this.emit();
496
481
  }
497
482
  }
@@ -983,17 +968,10 @@ export class AccountDialogController {
983
968
  * Register a token-planted session into the device set. Prefers the
984
969
  * consumer's commit funnel (durable persist + hydration); falls back to
985
970
  * `SessionClient.registerAndActivate` (registration + activation only).
986
- *
987
- * A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel;
988
- * a SIGN-IN uses `commitSession`. When the switch funnel is not wired it falls
989
- * back to the sign-in funnel, then to `registerAndActivate`.
990
971
  */
991
- async commitAuthorizedSession(session, user, opts) {
992
- const commit = opts?.fromSwitch
993
- ? this.commitSwitchedSession ?? this.commitSession
994
- : this.commitSession;
995
- if (commit) {
996
- await commit(session);
972
+ async commitAuthorizedSession(session, user) {
973
+ if (this.commitSession) {
974
+ await this.commitSession(session);
997
975
  }
998
976
  else {
999
977
  await this.sessionClient.registerAndActivate(user.id);
@@ -1113,20 +1091,16 @@ export class AccountDialogController {
1113
1091
  });
1114
1092
  }
1115
1093
  computeSnapshot() {
1116
- const state = this.sessionClient.getState();
1094
+ const directory = this.sessionClient.getDirectory();
1117
1095
  return {
1118
1096
  view: this.view,
1119
- accounts: projectSwitchableAccounts({
1120
- state,
1121
- graph: this.graph,
1122
- profilesById: this.profilesById,
1123
- locale: this.locale,
1124
- resolveAvatarUrl: (avatar) => (avatar ? this.oxyServices.getFileDownloadUrl(avatar, 'thumb') : undefined),
1125
- }),
1126
- activeAccountId: state?.activeAccountId ?? null,
1097
+ directory,
1098
+ activeContext: resolveActiveContext(directory),
1127
1099
  loading: this.loading,
1128
1100
  error: this.error,
1129
- switchingAccountId: this.switchingAccountId,
1101
+ activatingContextId: this.activatingContextId,
1102
+ removingContextId: this.removingContextId,
1103
+ removingPrincipalId: this.removingPrincipalId,
1130
1104
  signIn: this.signIn,
1131
1105
  commonsAvailability: this.commonsAvailability,
1132
1106
  };
@@ -0,0 +1,71 @@
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
+ import { isActAsEligibleKind } from '@oxyhq/contracts';
19
+ /**
20
+ * Whether the caller can BECOME this account — the one question every account
21
+ * switcher asks, answered here so no surface has to re-derive it.
22
+ *
23
+ * Two independent grounds, either of which suffices:
24
+ *
25
+ * - **It is already the caller's own identity** (`relationship: 'self'`).
26
+ * `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
27
+ * is the HUMAN operator's personal account even while they are operating an
28
+ * org — never the operated account. Kind is irrelevant on this ground: the
29
+ * caller IS that account, so returning to it asks the server for nothing.
30
+ * - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
31
+ * the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
32
+ * on this ground is never a dead button.
33
+ *
34
+ * `isActAsEligibleKind` ALONE is not this question, and reaching for it
35
+ * directly is the mistake this function exists to prevent: it is false for
36
+ * `personal` as well as `channel`, so a switcher gated on it alone renders an
37
+ * empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
38
+ * this question either — it silently admits every kind invented after it was
39
+ * written, which is the same trap `isActAsEligibleKind` was introduced to close
40
+ * on the server.
41
+ *
42
+ * Takes a structural subset rather than a whole {@link AccountNode} so a caller
43
+ * holding an already-projected row can ask it too.
44
+ */
45
+ export function isSwitchTargetAccount(node) {
46
+ return node.relationship === 'self' || isActAsEligibleKind(node.kind);
47
+ }
48
+ /**
49
+ * Whether the caller may switch INTO this account — the server-side
50
+ * `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
51
+ *
52
+ * `relationship: 'self'` always passes (returning to the caller's own personal
53
+ * account). Every other ground requires a switch-eligible kind AND
54
+ * `account:act_as` in the resolved membership permissions. When permissions are
55
+ * absent but the relationship is `owner`, the owner baseline is assumed — the
56
+ * API always resolves effective permissions for owned accounts, but test
57
+ * fixtures and stale rows may omit the membership blob.
58
+ */
59
+ export function canSwitchIntoAccount(node) {
60
+ if (node.relationship === 'self') {
61
+ return true;
62
+ }
63
+ if (!isSwitchTargetAccount(node)) {
64
+ return false;
65
+ }
66
+ const permissions = node.callerMembership?.permissions;
67
+ if (permissions) {
68
+ return permissions.includes('account:act_as');
69
+ }
70
+ return node.relationship === 'owner';
71
+ }
@@ -0,0 +1,135 @@
1
+ import { getAccountDisplayName } from '../utils/accountUtils.js';
2
+ import { getNormalizedUserHandle } from '../utils/userHandle.js';
3
+ /** Resolve one wire context under the principal it hangs off. */
4
+ function toDeviceContext(principal, context) {
5
+ return {
6
+ contextId: context.id,
7
+ actor: {
8
+ principalId: principal.id,
9
+ userId: principal.userId,
10
+ authuser: principal.authuser,
11
+ profile: principal.user,
12
+ },
13
+ subject: {
14
+ accountId: context.accountId,
15
+ kind: context.kind,
16
+ relationship: context.relationship,
17
+ profile: context.account,
18
+ onDevice: context.onDevice,
19
+ available: context.available,
20
+ lastUsedAt: context.lastUsedAt,
21
+ },
22
+ isDelegated: context.accountId !== principal.userId,
23
+ };
24
+ }
25
+ /**
26
+ * Resolve one context by its id.
27
+ *
28
+ * The search is over `(principal, context)` PAIRS, not over accounts: the same
29
+ * `accountId` legitimately appears under two principals on a shared device, and
30
+ * matching on the account would hand back whichever person happened to be
31
+ * enumerated first.
32
+ */
33
+ export function resolveDeviceContext(directory, contextId) {
34
+ if (directory === null) {
35
+ return null;
36
+ }
37
+ for (const principal of directory.principals) {
38
+ for (const context of principal.contexts) {
39
+ if (context.id === contextId) {
40
+ return toDeviceContext(principal, context);
41
+ }
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ /**
47
+ * The directory as the switcher renders it: people, each with what they may
48
+ * become.
49
+ *
50
+ * A principal with no contexts is kept rather than dropped. It is a real state
51
+ * — a person whose every context was removed while they remain on the device —
52
+ * and rendering them with nothing under them is how "sign out of this person"
53
+ * stays reachable. Silently omitting them would strand the row.
54
+ */
55
+ export function projectDevicePrincipals(directory) {
56
+ if (directory === null) {
57
+ return [];
58
+ }
59
+ return directory.principals.map((principal) => ({
60
+ principalId: principal.id,
61
+ userId: principal.userId,
62
+ authuser: principal.authuser,
63
+ profile: principal.user,
64
+ contexts: principal.contexts.map((context) => toDeviceContext(principal, context)),
65
+ isActive: directory.activeContextId !== null &&
66
+ principal.contexts.some((context) => context.id === directory.activeContextId),
67
+ }));
68
+ }
69
+ /**
70
+ * The device's active context, or `null`.
71
+ *
72
+ * `null` is a real state, not an error: a device with every context removed, or
73
+ * one whose active context was healed away, has none. It is also the answer when
74
+ * `activeContextId` names a row no principal holds — a directory that
75
+ * disagreed with itself, which resolves to "nothing is active" rather than to a
76
+ * guess.
77
+ */
78
+ export function resolveActiveContext(directory) {
79
+ if (directory === null || directory.activeContextId === null) {
80
+ return null;
81
+ }
82
+ return resolveDeviceContext(directory, directory.activeContextId);
83
+ }
84
+ /**
85
+ * The name a directory row renders: the API's `displayName` when it has one,
86
+ * otherwise the normalized handle, otherwise the localized unnamed sentinel.
87
+ *
88
+ * The identity contract's `displayName ?? handle`, and deliberately not
89
+ * `getAccountDisplayName`'s multi-field chain — that one is for LOCAL account
90
+ * surfaces, and the directory profile is an API DTO whose `name.displayName`
91
+ * the server already composed or deliberately omitted. `getAccountDisplayName`
92
+ * appears here only for its `null` case, which is the sentinel.
93
+ */
94
+ export function directoryDisplayName(profile, locale) {
95
+ const displayName = profile.name?.displayName?.trim();
96
+ if (displayName) {
97
+ return displayName;
98
+ }
99
+ return getNormalizedUserHandle(profile) ?? getAccountDisplayName(null, locale);
100
+ }
101
+ /**
102
+ * A directory row's `@handle`, or `null` when the profile carries no usable
103
+ * username.
104
+ *
105
+ * The directory profile has no email — by design, it is the minimum that
106
+ * renders a row — so the handle is the secondary line, never a synthesized
107
+ * `username@oxy.so` address.
108
+ */
109
+ export function directoryHandle(profile) {
110
+ return getNormalizedUserHandle(profile);
111
+ }
112
+ /**
113
+ * Whether a switcher may offer this row — the one question it asks, answered
114
+ * here so no surface has to re-derive it.
115
+ *
116
+ * It is deliberately a single field. `available` is the server's complete
117
+ * verdict (see {@link DeviceContextSubject.available}), and switchability is an
118
+ * authorization question the client must READ, never recompute; this exists to
119
+ * name the field that answers it, not to combine several.
120
+ *
121
+ * `onDevice` is NOT part of the question and composing the two is the mistake
122
+ * this function exists to prevent, in both directions. `onDevice: false` is an
123
+ * ordinary reachable context whose session is minted on first activation, so
124
+ * requiring it hides every organization the person has not used here yet.
125
+ * `onDevice: true` does not imply activatable either: when a principal's own
126
+ * personal session dies, their delegated contexts keep live sessions of their
127
+ * own and still cannot be activated, so `available || onDevice` would render a
128
+ * row the server answers with 403 and then heals away.
129
+ *
130
+ * Takes a structural subset so a caller holding a raw `DeviceAccountContext`
131
+ * from the wire can ask it without resolving the pair first.
132
+ */
133
+ export function canActivateContext(context) {
134
+ return context.available;
135
+ }