@livedesk/hub 0.1.58 → 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,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({
@@ -34,33 +34,97 @@ function randomToken() {
34
34
  return crypto.randomBytes(32).toString('base64url');
35
35
  }
36
36
 
37
- export function createHubUiSessionAuthority({
38
- ttlMs = HUB_UI_SESSION_TTL_MS,
39
- maxSessions = 32,
40
- now = () => Date.now()
41
- } = {}) {
42
- const sessions = new Map();
37
+ export function createHubUiSessionAuthority({
38
+ ttlMs = HUB_UI_SESSION_TTL_MS,
39
+ maxSessions = 32,
40
+ now = () => Date.now(),
41
+ onRevoke = () => {}
42
+ } = {}) {
43
+ const sessions = new Map();
44
+
45
+ function dropSession(sessionId, reason) {
46
+ const session = sessions.get(sessionId);
47
+ if (!session) return null;
48
+ sessions.delete(sessionId);
49
+ try {
50
+ onRevoke({ sessionId, ...session }, String(reason || 'hub-ui-session-revoked'));
51
+ } catch {
52
+ // Revocation remains authoritative even if a best-effort socket cleanup
53
+ // listener fails. The next protected request still fails closed.
54
+ }
55
+ return session;
56
+ }
43
57
 
44
58
  function purgeExpired() {
45
- const current = now();
46
- for (const [sessionId, session] of sessions) {
47
- if (session.expiresAt <= current) sessions.delete(sessionId);
48
- }
49
- while (sessions.size >= maxSessions) {
50
- sessions.delete(sessions.keys().next().value);
59
+ const current = now();
60
+ for (const [sessionId, session] of sessions) {
61
+ if (session.expiresAt <= current
62
+ || (session.accessExpiresAt > 0 && session.accessExpiresAt <= current)) {
63
+ dropSession(sessionId, 'hub-ui-session-expired');
64
+ }
65
+ }
66
+ while (sessions.size >= maxSessions) {
67
+ dropSession(sessions.keys().next().value, 'hub-ui-session-capacity-evicted');
51
68
  }
52
69
  }
53
70
 
54
- function issue(userId) {
55
- const normalizedUserId = String(userId || '').trim();
56
- if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
57
- purgeExpired();
58
- const sessionId = randomToken();
59
- const csrfToken = randomToken();
60
- const expiresAt = now() + ttlMs;
61
- sessions.set(sessionId, { userId: normalizedUserId, csrfToken, expiresAt });
62
- return { sessionId, csrfToken, expiresAt };
63
- }
71
+ function normalizedSessionAccess(userId, access = {}) {
72
+ const normalizedUserId = String(userId || '').trim();
73
+ if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
74
+ const workspaceId = String(access.workspaceId || '').trim();
75
+ const workspaceKind = String(access.workspaceKind || 'personal').trim().toLowerCase();
76
+ const role = String(access.role || 'owner').trim().toLowerCase();
77
+ const entitlementStatus = String(access.entitlementStatus || 'active').trim().toLowerCase();
78
+ const membershipRevision = Math.max(0, Number(access.membershipRevision || 0));
79
+ const accessExpiresAt = Math.max(0, Number(access.accessExpiresAt || 0));
80
+ if (workspaceId && !['owner', 'operator'].includes(role)) {
81
+ throw new Error('hub-ui-session-workspace-role-invalid');
82
+ }
83
+ return {
84
+ userId: normalizedUserId,
85
+ workspaceId,
86
+ workspaceKind,
87
+ role,
88
+ plan: String(access.plan || 'free').trim().toLowerCase(),
89
+ entitlementStatus,
90
+ membershipRevision,
91
+ accessExpiresAt
92
+ };
93
+ }
94
+
95
+ function issue(userId, access = {}) {
96
+ const normalized = normalizedSessionAccess(userId, access);
97
+ purgeExpired();
98
+ const sessionId = randomToken();
99
+ const csrfToken = randomToken();
100
+ const expiresAt = now() + ttlMs;
101
+ sessions.set(sessionId, {
102
+ ...normalized,
103
+ csrfToken,
104
+ expiresAt
105
+ });
106
+ return { sessionId, csrfToken, expiresAt };
107
+ }
108
+
109
+ function renew(req, userId, access = {}) {
110
+ purgeExpired();
111
+ const sessionId = parseCookies(req?.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
112
+ const session = sessions.get(sessionId);
113
+ const normalized = normalizedSessionAccess(userId, access);
114
+ if (!session
115
+ || session.userId !== normalized.userId
116
+ || session.workspaceId !== normalized.workspaceId
117
+ || session.role !== normalized.role) {
118
+ return issue(userId, access);
119
+ }
120
+ const expiresAt = now() + ttlMs;
121
+ sessions.set(sessionId, {
122
+ ...session,
123
+ ...normalized,
124
+ expiresAt
125
+ });
126
+ return { sessionId, csrfToken: session.csrfToken, expiresAt, renewed: true };
127
+ }
64
128
 
65
129
  function authorize(req, currentUserId, {
66
130
  requireCsrf = !SAFE_HTTP_METHODS.has(String(req.method || 'GET').toUpperCase())
@@ -74,23 +138,82 @@ export function createHubUiSessionAuthority({
74
138
  if (!session) {
75
139
  return { ok: false, status: 401, error: 'hub-ui-session-expired' };
76
140
  }
77
- const normalizedUserId = String(currentUserId || '').trim();
78
- if (!normalizedUserId || session.userId !== normalizedUserId) {
79
- sessions.delete(sessionId);
80
- return { ok: false, status: 403, error: 'hub-ui-session-account-mismatch' };
141
+ const expected = currentUserId && typeof currentUserId === 'object'
142
+ ? currentUserId
143
+ : { userId: currentUserId };
144
+ const normalizedUserId = String(expected.userId || '').trim();
145
+ const expectedWorkspaceId = String(expected.workspaceId || '').trim();
146
+ const allowedRoles = new Set(Array.isArray(expected.allowedRoles)
147
+ ? expected.allowedRoles.map(role => String(role || '').trim().toLowerCase())
148
+ : ['owner', 'operator']);
149
+ const identityMismatch = expectedWorkspaceId
150
+ ? session.workspaceId !== expectedWorkspaceId || !allowedRoles.has(session.role)
151
+ : !normalizedUserId || session.userId !== normalizedUserId;
152
+ if (identityMismatch || (session.accessExpiresAt > 0 && session.accessExpiresAt <= now())) {
153
+ dropSession(sessionId, 'hub-ui-session-account-mismatch');
154
+ return { ok: false, status: 403, error: 'hub-ui-session-account-mismatch' };
81
155
  }
82
156
  if (requireCsrf && !secureEqual(req.headers?.['x-livedesk-csrf'], session.csrfToken)) {
83
157
  return { ok: false, status: 403, error: 'hub-ui-csrf-invalid' };
84
158
  }
85
- return { ok: true, sessionId, userId: session.userId, expiresAt: session.expiresAt };
159
+ return {
160
+ ok: true,
161
+ sessionId,
162
+ userId: session.userId,
163
+ workspaceId: session.workspaceId,
164
+ workspaceKind: session.workspaceKind,
165
+ role: session.role,
166
+ plan: session.plan,
167
+ entitlementStatus: session.entitlementStatus,
168
+ membershipRevision: session.membershipRevision,
169
+ accessExpiresAt: session.accessExpiresAt,
170
+ expiresAt: session.expiresAt
171
+ };
86
172
  }
87
173
 
88
174
  return {
89
- issue,
90
- authorize,
91
- revokeAll() {
92
- sessions.clear();
93
- },
175
+ issue,
176
+ renew,
177
+ authorize,
178
+ sessionDeadline(sessionId) {
179
+ purgeExpired();
180
+ const session = sessions.get(String(sessionId || ''));
181
+ if (!session) return 0;
182
+ return session.accessExpiresAt > 0
183
+ ? Math.min(session.expiresAt, session.accessExpiresAt)
184
+ : session.expiresAt;
185
+ },
186
+ revokeAll(reason = 'hub-ui-session-revoked-all') {
187
+ let revoked = 0;
188
+ for (const sessionId of [...sessions.keys()]) {
189
+ if (dropSession(sessionId, reason)) revoked += 1;
190
+ }
191
+ return revoked;
192
+ },
193
+ revokeWorkspaceMember(workspaceId, userId) {
194
+ const normalizedWorkspaceId = String(workspaceId || '').trim();
195
+ const normalizedUserId = String(userId || '').trim();
196
+ let revoked = 0;
197
+ for (const [sessionId, session] of sessions) {
198
+ if (session.workspaceId !== normalizedWorkspaceId || session.userId !== normalizedUserId) continue;
199
+ if (dropSession(sessionId, 'workspace-member-revoked')) revoked += 1;
200
+ }
201
+ return revoked;
202
+ },
203
+ revoke(req) {
204
+ const sessionId = parseCookies(req?.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
205
+ if (!sessionId) return null;
206
+ const session = dropSession(sessionId, 'hub-ui-session-signed-out');
207
+ return session ? {
208
+ sessionId,
209
+ userId: session.userId,
210
+ workspaceId: session.workspaceId,
211
+ workspaceKind: session.workspaceKind,
212
+ role: session.role,
213
+ plan: session.plan,
214
+ entitlementStatus: session.entitlementStatus
215
+ } : null;
216
+ },
94
217
  sessionCount: () => sessions.size
95
218
  };
96
219
  }