@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.
- package/package.json +1 -1
- package/src/auth/workspace-access.js +176 -0
- package/src/console-direct.js +329 -10
- package/src/console-direct.test.mjs +332 -3
- package/src/control-presentation-borrow-contract.test.mjs +999 -0
- package/src/http/hub-ui-session.js +155 -32
- package/src/live-stream-monitor-contract.js +195 -3
- package/src/remote-hub.js +108 -56
- package/src/server.js +1324 -396
- package/src/settings/settings-schema.js +25 -6
- package/src/settings/settings-store.js +9 -8
- package/src/wall-source-restart-contract.test.mjs +48 -0
- package/src/wall-source-restart-runtime.test.mjs +146 -0
package/src/console-direct.js
CHANGED
|
@@ -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
|
-
|
|
629
|
-
body =
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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:
|
|
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, {
|
|
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({
|
|
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({
|