@oxyhq/core 20.1.0 → 21.0.1
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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +47 -8
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +26 -4
- package/dist/cjs/i18n/locales/es-ES.json +26 -4
- package/dist/cjs/i18n/locales/locales/en-US.json +26 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +26 -4
- package/dist/cjs/index.js +57 -16
- package/dist/cjs/inference/OxyInferenceClient.js +330 -0
- package/dist/cjs/mixins/OxyServices.accounts.js +5 -72
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/mixins/OxyServices.inference.js +59 -0
- package/dist/cjs/mixins/OxyServices.utility.js +18 -6
- package/dist/cjs/mixins/index.js +6 -0
- package/dist/cjs/server/auth.js +76 -0
- package/dist/cjs/server/index.js +5 -1
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +47 -8
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +26 -4
- package/dist/esm/i18n/locales/es-ES.json +26 -4
- package/dist/esm/i18n/locales/locales/en-US.json +26 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +26 -4
- package/dist/esm/index.js +36 -10
- package/dist/esm/inference/OxyInferenceClient.js +325 -0
- package/dist/esm/mixins/OxyServices.accounts.js +5 -72
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/mixins/OxyServices.inference.js +56 -0
- package/dist/esm/mixins/OxyServices.utility.js +18 -6
- package/dist/esm/mixins/index.js +6 -0
- package/dist/esm/server/auth.js +72 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +39 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +11 -4
- package/dist/types/inference/OxyInferenceClient.d.ts +324 -0
- package/dist/types/mixins/OxyServices.accounts.d.ts +73 -95
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +44 -13
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/server/auth.d.ts +80 -0
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/HttpService.ts +50 -10
- package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +26 -4
- package/src/i18n/locales/es-ES.json +26 -4
- package/src/index.ts +94 -25
- package/src/inference/OxyInferenceClient.ts +590 -0
- package/src/inference/__tests__/OxyInferenceClient.test.ts +383 -0
- package/src/mixins/OxyServices.accounts.ts +75 -176
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/OxyServices.inference.ts +57 -0
- package/src/mixins/OxyServices.utility.ts +58 -14
- package/src/mixins/__tests__/accounts.test.ts +57 -102
- package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/mixins/__tests__/serviceAuth.test.ts +2 -0
- package/src/mixins/index.ts +8 -0
- package/src/models/session.ts +11 -0
- package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
- package/src/server/auth.ts +118 -0
- package/src/server/index.ts +6 -0
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountDialogShape.test.ts +118 -0
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- 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
|
-
}
|