@dxos/edge-client 0.13.0 → 0.14.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/lib/chunk-edge-http-client.mjs +47 -24
- package/dist/lib/chunk-edge-http-client.mjs.map +1 -1
- package/dist/lib/index.mjs +60 -39
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/auth-challenge.d.ts +2 -1
- package/dist/types/src/auth-challenge.d.ts.map +1 -1
- package/dist/types/src/base-http-client.d.ts +1 -1
- package/dist/types/src/base-http-client.d.ts.map +1 -1
- package/dist/types/src/client-version.d.ts +15 -0
- package/dist/types/src/client-version.d.ts.map +1 -0
- package/dist/types/src/edge-client.d.ts.map +1 -1
- package/dist/types/src/edge-http-client.d.ts.map +1 -1
- package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -16
- package/src/auth-challenge.test.ts +17 -2
- package/src/auth-challenge.ts +9 -2
- package/src/base-http-client.ts +15 -2
- package/src/client-version.ts +44 -0
- package/src/edge-client.test.ts +28 -2
- package/src/edge-client.ts +25 -3
- package/src/edge-http-client.test.ts +43 -0
- package/src/edge-http-client.ts +1 -4
- package/src/edge-ws-connection.ts +3 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/edge-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "EDGE Client",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -61,23 +61,23 @@
|
|
|
61
61
|
"@bufbuild/protobuf": "2.11.0",
|
|
62
62
|
"isomorphic-ws": "^5.0.0",
|
|
63
63
|
"ws": "^8.17.1",
|
|
64
|
-
"@dxos/async": "0.
|
|
65
|
-
"@dxos/context": "0.
|
|
66
|
-
"@dxos/credentials": "0.
|
|
67
|
-
"@dxos/crypto": "0.
|
|
68
|
-
"@dxos/effect": "0.
|
|
69
|
-
"@dxos/errors": "0.
|
|
70
|
-
"@dxos/invariant": "0.
|
|
71
|
-
"@dxos/keyring": "0.
|
|
72
|
-
"@dxos/keys": "0.
|
|
73
|
-
"@dxos/log": "0.
|
|
74
|
-
"@dxos/node-std": "0.
|
|
75
|
-
"@dxos/protocols": "0.
|
|
76
|
-
"@dxos/tracing": "0.
|
|
77
|
-
"@dxos/util": "0.
|
|
64
|
+
"@dxos/async": "0.14.0",
|
|
65
|
+
"@dxos/context": "0.14.0",
|
|
66
|
+
"@dxos/credentials": "0.14.0",
|
|
67
|
+
"@dxos/crypto": "0.14.0",
|
|
68
|
+
"@dxos/effect": "0.14.0",
|
|
69
|
+
"@dxos/errors": "0.14.0",
|
|
70
|
+
"@dxos/invariant": "0.14.0",
|
|
71
|
+
"@dxos/keyring": "0.14.0",
|
|
72
|
+
"@dxos/keys": "0.14.0",
|
|
73
|
+
"@dxos/log": "0.14.0",
|
|
74
|
+
"@dxos/node-std": "0.14.0",
|
|
75
|
+
"@dxos/protocols": "0.14.0",
|
|
76
|
+
"@dxos/tracing": "0.14.0",
|
|
77
|
+
"@dxos/util": "0.14.0"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
|
-
"@dxos/test-utils": "0.
|
|
80
|
+
"@dxos/test-utils": "0.14.0"
|
|
81
81
|
},
|
|
82
82
|
"peerDependencies": {
|
|
83
83
|
"effect": "4.0.0"
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
import { create } from '@bufbuild/protobuf';
|
|
6
6
|
import { afterEach, describe, test, vi } from 'vitest';
|
|
7
7
|
|
|
8
|
+
import { EDGE_CLIENT_TOO_OLD, EDGE_CLIENT_VERSION_HEADER, EdgeClientTooOldError, EdgeResponse } from '@dxos/protocols';
|
|
8
9
|
import { type Presentation, PresentationSchema } from '@dxos/protocols/buf/dxos/halo/credentials_pb';
|
|
9
10
|
|
|
11
|
+
import { version as packageVersion } from '../package.json';
|
|
10
12
|
import {
|
|
11
13
|
authenticateViaChallengeEndpoint,
|
|
12
14
|
fetchAuthChallenge,
|
|
@@ -135,15 +137,28 @@ describe('fetchAuthChallenge', () => {
|
|
|
135
137
|
vi.unstubAllGlobals();
|
|
136
138
|
});
|
|
137
139
|
|
|
138
|
-
test('GETs /auth relative to the base URL', async ({ expect }) => {
|
|
140
|
+
test('GETs /auth relative to the base URL, advertising the SDK version', async ({ expect }) => {
|
|
139
141
|
// Typed with the input parameter so `mock.calls[0][0]` is a tuple element rather than `never`.
|
|
140
|
-
const fetchMock = vi.fn(async (_input: URL | RequestInfo) =>
|
|
142
|
+
const fetchMock = vi.fn(async (_input: URL | RequestInfo, _init?: RequestInit) =>
|
|
141
143
|
jsonResponse({ success: true, data: { challenge: CHALLENGE } }),
|
|
142
144
|
);
|
|
143
145
|
vi.stubGlobal('fetch', fetchMock);
|
|
144
146
|
|
|
145
147
|
expect(await fetchAuthChallenge('https://edge.example.com')).toBe(CHALLENGE);
|
|
146
148
|
expect(String(fetchMock.mock.calls[0][0])).toBe('https://edge.example.com/auth');
|
|
149
|
+
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get(EDGE_CLIENT_VERSION_HEADER)).toBe(packageVersion);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('throws when EDGE refuses the SDK as too old, rather than falling back', async ({ expect }) => {
|
|
153
|
+
// Every fallback carries the same version, so swallowing the refusal would only retry into it.
|
|
154
|
+
vi.stubGlobal('fetch', async () =>
|
|
155
|
+
EdgeResponse.failure({
|
|
156
|
+
message: 'Client too old: SDK 0.12.0 is older than 0.13.0. Update the app (reload it) to continue.',
|
|
157
|
+
status: 426,
|
|
158
|
+
data: { type: EDGE_CLIENT_TOO_OLD, clientVersion: '0.12.0', minimumVersion: '0.13.0' },
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
await expect(fetchAuthChallenge('https://edge.example.com')).rejects.toBeInstanceOf(EdgeClientTooOldError);
|
|
147
162
|
});
|
|
148
163
|
|
|
149
164
|
test('still works against a server whose /auth only answers 401', async ({ expect }) => {
|
package/src/auth-challenge.ts
CHANGED
|
@@ -7,8 +7,10 @@ import { base64Decode } from '@bufbuild/protobuf/wire';
|
|
|
7
7
|
|
|
8
8
|
import { invariant } from '@dxos/invariant';
|
|
9
9
|
import { log } from '@dxos/log';
|
|
10
|
+
import { EdgeClientTooOldError } from '@dxos/protocols';
|
|
10
11
|
import { PresentationSchema } from '@dxos/protocols/buf/dxos/halo/credentials_pb';
|
|
11
12
|
|
|
13
|
+
import { assertClientSupported, clientVersionHeaders } from './client-version.ts';
|
|
12
14
|
import { type EdgeIdentity } from './edge-identity.ts';
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -165,11 +167,13 @@ export type AuthChallengeInfo = {
|
|
|
165
167
|
* console and records a routine auth failure server-side on each client boot.
|
|
166
168
|
*
|
|
167
169
|
* Returns undefined if the endpoint is unreachable or answers in neither known shape; callers fall
|
|
168
|
-
* back to the 401 path, which still works against every server.
|
|
170
|
+
* back to the 401 path, which still works against every server. Throws {@link EdgeClientTooOldError} when EDGE refuses
|
|
171
|
+
* this SDK, which no fallback can get past.
|
|
169
172
|
*/
|
|
170
173
|
export const fetchAuthChallengeInfo = async (baseHttpUrl: string | URL): Promise<AuthChallengeInfo | undefined> => {
|
|
171
174
|
try {
|
|
172
|
-
const response = await fetch(new URL('/auth', baseHttpUrl));
|
|
175
|
+
const response = await fetch(new URL('/auth', baseHttpUrl), { headers: clientVersionHeaders() });
|
|
176
|
+
await assertClientSupported(response);
|
|
173
177
|
const challenge = await readAuthChallenge(response);
|
|
174
178
|
if (!challenge) {
|
|
175
179
|
log.verbose('no challenge in /auth response', { status: response.status });
|
|
@@ -186,6 +190,9 @@ export const fetchAuthChallengeInfo = async (baseHttpUrl: string | URL): Promise
|
|
|
186
190
|
}
|
|
187
191
|
return { challenge, expiresInMs };
|
|
188
192
|
} catch (error) {
|
|
193
|
+
if (error instanceof EdgeClientTooOldError) {
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
189
196
|
log.verbose('failed to fetch auth challenge', { error });
|
|
190
197
|
return undefined;
|
|
191
198
|
}
|
package/src/base-http-client.ts
CHANGED
|
@@ -6,7 +6,14 @@ import { sleep } from '@dxos/async';
|
|
|
6
6
|
import { Context, TRACE_SPAN_ATTRIBUTE, type TraceContextData } from '@dxos/context';
|
|
7
7
|
import { invariant } from '@dxos/invariant';
|
|
8
8
|
import { log } from '@dxos/log';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
EDGE_CLIENT_TAG_HEADER,
|
|
11
|
+
EDGE_CLIENT_TOO_OLD,
|
|
12
|
+
EdgeAuthChallengeError,
|
|
13
|
+
EdgeCallFailedError,
|
|
14
|
+
EdgeClientTooOldError,
|
|
15
|
+
type EdgeFailure,
|
|
16
|
+
} from '@dxos/protocols';
|
|
10
17
|
|
|
11
18
|
import { authenticateViaChallengeEndpoint, handleAuthChallenge, parseChallengeHeader } from './auth-challenge.ts';
|
|
12
19
|
import { type EdgeIdentity } from './edge-identity.ts';
|
|
@@ -193,6 +200,8 @@ export abstract class BaseHttpClient {
|
|
|
193
200
|
|
|
194
201
|
if (body?.data?.type === 'auth_challenge' && typeof body?.data?.challenge === 'string') {
|
|
195
202
|
processingError = new EdgeAuthChallengeError(body.data.challenge, body.data);
|
|
203
|
+
} else if (body?.success === false && body.data?.type === EDGE_CLIENT_TOO_OLD) {
|
|
204
|
+
processingError = new EdgeClientTooOldError(body, response.status);
|
|
196
205
|
} else if (body?.success === false) {
|
|
197
206
|
processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
|
|
198
207
|
} else {
|
|
@@ -293,12 +302,16 @@ export abstract class BaseHttpClient {
|
|
|
293
302
|
* device with no HALO chain (mid-invitation), and without this the rejection would surface from
|
|
294
303
|
* `_call` as a failed request — turning a call that used to succeed unauthenticated into an error.
|
|
295
304
|
*/
|
|
296
|
-
|
|
305
|
+
protected _prefetchAuthHeader(): Promise<void> {
|
|
297
306
|
if (this._authPrefetch) {
|
|
298
307
|
return this._authPrefetch;
|
|
299
308
|
}
|
|
300
309
|
const prefetch: Promise<void> = this._prefetchAuthHeaderOnce()
|
|
301
310
|
.catch((err) => {
|
|
311
|
+
// Unauthenticated would only be refused again, and silently; the caller has to see it.
|
|
312
|
+
if (err instanceof EdgeClientTooOldError) {
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
302
315
|
log.verbose('auth prefetch failed; proceeding unauthenticated', { err });
|
|
303
316
|
})
|
|
304
317
|
.finally(() => {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
EDGE_CLIENT_TOO_OLD,
|
|
7
|
+
EDGE_CLIENT_VERSION_HEADER,
|
|
8
|
+
EDGE_CLIENT_VERSION_PROTOCOL_PREFIX,
|
|
9
|
+
EdgeClientTooOldError,
|
|
10
|
+
type EdgeFailure,
|
|
11
|
+
} from '@dxos/protocols';
|
|
12
|
+
|
|
13
|
+
import { version } from '../package.json';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* This SDK's version, which EDGE checks against the oldest it serves. It releases in lockstep with the rest of the
|
|
17
|
+
* SDK, and Vite tree-shakes the import to this one field.
|
|
18
|
+
*/
|
|
19
|
+
export const CLIENT_SDK_VERSION: string = version;
|
|
20
|
+
|
|
21
|
+
/** Headers advertising {@link CLIENT_SDK_VERSION} on a request of the EDGE connect flow (`/auth` and its fallback). */
|
|
22
|
+
export const clientVersionHeaders = (): Record<string, string> => ({
|
|
23
|
+
[EDGE_CLIENT_VERSION_HEADER]: CLIENT_SDK_VERSION,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** The same advertisement for the WebSocket upgrade, where a browser can set no header but the subprotocol list. */
|
|
27
|
+
export const clientVersionProtocol = (): string => `${EDGE_CLIENT_VERSION_PROTOCOL_PREFIX}${CLIENT_SDK_VERSION}`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Throws {@link EdgeClientTooOldError} when `response` is EDGE's failure envelope refusing this SDK. Matched on the
|
|
31
|
+
* envelope's `data.type`, as any WebSocket endpoint answers a plain GET with a bare 426 "Upgrade Required".
|
|
32
|
+
*/
|
|
33
|
+
export const assertClientSupported = async (response: Response): Promise<void> => {
|
|
34
|
+
if (response.ok || !response.headers.get('Content-Type')?.startsWith('application/json')) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const body: EdgeFailure | undefined = await response
|
|
38
|
+
.clone()
|
|
39
|
+
.json()
|
|
40
|
+
.catch(() => undefined);
|
|
41
|
+
if (body?.success === false && body.data?.type === EDGE_CLIENT_TOO_OLD) {
|
|
42
|
+
throw new EdgeClientTooOldError(body, response.status);
|
|
43
|
+
}
|
|
44
|
+
};
|
package/src/edge-client.test.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { describe, expect, onTestFinished, test } from 'vitest';
|
|
5
|
+
import { describe, expect, onTestFinished, test, vi } from 'vitest';
|
|
6
6
|
|
|
7
|
-
import { Trigger } from '@dxos/async';
|
|
7
|
+
import { Trigger, sleep } from '@dxos/async';
|
|
8
8
|
import { Context } from '@dxos/context';
|
|
9
9
|
import { Keyring } from '@dxos/keyring';
|
|
10
|
+
import { EDGE_CLIENT_TOO_OLD, EdgeClientTooOldError, EdgeResponse } from '@dxos/protocols';
|
|
10
11
|
import { EdgeStatus_ConnectionState } from '@dxos/protocols/buf/dxos/client/services_pb';
|
|
11
12
|
import { TextMessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
12
13
|
import { openAndClose } from '@dxos/test-utils';
|
|
@@ -179,6 +180,31 @@ describe('EdgeClient', () => {
|
|
|
179
180
|
expect(client.isOpen).is.true;
|
|
180
181
|
});
|
|
181
182
|
|
|
183
|
+
// Every retry would carry the same version and be refused the same way, so the client must stop and say why.
|
|
184
|
+
test('a refusal at /auth stops reconnecting and fails sends with the refusal', async () => {
|
|
185
|
+
const fetchMock = vi.fn(async (_input: URL | RequestInfo) =>
|
|
186
|
+
EdgeResponse.failure({
|
|
187
|
+
message: 'Client too old: SDK 0.12.0 is older than 0.13.0. Update the app (reload it) to continue.',
|
|
188
|
+
status: 426,
|
|
189
|
+
data: { type: EDGE_CLIENT_TOO_OLD, clientVersion: '0.12.0', minimumVersion: '0.13.0' },
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
193
|
+
onTestFinished(() => {
|
|
194
|
+
vi.unstubAllGlobals();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const client = new EdgeClient(await createEphemeralEdgeIdentity(), { socketEndpoint: 'ws://127.0.0.1:1' });
|
|
198
|
+
await openAndClose(client);
|
|
199
|
+
|
|
200
|
+
await expect(client.send(Context.default(), textMessage('Hello'))).rejects.toBeInstanceOf(EdgeClientTooOldError);
|
|
201
|
+
// Several backoff periods (100 ms, doubling): a retrying client would have fetched /auth again by now.
|
|
202
|
+
await sleep(1_000);
|
|
203
|
+
// Counted by host: an earlier test's client may still be reconnecting through the same stub.
|
|
204
|
+
expect(fetchMock.mock.calls.filter((call) => new URL(String(call[0])).host === '127.0.0.1:1')).toHaveLength(1);
|
|
205
|
+
await expect(client.send(Context.default(), textMessage('Hello'))).rejects.toBeInstanceOf(EdgeClientTooOldError);
|
|
206
|
+
});
|
|
207
|
+
|
|
182
208
|
const textMessage = (message: string, source?: EdgeIdentity) =>
|
|
183
209
|
protocol.createMessage(TextMessageSchema, {
|
|
184
210
|
source: source && { peerKey: source.peerKey, identityDid: source.identityDid },
|
package/src/edge-client.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import { Context, TRACE_SPAN_ATTRIBUTE, type TraceContextData } from '@dxos/context';
|
|
18
18
|
import { type Lifecycle, Resource } from '@dxos/context';
|
|
19
19
|
import { log, logInfo } from '@dxos/log';
|
|
20
|
-
import { EdgeCredentialsHeaderCodec } from '@dxos/protocols';
|
|
20
|
+
import { EdgeClientTooOldError, EdgeCredentialsHeaderCodec } from '@dxos/protocols';
|
|
21
21
|
import {
|
|
22
22
|
type EdgeStatus,
|
|
23
23
|
EdgeStatus_ConnectionState,
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
presentCredentialsForChallenge,
|
|
32
32
|
readAuthChallenge,
|
|
33
33
|
} from './auth-challenge.ts';
|
|
34
|
+
import { assertClientSupported, clientVersionHeaders } from './client-version.ts';
|
|
34
35
|
import { protocol } from './defs.ts';
|
|
35
36
|
import { type EdgeIdentity } from './edge-identity.ts';
|
|
36
37
|
import { EdgeWsConnection } from './edge-ws-connection.ts';
|
|
@@ -120,6 +121,8 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
120
121
|
private readonly _baseHttpUrl: string;
|
|
121
122
|
private _currentConnection?: EdgeWsConnection = undefined;
|
|
122
123
|
private _ready = new Trigger();
|
|
124
|
+
/** Set when EDGE refused this SDK as outdated; a retry would carry the same version, so none is made. */
|
|
125
|
+
#refusal: EdgeClientTooOldError | undefined = undefined;
|
|
123
126
|
|
|
124
127
|
constructor(
|
|
125
128
|
private _identity: EdgeIdentity,
|
|
@@ -177,6 +180,9 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
177
180
|
* NOTE: The message is guaranteed to be delivered but the service must respond with a message to confirm processing.
|
|
178
181
|
*/
|
|
179
182
|
public async send(ctx: Context, message: Message) {
|
|
183
|
+
if (this.#refusal) {
|
|
184
|
+
throw this.#refusal;
|
|
185
|
+
}
|
|
180
186
|
if (this._ready.state !== TriggerState.RESOLVED) {
|
|
181
187
|
log('waiting for websocket');
|
|
182
188
|
await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
|
|
@@ -289,7 +295,22 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
289
295
|
|
|
290
296
|
const identity = this._identity;
|
|
291
297
|
const path = `/ws/${identity.identityDid}/${identity.peerKey}`;
|
|
292
|
-
|
|
298
|
+
this.#refusal = undefined;
|
|
299
|
+
let protocolHeader: string | undefined;
|
|
300
|
+
try {
|
|
301
|
+
protocolHeader = this._config.disableAuth ? undefined : await this._createAuthHeader(path);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
if (!(err instanceof EdgeClientTooOldError)) {
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
// Returning, not throwing, ends PersistentLifecycle's backoff loop: every retry would be refused the same way.
|
|
307
|
+
log.warn('EDGE refused this SDK as outdated; not reconnecting until the app is updated', { data: err.data });
|
|
308
|
+
this.#refusal = err;
|
|
309
|
+
this._ready.throw(err);
|
|
310
|
+
this._ready.reset();
|
|
311
|
+
this.statusChanged.emit(this.status);
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
293
314
|
if (this._identity !== identity) {
|
|
294
315
|
log('identity changed during auth header request');
|
|
295
316
|
return undefined;
|
|
@@ -446,7 +467,8 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
446
467
|
return encodePresentationWsAuthHeader(authentication.presentation);
|
|
447
468
|
}
|
|
448
469
|
|
|
449
|
-
const response = await fetch(new URL(path, this._baseHttpUrl), { method: 'GET' });
|
|
470
|
+
const response = await fetch(new URL(path, this._baseHttpUrl), { method: 'GET', headers: clientVersionHeaders() });
|
|
471
|
+
await assertClientSupported(response);
|
|
450
472
|
// Gate on a parsed VP challenge, not merely on a 401. A 401 forwarded from upstream can carry
|
|
451
473
|
// an unrelated `WWW-Authenticate` (or none), and signing a challenge that isn't there would
|
|
452
474
|
// throw instead of degrading to an unauthenticated attempt.
|
|
@@ -6,6 +6,7 @@ import { create } from '@bufbuild/protobuf';
|
|
|
6
6
|
import { afterEach, describe, it, test, vi } from 'vitest';
|
|
7
7
|
|
|
8
8
|
import { Context } from '@dxos/context';
|
|
9
|
+
import { EDGE_CLIENT_TOO_OLD, EdgeClientTooOldError, EdgeResponse } from '@dxos/protocols';
|
|
9
10
|
import { type Presentation, PresentationSchema } from '@dxos/protocols/buf/dxos/halo/credentials_pb';
|
|
10
11
|
|
|
11
12
|
import { createEphemeralEdgeIdentity } from './auth.ts';
|
|
@@ -329,6 +330,48 @@ describe('EdgeHttpClient auth refresh', () => {
|
|
|
329
330
|
});
|
|
330
331
|
});
|
|
331
332
|
|
|
333
|
+
describe('EdgeHttpClient outdated SDK', () => {
|
|
334
|
+
afterEach(() => {
|
|
335
|
+
vi.unstubAllGlobals();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Built by EDGE's own envelope producer, exactly as `requireClientSdkVersion` answers.
|
|
339
|
+
const refusal = () =>
|
|
340
|
+
EdgeResponse.failure({
|
|
341
|
+
message: 'Client too old: SDK 0.12.0 is older than 0.13.0. Update the app (reload it) to continue.',
|
|
342
|
+
status: 426,
|
|
343
|
+
data: { type: EDGE_CLIENT_TOO_OLD, clientVersion: '0.12.0', minimumVersion: '0.13.0' },
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// The prefetch swallows other failures to proceed unauthenticated, which here would only be refused again, silently.
|
|
347
|
+
test('a refusal at the /auth prefetch fails the call once, without sending it', async ({ expect }) => {
|
|
348
|
+
const fetchMock = vi.fn(async (_input: any, _init?: RequestInit) => refusal());
|
|
349
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
350
|
+
const client = new EdgeHttpClient('https://edge.example.com');
|
|
351
|
+
client.setIdentity({
|
|
352
|
+
peerKey: 'peer-key',
|
|
353
|
+
identityDid: 'did:halo:test',
|
|
354
|
+
presentCredentials: async (): Promise<Presentation> => create(PresentationSchema, {}),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const error = await client.getStatus(Context.default()).catch((error: unknown) => error);
|
|
358
|
+
expect(error).toBeInstanceOf(EdgeClientTooOldError);
|
|
359
|
+
expect(error).toMatchObject({ status: 426, isRetryable: false, data: { minimumVersion: '0.13.0' } });
|
|
360
|
+
expect(fetchMock.mock.calls.map((call) => new URL(String(call[0])).pathname)).toEqual(['/auth']);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('a route refusing the SDK fails with the typed error and is not retried', async ({ expect }) => {
|
|
364
|
+
const fetchMock = vi.fn(async (_input: any, _init?: RequestInit) => refusal());
|
|
365
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
366
|
+
const client = new EdgeHttpClient('https://edge.example.com');
|
|
367
|
+
|
|
368
|
+
await expect(client.request(Context.default(), '/status', { method: 'GET' })).rejects.toBeInstanceOf(
|
|
369
|
+
EdgeClientTooOldError,
|
|
370
|
+
);
|
|
371
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
332
375
|
describe('EdgeHttpClient blobs', () => {
|
|
333
376
|
afterEach(() => {
|
|
334
377
|
vi.unstubAllGlobals();
|
package/src/edge-http-client.ts
CHANGED
|
@@ -840,10 +840,7 @@ export class EdgeHttpClient extends BaseHttpClient {
|
|
|
840
840
|
let handledAuth = false;
|
|
841
841
|
while (true) {
|
|
842
842
|
if (!this._authHeader) {
|
|
843
|
-
|
|
844
|
-
if (authResponse.status === 401) {
|
|
845
|
-
this._authHeader = await this._handleUnauthorized(authResponse);
|
|
846
|
-
}
|
|
843
|
+
await this._prefetchAuthHeader();
|
|
847
844
|
}
|
|
848
845
|
|
|
849
846
|
const headers = new Headers(request.headers);
|
|
@@ -8,11 +8,11 @@ import { Mutex, scheduleTask, scheduleTaskInterval } from '@dxos/async';
|
|
|
8
8
|
import { Context, Resource } from '@dxos/context';
|
|
9
9
|
import { invariant } from '@dxos/invariant';
|
|
10
10
|
import { log, logInfo } from '@dxos/log';
|
|
11
|
-
import {
|
|
11
|
+
import { EdgeWebsocketProtocol } from '@dxos/protocols';
|
|
12
12
|
import { buf } from '@dxos/protocols/buf';
|
|
13
13
|
import { type Message, MessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { clientVersionProtocol } from './client-version.ts';
|
|
16
16
|
import { protocol } from './defs.ts';
|
|
17
17
|
import { type EdgeIdentity } from './edge-identity.ts';
|
|
18
18
|
import { CLOUDFLARE_MESSAGE_MAX_BYTES, WebSocketClosedError, WebSocketMuxer } from './edge-ws-muxer.ts';
|
|
@@ -181,8 +181,7 @@ export class EdgeWsConnection extends Resource {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
protected override async _open(): Promise<void> {
|
|
184
|
-
|
|
185
|
-
const baseProtocols = [...Object.values(EdgeWebsocketProtocol), `${EDGE_CLIENT_VERSION_PROTOCOL_PREFIX}${version}`];
|
|
184
|
+
const baseProtocols = [...Object.values(EdgeWebsocketProtocol), clientVersionProtocol()];
|
|
186
185
|
this._ws = new WebSocket(
|
|
187
186
|
this._connectionInfo.url.toString(),
|
|
188
187
|
this._connectionInfo.protocolHeader
|