@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
  createDirectConsoleWireBudget,
9
9
  encodeDirectConsoleWireMessage
10
10
  } from '../../runtime-core/src/console-direct-wire.js';
11
+ import { workspaceRoleCanControl, workspaceRoleCanRequest } from './auth/workspace-access.js';
11
12
 
12
13
  const DEFAULT_SIGNAL_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
13
14
  const DEFAULT_STUN_URLS = Object.freeze(['stun:stun.cloudflare.com:3478']);
@@ -40,6 +41,9 @@ const CONTROL_ASSEMBLY_TIMEOUT_MS = 15_000;
40
41
  const MEDIA_ASSEMBLY_TIMEOUT_MS = 500;
41
42
  const LOGICAL_BIND_TIMEOUT_MS = 5_000;
42
43
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
44
+ const CONSOLE_ACCESS_MAX_FUTURE_MS = 10 * 60 * 1000;
45
+ const REVOKED_MEMBER_FENCE_MS = 60_000;
46
+ const MAX_REVOKED_MEMBER_FENCES = 128;
43
47
 
44
48
  function normalizeSignalUrl(value) {
45
49
  const source = String(value || DEFAULT_SIGNAL_URL).trim();
@@ -57,6 +61,16 @@ function normalizeSignalUrl(value) {
57
61
  }
58
62
  }
59
63
 
