@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,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']);
@@ -34,10 +35,15 @@ const ICE_CONNECT_TIMEOUT_MS = 10_000;
34
35
  const PEER_DISCONNECTED_TIMEOUT_MS = 5_000;
35
36
  const ACCESS_TOKEN_TIMEOUT_MS = 8_000;
36
37
  const SIGNAL_READY_TIMEOUT_MS = 10_000;
38
+ const SIGNAL_HEARTBEAT_INTERVAL_MS = 15_000;
39
+ const SIGNAL_HEARTBEAT_TIMEOUT_MS = 10_000;
37
40
  const CONTROL_ASSEMBLY_TIMEOUT_MS = 15_000;
38
41
  const MEDIA_ASSEMBLY_TIMEOUT_MS = 500;
39
42
  const LOGICAL_BIND_TIMEOUT_MS = 5_000;
40
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;
41
47
 
42
48
  function normalizeSignalUrl(value) {
43
49
  const source = String(value || DEFAULT_SIGNAL_URL).trim();
@@ -55,6 +61,16 @@ function normalizeSignalUrl(value) {
55
61
  }
56
62
  }
57
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
+
58
74
  function normalizeStunUrls(value) {
59
75
  const values = Array.isArray(value)
60
76
  ? value
@@ -150,6 +166,7 @@ function allowedHttpRequest(method, pathname) {
150
166
  || pathname === '/api/runtime/status'
151
167
  || pathname === '/api/runtime/restart'
152
168
  || pathname === '/api/auth/status'
169
+ || pathname === '/api/auth/session'
153
170
  || pathname === '/api/remote/status'
154
171
  || pathname === '/api/hub/status'
155
172
  || pathname === '/api/update/status'
@@ -160,10 +177,12 @@ function allowedHttpRequest(method, pathname) {
160
177
  || /^\/api\/captures(?:\/|$)/.test(pathname)
161
178
  || /^\/api\/capture-sessions(?:\/|$)/.test(pathname)
162
179
  || /^\/api\/remote\/devices(?:\/|$)/.test(pathname)
180
+ || pathname === '/api/remote/wall-preferences'
163
181
  || /^\/api\/remote\/frames$/.test(pathname)
164
182
  || /^\/api\/remote\/filesystem(?:\/|$)/.test(pathname)
165
183
  || /^\/api\/remote\/files(?:\/|$)/.test(pathname)
166
184
  || /^\/api\/remote\/tasks(?:\/|$)/.test(pathname)
185
+ || /^\/api\/remote\/workspace(?:\/|$)/.test(pathname)
167
186
  || /^\/api\/remote\/license(?:\/sync)?$/.test(pathname)) {
168
187
  return pathname !== '/api/remote/registry-credentials'
169
188
  && pathname !== '/api/remote/pairing-pin'
@@ -269,6 +288,7 @@ async function readBoundedResponseBytes(response, maxBytes) {
269
288
 
270
289
  export function createHubConsoleDirect(options = {}) {
271
290
  const signalUrl = normalizeSignalUrl(options.url);
291
+ const workspaceReauthUrl = signalHttpEndpoint(signalUrl, '/v2/rtc/reauth');
272
292
  const deviceId = String(options.deviceId || '').trim();
273
293
  const requestedHubInstanceId = String(options.hubInstanceId || '').trim();
274
294
  const hubInstanceId = UUID_PATTERN.test(requestedHubInstanceId)
@@ -283,6 +303,10 @@ export function createHubConsoleDirect(options = {}) {
283
303
  const getAccessToken = typeof options.getAccessToken === 'function'
284
304
  ? options.getAccessToken
285
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();
286
310
  const createPeerConnection = typeof options.createPeerConnection === 'function'
287
311
  ? options.createPeerConnection
288
312
  : (name, config) => new nodeDataChannel.PeerConnection(name, config);
@@ -302,6 +326,20 @@ export function createHubConsoleDirect(options = {}) {
302
326
  1,
303
327
  Number(options.signalReadyTimeoutMs) || SIGNAL_READY_TIMEOUT_MS
304
328
  );
329
+ const signalHeartbeatIntervalMs = Math.max(
330
+ 1,
331
+ Number(options.signalHeartbeatIntervalMs) || SIGNAL_HEARTBEAT_INTERVAL_MS
332
+ );
333
+ const signalHeartbeatTimeoutMs = Math.max(
334
+ 1,
335
+ Number(options.signalHeartbeatTimeoutMs) || SIGNAL_HEARTBEAT_TIMEOUT_MS
336
+ );
337
+ const scheduleSignalHeartbeatTimer = typeof options.scheduleSignalHeartbeatTimer === 'function'
338
+ ? options.scheduleSignalHeartbeatTimer
339
+ : setTimeout;
340
+ const cancelSignalHeartbeatTimer = typeof options.cancelSignalHeartbeatTimer === 'function'
341
+ ? options.cancelSignalHeartbeatTimer
342
+ : clearTimeout;
305
343
  const mediaAssemblyTimeoutMs = Math.max(
306
344
  1,
307
345
  Number(options.mediaAssemblyTimeoutMs) || MEDIA_ASSEMBLY_TIMEOUT_MS
@@ -311,12 +349,15 @@ export function createHubConsoleDirect(options = {}) {
311
349
  Number(options.logicalBindTimeoutMs) || LOGICAL_BIND_TIMEOUT_MS
312
350
  );
313
351
  const peers = new Map();
352
+ const revokedMembers = new Map();
314
353
  let signalSocket = null;
315
354
  let signalGeneration = 0;
316
355
  let peerGeneration = 0;
317
356
  let retryTimer = null;
318
357
  let accessTokenAttempt = null;
358
+ let workspaceReauthAttempt = null;
319
359
  let signalReadyDeadline = null;
360
+ let signalHeartbeat = null;
320
361
  let retryAttempt = 0;
321
362
  let retryNotBeforeAt = 0;
322
363
  let nextRetryAt = '';
@@ -331,10 +372,91 @@ export function createHubConsoleDirect(options = {}) {
331
372
  let malformedWireCloses = 0;
332
373
  let wireBudgetCleanupFailures = 0;
333
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
+
334
438
  const isOwnerActive = owner => !owner.retired
335
439
  && owner.hubEpoch === hubEpoch
336
440
  && peers.get(owner.consoleId) === owner
337
- && 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
+ };
338
460
 
339
461
  const sendSignal = payload => {
340
462
  if (!socketIsOpen(signalSocket, SignalingWebSocketImpl)) return false;
@@ -418,6 +540,8 @@ export function createHubConsoleDirect(options = {}) {
418
540
  owner.iceTimer = null;
419
541
  clearTimeout(owner.disconnectTimer);
420
542
  owner.disconnectTimer = null;
543
+ clearTimeout(owner.accessTimer);
544
+ owner.accessTimer = null;
421
545
  for (const pending of owner.pendingHttp.values()) {
422
546
  clearTimeout(pending.timeout);
423
547
  pending.cancelled = true;
@@ -447,6 +571,24 @@ export function createHubConsoleDirect(options = {}) {
447
571
  }
448
572
  };
449
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
+
450
592
  const retireAllPeers = reason => {
451
593
  for (const owner of [...peers.values()]) retirePeer(owner, reason, { notify: false });
452
594
  };
@@ -599,6 +741,15 @@ export function createHubConsoleDirect(options = {}) {
599
741
  sendHttpResponse(owner, requestId, { error: 'console-route-not-allowed' });
600
742
  return;
601
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
+ }
602
753
  if (owner.pendingHttp.has(requestId)) {
603
754
  sendHttpResponse(owner, requestId, { error: 'console-http-request-duplicate' });
604
755
  return;
@@ -607,12 +758,20 @@ export function createHubConsoleDirect(options = {}) {
607
758
  sendHttpResponse(owner, requestId, { error: 'console-http-capacity-reached' });
608
759
  return;
609
760
  }
761
+ const isAuthSessionBootstrap = method === 'POST'
762
+ && requestUrl.pathname === '/api/auth/session';
610
763
  let body;
611
- try {
612
- body = decodeBase64Bounded(payload?.bodyBase64, MAX_HTTP_REQUEST_BYTES);
613
- } catch (error) {
614
- sendHttpResponse(owner, requestId, { error: error instanceof Error ? error.message : error });
615
- 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
+ }
616
775
  }
617
776
  const controller = new AbortController();
618
777
  const pending = { controller, timeout: null, cancelled: false };
@@ -625,7 +784,12 @@ export function createHubConsoleDirect(options = {}) {
625
784
  try {
626
785
  const response = await fetchImpl(requestUrl.href, {
627
786
  method,
628
- headers: safeContentHeaders(payload?.headers),
787
+ headers: {
788
+ ...(isAuthSessionBootstrap
789
+ ? { 'content-type': 'application/json' }
790
+ : safeContentHeaders(payload?.headers)),
791
+ ...consoleProxyHeaders(owner)
792
+ },
629
793
  body: ['GET', 'HEAD'].includes(method) || body.byteLength === 0 ? undefined : body,
630
794
  signal: controller.signal
631
795
  });
@@ -697,7 +861,8 @@ export function createHubConsoleDirect(options = {}) {
697
861
  if (channelState.openRequested
698
862
  || channelId !== channelState.channelId
699
863
  || purpose !== channelState.purpose
700
- || !allowedWebSocketPath(path, purpose)) {
864
+ || !allowedWebSocketPath(path, purpose)
865
+ || !workspaceRoleCanRequest(owner.workspaceAccess?.role, 'GET', String(path || '').split('?')[0])) {
701
866
  sendLifecycleControl(owner, {
702
867
  type: 'ws-error',
703
868
  channelId: channelState.channelId,
@@ -710,6 +875,15 @@ export function createHubConsoleDirect(options = {}) {
710
875
  });
711
876
  return;
712
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
+ }
713
887
  channelState.openRequested = true;
714
888
  const localUrl = new URL(path, httpBaseUrl);
715
889
  const httpOrigin = new URL(httpBaseUrl).origin;
@@ -720,7 +894,10 @@ export function createHubConsoleDirect(options = {}) {
720
894
  localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
721
895
  let socket;
722
896
  try {
723
- socket = new LocalWebSocketImpl(localUrl, { perMessageDeflate: false });
897
+ socket = new LocalWebSocketImpl(localUrl, {
898
+ headers: consoleProxyHeaders(owner),
899
+ perMessageDeflate: false
900
+ });
724
901
  } catch (error) {
725
902
  sendLifecycleControl(owner, {
726
903
  type: 'ws-error',
@@ -871,6 +1048,10 @@ export function createHubConsoleDirect(options = {}) {
871
1048
 
872
1049
  const handleDataChannelMessage = (owner, channelState, raw) => {
873
1050
  if (!isOwnerActive(owner) || channelState.closed) return;
1051
+ if (!ownerWorkspaceAccessCurrent(owner)) {
1052
+ retirePeer(owner, 'console-workspace-access-expired');
1053
+ return;
1054
+ }
874
1055
  try {
875
1056
  if (typeof raw === 'string') throw new Error('console-direct-unframed-message');
876
1057
  const message = channelState.assembler.push(raw);
@@ -991,12 +1172,33 @@ export function createHubConsoleDirect(options = {}) {
991
1172
  const connectionId = String(payload?.connectionId || '');
992
1173
  const ownerHubEpoch = String(payload?.hubEpoch || '');
993
1174
  const description = payload?.description;
1175
+ const workspaceAccess = consoleWorkspaceAccess(payload);
994
1176
  if (!UUID_PATTERN.test(consoleId)
995
1177
  || !UUID_PATTERN.test(connectionId)
996
1178
  || ownerHubEpoch !== hubEpoch
1179
+ || !workspaceAccess
997
1180
  || description?.type !== 'offer'
998
1181
  || typeof description.sdp !== 'string'
999
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
+ });
1000
1202
  return null;
1001
1203
  }
1002
1204
  if (candidateIsRelay(description.sdp)) {
@@ -1062,6 +1264,7 @@ export function createHubConsoleDirect(options = {}) {
1062
1264
  hubEpoch: ownerHubEpoch,
1063
1265
  generation: ++peerGeneration,
1064
1266
  peer,
1267
+ workspaceAccess,
1065
1268
  control: null,
1066
1269
  channels: new Map(),
1067
1270
  pendingLogicalControl: new Map(),
@@ -1069,12 +1272,14 @@ export function createHubConsoleDirect(options = {}) {
1069
1272
  wireBudget: createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
1070
1273
  iceTimer: null,
1071
1274
  disconnectTimer: null,
1275
+ accessTimer: null,
1072
1276
  peerState: 'new',
1073
1277
  iceState: 'new',
1074
1278
  retired: false,
1075
1279
  createdAt: Date.now()
1076
1280
  };
1077
1281
  peers.set(consoleId, owner);
1282
+ armOwnerAccessDeadline(owner);
1078
1283
  peer.onLocalDescription((sdp, type) => {
1079
1284
  if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
1080
1285
  if (candidateIsRelay(sdp)) {
@@ -1160,10 +1365,17 @@ export function createHubConsoleDirect(options = {}) {
1160
1365
  if (!payload?.type) return;
1161
1366
  if (payload.type === 'signal-ready' && payload.role === 'hub') {
1162
1367
  const nextEpoch = String(payload.hubEpoch || '');
1368
+ const signaledWorkspaceId = String(payload.workspaceId || '').trim();
1369
+ const expectedWorkspaceId = currentHubWorkspaceAccess().workspaceId;
1163
1370
  if (!UUID_PATTERN.test(nextEpoch)) {
1164
1371
  lastError = 'console-direct-hub-epoch-invalid';
1165
1372
  return;
1166
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
+ }
1167
1379
  clearSignalReadyDeadline(ownerSignalGeneration, socket);
1168
1380
  if (hubEpoch && hubEpoch !== nextEpoch) retireAllPeers('console-direct-hub-epoch-replaced');
1169
1381
  hubEpoch = nextEpoch;
@@ -1174,8 +1386,85 @@ export function createHubConsoleDirect(options = {}) {
1174
1386
  retryAttempt = 0;
1175
1387
  retryNotBeforeAt = 0;
1176
1388
  nextRetryAt = '';
1389
+ armSignalHeartbeat(ownerSignalGeneration, socket, connect);
1390
+ return;
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);
1177
1414
  return;
1178
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;
1179
1468
  const consoleId = String(payload.consoleId || '');
1180
1469
  const connectionId = String(payload.connectionId || '');
1181
1470
  const ownerHubEpoch = String(payload.hubEpoch || '');
@@ -1284,6 +1573,114 @@ export function createHubConsoleDirect(options = {}) {
1284
1573
  signalReadyDeadline = deadline;
1285
1574
  };
1286
1575
 
1576
+ const clearSignalHeartbeat = (ownerSignalGeneration, socket) => {
1577
+ const heartbeat = signalHeartbeat;
1578
+ if (!heartbeat
1579
+ || (ownerSignalGeneration !== undefined && heartbeat.generation !== ownerSignalGeneration)
1580
+ || (socket !== undefined && heartbeat.socket !== socket)) {
1581
+ return false;
1582
+ }
1583
+ signalHeartbeat = null;
1584
+ cancelSignalHeartbeatTimer(heartbeat.timer);
1585
+ return true;
1586
+ };
1587
+
1588
+ const failSignalHeartbeat = (heartbeat, connect, reason) => {
1589
+ if (signalHeartbeat !== heartbeat
1590
+ || stopped
1591
+ || heartbeat.generation !== signalGeneration
1592
+ || signalSocket !== heartbeat.socket) {
1593
+ return;
1594
+ }
1595
+ clearSignalHeartbeat(heartbeat.generation, heartbeat.socket);
1596
+ signalSocket = null;
1597
+ lastHttpStatus = 0;
1598
+ lastError = reason;
1599
+ retireNegotiatingPeers(reason);
1600
+ try { heartbeat.socket.terminate?.(); } catch {}
1601
+ scheduleReconnect(connect);
1602
+ };
1603
+
1604
+ const scheduleSignalHeartbeatPing = (heartbeat, connect) => {
1605
+ if (signalHeartbeat !== heartbeat
1606
+ || stopped
1607
+ || heartbeat.generation !== signalGeneration
1608
+ || signalSocket !== heartbeat.socket) {
1609
+ return;
1610
+ }
1611
+ heartbeat.awaitingPong = false;
1612
+ let pingTimer = null;
1613
+ pingTimer = scheduleSignalHeartbeatTimer(() => {
1614
+ if (signalHeartbeat !== heartbeat
1615
+ || heartbeat.timer !== pingTimer
1616
+ || stopped
1617
+ || heartbeat.generation !== signalGeneration
1618
+ || signalSocket !== heartbeat.socket) {
1619
+ return;
1620
+ }
1621
+ heartbeat.awaitingPong = true;
1622
+ let timeoutTimer = null;
1623
+ timeoutTimer = scheduleSignalHeartbeatTimer(() => {
1624
+ if (signalHeartbeat !== heartbeat
1625
+ || heartbeat.timer !== timeoutTimer
1626
+ || !heartbeat.awaitingPong
1627
+ || stopped
1628
+ || heartbeat.generation !== signalGeneration
1629
+ || signalSocket !== heartbeat.socket) {
1630
+ return;
1631
+ }
1632
+ failSignalHeartbeat(
1633
+ heartbeat,
1634
+ connect,
1635
+ 'console-direct-signal-heartbeat-timeout'
1636
+ );
1637
+ }, signalHeartbeatTimeoutMs);
1638
+ heartbeat.timer = timeoutTimer;
1639
+ heartbeat.timer.unref?.();
1640
+ try {
1641
+ if (heartbeat.socket.readyState !== WebSocket.OPEN
1642
+ || typeof heartbeat.socket.ping !== 'function') {
1643
+ throw new Error('console-direct-signal-heartbeat-unavailable');
1644
+ }
1645
+ heartbeat.socket.ping();
1646
+ } catch {
1647
+ failSignalHeartbeat(
1648
+ heartbeat,
1649
+ connect,
1650
+ 'console-direct-signal-heartbeat-failed'
1651
+ );
1652
+ }
1653
+ }, signalHeartbeatIntervalMs);
1654
+ heartbeat.timer = pingTimer;
1655
+ heartbeat.timer.unref?.();
1656
+ };
1657
+
1658
+ const armSignalHeartbeat = (ownerSignalGeneration, socket, connect) => {
1659
+ clearSignalHeartbeat();
1660
+ const heartbeat = {
1661
+ generation: ownerSignalGeneration,
1662
+ socket,
1663
+ timer: null,
1664
+ awaitingPong: false
1665
+ };
1666
+ signalHeartbeat = heartbeat;
1667
+ scheduleSignalHeartbeatPing(heartbeat, connect);
1668
+ };
1669
+
1670
+ const acceptSignalHeartbeatPong = (ownerSignalGeneration, socket, connect) => {
1671
+ const heartbeat = signalHeartbeat;
1672
+ if (!heartbeat
1673
+ || heartbeat.generation !== ownerSignalGeneration
1674
+ || heartbeat.socket !== socket
1675
+ || !heartbeat.awaitingPong) {
1676
+ return false;
1677
+ }
1678
+ cancelSignalHeartbeatTimer(heartbeat.timer);
1679
+ heartbeat.timer = null;
1680
+ scheduleSignalHeartbeatPing(heartbeat, connect);
1681
+ return true;
1682
+ };
1683
+
1287
1684
  const scheduleReconnect = connect => {
1288
1685
  if (stopped || retryTimer || !(signalUrl instanceof URL)) return;
1289
1686
  const sequenceDelay = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
@@ -1329,6 +1726,10 @@ export function createHubConsoleDirect(options = {}) {
1329
1726
  const url = new URL(signalUrl);
1330
1727
  url.searchParams.set('deviceId', deviceId);
1331
1728
  url.searchParams.set('hubInstanceId', hubInstanceId);
1729
+ const hubWorkspaceAccess = currentHubWorkspaceAccess();
1730
+ if (hubWorkspaceAccess.workspaceId) {
1731
+ url.searchParams.set('workspaceId', hubWorkspaceAccess.workspaceId);
1732
+ }
1332
1733
  let socket;
1333
1734
  try {
1334
1735
  socket = new SignalingWebSocketImpl(url, {
@@ -1374,12 +1775,16 @@ export function createHubConsoleDirect(options = {}) {
1374
1775
  handleSignalMessage(raw, ownerSignalGeneration, socket);
1375
1776
  }
1376
1777
  });
1778
+ socket.on('pong', () => {
1779
+ acceptSignalHeartbeatPong(ownerSignalGeneration, socket, connect);
1780
+ });
1377
1781
  socket.once('close', () => {
1378
1782
  const wasOwner = signalSocket === socket
1379
1783
  && ownerSignalGeneration === signalGeneration
1380
1784
  && !stopped;
1381
1785
  if (signalSocket === socket) signalSocket = null;
1382
1786
  clearSignalReadyDeadline(ownerSignalGeneration, socket);
1787
+ clearSignalHeartbeat(ownerSignalGeneration, socket);
1383
1788
  if (!wasOwner) return;
1384
1789
  retireNegotiatingPeers('console-direct-signaling-closed');
1385
1790
  if (!retryTimer) state = 'disconnected';
@@ -1393,6 +1798,7 @@ export function createHubConsoleDirect(options = {}) {
1393
1798
  }
1394
1799
  signalSocket = null;
1395
1800
  clearSignalReadyDeadline(ownerSignalGeneration, socket);
1801
+ clearSignalHeartbeat(ownerSignalGeneration, socket);
1396
1802
  retireNegotiatingPeers('console-direct-signaling-error');
1397
1803
  try { socket.terminate?.(); } catch {}
1398
1804
  scheduleReconnect(connect);
@@ -1411,8 +1817,12 @@ export function createHubConsoleDirect(options = {}) {
1411
1817
  return;
1412
1818
  }
1413
1819
  signalGeneration += 1;
1820
+ try { workspaceReauthAttempt?.controller?.abort?.(); } catch {}
1821
+ if (workspaceReauthAttempt?.timer) clearTimeout(workspaceReauthAttempt.timer);
1822
+ workspaceReauthAttempt = null;
1414
1823
  cancelAccessTokenAttempt();
1415
1824
  clearSignalReadyDeadline();
1825
+ clearSignalHeartbeat();
1416
1826
  if (retryTimer) clearTimeout(retryTimer);
1417
1827
  retryTimer = null;
1418
1828
  retryAttempt = 0;
@@ -1429,8 +1839,12 @@ export function createHubConsoleDirect(options = {}) {
1429
1839
  if (stopped) return;
1430
1840
  stopped = true;
1431
1841
  signalGeneration += 1;
1842
+ try { workspaceReauthAttempt?.controller?.abort?.(); } catch {}
1843
+ if (workspaceReauthAttempt?.timer) clearTimeout(workspaceReauthAttempt.timer);
1844
+ workspaceReauthAttempt = null;
1432
1845
  cancelAccessTokenAttempt();
1433
1846
  clearSignalReadyDeadline();
1847
+ clearSignalHeartbeat();
1434
1848
  if (retryTimer) clearTimeout(retryTimer);
1435
1849
  retryTimer = null;
1436
1850
  nextRetryAt = '';
@@ -1443,6 +1857,31 @@ export function createHubConsoleDirect(options = {}) {
1443
1857
  state = signalUrl ? 'closed' : 'disabled';
1444
1858
  };
1445
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
+
1446
1885
  const inspect = () => {
1447
1886
  let logicalWebSocketChannels = 0;
1448
1887
  let localWebSocketChannels = 0;
@@ -1454,12 +1893,14 @@ export function createHubConsoleDirect(options = {}) {
1454
1893
  let iceConnectTimers = 0;
1455
1894
  let peerDisconnectTimers = 0;
1456
1895
  let assemblyDeadlineTimers = 0;
1896
+ let workspaceAccessTimers = 0;
1457
1897
  for (const owner of peers.values()) {
1458
1898
  logicalWebSocketChannels += owner.channels.size;
1459
1899
  pendingHttpRequests += owner.pendingHttp.size;
1460
1900
  pendingLogicalControlRequests += owner.pendingLogicalControl.size;
1461
1901
  if (owner.iceTimer) iceConnectTimers += 1;
1462
1902
  if (owner.disconnectTimer) peerDisconnectTimers += 1;
1903
+ if (owner.accessTimer) workspaceAccessTimers += 1;
1463
1904
  if (owner.control && !owner.control.closed) controlChannels += 1;
1464
1905
  if (owner.control?.assemblyTimer) assemblyDeadlineTimers += 1;
1465
1906
  for (const channelState of owner.channels.values()) {
@@ -1472,6 +1913,7 @@ export function createHubConsoleDirect(options = {}) {
1472
1913
  const reconnectTimerActive = Boolean(retryTimer);
1473
1914
  const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
1474
1915
  const signalReadyDeadlineActive = Boolean(signalReadyDeadline);
1916
+ const signalHeartbeatTimerActive = Boolean(signalHeartbeat?.timer);
1475
1917
  return Object.freeze({
1476
1918
  enabled: signalUrl instanceof URL && Boolean(deviceId),
1477
1919
  state,
@@ -1488,9 +1930,13 @@ export function createHubConsoleDirect(options = {}) {
1488
1930
  iceConnectTimers,
1489
1931
  peerDisconnectTimers,
1490
1932
  assemblyDeadlineTimers,
1933
+ workspaceAccessTimers,
1934
+ revokedMemberFences: (pruneRevokedMembers(), revokedMembers.size),
1491
1935
  reconnectTimerActive,
1492
1936
  accessTokenDeadlineActive,
1493
1937
  signalReadyDeadlineActive,
1938
+ signalHeartbeatTimerActive,
1939
+ signalHeartbeatAwaitingPong: signalHeartbeat?.awaitingPong === true,
1494
1940
  resourceTimers: iceConnectTimers
1495
1941
  + peerDisconnectTimers
1496
1942
  + assemblyDeadlineTimers
@@ -1498,7 +1944,10 @@ export function createHubConsoleDirect(options = {}) {
1498
1944
  + pendingLogicalControlRequests
1499
1945
  + Number(reconnectTimerActive)
1500
1946
  + Number(accessTokenDeadlineActive)
1501
- + Number(signalReadyDeadlineActive),
1947
+ + workspaceAccessTimers
1948
+ + Number(Boolean(workspaceReauthAttempt?.timer))
1949
+ + Number(signalReadyDeadlineActive)
1950
+ + Number(signalHeartbeatTimerActive),
1502
1951
  rejectedRelayCandidates,
1503
1952
  dataChannelBackpressureCloses,
1504
1953
  malformedWireCloses,
@@ -1511,7 +1960,14 @@ export function createHubConsoleDirect(options = {}) {
1511
1960
  });
1512
1961
  };
1513
1962
 
1514
- return Object.freeze({ start, refresh, close, inspect });
1963
+ return Object.freeze({
1964
+ start,
1965
+ refresh,
1966
+ close,
1967
+ invalidateWorkspaceAccess,
1968
+ revokeWorkspaceMember,
1969
+ inspect
1970
+ });
1515
1971
  }
1516
1972
 
1517
1973
  export const consoleDirectContract = Object.freeze({
@@ -1530,6 +1986,8 @@ export const consoleDirectContract = Object.freeze({
1530
1986
  maxDefaultRetryDelayMs: DEFAULT_RETRY_DELAYS_MS.at(-1),
1531
1987
  accessTokenTimeoutMs: ACCESS_TOKEN_TIMEOUT_MS,
1532
1988
  signalReadyTimeoutMs: SIGNAL_READY_TIMEOUT_MS,
1989
+ signalHeartbeatIntervalMs: SIGNAL_HEARTBEAT_INTERVAL_MS,
1990
+ signalHeartbeatTimeoutMs: SIGNAL_HEARTBEAT_TIMEOUT_MS,
1533
1991
  logicalBindTimeoutMs: LOGICAL_BIND_TIMEOUT_MS,
1534
1992
  iceConnectTimeoutMs: ICE_CONNECT_TIMEOUT_MS,
1535
1993
  peerDisconnectedTimeoutMs: PEER_DISCONNECTED_TIMEOUT_MS,