@oxyhq/core 12.5.4 → 12.6.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +4 -1
- package/dist/cjs/OxyServices.errors.js +42 -1
- package/dist/cjs/OxyServices.js +2 -1
- package/dist/cjs/index.js +5 -4
- package/dist/cjs/mixins/OxyServices.assets.js +175 -25
- package/dist/cjs/session/SessionClient.js +57 -8
- package/dist/cjs/utils/redactUrl.js +29 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +4 -1
- package/dist/esm/OxyServices.errors.js +40 -0
- package/dist/esm/OxyServices.js +2 -2
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.assets.js +175 -25
- package/dist/esm/session/SessionClient.js +57 -8
- package/dist/esm/utils/redactUrl.js +26 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/OxyServices.errors.d.ts +40 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +103 -13
- package/dist/types/models/interfaces.d.ts +18 -0
- package/dist/types/session/SessionClient.d.ts +19 -2
- package/dist/types/utils/redactUrl.d.ts +17 -0
- package/package.json +1 -1
- package/src/HttpService.ts +4 -1
- package/src/OxyServices.errors.ts +51 -0
- package/src/OxyServices.ts +2 -2
- package/src/index.ts +3 -1
- package/src/mixins/OxyServices.assets.ts +192 -28
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +265 -1
- package/src/models/interfaces.ts +20 -0
- package/src/session/SessionClient.ts +59 -8
- package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
- package/src/utils/__tests__/redactUrl.test.ts +33 -0
- package/src/utils/redactUrl.ts +28 -0
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type DeviceSessionState,
|
|
8
8
|
} from '@oxyhq/contracts';
|
|
9
9
|
import { logger } from '../logger';
|
|
10
|
+
import { computeIdentityTag } from '../utils/cacheKey';
|
|
10
11
|
import { getSocketIO } from './socketLoader';
|
|
11
12
|
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
12
13
|
|
|
@@ -172,8 +173,25 @@ export class SessionClient {
|
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
/**
|
|
176
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Validate + last-writer-wins by revision. Returns true if applied.
|
|
178
|
+
*
|
|
179
|
+
* `activeToken` (sync path only) is the server-issued access token for
|
|
180
|
+
* `raw.activeAccountId`. When present and the state is applied, it is planted
|
|
181
|
+
* BEFORE any subscriber is notified so the bearer already belongs to the new
|
|
182
|
+
* active account — the local switch/bootstrap path then needs no redundant
|
|
183
|
+
* device-secret mint. Push-origin applies carry no token and rely on the
|
|
184
|
+
* mint-before-notify gate below.
|
|
185
|
+
*
|
|
186
|
+
* ORDERING INVARIANT: a subscriber must NEVER observe a newly-active account
|
|
187
|
+
* while the planted bearer still identifies the PREVIOUS one — otherwise a
|
|
188
|
+
* `useCurrentUser`-style refetch fires under the wrong account's token (the
|
|
189
|
+
* account-switch 404 race). So when a transport is available and the planted
|
|
190
|
+
* bearer does not already belong to `next.activeAccountId`, minting is awaited
|
|
191
|
+
* BEFORE `notify()`. This covers EVERY notify source (a switch push, a
|
|
192
|
+
* cross-device push, a cold mint), not just the initial "no bearer yet" case.
|
|
193
|
+
*/
|
|
194
|
+
protected applyState(raw: unknown, origin: SessionStateOrigin = 'push', activeToken?: string): boolean {
|
|
177
195
|
const next = safeParseContract(deviceSessionStateSchema, raw);
|
|
178
196
|
if (!next) {
|
|
179
197
|
logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -192,10 +210,27 @@ export class SessionClient {
|
|
|
192
210
|
) {
|
|
193
211
|
return false;
|
|
194
212
|
}
|
|
213
|
+
const previousState = this.state;
|
|
195
214
|
this.state = next;
|
|
215
|
+
// Plant the sync-supplied active token (it is for `next.activeAccountId`)
|
|
216
|
+
// now — before the notify below — so the bearer matches the new active
|
|
217
|
+
// account when subscribers observe it. Guarded on difference to avoid a
|
|
218
|
+
// redundant token-change notification on an unchanged token (bootstrap
|
|
219
|
+
// restate).
|
|
220
|
+
if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
|
|
221
|
+
this.host.setTokens(activeToken);
|
|
222
|
+
}
|
|
196
223
|
const transport = this.options.transport;
|
|
224
|
+
const activeAccountId = next.activeAccountId;
|
|
225
|
+
// Mint before notifying when the bearer does not already belong to the new
|
|
226
|
+
// active account: no bearer at all, an opaque bearer, OR a bearer for a
|
|
227
|
+
// DIFFERENT account. `computeIdentityTag` yields the token's `userId`/`id`
|
|
228
|
+
// for a real JWT (comparable to the account id) and a non-account sentinel
|
|
229
|
+
// otherwise, so a mismatch always resolves to "mint".
|
|
197
230
|
const needsMintBeforeNotify =
|
|
198
|
-
transport != null &&
|
|
231
|
+
transport != null &&
|
|
232
|
+
next.accounts.length > 0 &&
|
|
233
|
+
(activeAccountId === null || computeIdentityTag(this.host.getAccessToken()) !== activeAccountId);
|
|
199
234
|
|
|
200
235
|
const finishApply = (): void => {
|
|
201
236
|
this.notify();
|
|
@@ -210,8 +245,10 @@ export class SessionClient {
|
|
|
210
245
|
|
|
211
246
|
if (needsMintBeforeNotify) {
|
|
212
247
|
void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
|
|
213
|
-
logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
|
|
214
|
-
|
|
248
|
+
logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
|
|
249
|
+
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
250
|
+
// state so subscribers keep observing the account whose token is planted.
|
|
251
|
+
this.state = previousState ?? null;
|
|
215
252
|
});
|
|
216
253
|
} else {
|
|
217
254
|
if (transport) {
|
|
@@ -251,9 +288,23 @@ export class SessionClient {
|
|
|
251
288
|
}
|
|
252
289
|
// A `sync` is always the response to a direct REST call this client made
|
|
253
290
|
// (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
|
|
254
|
-
// verdict.
|
|
255
|
-
|
|
256
|
-
|
|
291
|
+
// verdict. Hand the active token to `applyState`: in the applied path it is
|
|
292
|
+
// planted BEFORE notify (bearer matches the new active account when
|
|
293
|
+
// subscribers observe it, and no redundant device-secret mint is triggered).
|
|
294
|
+
const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
|
|
295
|
+
// Equal-revision restate (this revision was already applied by a preceding
|
|
296
|
+
// socket push): `applyState` no-ops without planting, but the token still
|
|
297
|
+
// needs planting. Guard on the sync's active account STILL being the current
|
|
298
|
+
// active account so a stale response cannot adopt a token for an account a
|
|
299
|
+
// newer state already switched away from.
|
|
300
|
+
if (
|
|
301
|
+
!applied &&
|
|
302
|
+
sync.activeToken &&
|
|
303
|
+
this.state &&
|
|
304
|
+
sync.state.activeAccountId !== null &&
|
|
305
|
+
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
306
|
+
sync.activeToken.accessToken !== this.host.getAccessToken()
|
|
307
|
+
) {
|
|
257
308
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
258
309
|
}
|
|
259
310
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import { SessionClient, type SessionClientHost, type TokenTransport } from '../SessionClient';
|
|
3
|
+
import { computeIdentityTag } from '../../utils/cacheKey';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The account-switch 404 race (regression guard).
|
|
7
|
+
*
|
|
8
|
+
* When a `session_state` push re-elects the active account from A to B, the
|
|
9
|
+
* push carries NO token — the app still holds A's bearer. If a subscriber were
|
|
10
|
+
* notified before B's bearer is planted, a `useCurrentUser`-style refetch would
|
|
11
|
+
* fire under A's token against B's session and 404.
|
|
12
|
+
*
|
|
13
|
+
* INVARIANT: no subscriber is ever notified while the planted bearer identifies
|
|
14
|
+
* an account OTHER than the observed active account. This test records, at every
|
|
15
|
+
* notify, the observed active account alongside the account the CURRENT bearer
|
|
16
|
+
* belongs to (via the same `computeIdentityTag` derivation `applyState` uses)
|
|
17
|
+
* and asserts they always match — and that the switch notify is DEFERRED until
|
|
18
|
+
* the mint lands B's token.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A minimal jwt-decode-able token whose `userId` claim is `accountId`. */
|
|
22
|
+
function jwtFor(accountId: string): string {
|
|
23
|
+
const payload = Buffer.from(JSON.stringify({ userId: accountId })).toString('base64url');
|
|
24
|
+
return `h.${payload}.s`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const stateWith = (rev: number, active: string): DeviceSessionState => ({
|
|
28
|
+
deviceId: 'd1',
|
|
29
|
+
accounts: [
|
|
30
|
+
{ accountId: 'a1', sessionId: 's-a1', authuser: 0 },
|
|
31
|
+
{ accountId: 'b1', sessionId: 's-b1', authuser: 1 },
|
|
32
|
+
],
|
|
33
|
+
activeAccountId: active,
|
|
34
|
+
revision: rev,
|
|
35
|
+
updatedAt: 1720000000000,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
class TestClient extends SessionClient {
|
|
39
|
+
public apply(raw: unknown): boolean {
|
|
40
|
+
return this.applyState(raw);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('SessionClient — no notify under a mismatched bearer on an account switch', () => {
|
|
45
|
+
it('defers the switch notify until the mint lands the new active account bearer', async () => {
|
|
46
|
+
// Mutable planted bearer, starting on account A.
|
|
47
|
+
let planted: string | null = jwtFor('a1');
|
|
48
|
+
const host: SessionClientHost = {
|
|
49
|
+
makeRequest: jest.fn(),
|
|
50
|
+
getBaseURL: () => 'http://test.invalid',
|
|
51
|
+
getAccessToken: () => planted,
|
|
52
|
+
getDeviceCredential: () => null,
|
|
53
|
+
onTokensChanged: () => () => undefined,
|
|
54
|
+
setTokens: (token) => {
|
|
55
|
+
planted = token;
|
|
56
|
+
},
|
|
57
|
+
getCurrentAccountId: () => null,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// The mint lands the ACTIVE account's bearer, asynchronously (models the
|
|
61
|
+
// real device-secret mint round trip).
|
|
62
|
+
const transport: TokenTransport = {
|
|
63
|
+
ensureActiveToken: jest.fn(async (state: DeviceSessionState) => {
|
|
64
|
+
await Promise.resolve();
|
|
65
|
+
if (state.activeAccountId) {
|
|
66
|
+
planted = jwtFor(state.activeAccountId);
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const c = new TestClient(host, { transport });
|
|
72
|
+
|
|
73
|
+
const observations: Array<{ active: string | null; bearer: string }> = [];
|
|
74
|
+
c.subscribe((s) => {
|
|
75
|
+
observations.push({
|
|
76
|
+
active: s?.activeAccountId ?? null,
|
|
77
|
+
bearer: computeIdentityTag(host.getAccessToken()),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Apply A (bearer already A's) → matches → synchronous notify.
|
|
82
|
+
c.apply(stateWith(1, 'a1'));
|
|
83
|
+
expect(observations).toEqual([{ active: 'a1', bearer: 'a1' }]);
|
|
84
|
+
|
|
85
|
+
// A `session_state` push re-elects B while the bearer is still A's.
|
|
86
|
+
c.apply(stateWith(2, 'b1'));
|
|
87
|
+
// The switch notify MUST NOT have fired yet — the bearer is still A's, so a
|
|
88
|
+
// synchronous notify would let a subscriber observe B under A's token.
|
|
89
|
+
expect(observations).toEqual([{ active: 'a1', bearer: 'a1' }]);
|
|
90
|
+
|
|
91
|
+
// Flush the mint + deferred notify.
|
|
92
|
+
for (let i = 0; i < 5; i++) {
|
|
93
|
+
await Promise.resolve();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The mint ran, and the switch notify fired only AFTER B's bearer was planted.
|
|
97
|
+
expect(transport.ensureActiveToken).toHaveBeenCalledWith(
|
|
98
|
+
expect.objectContaining({ activeAccountId: 'b1' }),
|
|
99
|
+
);
|
|
100
|
+
expect(observations).toContainEqual({ active: 'b1', bearer: 'b1' });
|
|
101
|
+
|
|
102
|
+
// At NO notify did the observed active account differ from the bearer's account.
|
|
103
|
+
for (const o of observations) {
|
|
104
|
+
expect(o.bearer).toBe(o.active);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not defer when the bearer already belongs to the new active account', () => {
|
|
109
|
+
let planted: string | null = jwtFor('b1');
|
|
110
|
+
const host: SessionClientHost = {
|
|
111
|
+
makeRequest: jest.fn(),
|
|
112
|
+
getBaseURL: () => 'http://test.invalid',
|
|
113
|
+
getAccessToken: () => planted,
|
|
114
|
+
getDeviceCredential: () => null,
|
|
115
|
+
onTokensChanged: () => () => undefined,
|
|
116
|
+
setTokens: (token) => {
|
|
117
|
+
planted = token;
|
|
118
|
+
},
|
|
119
|
+
getCurrentAccountId: () => null,
|
|
120
|
+
};
|
|
121
|
+
const transport: TokenTransport = { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
|
|
122
|
+
const c = new TestClient(host, { transport });
|
|
123
|
+
|
|
124
|
+
const seen: Array<string | null> = [];
|
|
125
|
+
c.subscribe((s) => seen.push(s?.activeAccountId ?? null));
|
|
126
|
+
|
|
127
|
+
// Bearer is already B's → the notify is synchronous (no mint-before-notify).
|
|
128
|
+
c.apply(stateWith(3, 'b1'));
|
|
129
|
+
expect(seen).toEqual(['b1']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('reverts state and does not notify when minting fails on an account switch', async () => {
|
|
133
|
+
let planted: string | null = jwtFor('a1');
|
|
134
|
+
const host: SessionClientHost = {
|
|
135
|
+
makeRequest: jest.fn(),
|
|
136
|
+
getBaseURL: () => 'http://test.invalid',
|
|
137
|
+
getAccessToken: () => planted,
|
|
138
|
+
getDeviceCredential: () => null,
|
|
139
|
+
onTokensChanged: () => () => undefined,
|
|
140
|
+
setTokens: (token) => {
|
|
141
|
+
planted = token;
|
|
142
|
+
},
|
|
143
|
+
getCurrentAccountId: () => null,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const transport: TokenTransport = {
|
|
147
|
+
ensureActiveToken: jest.fn(async () => {
|
|
148
|
+
await Promise.resolve();
|
|
149
|
+
throw new Error('mint failed');
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const c = new TestClient(host, { transport });
|
|
154
|
+
const seen: Array<string | null> = [];
|
|
155
|
+
c.subscribe((s) => seen.push(s?.activeAccountId ?? null));
|
|
156
|
+
|
|
157
|
+
c.apply(stateWith(1, 'a1'));
|
|
158
|
+
expect(seen).toEqual(['a1']);
|
|
159
|
+
|
|
160
|
+
c.apply(stateWith(2, 'b1'));
|
|
161
|
+
for (let i = 0; i < 5; i++) {
|
|
162
|
+
await Promise.resolve();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Still on A — no notify under A's bearer for B's active account.
|
|
166
|
+
expect(seen).toEqual(['a1']);
|
|
167
|
+
expect(c.getState()?.activeAccountId).toBe('a1');
|
|
168
|
+
expect(computeIdentityTag(host.getAccessToken())).toBe('a1');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `redactUrlQuery` tests — the query-string scrubber used before any asset URL
|
|
3
|
+
* reaches a log sink. Asset stream URLs carry a scoped `mt=` media token that
|
|
4
|
+
* is a bearer credential; it must never be logged.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { redactUrlQuery } from '../redactUrl';
|
|
8
|
+
|
|
9
|
+
describe('redactUrlQuery', () => {
|
|
10
|
+
it('strips the query string (including a media token) from an absolute URL', () => {
|
|
11
|
+
const redacted = redactUrlQuery(
|
|
12
|
+
'https://api.oxy.so/assets/priv1/stream?variant=thumb&mt=SECRET-TOKEN',
|
|
13
|
+
);
|
|
14
|
+
expect(redacted).toBe('https://api.oxy.so/assets/priv1/stream?<redacted>');
|
|
15
|
+
expect(redacted).not.toContain('mt=');
|
|
16
|
+
expect(redacted).not.toContain('SECRET-TOKEN');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('strips the query string from a relative path too', () => {
|
|
20
|
+
expect(redactUrlQuery('/assets/priv1/url?expiresIn=600&mt=SECRET')).toBe(
|
|
21
|
+
'/assets/priv1/url?<redacted>',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns a URL without a query string unchanged', () => {
|
|
26
|
+
expect(redactUrlQuery('https://cloud.oxy.so/pub1')).toBe('https://cloud.oxy.so/pub1');
|
|
27
|
+
expect(redactUrlQuery('/assets')).toBe('/assets');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('passes through empty input', () => {
|
|
31
|
+
expect(redactUrlQuery('')).toBe('');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL redaction for logging.
|
|
3
|
+
*
|
|
4
|
+
* Asset URLs the API hands back for private assets carry a scoped, short-lived
|
|
5
|
+
* media token (`mt=…`) in their query string. That token is a bearer credential
|
|
6
|
+
* for the underlying object, so it must never land in a log line, breadcrumb,
|
|
7
|
+
* or metric — a captured log would otherwise grant read access until the token
|
|
8
|
+
* expires. Query strings on API URLs can also carry other sensitive params, so
|
|
9
|
+
* we redact the whole query rather than allow-listing one key.
|
|
10
|
+
*
|
|
11
|
+
* `redactUrlQuery` returns the URL's path portion with a `?<redacted>` marker
|
|
12
|
+
* when a query string is present, and the input unchanged otherwise. It is
|
|
13
|
+
* defensive: any input that does not parse as a URL is passed through as-is,
|
|
14
|
+
* except that a bare `?query` tail is still stripped so a relative path with a
|
|
15
|
+
* query never leaks.
|
|
16
|
+
*/
|
|
17
|
+
export function redactUrlQuery(url: string): string {
|
|
18
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
19
|
+
return url;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const queryIndex = url.indexOf('?');
|
|
23
|
+
if (queryIndex === -1) {
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return `${url.slice(0, queryIndex)}?<redacted>`;
|
|
28
|
+
}
|