@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.
@@ -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().resourceTimers, 0);
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().resourceTimers, 0);
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 () => {