@livedesk/hub 0.1.58 → 0.1.61

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,6 +8,7 @@ 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);
@@ -23,6 +24,10 @@ const CONNECTION_ONE = '33333333-3333-4333-8333-333333333333';
23
24
  const CONNECTION_TWO = '44444444-4444-4444-8444-444444444444';
24
25
  const CHANNEL_ID = '55555555-5555-4555-8555-555555555555';
25
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';
26
31
 
27
32
  class FakeSocket extends EventEmitter {
28
33
  static CONNECTING = 0;
@@ -225,6 +230,8 @@ async function connectedDirect(options = {}) {
225
230
  httpBaseUrl: 'http://127.0.0.1:5179',
226
231
  stunUrls: options.stunUrls || ['stun:stun.example.test:3478'],
227
232
  getAccessToken: async () => 'access-token',
233
+ ...(options.workspaceAccess ? { getWorkspaceAccess: () => options.workspaceAccess } : {}),
234
+ ...(options.consoleProxyToken ? { consoleProxyToken: options.consoleProxyToken } : {}),
228
235
  SignalingWebSocketImpl: FakeSignalSocket,
229
236
  LocalWebSocketImpl: FakeLocalSocket,
230
237
  createPeerConnection: (name, config) => new FakePeerConnection(name, config),
@@ -263,18 +270,23 @@ async function connectedDirect(options = {}) {
263
270
  type: 'signal-ready',
264
271
  role: 'hub',
265
272
  deviceId: 'hub-device',
266
- hubEpoch: HUB_EPOCH
273
+ hubEpoch: HUB_EPOCH,
274
+ ...(options.workspaceAccess ? {
275
+ workspaceId: options.workspaceAccess.workspaceId,
276
+ workspaceKind: options.workspaceAccess.workspaceKind
277
+ } : {})
267
278
  }), false);
268
279
  return { direct, signal };
269
280
  }
270
281
 
271
- 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 = {}) {
272
283
  signal.receive(JSON.stringify({
273
284
  type: 'rtc-offer',
274
285
  consoleId: CONSOLE_ID,
275
286
  connectionId,
276
287
  hubEpoch: HUB_EPOCH,
277
- description: { type: 'offer', sdp }
288
+ description: { type: 'offer', sdp },
289
+ ...extra
278
290
  }), false);
279
291
  return FakePeerConnection.instances.at(-1);
280
292
  }
@@ -615,6 +627,323 @@ test('reliable control channel bridges bounded HTTP over fragmented wire message
615
627
  direct.close();
616
628
  });
617
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
+
618
947
  test('canonical HTTP routing rejects encoded, backslash, duplicate-slash, and external-origin bypasses', async () => {
619
948
  const fetchedUrls = [];
620
949
  const { direct, signal } = await connectedDirect({