@oxyhq/core 20.1.0 → 21.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +8 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +75 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/models/session.ts +11 -0
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
|
@@ -10,15 +10,16 @@
|
|
|
10
10
|
* replaces.
|
|
11
11
|
*
|
|
12
12
|
* The controller owns:
|
|
13
|
-
* - the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
* - `
|
|
19
|
-
* account
|
|
20
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
223
|
-
//
|
|
224
|
-
//
|
|
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
|
|
307
|
-
//
|
|
308
|
-
|
|
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
|
-
//
|
|
339
|
+
// The directory
|
|
340
340
|
// =========================================================================
|
|
341
341
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
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
|
|
349
|
-
//
|
|
350
|
-
//
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
369
|
+
await this.sessionClient.refreshDirectory();
|
|
366
370
|
}
|
|
367
371
|
catch (error) {
|
|
368
|
-
// A 401
|
|
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)
|
|
371
|
-
//
|
|
372
|
-
//
|
|
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]
|
|
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]
|
|
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
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
414
|
+
await this.sessionClient.activateContext(contextId);
|
|
415
|
+
await this.refresh();
|
|
416
|
+
return true;
|
|
419
417
|
}
|
|
420
418
|
catch (error) {
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
*
|
|
428
|
+
* Remove ONE `principal → account` pair, and only that pair.
|
|
446
429
|
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
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
|
|
457
|
-
*
|
|
458
|
-
*
|
|
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
|
|
461
|
-
if (this.
|
|
439
|
+
async signOutContext(contextId) {
|
|
440
|
+
if (this.removingContextId || this.removingPrincipalId)
|
|
462
441
|
return false;
|
|
463
|
-
this.
|
|
442
|
+
this.removingContextId = contextId;
|
|
464
443
|
this.error = null;
|
|
465
444
|
this.emit();
|
|
466
445
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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.
|
|
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
|
|
996
|
-
|
|
997
|
-
|
|
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
|
|
1098
|
+
const directory = this.sessionClient.getDirectory();
|
|
1121
1099
|
return {
|
|
1122
1100
|
view: this.view,
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
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
|
+
}
|