@livedesk/hub 0.1.57 → 0.1.59
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/package.json +3 -3
- package/src/auth/workspace-access.js +176 -0
- package/src/console-direct.js +469 -11
- package/src/console-direct.test.mjs +446 -5
- package/src/http/hub-ui-session.js +155 -32
- package/src/server.js +1141 -328
|
@@ -8,11 +8,14 @@ import {
|
|
|
8
8
|
encodeDirectConsoleWireMessage
|
|
9
9
|
} from '../../runtime-core/src/console-direct-wire.js';
|
|
10
10
|
import { consoleDirectContract, createHubConsoleDirect } from './console-direct.js';
|
|
11
|
+
import { workspaceRoleCanRequest } from './auth/workspace-access.js';
|
|
11
12
|
|
|
12
13
|
assert.equal(consoleDirectContract.maxDefaultRetryDelayMs, 20_000);
|
|
13
14
|
assert.equal(consoleDirectContract.peerDisconnectedTimeoutMs, 5_000);
|
|
14
15
|
assert.equal(consoleDirectContract.maxPendingMediaWireMessages, 8);
|
|
15
16
|
assert.equal(consoleDirectContract.reliableAssemblyTimeoutMs, 15_000);
|
|
17
|
+
assert.equal(consoleDirectContract.signalHeartbeatIntervalMs, 15_000);
|
|
18
|
+
assert.equal(consoleDirectContract.signalHeartbeatTimeoutMs, 10_000);
|
|
16
19
|
|
|
17
20
|
const HUB_EPOCH = '11111111-1111-4111-8111-111111111111';
|
|
18
21
|
const HUB_EPOCH_TWO = '77777777-7777-4777-8777-777777777777';
|
|
@@ -21,6 +24,10 @@ const CONNECTION_ONE = '33333333-3333-4333-8333-333333333333';
|
|
|
21
24
|
const CONNECTION_TWO = '44444444-4444-4444-8444-444444444444';
|
|
22
25
|
const CHANNEL_ID = '55555555-5555-4555-8555-555555555555';
|
|
23
26
|
const REQUEST_ID = '66666666-6666-4666-8666-666666666666';
|
|
27
|
+
const TEAM_WORKSPACE_ID = '88888888-8888-4888-8888-888888888888';
|
|
28
|
+
const TEAM_OWNER_USER_ID = '99999999-9999-4999-8999-999999999999';
|
|
29
|
+
const TEAM_OPERATOR_USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
|
30
|
+
const CONSOLE_PROXY_TOKEN = 'test-console-proxy-token';
|
|
24
31
|
|
|
25
32
|
class FakeSocket extends EventEmitter {
|
|
26
33
|
static CONNECTING = 0;
|
|
@@ -38,6 +45,7 @@ class FakeSocket extends EventEmitter {
|
|
|
38
45
|
this.sent = [];
|
|
39
46
|
this.closeCode = 0;
|
|
40
47
|
this.closeReason = '';
|
|
48
|
+
this.pingCount = 0;
|
|
41
49
|
this.constructor.instances.push(this);
|
|
42
50
|
}
|
|
43
51
|
|
|
@@ -55,6 +63,15 @@ class FakeSocket extends EventEmitter {
|
|
|
55
63
|
this.emit('message', value, binary);
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
ping() {
|
|
67
|
+
if (this.readyState !== FakeSocket.OPEN) throw new Error('fake-socket-not-open');
|
|
68
|
+
this.pingCount += 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pong() {
|
|
72
|
+
this.emit('pong');
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
close(code = 1000, reason = '') {
|
|
59
76
|
if (this.readyState === FakeSocket.CLOSED) return;
|
|
60
77
|
this.closeCode = code;
|
|
@@ -213,6 +230,8 @@ async function connectedDirect(options = {}) {
|
|
|
213
230
|
httpBaseUrl: 'http://127.0.0.1:5179',
|
|
214
231
|
stunUrls: options.stunUrls || ['stun:stun.example.test:3478'],
|
|
215
232
|
getAccessToken: async () => 'access-token',
|
|
233
|
+
...(options.workspaceAccess ? { getWorkspaceAccess: () => options.workspaceAccess } : {}),
|
|
234
|
+
...(options.consoleProxyToken ? { consoleProxyToken: options.consoleProxyToken } : {}),
|
|
216
235
|
SignalingWebSocketImpl: FakeSignalSocket,
|
|
217
236
|
LocalWebSocketImpl: FakeLocalSocket,
|
|
218
237
|
createPeerConnection: (name, config) => new FakePeerConnection(name, config),
|
|
@@ -222,6 +241,18 @@ async function connectedDirect(options = {}) {
|
|
|
222
241
|
? { peerDisconnectedTimeoutMs: options.peerDisconnectedTimeoutMs }
|
|
223
242
|
: {}),
|
|
224
243
|
...(options.mediaAssemblyTimeoutMs ? { mediaAssemblyTimeoutMs: options.mediaAssemblyTimeoutMs } : {}),
|
|
244
|
+
...(options.signalHeartbeatIntervalMs
|
|
245
|
+
? { signalHeartbeatIntervalMs: options.signalHeartbeatIntervalMs }
|
|
246
|
+
: {}),
|
|
247
|
+
...(options.signalHeartbeatTimeoutMs
|
|
248
|
+
? { signalHeartbeatTimeoutMs: options.signalHeartbeatTimeoutMs }
|
|
249
|
+
: {}),
|
|
250
|
+
...(options.scheduleSignalHeartbeatTimer
|
|
251
|
+
? { scheduleSignalHeartbeatTimer: options.scheduleSignalHeartbeatTimer }
|
|
252
|
+
: {}),
|
|
253
|
+
...(options.cancelSignalHeartbeatTimer
|
|
254
|
+
? { cancelSignalHeartbeatTimer: options.cancelSignalHeartbeatTimer }
|
|
255
|
+
: {}),
|
|
225
256
|
fetchImpl: options.fetchImpl || (async () => new Response('{}', {
|
|
226
257
|
status: 200,
|
|
227
258
|
headers: { 'Content-Type': 'application/json' }
|
|
@@ -239,18 +270,23 @@ async function connectedDirect(options = {}) {
|
|
|
239
270
|
type: 'signal-ready',
|
|
240
271
|
role: 'hub',
|
|
241
272
|
deviceId: 'hub-device',
|
|
242
|
-
hubEpoch: HUB_EPOCH
|
|
273
|
+
hubEpoch: HUB_EPOCH,
|
|
274
|
+
...(options.workspaceAccess ? {
|
|
275
|
+
workspaceId: options.workspaceAccess.workspaceId,
|
|
276
|
+
workspaceKind: options.workspaceAccess.workspaceKind
|
|
277
|
+
} : {})
|
|
243
278
|
}), false);
|
|
244
279
|
return { direct, signal };
|
|
245
280
|
}
|
|
246
281
|
|
|
247
|
-
function offer(signal, connectionId = CONNECTION_ONE, sdp = 'v=0\r\na=fake-offer\r\n') {
|
|
282
|
+
function offer(signal, connectionId = CONNECTION_ONE, sdp = 'v=0\r\na=fake-offer\r\n', extra = {}) {
|
|
248
283
|
signal.receive(JSON.stringify({
|
|
249
284
|
type: 'rtc-offer',
|
|
250
285
|
consoleId: CONSOLE_ID,
|
|
251
286
|
connectionId,
|
|
252
287
|
hubEpoch: HUB_EPOCH,
|
|
253
|
-
description: { type: 'offer', sdp }
|
|
288
|
+
description: { type: 'offer', sdp },
|
|
289
|
+
...extra
|
|
254
290
|
}), false);
|
|
255
291
|
return FakePeerConnection.instances.at(-1);
|
|
256
292
|
}
|
|
@@ -319,6 +355,89 @@ test('a signaling reconnect preserves a healthy direct peer until the Hub epoch
|
|
|
319
355
|
direct.close();
|
|
320
356
|
});
|
|
321
357
|
|
|
358
|
+
test('a silent half-open signaling socket is replaced while a healthy direct peer stays alive', async () => {
|
|
359
|
+
const { direct, signal } = await connectedDirect({
|
|
360
|
+
retryDelaysMs: [0],
|
|
361
|
+
signalHeartbeatIntervalMs: 5,
|
|
362
|
+
signalHeartbeatTimeoutMs: 20
|
|
363
|
+
});
|
|
364
|
+
const peer = offer(signal);
|
|
365
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
366
|
+
peer.emitDataChannel(control);
|
|
367
|
+
control.open();
|
|
368
|
+
|
|
369
|
+
assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
|
|
370
|
+
await waitFor(() => signal.pingCount === 1);
|
|
371
|
+
assert.equal(direct.inspect().signalHeartbeatAwaitingPong, true);
|
|
372
|
+
await waitFor(() => FakeSignalSocket.instances.length === 2);
|
|
373
|
+
|
|
374
|
+
const replacementSignal = FakeSignalSocket.instances[1];
|
|
375
|
+
assert.equal(signal.readyState, FakeSignalSocket.CLOSED);
|
|
376
|
+
assert.equal(peer.closed, false);
|
|
377
|
+
assert.equal(direct.inspect().peerConnections, 1);
|
|
378
|
+
assert.equal(direct.inspect().lastError, 'console-direct-signal-heartbeat-timeout');
|
|
379
|
+
replacementSignal.open();
|
|
380
|
+
replacementSignal.receive(JSON.stringify({
|
|
381
|
+
type: 'signal-ready',
|
|
382
|
+
role: 'hub',
|
|
383
|
+
deviceId: 'hub-device',
|
|
384
|
+
hubEpoch: HUB_EPOCH
|
|
385
|
+
}), false);
|
|
386
|
+
assert.equal(peer.closed, false);
|
|
387
|
+
assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
|
|
388
|
+
|
|
389
|
+
await waitFor(() => replacementSignal.pingCount === 1);
|
|
390
|
+
replacementSignal.pong();
|
|
391
|
+
assert.equal(direct.inspect().signalHeartbeatAwaitingPong, false);
|
|
392
|
+
assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
|
|
393
|
+
direct.close();
|
|
394
|
+
assert.equal(direct.inspect().signalHeartbeatTimerActive, false);
|
|
395
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('a cancelled heartbeat timeout cannot retire a healthy signal after pong re-arms it', async () => {
|
|
399
|
+
let nextTimerId = 0;
|
|
400
|
+
const callbacks = new Map();
|
|
401
|
+
const cancelledTimers = new Set();
|
|
402
|
+
const { direct, signal } = await connectedDirect({
|
|
403
|
+
scheduleSignalHeartbeatTimer(callback) {
|
|
404
|
+
const timerId = ++nextTimerId;
|
|
405
|
+
callbacks.set(timerId, callback);
|
|
406
|
+
return timerId;
|
|
407
|
+
},
|
|
408
|
+
cancelSignalHeartbeatTimer(timerId) {
|
|
409
|
+
cancelledTimers.add(timerId);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
const peer = offer(signal);
|
|
413
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
414
|
+
peer.emitDataChannel(control);
|
|
415
|
+
control.open();
|
|
416
|
+
|
|
417
|
+
assert.equal(nextTimerId, 1);
|
|
418
|
+
callbacks.get(1)();
|
|
419
|
+
assert.equal(signal.pingCount, 1);
|
|
420
|
+
assert.equal(direct.inspect().signalHeartbeatAwaitingPong, true);
|
|
421
|
+
assert.equal(nextTimerId, 2);
|
|
422
|
+
|
|
423
|
+
signal.pong();
|
|
424
|
+
assert.equal(cancelledTimers.has(2), true);
|
|
425
|
+
assert.equal(nextTimerId, 3);
|
|
426
|
+
assert.equal(direct.inspect().signalHeartbeatAwaitingPong, false);
|
|
427
|
+
|
|
428
|
+
callbacks.get(2)();
|
|
429
|
+
assert.equal(signal.readyState, FakeSignalSocket.OPEN);
|
|
430
|
+
assert.equal(FakeSignalSocket.instances.length, 1);
|
|
431
|
+
assert.equal(peer.closed, false);
|
|
432
|
+
assert.equal(direct.inspect().peerConnections, 1);
|
|
433
|
+
assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
|
|
434
|
+
assert.equal(direct.inspect().signalHeartbeatAwaitingPong, false);
|
|
435
|
+
|
|
436
|
+
direct.close();
|
|
437
|
+
assert.equal(cancelledTimers.has(3), true);
|
|
438
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
439
|
+
});
|
|
440
|
+
|
|
322
441
|
test('a same-account token refresh reconnects signaling without dropping a healthy direct peer', async () => {
|
|
323
442
|
const { direct, signal } = await connectedDirect();
|
|
324
443
|
const peer = offer(signal);
|
|
@@ -508,6 +627,323 @@ test('reliable control channel bridges bounded HTTP over fragmented wire message
|
|
|
508
627
|
direct.close();
|
|
509
628
|
});
|
|
510
629
|
|
|
630
|
+
test('Team Operator can use Wall and Control but cannot mutate the Hub owner runtime', async () => {
|
|
631
|
+
const teamWorkspaceAccess = {
|
|
632
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
633
|
+
workspaceKind: 'team',
|
|
634
|
+
role: 'owner',
|
|
635
|
+
membershipRevision: 7
|
|
636
|
+
};
|
|
637
|
+
const fetched = [];
|
|
638
|
+
const { direct, signal } = await connectedDirect({
|
|
639
|
+
workspaceAccess: teamWorkspaceAccess,
|
|
640
|
+
consoleProxyToken: CONSOLE_PROXY_TOKEN,
|
|
641
|
+
fetchImpl: async (url, init) => {
|
|
642
|
+
fetched.push({ url: String(url), init });
|
|
643
|
+
return new Response('{"ok":true}', {
|
|
644
|
+
status: 200,
|
|
645
|
+
headers: { 'Content-Type': 'application/json' }
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
assert.equal(new URL(signal.url).searchParams.get('workspaceId'), TEAM_WORKSPACE_ID);
|
|
650
|
+
const hubChallengeNonce = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
|
651
|
+
signal.receive(JSON.stringify({
|
|
652
|
+
type: 'workspace-reauth-required',
|
|
653
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
654
|
+
challengeNonce: hubChallengeNonce,
|
|
655
|
+
deadlineAt: Date.now() + 15_000
|
|
656
|
+
}), false);
|
|
657
|
+
await waitFor(() => fetched.some(call => call.url.endsWith('/v2/rtc/reauth')));
|
|
658
|
+
const hubReauth = fetched.find(call => call.url.endsWith('/v2/rtc/reauth'));
|
|
659
|
+
assert.equal(hubReauth.init.headers.Authorization, 'Bearer access-token');
|
|
660
|
+
assert.deepEqual(JSON.parse(hubReauth.init.body), {
|
|
661
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
662
|
+
challengeNonce: hubChallengeNonce
|
|
663
|
+
});
|
|
664
|
+
assert.equal(
|
|
665
|
+
signalingMessages(signal).some(message => JSON.stringify(message).includes('access-token')),
|
|
666
|
+
false,
|
|
667
|
+
'A bearer token must never be sent through the signaling WebSocket.'
|
|
668
|
+
);
|
|
669
|
+
fetched.length = 0;
|
|
670
|
+
const accessExpiresAt = Date.now() + 60_000;
|
|
671
|
+
const peer = offer(signal, CONNECTION_ONE, 'v=0\r\na=fake-offer\r\n', {
|
|
672
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
673
|
+
workspaceKind: 'team',
|
|
674
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
675
|
+
memberRole: 'operator',
|
|
676
|
+
membershipRevision: 7,
|
|
677
|
+
accessExpiresAt
|
|
678
|
+
});
|
|
679
|
+
assert.ok(peer);
|
|
680
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
681
|
+
peer.emitDataChannel(control);
|
|
682
|
+
control.open();
|
|
683
|
+
|
|
684
|
+
sendWire(control, {
|
|
685
|
+
type: 'http-request',
|
|
686
|
+
requestId: CHANNEL_ID,
|
|
687
|
+
method: 'POST',
|
|
688
|
+
path: '/api/auth/session',
|
|
689
|
+
headers: { 'content-type': 'application/json' },
|
|
690
|
+
bodyBase64: Buffer.from(JSON.stringify({ workspaceId: TEAM_WORKSPACE_ID })).toString('base64')
|
|
691
|
+
}, { messageId: 30 });
|
|
692
|
+
await settle();
|
|
693
|
+
assert.equal(fetched.length, 1);
|
|
694
|
+
assert.equal(fetched[0].url, 'http://127.0.0.1:5179/api/auth/session');
|
|
695
|
+
assert.deepEqual(JSON.parse(Buffer.from(fetched[0].init.body).toString('utf8')), {
|
|
696
|
+
workspaceId: TEAM_WORKSPACE_ID
|
|
697
|
+
});
|
|
698
|
+
assert.equal(fetched[0].init.headers.Authorization, undefined);
|
|
699
|
+
fetched.length = 0;
|
|
700
|
+
|
|
701
|
+
sendWire(control, {
|
|
702
|
+
type: 'http-request',
|
|
703
|
+
requestId: REQUEST_ID,
|
|
704
|
+
method: 'POST',
|
|
705
|
+
path: '/api/update/apply'
|
|
706
|
+
}, { messageId: 31 });
|
|
707
|
+
await settle();
|
|
708
|
+
let responses = decodeWireMessages(control.sent).map(message => JSON.parse(message.data));
|
|
709
|
+
const forbidden = responses.find(message => message.requestId === REQUEST_ID);
|
|
710
|
+
assert.equal(forbidden?.status, 403);
|
|
711
|
+
assert.equal(forbidden?.error, 'workspace-owner-required');
|
|
712
|
+
assert.equal(fetched.length, 0, 'A denied Operator request must never reach the local Hub API.');
|
|
713
|
+
|
|
714
|
+
sendWire(control, {
|
|
715
|
+
type: 'http-request',
|
|
716
|
+
requestId: HUB_EPOCH_TWO,
|
|
717
|
+
method: 'POST',
|
|
718
|
+
path: '/api/remote/devices/client-1/input',
|
|
719
|
+
headers: { 'content-type': 'application/json' },
|
|
720
|
+
bodyBase64: Buffer.from('{"type":"pointermove"}').toString('base64')
|
|
721
|
+
}, { messageId: 32 });
|
|
722
|
+
await settle();
|
|
723
|
+
assert.equal(fetched.length, 1);
|
|
724
|
+
assert.equal(fetched[0].url, 'http://127.0.0.1:5179/api/remote/devices/client-1/input');
|
|
725
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Console-Proxy'], CONSOLE_PROXY_TOKEN);
|
|
726
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-Id'], TEAM_WORKSPACE_ID);
|
|
727
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-User-Id'], TEAM_OPERATOR_USER_ID);
|
|
728
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-Role'], 'operator');
|
|
729
|
+
responses = decodeWireMessages(control.sent).map(message => JSON.parse(message.data));
|
|
730
|
+
assert.equal(responses.find(message => message.requestId === HUB_EPOCH_TWO)?.status, 200);
|
|
731
|
+
|
|
732
|
+
signal.receive(JSON.stringify({
|
|
733
|
+
type: 'workspace-access-refreshed',
|
|
734
|
+
consoleId: CONSOLE_ID,
|
|
735
|
+
connectionId: CONNECTION_ONE,
|
|
736
|
+
hubEpoch: HUB_EPOCH,
|
|
737
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
738
|
+
workspaceKind: 'team',
|
|
739
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
740
|
+
memberRole: 'operator',
|
|
741
|
+
membershipRevision: 8,
|
|
742
|
+
accessExpiresAt: Date.now() + 60_000
|
|
743
|
+
}), false);
|
|
744
|
+
assert.equal(direct.inspect().peerConnections, 1, 'A successful Team reauth must preserve the healthy P2P peer.');
|
|
745
|
+
assert.equal(direct.revokeWorkspaceMember(TEAM_WORKSPACE_ID, TEAM_OPERATOR_USER_ID), 1);
|
|
746
|
+
assert.equal(peer.closed, true);
|
|
747
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
748
|
+
assert.equal(direct.inspect().controlChannels, 0);
|
|
749
|
+
const peerCountBeforeReoffer = FakePeerConnection.instances.length;
|
|
750
|
+
offer(signal, CONNECTION_TWO, 'v=0\r\na=fake-offer\r\n', {
|
|
751
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
752
|
+
workspaceKind: 'team',
|
|
753
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
754
|
+
memberRole: 'operator',
|
|
755
|
+
membershipRevision: 8,
|
|
756
|
+
accessExpiresAt: Date.now() + 60_000
|
|
757
|
+
});
|
|
758
|
+
assert.equal(FakePeerConnection.instances.length, peerCountBeforeReoffer);
|
|
759
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
760
|
+
assert.equal(direct.inspect().revokedMemberFences, 1);
|
|
761
|
+
assert.equal(signalingMessages(signal).at(-1).reason, 'workspace-member-revoked');
|
|
762
|
+
direct.close();
|
|
763
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test('Team peer access refresh replaces the exact deadline and signaling loss cannot extend access', async () => {
|
|
767
|
+
const { direct, signal } = await connectedDirect({
|
|
768
|
+
retryDelaysMs: [60_000],
|
|
769
|
+
workspaceAccess: {
|
|
770
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
771
|
+
workspaceKind: 'team',
|
|
772
|
+
role: 'owner',
|
|
773
|
+
membershipRevision: 7
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
const peer = offer(signal, CONNECTION_ONE, 'v=0\r\na=fake-offer\r\n', {
|
|
777
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
778
|
+
workspaceKind: 'team',
|
|
779
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
780
|
+
memberRole: 'operator',
|
|
781
|
+
membershipRevision: 7,
|
|
782
|
+
accessExpiresAt: Date.now() + 30
|
|
783
|
+
});
|
|
784
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
785
|
+
peer.emitDataChannel(control);
|
|
786
|
+
control.open();
|
|
787
|
+
signal.receive(JSON.stringify({
|
|
788
|
+
type: 'workspace-access-refreshed',
|
|
789
|
+
consoleId: CONSOLE_ID,
|
|
790
|
+
connectionId: CONNECTION_ONE,
|
|
791
|
+
hubEpoch: HUB_EPOCH,
|
|
792
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
793
|
+
workspaceKind: 'team',
|
|
794
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
795
|
+
memberRole: 'operator',
|
|
796
|
+
membershipRevision: 8,
|
|
797
|
+
accessExpiresAt: Date.now() + 100
|
|
798
|
+
}), false);
|
|
799
|
+
signal.close(1012, 'signaling-temporarily-offline');
|
|
800
|
+
await new Promise(resolve => setTimeout(resolve, 45));
|
|
801
|
+
assert.equal(peer.closed, false, 'The stale pre-refresh deadline must not close a refreshed peer.');
|
|
802
|
+
assert.equal(direct.inspect().peerConnections, 1);
|
|
803
|
+
await waitFor(() => peer.closed, 150);
|
|
804
|
+
assert.equal(direct.inspect().peerConnections, 0, 'A lost signaling socket must not extend Team access past its verified deadline.');
|
|
805
|
+
assert.equal(direct.inspect().localWebSocketChannels, 0);
|
|
806
|
+
direct.close();
|
|
807
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test('verified Personal Owner, Team Owner and Team Operator auth bootstrap discards every caller credential', async () => {
|
|
811
|
+
for (const scenario of [
|
|
812
|
+
{
|
|
813
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
814
|
+
workspaceKind: 'team',
|
|
815
|
+
userId: TEAM_OWNER_USER_ID,
|
|
816
|
+
role: 'owner',
|
|
817
|
+
accessExpiresAt: Date.now() + 60_000
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
821
|
+
workspaceKind: 'team',
|
|
822
|
+
userId: TEAM_OPERATOR_USER_ID,
|
|
823
|
+
role: 'operator',
|
|
824
|
+
accessExpiresAt: Date.now() + 60_000
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
workspaceId: TEAM_OWNER_USER_ID,
|
|
828
|
+
workspaceKind: 'personal',
|
|
829
|
+
userId: TEAM_OWNER_USER_ID,
|
|
830
|
+
role: 'owner',
|
|
831
|
+
accessExpiresAt: 0
|
|
832
|
+
}
|
|
833
|
+
]) {
|
|
834
|
+
const fetched = [];
|
|
835
|
+
const { direct, signal } = await connectedDirect({
|
|
836
|
+
workspaceAccess: {
|
|
837
|
+
workspaceId: scenario.workspaceId,
|
|
838
|
+
workspaceKind: scenario.workspaceKind,
|
|
839
|
+
role: 'owner',
|
|
840
|
+
membershipRevision: 3
|
|
841
|
+
},
|
|
842
|
+
consoleProxyToken: CONSOLE_PROXY_TOKEN,
|
|
843
|
+
fetchImpl: async (url, init) => {
|
|
844
|
+
fetched.push({ url: String(url), init });
|
|
845
|
+
return new Response('{"ok":true}', { headers: { 'Content-Type': 'application/json' } });
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
const peer = offer(signal, CONNECTION_ONE, 'v=0\r\na=fake-offer\r\n', {
|
|
849
|
+
workspaceId: scenario.workspaceId,
|
|
850
|
+
workspaceKind: scenario.workspaceKind,
|
|
851
|
+
memberUserId: scenario.userId,
|
|
852
|
+
memberRole: scenario.role,
|
|
853
|
+
membershipRevision: 3,
|
|
854
|
+
accessExpiresAt: scenario.accessExpiresAt
|
|
855
|
+
});
|
|
856
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
857
|
+
peer.emitDataChannel(control);
|
|
858
|
+
control.open();
|
|
859
|
+
sendWire(control, {
|
|
860
|
+
type: 'http-request',
|
|
861
|
+
requestId: REQUEST_ID,
|
|
862
|
+
method: 'POST',
|
|
863
|
+
path: '/api/auth/session',
|
|
864
|
+
headers: {
|
|
865
|
+
accept: 'application/attacker-controlled',
|
|
866
|
+
'content-type': 'text/plain',
|
|
867
|
+
'if-match': 'attacker-etag',
|
|
868
|
+
range: 'bytes=0-999',
|
|
869
|
+
'x-livedesk-csrf': 'attacker-csrf'
|
|
870
|
+
},
|
|
871
|
+
bodyBase64: Buffer.from(JSON.stringify({
|
|
872
|
+
workspaceId: HUB_EPOCH_TWO,
|
|
873
|
+
accessToken: 'attacker-access-token',
|
|
874
|
+
refreshToken: 'attacker-refresh-token',
|
|
875
|
+
user: { id: HUB_EPOCH_TWO },
|
|
876
|
+
userId: HUB_EPOCH_TWO,
|
|
877
|
+
expiresAt: 9_999_999_999
|
|
878
|
+
})).toString('base64')
|
|
879
|
+
});
|
|
880
|
+
await settle();
|
|
881
|
+
assert.equal(fetched.length, 1);
|
|
882
|
+
assert.equal(fetched[0].init.headers.Authorization, undefined);
|
|
883
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-Role'], scenario.role);
|
|
884
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-Kind'], scenario.workspaceKind);
|
|
885
|
+
assert.equal(fetched[0].init.headers['content-type'], 'application/json');
|
|
886
|
+
assert.equal(fetched[0].init.headers.accept, undefined);
|
|
887
|
+
assert.equal(fetched[0].init.headers['if-match'], undefined);
|
|
888
|
+
assert.equal(fetched[0].init.headers.range, undefined);
|
|
889
|
+
assert.equal(fetched[0].init.headers['x-livedesk-csrf'], undefined);
|
|
890
|
+
assert.deepEqual(JSON.parse(Buffer.from(fetched[0].init.body).toString('utf8')), {
|
|
891
|
+
workspaceId: scenario.workspaceId
|
|
892
|
+
});
|
|
893
|
+
direct.close();
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test('cross-workspace Worker claims are rejected before allocating a P2P peer', async () => {
|
|
898
|
+
const { direct, signal } = await connectedDirect({
|
|
899
|
+
workspaceAccess: {
|
|
900
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
901
|
+
workspaceKind: 'team',
|
|
902
|
+
role: 'owner',
|
|
903
|
+
membershipRevision: 1
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
offer(signal, CONNECTION_ONE, 'v=0\r\na=fake-offer\r\n', {
|
|
907
|
+
workspaceId: HUB_EPOCH_TWO,
|
|
908
|
+
workspaceKind: 'team',
|
|
909
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
910
|
+
memberRole: 'operator',
|
|
911
|
+
membershipRevision: 1,
|
|
912
|
+
accessExpiresAt: Date.now() + 60_000
|
|
913
|
+
});
|
|
914
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
915
|
+
assert.equal(signalingMessages(signal).some(message => (
|
|
916
|
+
message.type === 'rtc-close'
|
|
917
|
+
&& message.reason === 'console-workspace-access-invalid'
|
|
918
|
+
)), true);
|
|
919
|
+
direct.close();
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test('Operator request policy is limited to Wall, Control, audio and self sign-out', () => {
|
|
923
|
+
assert.equal(workspaceRoleCanRequest('operator', 'GET', '/api/hub/devices'), true);
|
|
924
|
+
assert.equal(workspaceRoleCanRequest('operator', 'GET', '/api/remote/devices/client-1/thumbnail'), true);
|
|
925
|
+
assert.equal(workspaceRoleCanRequest('operator', 'POST', '/api/remote/devices/client-1/thumbnail/request'), true);
|
|
926
|
+
assert.equal(workspaceRoleCanRequest('operator', 'GET', '/api/remote/devices/client-1/live/frame'), true);
|
|
927
|
+
assert.equal(workspaceRoleCanRequest('operator', 'POST', '/api/remote/devices/client-1/live/start'), true);
|
|
928
|
+
assert.equal(workspaceRoleCanRequest('operator', 'POST', '/api/remote/devices/client-1/input'), true);
|
|
929
|
+
assert.equal(workspaceRoleCanRequest('operator', 'POST', '/api/remote/devices/client-1/audio/start'), true);
|
|
930
|
+
assert.equal(workspaceRoleCanRequest('operator', 'DELETE', '/api/auth/session'), true);
|
|
931
|
+
for (const [method, path] of [
|
|
932
|
+
['POST', '/api/runtime/restart'],
|
|
933
|
+
['POST', '/api/runtime/shutdown'],
|
|
934
|
+
['POST', '/api/runtime/role'],
|
|
935
|
+
['POST', '/api/hub/sync'],
|
|
936
|
+
['POST', '/api/update/apply'],
|
|
937
|
+
['PATCH', '/api/settings/profile'],
|
|
938
|
+
['POST', '/api/files/upload'],
|
|
939
|
+
['POST', '/api/tasks'],
|
|
940
|
+
['POST', `/api/remote/workspace/members/${TEAM_OWNER_USER_ID}/revoke`]
|
|
941
|
+
]) {
|
|
942
|
+
assert.equal(workspaceRoleCanRequest('operator', method, path), false, `${method} ${path}`);
|
|
943
|
+
}
|
|
944
|
+
assert.equal(workspaceRoleCanRequest('owner', 'POST', '/api/update/apply'), true);
|
|
945
|
+
});
|
|
946
|
+
|
|
511
947
|
test('canonical HTTP routing rejects encoded, backslash, duplicate-slash, and external-origin bypasses', async () => {
|
|
512
948
|
const fetchedUrls = [];
|
|
513
949
|
const { direct, signal } = await connectedDirect({
|
|
@@ -812,8 +1248,10 @@ test('a transient disconnected state recovers or retires the exact peer on one b
|
|
|
812
1248
|
assert.equal(peer.closed, true);
|
|
813
1249
|
assert.equal(direct.inspect().peerConnections, 0);
|
|
814
1250
|
assert.equal(direct.inspect().peerDisconnectTimers, 0);
|
|
815
|
-
assert.equal(direct.inspect().
|
|
1251
|
+
assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
|
|
1252
|
+
assert.equal(direct.inspect().resourceTimers, 1);
|
|
816
1253
|
direct.close();
|
|
1254
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
817
1255
|
});
|
|
818
1256
|
|
|
819
1257
|
test('an oversized local frame closes only its logical media lane', async () => {
|
|
@@ -851,8 +1289,11 @@ test('an unopened control channel cannot suppress the bounded ICE owner timeout'
|
|
|
851
1289
|
await new Promise(resolve => setTimeout(resolve, 15));
|
|
852
1290
|
assert.equal(peer.closed, true);
|
|
853
1291
|
assert.equal(direct.inspect().peerConnections, 0);
|
|
854
|
-
assert.equal(direct.inspect().
|
|
1292
|
+
assert.equal(direct.inspect().iceConnectTimers, 0);
|
|
1293
|
+
assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
|
|
1294
|
+
assert.equal(direct.inspect().resourceTimers, 1);
|
|
855
1295
|
direct.close();
|
|
1296
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
856
1297
|
});
|
|
857
1298
|
|
|
858
1299
|
test('an incomplete partial-reliable media message owns one deadline and close returns terminal zero', async () => {
|