64
+ function signalHttpEndpoint(signalUrl, pathname) {
65
+ if (!(signalUrl instanceof URL)) return null;
66
+ const url = new URL(signalUrl.href);
67
+ url.protocol = url.protocol === 'ws:' ? 'http:' : 'https:';
68
+ url.pathname = pathname;
69
+ url.search = '';
70
+ url.hash = '';
71
+ return url;
72
+ }
73
+
60
74
  function normalizeStunUrls(value) {
61
75
  const values = Array.isArray(value)
62
76
  ? value
@@ -152,6 +166,7 @@ function allowedHttpRequest(method, pathname) {
152
166
  || pathname === '/api/runtime/status'
153
167
  || pathname === '/api/runtime/restart'
154
168
  || pathname === '/api/auth/status'
169
+ || pathname === '/api/auth/session'
155
170
  || pathname === '/api/remote/status'
156
171
  || pathname === '/api/hub/status'
157
172
  || pathname === '/api/update/status'
@@ -162,10 +177,12 @@ function allowedHttpRequest(method, pathname) {
162
177
  || /^\/api\/captures(?:\/|$)/.test(pathname)
163
178
  || /^\/api\/capture-sessions(?:\/|$)/.test(pathname)
164
179
  || /^\/api\/remote\/devices(?:\/|$)/.test(pathname)
180
+ || pathname === '/api/remote/wall-preferences'
165
181
  || /^\/api\/remote\/frames$/.test(pathname)
166
182
  || /^\/api\/remote\/filesystem(?:\/|$)/.test(pathname)
167
183
  || /^\/api\/remote\/files(?:\/|$)/.test(pathname)
168
184
  || /^\/api\/remote\/tasks(?:\/|$)/.test(pathname)
185
+ || /^\/api\/remote\/workspace(?:\/|$)/.test(pathname)
169
186
  || /^\/api\/remote\/license(?:\/sync)?$/.test(pathname)) {
170
187
  return pathname !== '/api/remote/registry-credentials'
171
188
  && pathname !== '/api/remote/pairing-pin'
@@ -271,6 +288,7 @@ async function readBoundedResponseBytes(response, maxBytes) {
271
288
 
272
289
  export function createHubConsoleDirect(options = {}) {
273
290
  const signalUrl = normalizeSignalUrl(options.url);
291
+ const workspaceReauthUrl = signalHttpEndpoint(signalUrl, '/v2/rtc/reauth');
274
292
  const deviceId = String(options.deviceId || '').trim();
275
293
  const requestedHubInstanceId = String(options.hubInstanceId || '').trim();
276
294
  const hubInstanceId = UUID_PATTERN.test(requestedHubInstanceId)
@@ -285,6 +303,10 @@ export function createHubConsoleDirect(options = {}) {
285
303
  const getAccessToken = typeof options.getAccessToken === 'function'
286
304
  ? options.getAccessToken
287
305
  : async () => String(options.accessToken || '').trim();
306
+ const getWorkspaceAccess = typeof options.getWorkspaceAccess === 'function'
307
+ ? options.getWorkspaceAccess
308
+ : () => options.workspaceAccess || null;
309
+ const consoleProxyToken = String(options.consoleProxyToken || '').trim();
288
310
  const createPeerConnection = typeof options.createPeerConnection === 'function'
289
311
  ? options.createPeerConnection
290
312
  : (name, config) => new nodeDataChannel.PeerConnection(name, config);
@@ -327,11 +349,13 @@ export function createHubConsoleDirect(options = {}) {
327
349
  Number(options.logicalBindTimeoutMs) || LOGICAL_BIND_TIMEOUT_MS
328
350
  );
329
351
  const peers = new Map();
352
+ const revokedMembers = new Map();
330
353
  let signalSocket = null;
331
354
  let signalGeneration = 0;
332
355
  let peerGeneration = 0;
333
356
  let retryTimer = null;
334
357
  let accessTokenAttempt = null;
358
+ let workspaceReauthAttempt = null;
335
359
  let signalReadyDeadline = null;
336
360
  let signalHeartbeat = null;
337
361
  let retryAttempt = 0;
@@ -348,10 +372,91 @@ export function createHubConsoleDirect(options = {}) {
348
372
  let malformedWireCloses = 0;
349
373
  let wireBudgetCleanupFailures = 0;
350
374
 
375
+ const currentHubWorkspaceAccess = () => {
376
+ const access = getWorkspaceAccess() || {};
377
+ return {
378
+ workspaceId: String(access.workspaceId || '').trim(),
379
+ workspaceKind: String(access.workspaceKind || '').trim().toLowerCase(),
380
+ role: String(access.role || '').trim().toLowerCase(),
381
+ membershipRevision: Math.max(0, Number(access.membershipRevision || 0))
382
+ };
383
+ };
384
+
385
+ const consoleWorkspaceAccess = payload => {
386
+ const workspaceId = String(payload?.workspaceId || '').trim();
387
+ const workspaceKind = String(payload?.workspaceKind || '').trim().toLowerCase();
388
+ const userId = String(payload?.memberUserId || '').trim();
389
+ const role = String(payload?.memberRole || '').trim().toLowerCase();
390
+ const membershipRevision = Number(payload?.membershipRevision || 0);
391
+ const accessExpiresAt = Number(payload?.accessExpiresAt || 0);
392
+ const hubAccess = currentHubWorkspaceAccess();
393
+ if (!hubAccess.workspaceId && !workspaceId && !workspaceKind && !userId && !role && accessExpiresAt === 0) {
394
+ return Object.freeze({
395
+ workspaceId: 'unconfigured-personal-workspace',
396
+ workspaceKind: 'personal',
397
+ userId: 'unconfigured-personal-user',
398
+ role: 'owner',
399
+ membershipRevision: 0,
400
+ accessExpiresAt: Date.now() + CONSOLE_ACCESS_MAX_FUTURE_MS
401
+ });
402
+ }
403
+ if (!workspaceId
404
+ || !['personal', 'team'].includes(workspaceKind)
405
+ || !userId
406
+ || workspaceId !== hubAccess.workspaceId
407
+ || workspaceKind !== hubAccess.workspaceKind
408
+ || !workspaceRoleCanControl(role)
409
+ || !Number.isSafeInteger(membershipRevision)
410
+ || membershipRevision < 0
411
+ || !Number.isFinite(accessExpiresAt)
412
+ || (workspaceKind === 'team' && (accessExpiresAt <= Date.now()
413
+ || accessExpiresAt > Date.now() + CONSOLE_ACCESS_MAX_FUTURE_MS))
414
+ || (workspaceKind === 'personal' && accessExpiresAt !== 0)) {
415
+ return null;
416
+ }
417
+ return Object.freeze({ workspaceId, workspaceKind, userId, role, membershipRevision, accessExpiresAt });
418
+ };
419
+
420
+ const consoleProxyHeaders = owner => ({
421
+ 'X-LiveDesk-Console-Proxy': consoleProxyToken,
422
+ 'X-LiveDesk-Workspace-Id': owner.workspaceAccess.workspaceId,
423
+ 'X-LiveDesk-Workspace-Kind': owner.workspaceAccess.workspaceKind,
424
+ 'X-LiveDesk-Workspace-User-Id': owner.workspaceAccess.userId,
425
+ 'X-LiveDesk-Workspace-Role': owner.workspaceAccess.role,
426
+ 'X-LiveDesk-Membership-Revision': String(owner.workspaceAccess.membershipRevision),
427
+ 'X-LiveDesk-Workspace-Access-Expires-At': String(owner.workspaceAccess.accessExpiresAt)
428
+ });
429
+
430
+ const ownerWorkspaceAccessCurrent = owner => owner?.workspaceAccess
431
+ && (owner.workspaceAccess.workspaceId === currentHubWorkspaceAccess().workspaceId
432
+ || (!currentHubWorkspaceAccess().workspaceId
433
+ && owner.workspaceAccess.workspaceId === 'unconfigured-personal-workspace'))
434
+ && workspaceRoleCanControl(owner.workspaceAccess.role)
435
+ && (owner.workspaceAccess.workspaceKind === 'personal'
436
+ || owner.workspaceAccess.accessExpiresAt > Date.now());
437
+
351
438
  const isOwnerActive = owner => !owner.retired
352
439
  && owner.hubEpoch === hubEpoch
353
440
  && peers.get(owner.consoleId) === owner
354
- && owner.generation > 0;
441
+ && owner.generation > 0
442
+ && ownerWorkspaceAccessCurrent(owner);
443
+
444
+ const memberFenceKey = (workspaceId, userId) => `${String(workspaceId || '').trim()}:${String(userId || '').trim()}`;
445
+
446
+ const pruneRevokedMembers = () => {
447
+ const now = Date.now();
448
+ for (const [key, expiresAt] of revokedMembers) {
449
+ if (expiresAt <= now) revokedMembers.delete(key);
450
+ }
451
+ while (revokedMembers.size > MAX_REVOKED_MEMBER_FENCES) {
452
+ revokedMembers.delete(revokedMembers.keys().next().value);
453
+ }
454
+ };
455
+
456
+ const memberIsFenced = access => {
457
+ pruneRevokedMembers();
458
+ return Number(revokedMembers.get(memberFenceKey(access?.workspaceId, access?.userId)) || 0) > Date.now();
459
+ };
355
460
 
356
461
  const sendSignal = payload => {
357
462
  if (!socketIsOpen(signalSocket, SignalingWebSocketImpl)) return false;
@@ -435,6 +540,8 @@ export function createHubConsoleDirect(options = {}) {
435
540
  owner.iceTimer = null;
436
541
  clearTimeout(owner.disconnectTimer);
437
542
  owner.disconnectTimer = null;
543
+ clearTimeout(owner.accessTimer);
544
+ owner.accessTimer = null;
438
545
  for (const pending of owner.pendingHttp.values()) {
439
546
  clearTimeout(pending.timeout);
440
547
  pending.cancelled = true;
@@ -464,6 +571,24 @@ export function createHubConsoleDirect(options = {}) {
464
571
  }
465
572
  };
466
573
 
574
+ const armOwnerAccessDeadline = owner => {
575
+ clearTimeout(owner.accessTimer);
576
+ owner.accessTimer = null;
577
+ if (owner.workspaceAccess?.workspaceKind !== 'team') return;
578
+ const delay = Number(owner.workspaceAccess.accessExpiresAt || 0) - Date.now();
579
+ if (delay <= 0) {
580
+ retirePeer(owner, 'console-workspace-access-expired');
581
+ return;
582
+ }
583
+ const generation = owner.generation;
584
+ owner.accessTimer = setTimeout(() => {
585
+ owner.accessTimer = null;
586
+ if (peers.get(owner.consoleId) !== owner || owner.generation !== generation || owner.retired) return;
587
+ retirePeer(owner, 'console-workspace-access-expired');
588
+ }, delay);
589
+ owner.accessTimer.unref?.();
590
+ };
591
+
467
592
  const retireAllPeers = reason => {
468
593
  for (const owner of [...peers.values()]) retirePeer(owner, reason, { notify: false });
469
594
  };
@@ -616,6 +741,15 @@ export function createHubConsoleDirect(options = {}) {
616
741
  sendHttpResponse(owner, requestId, { error: 'console-route-not-allowed' });
617
742
  return;
618
743
  }
744
+ if (!workspaceRoleCanRequest(owner.workspaceAccess?.role, method, requestUrl.pathname)) {
745
+ sendHttpResponse(owner, requestId, { status: 403, error: 'workspace-owner-required' });
746
+ return;
747
+ }
748
+ if (!ownerWorkspaceAccessCurrent(owner)) {
749
+ sendHttpResponse(owner, requestId, { status: 403, error: 'console-workspace-access-expired' });
750
+ retirePeer(owner, 'console-workspace-access-expired');
751
+ return;
752
+ }
619
753
  if (owner.pendingHttp.has(requestId)) {
620
754
  sendHttpResponse(owner, requestId, { error: 'console-http-request-duplicate' });
621
755
  return;
@@ -624,12 +758,20 @@ export function createHubConsoleDirect(options = {}) {
624
758
  sendHttpResponse(owner, requestId, { error: 'console-http-capacity-reached' });
625
759
  return;
626
760
  }
761
+ const isAuthSessionBootstrap = method === 'POST'
762
+ && requestUrl.pathname === '/api/auth/session';
627
763
  let body;
628
- try {
629
- body = decodeBase64Bounded(payload?.bodyBase64, MAX_HTTP_REQUEST_BYTES);
630
- } catch (error) {
631
- sendHttpResponse(owner, requestId, { error: error instanceof Error ? error.message : error });
632
- return;
764
+ if (isAuthSessionBootstrap) {
765
+ body = Buffer.from(JSON.stringify({
766
+ workspaceId: owner.workspaceAccess.workspaceId
767
+ }), 'utf8');
768
+ } else {
769
+ try {
770
+ body = decodeBase64Bounded(payload?.bodyBase64, MAX_HTTP_REQUEST_BYTES);
771
+ } catch (error) {
772
+ sendHttpResponse(owner, requestId, { error: error instanceof Error ? error.message : error });
773
+ return;
774
+ }
633
775
  }
634
776
  const controller = new AbortController();
635
777
  const pending = { controller, timeout: null, cancelled: false };
@@ -642,7 +784,12 @@ export function createHubConsoleDirect(options = {}) {
642
784
  try {
643
785
  const response = await fetchImpl(requestUrl.href, {
644
786
  method,
645
- headers: safeContentHeaders(payload?.headers),
787
+ headers: {
788
+ ...(isAuthSessionBootstrap
789
+ ? { 'content-type': 'application/json' }
790
+ : safeContentHeaders(payload?.headers)),
791
+ ...consoleProxyHeaders(owner)
792
+ },
646
793
  body: ['GET', 'HEAD'].includes(method) || body.byteLength === 0 ? undefined : body,
647
794
  signal: controller.signal
648
795
  });
@@ -714,7 +861,8 @@ export function createHubConsoleDirect(options = {}) {
714
861
  if (channelState.openRequested
715
862
  || channelId !== channelState.channelId
716
863
  || purpose !== channelState.purpose
717
- || !allowedWebSocketPath(path, purpose)) {
864
+ || !allowedWebSocketPath(path, purpose)
865
+ || !workspaceRoleCanRequest(owner.workspaceAccess?.role, 'GET', String(path || '').split('?')[0])) {
718
866
  sendLifecycleControl(owner, {
719
867
  type: 'ws-error',
720
868
  channelId: channelState.channelId,
@@ -727,6 +875,15 @@ export function createHubConsoleDirect(options = {}) {
727
875
  });
728
876
  return;
729
877
  }
878
+ if (!ownerWorkspaceAccessCurrent(owner)) {
879
+ sendLifecycleControl(owner, {
880
+ type: 'ws-error',
881
+ channelId: channelState.channelId,
882
+ error: 'console-workspace-access-expired'
883
+ });
884
+ retirePeer(owner, 'console-workspace-access-expired');
885
+ return;
886
+ }
730
887
  channelState.openRequested = true;
731
888
  const localUrl = new URL(path, httpBaseUrl);
732
889
  const httpOrigin = new URL(httpBaseUrl).origin;
@@ -737,7 +894,10 @@ export function createHubConsoleDirect(options = {}) {
737
894
  localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
738
895
  let socket;
739
896
  try {
740
- socket = new LocalWebSocketImpl(localUrl, { perMessageDeflate: false });
897
+ socket = new LocalWebSocketImpl(localUrl, {
898
+ headers: consoleProxyHeaders(owner),
899
+ perMessageDeflate: false
900
+ });
741
901
  } catch (error) {
742
902
  sendLifecycleControl(owner, {
743
903
  type: 'ws-error',
@@ -888,6 +1048,10 @@ export function createHubConsoleDirect(options = {}) {
888
1048
 
889
1049
  const handleDataChannelMessage = (owner, channelState, raw) => {
890
1050
  if (!isOwnerActive(owner) || channelState.closed) return;
1051
+ if (!ownerWorkspaceAccessCurrent(owner)) {
1052
+ retirePeer(owner, 'console-workspace-access-expired');
1053
+ return;
1054
+ }
891
1055
  try {
892
1056
  if (typeof raw === 'string') throw new Error('console-direct-unframed-message');
893
1057
  const message = channelState.assembler.push(raw);
@@ -1008,12 +1172,33 @@ export function createHubConsoleDirect(options = {}) {
1008
1172
  const connectionId = String(payload?.connectionId || '');
1009
1173
  const ownerHubEpoch = String(payload?.hubEpoch || '');
1010
1174
  const description = payload?.description;
1175
+ const workspaceAccess = consoleWorkspaceAccess(payload);
1011
1176
  if (!UUID_PATTERN.test(consoleId)
1012
1177
  || !UUID_PATTERN.test(connectionId)
1013
1178
  || ownerHubEpoch !== hubEpoch
1179
+ || !workspaceAccess
1014
1180
  || description?.type !== 'offer'
1015
1181
  || typeof description.sdp !== 'string'
1016
1182
  || byteLength(description.sdp) > SDP_MAX_BYTES) {
1183
+ if (UUID_PATTERN.test(consoleId) && UUID_PATTERN.test(connectionId)) {
1184
+ sendSignal({
1185
+ type: 'rtc-close',
1186
+ consoleId,
1187
+ connectionId,
1188
+ hubEpoch: ownerHubEpoch,
1189
+ reason: workspaceAccess ? 'console-direct-offer-invalid' : 'console-workspace-access-invalid'
1190
+ });
1191
+ }
1192
+ return null;
1193
+ }
1194
+ if (memberIsFenced(workspaceAccess)) {
1195
+ sendSignal({
1196
+ type: 'rtc-close',
1197
+ consoleId,
1198
+ connectionId,
1199
+ hubEpoch: ownerHubEpoch,
1200
+ reason: 'workspace-member-revoked'
1201
+ });
1017
1202
  return null;
1018
1203
  }
1019
1204
  if (candidateIsRelay(description.sdp)) {
@@ -1079,6 +1264,7 @@ export function createHubConsoleDirect(options = {}) {
1079
1264
  hubEpoch: ownerHubEpoch,
1080
1265
  generation: ++peerGeneration,
1081
1266
  peer,
1267
+ workspaceAccess,
1082
1268
  control: null,
1083
1269
  channels: new Map(),
1084
1270
  pendingLogicalControl: new Map(),
@@ -1086,12 +1272,14 @@ export function createHubConsoleDirect(options = {}) {
1086
1272
  wireBudget: createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
1087
1273
  iceTimer: null,
1088
1274
  disconnectTimer: null,
1275
+ accessTimer: null,
1089
1276
  peerState: 'new',
1090
1277
  iceState: 'new',
1091
1278
  retired: false,
1092
1279
  createdAt: Date.now()
1093
1280
  };
1094
1281
  peers.set(consoleId, owner);
1282
+ armOwnerAccessDeadline(owner);
1095
1283
  peer.onLocalDescription((sdp, type) => {
1096
1284
  if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
1097
1285
  if (candidateIsRelay(sdp)) {
@@ -1177,10 +1365,17 @@ export function createHubConsoleDirect(options = {}) {
1177
1365
  if (!payload?.type) return;
1178
1366
  if (payload.type === 'signal-ready' && payload.role === 'hub') {
1179
1367
  const nextEpoch = String(payload.hubEpoch || '');
1368
+ const signaledWorkspaceId = String(payload.workspaceId || '').trim();
1369
+ const expectedWorkspaceId = currentHubWorkspaceAccess().workspaceId;
1180
1370
  if (!UUID_PATTERN.test(nextEpoch)) {
1181
1371
  lastError = 'console-direct-hub-epoch-invalid';
1182
1372
  return;
1183
1373
  }
1374
+ if (expectedWorkspaceId && signaledWorkspaceId !== expectedWorkspaceId) {
1375
+ lastError = 'console-direct-workspace-mismatch';
1376
+ try { socket?.close?.(1008, 'console-direct-workspace-mismatch'); } catch {}
1377
+ return;
1378
+ }
1184
1379
  clearSignalReadyDeadline(ownerSignalGeneration, socket);
1185
1380
  if (hubEpoch && hubEpoch !== nextEpoch) retireAllPeers('console-direct-hub-epoch-replaced');
1186
1381
  hubEpoch = nextEpoch;
@@ -1194,6 +1389,82 @@ export function createHubConsoleDirect(options = {}) {
1194
1389
  armSignalHeartbeat(ownerSignalGeneration, socket, connect);
1195
1390
  return;
1196
1391
  }
1392
+ if (payload.type === 'workspace-access-expired') {
1393
+ retireAllPeers('console-workspace-access-expired');
1394
+ lastError = 'console-workspace-access-expired';
1395
+ return;
1396
+ }
1397
+ if (payload.type === 'workspace-access-refreshed') {
1398
+ const consoleId = String(payload.consoleId || '');
1399
+ const connectionId = String(payload.connectionId || '');
1400
+ const owner = peers.get(consoleId);
1401
+ const refreshedAccess = consoleWorkspaceAccess(payload);
1402
+ if (!owner
1403
+ || owner.connectionId !== connectionId
1404
+ || owner.hubEpoch !== String(payload.hubEpoch || '')
1405
+ || !refreshedAccess
1406
+ || owner.workspaceAccess.workspaceId !== refreshedAccess.workspaceId
1407
+ || owner.workspaceAccess.userId !== refreshedAccess.userId
1408
+ || memberIsFenced(refreshedAccess)) {
1409
+ if (owner) retirePeer(owner, 'console-workspace-access-refresh-invalid');
1410
+ return;
1411
+ }
1412
+ owner.workspaceAccess = refreshedAccess;
1413
+ armOwnerAccessDeadline(owner);
1414
+ return;
1415
+ }
1416
+ if (payload.type === 'workspace-reauth-required') {
1417
+ const workspaceId = String(payload.workspaceId || '').trim();
1418
+ const challengeNonce = String(payload.challengeNonce || '').trim();
1419
+ if (workspaceId !== currentHubWorkspaceAccess().workspaceId
1420
+ || !UUID_PATTERN.test(challengeNonce)
1421
+ || !(workspaceReauthUrl instanceof URL)) {
1422
+ lastError = 'console-workspace-reauth-challenge-invalid';
1423
+ return;
1424
+ }
1425
+ if (workspaceReauthAttempt?.challengeNonce === challengeNonce) return;
1426
+ try { workspaceReauthAttempt?.controller?.abort?.(); } catch {}
1427
+ if (workspaceReauthAttempt?.timer) clearTimeout(workspaceReauthAttempt.timer);
1428
+ const controller = new AbortController();
1429
+ const attempt = {
1430
+ challengeNonce,
1431
+ generation: ownerSignalGeneration,
1432
+ socket,
1433
+ controller,
1434
+ timer: null
1435
+ };
1436
+ workspaceReauthAttempt = attempt;
1437
+ attempt.timer = setTimeout(() => controller.abort(), accessTokenTimeoutMs);
1438
+ attempt.timer.unref?.();
1439
+ void readAccessToken(ownerSignalGeneration).then(async result => {
1440
+ if (signalSocket !== socket
1441
+ || ownerSignalGeneration !== signalGeneration
1442
+ || result.kind !== 'value') return;
1443
+ const accessToken = String(result.value || '').trim();
1444
+ if (!accessToken) return;
1445
+ try {
1446
+ const response = await fetchImpl(workspaceReauthUrl.href, {
1447
+ method: 'POST',
1448
+ headers: {
1449
+ Authorization: `Bearer ${accessToken}`,
1450
+ 'Content-Type': 'application/json',
1451
+ Accept: 'application/json'
1452
+ },
1453
+ body: JSON.stringify({ workspaceId, challengeNonce }),
1454
+ signal: controller.signal
1455
+ });
1456
+ if (!response?.ok) lastError = `console-workspace-reauth-failed:${Number(response?.status || 0)}`;
1457
+ } catch (error) {
1458
+ if (error?.name !== 'AbortError') lastError = 'console-workspace-reauth-failed';
1459
+ }
1460
+ }).finally(() => {
1461
+ if (workspaceReauthAttempt !== attempt) return;
1462
+ if (attempt.timer) clearTimeout(attempt.timer);
1463
+ workspaceReauthAttempt = null;
1464
+ });
1465
+ return;
1466
+ }
1467
+ if (payload.type === 'workspace-reauth-ok') return;
1197
1468
  const consoleId = String(payload.consoleId || '');
1198
1469
  const connectionId = String(payload.connectionId || '');
1199
1470
  const ownerHubEpoch = String(payload.hubEpoch || '');
@@ -1455,6 +1726,10 @@ export function createHubConsoleDirect(options = {}) {
1455
1726
  const url = new URL(signalUrl);
1456
1727
  url.searchParams.set('deviceId', deviceId);
1457
1728
  url.searchParams.set('hubInstanceId', hubInstanceId);
1729
+ const hubWorkspaceAccess = currentHubWorkspaceAccess();
1730
+ if (hubWorkspaceAccess.workspaceId) {
1731
+ url.searchParams.set('workspaceId', hubWorkspaceAccess.workspaceId);
1732
+ }
1458
1733
  let socket;
1459
1734
  try {
1460
1735
  socket = new SignalingWebSocketImpl(url, {
@@ -1542,6 +1817,9 @@ export function createHubConsoleDirect(options = {}) {
1542
1817
  return;
1543
1818
  }
1544
1819
  signalGeneration += 1;
1820
+ try { workspaceReauthAttempt?.controller?.abort?.(); } catch {}
1821
+ if (workspaceReauthAttempt?.timer) clearTimeout(workspaceReauthAttempt.timer);
1822
+ workspaceReauthAttempt = null;
1545
1823
  cancelAccessTokenAttempt();
1546
1824
  clearSignalReadyDeadline();
1547
1825
  clearSignalHeartbeat();
@@ -1561,6 +1839,9 @@ export function createHubConsoleDirect(options = {}) {
1561
1839
  if (stopped) return;
1562
1840
  stopped = true;
1563
1841
  signalGeneration += 1;
1842
+ try { workspaceReauthAttempt?.controller?.abort?.(); } catch {}
1843
+ if (workspaceReauthAttempt?.timer) clearTimeout(workspaceReauthAttempt.timer);
1844
+ workspaceReauthAttempt = null;
1564
1845
  cancelAccessTokenAttempt();
1565
1846
  clearSignalReadyDeadline();
1566
1847
  clearSignalHeartbeat();
@@ -1576,6 +1857,31 @@ export function createHubConsoleDirect(options = {}) {
1576
1857
  state = signalUrl ? 'closed' : 'disabled';
1577
1858
  };
1578
1859
 
1860
+ const invalidateWorkspaceAccess = (reason = 'console-workspace-access-invalidated') => {
1861
+ retireAllPeers(String(reason || 'console-workspace-access-invalidated').slice(0, 120));
1862
+ refresh();
1863
+ };
1864
+
1865
+ const revokeWorkspaceMember = (workspaceId, userId, reason = 'workspace-member-revoked') => {
1866
+ const normalizedWorkspaceId = String(workspaceId || '').trim();
1867
+ const normalizedUserId = String(userId || '').trim();
1868
+ if (!normalizedWorkspaceId || !normalizedUserId) return 0;
1869
+ pruneRevokedMembers();
1870
+ revokedMembers.set(
1871
+ memberFenceKey(normalizedWorkspaceId, normalizedUserId),
1872
+ Date.now() + REVOKED_MEMBER_FENCE_MS
1873
+ );
1874
+ pruneRevokedMembers();
1875
+ let retired = 0;
1876
+ for (const owner of [...peers.values()]) {
1877
+ if (owner.workspaceAccess?.workspaceId !== normalizedWorkspaceId
1878
+ || owner.workspaceAccess?.userId !== normalizedUserId) continue;
1879
+ retirePeer(owner, String(reason || 'workspace-member-revoked').slice(0, 120));
1880
+ retired += 1;
1881
+ }
1882
+ return retired;
1883
+ };
1884
+
1579
1885
  const inspect = () => {
1580
1886
  let logicalWebSocketChannels = 0;
1581
1887
  let localWebSocketChannels = 0;
@@ -1587,12 +1893,14 @@ export function createHubConsoleDirect(options = {}) {
1587
1893
  let iceConnectTimers = 0;
1588
1894
  let peerDisconnectTimers = 0;
1589
1895
  let assemblyDeadlineTimers = 0;
1896
+ let workspaceAccessTimers = 0;
1590
1897
  for (const owner of peers.values()) {
1591
1898
  logicalWebSocketChannels += owner.channels.size;
1592
1899
  pendingHttpRequests += owner.pendingHttp.size;
1593
1900
  pendingLogicalControlRequests += owner.pendingLogicalControl.size;
1594
1901
  if (owner.iceTimer) iceConnectTimers += 1;
1595
1902
  if (owner.disconnectTimer) peerDisconnectTimers += 1;
1903
+ if (owner.accessTimer) workspaceAccessTimers += 1;
1596
1904
  if (owner.control && !owner.control.closed) controlChannels += 1;
1597
1905
  if (owner.control?.assemblyTimer) assemblyDeadlineTimers += 1;
1598
1906
  for (const channelState of owner.channels.values()) {
@@ -1622,6 +1930,8 @@ export function createHubConsoleDirect(options = {}) {
1622
1930
  iceConnectTimers,
1623
1931
  peerDisconnectTimers,
1624
1932
  assemblyDeadlineTimers,
1933
+ workspaceAccessTimers,
1934
+ revokedMemberFences: (pruneRevokedMembers(), revokedMembers.size),
1625
1935
  reconnectTimerActive,
1626
1936
  accessTokenDeadlineActive,
1627
1937
  signalReadyDeadlineActive,
@@ -1634,6 +1944,8 @@ export function createHubConsoleDirect(options = {}) {
1634
1944
  + pendingLogicalControlRequests
1635
1945
  + Number(reconnectTimerActive)
1636
1946
  + Number(accessTokenDeadlineActive)
1947
+ + workspaceAccessTimers
1948
+ + Number(Boolean(workspaceReauthAttempt?.timer))
1637
1949
  + Number(signalReadyDeadlineActive)
1638
1950
  + Number(signalHeartbeatTimerActive),
1639
1951
  rejectedRelayCandidates,
@@ -1648,7 +1960,14 @@ export function createHubConsoleDirect(options = {}) {
1648
1960
  });
1649
1961
  };
1650
1962
 
1651
- return Object.freeze({ start, refresh, close, inspect });
1963
+ return Object.freeze({
1964
+ start,
1965
+ refresh,
1966
+ close,
1967
+ invalidateWorkspaceAccess,
1968
+ revokeWorkspaceMember,
1969
+ inspect
1970
+ });
1652
1971
  }
1653
1972
 
1654
1973
  export const consoleDirectContract = Object.freeze({