@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,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HttpService.unwrapResponse` envelope tests — driven through the REAL class
|
|
3
|
+
* against a stubbed `fetch`, never by calling the private method.
|
|
4
|
+
*
|
|
5
|
+
* The convenience unwrap reduces the house `{ data: <payload> }` envelope to its
|
|
6
|
+
* payload, and in doing so it discards every sibling key. That silently killed
|
|
7
|
+
* pagination on the account audit trails (`GET /accounts/:id/audit` and
|
|
8
|
+
* `GET /accounts/:id/billing/audit`, which answer `{ data, count, nextCursor }`):
|
|
9
|
+
* the caller got a bare array, `getNextPageParam` read `undefined`, and there was
|
|
10
|
+
* nothing at the call site to show that page 2 could never be requested.
|
|
11
|
+
*
|
|
12
|
+
* The three cases below are the whole contract, and each is load-bearing:
|
|
13
|
+
*
|
|
14
|
+
* - a cursor page travels WHOLE (the regression),
|
|
15
|
+
* - a `{ data, pagination }` page still travels whole (the behaviour that
|
|
16
|
+
* already existed — the positive control for "pages are preserved"),
|
|
17
|
+
* - a bare `{ data }` body still unwraps (the control proving the convenience
|
|
18
|
+
* every other call site depends on is intact).
|
|
19
|
+
*
|
|
20
|
+
* Reverting the fix must redden the first and leave the other two green.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { HttpService } from '../HttpService';
|
|
24
|
+
|
|
25
|
+
/** One entry of an audit page — only the shape matters here. */
|
|
26
|
+
const ENTRY = { source: 'application_credential', eventType: 'created' } as const;
|
|
27
|
+
|
|
28
|
+
function jsonBody(body: unknown): Response {
|
|
29
|
+
return new Response(JSON.stringify(body), {
|
|
30
|
+
status: 200,
|
|
31
|
+
headers: { 'content-type': 'application/json' },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('HttpService response-envelope unwrapping', () => {
|
|
36
|
+
const originalFetch = globalThis.fetch;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
globalThis.fetch = originalFetch;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Drive a real `HttpService.get` against a stubbed wire body. */
|
|
43
|
+
async function fetchWireBody<T>(body: unknown): Promise<T> {
|
|
44
|
+
globalThis.fetch = jest.fn(async () => jsonBody(body)) as unknown as typeof fetch;
|
|
45
|
+
const http = new HttpService({ baseURL: 'http://api.test.invalid' });
|
|
46
|
+
return http.get<T>('/accounts/acct-1/audit', { cache: false });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('preserves a cursor page whole — `count` and `nextCursor` reach the caller', async () => {
|
|
50
|
+
const wire = { data: [ENTRY], count: 1, nextCursor: 'CURSOR-XYZ' };
|
|
51
|
+
|
|
52
|
+
const page = await fetchWireBody<typeof wire>(wire);
|
|
53
|
+
|
|
54
|
+
expect(page).toEqual(wire);
|
|
55
|
+
// The cursor is the only thing that says where page 2 starts. Asserted on
|
|
56
|
+
// its own because `toEqual` above would also pass a body that merely
|
|
57
|
+
// happened to be an array of one entry.
|
|
58
|
+
expect(page.nextCursor).toBe('CURSOR-XYZ');
|
|
59
|
+
expect(page.count).toBe(1);
|
|
60
|
+
expect(page.data).toEqual([ENTRY]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('preserves the LAST cursor page, where `nextCursor` is null', async () => {
|
|
64
|
+
// The recognition is by key presence, not truthiness: if the envelope
|
|
65
|
+
// collapsed into a bare array exactly when the stream ended, every caller
|
|
66
|
+
// would crash on the final page instead of finishing.
|
|
67
|
+
const wire = { data: [ENTRY], count: 1, nextCursor: null };
|
|
68
|
+
|
|
69
|
+
const page = await fetchWireBody<typeof wire>(wire);
|
|
70
|
+
|
|
71
|
+
expect(page).toEqual(wire);
|
|
72
|
+
expect(page.nextCursor).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('preserves the offset-paginated `{ data, pagination }` envelope (control)', async () => {
|
|
76
|
+
const wire = {
|
|
77
|
+
data: [ENTRY],
|
|
78
|
+
pagination: { total: 1, limit: 50, offset: 0, hasMore: false },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const page = await fetchWireBody<typeof wire>(wire);
|
|
82
|
+
|
|
83
|
+
expect(page).toEqual(wire);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('still unwraps a bare `{ data }` success envelope to its payload (control)', async () => {
|
|
87
|
+
const payload = { id: 'acct-1', name: 'Acme' };
|
|
88
|
+
|
|
89
|
+
const unwrapped = await fetchWireBody<typeof payload>({ data: payload });
|
|
90
|
+
|
|
91
|
+
expect(unwrapped).toEqual(payload);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('still unwraps `{ data, count }`, which a dozen callers type as the bare payload', async () => {
|
|
95
|
+
// `{ data, count }` is answered by ~15 routes whose Console call sites type
|
|
96
|
+
// the result `Array<T>`. `count` is `data.length` — recoverable — so this
|
|
97
|
+
// envelope deliberately does NOT survive, and widening the rule to "any
|
|
98
|
+
// sibling key" would break every one of those callers at runtime.
|
|
99
|
+
const entries = [ENTRY, ENTRY];
|
|
100
|
+
|
|
101
|
+
const unwrapped = await fetchWireBody<typeof entries>({ data: entries, count: 2 });
|
|
102
|
+
|
|
103
|
+
expect(unwrapped).toEqual(entries);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('passes through a body that has no `data` key at all', async () => {
|
|
107
|
+
// The cursor surfaces already in the SDK (`{ follows, nextCursor }`,
|
|
108
|
+
// `{ records, nextCursor }`) rely on this lane.
|
|
109
|
+
const wire = { follows: [{ relationshipId: 'rel-1' }], nextCursor: 'CURSOR-ABC' };
|
|
110
|
+
|
|
111
|
+
const passed = await fetchWireBody<typeof wire>(wire);
|
|
112
|
+
|
|
113
|
+
expect(passed).toEqual(wire);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shared-device-adopt` — the cold-boot lane that lets a freshly installed
|
|
3
|
+
* official app join the device's EXISTING session with no QR and, crucially,
|
|
4
|
+
* with no access to the Commons private identity key.
|
|
5
|
+
*
|
|
6
|
+
* What is pinned here:
|
|
7
|
+
* - a fresh install adopts the shared credential and mints;
|
|
8
|
+
* - an app that already has a credential is never moved (upgrade order cannot
|
|
9
|
+
* sign anyone out, in either direction);
|
|
10
|
+
* - an UNREADABLE shared slot is not a fresh device — the lane skips and the
|
|
11
|
+
* legacy identity-key lane still gets its turn;
|
|
12
|
+
* - a failed adoption leaves the local store byte-for-byte as it was;
|
|
13
|
+
* - `sessionMode: 'identity'` never runs the lane at all;
|
|
14
|
+
* - web never runs it.
|
|
15
|
+
*/
|
|
16
|
+
import type { OxyServices } from '../../OxyServices';
|
|
17
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
18
|
+
import type { SessionLoginResponse } from '../../models/session';
|
|
19
|
+
import { runSessionColdBoot } from '../sessionColdBoot';
|
|
20
|
+
import type { DeviceSecretMintOutcome } from '../../session/refresh';
|
|
21
|
+
import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
|
|
22
|
+
import type {
|
|
23
|
+
SharedDeviceCredentialRead,
|
|
24
|
+
SharedDeviceCredentialStore,
|
|
25
|
+
} from '../../session/sharedDeviceCredential';
|
|
26
|
+
import { createMemoryIdentityPinStore } from '../../session/identityPin';
|
|
27
|
+
import type { IdentityBinding } from '../../session/identitySession';
|
|
28
|
+
|
|
29
|
+
const WEB = { isWeb: true, isNative: false };
|
|
30
|
+
const NATIVE = { isWeb: false, isNative: true };
|
|
31
|
+
|
|
32
|
+
const SHARED_CRED = { deviceId: 'dev-shared', deviceSecret: 'ds-shared' };
|
|
33
|
+
const OWN_CRED = { deviceId: 'dev-own', deviceSecret: 'ds-own' };
|
|
34
|
+
|
|
35
|
+
const MINT: DeviceTokenMintResponse = {
|
|
36
|
+
accessToken: 'access-joined',
|
|
37
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
38
|
+
nextDeviceSecret: SHARED_CRED.deviceSecret,
|
|
39
|
+
state: {
|
|
40
|
+
deviceId: SHARED_CRED.deviceId,
|
|
41
|
+
accounts: [{ accountId: 'user-shared', sessionId: 'sess-shared', authuser: 0 }],
|
|
42
|
+
activeAccountId: 'user-shared',
|
|
43
|
+
revision: 9,
|
|
44
|
+
updatedAt: 1_700_000_000_000,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A real device-secret mint single-flight matching HttpService's. */
|
|
49
|
+
function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
|
|
50
|
+
let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
51
|
+
return (mint) => {
|
|
52
|
+
if (!inFlight) {
|
|
53
|
+
inFlight = mint().finally(() => {
|
|
54
|
+
inFlight = null;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return inFlight;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A 401 error shaped like `HttpService`/`handleError` output for the given body. */
|
|
62
|
+
function mint401(body: string): Error & { status: number } {
|
|
63
|
+
return Object.assign(new Error(body), { status: 401 });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeOxy(overrides: {
|
|
67
|
+
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
68
|
+
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
69
|
+
} = {}) {
|
|
70
|
+
const setTokens = jest.fn();
|
|
71
|
+
const mintFromDeviceSecret = jest.fn(
|
|
72
|
+
overrides.mintFromDeviceSecret ?? (async () => MINT),
|
|
73
|
+
) as unknown as OxyServices['mintFromDeviceSecret'];
|
|
74
|
+
const signInWithSharedIdentity = jest.fn(
|
|
75
|
+
overrides.signInWithSharedIdentity ?? (async () => null),
|
|
76
|
+
) as unknown as OxyServices['signInWithSharedIdentity'];
|
|
77
|
+
const oxy = {
|
|
78
|
+
getBaseURL: () => 'https://api.oxy.so',
|
|
79
|
+
setTokens,
|
|
80
|
+
mintFromDeviceSecret,
|
|
81
|
+
signInWithSharedIdentity,
|
|
82
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
83
|
+
} as unknown as OxyServices;
|
|
84
|
+
return { oxy, setTokens, mintFromDeviceSecret, signInWithSharedIdentity };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeSharedSlot(initial: SharedDeviceCredentialRead) {
|
|
88
|
+
let current = initial;
|
|
89
|
+
const read = jest.fn(async () => current);
|
|
90
|
+
const publish = jest.fn(async () => true);
|
|
91
|
+
const clear = jest.fn(async () => {
|
|
92
|
+
current = { state: 'absent' };
|
|
93
|
+
});
|
|
94
|
+
const store: SharedDeviceCredentialStore = { read, publish, clear };
|
|
95
|
+
return { store, read, publish, clear };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An identity binding that resolves a local key, for the identity-mode case. */
|
|
99
|
+
function makeIdentityBinding(): IdentityBinding {
|
|
100
|
+
return {
|
|
101
|
+
pinStore: createMemoryIdentityPinStore(),
|
|
102
|
+
getPublicKey: async () => 'pub-identity',
|
|
103
|
+
signMessage: async () => 'sig-identity',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('cold boot — shared-device-adopt', () => {
|
|
108
|
+
test('a fresh install adopts the shared credential and mints a session', async () => {
|
|
109
|
+
const store = createMemoryAuthStateStore();
|
|
110
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
111
|
+
const { oxy, mintFromDeviceSecret, setTokens } = makeOxy();
|
|
112
|
+
|
|
113
|
+
const outcome = await runSessionColdBoot({
|
|
114
|
+
oxy,
|
|
115
|
+
store,
|
|
116
|
+
platform: NATIVE,
|
|
117
|
+
sharedDeviceCredential: slot.store,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'shared-device-adopt' });
|
|
121
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith(SHARED_CRED.deviceId, SHARED_CRED.deviceSecret);
|
|
122
|
+
expect(setTokens).toHaveBeenCalledWith(MINT.accessToken);
|
|
123
|
+
// The adopted credential is now this app's own, so the next boot takes the
|
|
124
|
+
// faster `device-secret-mint` lane.
|
|
125
|
+
expect(await store.load()).toMatchObject({
|
|
126
|
+
deviceId: SHARED_CRED.deviceId,
|
|
127
|
+
deviceSecret: SHARED_CRED.deviceSecret,
|
|
128
|
+
sessionId: 'sess-shared',
|
|
129
|
+
userId: 'user-shared',
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the identity key is never touched when the shared credential works', async () => {
|
|
134
|
+
// The whole point of the separation: an ordinary app joins the device session
|
|
135
|
+
// without ever asking for the key that signs identity approvals.
|
|
136
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
137
|
+
const { oxy, signInWithSharedIdentity } = makeOxy();
|
|
138
|
+
|
|
139
|
+
await runSessionColdBoot({
|
|
140
|
+
oxy,
|
|
141
|
+
store: createMemoryAuthStateStore(),
|
|
142
|
+
platform: NATIVE,
|
|
143
|
+
sharedDeviceCredential: slot.store,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('an app that already holds its own credential is not moved onto the shared one', async () => {
|
|
150
|
+
// Upgrade order must not sign anyone out. This app's own session wins.
|
|
151
|
+
const store = createMemoryAuthStateStore();
|
|
152
|
+
await store.save({ sessionId: 'sess-own', userId: 'user-own', ...OWN_CRED });
|
|
153
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
154
|
+
const { oxy, mintFromDeviceSecret } = makeOxy({
|
|
155
|
+
mintFromDeviceSecret: (async () => ({
|
|
156
|
+
...MINT,
|
|
157
|
+
nextDeviceSecret: OWN_CRED.deviceSecret,
|
|
158
|
+
state: { ...MINT.state, deviceId: OWN_CRED.deviceId },
|
|
159
|
+
})) as unknown as OxyServices['mintFromDeviceSecret'],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const outcome = await runSessionColdBoot({
|
|
163
|
+
oxy,
|
|
164
|
+
store,
|
|
165
|
+
platform: NATIVE,
|
|
166
|
+
sharedDeviceCredential: slot.store,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
|
|
170
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith(OWN_CRED.deviceId, OWN_CRED.deviceSecret);
|
|
171
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalledWith(SHARED_CRED.deviceId, SHARED_CRED.deviceSecret);
|
|
172
|
+
expect(await store.load()).toMatchObject({ deviceId: OWN_CRED.deviceId });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('an UNREADABLE shared slot is not treated as a fresh device', async () => {
|
|
176
|
+
// A locked keystore must never authorise anything. The lane skips, the store
|
|
177
|
+
// is untouched, and the legacy identity lane still gets its turn — which is
|
|
178
|
+
// what keeps a momentarily-locked device from silently onboarding again.
|
|
179
|
+
const store = createMemoryAuthStateStore();
|
|
180
|
+
const slot = makeSharedSlot({ state: 'unavailable', cause: new Error('keystore locked') });
|
|
181
|
+
const sharedKeySession = {
|
|
182
|
+
sessionId: 'sess-legacy',
|
|
183
|
+
user: { id: 'user-legacy' },
|
|
184
|
+
accessToken: 'access-legacy',
|
|
185
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
186
|
+
deviceId: 'dev-legacy',
|
|
187
|
+
deviceSecret: 'ds-legacy',
|
|
188
|
+
} as unknown as SessionLoginResponse;
|
|
189
|
+
const { oxy, signInWithSharedIdentity } = makeOxy({
|
|
190
|
+
signInWithSharedIdentity: (async () => sharedKeySession) as unknown as OxyServices['signInWithSharedIdentity'],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const outcome = await runSessionColdBoot({
|
|
194
|
+
oxy,
|
|
195
|
+
store,
|
|
196
|
+
platform: NATIVE,
|
|
197
|
+
sharedDeviceCredential: slot.store,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(slot.publish).not.toHaveBeenCalled();
|
|
201
|
+
expect(slot.clear).not.toHaveBeenCalled();
|
|
202
|
+
expect(signInWithSharedIdentity).toHaveBeenCalled();
|
|
203
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'shared-key-signin' });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('an empty shared slot falls through to the legacy identity lane', async () => {
|
|
207
|
+
const slot = makeSharedSlot({ state: 'absent' });
|
|
208
|
+
const { oxy, signInWithSharedIdentity } = makeOxy();
|
|
209
|
+
|
|
210
|
+
const outcome = await runSessionColdBoot({
|
|
211
|
+
oxy,
|
|
212
|
+
store: createMemoryAuthStateStore(),
|
|
213
|
+
platform: NATIVE,
|
|
214
|
+
sharedDeviceCredential: slot.store,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(signInWithSharedIdentity).toHaveBeenCalled();
|
|
218
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('a rejected shared credential is reverted locally and cleared from the slot', async () => {
|
|
222
|
+
// `invalid_device_secret` is the one positive proof that the exact shared
|
|
223
|
+
// bytes are dead. Reverting keeps this app where it was; clearing the slot is
|
|
224
|
+
// what stops a dead credential from blocking every future install.
|
|
225
|
+
const store = createMemoryAuthStateStore();
|
|
226
|
+
const before: PersistedAuthState = { sessionId: 'sess-stale', userId: 'user-stale' };
|
|
227
|
+
await store.save(before);
|
|
228
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
229
|
+
const { oxy } = makeOxy({
|
|
230
|
+
mintFromDeviceSecret: (async () => {
|
|
231
|
+
throw mint401('invalid_device_secret');
|
|
232
|
+
}) as unknown as OxyServices['mintFromDeviceSecret'],
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const outcome = await runSessionColdBoot({
|
|
236
|
+
oxy,
|
|
237
|
+
store,
|
|
238
|
+
platform: NATIVE,
|
|
239
|
+
sharedDeviceCredential: slot.store,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
243
|
+
expect(slot.clear).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(await store.load()).toEqual(before);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('a transient mint failure reverts without clearing the shared slot', async () => {
|
|
248
|
+
// A network blip says nothing about the credential. Wiping the device-wide
|
|
249
|
+
// join point on an ambiguous failure is the deploy-window bug, one layer up.
|
|
250
|
+
const store = createMemoryAuthStateStore();
|
|
251
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
252
|
+
const { oxy } = makeOxy({
|
|
253
|
+
mintFromDeviceSecret: (async () => {
|
|
254
|
+
throw new Error('network down');
|
|
255
|
+
}) as unknown as OxyServices['mintFromDeviceSecret'],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await runSessionColdBoot({ oxy, store, platform: NATIVE, sharedDeviceCredential: slot.store });
|
|
259
|
+
|
|
260
|
+
expect(slot.clear).not.toHaveBeenCalled();
|
|
261
|
+
expect(await store.load()).toBeNull();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('the lane does not run on web', async () => {
|
|
265
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
266
|
+
const { oxy, mintFromDeviceSecret } = makeOxy();
|
|
267
|
+
|
|
268
|
+
const outcome = await runSessionColdBoot({
|
|
269
|
+
oxy,
|
|
270
|
+
store: createMemoryAuthStateStore(),
|
|
271
|
+
platform: WEB,
|
|
272
|
+
sharedDeviceCredential: slot.store,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(slot.read).not.toHaveBeenCalled();
|
|
276
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
277
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('the lane does not run when the device reports itself offline', async () => {
|
|
281
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
282
|
+
const { oxy } = makeOxy();
|
|
283
|
+
|
|
284
|
+
await runSessionColdBoot({
|
|
285
|
+
oxy,
|
|
286
|
+
store: createMemoryAuthStateStore(),
|
|
287
|
+
platform: NATIVE,
|
|
288
|
+
isOffline: () => true,
|
|
289
|
+
sharedDeviceCredential: slot.store,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(slot.read).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('identity mode never reads the shared slot', async () => {
|
|
296
|
+
// The slot belongs to whichever principal signed in on this device. An
|
|
297
|
+
// identity-bound client resolves its session from the local key alone —
|
|
298
|
+
// adopting a device credential is the drift that mode exists to prevent.
|
|
299
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
300
|
+
const { oxy } = makeOxy();
|
|
301
|
+
|
|
302
|
+
await runSessionColdBoot({
|
|
303
|
+
oxy,
|
|
304
|
+
store: createMemoryAuthStateStore(),
|
|
305
|
+
platform: NATIVE,
|
|
306
|
+
sessionMode: 'identity',
|
|
307
|
+
identity: makeIdentityBinding(),
|
|
308
|
+
sharedDeviceCredential: slot.store,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(slot.read).not.toHaveBeenCalled();
|
|
312
|
+
expect(slot.publish).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('omitting the slot leaves the boot chain exactly as it was', async () => {
|
|
316
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
317
|
+
const { oxy, signInWithSharedIdentity } = makeOxy();
|
|
318
|
+
|
|
319
|
+
const outcome = await runSessionColdBoot({ oxy, store: createMemoryAuthStateStore(), platform: NATIVE });
|
|
320
|
+
|
|
321
|
+
expect(slot.read).not.toHaveBeenCalled();
|
|
322
|
+
expect(signInWithSharedIdentity).toHaveBeenCalled();
|
|
323
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -15,10 +15,17 @@
|
|
|
15
15
|
* origin persisted a `deviceId` + `deviceSecret`, mint a short access token
|
|
16
16
|
* with a single bearer-less POST to `/session/device/token` (no cookie, no
|
|
17
17
|
* navigation) and rotate the secret in-use.
|
|
18
|
-
* 3. `shared-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* 3. `shared-device-adopt` (native, ACCOUNT mode) — this app has no credential
|
|
19
|
+
* of its own but a sibling official app already put one in the shared native
|
|
20
|
+
* slot: adopt it and mint. This is how a newly installed official app joins
|
|
21
|
+
* the device's existing session WITHOUT another QR and without ever touching
|
|
22
|
+
* the Commons private key.
|
|
23
|
+
* 4. `shared-key-signin` (native, ACCOUNT mode) — the legacy lane: re-mint by
|
|
24
|
+
* signing with the shared-keychain IDENTITY key. Retained as a recovery /
|
|
25
|
+
* compatibility path for devices whose apps have not yet published a shared
|
|
26
|
+
* device credential — OR `identity-key-signin` (IDENTITY mode) — re-mint
|
|
27
|
+
* from THIS device's primary identity key.
|
|
28
|
+
* 5. Signed out.
|
|
22
29
|
*
|
|
23
30
|
* Two session modes (see {@link RunSessionColdBootOptions.sessionMode}):
|
|
24
31
|
* - `account` (default) — the device's ACTIVE account owns the session. Every
|
|
@@ -42,8 +49,12 @@ import {
|
|
|
42
49
|
type IdentityBinding,
|
|
43
50
|
} from '../session/identitySession';
|
|
44
51
|
import type { IdentityPin } from '../session/identityPin';
|
|
52
|
+
import {
|
|
53
|
+
decideSharedDeviceJoin,
|
|
54
|
+
type SharedDeviceCredentialStore,
|
|
55
|
+
} from '../session/sharedDeviceCredential';
|
|
45
56
|
import type { OxyServices } from '../OxyServices';
|
|
46
|
-
import type { AuthStateStore } from '../session/authStateStore';
|
|
57
|
+
import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
|
|
47
58
|
|
|
48
59
|
/**
|
|
49
60
|
* Who owns the session this boot resolves.
|
|
@@ -116,6 +127,18 @@ export interface RunSessionColdBootOptions {
|
|
|
116
127
|
* `sessionMode: 'identity'`. Ignored in `'account'` mode.
|
|
117
128
|
*/
|
|
118
129
|
identity?: IdentityBinding;
|
|
130
|
+
/**
|
|
131
|
+
* The cross-app native slot holding this device's shared DeviceSession
|
|
132
|
+
* credential, enabling the `shared-device-adopt` lane. Supplied by
|
|
133
|
+
* `@oxyhq/services` on native; absent on web, where each origin is its own
|
|
134
|
+
* device by design.
|
|
135
|
+
*
|
|
136
|
+
* IGNORED in `sessionMode: 'identity'`. The shared slot belongs to whichever
|
|
137
|
+
* principal signed in on this device; an identity-bound client must resolve
|
|
138
|
+
* its session from the local key alone, and adopting a device credential is
|
|
139
|
+
* exactly the drift that mode exists to prevent.
|
|
140
|
+
*/
|
|
141
|
+
sharedDeviceCredential?: SharedDeviceCredentialStore;
|
|
119
142
|
}
|
|
120
143
|
|
|
121
144
|
/**
|
|
@@ -342,10 +365,111 @@ export async function runSessionColdBoot(
|
|
|
342
365
|
};
|
|
343
366
|
},
|
|
344
367
|
});
|
|
345
|
-
} else {
|
|
346
|
-
// 3. shared-
|
|
347
|
-
//
|
|
348
|
-
//
|
|
368
|
+
} else if (opts.sharedDeviceCredential) {
|
|
369
|
+
// 3. shared-device-adopt (native, ACCOUNT mode) — join the device's existing
|
|
370
|
+
// session through the shared native credential slot.
|
|
371
|
+
//
|
|
372
|
+
// This is the lane that separates identity from session transport. What it
|
|
373
|
+
// reads is an ordinary, individually revocable `deviceId` + `deviceSecret`
|
|
374
|
+
// put there by a sibling official app — never the Commons private key. It
|
|
375
|
+
// is what lets a freshly installed official app land signed in with no QR,
|
|
376
|
+
// and it is why an ordinary app never needs identity-key access at all.
|
|
377
|
+
//
|
|
378
|
+
// `decideSharedDeviceJoin` gates it: an app that already holds its own
|
|
379
|
+
// credential is never moved, and an UNREADABLE slot is never mistaken for
|
|
380
|
+
// an empty one. The lane therefore cannot sign anyone out, in either
|
|
381
|
+
// upgrade order.
|
|
382
|
+
const sharedSlot = opts.sharedDeviceCredential;
|
|
383
|
+
steps.push({
|
|
384
|
+
id: 'shared-device-adopt',
|
|
385
|
+
// The adoption itself is local, but it is only worth committing alongside
|
|
386
|
+
// a mint that proves the credential — so the whole lane is online-gated
|
|
387
|
+
// like every other network step.
|
|
388
|
+
enabled: () => isNative && !isOffline(),
|
|
389
|
+
run: async () => {
|
|
390
|
+
const before = await store.load();
|
|
391
|
+
const decision = decideSharedDeviceJoin(before, await sharedSlot.read());
|
|
392
|
+
if (decision.action === 'skip') {
|
|
393
|
+
logger.debug(
|
|
394
|
+
`shared device credential not adopted (${decision.reason})`,
|
|
395
|
+
{ component: 'sessionColdBoot', method: 'shared-device-adopt' },
|
|
396
|
+
);
|
|
397
|
+
return { kind: 'skip' };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Restore the store to exactly what it held before this lane touched it.
|
|
401
|
+
// A credential we adopted and could not prove must not be left behind for
|
|
402
|
+
// the next boot's mint lane to keep retrying.
|
|
403
|
+
const revert = async (): Promise<void> => {
|
|
404
|
+
if (before) {
|
|
405
|
+
await store.save(before);
|
|
406
|
+
} else {
|
|
407
|
+
await store.clear();
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const adopted: PersistedAuthState = {
|
|
412
|
+
// The mint fills both in from the device's live state; carrying the
|
|
413
|
+
// previous session's ids into a different device session would be a
|
|
414
|
+
// lie for however long the mint takes.
|
|
415
|
+
sessionId: '',
|
|
416
|
+
userId: '',
|
|
417
|
+
deviceId: decision.credential.deviceId,
|
|
418
|
+
deviceSecret: decision.credential.deviceSecret,
|
|
419
|
+
};
|
|
420
|
+
if (!(await store.save(adopted))) {
|
|
421
|
+
logger.error(
|
|
422
|
+
'adopted the shared device credential but it could not be durably persisted — reverting',
|
|
423
|
+
undefined,
|
|
424
|
+
{ component: 'sessionColdBoot', method: 'shared-device-adopt' },
|
|
425
|
+
);
|
|
426
|
+
await revert();
|
|
427
|
+
return { kind: 'skip' };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const result = await refreshDeviceSecretArm({ oxy, store, pin: null });
|
|
431
|
+
if (result.status === 'ok') {
|
|
432
|
+
return {
|
|
433
|
+
kind: 'session',
|
|
434
|
+
session: {
|
|
435
|
+
sessionId: result.sessionId,
|
|
436
|
+
userId: result.userId,
|
|
437
|
+
accessToken: result.token,
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (result.status === 'invalid-secret') {
|
|
443
|
+
// The one place we hold POSITIVE proof that the exact bytes in the
|
|
444
|
+
// shared slot are dead — the server rejected them by name. Clearing it
|
|
445
|
+
// signs nobody out (a credential the server does not recognise cannot
|
|
446
|
+
// be minting for anyone) and it is what stops a dead credential from
|
|
447
|
+
// blocking every future install: a stale slot owned by a different
|
|
448
|
+
// `deviceId` is otherwise never overwritten, by design.
|
|
449
|
+
await sharedSlot.clear();
|
|
450
|
+
} else if (result.status === 'no-session') {
|
|
451
|
+
signedOutReason = 'no_session';
|
|
452
|
+
}
|
|
453
|
+
await revert();
|
|
454
|
+
return { kind: 'skip' };
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (identityBinding === null) {
|
|
460
|
+
// 4. shared-key-signin (native) — the RECOVERY / COMPATIBILITY lane: sign a
|
|
461
|
+
// challenge with the shared-keychain IDENTITY key to re-mint a session.
|
|
462
|
+
//
|
|
463
|
+
// It runs LAST on purpose. Using the self-custody key to obtain an ordinary
|
|
464
|
+
// session is the over-sharing #937 sets out to end, so it is now reachable
|
|
465
|
+
// only on a device where no sibling app has published a shared device
|
|
466
|
+
// credential yet — an install that predates this lane, or one where the
|
|
467
|
+
// shared slot is unreadable. Its own `store.save` below feeds the shared
|
|
468
|
+
// slot through the mirroring store, so the FIRST boot that takes this lane
|
|
469
|
+
// is also the last one that needs to: every later app joins by credential.
|
|
470
|
+
//
|
|
471
|
+
// Native AND online: it is a network step (challenge + verify round-trips),
|
|
472
|
+
// so it is gated by the same offline hint as the mint lane. `{ retry: false }`
|
|
349
473
|
// keeps the two round-trips as single attempts — the refresh scheduler /
|
|
350
474
|
// 401 lane own later retries — so this step cannot multiply boot latency.
|
|
351
475
|
steps.push({
|
|
@@ -396,13 +396,22 @@
|
|
|
396
396
|
"remoteSignOutFailed": "There was a problem signing out from the device. Please try again.",
|
|
397
397
|
"noOtherDeviceSessions": "No other device sessions found to sign out from.",
|
|
398
398
|
"signOutOthersSuccess": "Signed out from all other devices successfully!",
|
|
399
|
-
"signOutOthersFailed": "There was a problem signing out from other devices. Please try again."
|
|
399
|
+
"signOutOthersFailed": "There was a problem signing out from other devices. Please try again.",
|
|
400
|
+
"contextRemoved": "Removed {{account}}",
|
|
401
|
+
"contextRemoveFailed": "There was a problem removing that account. Please try again.",
|
|
402
|
+
"principalRemoved": "Signed {{name}} out of this device",
|
|
403
|
+
"principalRemoveFailed": "There was a problem signing that person out. Please try again.",
|
|
404
|
+
"activateFailed": "There was a problem switching accounts. Please try again."
|
|
400
405
|
},
|
|
401
406
|
"confirms": {
|
|
402
407
|
"remove": "Are you sure you want to remove {{displayName}} from this device? You'll need to sign in again to access this account.",
|
|
403
408
|
"logoutAll": "Are you sure you want to sign out of all accounts? This will remove all saved accounts from this device.",
|
|
404
409
|
"remoteLogout": "Are you sure you want to sign out from \"{{deviceName}}\"? This will end the session on that device.",
|
|
405
|
-
"logoutOthers": "Are you sure you want to sign out from all {{count}} other device(s)? This will end sessions on all other devices except this one."
|
|
410
|
+
"logoutOthers": "Are you sure you want to sign out from all {{count}} other device(s)? This will end sessions on all other devices except this one.",
|
|
411
|
+
"removeContextTitle": "Remove this account?",
|
|
412
|
+
"removeContext": "Stop {{person}} acting as {{account}} on this device? Anyone else who reaches {{account}} here keeps their access.",
|
|
413
|
+
"removePrincipalTitle": "Sign out of this device?",
|
|
414
|
+
"removePrincipal": "Sign {{name}} out of this device? Every account they reach here is removed. Nobody else is affected."
|
|
406
415
|
},
|
|
407
416
|
"device": {
|
|
408
417
|
"loadingTitle": "Loading device sessions...",
|
|
@@ -439,6 +448,14 @@
|
|
|
439
448
|
"openedInCommons": "Opened in Commons",
|
|
440
449
|
"confirming": "Confirming identity",
|
|
441
450
|
"confirmed": "Identity confirmed"
|
|
451
|
+
},
|
|
452
|
+
"context": {
|
|
453
|
+
"unavailable": "Unavailable right now",
|
|
454
|
+
"operatedBy": "Operated by {{name}}",
|
|
455
|
+
"remove": "Remove {{account}} from {{person}}"
|
|
456
|
+
},
|
|
457
|
+
"principal": {
|
|
458
|
+
"signOut": "Sign {{name}} out of this device"
|
|
442
459
|
}
|
|
443
460
|
},
|
|
444
461
|
"reputation": {
|
|
@@ -2119,8 +2136,13 @@
|
|
|
2119
2136
|
"filesWrite": "Upload and modify your files",
|
|
2120
2137
|
"filesDelete": "Delete your files",
|
|
2121
2138
|
"webhooksReceive": "Receive webhooks",
|
|
2122
|
-
"
|
|
2123
|
-
"
|
|
2139
|
+
"inferenceInvoke": "Run AI requests on your behalf",
|
|
2140
|
+
"inferenceModelsRead": "List available AI models",
|
|
2141
|
+
"inferenceUsageRead": "Read its AI usage and costs",
|
|
2142
|
+
"inferenceRoutingRead": "Read how AI requests are routed",
|
|
2143
|
+
"inferenceRoutingWrite": "Change how AI requests are routed",
|
|
2144
|
+
"inferenceProvidersRead": "Read its connected AI providers",
|
|
2145
|
+
"inferenceProvidersWrite": "Manage its connected AI providers",
|
|
2124
2146
|
"federationWrite": "Act across federated services"
|
|
2125
2147
|
},
|
|
2126
2148
|
"account": {
|