@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,447 +0,0 @@
1
- import type { DeviceSessionState } from '@oxyhq/contracts';
2
- import { ACCOUNT_KINDS } from '@oxyhq/contracts';
3
- import type { User } from '../../models/interfaces';
4
- import type { AccountNode } from '../../mixins/OxyServices.accounts';
5
- import {
6
- canSwitchIntoAccount,
7
- isSwitchTargetAccount,
8
- projectSwitchableAccounts,
9
- switchableAccountIds,
10
- } from '../accountProjection';
11
-
12
- function user(id: string, over: Partial<User> = {}): User {
13
- return {
14
- id,
15
- publicKey: `pk_${id}`,
16
- username: `user_${id}`,
17
- name: { displayName: `User ${id}` },
18
- ...over,
19
- } as User;
20
- }
21
-
22
- function state(
23
- accounts: Array<{ accountId: string; sessionId: string; authuser?: number }>,
24
- activeAccountId: string | null,
25
- ): DeviceSessionState {
26
- return {
27
- deviceId: 'device-1',
28
- accounts: accounts.map((a) => ({ accountId: a.accountId, sessionId: a.sessionId, authuser: a.authuser ?? 0 })),
29
- activeAccountId,
30
- revision: 1,
31
- updatedAt: 1_720_000_000_000,
32
- };
33
- }
34
-
35
- function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
36
- return {
37
- accountId: id,
38
- kind: 'organization',
39
- parentAccountId: null,
40
- account: user(id),
41
- relationship: 'owner',
42
- callerMembership: null,
43
- ...over,
44
- };
45
- }
46
-
47
- const mapOf = (...users: User[]): Map<string, User> => {
48
- const map = new Map<string, User>();
49
- for (const u of users) map.set(u.id, u);
50
- return map;
51
- };
52
-
53
- const noAvatar = (): undefined => undefined;
54
-
55
- describe('isSwitchTargetAccount', () => {
56
- /**
57
- * EXHAUSTIVE over `ACCOUNT_KINDS`, as one object equality rather than a
58
- * per-kind assertion, for two reasons that a `channel → false` spot-check
59
- * cannot give:
60
- *
61
- * - It distinguishes "excludes channels" from "excludes everything". Three
62
- * kinds must come back `true` here, so a predicate that answered `false`
63
- * unconditionally — the shape that empties a switcher instead of filtering
64
- * it — fails on those three, not on the channel.
65
- * - Adding a sixth kind fails this test with a missing key, forcing the
66
- * decision to be made HERE rather than inherited silently from whichever
67
- * literal comparison happened to be written first.
68
- */
69
- it('answers by kind for an account the caller merely owns or is a member of', () => {
70
- expect(
71
- Object.fromEntries(
72
- ACCOUNT_KINDS.map((kind) => [kind, isSwitchTargetAccount({ kind, relationship: 'owner' })]),
73
- ),
74
- ).toEqual({
75
- personal: false,
76
- organization: true,
77
- project: true,
78
- bot: true,
79
- channel: false,
80
- });
81
- });
82
-
83
- /**
84
- * The `self` ground, and the reason this predicate is not `isActAsEligibleKind`.
85
- *
86
- * `personal` is act-as INELIGIBLE — assuming somebody's human login would be
87
- * impersonation — so a switcher gated on that predicate alone would drop the
88
- * caller's OWN account and render an empty list. `GET /accounts` resolves its
89
- * caller through `resolveOperatorId`, so a `self` node is always the human
90
- * operator's own personal account, even while they operate an org.
91
- */
92
- it('admits the caller’s own personal account, which is act-as ineligible', () => {
93
- expect(isSwitchTargetAccount({ kind: 'personal', relationship: 'self' })).toBe(true);
94
- // Same kind, not the caller's own → refused. The `relationship` is doing the
95
- // work, so neither half of the predicate can be deleted without a failure.
96
- expect(isSwitchTargetAccount({ kind: 'personal', relationship: 'member' })).toBe(false);
97
- });
98
-
99
- it('refuses an account with no kind information rather than assuming', () => {
100
- expect(isSwitchTargetAccount({})).toBe(false);
101
- expect(isSwitchTargetAccount({ kind: null })).toBe(false);
102
- expect(isSwitchTargetAccount({ kind: undefined, relationship: 'owner' })).toBe(false);
103
- });
104
- });
105
-
106
- describe('canSwitchIntoAccount', () => {
107
- it('admits self without membership permissions', () => {
108
- expect(canSwitchIntoAccount({ kind: 'personal', relationship: 'self' })).toBe(true);
109
- });
110
-
111
- it('admits an owned switch target when membership is absent (owner baseline)', () => {
112
- expect(canSwitchIntoAccount({ kind: 'organization', relationship: 'owner' })).toBe(true);
113
- });
114
-
115
- it('requires account:act_as for member relationships', () => {
116
- expect(
117
- canSwitchIntoAccount({
118
- kind: 'organization',
119
- relationship: 'member',
120
- callerMembership: {
121
- _id: 'm1',
122
- accountId: 'org1',
123
- memberUserId: 'u1',
124
- role: 'billing',
125
- status: 'active',
126
- permissions: ['account:read', 'billing:manage'],
127
- inherit: true,
128
- source: 'direct',
129
- },
130
- }),
131
- ).toBe(false);
132
-
133
- expect(
134
- canSwitchIntoAccount({
135
- kind: 'organization',
136
- relationship: 'member',
137
- callerMembership: {
138
- _id: 'm2',
139
- accountId: 'org1',
140
- memberUserId: 'u1',
141
- role: 'admin',
142
- status: 'active',
143
- permissions: ['account:act_as', 'account:read'],
144
- inherit: true,
145
- source: 'direct',
146
- },
147
- }),
148
- ).toBe(true);
149
- });
150
-
151
- it('refuses channels even with act_as permission', () => {
152
- expect(
153
- canSwitchIntoAccount({
154
- kind: 'channel',
155
- relationship: 'owner',
156
- callerMembership: {
157
- _id: 'm3',
158
- accountId: 'chan1',
159
- memberUserId: 'u1',
160
- role: 'owner',
161
- status: 'active',
162
- permissions: ['account:act_as'],
163
- inherit: true,
164
- source: 'direct',
165
- },
166
- }),
167
- ).toBe(false);
168
- });
169
- });
170
-
171
- describe('projectSwitchableAccounts', () => {
172
- it('returns [] for null state and empty graph', () => {
173
- expect(
174
- projectSwitchableAccounts({ state: null, graph: [], profilesById: new Map(), resolveAvatarUrl: noAvatar }),
175
- ).toEqual([]);
176
- });
177
-
178
- it('projects device rows and flags the active account current', () => {
179
- const rows = projectSwitchableAccounts({
180
- state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }, { accountId: 'a2', sessionId: 's2', authuser: 1 }], 'a2'),
181
- graph: [],
182
- profilesById: mapOf(user('a1'), user('a2')),
183
- resolveAvatarUrl: noAvatar,
184
- });
185
-
186
- expect(rows.map((r) => r.accountId)).toEqual(['a1', 'a2']);
187
- expect(rows.map((r) => r.isCurrent)).toEqual([false, true]);
188
- expect(rows.every((r) => r.onDevice)).toBe(true);
189
- expect(rows[1].sessionId).toBe('s2');
190
- expect(rows[1].authuser).toBe(1);
191
- });
192
-
193
- it('omits device accounts whose profile is not resolved (except the active one via activeUser)', () => {
194
- const rows = projectSwitchableAccounts({
195
- state: state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'),
196
- graph: [],
197
- // a2 has no resolved profile; a1 is active and provided via activeUser.
198
- profilesById: new Map(),
199
- activeUser: user('a1', { name: { displayName: 'Fresh A1' } }),
200
- resolveAvatarUrl: noAvatar,
201
- });
202
-
203
- expect(rows.map((r) => r.accountId)).toEqual(['a1']);
204
- expect(rows[0].displayName).toBe('Fresh A1');
205
- expect(rows[0].isCurrent).toBe(true);
206
- });
207
-
208
- it('prefers activeUser over profilesById for the active row (freshness)', () => {
209
- const rows = projectSwitchableAccounts({
210
- state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
211
- graph: [],
212
- profilesById: mapOf(user('a1', { name: { displayName: 'Stale' } })),
213
- activeUser: user('a1', { name: { displayName: 'Fresh' } }),
214
- resolveAvatarUrl: noAvatar,
215
- });
216
- expect(rows[0].displayName).toBe('Fresh');
217
- });
218
-
219
- it('merges graph-only accounts after device rows, carrying graph metadata', () => {
220
- const rows = projectSwitchableAccounts({
221
- state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
222
- graph: [graphNode('org1', { kind: 'organization', relationship: 'owner', parentAccountId: 'a1' })],
223
- profilesById: mapOf(user('a1')),
224
- resolveAvatarUrl: noAvatar,
225
- });
226
-
227
- expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org1']);
228
- const org = rows[1];
229
- expect(org.onDevice).toBe(false);
230
- expect(org.isCurrent).toBe(false);
231
- expect(org.sessionId).toBeUndefined();
232
- expect(org.kind).toBe('organization');
233
- expect(org.relationship).toBe('owner');
234
- expect(org.parentAccountId).toBe('a1');
235
- });
236
-
237
- /**
238
- * A channel is a content identity nobody acts as, so it must never be offered
239
- * as a switch target. It reaches the projection through the GRAPH lane, not
240
- * the device lane — a channel has no credentials and so can never be a device
241
- * session, which is exactly why "no-login accounts can't appear here by
242
- * construction" is false: `listAccounts()` contributes credential-less
243
- * accounts on purpose (that is how an org first becomes switchable).
244
- */
245
- it('omits a graph-only channel account from the switcher', () => {
246
- const rows = projectSwitchableAccounts({
247
- state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
248
- graph: [
249
- graphNode('org1', { kind: 'organization' }),
250
- graphNode('chan1', { kind: 'channel' }),
251
- ],
252
- profilesById: mapOf(user('a1')),
253
- resolveAvatarUrl: noAvatar,
254
- });
255
-
256
- expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org1']);
257
- expect(rows.some((r) => r.kind === 'channel')).toBe(false);
258
- });
259
-
260
- /**
261
- * The same rule as the `isSwitchTargetAccount` matrix above, asserted through
262
- * the projection so the WIRING is covered and not just the predicate.
263
- *
264
- * The fixture deliberately carries one graph-only node of every kind, and
265
- * FOUR of the five must survive: a fixture list of channels alone could not
266
- * tell "omits channels" from "omits every graph-only row", which is the
267
- * failure mode that would silently empty an operator's switcher of the orgs
268
- * they actually work in. `a1` is a device row and is asserted separately, so
269
- * the graph lane's output is never confused with the device lane's.
270
- */
271
- it('keeps every switchable kind while dropping the channel (graph lane)', () => {
272
- const rows = projectSwitchableAccounts({
273
- state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
274
- graph: [
275
- graphNode('self1', { kind: 'personal', relationship: 'self' }),
276
- graphNode('org1', { kind: 'organization' }),
277
- graphNode('proj1', { kind: 'project' }),
278
- graphNode('bot1', { kind: 'bot' }),
279
- graphNode('chan1', { kind: 'channel' }),
280
- ],
281
- profilesById: mapOf(
282
- user('a1'),
283
- user('self1'),
284
- user('org1'),
285
- user('proj1'),
286
- user('bot1'),
287
- user('chan1'),
288
- ),
289
- resolveAvatarUrl: noAvatar,
290
- });
291
-
292
- expect(rows.map((r) => r.accountId)).toEqual(['a1', 'self1', 'org1', 'proj1', 'bot1']);
293
- // Stated the other way round too, so a fixture that stopped reaching the
294
- // graph lane at all could not pass this as a vacuous "no channels found".
295
- expect(rows.map((r) => r.kind)).toEqual([
296
- undefined,
297
- 'personal',
298
- 'organization',
299
- 'project',
300
- 'bot',
301
- ]);
302
- });
303
-
304
- it('omits a graph-only member without account:act_as', () => {
305
- const rows = projectSwitchableAccounts({
306
- state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
307
- graph: [
308
- graphNode('org1', { kind: 'organization', relationship: 'member', callerMembership: {
309
- _id: 'm1',
310
- accountId: 'org1',
311
- memberUserId: 'a1',
312
- role: 'billing',
313
- status: 'active',
314
- permissions: ['account:read', 'billing:manage'],
315
- inherit: true,
316
- source: 'direct',
317
- } }),
318
- graphNode('org2', { kind: 'organization', relationship: 'member', callerMembership: {
319
- _id: 'm2',
320
- accountId: 'org2',
321
- memberUserId: 'a1',
322
- role: 'admin',
323
- status: 'active',
324
- permissions: ['account:act_as', 'account:read'],
325
- inherit: true,
326
- source: 'direct',
327
- } }),
328
- ],
329
- profilesById: mapOf(user('a1'), user('org1'), user('org2')),
330
- resolveAvatarUrl: noAvatar,
331
- });
332
-
333
- expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org2']);
334
- });
335
-
336
- it('dedups an account present as BOTH device session and graph node into ONE enriched row', () => {
337
- const rows = projectSwitchableAccounts({
338
- state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }], 'a1'),
339
- graph: [graphNode('a1', { kind: 'personal', relationship: 'self', callerMembership: null })],
340
- profilesById: mapOf(user('a1')),
341
- resolveAvatarUrl: noAvatar,
342
- });
343
-
344
- expect(rows).toHaveLength(1);
345
- const row = rows[0];
346
- // Keeps the device sessionId + active flag, gains the graph metadata.
347
- expect(row.sessionId).toBe('s1');
348
- expect(row.onDevice).toBe(true);
349
- expect(row.isCurrent).toBe(true);
350
- expect(row.kind).toBe('personal');
351
- expect(row.relationship).toBe('self');
352
- });
353
-
354
- it('resolves avatar url via the injected resolver and falls back email to @handle', () => {
355
- const rows = projectSwitchableAccounts({
356
- state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
357
- graph: [],
358
- profilesById: mapOf(user('a1', { avatar: 'file123', email: undefined, username: 'nate' })),
359
- resolveAvatarUrl: (avatar) => (avatar ? `https://cdn/${avatar}` : undefined),
360
- });
361
- expect(rows[0].avatarUrl).toBe('https://cdn/file123');
362
- // No real email → `@handle` secondary line, never synthesized.
363
- expect(rows[0].email).toBe('@nate');
364
- });
365
-
366
- it('uses a real email when present', () => {
367
- const rows = projectSwitchableAccounts({
368
- state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
369
- graph: [],
370
- profilesById: mapOf(user('a1', { email: 'real@oxy.so' })),
371
- resolveAvatarUrl: noAvatar,
372
- });
373
- expect(rows[0].email).toBe('real@oxy.so');
374
- });
375
-
376
- it('marks no row current when activeAccountId is null', () => {
377
- const rows = projectSwitchableAccounts({
378
- state: state([{ accountId: 'a1', sessionId: 's1' }], null),
379
- graph: [],
380
- profilesById: mapOf(user('a1')),
381
- resolveAvatarUrl: noAvatar,
382
- });
383
- expect(rows.every((r) => !r.isCurrent)).toBe(true);
384
- });
385
-
386
- it('keeps the OPERATOR full set when acting-as a sub-account (no collapse to the active account)', () => {
387
- // nate operates albert: albert is the active on-device account, nate is also
388
- // on-device, and the graph is OPERATOR-anchored (the server returns nate's
389
- // full forest regardless of which account is active). The projection must
390
- // faithfully union — switching in only changes which row is `isCurrent`,
391
- // never which accounts are listed. This is the switcher-collapse regression.
392
- const rows = projectSwitchableAccounts({
393
- state: state([{ accountId: 'nate', sessionId: 's-nate' }, { accountId: 'albert', sessionId: 's-albert' }], 'albert'),
394
- graph: [
395
- graphNode('nate', { kind: 'personal', relationship: 'self' }),
396
- graphNode('albert', { relationship: 'owner' }),
397
- graphNode('oxy', { relationship: 'owner' }),
398
- graphNode('faircoin', { relationship: 'owner' }),
399
- ],
400
- profilesById: mapOf(user('nate'), user('albert'), user('oxy'), user('faircoin')),
401
- resolveAvatarUrl: noAvatar,
402
- });
403
-
404
- // The full operable set is present even though a leaf sub-account is active.
405
- expect(rows.map((r) => r.accountId).sort()).toEqual(['albert', 'faircoin', 'nate', 'oxy']);
406
- // Exactly the active sub-account is flagged current; operator + siblings stay.
407
- expect(rows.find((r) => r.accountId === 'albert')?.isCurrent).toBe(true);
408
- expect(rows.filter((r) => r.isCurrent)).toHaveLength(1);
409
- // The operator's own personal account is not dropped by acting-as.
410
- expect(rows.find((r) => r.accountId === 'nate')).toBeDefined();
411
- // Graph-only siblings (operable but not yet on this device) still appear.
412
- expect(rows.find((r) => r.accountId === 'oxy')?.onDevice).toBe(false);
413
- expect(rows.find((r) => r.accountId === 'faircoin')?.onDevice).toBe(false);
414
- });
415
- });
416
-
417
- describe('switchableAccountIds', () => {
418
- it('unions device + graph ids, deduped and sorted', () => {
419
- const ids = switchableAccountIds(
420
- state([{ accountId: 'b', sessionId: 's1' }, { accountId: 'a', sessionId: 's2' }], 'a'),
421
- [graphNode('c'), graphNode('a')],
422
- );
423
- expect(ids).toEqual(['a', 'b', 'c']);
424
- });
425
-
426
- it('returns [] for null state and empty graph', () => {
427
- expect(switchableAccountIds(null, [])).toEqual([]);
428
- });
429
-
430
- /**
431
- * Must stay in lockstep with the projection's own filter in BOTH directions:
432
- * an id fetched for a dropped row is wasted work, but an id NOT fetched for a
433
- * row the projection keeps is worse — that row has no profile, so the
434
- * projection's device lane skips it and it silently never renders.
435
- */
436
- it('applies the same switch-target filter as the projection', () => {
437
- const ids = switchableAccountIds(null, [
438
- graphNode('self1', { kind: 'personal', relationship: 'self' }),
439
- graphNode('org1', { kind: 'organization' }),
440
- graphNode('proj1', { kind: 'project' }),
441
- graphNode('bot1', { kind: 'bot' }),
442
- graphNode('chan1', { kind: 'channel' }),
443
- graphNode('other1', { kind: 'personal', relationship: 'member' }),
444
- ]);
445
- expect(ids).toEqual(['bot1', 'org1', 'proj1', 'self1']);
446
- });
447
- });