@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,349 @@
1
+ /**
2
+ * The shared native DeviceSession credential — one device, one session, many apps.
3
+ *
4
+ * ## What this is, and what it deliberately is NOT
5
+ *
6
+ * Two different secrets can make a native Oxy app boot signed in, and conflating
7
+ * them is the bug this module exists to end:
8
+ *
9
+ * Commons private identity key → identity and signed approval ONLY. It is
10
+ * self-custody, irreplaceable, and must never
11
+ * become the general app session transport.
12
+ * Shared DeviceSession credential → THIS module. An ordinary `deviceId` +
13
+ * `deviceSecret` pair that restores ordinary
14
+ * official apps, follows the device's active
15
+ * context, and is individually rotatable and
16
+ * revocable server-side.
17
+ *
18
+ * An ordinary app needs the second one. Handing it the first — which is what the
19
+ * `shared-key-signin` lane does today — gives every sibling app the ability to
20
+ * sign as the user's cryptographic identity to obtain something as mundane as a
21
+ * session. That lane stays as a recovery/compatibility path; this one supersedes
22
+ * it for the ordinary case.
23
+ *
24
+ * ## Why sharing one credential is safe against the server
25
+ *
26
+ * `POST /session/device/token` does NOT rotate: the response echoes the presented
27
+ * secret back as `nextDeviceSecret` precisely so several first-party apps sharing
28
+ * one `DeviceSession` can refresh concurrently without invalidating one another.
29
+ * So N apps holding one credential is a supported server state, not a race — and
30
+ * because they then share ONE `DeviceSession`, they automatically share its
31
+ * `activeContextId` and its token-free `session_state` broadcasts.
32
+ *
33
+ * ## The one safety rule everything here is built around
34
+ *
35
+ * A read that FAILED and a slot that is EMPTY must never be the same value. The
36
+ * empty answer authorises writes (seed the slot); the failed answer must
37
+ * authorise nothing. {@link SharedDeviceCredentialRead} keeps them apart at the
38
+ * type level, and every decision below fails closed on anything that is not a
39
+ * positive `absent`/`present`.
40
+ *
41
+ * Platform-agnostic: the actual keychain / keystore access is injected as a
42
+ * {@link SharedDeviceCredentialStore} by `@oxyhq/services`. ESM-safe, no
43
+ * `require()`, no react/react-native/expo imports.
44
+ */
45
+
46
+ import { logger } from '../logger';
47
+ import type { AuthStateStore, PersistedAuthState } from './authStateStore';
48
+
49
+ /**
50
+ * The zero-cookie device credential, as shared between apps. Exactly the pair
51
+ * `POST /session/device/token` takes — nothing else travels through the shared
52
+ * slot: no access token, no account id, no user id, no identity key.
53
+ */
54
+ export interface SharedDeviceCredential {
55
+ deviceId: string;
56
+ deviceSecret: string;
57
+ }
58
+
59
+ /**
60
+ * The outcome of reading the shared slot. FOUR states, and the distinction
61
+ * between the last two is load-bearing:
62
+ *
63
+ * - `present` — a well-formed credential was read.
64
+ * - `absent` — the read SUCCEEDED and the slot is empty. The only state that
65
+ * may authorise seeding the slot.
66
+ * - `unavailable` — the read failed (keychain locked, keystore unreadable, the
67
+ * bridge returned something unrecognisable). Authorises
68
+ * nothing: not adoption, and above all not a write.
69
+ * - `unsupported` — this build has no shared slot at all (web, or a native app
70
+ * without the module linked). Not an error; simply means the
71
+ * app keeps its own per-app credential.
72
+ */
73
+ export type SharedDeviceCredentialRead =
74
+ | { state: 'present'; credential: SharedDeviceCredential }
75
+ | { state: 'absent' }
76
+ | { state: 'unavailable'; cause: unknown }
77
+ | { state: 'unsupported' };
78
+
79
+ /**
80
+ * The platform seam. `@oxyhq/services` implements this over the iOS Keychain
81
+ * Access Group (a dedicated `keychainService`) or the Android signature-protected
82
+ * `OxyDeviceSession` broker.
83
+ */
84
+ export interface SharedDeviceCredentialStore {
85
+ /** Never throws — a failure is reported as `unavailable`, never as `absent`. */
86
+ read(): Promise<SharedDeviceCredentialRead>;
87
+ /**
88
+ * Publish the credential. Resolves `true` only when a read-back confirmed the
89
+ * exact bytes landed; `false` on any failure. Never throws.
90
+ */
91
+ publish(credential: SharedDeviceCredential): Promise<boolean>;
92
+ /** Drop this app's copy of the shared credential. Never throws. */
93
+ clear(): Promise<void>;
94
+ }
95
+
96
+ /** Why {@link decideSharedDeviceJoin} declined to adopt the shared credential. */
97
+ export type SharedDeviceJoinSkipReason =
98
+ | 'shared-unsupported'
99
+ | 'shared-unreadable'
100
+ | 'shared-empty'
101
+ | 'local-credential-present';
102
+
103
+ /** What a booting app should do with the shared slot it just read. */
104
+ export type SharedDeviceJoinDecision =
105
+ | { action: 'adopt'; credential: SharedDeviceCredential }
106
+ | { action: 'skip'; reason: SharedDeviceJoinSkipReason };
107
+
108
+ /** Why {@link decideSharedDevicePublish} declined to write the shared slot. */
109
+ export type SharedDevicePublishSkipReason =
110
+ | 'shared-unsupported'
111
+ | 'shared-unreadable'
112
+ | 'already-current'
113
+ | 'owned-by-another-device';
114
+
115
+ /** What an app that just PROVED a credential should do with the shared slot. */
116
+ export type SharedDevicePublishDecision =
117
+ | { action: 'publish' }
118
+ | { action: 'skip'; reason: SharedDevicePublishSkipReason };
119
+
120
+ /** The usable `{deviceId, deviceSecret}` pair in a persisted state, or null. */
121
+ export function readLocalDeviceCredential(
122
+ state: PersistedAuthState | null,
123
+ ): SharedDeviceCredential | null {
124
+ if (!state?.deviceId || !state.deviceSecret) {
125
+ return null;
126
+ }
127
+ return { deviceId: state.deviceId, deviceSecret: state.deviceSecret };
128
+ }
129
+
130
+ /**
131
+ * Narrow an UNTRUSTED bridge payload into a {@link SharedDeviceCredentialRead}.
132
+ *
133
+ * Anything unrecognised resolves to `unavailable`, never `absent`. A native
134
+ * module returning a shape this build does not understand (an older app in the
135
+ * signing group, a partially-applied upgrade) means we do not KNOW whether the
136
+ * device has a shared session — and "do not know" must never authorise a write
137
+ * that would overwrite one.
138
+ */
139
+ export function normalizeSharedDeviceSessionRead(raw: unknown): SharedDeviceCredentialRead {
140
+ if (!raw || typeof raw !== 'object') {
141
+ return { state: 'unavailable', cause: new Error('shared device session bridge returned a non-object') };
142
+ }
143
+ const payload = raw as Record<string, unknown>;
144
+ if (payload.status === 'absent') {
145
+ return { state: 'absent' };
146
+ }
147
+ if (payload.status === 'present') {
148
+ const deviceId = payload.deviceId;
149
+ const deviceSecret = payload.deviceSecret;
150
+ if (
151
+ typeof deviceId === 'string' &&
152
+ deviceId.length > 0 &&
153
+ typeof deviceSecret === 'string' &&
154
+ deviceSecret.length > 0
155
+ ) {
156
+ return { state: 'present', credential: { deviceId, deviceSecret } };
157
+ }
158
+ // A `present` verdict whose payload is incomplete is a broken slot, not an
159
+ // empty one. Reporting `absent` here would let the next successful sign-in
160
+ // overwrite whatever is really in there.
161
+ return {
162
+ state: 'unavailable',
163
+ cause: new Error('shared device session bridge reported `present` with an incomplete credential'),
164
+ };
165
+ }
166
+ if (payload.status === 'unavailable') {
167
+ const reason = typeof payload.reason === 'string' ? payload.reason : 'unknown';
168
+ return { state: 'unavailable', cause: new Error(`shared device session slot unavailable: ${reason}`) };
169
+ }
170
+ return {
171
+ state: 'unavailable',
172
+ cause: new Error(`shared device session bridge returned an unrecognised status: ${String(payload.status)}`),
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Should this app adopt the shared credential? Pure.
178
+ *
179
+ * Adoption happens in exactly ONE case: the shared slot holds a credential and
180
+ * this app has none of its own. That single rule delivers the product
181
+ * requirement — a newly installed official app joins the device's existing
182
+ * session without another QR — while making the two failure modes that matter
183
+ * unreachable:
184
+ *
185
+ * - An app that is already signed in is NEVER moved onto another credential, so
186
+ * "no user is signed out merely because one app updates first" holds by
187
+ * construction, in both upgrade directions.
188
+ * - A failed read never looks like an empty slot, so a locked keychain resolves
189
+ * to "keep what I have" rather than "this is a fresh device".
190
+ *
191
+ * The cost is stated plainly: two apps that each already own a DIFFERENT device
192
+ * session stay on their own until one of them loses its credential. Converging
193
+ * them would mean signing one of them out or a server-side device merge, and
194
+ * neither is something a boot path may do silently.
195
+ */
196
+ export function decideSharedDeviceJoin(
197
+ local: PersistedAuthState | null,
198
+ shared: SharedDeviceCredentialRead,
199
+ ): SharedDeviceJoinDecision {
200
+ if (readLocalDeviceCredential(local) !== null) {
201
+ return { action: 'skip', reason: 'local-credential-present' };
202
+ }
203
+ switch (shared.state) {
204
+ case 'present':
205
+ return { action: 'adopt', credential: shared.credential };
206
+ case 'absent':
207
+ return { action: 'skip', reason: 'shared-empty' };
208
+ case 'unavailable':
209
+ return { action: 'skip', reason: 'shared-unreadable' };
210
+ case 'unsupported':
211
+ return { action: 'skip', reason: 'shared-unsupported' };
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Should this app write the credential it just proved into the shared slot? Pure.
217
+ *
218
+ * `proven` means the server accepted it moments ago — a successful sign-in or a
219
+ * successful mint. Only a proven credential is ever published, so the slot can
220
+ * never be seeded with something no app could use.
221
+ *
222
+ * A slot already held by a DIFFERENT `deviceId` is left alone. Overwriting it
223
+ * would silently migrate every other app on this device onto our session at their
224
+ * next cold boot — a real, user-visible change of who they are signed in as, and
225
+ * not something a background persist may decide.
226
+ */
227
+ export function decideSharedDevicePublish(
228
+ proven: SharedDeviceCredential,
229
+ shared: SharedDeviceCredentialRead,
230
+ ): SharedDevicePublishDecision {
231
+ switch (shared.state) {
232
+ case 'unsupported':
233
+ return { action: 'skip', reason: 'shared-unsupported' };
234
+ case 'unavailable':
235
+ return { action: 'skip', reason: 'shared-unreadable' };
236
+ case 'absent':
237
+ return { action: 'publish' };
238
+ case 'present': {
239
+ if (shared.credential.deviceId !== proven.deviceId) {
240
+ return { action: 'skip', reason: 'owned-by-another-device' };
241
+ }
242
+ if (shared.credential.deviceSecret === proven.deviceSecret) {
243
+ return { action: 'skip', reason: 'already-current' };
244
+ }
245
+ // Same device, newer secret. Sign-in rotates the secret (the mint does
246
+ // not), so the just-proven one is the credential a fresh install should
247
+ // join with.
248
+ return { action: 'publish' };
249
+ }
250
+ }
251
+ }
252
+
253
+ /** The result of {@link publishProvenDeviceCredential}, for logs and tests. */
254
+ export type SharedDevicePublishOutcome =
255
+ | { status: 'published' }
256
+ | { status: 'publish-failed' }
257
+ | { status: 'skipped'; reason: SharedDevicePublishSkipReason };
258
+
259
+ /**
260
+ * Read the shared slot, apply {@link decideSharedDevicePublish}, and write when
261
+ * it says so. Best-effort by contract: the caller's own durable credential is
262
+ * already persisted, so a failure here only means a future install will have to
263
+ * sign in interactively.
264
+ */
265
+ export async function publishProvenDeviceCredential(deps: {
266
+ shared: SharedDeviceCredentialStore;
267
+ credential: SharedDeviceCredential;
268
+ }): Promise<SharedDevicePublishOutcome> {
269
+ const read = await deps.shared.read();
270
+ const decision = decideSharedDevicePublish(deps.credential, read);
271
+ if (decision.action === 'skip') {
272
+ if (decision.reason === 'shared-unreadable') {
273
+ logger.debug(
274
+ 'shared device credential slot unreadable — not publishing (an unreadable slot is never an empty one)',
275
+ { component: 'sharedDeviceCredential', method: 'publishProvenDeviceCredential' },
276
+ );
277
+ }
278
+ return { status: 'skipped', reason: decision.reason };
279
+ }
280
+ const published = await deps.shared.publish(deps.credential);
281
+ return published ? { status: 'published' } : { status: 'publish-failed' };
282
+ }
283
+
284
+ /**
285
+ * Wrap a platform {@link AuthStateStore} so that every durable credential it
286
+ * persists is ALSO mirrored into the shared slot.
287
+ *
288
+ * Writes mirror automatically; reads do NOT adopt. That split is deliberate:
289
+ *
290
+ * - Mirroring on write is the right place because `save()` is where a proven
291
+ * credential lands, on every lane there is — interactive sign-in, the cold
292
+ * boot mint, the refresh scheduler, the 401 re-mint, shared-key recovery. One
293
+ * seam, no lane left out, and no new call site to forget.
294
+ * - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
295
+ * a storage primitive, and would run on every `load()`. Adoption is an
296
+ * explicit, once-per-boot cold-boot step instead (`shared-device-adopt`).
297
+ *
298
+ * `clear()` deliberately does NOT clear the shared slot. This app signing out is
299
+ * not authority over the device-wide join point: other apps may still be signed
300
+ * in on that same credential, and once the server session is really gone the
301
+ * credential mints `no_active_session` for everyone anyway.
302
+ */
303
+ export function createSharedMirroringAuthStateStore(deps: {
304
+ local: AuthStateStore;
305
+ shared: SharedDeviceCredentialStore;
306
+ }): AuthStateStore {
307
+ const { local, shared } = deps;
308
+ // Process-local memo of the credential we last observed the shared slot to
309
+ // hold. Purely an optimization to keep the refresh scheduler from re-reading
310
+ // the keychain every mint; a slot wiped out from under us is re-seeded on the
311
+ // next launch rather than mid-process.
312
+ let mirrored: SharedDeviceCredential | null = null;
313
+
314
+ return {
315
+ load: () => local.load(),
316
+ clear: () => local.clear(),
317
+ save: async (state) => {
318
+ // The durable local write is the contract this store owes its caller —
319
+ // run it first and report ITS result, unchanged. The mirror is additive.
320
+ const durablePersisted = await local.save(state);
321
+ const credential = readLocalDeviceCredential(state);
322
+ if (!credential) {
323
+ return durablePersisted;
324
+ }
325
+ if (
326
+ mirrored !== null &&
327
+ mirrored.deviceId === credential.deviceId &&
328
+ mirrored.deviceSecret === credential.deviceSecret
329
+ ) {
330
+ return durablePersisted;
331
+ }
332
+ try {
333
+ const outcome = await publishProvenDeviceCredential({ shared, credential });
334
+ if (outcome.status === 'published' || (outcome.status === 'skipped' && outcome.reason === 'already-current')) {
335
+ mirrored = credential;
336
+ }
337
+ } catch (error) {
338
+ // A store implementation is contractually non-throwing, but a mirror
339
+ // failure must never take down the durable write that already succeeded.
340
+ logger.debug(
341
+ 'mirroring the device credential into the shared slot threw — the local credential is unaffected',
342
+ { component: 'sharedDeviceCredential', method: 'save' },
343
+ error,
344
+ );
345
+ }
346
+ return durablePersisted;
347
+ },
348
+ };
349
+ }
@@ -1,213 +0,0 @@
1
- "use strict";
2
- /**
3
- * Unified account-list projection — THE single source of truth.
4
- *
5
- * Produces the flat `SwitchableAccount[]` every account chooser renders, by
6
- * merging the device's server-authoritative session set (`DeviceSessionState`
7
- * from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
8
- * from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
9
- * `@oxyhq/core` so every `@oxyhq/services` platform variant — and
10
- * `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
11
- * diverge.
12
- *
13
- * Pure and I/O-free: the caller resolves per-account profiles via
14
- * `oxyServices.getUsersByIds(...)` and passes them in as `profilesById`, and
15
- * binds `resolveAvatarUrl` to `oxyServices.getFileDownloadUrl`. This is the same
16
- * split the former `@oxyhq/services` `buildSwitchableAccounts` used — hoisted
17
- * into core, keyed directly on `DeviceSessionState` (whose `activeAccountId` is
18
- * atomic, so no cross-call current-row reconciliation is needed).
19
- */
20
- Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.isSwitchTargetAccount = isSwitchTargetAccount;
22
- exports.canSwitchIntoAccount = canSwitchIntoAccount;
23
- exports.projectSwitchableAccounts = projectSwitchableAccounts;
24
- exports.switchableAccountIds = switchableAccountIds;
25
- const contracts_1 = require("@oxyhq/contracts");
26
- const accountUtils_1 = require("../utils/accountUtils");
27
- const userHandle_1 = require("../utils/userHandle");
28
- /**
29
- * Whether the caller can BECOME this account — the one question every account
30
- * switcher asks, answered here so no surface has to re-derive it.
31
- *
32
- * Two independent grounds, either of which suffices:
33
- *
34
- * - **It is already the caller's own identity** (`relationship: 'self'`).
35
- * `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
36
- * is the HUMAN operator's personal account even while they are operating an
37
- * org — never the operated account. Kind is irrelevant on this ground: the
38
- * caller IS that account, so returning to it asks the server for nothing.
39
- * - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
40
- * the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
41
- * on this ground is never a dead button.
42
- *
43
- * `isActAsEligibleKind` ALONE is not this question, and reaching for it
44
- * directly is the mistake this function exists to prevent: it is false for
45
- * `personal` as well as `channel`, so a switcher gated on it alone renders an
46
- * empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
47
- * this question either — it silently admits every kind invented after it was
48
- * written, which is the same trap `isActAsEligibleKind` was introduced to close
49
- * on the server.
50
- *
51
- * Takes a structural subset rather than a whole {@link AccountNode} so a caller
52
- * holding a projected {@link SwitchableAccount} can ask it too.
53
- */
54
- function isSwitchTargetAccount(node) {
55
- return node.relationship === 'self' || (0, contracts_1.isActAsEligibleKind)(node.kind);
56
- }
57
- /**
58
- * Whether the caller may switch INTO this account — the server-side
59
- * `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
60
- *
61
- * `relationship: 'self'` always passes (returning to the caller's own personal
62
- * account). Every other ground requires a switch-eligible kind AND
63
- * `account:act_as` in the resolved membership permissions. When permissions are
64
- * absent but the relationship is `owner`, the owner baseline is assumed — the
65
- * API always resolves effective permissions for owned accounts, but test
66
- * fixtures and stale rows may omit the membership blob.
67
- */
68
- function canSwitchIntoAccount(node) {
69
- if (node.relationship === 'self') {
70
- return true;
71
- }
72
- if (!isSwitchTargetAccount(node)) {
73
- return false;
74
- }
75
- const permissions = node.callerMembership?.permissions;
76
- if (permissions) {
77
- return permissions.includes('account:act_as');
78
- }
79
- return node.relationship === 'owner';
80
- }
81
- /**
82
- * Pure union of device sign-ins and account-graph nodes into the flat
83
- * {@link SwitchableAccount}[] every switcher renders.
84
- *
85
- * Order: device rows first (in `state.accounts` order, active flagged), then
86
- * graph-only rows (in graph order). An account present as BOTH a device session
87
- * and a graph node is deduped into ONE device row enriched with the graph
88
- * metadata (relationship / kind / parent / membership).
89
- *
90
- * Graph nodes the caller cannot switch into — a `channel`, or a managed account
91
- * whose membership lacks `account:act_as` — are omitted.
92
- * {@link canSwitchIntoAccount} is the rule; see the filter below.
93
- */
94
- function projectSwitchableAccounts(input) {
95
- const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
96
- const activeAccountId = state?.activeAccountId ?? null;
97
- const toRow = (accountUser, opts) => {
98
- const accountId = accountUser.id?.toString() ?? '';
99
- const handle = (0, accountUtils_1.getAccountFallbackHandle)(accountUser);
100
- const secondaryHandle = handle ? `@${handle}` : null;
101
- return {
102
- accountId,
103
- sessionId: opts.sessionId,
104
- authuser: opts.authuser,
105
- isCurrent: Boolean(accountId) && accountId === activeAccountId,
106
- onDevice: Boolean(opts.sessionId),
107
- relationship: opts.relationship,
108
- kind: opts.kind,
109
- parentAccountId: opts.parentAccountId,
110
- callerMembership: opts.callerMembership,
111
- displayName: accountUser.name?.displayName ??
112
- (0, userHandle_1.getNormalizedUserHandle)(accountUser) ??
113
- (0, accountUtils_1.getAccountDisplayName)(null, locale),
114
- // Real email, or the `@handle` fallback (NEVER synthesized).
115
- email: accountUser.email ?? secondaryHandle,
116
- avatarUrl: resolveAvatarUrl(accountUser.avatar),
117
- color: accountUser.color ?? null,
118
- user: accountUser,
119
- };
120
- };
121
- // --- Device rows (from the server-authoritative session set) ---
122
- const deviceRows = (state?.accounts ?? []).flatMap((account) => {
123
- const isActive = account.accountId === activeAccountId;
124
- // The active row prefers the freshest `activeUser` (when supplied), then the
125
- // batch-resolved profile; every other row uses the batch-resolved profile.
126
- const accountUser = isActive && activeUser
127
- ? activeUser
128
- : profilesById.get(account.accountId);
129
- if (!accountUser) {
130
- return [];
131
- }
132
- return [toRow(accountUser, { sessionId: account.sessionId, authuser: account.authuser })];
133
- });
134
- // --- Merge graph nodes, deduping by account id ---
135
- const byAccountId = new Map();
136
- const order = [];
137
- const remember = (row) => {
138
- if (!row.accountId || byAccountId.has(row.accountId)) {
139
- return;
140
- }
141
- byAccountId.set(row.accountId, row);
142
- order.push(row.accountId);
143
- };
144
- for (const row of deviceRows) {
145
- remember(row);
146
- }
147
- for (const node of graph) {
148
- const existing = byAccountId.get(node.accountId);
149
- if (existing) {
150
- // On-device account that is ALSO in the graph: enrich the device row with
151
- // graph metadata; keep its (freshest) profile + sessionId + active flag.
152
- byAccountId.set(node.accountId, {
153
- ...existing,
154
- relationship: node.relationship,
155
- kind: node.kind,
156
- parentAccountId: node.parentAccountId,
157
- callerMembership: node.callerMembership,
158
- });
159
- continue;
160
- }
161
- // Graph-only account (owned org / shared, not yet a device session).
162
- //
163
- // This lane is why a no-login account is NOT kept out of the switcher "by
164
- // construction": the graph contributes accounts that have no device session
165
- // and no credentials at all, which is exactly how an org first becomes
166
- // switchable. So a kind that must never be switched into has to be filtered
167
- // HERE — offering a row the server would 403 is a dead button.
168
- //
169
- // An account already on the device skipped this check via the branch above,
170
- // and correctly: whatever its kind, the caller is signed into it, so
171
- // switching is a local activation that asks the server for nothing.
172
- if (!canSwitchIntoAccount(node)) {
173
- continue;
174
- }
175
- remember(toRow(node.account, {
176
- relationship: node.relationship,
177
- kind: node.kind,
178
- parentAccountId: node.parentAccountId,
179
- callerMembership: node.callerMembership,
180
- }));
181
- }
182
- return order.flatMap((id) => {
183
- const row = byAccountId.get(id);
184
- return row ? [row] : [];
185
- });
186
- }
187
- /**
188
- * Every distinct account id referenced by a device session set AND an account
189
- * graph, sorted for a stable profile-fetch key. Feed to
190
- * `oxyServices.getUsersByIds(...)`; graph nodes already embed their `account`
191
- * document, but including their ids lets the caller pass one id set and lets the
192
- * projection prefer freshly-fetched profiles uniformly.
193
- *
194
- * Applies the SAME {@link canSwitchIntoAccount} filter as
195
- * {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
196
- * profile for a row the projection will drop — and, just as importantly, never
197
- * SKIPS one the projection will keep, which would leave that row unrendered
198
- * until some later fetch happened to resolve it.
199
- */
200
- function switchableAccountIds(state, graph) {
201
- const ids = new Set();
202
- for (const account of state?.accounts ?? []) {
203
- if (account.accountId) {
204
- ids.add(account.accountId);
205
- }
206
- }
207
- for (const node of graph) {
208
- if (node.accountId && canSwitchIntoAccount(node)) {
209
- ids.add(node.accountId);
210
- }
211
- }
212
- return Array.from(ids).sort();
213
- }