@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
@@ -0,0 +1,269 @@
1
+ import type {
2
+ AccountKind,
3
+ DeviceAccountContext,
4
+ DeviceContextRelationship,
5
+ DeviceDirectory,
6
+ DeviceDirectoryProfile,
7
+ DevicePrincipal,
8
+ } from '@oxyhq/contracts';
9
+ import { getAccountDisplayName } from '../utils/accountUtils';
10
+ import { getNormalizedUserHandle } from '../utils/userHandle';
11
+
12
+ /**
13
+ * Pure projections over the device directory (`GET /session/device/directory`,
14
+ * ADR 0002). No I/O — the caller holds the directory `SessionClient` fetched.
15
+ *
16
+ * These exist to say the one thing the flat `DeviceSessionState` projection
17
+ * structurally cannot: WHO an account is being reached through.
18
+ * `DeviceSessionState.accounts[]` has carried `operatedByUserId` on the wire all
19
+ * along and nothing in this SDK ever read it, so "signed in as The Oxy
20
+ * Collective" and "Nate operating The Oxy Collective" render identically today —
21
+ * two different audit actors, two different revocation paths, one row. The
22
+ * directory keeps the two facts apart, and so does everything below.
23
+ */
24
+
25
+ /**
26
+ * The human whose authentication backs a context — the audit actor.
27
+ *
28
+ * Always a person (ADR 0001: a principal is never an organization, project,
29
+ * channel or bot) and the owner of the `authuser` slot. An organization in the
30
+ * switcher consumes no slot of its own; it is a SUBJECT under some principal.
31
+ */
32
+ export interface DeviceContextActor {
33
+ /** Identifies the principal row — the id `POST /session/device/signout { principalId }` takes. */
34
+ principalId: string;
35
+ /** The person's own account id. */
36
+ userId: string;
37
+ /** Google-style signed-in-human slot, allocated per person. */
38
+ authuser: number;
39
+ profile: DeviceDirectoryProfile;
40
+ }
41
+
42
+ /** The account a context acts AS — what a profile header renders. */
43
+ export interface DeviceContextSubject {
44
+ accountId: string;
45
+ kind: AccountKind;
46
+ relationship: DeviceContextRelationship;
47
+ profile: DeviceDirectoryProfile;
48
+ /**
49
+ * `false` for a context the principal may act as but has never activated here.
50
+ *
51
+ * NOT a synonym for activatable, in either direction — see
52
+ * {@link canActivateContext}.
53
+ */
54
+ onDevice: boolean;
55
+ /**
56
+ * Whether this context can be activated right now — the server's whole verdict,
57
+ * returned as a row rather than omitted so the UI can explain a row going away.
58
+ *
59
+ * It is STRICTER than the old `/switch` gate, and stricter than the name
60
+ * suggests: it is the live `account:act_as` check AND the PRINCIPAL's own
61
+ * personal session being live. Activation has no proof of who is acting once
62
+ * the human's own session is gone, so a dead principal makes every one of
63
+ * their contexts unavailable — the delegated ones whose own sessions are
64
+ * perfectly alive included.
65
+ */
66
+ available: boolean;
67
+ lastUsedAt: number | null;
68
+ }
69
+
70
+ /**
71
+ * One resolved `principal acting as account` pair — the globally switchable
72
+ * unit, with its two halves named.
73
+ */
74
+ export interface DeviceContext {
75
+ /**
76
+ * The identifier `POST /session/device/activate` takes. Names the PAIR, never
77
+ * the account.
78
+ *
79
+ * NOT STABLE ACROSS A REMOVAL, and therefore never something to persist or to
80
+ * hold across a read. Removing a delegated context is not permanent while the
81
+ * membership lives: the server rematerializes the pair on the next directory
82
+ * read, as `onDevice: false` under a NEW id — and it does so WITHOUT bumping
83
+ * `revision`, so "the device has not changed" is not evidence the id has not.
84
+ * Re-resolve from the directory in hand every time, and read an id that no
85
+ * longer resolves as gone rather than as an error.
86
+ */
87
+ contextId: string;
88
+ actor: DeviceContextActor;
89
+ subject: DeviceContextSubject;
90
+ /**
91
+ * Whether the actor and the subject are different accounts — "Nate operating
92
+ * The Oxy Collective" rather than "Nate". Compared by id rather than read off
93
+ * `relationship`, so it stays true if the vocabulary ever grows a fourth term.
94
+ */
95
+ isDelegated: boolean;
96
+ }
97
+
98
+ /** Resolve one wire context under the principal it hangs off. */
99
+ function toDeviceContext(principal: DevicePrincipal, context: DeviceAccountContext): DeviceContext {
100
+ return {
101
+ contextId: context.id,
102
+ actor: {
103
+ principalId: principal.id,
104
+ userId: principal.userId,
105
+ authuser: principal.authuser,
106
+ profile: principal.user,
107
+ },
108
+ subject: {
109
+ accountId: context.accountId,
110
+ kind: context.kind,
111
+ relationship: context.relationship,
112
+ profile: context.account,
113
+ onDevice: context.onDevice,
114
+ available: context.available,
115
+ lastUsedAt: context.lastUsedAt,
116
+ },
117
+ isDelegated: context.accountId !== principal.userId,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Resolve one context by its id.
123
+ *
124
+ * The search is over `(principal, context)` PAIRS, not over accounts: the same
125
+ * `accountId` legitimately appears under two principals on a shared device, and
126
+ * matching on the account would hand back whichever person happened to be
127
+ * enumerated first.
128
+ */
129
+ export function resolveDeviceContext(
130
+ directory: DeviceDirectory | null,
131
+ contextId: string,
132
+ ): DeviceContext | null {
133
+ if (directory === null) {
134
+ return null;
135
+ }
136
+ for (const principal of directory.principals) {
137
+ for (const context of principal.contexts) {
138
+ if (context.id === contextId) {
139
+ return toDeviceContext(principal, context);
140
+ }
141
+ }
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * One person on the device, with every account they can act as beneath them.
148
+ *
149
+ * This is the switcher's shape, and it is grouped rather than flat because the
150
+ * flat one cannot state the fact the whole model exists for: the same
151
+ * organization reachable through two people is TWO rows, under two different
152
+ * humans, and a list keyed by account can only ever show one of them.
153
+ */
154
+ export interface DevicePrincipalGroup {
155
+ /** The id `POST /session/device/signout { principalId }` takes. */
156
+ principalId: string;
157
+ /** The person's own account id. */
158
+ userId: string;
159
+ /** Google-style signed-in-human slot. An organization consumes none. */
160
+ authuser: number;
161
+ profile: DeviceDirectoryProfile;
162
+ /**
163
+ * Every context this person can reach, in the server's order (their personal
164
+ * account first, then the accounts they act as, by account id). Not re-sorted
165
+ * here: the server's order is already total and revision-stable, and a second
166
+ * ordering rule on the client would be a second thing to keep in agreement.
167
+ */
168
+ contexts: DeviceContext[];
169
+ /** Whether the device's ACTIVE context belongs to this person. */
170
+ isActive: boolean;
171
+ }
172
+
173
+ /**
174
+ * The directory as the switcher renders it: people, each with what they may
175
+ * become.
176
+ *
177
+ * A principal with no contexts is kept rather than dropped. It is a real state
178
+ * — a person whose every context was removed while they remain on the device —
179
+ * and rendering them with nothing under them is how "sign out of this person"
180
+ * stays reachable. Silently omitting them would strand the row.
181
+ */
182
+ export function projectDevicePrincipals(
183
+ directory: DeviceDirectory | null,
184
+ ): DevicePrincipalGroup[] {
185
+ if (directory === null) {
186
+ return [];
187
+ }
188
+ return directory.principals.map((principal) => ({
189
+ principalId: principal.id,
190
+ userId: principal.userId,
191
+ authuser: principal.authuser,
192
+ profile: principal.user,
193
+ contexts: principal.contexts.map((context) => toDeviceContext(principal, context)),
194
+ isActive:
195
+ directory.activeContextId !== null &&
196
+ principal.contexts.some((context) => context.id === directory.activeContextId),
197
+ }));
198
+ }
199
+
200
+ /**
201
+ * The device's active context, or `null`.
202
+ *
203
+ * `null` is a real state, not an error: a device with every context removed, or
204
+ * one whose active context was healed away, has none. It is also the answer when
205
+ * `activeContextId` names a row no principal holds — a directory that
206
+ * disagreed with itself, which resolves to "nothing is active" rather than to a
207
+ * guess.
208
+ */
209
+ export function resolveActiveContext(directory: DeviceDirectory | null): DeviceContext | null {
210
+ if (directory === null || directory.activeContextId === null) {
211
+ return null;
212
+ }
213
+ return resolveDeviceContext(directory, directory.activeContextId);
214
+ }
215
+
216
+ /**
217
+ * The name a directory row renders: the API's `displayName` when it has one,
218
+ * otherwise the normalized handle, otherwise the localized unnamed sentinel.
219
+ *
220
+ * The identity contract's `displayName ?? handle`, and deliberately not
221
+ * `getAccountDisplayName`'s multi-field chain — that one is for LOCAL account
222
+ * surfaces, and the directory profile is an API DTO whose `name.displayName`
223
+ * the server already composed or deliberately omitted. `getAccountDisplayName`
224
+ * appears here only for its `null` case, which is the sentinel.
225
+ */
226
+ export function directoryDisplayName(profile: DeviceDirectoryProfile, locale?: string): string {
227
+ const displayName = profile.name?.displayName?.trim();
228
+ if (displayName) {
229
+ return displayName;
230
+ }
231
+ return getNormalizedUserHandle(profile) ?? getAccountDisplayName(null, locale);
232
+ }
233
+
234
+ /**
235
+ * A directory row's `@handle`, or `null` when the profile carries no usable
236
+ * username.
237
+ *
238
+ * The directory profile has no email — by design, it is the minimum that
239
+ * renders a row — so the handle is the secondary line, never a synthesized
240
+ * `username@oxy.so` address.
241
+ */
242
+ export function directoryHandle(profile: DeviceDirectoryProfile): string | null {
243
+ return getNormalizedUserHandle(profile);
244
+ }
245
+
246
+ /**
247
+ * Whether a switcher may offer this row — the one question it asks, answered
248
+ * here so no surface has to re-derive it.
249
+ *
250
+ * It is deliberately a single field. `available` is the server's complete
251
+ * verdict (see {@link DeviceContextSubject.available}), and switchability is an
252
+ * authorization question the client must READ, never recompute; this exists to
253
+ * name the field that answers it, not to combine several.
254
+ *
255
+ * `onDevice` is NOT part of the question and composing the two is the mistake
256
+ * this function exists to prevent, in both directions. `onDevice: false` is an
257
+ * ordinary reachable context whose session is minted on first activation, so
258
+ * requiring it hides every organization the person has not used here yet.
259
+ * `onDevice: true` does not imply activatable either: when a principal's own
260
+ * personal session dies, their delegated contexts keep live sessions of their
261
+ * own and still cannot be activated, so `available || onDevice` would render a
262
+ * row the server answers with 403 and then heals away.
263
+ *
264
+ * Takes a structural subset so a caller holding a raw `DeviceAccountContext`
265
+ * from the wire can ask it without resolving the pair first.
266
+ */
267
+ export function canActivateContext(context: { available: boolean }): boolean {
268
+ return context.available;
269
+ }
@@ -0,0 +1,145 @@
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
+
28
+ import {
29
+ canActivateContext,
30
+ directoryDisplayName,
31
+ directoryHandle,
32
+ type DeviceContext,
33
+ type DevicePrincipalGroup,
34
+ } from './deviceDirectory';
35
+
36
+ /** One `principal acting as account` row. */
37
+ export interface SwitcherContextRow {
38
+ /**
39
+ * The id `activateContext` / `signOutContext` take. Names the PAIR.
40
+ *
41
+ * Not stable across a removal, so it is read out of the directory in hand on
42
+ * every render and never held across one.
43
+ */
44
+ contextId: string;
45
+ accountId: string;
46
+ displayName: string;
47
+ /** The `@handle` secondary line, or `null` when the profile has no username. */
48
+ handle: string | null;
49
+ avatarUrl: string | undefined;
50
+ /**
51
+ * The SUBJECT account's own accent — a named Bloom preset, or `null` when it
52
+ * has none and the renderer should use the ambient theme accent.
53
+ *
54
+ * Forwarded verbatim, never resolved to a colour here: mapping a preset name
55
+ * to a hex is Bloom's job, and `@oxyhq/core` cannot import a UI package.
56
+ */
57
+ color: string | null;
58
+ /** Whether this pair is the device's active context. */
59
+ isActive: boolean;
60
+ /** Whether the actor and the subject are different accounts. */
61
+ isDelegated: boolean;
62
+ /**
63
+ * Whether the row may be pressed — the server's `available`, read and never
64
+ * recomputed. Composing it with `onDevice` is wrong in both directions (see
65
+ * `canActivateContext`), so this is one field forwarded, not a derivation.
66
+ */
67
+ canActivate: boolean;
68
+ }
69
+
70
+ /** One person on this device, with the accounts they can act as beneath them. */
71
+ export interface SwitcherPrincipalRow {
72
+ /** The id `signOutPrincipal` takes. */
73
+ principalId: string;
74
+ displayName: string;
75
+ handle: string | null;
76
+ avatarUrl: string | undefined;
77
+ /** The PERSON's own accent, on the same terms as a context row's. */
78
+ color: string | null;
79
+ /** Whether the device's ACTIVE context belongs to this person. */
80
+ isActive: boolean;
81
+ contexts: SwitcherContextRow[];
82
+ }
83
+
84
+ /** Resolves an avatar file id to a thumbnail URL. Bind to `getFileDownloadUrl`. */
85
+ export type ResolveAvatarUrl = (avatar: string | null | undefined) => string | undefined;
86
+
87
+ function toContextRow(
88
+ context: DeviceContext,
89
+ activeContextId: string | null,
90
+ resolveAvatarUrl: ResolveAvatarUrl,
91
+ locale: string | undefined,
92
+ ): SwitcherContextRow {
93
+ return {
94
+ contextId: context.contextId,
95
+ accountId: context.subject.accountId,
96
+ displayName: directoryDisplayName(context.subject.profile, locale),
97
+ handle: directoryHandle(context.subject.profile),
98
+ avatarUrl: resolveAvatarUrl(context.subject.profile.avatar),
99
+ color: context.subject.profile.color ?? null,
100
+ // Compared on the CONTEXT id, never the account id: on a device holding two
101
+ // people the same account is active through exactly one of them, and an
102
+ // account comparison would light up both rows.
103
+ isActive: context.contextId === activeContextId,
104
+ isDelegated: context.isDelegated,
105
+ canActivate: canActivateContext(context.subject),
106
+ };
107
+ }
108
+
109
+ /** The directory's people and their contexts, ready to render. */
110
+ export function buildSwitcherRows(
111
+ groups: DevicePrincipalGroup[],
112
+ activeContextId: string | null,
113
+ resolveAvatarUrl: ResolveAvatarUrl,
114
+ locale?: string,
115
+ ): SwitcherPrincipalRow[] {
116
+ return groups.map((group) => ({
117
+ principalId: group.principalId,
118
+ displayName: directoryDisplayName(group.profile, locale),
119
+ handle: directoryHandle(group.profile),
120
+ avatarUrl: resolveAvatarUrl(group.profile.avatar),
121
+ color: group.profile.color ?? null,
122
+ isActive: group.isActive,
123
+ contexts: group.contexts.map((context) =>
124
+ toContextRow(context, activeContextId, resolveAvatarUrl, locale),
125
+ ),
126
+ }));
127
+ }
128
+
129
+ /**
130
+ * Whether the switcher should name the person above each block.
131
+ *
132
+ * The question a header answers is "who is operating this account", and that is
133
+ * only a question worth printing once somebody holds MORE THAN ONE account
134
+ * here. Two people with one personal account each is the flat list again — every
135
+ * row already IS a person, and a header would just print each name twice.
136
+ *
137
+ * The moment any one of them can act as a second account, every group gets a
138
+ * header, including the single-context ones: an inconsistent list is harder to
139
+ * read than a slightly redundant one, and it is exactly then that "The Oxy
140
+ * Collective, under Nate" and "The Oxy Collective, under Alice" become two
141
+ * different rows that must be told apart.
142
+ */
143
+ export function showsPrincipalHeaders(rows: SwitcherPrincipalRow[]): boolean {
144
+ return rows.some((row) => row.contexts.length > 1);
145
+ }
@@ -25,15 +25,20 @@ import type { User } from '../models/interfaces';
25
25
  * The account a projection should resolve: the pin when one is supplied and
26
26
  * non-empty, else the device's active account. An empty-string pin is treated as
27
27
  * "not pinned" rather than as an account that can never match.
28
+ *
29
+ * Exported so no other projection over the same state answers it a second,
30
+ * subtly different way. A null state with
31
+ * a pin still resolves to the pin: the pinned identity is bound by a local key,
32
+ * not by device membership.
28
33
  */
29
- function boundAccountIdOf(
30
- state: DeviceSessionState,
34
+ export function boundAccountIdOf(
35
+ state: DeviceSessionState | null,
31
36
  pinnedAccountId?: string | null,
32
37
  ): string | null {
33
38
  if (typeof pinnedAccountId === 'string' && pinnedAccountId.length > 0) {
34
39
  return pinnedAccountId;
35
40
  }
36
- return state.activeAccountId;
41
+ return state?.activeAccountId ?? null;
37
42
  }
38
43
 
39
44
  /**
@@ -68,6 +73,7 @@ export function deviceStateToClientSessions(
68
73
  userId: account.accountId,
69
74
  isCurrent: account.accountId === boundAccountId,
70
75
  authuser: account.authuser,
76
+ operatedByUserId: account.operatedByUserId,
71
77
  }));
72
78
  }
73
79