@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.
Files changed (70) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  3. package/dist/cjs/i18n/locales/en-US.json +19 -2
  4. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  5. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  7. package/dist/cjs/index.js +50 -16
  8. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  9. package/dist/cjs/session/SessionClient.js +361 -1
  10. package/dist/cjs/session/accountDialogController.js +121 -147
  11. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  12. package/dist/cjs/session/deviceDirectory.js +143 -0
  13. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  14. package/dist/cjs/session/projectSessionState.js +8 -1
  15. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/boot/sessionColdBoot.js +107 -8
  18. package/dist/esm/i18n/locales/en-US.json +19 -2
  19. package/dist/esm/i18n/locales/es-ES.json +19 -2
  20. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  21. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  22. package/dist/esm/index.js +32 -10
  23. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  24. package/dist/esm/session/SessionClient.js +362 -2
  25. package/dist/esm/session/accountDialogController.js +121 -147
  26. package/dist/esm/session/accountSwitchTargets.js +71 -0
  27. package/dist/esm/session/deviceDirectory.js +135 -0
  28. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  29. package/dist/esm/session/projectSessionState.js +8 -2
  30. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  33. package/dist/types/index.d.ts +8 -3
  34. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  35. package/dist/types/models/session.d.ts +11 -0
  36. package/dist/types/session/SessionClient.d.ts +202 -1
  37. package/dist/types/session/accountDialogController.d.ts +76 -64
  38. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  39. package/dist/types/session/deviceDirectory.d.ts +182 -0
  40. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  41. package/dist/types/session/projectSessionState.d.ts +29 -0
  42. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  43. package/package.json +3 -3
  44. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  45. package/src/boot/sessionColdBoot.ts +133 -9
  46. package/src/i18n/locales/en-US.json +19 -2
  47. package/src/i18n/locales/es-ES.json +19 -2
  48. package/src/index.ts +75 -18
  49. package/src/mixins/OxyServices.auth.ts +67 -5
  50. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  51. package/src/models/session.ts +11 -0
  52. package/src/session/SessionClient.ts +386 -1
  53. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  54. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  55. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  56. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  57. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  58. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  59. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  60. package/src/session/accountDialogController.ts +141 -179
  61. package/src/session/accountSwitchTargets.ts +87 -0
  62. package/src/session/deviceDirectory.ts +269 -0
  63. package/src/session/deviceSwitcherRows.ts +145 -0
  64. package/src/session/projectSessionState.ts +9 -3
  65. package/src/session/sharedDeviceCredential.ts +349 -0
  66. package/dist/cjs/session/accountProjection.js +0 -213
  67. package/dist/esm/session/accountProjection.js +0 -207
  68. package/dist/types/session/accountProjection.d.ts +0 -198
  69. package/src/session/__tests__/accountProjection.test.ts +0 -447
  70. package/src/session/accountProjection.ts +0 -354
@@ -9,15 +9,16 @@
9
9
  * replaces.
10
10
  *
11
11
  * The controller owns:
12
- * - the unified account list (via {@link projectSwitchableAccounts}), fetched
13
- * from `SessionClient` state `oxyServices.listAccounts()` and hydrated
14
- * with `oxyServices.getUsersByIds()`;
12
+ * - the device DIRECTORY (ADR 0002) — the server-authoritative read model of
13
+ * who is on this device and what each of them may act as, read through
14
+ * `SessionClient.refreshDirectory()`. It is not assembled here: the client
15
+ * holds one caller's account graph and cannot enumerate another principal's,
16
+ * so switchability is the server's answer and the controller only reads it;
15
17
  * - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
16
18
  * `signup`);
17
- * - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
18
- * account already on the device, `oxyServices.switchToAccount` to mint on
19
- * first entry into a graph account — reusing the existing SDK primitives, no
20
- * new switch path);
19
+ * - `activateContext` (the ADR 0002 switch, keyed on the `principal acting as
20
+ * account` pair) and the two removals an account id cannot name —
21
+ * `signOutContext` and `signOutPrincipal`;
21
22
  * - the "Sign in with Oxy" device flow (same-device shared-keychain via
22
23
  * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
23
24
  * via `startCommonsSignIn` → poll → `claimSessionByToken`);
