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