@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
@@ -1,207 +0,0 @@
1
- /**
2
- * Unified account-list projection — THE single source of truth.
3
- *
4
- * Produces the flat `SwitchableAccount[]` every account chooser renders, by
5
- * merging the device's server-authoritative session set (`DeviceSessionState`
6
- * from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
7
- * from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
8
- * `@oxyhq/core` so every `@oxyhq/services` platform variant — and
9
- * `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
10
- * diverge.
11
- *
12
- * Pure and I/O-free: the caller resolves per-account profiles via
13
- * `oxyServices.getUsersByIds(...)` and passes them in as `profilesById`, and
14
- * binds `resolveAvatarUrl` to `oxyServices.getFileDownloadUrl`. This is the same
15
- * split the former `@oxyhq/services` `buildSwitchableAccounts` used — hoisted
16
- * into core, keyed directly on `DeviceSessionState` (whose `activeAccountId` is
17
- * atomic, so no cross-call current-row reconciliation is needed).
18
- */
19
- import { isActAsEligibleKind } from '@oxyhq/contracts';
20
- import { getAccountDisplayName, getAccountFallbackHandle } from '../utils/accountUtils.js';
21
- import { getNormalizedUserHandle } from '../utils/userHandle.js';
22
- /**
23
- * Whether the caller can BECOME this account — the one question every account
24
- * switcher asks, answered here so no surface has to re-derive it.
25
- *
26
- * Two independent grounds, either of which suffices:
27
- *
28
- * - **It is already the caller's own identity** (`relationship: 'self'`).
29
- * `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
30
- * is the HUMAN operator's personal account even while they are operating an
31
- * org — never the operated account. Kind is irrelevant on this ground: the
32
- * caller IS that account, so returning to it asks the server for nothing.
33
- * - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
34
- * the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
35
- * on this ground is never a dead button.
36
- *
37
- * `isActAsEligibleKind` ALONE is not this question, and reaching for it
38
- * directly is the mistake this function exists to prevent: it is false for
39
- * `personal` as well as `channel`, so a switcher gated on it alone renders an
40
- * empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
41
- * this question either — it silently admits every kind invented after it was
42
- * written, which is the same trap `isActAsEligibleKind` was introduced to close
43
- * on the server.
44
- *
45
- * Takes a structural subset rather than a whole {@link AccountNode} so a caller
46
- * holding a projected {@link SwitchableAccount} can ask it too.
47
- */
48
- export function isSwitchTargetAccount(node) {
49
- return node.relationship === 'self' || isActAsEligibleKind(node.kind);
50
- }
51
- /**
52
- * Whether the caller may switch INTO this account — the server-side
53
- * `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
54
- *
55
- * `relationship: 'self'` always passes (returning to the caller's own personal
56
- * account). Every other ground requires a switch-eligible kind AND
57
- * `account:act_as` in the resolved membership permissions. When permissions are
58
- * absent but the relationship is `owner`, the owner baseline is assumed — the
59
- * API always resolves effective permissions for owned accounts, but test
60
- * fixtures and stale rows may omit the membership blob.
61
- */
62
- export function canSwitchIntoAccount(node) {
63
- if (node.relationship === 'self') {
64
- return true;
65
- }
66
- if (!isSwitchTargetAccount(node)) {
67
- return false;
68
- }
69
- const permissions = node.callerMembership?.permissions;
70
- if (permissions) {
71
- return permissions.includes('account:act_as');
72
- }
73
- return node.relationship === 'owner';
74
- }
75
- /**
76
- * Pure union of device sign-ins and account-graph nodes into the flat
77
- * {@link SwitchableAccount}[] every switcher renders.
78
- *
79
- * Order: device rows first (in `state.accounts` order, active flagged), then
80
- * graph-only rows (in graph order). An account present as BOTH a device session
81
- * and a graph node is deduped into ONE device row enriched with the graph
82
- * metadata (relationship / kind / parent / membership).
83
- *
84
- * Graph nodes the caller cannot switch into — a `channel`, or a managed account
85
- * whose membership lacks `account:act_as` — are omitted.
86
- * {@link canSwitchIntoAccount} is the rule; see the filter below.
87
- */
88
- export function projectSwitchableAccounts(input) {
89
- const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
90
- const activeAccountId = state?.activeAccountId ?? null;
91
- const toRow = (accountUser, opts) => {
92
- const accountId = accountUser.id?.toString() ?? '';
93
- const handle = getAccountFallbackHandle(accountUser);
94
- const secondaryHandle = handle ? `@${handle}` : null;
95
- return {
96
- accountId,
97
- sessionId: opts.sessionId,
98
- authuser: opts.authuser,
99
- isCurrent: Boolean(accountId) && accountId === activeAccountId,
100
- onDevice: Boolean(opts.sessionId),
101
- relationship: opts.relationship,
102
- kind: opts.kind,
103
- parentAccountId: opts.parentAccountId,
104
- callerMembership: opts.callerMembership,
105
- displayName: accountUser.name?.displayName ??
106
- getNormalizedUserHandle(accountUser) ??
107
- getAccountDisplayName(null, locale),
108
- // Real email, or the `@handle` fallback (NEVER synthesized).
109
- email: accountUser.email ?? secondaryHandle,
110
- avatarUrl: resolveAvatarUrl(accountUser.avatar),
111
- color: accountUser.color ?? null,
112
- user: accountUser,
113
- };
114
- };
115
- // --- Device rows (from the server-authoritative session set) ---
116
- const deviceRows = (state?.accounts ?? []).flatMap((account) => {
117
- const isActive = account.accountId === activeAccountId;
118
- // The active row prefers the freshest `activeUser` (when supplied), then the
119
- // batch-resolved profile; every other row uses the batch-resolved profile.
120
- const accountUser = isActive && activeUser
121
- ? activeUser
122
- : profilesById.get(account.accountId);
123
- if (!accountUser) {
124
- return [];
125
- }
126
- return [toRow(accountUser, { sessionId: account.sessionId, authuser: account.authuser })];
127
- });
128
- // --- Merge graph nodes, deduping by account id ---
129
- const byAccountId = new Map();
130
- const order = [];
131
- const remember = (row) => {
132
- if (!row.accountId || byAccountId.has(row.accountId)) {
133
- return;
134
- }
135
- byAccountId.set(row.accountId, row);
136
- order.push(row.accountId);
137
- };
138
- for (const row of deviceRows) {
139
- remember(row);
140
- }
141
- for (const node of graph) {
142
- const existing = byAccountId.get(node.accountId);
143
- if (existing) {
144
- // On-device account that is ALSO in the graph: enrich the device row with
145
- // graph metadata; keep its (freshest) profile + sessionId + active flag.
146
- byAccountId.set(node.accountId, {
147
- ...existing,
148
- relationship: node.relationship,
149
- kind: node.kind,
150
- parentAccountId: node.parentAccountId,
151
- callerMembership: node.callerMembership,
152
- });
153
- continue;
154
- }
155
- // Graph-only account (owned org / shared, not yet a device session).
156
- //
157
- // This lane is why a no-login account is NOT kept out of the switcher "by
158
- // construction": the graph contributes accounts that have no device session
159
- // and no credentials at all, which is exactly how an org first becomes
160
- // switchable. So a kind that must never be switched into has to be filtered
161
- // HERE — offering a row the server would 403 is a dead button.
162
- //
163
- // An account already on the device skipped this check via the branch above,
164
- // and correctly: whatever its kind, the caller is signed into it, so
165
- // switching is a local activation that asks the server for nothing.
166
- if (!canSwitchIntoAccount(node)) {
167
- continue;
168
- }
169
- remember(toRow(node.account, {
170
- relationship: node.relationship,
171
- kind: node.kind,
172
- parentAccountId: node.parentAccountId,
173
- callerMembership: node.callerMembership,
174
- }));
175
- }
176
- return order.flatMap((id) => {
177
- const row = byAccountId.get(id);
178
- return row ? [row] : [];
179
- });
180
- }
181
- /**
182
- * Every distinct account id referenced by a device session set AND an account
183
- * graph, sorted for a stable profile-fetch key. Feed to
184
- * `oxyServices.getUsersByIds(...)`; graph nodes already embed their `account`
185
- * document, but including their ids lets the caller pass one id set and lets the
186
- * projection prefer freshly-fetched profiles uniformly.
187
- *
188
- * Applies the SAME {@link canSwitchIntoAccount} filter as
189
- * {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
190
- * profile for a row the projection will drop — and, just as importantly, never
191
- * SKIPS one the projection will keep, which would leave that row unrendered
192
- * until some later fetch happened to resolve it.
193
- */
194
- export function switchableAccountIds(state, graph) {
195
- const ids = new Set();
196
- for (const account of state?.accounts ?? []) {
197
- if (account.accountId) {
198
- ids.add(account.accountId);
199
- }
200
- }
201
- for (const node of graph) {
202
- if (node.accountId && canSwitchIntoAccount(node)) {
203
- ids.add(node.accountId);
204
- }
205
- }
206
- return Array.from(ids).sort();
207
- }
@@ -1,198 +0,0 @@
1
- /**
2
- * Unified account-list projection — THE single source of truth.
3
- *
4
- * Produces the flat `SwitchableAccount[]` every account chooser renders, by
5
- * merging the device's server-authoritative session set (`DeviceSessionState`
6
- * from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
7
- * from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
8
- * `@oxyhq/core` so every `@oxyhq/services` platform variant — and
9
- * `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
10
- * diverge.
11
- *
12
- * Pure and I/O-free: the caller resolves per-account profiles via
13
- * `oxyServices.getUsersByIds(...)` and passes them in as `profilesById`, and
14
- * binds `resolveAvatarUrl` to `oxyServices.getFileDownloadUrl`. This is the same
15
- * split the former `@oxyhq/services` `buildSwitchableAccounts` used — hoisted
16
- * into core, keyed directly on `DeviceSessionState` (whose `activeAccountId` is
17
- * atomic, so no cross-call current-row reconciliation is needed).
18
- */
19
- import type { DeviceSessionState } from '@oxyhq/contracts';
20
- import type { User } from '../models/interfaces';
21
- import type { AccountNode, AccountRelationship, AccountKind, AccountMember } from '../mixins/OxyServices.accounts';
22
- /**
23
- * The per-account user shape carried by a {@link SwitchableAccount}. The SDK's
24
- * canonical {@link User} document — either a profile resolved via
25
- * `oxyServices.getUsersByIds()` (device rows), the caller-supplied
26
- * `activeUser` override (the freshest copy of the active row), or the `account`
27
- * document embedded in an account-graph node (graph-only rows).
28
- */
29
- export type SwitchableAccountUser = User;
30
- /**
31
- * One account the signed-in user can switch INTO, in the uniform switch model.
32
- *
33
- * A switchable account is either a device sign-in, an account-graph node (owned
34
- * org / shared-with-you), or BOTH (an account that has been switched into
35
- * becomes a device session while still being a graph node — the two are deduped
36
- * into a single row). Every row carries a canonical `accountId` (the uniform
37
- * switch key); `sessionId` is present IFF the account is currently signed in on
38
- * THIS device.
39
- */
40
- export interface SwitchableAccount {
41
- /**
42
- * Canonical account id (the underlying `User._id`). The single key EVERY
43
- * switch uses — `controller.switchTo(accountId)`. Always present.
44
- */
45
- accountId: string;
46
- /**
47
- * Device session id, present IFF this account is signed in on THIS device.
48
- * Absent for a graph account not yet switched into. Used only for
49
- * device-scoped actions (per-account sign-out); switching ALWAYS goes through
50
- * `switchTo(accountId)`.
51
- */
52
- sessionId?: string;
53
- /**
54
- * Device-local account slot index (0..N-1) carried on the underlying
55
- * `SessionAccount`. Absent for graph-only rows.
56
- */
57
- authuser?: number;
58
- /** Whether this account is the currently-active one (`accountId === activeAccountId`). */
59
- isCurrent: boolean;
60
- /** Whether this account is signed in on THIS device (has a `sessionId`). */
61
- onDevice: boolean;
62
- /**
63
- * The caller's relationship to this account when it appears in the account
64
- * graph: `self` (the caller's own personal account), `owner` (an org/project/
65
- * bot the caller owns), or `member` (shared with the caller). Absent for an
66
- * independent device sign-in that is NOT in the active account's graph.
67
- */
68
- relationship?: AccountRelationship;
69
- /** Account classification (personal/organization/…). Cosmetic badge only. */
70
- kind?: AccountKind;
71
- /** Parent account id for 2-level tree grouping, or `null` for a root. */
72
- parentAccountId?: string | null;
73
- /**
74
- * The caller's effective membership (role + permissions) in this account when
75
- * it appears in the graph, or `null`/absent otherwise. Use `permissions` to
76
- * gate per-account settings UI.
77
- */
78
- callerMembership?: AccountMember | null;
79
- /** Friendly display name (never blank — falls back to a handle/sentinel). */
80
- displayName: string;
81
- /**
82
- * Real account email, or `null` when the account genuinely has none. NEVER a
83
- * synthesized `username@oxy.so` — a missing email falls back to the `@handle`
84
- * secondary line.
85
- */
86
- email: string | null;
87
- /** Resolved avatar thumbnail URL, or `undefined` when the account has no avatar. */
88
- avatarUrl?: string;
89
- /** Account's preferred Bloom color preset, or `null` when unset. */
90
- color: string | null;
91
- /** The underlying per-account user payload. */
92
- user: SwitchableAccountUser;
93
- }
94
- /**
95
- * Whether the caller can BECOME this account — the one question every account
96
- * switcher asks, answered here so no surface has to re-derive it.
97
- *
98
- * Two independent grounds, either of which suffices:
99
- *
100
- * - **It is already the caller's own identity** (`relationship: 'self'`).
101
- * `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
102
- * is the HUMAN operator's personal account even while they are operating an
103
- * org — never the operated account. Kind is irrelevant on this ground: the
104
- * caller IS that account, so returning to it asks the server for nothing.
105
- * - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
106
- * the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
107
- * on this ground is never a dead button.
108
- *
109
- * `isActAsEligibleKind` ALONE is not this question, and reaching for it
110
- * directly is the mistake this function exists to prevent: it is false for
111
- * `personal` as well as `channel`, so a switcher gated on it alone renders an
112
- * empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
113
- * this question either — it silently admits every kind invented after it was
114
- * written, which is the same trap `isActAsEligibleKind` was introduced to close
115
- * on the server.
116
- *
117
- * Takes a structural subset rather than a whole {@link AccountNode} so a caller
118
- * holding a projected {@link SwitchableAccount} can ask it too.
119
- */
120
- export declare function isSwitchTargetAccount(node: {
121
- kind?: AccountKind | null;
122
- relationship?: AccountRelationship;
123
- }): boolean;
124
- /**
125
- * Whether the caller may switch INTO this account — the server-side
126
- * `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
127
- *
128
- * `relationship: 'self'` always passes (returning to the caller's own personal
129
- * account). Every other ground requires a switch-eligible kind AND
130
- * `account:act_as` in the resolved membership permissions. When permissions are
131
- * absent but the relationship is `owner`, the owner baseline is assumed — the
132
- * API always resolves effective permissions for owned accounts, but test
133
- * fixtures and stale rows may omit the membership blob.
134
- */
135
- export declare function canSwitchIntoAccount(node: {
136
- kind?: AccountKind | null;
137
- relationship?: AccountRelationship;
138
- callerMembership?: AccountMember | null;
139
- }): boolean;
140
- /** Input to {@link projectSwitchableAccounts}. */
141
- export interface ProjectSwitchableAccountsInput {
142
- /**
143
- * The device-scoped session state from `SessionClient.getState()`. `null`
144
- * (or an empty account set) contributes no device rows.
145
- */
146
- state: DeviceSessionState | null;
147
- /** The caller's account graph (`oxyServices.listAccounts()`). `[]` when none. */
148
- graph: AccountNode[];
149
- /**
150
- * Per-account profiles resolved via `oxyServices.getUsersByIds()`, keyed by
151
- * account id (`User.id`). Device accounts whose profile is absent here are
152
- * omitted until a subsequent fetch resolves them (unless they are the active
153
- * account and `activeUser` is supplied).
154
- */
155
- profilesById: Map<string, User>;
156
- /**
157
- * The freshest copy of the ACTIVE account's user (e.g. `useOxy().user`),
158
- * preferred over `profilesById` for the active row so a just-committed profile
159
- * edit is reflected immediately. Optional — the controller relies on
160
- * `profilesById` alone when omitted.
161
- */
162
- activeUser?: User | null;
163
- /** Locale for display-name resolution (passed to `getAccountDisplayName`). */
164
- locale?: string;
165
- /**
166
- * Resolves an avatar file id to a thumbnail URL — bind to
167
- * `(id) => id ? oxyServices.getFileDownloadUrl(id, 'thumb') : undefined`.
168
- */
169
- resolveAvatarUrl: (avatar: string | null | undefined) => string | undefined;
170
- }
171
- /**
172
- * Pure union of device sign-ins and account-graph nodes into the flat
173
- * {@link SwitchableAccount}[] every switcher renders.
174
- *
175
- * Order: device rows first (in `state.accounts` order, active flagged), then
176
- * graph-only rows (in graph order). An account present as BOTH a device session
177
- * and a graph node is deduped into ONE device row enriched with the graph
178
- * metadata (relationship / kind / parent / membership).
179
- *
180
- * Graph nodes the caller cannot switch into — a `channel`, or a managed account
181
- * whose membership lacks `account:act_as` — are omitted.
182
- * {@link canSwitchIntoAccount} is the rule; see the filter below.
183
- */
184
- export declare function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[];
185
- /**
186
- * Every distinct account id referenced by a device session set AND an account
187
- * graph, sorted for a stable profile-fetch key. Feed to
188
- * `oxyServices.getUsersByIds(...)`; graph nodes already embed their `account`
189
- * document, but including their ids lets the caller pass one id set and lets the
190
- * projection prefer freshly-fetched profiles uniformly.
191
- *
192
- * Applies the SAME {@link canSwitchIntoAccount} filter as
193
- * {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
194
- * profile for a row the projection will drop — and, just as importantly, never
195
- * SKIPS one the projection will keep, which would leave that row unrendered
196
- * until some later fetch happened to resolve it.
197
- */
198
- export declare function switchableAccountIds(state: DeviceSessionState | null, graph: AccountNode[]): string[];