@@ -54,11 +55,12 @@
54
55
  * `add` / `startSignup` (the view axis) and `cancelSignIn` (device-flow
55
56
  * teardown), never an `open` / `close` / `visible`.
56
57
  */
58
+ import type { DeviceDirectory } from '@oxyhq/contracts';
57
59
  import type { OxyServices } from '../OxyServices';
58
60
  import type { SessionLoginResponse, MinimalUserData } from '../models/session';
59
61
  import type { SessionClient } from './SessionClient';
60
62
  import type { SocketIOFactory } from './socketLoader';
61
- import { type SwitchableAccount } from './accountProjection';
63
+ import { type DeviceContext } from './deviceDirectory';
62
64
  import { type CommonsDeliveryPlatform, type CommonsDeliveryRoute } from '../utils/commonsDelivery';
63
65
  /** The dialog's top-level view. */
64
66
  export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
@@ -192,16 +194,27 @@ export interface SignInFlowState {
192
194
  export interface AccountDialogSnapshot {
193
195
  /** The current view. */
194
196
  view: AccountDialogView;
195
- /** The unified, deduped account list (device sign-ins ∪ graph accounts). */
196
- accounts: SwitchableAccount[];
197
- /** The currently-active account id, or `null` when signed out. */
198
- activeAccountId: string | null;
199
- /** `true` while the initial account-list fetch is in flight with no data yet. */
197
+ /**
198
+ * The server-authoritative device directory — principals and the contexts
199
+ * each may act as (ADR 0002) — or `null` before the first read.
200
+ *
201
+ * The ONE read model a switcher renders. The flat list this replaced was
202
+ * keyed by account id, so on a device holding two people it could show one
203
+ * route to a shared organization and never both.
204
+ */
205
+ directory: DeviceDirectory | null;
206
+ /** The active `principal acting as account` pair, actor and subject apart. */
207
+ activeContext: DeviceContext | null;
208
+ /** `true` while the first directory read is in flight with nothing to show. */
200
209
  loading: boolean;
201
- /** A human-readable account-list error, or `null`. */
210
+ /** A human-readable directory error, or `null`. */
202
211
  error: string | null;
203
- /** The `accountId` of an in-flight switch, or `null`. */
204
- switchingAccountId: string | null;
212
+ /** The `contextId` of an in-flight activation, or `null`. */
213
+ activatingContextId: string | null;
214
+ /** The `contextId` of an in-flight context removal, or `null`. */
215
+ removingContextId: string | null;
216
+ /** The `principalId` of an in-flight principal removal, or `null`. */
217
+ removingPrincipalId: string | null;
205
218
  /** The "Sign in with Oxy" device-flow state. */
206
219
  signIn: SignInFlowState;
207
220
  /** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
@@ -220,8 +233,6 @@ export interface AccountDialogControllerOptions {
220
233
  * server would reject.
221
234
  */
222
235
  clientId?: string | null;
223
- /** Locale for display-name resolution. */
224
- locale?: string;
225
236
  /**
226
237
  * Commit a freshly-authorized SIGN-IN session (device flow / shared identity)
227
238
  * into the host's session set — device-first registration + durable persist +
@@ -231,24 +242,12 @@ export interface AccountDialogControllerOptions {
231
242
  * `SessionClient.registerAndActivate` (registration + activation only — no
232
243
  * provider-side durable persist/hydration).
233
244
  *
234
- * This is the SIGN-IN commit: registers the session into the host's device
235
- * set with durable persist + profile hydration. An account SWITCH uses
236
- * {@link commitSwitchedSession} instead see below.
245
+ * Sign-in is the only thing that commits a session here. An account SWITCH
246
+ * used to mint one too, on first entry into a graph account; activation mints
247
+ * the delegated session SERVER-side and hands back a bearer, so there is no
248
+ * second commit funnel to keep in step with this one.
237
249
  */
238
250
  commitSession?: (session: SessionLoginResponse) => Promise<void>;
239
- /**
240
- * Commit a minted graph SWITCH session into the host's session set — same
241
- * device-first registration + durable persist + profile hydration as
242
- * {@link commitSession}, but IN-PLACE: it must NOT re-run sign-in side effects
243
- * that belong only to a fresh authorization (for example, a redundant full
244
- * device-set reconcile on switch). Cross-tab/app propagation of the switch
245
- * still happens instantly via the server's device-scoped `session_state` /
246
- * `session_accounts_changed` socket broadcast — no navigation required.
247
- *
248
- * When omitted the controller falls back to {@link commitSession} (if wired)
249
- * and then to `SessionClient.registerAndActivate`.
250
- */
251
- commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
252
251
  /** Notified after a completed sign-in (bearer planted + session committed). */
253
252
  onSignedIn?: (user: MinimalUserData) => void;
254
253
  /**
@@ -313,9 +312,7 @@ export declare class AccountDialogController {
313
312
  private readonly oxyServices;
314
313
  private readonly sessionClient;
315
314
  private readonly clientId;
316
- private readonly locale?;
317
315
  private readonly commitSession?;
318
- private readonly commitSwitchedSession?;
319
316
  private readonly onSignedIn?;
320
317
  private readonly pollIntervalMs;
321
318
  private readonly openUrl?;
@@ -326,11 +323,11 @@ export declare class AccountDialogController {
326
323
  private readonly platform;
327
324
  private readonly listeners;
328
325
  private view;
329
- private graph;
330
- private profilesById;
331
326
  private loading;
332
327
  private error;
333
- private switchingAccountId;
328
+ private activatingContextId;
329
+ private removingContextId;
330
+ private removingPrincipalId;
334
331
  private signIn;
335
332
  private commonsAvailability;
336
333
  /** The secret device-flow token of the active QR flow (never surfaced). */
@@ -406,35 +403,54 @@ export declare class AccountDialogController {
406
403
  /** Switch to the "create account" view (passkey / Commons signup entry). */
407
404
  startSignup(): void;
408
405
  /**
409
- * Reload the account graph and per-account profiles, then re-project. Safe to
410
- * call repeatedly; concurrent calls are reconciled by a sequence guard so a
411
- * slow earlier fetch never overwrites a newer result.
406
+ * Re-read `GET /session/device/directory`. Safe to call repeatedly;
407
+ * concurrent calls are reconciled by a sequence guard so a slow earlier read
408
+ * never overwrites a newer result.
409
+ *
410
+ * This is ONE request. It used to be three — the directory, plus
411
+ * `listAccounts()` and `getUsersByIds()` to rebuild the same tree client-side
412
+ * — and the reconstruction was not merely redundant: it enumerated the
413
+ * CALLER's account graph, which on a device holding two people is one
414
+ * person's answer presented as the device's.
412
415
  */
413
416
  refresh(): Promise<void>;
414
417
  /**
415
- * Fetch profiles for any account id (device set graph) not yet resolved.
416
- * Cheap no-op when everything is already hydrated used from the session
417
- * subscription so a newly-added device account gets a name/avatar.
418
+ * Activate one `principal acting as account` context the ADR 0002 switch,
419
+ * and the one that can express what an account id cannot: WHICH person's
420
+ * route to a shared organization to become.
421
+ *
422
+ * There is no on-device/graph fork. The directory has a row for a context the
423
+ * principal may act as but has never entered, and `POST /session/device/
424
+ * activate` reuses or mints the delegated session server-side, so one call
425
+ * covers both cases.
426
+ *
427
+ * A context id is not stable across a removal, so a stale one is an ordinary
428
+ * outcome rather than a bug: the server answers 404 or 403, heals the row, and
429
+ * the refresh below re-reads a directory that no longer offers it.
418
430
  */
419
- private ensureProfiles;
420
- private loadProfiles;
431
+ activateContext(contextId: string): Promise<boolean>;
421
432
  /**
422
- * Switch the active account to `accountId`.
433
+ * Remove ONE `principal account` pair, and only that pair.
434
+ *
435
+ * Not the account across the device: the same organization reached through a
436
+ * second person is a different session with a different audit actor, and it
437
+ * stays. Routing this through `signOut({accountId})` would revoke that second
438
+ * person's access as a side effect of one person tidying their own list.
423
439
  *
424
- * Uniform switch model, mirroring the SDK's existing path NOT a new switch
425
- * mechanism:
426
- * - already on this device `SessionClient.switchAccount` (device-first
427
- * switch of `/session/device/switch`);
428
- * - a graph account not yet on the device (first entry)
429
- * `oxyServices.switchToAccount` mints + plants a real session and the
430
- * server registers it into the device set, then it is committed
431
- * (`commitSession` when supplied, else `SessionClient.registerAndActivate`).
440
+ * The removed pair is not gone for good while the membership lives — the
441
+ * server offers it again on the next read, under a NEW id and at an unchanged
442
+ * revision so nothing may hold a context id across this call.
443
+ */
444
+ signOutContext(contextId: string): Promise<boolean>;
445
+ /**
446
+ * Remove ONE PERSON and every context they reach and nobody else's,
447
+ * including when another principal independently operates the same account.
432
448
  *
433
- * The resulting device-state change flows back through the `SessionClient`
434
- * subscription, which re-projects the active row. Concurrent switches are
435
- * ignored while one is in flight.
449
+ * A separate call from {@link signOutContext} because it is a separate
450
+ * question, not a loop over the first one: the server removes the principal
451
+ * and elects a replacement active context in one transition.
436
452
  */
437
- switchTo(accountId: string): Promise<boolean>;
453
+ signOutPrincipal(principalId: string): Promise<boolean>;
438
454
  /**
439
455
  * Start "Sign in with Oxy". Native devices with a shared identity mint a
440
456
  * session silently (`signInWithSharedIdentity`); everything else (web, or a
@@ -595,10 +611,6 @@ export declare class AccountDialogController {
595
611
  * Register a token-planted session into the device set. Prefers the
596
612
  * consumer's commit funnel (durable persist + hydration); falls back to
597
613
  * `SessionClient.registerAndActivate` (registration + activation only).
598
- *
599
- * A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel;
600
- * a SIGN-IN uses `commitSession`. When the switch funnel is not wired it falls
601
- * back to the sign-in funnel, then to `registerAndActivate`.
602
614
  */
603
615
  private commitAuthorizedSession;
604
616
  private failSignIn;
@@ -0,0 +1,64 @@
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 type { AccountRelationship, AccountKind, AccountMember } from '../mixins/OxyServices.accounts';
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 declare function isSwitchTargetAccount(node: {
46
+ kind?: AccountKind | null;
47
+ relationship?: AccountRelationship;
48
+ }): boolean;
49
+ /**
50
+ * Whether the caller may switch INTO this account — the server-side
51
+ * `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
52
+ *
53
+ * `relationship: 'self'` always passes (returning to the caller's own personal
54
+ * account). Every other ground requires a switch-eligible kind AND
55
+ * `account:act_as` in the resolved membership permissions. When permissions are
56
+ * absent but the relationship is `owner`, the owner baseline is assumed — the
57
+ * API always resolves effective permissions for owned accounts, but test
58
+ * fixtures and stale rows may omit the membership blob.
59
+ */
60
+ export declare function canSwitchIntoAccount(node: {
61
+ kind?: AccountKind | null;
62
+ relationship?: AccountRelationship;
63
+ callerMembership?: AccountMember | null;
64
+ }): boolean;
@@ -0,0 +1,182 @@
1
+ import type { AccountKind, DeviceContextRelationship, DeviceDirectory, DeviceDirectoryProfile } from '@oxyhq/contracts';
2
+ /**
3
+ * Pure projections over the device directory (`GET /session/device/directory`,
4
+ * ADR 0002). No I/O — the caller holds the directory `SessionClient` fetched.
5
+ *
6
+ * These exist to say the one thing the flat `DeviceSessionState` projection
7
+ * structurally cannot: WHO an account is being reached through.
8
+ * `DeviceSessionState.accounts[]` has carried `operatedByUserId` on the wire all
9
+ * along and nothing in this SDK ever read it, so "signed in as The Oxy
10
+ * Collective" and "Nate operating The Oxy Collective" render identically today —
11
+ * two different audit actors, two different revocation paths, one row. The
12
+ * directory keeps the two facts apart, and so does everything below.
13
+ */
14
+ /**
15
+ * The human whose authentication backs a context — the audit actor.
16
+ *
17
+ * Always a person (ADR 0001: a principal is never an organization, project,
18
+ * channel or bot) and the owner of the `authuser` slot. An organization in the
19
+ * switcher consumes no slot of its own; it is a SUBJECT under some principal.
20
+ */
21
+ export interface DeviceContextActor {
22
+ /** Identifies the principal row — the id `POST /session/device/signout { principalId }` takes. */
23
+ principalId: string;
24
+ /** The person's own account id. */
25
+ userId: string;
26
+ /** Google-style signed-in-human slot, allocated per person. */
27
+ authuser: number;
28
+ profile: DeviceDirectoryProfile;
29
+ }
30
+ /** The account a context acts AS — what a profile header renders. */
31
+ export interface DeviceContextSubject {
32
+ accountId: string;
33
+ kind: AccountKind;
34
+ relationship: DeviceContextRelationship;
35
+ profile: DeviceDirectoryProfile;
36
+ /**
37
+ * `false` for a context the principal may act as but has never activated here.
38
+ *
39
+ * NOT a synonym for activatable, in either direction — see
40
+ * {@link canActivateContext}.
41
+ */
42
+ onDevice: boolean;
43
+ /**
44
+ * Whether this context can be activated right now — the server's whole verdict,
45
+ * returned as a row rather than omitted so the UI can explain a row going away.
46
+ *
47
+ * It is STRICTER than the old `/switch` gate, and stricter than the name
48
+ * suggests: it is the live `account:act_as` check AND the PRINCIPAL's own
49
+ * personal session being live. Activation has no proof of who is acting once
50
+ * the human's own session is gone, so a dead principal makes every one of
51
+ * their contexts unavailable — the delegated ones whose own sessions are
52
+ * perfectly alive included.
53
+ */
54
+ available: boolean;
55
+ lastUsedAt: number | null;
56
+ }
57
+ /**
58
+ * One resolved `principal acting as account` pair — the globally switchable
59
+ * unit, with its two halves named.
60
+ */
61
+ export interface DeviceContext {
62
+ /**
63
+ * The identifier `POST /session/device/activate` takes. Names the PAIR, never
64
+ * the account.
65
+ *
66
+ * NOT STABLE ACROSS A REMOVAL, and therefore never something to persist or to
67
+ * hold across a read. Removing a delegated context is not permanent while the
68
+ * membership lives: the server rematerializes the pair on the next directory
69
+ * read, as `onDevice: false` under a NEW id — and it does so WITHOUT bumping
70
+ * `revision`, so "the device has not changed" is not evidence the id has not.
71
+ * Re-resolve from the directory in hand every time, and read an id that no
72
+ * longer resolves as gone rather than as an error.
73
+ */
74
+ contextId: string;
75
+ actor: DeviceContextActor;
76
+ subject: DeviceContextSubject;
77
+ /**
78
+ * Whether the actor and the subject are different accounts — "Nate operating
79
+ * The Oxy Collective" rather than "Nate". Compared by id rather than read off
80
+ * `relationship`, so it stays true if the vocabulary ever grows a fourth term.
81
+ */
82
+ isDelegated: boolean;
83
+ }
84
+ /**
85
+ * Resolve one context by its id.
86
+ *
87
+ * The search is over `(principal, context)` PAIRS, not over accounts: the same
88
+ * `accountId` legitimately appears under two principals on a shared device, and
89
+ * matching on the account would hand back whichever person happened to be
90
+ * enumerated first.
91
+ */
92
+ export declare function resolveDeviceContext(directory: DeviceDirectory | null, contextId: string): DeviceContext | null;
93
+ /**
94
+ * One person on the device, with every account they can act as beneath them.
95
+ *
96
+ * This is the switcher's shape, and it is grouped rather than flat because the
97
+ * flat one cannot state the fact the whole model exists for: the same
98
+ * organization reachable through two people is TWO rows, under two different
99
+ * humans, and a list keyed by account can only ever show one of them.
100
+ */
101
+ export interface DevicePrincipalGroup {
102
+ /** The id `POST /session/device/signout { principalId }` takes. */
103
+ principalId: string;
104
+ /** The person's own account id. */
105
+ userId: string;
106
+ /** Google-style signed-in-human slot. An organization consumes none. */
107
+ authuser: number;
108
+ profile: DeviceDirectoryProfile;
109
+ /**
110
+ * Every context this person can reach, in the server's order (their personal
111
+ * account first, then the accounts they act as, by account id). Not re-sorted
112
+ * here: the server's order is already total and revision-stable, and a second
113
+ * ordering rule on the client would be a second thing to keep in agreement.
114
+ */
115
+ contexts: DeviceContext[];
116
+ /** Whether the device's ACTIVE context belongs to this person. */
117
+ isActive: boolean;
118
+ }
119
+ /**
120
+ * The directory as the switcher renders it: people, each with what they may
121
+ * become.
122
+ *
123
+ * A principal with no contexts is kept rather than dropped. It is a real state
124
+ * — a person whose every context was removed while they remain on the device —
125
+ * and rendering them with nothing under them is how "sign out of this person"
126
+ * stays reachable. Silently omitting them would strand the row.
127
+ */
128
+ export declare function projectDevicePrincipals(directory: DeviceDirectory | null): DevicePrincipalGroup[];
129
+ /**
130
+ * The device's active context, or `null`.
131
+ *
132
+ * `null` is a real state, not an error: a device with every context removed, or
133
+ * one whose active context was healed away, has none. It is also the answer when
134
+ * `activeContextId` names a row no principal holds — a directory that
135
+ * disagreed with itself, which resolves to "nothing is active" rather than to a
136
+ * guess.
137
+ */
138
+ export declare function resolveActiveContext(directory: DeviceDirectory | null): DeviceContext | null;
139
+ /**
140
+ * The name a directory row renders: the API's `displayName` when it has one,
141
+ * otherwise the normalized handle, otherwise the localized unnamed sentinel.
142
+ *
143
+ * The identity contract's `displayName ?? handle`, and deliberately not
144
+ * `getAccountDisplayName`'s multi-field chain — that one is for LOCAL account
145
+ * surfaces, and the directory profile is an API DTO whose `name.displayName`
146
+ * the server already composed or deliberately omitted. `getAccountDisplayName`
147
+ * appears here only for its `null` case, which is the sentinel.
148
+ */
149
+ export declare function directoryDisplayName(profile: DeviceDirectoryProfile, locale?: string): string;
150
+ /**
151
+ * A directory row's `@handle`, or `null` when the profile carries no usable
152
+ * username.
153
+ *
154
+ * The directory profile has no email — by design, it is the minimum that
155
+ * renders a row — so the handle is the secondary line, never a synthesized
156
+ * `username@oxy.so` address.
157
+ */
158
+ export declare function directoryHandle(profile: DeviceDirectoryProfile): string | null;
159
+ /**
160
+ * Whether a switcher may offer this row — the one question it asks, answered
161
+ * here so no surface has to re-derive it.
162
+ *
163
+ * It is deliberately a single field. `available` is the server's complete
164
+ * verdict (see {@link DeviceContextSubject.available}), and switchability is an
165
+ * authorization question the client must READ, never recompute; this exists to
166
+ * name the field that answers it, not to combine several.
167
+ *
168
+ * `onDevice` is NOT part of the question and composing the two is the mistake
169
+ * this function exists to prevent, in both directions. `onDevice: false` is an
170
+ * ordinary reachable context whose session is minted on first activation, so
171
+ * requiring it hides every organization the person has not used here yet.
172
+ * `onDevice: true` does not imply activatable either: when a principal's own
173
+ * personal session dies, their delegated contexts keep live sessions of their
174
+ * own and still cannot be activated, so `available || onDevice` would render a
175
+ * row the server answers with 403 and then heals away.
176
+ *
177
+ * Takes a structural subset so a caller holding a raw `DeviceAccountContext`
178
+ * from the wire can ask it without resolving the pair first.
179
+ */
180
+ export declare function canActivateContext(context: {
181
+ available: boolean;
182
+ }): boolean;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The device switcher's RENDER model: the directory's people and contexts with
3
+ * their names, handles and avatar URLs already resolved.
4
+ *
5
+ * Pure. It takes the projection {@link projectDevicePrincipals} produces from
6
+ * the directory and answers the five questions a row asks —
7
+ * what is it called, what is its handle, where is its avatar, what accent is it
8
+ * drawn in, may it be activated — so the views stay presentational and neither
9
+ * of them re-derives a display rule.
10
+ *
11
+ * One thing the flat account rows this replaced carried is deliberately absent,
12
+ * because the directory does not carry it and inventing it would mean going back
13
+ * to fetching another person's profiles: **email**. The directory profile is the
14
+ * minimum that renders a row, so the secondary line is the `@handle` — never a
15
+ * synthesized `username@oxy.so`.
16
+ *
17
+ * The accent is NOT in that category. It was, briefly, and it was wrong: an
18
+ * accent is part of drawing the row rather than profile data about its owner,
19
+ * and without it every non-active row falls back to the ambient theme accent, so
20
+ * a device holding two people draws them identically (issue #961). The server
21
+ * carries it on the directory profile, so no client has to guess or fetch.
22
+ *
23
+ * It lives here rather than in a UI package because there is more than one
24
+ * switcher — the SDK's account dialog and the `auth.oxy.so` chooser — and the
25
+ * whole point of ADR 0002 is that they render ONE list, built once.
26
+ */
27
+ import { type DevicePrincipalGroup } from './deviceDirectory';
28
+ /** One `principal acting as account` row. */
29
+ export interface SwitcherContextRow {
30
+ /**
31
+ * The id `activateContext` / `signOutContext` take. Names the PAIR.
32
+ *
33
+ * Not stable across a removal, so it is read out of the directory in hand on
34
+ * every render and never held across one.
35
+ */
36
+ contextId: string;
37
+ accountId: string;
38
+ displayName: string;
39
+ /** The `@handle` secondary line, or `null` when the profile has no username. */
40
+ handle: string | null;
41
+ avatarUrl: string | undefined;
42
+ /**
43
+ * The SUBJECT account's own accent — a named Bloom preset, or `null` when it
44
+ * has none and the renderer should use the ambient theme accent.
45
+ *
46
+ * Forwarded verbatim, never resolved to a colour here: mapping a preset name
47
+ * to a hex is Bloom's job, and `@oxyhq/core` cannot import a UI package.
48
+ */
49
+ color: string | null;
50
+ /** Whether this pair is the device's active context. */
51
+ isActive: boolean;
52
+ /** Whether the actor and the subject are different accounts. */
53
+ isDelegated: boolean;
54
+ /**
55
+ * Whether the row may be pressed — the server's `available`, read and never
56
+ * recomputed. Composing it with `onDevice` is wrong in both directions (see
57
+ * `canActivateContext`), so this is one field forwarded, not a derivation.
58
+ */
59
+ canActivate: boolean;
60
+ }
61
+ /** One person on this device, with the accounts they can act as beneath them. */
62
+ export interface SwitcherPrincipalRow {
63
+ /** The id `signOutPrincipal` takes. */
64
+ principalId: string;
65
+ displayName: string;
66
+ handle: string | null;
67
+ avatarUrl: string | undefined;
68
+ /** The PERSON's own accent, on the same terms as a context row's. */
69
+ color: string | null;
70
+ /** Whether the device's ACTIVE context belongs to this person. */
71
+ isActive: boolean;
72
+ contexts: SwitcherContextRow[];
73
+ }
74
+ /** Resolves an avatar file id to a thumbnail URL. Bind to `getFileDownloadUrl`. */
75
+ export type ResolveAvatarUrl = (avatar: string | null | undefined) => string | undefined;
76
+ /** The directory's people and their contexts, ready to render. */
77
+ export declare function buildSwitcherRows(groups: DevicePrincipalGroup[], activeContextId: string | null, resolveAvatarUrl: ResolveAvatarUrl, locale?: string): SwitcherPrincipalRow[];
78
+ /**
79
+ * Whether the switcher should name the person above each block.
80
+ *
81
+ * The question a header answers is "who is operating this account", and that is
82
+ * only a question worth printing once somebody holds MORE THAN ONE account
83
+ * here. Two people with one personal account each is the flat list again — every
84
+ * row already IS a person, and a header would just print each name twice.
85
+ *
86
+ * The moment any one of them can act as a second account, every group gets a
87
+ * header, including the single-context ones: an inconsistent list is harder to
88
+ * read than a slightly redundant one, and it is exactly then that "The Oxy
89
+ * Collective, under Nate" and "The Oxy Collective, under Alice" become two
90
+ * different rows that must be told apart.
91
+ */
92
+ export declare function showsPrincipalHeaders(rows: SwitcherPrincipalRow[]): boolean;
@@ -1,6 +1,35 @@
1
1
  import type { DeviceSessionState } from '@oxyhq/contracts';
2
2
  import type { ClientSession } from '../models/session';
3
3
  import type { User } from '../models/interfaces';
4
+ /**
5
+ * Pure projection helpers: `DeviceSessionState` (the device-scoped
6
+ * multi-account session-sync state produced by `SessionClient`) -> the
7
+ * shapes `@oxyhq/services` consumers render today
8
+ * (`ClientSession[]`, an active session id, an active `User`).
9
+ *
10
+ * No I/O. The caller fetches profiles via
11
+ * `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
12
+ * from the result before calling `deviceStateToClientSessions` /
13
+ * `activeUserOf`.
14
+ *
15
+ * Each projection takes an OPTIONAL `pinnedAccountId`. Omit it (or pass `null`)
16
+ * and the projection resolves the device's `activeAccountId` exactly as before.
17
+ * Pass it — an IDENTITY-BOUND client, whose user is fixed by the local identity
18
+ * key — and the projection resolves THAT account instead, so an account switch
19
+ * made by another app on the same device changes `state` but never the user this
20
+ * client renders.
21
+ */
22
+ /**
23
+ * The account a projection should resolve: the pin when one is supplied and
24
+ * non-empty, else the device's active account. An empty-string pin is treated as
25
+ * "not pinned" rather than as an account that can never match.
26
+ *
27
+ * Exported so no other projection over the same state answers it a second,
28
+ * subtly different way. A null state with
29
+ * a pin still resolves to the pin: the pinned identity is bound by a local key,
30
+ * not by device membership.
31
+ */
32
+ export declare function boundAccountIdOf(state: DeviceSessionState | null, pinnedAccountId?: string | null): string | null;
4
33
  /**
5
34
  * Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
6
35
  *