@oxyhq/core 20.0.0 → 21.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +10 -9
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/mixins/OxyServices.chains.js +73 -0
- package/dist/cjs/mixins/OxyServices.store.js +266 -0
- package/dist/cjs/mixins/OxyServices.utility.js +159 -104
- package/dist/cjs/mixins/index.js +7 -0
- package/dist/cjs/server/rateLimit.js +15 -6
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/mixins/OxyServices.chains.js +70 -0
- package/dist/esm/mixins/OxyServices.store.js +263 -0
- package/dist/esm/mixins/OxyServices.utility.js +159 -104
- package/dist/esm/mixins/index.js +7 -0
- package/dist/esm/server/rateLimit.js +15 -6
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +15 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
- package/dist/types/mixins/OxyServices.store.d.ts +334 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
- package/dist/types/mixins/index.d.ts +3 -1
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +105 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/OxyServices.chains.ts +134 -0
- package/src/mixins/OxyServices.store.ts +585 -0
- package/src/mixins/OxyServices.utility.ts +161 -108
- package/src/mixins/__tests__/chains.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/mixins/__tests__/store.test.ts +304 -0
- package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
- package/src/mixins/index.ts +9 -0
- package/src/models/session.ts +11 -0
- package/src/server/__tests__/rateLimit.test.ts +47 -0
- package/src/server/rateLimit.ts +18 -8
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
|
@@ -9,15 +9,16 @@
|
|
|
9
9
|
* replaces.
|
|
10
10
|
*
|
|
11
11
|
* The controller owns:
|
|
12
|
-
* - the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* - the device DIRECTORY (ADR 0002) — the server-authoritative read model of
|
|
13
|
+
* who is on this device and what each of them may act as, read through
|
|
14
|
+
* `SessionClient.refreshDirectory()`. It is not assembled here: the client
|
|
15
|
+
* holds one caller's account graph and cannot enumerate another principal's,
|
|
16
|
+
* so switchability is the server's answer and the controller only reads it;
|
|
15
17
|
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
|
|
16
18
|
* `signup`);
|
|
17
|
-
* - `
|
|
18
|
-
* account
|
|
19
|
-
*
|
|
20
|
-
* new switch path);
|
|
19
|
+
* - `activateContext` (the ADR 0002 switch, keyed on the `principal acting as
|
|
20
|
+
* account` pair) and the two removals an account id cannot name —
|
|
21
|
+
* `signOutContext` and `signOutPrincipal`;
|
|
21
22
|
* - the "Sign in with Oxy" device flow (same-device shared-keychain via
|
|
22
23
|
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
23
24
|
* via `startCommonsSignIn` → poll → `claimSessionByToken`);
|
|
@@ -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 {
|
|
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.
|
|
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
|
|
219
|
-
//
|
|
220
|
-
//
|
|
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
|
|
303
|
-
//
|
|
304
|
-
|
|
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
|
-
//
|
|
335
|
+
// The directory
|
|
336
336
|
// =========================================================================
|
|
337
337
|
/**
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
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
|
|
345
|
-
//
|
|
346
|
-
//
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
365
|
+
await this.sessionClient.refreshDirectory();
|
|
362
366
|
}
|
|
363
367
|
catch (error) {
|
|
364
|
-
// A 401
|
|
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)
|
|
367
|
-
//
|
|
368
|
-
//
|
|
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]
|
|
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]
|
|
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
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
410
|
+
await this.sessionClient.activateContext(contextId);
|
|
411
|
+
await this.refresh();
|
|
412
|
+
return true;
|
|
415
413
|
}
|
|
416
414
|
catch (error) {
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
*
|
|
424
|
+
* Remove ONE `principal → account` pair, and only that pair.
|
|
442
425
|
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
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
|
|
453
|
-
*
|
|
454
|
-
*
|
|
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
|
|
457
|
-
if (this.
|
|
435
|
+
async signOutContext(contextId) {
|
|
436
|
+
if (this.removingContextId || this.removingPrincipalId)
|
|
458
437
|
return false;
|
|
459
|
-
this.
|
|
438
|
+
this.removingContextId = contextId;
|
|
460
439
|
this.error = null;
|
|
461
440
|
this.emit();
|
|
462
441
|
try {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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.
|
|
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
|
|
992
|
-
|
|
993
|
-
|
|
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
|
|
1094
|
+
const directory = this.sessionClient.getDirectory();
|
|
1117
1095
|
return {
|
|
1118
1096
|
view: this.view,
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
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
|
+
}
|