@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/src/server.js CHANGED
@@ -30,7 +30,10 @@ import {
30
30
  observeRemoteAudioStopConfirmation,
31
31
  remoteAudioSubscriberOwns
32
32
  } from './remote-audio-subscription-contract.mjs';
33
- import { isReusedLiveStreamFrameReady } from './live-stream-monitor-contract.js';
33
+ import {
34
+ createReadOnlyControlPresentationReconcileCoordinator,
35
+ isReusedLiveStreamFrameReady
36
+ } from './live-stream-monitor-contract.js';
34
37
  import { createLiveCaptureTransitionRetryCoordinator } from './live-capture-transition-retry.mjs';
35
38
  import { buildMode4AtlasSessionKey, Mode4AtlasPool, planMode4AtlasInputTransitions } from './mode4-atlas-pool.js';
36
39
  import { resolveMode4AtlasTileSize } from './mode4-atlas-sizing.js';
@@ -60,11 +63,18 @@ import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '..
60
63
  import { createHubRuntime } from './runtime/hub-runtime.js';
61
64
  import { hubRuntimeStatus } from './runtime/hub-runtime-status.js';
62
65
  import { roleGuardResponse } from './http/hub-role-guard.js';
63
- import {
64
- clearHubUiSessionCookie,
65
- createHubUiSessionAuthority,
66
- serializeHubUiSessionCookie
67
- } from './http/hub-ui-session.js';
66
+ import {
67
+ clearHubUiSessionCookie,
68
+ createHubUiSessionAuthority,
69
+ serializeHubUiSessionCookie
70
+ } from './http/hub-ui-session.js';
71
+ import {
72
+ resolveWorkspaceAccess,
73
+ WorkspaceAccessError,
74
+ workspaceEntitlementCanRequest,
75
+ workspaceRoleCanControl,
76
+ workspaceRoleCanRequest
77
+ } from './auth/workspace-access.js';
68
78
 
69
79
  const __dirname = dirname(fileURLToPath(import.meta.url));
70
80
  const webDistCandidates = [
@@ -176,7 +186,8 @@ const supabasePublishableKey = String(configuredSupabaseKey || PRODUCTION_SUPABA
176
186
  const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
177
187
  ? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
178
188
  : AUTH_REQUEST_TIMEOUT_MS;
179
- const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
189
+ const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
190
+ const HUB_WORKSPACE_STORAGE_KEY = 'livedesk.hub.workspace';
180
191
  const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
181
192
  ? ''
182
193
  : String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
@@ -194,10 +205,18 @@ const persistentSessionGcToken = persistentSessionGcEnabled
194
205
  ? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
195
206
  : '';
196
207
  let connectedDeviceCount = 0;
197
- let runtimeAccessToken = '';
198
- let runtimeRefreshToken = '';
199
- let runtimeAccessTokenExpiresAt = 0;
200
- const hubUiSessionAuthority = createHubUiSessionAuthority();
208
+ let runtimeAccessToken = '';
209
+ let runtimeRefreshToken = '';
210
+ let runtimeAccessTokenExpiresAt = 0;
211
+ let runtimeAuthEpoch = 0;
212
+ let runtimeAccessTokenRefreshOwner = null;
213
+ let runtimeWorkspaceAccess = null;
214
+ let workspaceAccessLastAttemptAt = 0;
215
+ let licenseRefreshLastAttemptAt = 0;
216
+ const hubUiSessionAuthority = createHubUiSessionAuthority({
217
+ onRevoke: (session, reason) => retireHubUiSessionSockets(session, reason)
218
+ });
219
+ const consoleProxyToken = crypto.randomBytes(32).toString('base64url');
201
220
  let roleWatchInFlight = false;
202
221
  let verifiedLicense = {
203
222
  userId: '',
@@ -217,7 +236,16 @@ const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
217
236
  readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
218
237
  Math.max(1000, HUB_HOST_TARGET_LEASE_MS - 1000)
219
238
  ));
220
- const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
239
+ const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
240
+ const WORKSPACE_ACCESS_REVERIFY_MS = Math.max(
241
+ 30_000,
242
+ Math.min(5 * 60_000, readPositiveIntegerEnv('LIVEDESK_WORKSPACE_ACCESS_REVERIFY_MS', 60_000))
243
+ );
244
+ const LICENSE_REFRESH_INTERVAL_MS = Math.max(
245
+ 30 * 60_000,
246
+ readPositiveIntegerEnv('LIVEDESK_LICENSE_REFRESH_INTERVAL_MS', 30 * 60_000)
247
+ );
248
+ const TEAM_UI_ACCESS_MAX_AGE_MS = 60_000;
221
249
  const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
222
250
  const hubConsoleDirectSignalBaseUrl = String(
223
251
  process.env.LIVEDESK_CONSOLE_SIGNAL_URL
@@ -284,6 +312,52 @@ function broadcastRemoteInputRouteState(deviceId, reason = '') {
284
312
  }
285
313
  }
286
314
 
315
+ function reconcileReadOnlyControlPresentationSubscribers(deviceId) {
316
+ const normalizedDeviceId = String(deviceId || '').trim();
317
+ if (!normalizedDeviceId) return;
318
+ const clients = new Set([
319
+ ...(frameClientsByDeviceId.get(normalizedDeviceId) || []),
320
+ ...frameWildcardClients
321
+ ]);
322
+ for (const ws of clients) {
323
+ const liveOptions = ws.liveDeskLiveOptions;
324
+ if (ws.readyState !== 1
325
+ || ws.liveDeskAutoStart !== true
326
+ || !(ws.liveDeskDeviceIds instanceof Set)
327
+ || !ws.liveDeskDeviceIds.has(normalizedDeviceId)
328
+ || liveOptions?.allowReadOnlyControlBorrow !== true
329
+ || String(liveOptions?.streamPurpose || '').trim().toLowerCase() !== 'wall') {
330
+ continue;
331
+ }
332
+ startFrameSubscriptionLive(ws, 'control-borrow-reconcile', normalizedDeviceId, {
333
+ ...liveOptions,
334
+ forceRestart: false,
335
+ reuseExisting: true
336
+ });
337
+ }
338
+ }
339
+
340
+ const readOnlyControlPresentationReconcileCoordinator =
341
+ createReadOnlyControlPresentationReconcileCoordinator({
342
+ onReconcile: reconcileReadOnlyControlPresentationSubscribers
343
+ });
344
+
345
+ function readRemoteLiveStreamEventPurpose(event) {
346
+ const explicitPurpose = String(event?.streamPurpose || '').trim().toLowerCase();
347
+ if (explicitPurpose) return explicitPurpose;
348
+ const commandId = String(event?.commandId || '').trim();
349
+ const activeStream = event?.device?.activeLiveStream;
350
+ if (!commandId || !activeStream) return '';
351
+ const pendingDescriptor = activeStream.pendingDescriptor;
352
+ if (String(pendingDescriptor?.commandId || '').trim() === commandId) {
353
+ return String(pendingDescriptor?.streamPurpose || '').trim().toLowerCase();
354
+ }
355
+ if (String(activeStream.commandId || '').trim() === commandId) {
356
+ return String(activeStream.streamPurpose || '').trim().toLowerCase();
357
+ }
358
+ return '';
359
+ }
360
+
287
361
  function handleRemoteHubEvent(type, event) {
288
362
  liveDeskUpdateManager?.handleRemoteEvent(type, event);
289
363
  hubTransferJobs?.handleRemoteEvent(type, event);
@@ -338,9 +412,32 @@ function handleRemoteHubEvent(type, event) {
338
412
  broadcastRemoteInputRouteState(deviceId, event?.reason || type);
339
413
  return;
340
414
  }
415
+ if (type === 'RemoteLiveStreamStarted'
416
+ || type === 'RemoteLiveStreamOpened'
417
+ || type === 'RemoteLiveStreamReady'
418
+ || type === 'RemoteLiveStreamStopped') {
419
+ const liveStreamEventDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
420
+ const liveStreamEventPurpose = readRemoteLiveStreamEventPurpose(event);
421
+ if ((type === 'RemoteLiveStreamStarted' || type === 'RemoteLiveStreamOpened')
422
+ && liveStreamEventPurpose === 'control') {
423
+ readOnlyControlPresentationReconcileCoordinator
424
+ .cancelForControlTransition(liveStreamEventDeviceId);
425
+ } else if (type === 'RemoteLiveStreamReady' && liveStreamEventPurpose === 'control') {
426
+ readOnlyControlPresentationReconcileCoordinator
427
+ .reconcileReadyControl(liveStreamEventDeviceId);
428
+ } else if (type === 'RemoteLiveStreamStopped'
429
+ && liveStreamEventPurpose === 'control'
430
+ && event?.captureStopConfirmed === true) {
431
+ readOnlyControlPresentationReconcileCoordinator
432
+ .scheduleAfterConfirmedStop(liveStreamEventDeviceId);
433
+ }
434
+ }
341
435
  if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
342
436
  connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
343
437
  const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
438
+ if (type === 'RemoteDeviceDisconnected') {
439
+ readOnlyControlPresentationReconcileCoordinator.cancelForControlTransition(deviceId);
440
+ }
344
441
  broadcastRemoteInputRouteState(deviceId, event?.reason || type);
345
442
  }
346
443
  if (type !== 'RemoteDeviceConnected') {
@@ -368,7 +465,7 @@ function handleRemoteHubEvent(type, event) {
368
465
  }
369
466
  }
370
467
 
371
- function activeLicensePlan() {
468
+ function activeLicensePlan() {
372
469
  if (testLicensePlan) {
373
470
  return testLicensePlan;
374
471
  }
@@ -381,12 +478,22 @@ function activeLicensePlan() {
381
478
  if (Number.isFinite(expiresAt) && expiresAt <= now) {
382
479
  return 'free';
383
480
  }
384
- return verifiedLicense.plan === 'pro' ? 'pro' : verifiedLicense.plan === 'ltd' ? 'ltd' : 'free';
385
- }
386
-
387
- function activeDeviceLimit() {
388
- const plan = activeLicensePlan();
389
- return plan === 'pro' ? Number.POSITIVE_INFINITY : plan === 'ltd' ? PLUS_DEVICE_LIMIT : FREE_DEVICE_LIMIT;
481
+ return verifiedLicense.plan === 'team'
482
+ ? 'team'
483
+ : verifiedLicense.plan === 'pro'
484
+ ? 'pro'
485
+ : verifiedLicense.plan === 'ltd'
486
+ ? 'ltd'
487
+ : 'free';
488
+ }
489
+
490
+ function activeDeviceLimit() {
491
+ const plan = activeLicensePlan();
492
+ return plan === 'team' || plan === 'pro'
493
+ ? Number.POSITIVE_INFINITY
494
+ : plan === 'ltd'
495
+ ? PLUS_DEVICE_LIMIT
496
+ : FREE_DEVICE_LIMIT;
390
497
  }
391
498
 
392
499
  function hasHubFeatureAccess() {
@@ -409,7 +516,7 @@ function hasHubFeatureAccessForRequest(req) {
409
516
  return ids.size <= limit;
410
517
  }
411
518
 
412
- function licenseSnapshot() {
519
+ function licenseSnapshot() {
413
520
  const plan = activeLicensePlan();
414
521
  const limit = activeDeviceLimit();
415
522
  return {
@@ -417,47 +524,80 @@ function licenseSnapshot() {
417
524
  status: plan === 'free' ? 'free' : 'active',
418
525
  deviceLimit: Number.isFinite(limit) ? limit : null,
419
526
  connectedDeviceCount,
420
- featureAccess: connectedDeviceCount <= limit,
421
- verifiedAt: verifiedLicense.verifiedAt ? new Date(verifiedLicense.verifiedAt).toISOString() : '',
527
+ featureAccess: connectedDeviceCount <= limit,
528
+ workspaceId: verifiedLicense.workspaceId || runtimeWorkspaceAccess?.workspaceId || '',
529
+ workspaceKind: verifiedLicense.workspaceKind || runtimeWorkspaceAccess?.workspaceKind || 'personal',
530
+ memberLimit: Number.isSafeInteger(verifiedLicense.memberLimit) ? verifiedLicense.memberLimit : 1,
531
+ commercialUse: verifiedLicense.commercialUse === true,
532
+ verifiedAt: verifiedLicense.verifiedAt ? new Date(verifiedLicense.verifiedAt).toISOString() : '',
422
533
  expiresAt: verifiedLicense.expiresAt || ''
423
534
  };
424
- }
425
-
426
- async function syncVerifiedLicense(accessToken) {
427
- const token = String(accessToken || '').trim();
535
+ }
536
+
537
+ function workspaceAccessSnapshot(access = runtimeWorkspaceAccess) {
538
+ if (!access?.workspaceId) return null;
539
+ return {
540
+ workspaceId: access.workspaceId,
541
+ name: access.workspaceName || '',
542
+ kind: access.workspaceKind || 'personal',
543
+ role: access.role || '',
544
+ membershipRevision: Math.max(0, Number(access.membershipRevision || 0)),
545
+ plan: access.plan || 'free',
546
+ status: access.entitlementStatus || 'inactive',
547
+ deviceLimit: access.deviceLimit,
548
+ memberLimit: access.memberLimit,
549
+ commercialUse: access.commercialUse === true,
550
+ verifiedAt: access.verifiedAt ? new Date(access.verifiedAt).toISOString() : ''
551
+ };
552
+ }
553
+
554
+ function workspaceRemoteAccessActive(access = runtimeWorkspaceAccess) {
555
+ return Boolean(access?.workspaceId) && (access.workspaceKind !== 'team'
556
+ || (access?.plan === 'team' && access?.entitlementStatus === 'active'));
557
+ }
558
+
559
+ async function verifyWorkspaceAccess(accessToken, user, requestedWorkspaceId = '') {
560
+ return resolveWorkspaceAccess({
561
+ accessToken,
562
+ requestedWorkspaceId,
563
+ userId: user?.id || '',
564
+ supabaseUrl,
565
+ supabasePublishableKey,
566
+ fetchResponse: (url, options, timeoutMs) => fetchAuthResponse(fetch, url, options, timeoutMs),
567
+ timeoutMs: SUPABASE_AUTH_TIMEOUT_MS
568
+ });
569
+ }
570
+
571
+ function applyVerifiedWorkspaceLicense(access) {
572
+ verifiedLicense = {
573
+ userId: access.userId || '',
574
+ workspaceId: access.workspaceId,
575
+ workspaceKind: access.workspaceKind,
576
+ plan: ['ltd', 'pro', 'team'].includes(access.plan) ? access.plan : 'free',
577
+ status: access.entitlementStatus === 'active' ? 'active' : 'inactive',
578
+ expiresAt: '',
579
+ memberLimit: access.memberLimit,
580
+ commercialUse: access.commercialUse === true,
581
+ verifiedAt: Date.now()
582
+ };
583
+ return licenseSnapshot();
584
+ }
585
+
586
+ async function syncVerifiedLicense(accessToken, requestedWorkspaceId = '') {
587
+ const token = String(accessToken || '').trim();
428
588
  if (!token) {
429
589
  throw new Error('supabase-access-token-required');
430
- }
431
- const user = await verifySupabaseUser(token);
432
- const headers = {
433
- apikey: supabasePublishableKey,
434
- Authorization: `Bearer ${token}`,
435
- Accept: 'application/json'
436
- };
437
- const userId = user.id;
438
- const query = new URLSearchParams({
439
- select: 'user_id,product_key,plan,status,expires_at,updated_at',
440
- user_id: `eq.${userId}`,
441
- product_key: 'eq.livedesk',
442
- limit: '1'
443
- });
444
- const entitlementResponse = await fetch(`${supabaseUrl}/rest/v1/livedesk_entitlements?${query}`, { headers });
445
- if (!entitlementResponse.ok) {
446
- throw new Error(`supabase-entitlement-query-failed:${entitlementResponse.status}`);
447
- }
448
- const rows = await entitlementResponse.json();
449
- const entitlement = Array.isArray(rows) ? rows[0] : null;
450
- const plan = entitlement?.plan === 'pro' ? 'pro' : entitlement?.plan === 'ltd' ? 'ltd' : 'free';
451
- const status = entitlement?.status === 'active' ? 'active' : 'inactive';
452
- verifiedLicense = {
453
- userId,
454
- plan,
455
- status,
456
- expiresAt: String(entitlement?.expires_at || ''),
457
- verifiedAt: Date.now()
458
- };
459
- return licenseSnapshot();
460
- }
590
+ }
591
+ const user = await verifySupabaseUser(token);
592
+ const selectedWorkspaceId = String(
593
+ requestedWorkspaceId
594
+ || runtimeWorkspaceAccess?.workspaceId
595
+ || readPersistedWorkspaceId()
596
+ || ''
597
+ ).trim();
598
+ const access = await verifyWorkspaceAccess(token, user, selectedWorkspaceId);
599
+ return applyVerifiedWorkspaceLicense(access);
600
+ }
461
601
 
462
602
  function normalizeSessionExpiryMs(value) {
463
603
  const numeric = Number(value || 0);
@@ -475,7 +615,7 @@ function readRuntimeAuthState() {
475
615
  }
476
616
  }
477
617
 
478
- function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
618
+ function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
479
619
  try {
480
620
  const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
481
621
  const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
@@ -483,7 +623,12 @@ function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
483
623
  } catch {
484
624
  return null;
485
625
  }
486
- }
626
+ }
627
+
628
+ function readPersistedWorkspaceId(state = readRuntimeAuthState()) {
629
+ const workspaceId = String(state?.[HUB_WORKSPACE_STORAGE_KEY]?.workspaceId || '').trim();
630
+ return workspaceId.slice(0, 160);
631
+ }
487
632
 
488
633
  function writePrivateRuntimeAuthState(state) {
489
634
  if (!runtimeAuthStatePath) return false;
@@ -501,7 +646,7 @@ function writePrivateRuntimeAuthState(state) {
501
646
  }
502
647
  }
503
648
 
504
- function persistRuntimeSession(session) {
649
+ function persistRuntimeSession(session) {
505
650
  if (!runtimeAuthStatePath) return false;
506
651
  const state = readRuntimeAuthState();
507
652
  const previous = readPersistedRuntimeSession(state) || {};
@@ -526,25 +671,43 @@ function persistRuntimeSession(session) {
526
671
  state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
527
672
  writePrivateRuntimeAuthState(state);
528
673
  return true;
529
- }
674
+ }
675
+
676
+ function persistRuntimeWorkspace(access) {
677
+ const workspaceId = String(access?.workspaceId || '').trim();
678
+ if (!runtimeAuthStatePath || !workspaceId) return false;
679
+ const state = readRuntimeAuthState();
680
+ state[HUB_WORKSPACE_STORAGE_KEY] = {
681
+ workspaceId,
682
+ workspaceKind: String(access?.workspaceKind || 'personal'),
683
+ selectedAt: new Date().toISOString()
684
+ };
685
+ writePrivateRuntimeAuthState(state);
686
+ return true;
687
+ }
530
688
 
531
- function clearPersistedRuntimeSession() {
689
+ function clearPersistedRuntimeSession() {
532
690
  if (!runtimeAuthStatePath) return false;
533
- const state = readRuntimeAuthState();
534
- delete state[CLIENT_AUTH_STORAGE_KEY];
535
- return writePrivateRuntimeAuthState(state);
536
- }
691
+ const state = readRuntimeAuthState();
692
+ delete state[CLIENT_AUTH_STORAGE_KEY];
693
+ delete state[HUB_WORKSPACE_STORAGE_KEY];
694
+ return writePrivateRuntimeAuthState(state);
695
+ }
537
696
 
538
- function clearRuntimeSession() {
539
- runtimeAccessToken = '';
540
- runtimeRefreshToken = '';
541
- runtimeAccessTokenExpiresAt = 0;
697
+ function clearRuntimeSession() {
698
+ runtimeAuthEpoch += 1;
699
+ runtimeAccessToken = '';
700
+ runtimeRefreshToken = '';
701
+ runtimeAccessTokenExpiresAt = 0;
702
+ runtimeWorkspaceAccess = null;
703
+ workspaceAccessLastAttemptAt = 0;
704
+ licenseRefreshLastAttemptAt = 0;
542
705
  hubUiSessionAuthority.revokeAll();
543
706
  runtimeManager.setAuthenticated(false);
544
707
  try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
545
708
  }
546
709
 
547
- async function getRuntimeAccessToken() {
710
+ async function getRuntimeAccessToken() {
548
711
  const accessToken = String(runtimeAccessToken || '').trim();
549
712
  const expiresSoon = runtimeAccessTokenExpiresAt > 0
550
713
  && runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
@@ -560,52 +723,74 @@ async function getRuntimeAccessToken() {
560
723
  return accessToken;
561
724
  }
562
725
 
563
- const refreshToken = String(runtimeRefreshToken || '').trim();
564
- if (!refreshToken) {
565
- return accessToken;
566
- }
567
-
568
- const response = await fetchAuthResponse(
569
- fetch,
570
- `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
571
- {
572
- method: 'POST',
573
- headers: {
574
- apikey: supabasePublishableKey,
575
- 'Content-Type': 'application/json',
576
- Accept: 'application/json'
577
- },
578
- body: JSON.stringify({ refresh_token: refreshToken })
579
- },
580
- SUPABASE_AUTH_TIMEOUT_MS
581
- );
582
- if (!response.ok) {
583
- if (response.status === 400 || response.status === 401 || response.status === 403) {
584
- clearRuntimeSession();
585
- throw new Error(`hub-session-refresh-failed:${response.status}`);
586
- }
587
- throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
588
- }
589
- const refreshed = await response.json().catch(() => null);
590
- const nextAccessToken = String(refreshed?.access_token || '').trim();
591
- if (!nextAccessToken) {
592
- throw new Error('hub-session-refresh-missing-access-token');
593
- }
594
- runtimeAccessToken = nextAccessToken;
595
- runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
596
- runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
597
- || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
598
- try {
599
- persistRuntimeSession({
600
- access_token: runtimeAccessToken,
601
- refresh_token: runtimeRefreshToken,
602
- expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
603
- });
604
- } catch (error) {
605
- console.warn(`[VuvoDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
606
- }
607
- return runtimeAccessToken;
608
- }
726
+ const refreshToken = String(runtimeRefreshToken || '').trim();
727
+ if (!refreshToken) {
728
+ return accessToken;
729
+ }
730
+ if (runtimeAccessTokenRefreshOwner?.epoch === runtimeAuthEpoch
731
+ && runtimeAccessTokenRefreshOwner.refreshToken === refreshToken) {
732
+ return runtimeAccessTokenRefreshOwner.promise;
733
+ }
734
+ const owner = {
735
+ epoch: runtimeAuthEpoch,
736
+ userId: currentBoundHubUserId(),
737
+ workspaceId: currentBoundHubWorkspaceId(),
738
+ refreshToken,
739
+ promise: null
740
+ };
741
+ owner.promise = (async () => {
742
+ const response = await fetchAuthResponse(
743
+ fetch,
744
+ `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
745
+ {
746
+ method: 'POST',
747
+ headers: {
748
+ apikey: supabasePublishableKey,
749
+ 'Content-Type': 'application/json',
750
+ Accept: 'application/json'
751
+ },
752
+ body: JSON.stringify({ refresh_token: refreshToken })
753
+ },
754
+ SUPABASE_AUTH_TIMEOUT_MS
755
+ );
756
+ if (!response.ok) {
757
+ if (response.status === 400 || response.status === 401 || response.status === 403) {
758
+ if (runtimeAuthEpoch === owner.epoch && runtimeRefreshToken === owner.refreshToken) {
759
+ clearRuntimeSession();
760
+ }
761
+ throw new Error(`hub-session-refresh-failed:${response.status}`);
762
+ }
763
+ throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
764
+ }
765
+ const refreshed = await response.json().catch(() => null);
766
+ const nextAccessToken = String(refreshed?.access_token || '').trim();
767
+ if (!nextAccessToken) throw new Error('hub-session-refresh-missing-access-token');
768
+ if (runtimeAuthEpoch !== owner.epoch
769
+ || runtimeRefreshToken !== owner.refreshToken
770
+ || currentBoundHubUserId() !== owner.userId
771
+ || currentBoundHubWorkspaceId() !== owner.workspaceId) {
772
+ throw new Error('hub-session-refresh-superseded');
773
+ }
774
+ runtimeAccessToken = nextAccessToken;
775
+ runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
776
+ runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
777
+ || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
778
+ try {
779
+ persistRuntimeSession({
780
+ access_token: runtimeAccessToken,
781
+ refresh_token: runtimeRefreshToken,
782
+ expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
783
+ });
784
+ } catch (error) {
785
+ console.warn(`[VuvoDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
786
+ }
787
+ return runtimeAccessToken;
788
+ })().finally(() => {
789
+ if (runtimeAccessTokenRefreshOwner === owner) runtimeAccessTokenRefreshOwner = null;
790
+ });
791
+ runtimeAccessTokenRefreshOwner = owner;
792
+ return owner.promise;
793
+ }
609
794
 
610
795
  async function verifySupabaseUser(accessToken) {
611
796
  const token = String(accessToken || '').trim();
@@ -637,21 +822,51 @@ async function verifySupabaseUser(accessToken) {
637
822
  };
638
823
  }
639
824
 
640
- function authVerificationHttpStatus(message) {
825
+ function authVerificationHttpStatus(message) {
641
826
  const upstreamStatus = Number(String(message || '').match(/supabase-user-verification-failed:(\d+)/)?.[1] || 0);
642
827
  return upstreamStatus === 401 || upstreamStatus === 403 ? 401 : 502;
643
828
  }
644
829
 
645
- async function queryAuthoritativeRuntimeRole() {
646
- const accessToken = await getRuntimeAccessToken();
647
- if (runtimeRole !== 'hub' || !accessToken || !runtimeDeviceId) {
648
- return '';
649
- }
650
- const query = new URLSearchParams({
651
- select: 'role,enabled,role_version,assigned_hub_id',
652
- device_id: `eq.${runtimeDeviceId}`,
653
- limit: '1'
654
- });
830
+ async function queryRuntimeDeviceWorkspace(accessToken) {
831
+ const token = String(accessToken || '').trim();
832
+ if (!token || !runtimeDeviceId) return '';
833
+ const query = new URLSearchParams({
834
+ select: 'workspace_id,role,enabled',
835
+ device_id: `eq.${runtimeDeviceId}`,
836
+ enabled: 'eq.true',
837
+ limit: '1'
838
+ });
839
+ const response = await fetchAuthResponse(
840
+ fetch,
841
+ `${supabaseUrl}/rest/v1/livedesk_devices?${query}`,
842
+ {
843
+ headers: {
844
+ apikey: supabasePublishableKey,
845
+ Authorization: `Bearer ${token}`,
846
+ Accept: 'application/json'
847
+ }
848
+ },
849
+ SUPABASE_AUTH_TIMEOUT_MS
850
+ );
851
+ if (response.status === 401 || response.status === 403) {
852
+ throw new Error(`hub-device-workspace-query-not-authenticated:${response.status}`);
853
+ }
854
+ if (!response.ok) return '';
855
+ const rows = await response.json().catch(() => []);
856
+ const record = Array.isArray(rows) ? rows[0] : null;
857
+ return record?.enabled === false ? '' : String(record?.workspace_id || '').trim();
858
+ }
859
+
860
+ async function queryAuthoritativeRuntimeDevice() {
861
+ const accessToken = await getRuntimeAccessToken();
862
+ if (runtimeRole !== 'hub' || !accessToken || !runtimeDeviceId) {
863
+ return { role: '', workspaceId: '' };
864
+ }
865
+ const query = new URLSearchParams({
866
+ select: 'workspace_id,role,enabled,role_version,assigned_hub_id',
867
+ device_id: `eq.${runtimeDeviceId}`,
868
+ limit: '1'
869
+ });
655
870
  const response = await fetchAuthResponse(
656
871
  fetch,
657
872
  `${supabaseUrl}/rest/v1/livedesk_devices?${query}`,
@@ -664,38 +879,149 @@ async function queryAuthoritativeRuntimeRole() {
664
879
  },
665
880
  SUPABASE_AUTH_TIMEOUT_MS
666
881
  );
667
- if (response.status === 401 || response.status === 403) {
668
- if (!desktopMainAuthConfig) clearRuntimeSession();
669
- throw new Error(`hub-role-query-not-authenticated:${response.status}`);
882
+ if (response.status === 401 || response.status === 403) {
883
+ throw new Error(`hub-role-query-not-authenticated:${response.status}`);
670
884
  }
671
885
  if (!response.ok) {
672
886
  throw new Error(`hub-role-query-failed:${response.status}`);
673
887
  }
674
888
  const rows = await response.json().catch(() => []);
675
889
  const record = Array.isArray(rows) ? rows[0] : null;
676
- if (!record || record.enabled === false) {
677
- return '';
678
- }
679
- const role = String(record.role || '').trim().toLowerCase();
680
- return role === 'hub' || role === 'client' ? role : '';
681
- }
890
+ if (!record || record.enabled === false) {
891
+ return { role: '', workspaceId: '' };
892
+ }
893
+ const role = String(record.role || '').trim().toLowerCase();
894
+ return {
895
+ role: role === 'hub' || role === 'client' ? role : '',
896
+ workspaceId: String(record.workspace_id || '').trim()
897
+ };
898
+ }
899
+
900
+ async function queryAuthoritativeRuntimeRole() {
901
+ return (await queryAuthoritativeRuntimeDevice()).role;
902
+ }
682
903
 
683
- async function watchAuthoritativeRuntimeRole() {
904
+ async function watchAuthoritativeRuntimeRole() {
684
905
  if (runtimeRole !== 'hub' || roleWatchInFlight || !runtimeAccessToken) {
685
906
  return;
686
- }
687
- roleWatchInFlight = true;
688
- try {
689
- const role = await queryAuthoritativeRuntimeRole();
907
+ }
908
+ const watchAuthEpoch = runtimeAuthEpoch;
909
+ const watchUserId = currentBoundHubUserId();
910
+ const watchWorkspaceId = currentBoundHubWorkspaceId();
911
+ roleWatchInFlight = true;
912
+ try {
913
+ const authoritativeDevice = await queryAuthoritativeRuntimeDevice();
914
+ if (runtimeAuthEpoch !== watchAuthEpoch
915
+ || currentBoundHubUserId() !== watchUserId
916
+ || currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
917
+ const role = authoritativeDevice.role;
690
918
  if (role === 'client') {
691
919
  console.warn('[VuvoDesk Hub] Supabase selected another Sync Server. Transitioning this runtime to Client.');
692
920
  void clearHubHostTarget('role-demoted').finally(() => {
693
- setTimeout(() => process.exit(ROLE_TRANSITION_EXIT_CODE), 150);
694
- });
695
- }
696
- } catch {
697
- // A temporary provider failure must not stop a working Hub. The next
698
- // interval retries the authoritative role check.
921
+ setTimeout(() => process.exit(ROLE_TRANSITION_EXIT_CODE), 150);
922
+ });
923
+ return;
924
+ }
925
+ if (authoritativeDevice.workspaceId
926
+ && authoritativeDevice.workspaceId !== currentBoundHubWorkspaceId()) {
927
+ const token = await getRuntimeAccessToken();
928
+ const userId = currentBoundHubUserId();
929
+ const movedAccess = await verifyWorkspaceAccess(
930
+ token,
931
+ { id: userId },
932
+ authoritativeDevice.workspaceId
933
+ );
934
+ if (movedAccess.userId !== userId || movedAccess.role !== 'owner') {
935
+ throw new WorkspaceAccessError('hub-device-workspace-mismatch', { status: 403 });
936
+ }
937
+ if (runtimeAuthEpoch !== watchAuthEpoch
938
+ || currentBoundHubUserId() !== watchUserId
939
+ || currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
940
+ await clearHubHostTarget('hub-workspace-moved');
941
+ if (runtimeAuthEpoch !== watchAuthEpoch
942
+ || currentBoundHubUserId() !== watchUserId
943
+ || currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
944
+ hubUiSessionAuthority.revokeAll('hub-workspace-moved');
945
+ runtimeAuthEpoch += 1;
946
+ runtimeWorkspaceAccess = movedAccess;
947
+ try { persistRuntimeWorkspace(movedAccess); } catch {}
948
+ applyVerifiedWorkspaceLicense(movedAccess);
949
+ licenseRefreshLastAttemptAt = Date.now();
950
+ workspaceAccessLastAttemptAt = Date.now();
951
+ hubConsoleDirect?.invalidateWorkspaceAccess('hub-workspace-moved');
952
+ if (workspaceRemoteAccessActive(movedAccess)) {
953
+ scheduleAuthenticatedHubHostTargetPublication('hub-workspace-moved');
954
+ }
955
+ return;
956
+ }
957
+ const now = Date.now();
958
+ if (now - workspaceAccessLastAttemptAt < WORKSPACE_ACCESS_REVERIFY_MS) {
959
+ return;
960
+ }
961
+ workspaceAccessLastAttemptAt = now;
962
+ const token = await getRuntimeAccessToken();
963
+ const userId = currentBoundHubUserId();
964
+ const previousAccess = runtimeWorkspaceAccess;
965
+ const access = await verifyWorkspaceAccess(token, { id: userId }, currentBoundHubWorkspaceId());
966
+ if (access.role !== 'owner') {
967
+ throw new WorkspaceAccessError('workspace-owner-required', { status: 403 });
968
+ }
969
+ if (runtimeAuthEpoch !== watchAuthEpoch
970
+ || currentBoundHubUserId() !== watchUserId
971
+ || currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
972
+ const licenseChanged = !previousAccess
973
+ || previousAccess.plan !== access.plan
974
+ || previousAccess.entitlementStatus !== access.entitlementStatus
975
+ || previousAccess.deviceLimit !== access.deviceLimit
976
+ || previousAccess.memberLimit !== access.memberLimit
977
+ || previousAccess.commercialUse !== access.commercialUse;
978
+ const teamBecameInactive = access.workspaceKind === 'team'
979
+ && !workspaceRemoteAccessActive(access)
980
+ && workspaceRemoteAccessActive(previousAccess);
981
+ const teamBecameActive = access.workspaceKind === 'team'
982
+ && workspaceRemoteAccessActive(access)
983
+ && !workspaceRemoteAccessActive(previousAccess);
984
+ runtimeWorkspaceAccess = access;
985
+ try { persistRuntimeWorkspace(access); } catch { /* the verified in-memory owner remains authoritative */ }
986
+ if (licenseChanged || now - licenseRefreshLastAttemptAt >= LICENSE_REFRESH_INTERVAL_MS) {
987
+ applyVerifiedWorkspaceLicense(access);
988
+ licenseRefreshLastAttemptAt = now;
989
+ }
990
+ if (teamBecameInactive) {
991
+ hubConsoleDirect?.close();
992
+ await clearHubHostTarget('workspace-entitlement-inactive');
993
+ } else if (teamBecameActive) {
994
+ hubConsoleDirect?.refresh();
995
+ scheduleAuthenticatedHubHostTargetPublication('workspace-entitlement-activated');
996
+ }
997
+ // The revision also changes for invitations, billing and unrelated member
998
+ // updates. Closing every healthy peer here would turn a harmless Team
999
+ // metadata change into an input outage. Exact member removal is handled by
1000
+ // the owner revoke endpoint; every Team signaling socket is independently
1001
+ // revalidated by the Wake Worker within its bounded access window.
1002
+ } catch (error) {
1003
+ if (error instanceof WorkspaceAccessError
1004
+ && error.status < 500
1005
+ && runtimeAuthEpoch === watchAuthEpoch
1006
+ && currentBoundHubUserId() === watchUserId
1007
+ && currentBoundHubWorkspaceId() === watchWorkspaceId) {
1008
+ runtimeWorkspaceAccess = null;
1009
+ verifiedLicense = {
1010
+ userId: '',
1011
+ workspaceId: currentBoundHubWorkspaceId(),
1012
+ workspaceKind: 'personal',
1013
+ plan: 'free',
1014
+ status: 'inactive',
1015
+ expiresAt: '',
1016
+ memberLimit: 1,
1017
+ commercialUse: false,
1018
+ verifiedAt: Date.now()
1019
+ };
1020
+ hubUiSessionAuthority.revokeAll();
1021
+ hubConsoleDirect?.close();
1022
+ }
1023
+ // A temporary provider failure must not stop a working Hub. The next
1024
+ // interval retries the authoritative role check.
699
1025
  } finally {
700
1026
  roleWatchInFlight = false;
701
1027
  }
@@ -1402,17 +1728,87 @@ hubConsoleDirect = createHubConsoleDirect({
1402
1728
  deviceId: runtimeDeviceId,
1403
1729
  httpBaseUrl: `http://127.0.0.1:${httpPort}`,
1404
1730
  stunUrls: hubConsoleDirectStunUrls,
1405
- getAccessToken: () => getRuntimeAccessToken()
1731
+ getAccessToken: () => getRuntimeAccessToken(),
1732
+ getWorkspaceAccess: () => runtimeWorkspaceAccess,
1733
+ consoleProxyToken
1406
1734
  });
1407
1735
  const httpConnections = new Set();
1408
1736
  const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1409
1737
  const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1410
1738
  const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1411
1739
  const audioWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1412
- const browserWebSocketServers = [frameWss, atlasWss, inputWss, audioWss];
1413
- const frameCaptureTransitionRetries = createLiveCaptureTransitionRetryCoordinator();
1414
-
1415
- httpServer.on('connection', socket => {
1740
+ const browserWebSocketServers = [frameWss, atlasWss, inputWss, audioWss];
1741
+ const frameCaptureTransitionRetries = createLiveCaptureTransitionRetryCoordinator();
1742
+
1743
+ function retireHubUiSessionSocket(ws, reason = 'hub-ui-session-revoked') {
1744
+ if (!ws || ws.liveDeskUiSessionRevoked === true) return false;
1745
+ ws.liveDeskUiSessionRevoked = true;
1746
+ if (ws.liveDeskUiSessionTimer) clearTimeout(ws.liveDeskUiSessionTimer);
1747
+ ws.liveDeskUiSessionTimer = null;
1748
+ if (ws.liveDeskInputClientId) {
1749
+ for (const deviceId of ws.liveDeskInputDeviceIds || []) {
1750
+ remoteHub.releaseInputOwner(
1751
+ deviceId,
1752
+ ws.liveDeskInputClientId,
1753
+ String(reason || 'hub-ui-session-revoked').slice(0, 120)
1754
+ );
1755
+ }
1756
+ ws.liveDeskInputDeviceIds?.clear?.();
1757
+ }
1758
+ try {
1759
+ ws.close(1008, String(reason || 'hub-ui-session-revoked').slice(0, 120));
1760
+ } catch {
1761
+ try { ws.terminate(); } catch {}
1762
+ }
1763
+ return true;
1764
+ }
1765
+
1766
+ function retireHubUiSessionSockets(session, reason = 'hub-ui-session-revoked') {
1767
+ const sessionId = String(session?.sessionId || '');
1768
+ if (!sessionId) return 0;
1769
+ let retired = 0;
1770
+ for (const wss of browserWebSocketServers) {
1771
+ for (const ws of wss.clients) {
1772
+ if (ws.liveDeskUiSessionId !== sessionId) continue;
1773
+ if (retireHubUiSessionSocket(ws, reason)) retired += 1;
1774
+ }
1775
+ }
1776
+ return retired;
1777
+ }
1778
+
1779
+ function bindHubUiSessionSocket(ws, req) {
1780
+ const authorization = req?.liveDeskAuthorization;
1781
+ const sessionId = String(authorization?.sessionId || '');
1782
+ if (!sessionId) return;
1783
+ ws.liveDeskUiSessionId = sessionId;
1784
+ ws.liveDeskUiSessionUserId = String(authorization.userId || '');
1785
+ ws.liveDeskUiSessionWorkspaceId = String(authorization.workspaceId || '');
1786
+ ws.liveDeskUiSessionRevoked = false;
1787
+ const armDeadline = () => {
1788
+ if (ws.liveDeskUiSessionRevoked === true) return;
1789
+ const deadline = hubUiSessionAuthority.sessionDeadline(sessionId);
1790
+ if (!deadline) {
1791
+ retireHubUiSessionSocket(ws, 'hub-ui-session-expired');
1792
+ return;
1793
+ }
1794
+ const delay = deadline - Date.now();
1795
+ if (delay <= 0) {
1796
+ retireHubUiSessionSocket(ws, 'hub-ui-session-expired');
1797
+ return;
1798
+ }
1799
+ ws.liveDeskUiSessionTimer = setTimeout(armDeadline, Math.min(delay, 60_000));
1800
+ ws.liveDeskUiSessionTimer.unref?.();
1801
+ };
1802
+ armDeadline();
1803
+ const cleanup = () => {
1804
+ if (ws.liveDeskUiSessionTimer) clearTimeout(ws.liveDeskUiSessionTimer);
1805
+ ws.liveDeskUiSessionTimer = null;
1806
+ };
1807
+ ws.once('close', cleanup);
1808
+ ws.once('error', cleanup);
1809
+ }
1810
+
1811
+ httpServer.on('connection', socket => {
1416
1812
  httpConnections.add(socket);
1417
1813
  socket.once('close', () => httpConnections.delete(socket));
1418
1814
  });
@@ -1509,13 +1905,34 @@ function isSecureHubHttpRequest(req) {
1509
1905
  || String(req.headers['x-forwarded-proto'] || '').trim().toLowerCase() === 'https';
1510
1906
  }
1511
1907
 
1512
- function currentBoundHubUserId() {
1908
+ function currentBoundHubUserId() {
1513
1909
  return String(
1514
1910
  runtimeManager.getSnapshot().userId
1515
1911
  || readPersistedRuntimeSession()?.user?.id
1516
1912
  || ''
1517
1913
  ).trim();
1518
- }
1914
+ }
1915
+
1916
+ function workspaceAccessHttpStatus(error) {
1917
+ return error instanceof WorkspaceAccessError
1918
+ ? Math.max(400, Math.min(599, Number(error.status || 403)))
1919
+ : authVerificationHttpStatus(error instanceof Error ? error.message : String(error));
1920
+ }
1921
+
1922
+ function currentBoundHubWorkspaceId() {
1923
+ return String(
1924
+ runtimeWorkspaceAccess?.workspaceId
1925
+ || readPersistedWorkspaceId()
1926
+ || currentBoundHubUserId()
1927
+ || ''
1928
+ ).trim();
1929
+ }
1930
+
1931
+ function runtimeAuthOwnerIsCurrent(epoch, userId, workspaceId) {
1932
+ return runtimeAuthEpoch === epoch
1933
+ && currentBoundHubUserId() === String(userId || '')
1934
+ && currentBoundHubWorkspaceId() === String(workspaceId || '');
1935
+ }
1519
1936
 
1520
1937
  function isPublicHubApiRequest(req) {
1521
1938
  const method = String(req.method || 'GET').toUpperCase();
@@ -1524,21 +1941,98 @@ function isPublicHubApiRequest(req) {
1524
1941
  || (method === 'POST' && path === '/api/auth/session');
1525
1942
  }
1526
1943
 
1527
- function isTrustedNativeLoopbackRequest(req) {
1944
+ function isTrustedNativeLoopbackRequest(req) {
1528
1945
  return isLoopbackAddress(req.socket?.remoteAddress)
1529
1946
  && !String(req.headers.origin || '').trim()
1530
1947
  && !String(req.headers['sec-fetch-site'] || '').trim();
1531
- }
1532
-
1533
- function authorizeHubUiRequest(req) {
1534
- if (process.env.LIVEDESK_TEST_MODE === '1') {
1535
- return { ok: true, explicitTestMode: true };
1536
- }
1537
- if (isTrustedNativeLoopbackRequest(req)) {
1538
- return { ok: true, nativeLoopback: true };
1539
- }
1540
- return hubUiSessionAuthority.authorize(req, currentBoundHubUserId());
1541
- }
1948
+ }
1949
+
1950
+ function authorizeConsoleProxyRequest(req) {
1951
+ const suppliedToken = String(req.headers['x-livedesk-console-proxy'] || '').trim();
1952
+ if (!suppliedToken) return null;
1953
+ if (!isTrustedNativeLoopbackRequest(req)
1954
+ || !secureExactTestTokenMatches(suppliedToken, consoleProxyToken)) {
1955
+ return { ok: false, status: 403, error: 'console-proxy-not-authorized' };
1956
+ }
1957
+ const workspaceId = String(req.headers['x-livedesk-workspace-id'] || '').trim();
1958
+ const workspaceKind = String(req.headers['x-livedesk-workspace-kind'] || '').trim().toLowerCase();
1959
+ const userId = String(req.headers['x-livedesk-workspace-user-id'] || '').trim();
1960
+ const role = String(req.headers['x-livedesk-workspace-role'] || '').trim().toLowerCase();
1961
+ const membershipRevision = Number(req.headers['x-livedesk-membership-revision'] || 0);
1962
+ const accessExpiresAt = Number(req.headers['x-livedesk-workspace-access-expires-at'] || 0);
1963
+ if (!workspaceId
1964
+ || workspaceKind !== String(runtimeWorkspaceAccess?.workspaceKind || '').trim().toLowerCase()
1965
+ || !userId
1966
+ || workspaceId !== currentBoundHubWorkspaceId()
1967
+ || !workspaceRoleCanControl(role)
1968
+ || !Number.isSafeInteger(membershipRevision)
1969
+ || membershipRevision < 0
1970
+ || !Number.isFinite(accessExpiresAt)
1971
+ || (workspaceKind === 'team' && accessExpiresAt <= Date.now())
1972
+ || (workspaceKind === 'personal' && accessExpiresAt !== 0)) {
1973
+ return { ok: false, status: 403, error: 'console-workspace-access-invalid' };
1974
+ }
1975
+ return {
1976
+ ok: true,
1977
+ consoleProxy: true,
1978
+ workspaceId,
1979
+ workspaceKind,
1980
+ userId,
1981
+ role,
1982
+ plan: String(runtimeWorkspaceAccess?.plan || 'free'),
1983
+ entitlementStatus: String(runtimeWorkspaceAccess?.entitlementStatus || 'inactive'),
1984
+ membershipRevision,
1985
+ accessExpiresAt
1986
+ };
1987
+ }
1988
+
1989
+ function authorizationWorkspaceAccess(authorization) {
1990
+ return {
1991
+ workspaceKind: String(
1992
+ authorization?.workspaceKind
1993
+ || runtimeWorkspaceAccess?.workspaceKind
1994
+ || 'personal'
1995
+ ).trim().toLowerCase(),
1996
+ plan: String(
1997
+ authorization?.plan
1998
+ || runtimeWorkspaceAccess?.plan
1999
+ || 'free'
2000
+ ).trim().toLowerCase(),
2001
+ entitlementStatus: String(
2002
+ authorization?.entitlementStatus
2003
+ || runtimeWorkspaceAccess?.entitlementStatus
2004
+ || 'active'
2005
+ ).trim().toLowerCase()
2006
+ };
2007
+ }
2008
+
2009
+ function authorizeHubUiRequest(req) {
2010
+ if (process.env.LIVEDESK_TEST_MODE === '1') {
2011
+ return { ok: true, explicitTestMode: true };
2012
+ }
2013
+ const consoleAuthorization = authorizeConsoleProxyRequest(req);
2014
+ if (consoleAuthorization) return consoleAuthorization;
2015
+ if (isTrustedNativeLoopbackRequest(req)) {
2016
+ return { ok: true, nativeLoopback: true };
2017
+ }
2018
+ return hubUiSessionAuthority.authorize(req, {
2019
+ workspaceId: currentBoundHubWorkspaceId(),
2020
+ allowedRoles: ['owner', 'operator']
2021
+ });
2022
+ }
2023
+
2024
+ function authorizedWorkspaceRole(req) {
2025
+ if (req.liveDeskAuthorization?.role) return req.liveDeskAuthorization.role;
2026
+ const consoleAuthorization = authorizeConsoleProxyRequest(req);
2027
+ if (consoleAuthorization?.ok) return consoleAuthorization.role;
2028
+ const uiAuthorization = hubUiSessionAuthority.authorize(req, {
2029
+ workspaceId: currentBoundHubWorkspaceId(),
2030
+ allowedRoles: ['owner', 'operator']
2031
+ }, { requireCsrf: false });
2032
+ return uiAuthorization?.ok
2033
+ ? uiAuthorization.role
2034
+ : String(runtimeWorkspaceAccess?.role || '').trim().toLowerCase();
2035
+ }
1542
2036
 
1543
2037
  app.use((req, res, next) => {
1544
2038
  if (!isTrustedBrowserRequest(req)) {
@@ -1566,13 +2060,29 @@ app.use((req, res, next) => {
1566
2060
  next();
1567
2061
  return;
1568
2062
  }
1569
- const authorization = authorizeHubUiRequest(req);
1570
- if (!authorization.ok) {
2063
+ const authorization = authorizeHubUiRequest(req);
2064
+ if (!authorization.ok) {
1571
2065
  noStore(res);
1572
2066
  res.status(authorization.status).json({ ok: false, error: authorization.error });
1573
- return;
1574
- }
1575
- next();
2067
+ return;
2068
+ }
2069
+ if (!workspaceEntitlementCanRequest(
2070
+ authorizationWorkspaceAccess(authorization),
2071
+ req.method,
2072
+ req.path
2073
+ )) {
2074
+ noStore(res);
2075
+ res.status(403).json({ ok: false, error: 'workspace-entitlement-inactive' });
2076
+ return;
2077
+ }
2078
+ if (authorization.role
2079
+ && !workspaceRoleCanRequest(authorization.role, req.method, req.path)) {
2080
+ noStore(res);
2081
+ res.status(403).json({ ok: false, error: 'workspace-owner-required' });
2082
+ return;
2083
+ }
2084
+ req.liveDeskAuthorization = authorization;
2085
+ next();
1576
2086
  });
1577
2087
  app.use((req, res, next) => {
1578
2088
  if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
@@ -1774,7 +2284,7 @@ function normalizeTransferChunk(body = {}) {
1774
2284
  };
1775
2285
  }
1776
2286
 
1777
- function normalizeLiveOptions(payload = {}) {
2287
+ function normalizeLiveOptions(payload = {}) {
1778
2288
  const mode = String(payload.frameMode || payload.mode || 'mode3-h264-hw').trim() || 'mode3-h264-hw';
1779
2289
  const streamPurpose = String(payload.streamPurpose || payload.purpose || 'wall').trim().slice(0, 24) || 'wall';
1780
2290
  const maxFps = streamPurpose === 'control' ? 60 : 30;
@@ -1784,10 +2294,11 @@ function normalizeLiveOptions(payload = {}) {
1784
2294
  maxHeight: clampNumber(payload.maxHeight, 180, 2160, 360),
1785
2295
  quality: clampNumber(payload.quality, 20, 95, 45),
1786
2296
  monitorIndex: normalizeMonitorIndex(payload.monitorIndex ?? payload.screenIndex ?? payload.displayIndex),
1787
- monitorSelections: normalizeMonitorSelections(payload.monitorSelections),
1788
- forceRestart: /^(1|true|yes|on)$/i.test(String(payload.forceRestart ?? '')),
1789
- reuseExisting: /^(1|true|yes|on)$/i.test(String(payload.reuseExisting ?? '')),
1790
- streamPurpose,
2297
+ monitorSelections: normalizeMonitorSelections(payload.monitorSelections),
2298
+ forceRestart: /^(1|true|yes|on)$/i.test(String(payload.forceRestart ?? '')),
2299
+ reuseExisting: /^(1|true|yes|on)$/i.test(String(payload.reuseExisting ?? '')),
2300
+ allowReadOnlyControlBorrow: /^(1|true|yes|on)$/i.test(String(payload.allowReadOnlyControlBorrow ?? '')),
2301
+ streamPurpose,
1791
2302
  mode,
1792
2303
  frameMode: mode
1793
2304
  };
@@ -1888,15 +2399,23 @@ function unregisterFrameClient(ws) {
1888
2399
  retireFrameClientSendLane(ws);
1889
2400
  }
1890
2401
 
1891
- function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
2402
+ function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
1892
2403
  if (!streamId) {
1893
2404
  return false;
1894
2405
  }
1895
- for (const candidate of frameClients) {
1896
- if (candidate === ws || candidate.readyState !== candidate.OPEN) {
1897
- continue;
1898
- }
1899
- if (candidate.liveDeskStreamIdsByDeviceId instanceof Map
2406
+ for (const candidate of frameClients) {
2407
+ if (candidate === ws || candidate.readyState !== candidate.OPEN) {
2408
+ continue;
2409
+ }
2410
+ const expectedBinding = candidate.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
2411
+ ? candidate.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
2412
+ : null;
2413
+ if (expectedBinding?.readOnlyControlBorrow === true) {
2414
+ // A presentation observer can share immutable packets, but it must never
2415
+ // keep the native Control owner alive after the real controller leaves.
2416
+ continue;
2417
+ }
2418
+ if (candidate.liveDeskStreamIdsByDeviceId instanceof Map
1900
2419
  && candidate.liveDeskStreamIdsByDeviceId.get(deviceId) === streamId) {
1901
2420
  return true;
1902
2421
  }
@@ -1987,17 +2506,28 @@ function scheduleFrameStreamStop(deviceId, streamId, streamPurpose) {
1987
2506
  pendingFrameStreamStops.set(key, { timer });
1988
2507
  }
1989
2508
 
1990
- function stopFrameClientStreams(ws) {
2509
+ function stopFrameClientStreams(ws) {
1991
2510
  const streams = ws.liveDeskStreamIdsByDeviceId instanceof Map
1992
2511
  ? ws.liveDeskStreamIdsByDeviceId
1993
2512
  : null;
1994
2513
  if (!streams) {
1995
2514
  return;
1996
2515
  }
1997
- const streamPurpose = String(ws.liveDeskLiveOptions?.streamPurpose || 'wall');
1998
- for (const [deviceId, streamId] of streams.entries()) {
1999
- if (!streamId || hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose)) {
2000
- continue;
2516
+ const requestedStreamPurpose = String(ws.liveDeskLiveOptions?.streamPurpose || 'wall');
2517
+ for (const [deviceId, streamId] of streams.entries()) {
2518
+ const expectedBinding = ws.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
2519
+ ? ws.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
2520
+ : null;
2521
+ if (expectedBinding?.readOnlyControlBorrow === true) {
2522
+ // Borrowers own only browser presentation resources. The real Control
2523
+ // subscriber remains the sole native stop owner.
2524
+ continue;
2525
+ }
2526
+ const streamPurpose = expectedBinding?.streamId === streamId
2527
+ ? String(expectedBinding.streamPurpose || requestedStreamPurpose)
2528
+ : requestedStreamPurpose;
2529
+ if (!streamId || hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose)) {
2530
+ continue;
2001
2531
  }
2002
2532
  scheduleFrameStreamStop(deviceId, streamId, streamPurpose);
2003
2533
  }
@@ -3030,21 +3560,21 @@ function startFrameSubscriptionLive(
3030
3560
  const monitorIndex = Object.prototype.hasOwnProperty.call(liveOptions.monitorSelections || {}, deviceId)
3031
3561
  ? liveOptions.monitorSelections[deviceId]
3032
3562
  : liveOptions.monitorIndex;
3033
- const result = remoteHub.startLiveStream(deviceId, {
3034
- ...liveOptions,
3035
- monitorIndex,
3036
- reuseExisting: liveOptions.forceRestart !== true
3037
- && (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
3038
- // Wall capture is one shared native encoder per device. Two open browser
3039
- // views may ask for different soft profiles (for example mobile 5 fps at
3040
- // 1080p and desktop 20 fps at 540p). Once a healthy shared owner exists,
3041
- // those preferences must bind to that owner instead of replacing it back
3042
- // and forth every time either view refreshes its subscription.
3043
- reuseSharedExisting: liveOptions.forceRestart !== true
3044
- && String(liveOptions.streamPurpose || '').trim().toLowerCase() === 'wall'
3045
- && hasOtherFrameStreamDemand(ws, deviceId, 'wall'),
3046
- silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
3047
- });
3563
+ const result = remoteHub.startLiveStream(deviceId, {
3564
+ ...liveOptions,
3565
+ monitorIndex,
3566
+ reuseExisting: liveOptions.forceRestart !== true
3567
+ && (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
3568
+ // Wall capture is one shared native encoder per device. Two open browser
3569
+ // views may ask for different soft profiles (for example mobile 5 fps at
3570
+ // 1080p and desktop 20 fps at 540p). Once a healthy shared owner exists,
3571
+ // those preferences must bind to that owner instead of replacing it back
3572
+ // and forth every time either view refreshes its subscription.
3573
+ reuseSharedExisting: liveOptions.forceRestart !== true
3574
+ && String(liveOptions.streamPurpose || '').trim().toLowerCase() === 'wall'
3575
+ && hasOtherFrameStreamDemand(ws, deviceId, 'wall'),
3576
+ silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
3577
+ });
3048
3578
  if (result?.ok) {
3049
3579
  if (result.sharedProfileReused === true) {
3050
3580
  ws.liveDeskSharedProfileReuseCount = Math.max(
@@ -3053,18 +3583,25 @@ function startFrameSubscriptionLive(
3053
3583
  ) + 1;
3054
3584
  }
3055
3585
  frameCaptureTransitionRetries.complete(ws, deviceId, intentGeneration, 'capture-started');
3056
- const expectedBinding = {
3586
+ const expectedBinding = {
3057
3587
  deviceId,
3058
3588
  sessionId: String(result.sessionId || device.sessionId || ''),
3059
3589
  streamId: String(result.streamId || ''),
3060
3590
  streamPurpose: String(result.streamPurpose || liveOptions.streamPurpose || 'wall'),
3061
3591
  commandId: String(result.commandId || ''),
3062
- captureGeneration: Number(result.captureGeneration || 0),
3063
- monitorIndex: Number(result.monitorIndex || 0),
3064
- readySent: false
3592
+ captureGeneration: Number(result.captureGeneration || 0),
3593
+ monitorIndex: Number(result.monitorIndex || 0),
3594
+ readOnlyControlBorrow: result.readOnlyControlBorrow === true,
3595
+ presentationPurpose: result.presentationPurpose === 'wall' ? 'wall' : '',
3596
+ effectiveProfile: result.effectiveProfile || null,
3597
+ readySent: false
3065
3598
  };
3066
3599
  const activeStream = device?.activeLiveStream;
3067
- const reusedFrameReady = isReusedLiveStreamFrameReady(result, activeStream, expectedBinding);
3600
+ // A new read-only browser presentation has no decoder history from the
3601
+ // already-running Control stream. It must enter through the next exact
3602
+ // key frame even when the native owner itself is already ready.
3603
+ const reusedFrameReady = expectedBinding.readOnlyControlBorrow !== true
3604
+ && isReusedLiveStreamFrameReady(result, activeStream, expectedBinding);
3068
3605
  expectedBinding.readySent = reusedFrameReady;
3069
3606
  ws.liveDeskStreamIdsByDeviceId.set(deviceId, result.streamId);
3070
3607
  const installedBindingIdentity = replaceExpectedFrameBindingForClient(
@@ -3076,19 +3613,24 @@ function startFrameSubscriptionLive(
3076
3613
  skipped.push({ deviceId, reason: 'invalid-live-stream-binding' });
3077
3614
  continue;
3078
3615
  }
3079
- cancelPendingFrameStreamStop(deviceId, result.streamId, String(liveOptions.streamPurpose || 'wall'));
3080
- started.push({
3616
+ if (expectedBinding.readOnlyControlBorrow !== true) {
3617
+ cancelPendingFrameStreamStop(deviceId, result.streamId, expectedBinding.streamPurpose);
3618
+ }
3619
+ started.push({
3081
3620
  deviceId: expectedBinding.deviceId,
3082
3621
  sessionId: expectedBinding.sessionId,
3083
3622
  streamId: expectedBinding.streamId,
3084
3623
  streamPurpose: expectedBinding.streamPurpose,
3085
3624
  commandId: expectedBinding.commandId,
3086
3625
  captureGeneration: expectedBinding.captureGeneration,
3087
- monitorIndex: expectedBinding.monitorIndex,
3088
- ready: reusedFrameReady,
3089
- fps: result.fps,
3090
- reused: result.reused === true
3091
- });
3626
+ monitorIndex: expectedBinding.monitorIndex,
3627
+ readOnlyControlBorrow: expectedBinding.readOnlyControlBorrow,
3628
+ presentationPurpose: expectedBinding.presentationPurpose,
3629
+ effectiveProfile: expectedBinding.effectiveProfile,
3630
+ ready: reusedFrameReady,
3631
+ fps: result.fps,
3632
+ reused: result.reused === true
3633
+ });
3092
3634
  } else {
3093
3635
  ws.liveDeskStreamIdsByDeviceId.delete(deviceId);
3094
3636
  replaceExpectedFrameBindingForClient(ws, deviceId, null);
@@ -3160,9 +3702,10 @@ function startFrameSubscriptionLive(
3160
3702
  }
3161
3703
  }
3162
3704
  }
3163
- if (reason === 'watchdog' && !started.some(item => item.reused !== true)) {
3164
- return;
3165
- }
3705
+ if ((reason === 'watchdog' && !started.some(item => item.reused !== true))
3706
+ || (reason === 'control-borrow-reconcile' && started.length === 0)) {
3707
+ return;
3708
+ }
3166
3709
  sendJson(ws, {
3167
3710
  type: 'RemoteFrameLiveAutoStart',
3168
3711
  timestamp: new Date().toISOString(),
@@ -3439,21 +3982,30 @@ function broadcastRemoteBinaryFrame(frameEvent) {
3439
3982
  continue;
3440
3983
  }
3441
3984
  const requiresReadyKeyFrame = String(expectedBinding.streamPurpose || '').toLowerCase() === 'control' && isH264;
3442
- if (!expectedBinding.readySent && (!requiresReadyKeyFrame || isKeyFrame)) {
3443
- expectedBinding.readySent = sendJson(client, {
3444
- type: 'RemoteFrameStreamReady',
3445
- deviceId,
3446
- sessionId: expectedBinding.sessionId,
3447
- streamId: expectedBinding.streamId,
3448
- streamPurpose: expectedBinding.streamPurpose,
3449
- commandId: expectedBinding.commandId,
3450
- captureGeneration: expectedBinding.captureGeneration,
3451
- monitorIndex: expectedBinding.monitorIndex
3452
- });
3453
- if (!expectedBinding.readySent) {
3454
- continue;
3455
- }
3456
- }
3985
+ if (!expectedBinding.readySent) {
3986
+ // A fresh H.264 decoder cannot consume dependent Control deltas. Keep
3987
+ // the browser lane closed until the first exact key frame; otherwise a
3988
+ // newly attached PWA can remain on "Preparing video" until a later IDR.
3989
+ if (requiresReadyKeyFrame && !isKeyFrame) {
3990
+ continue;
3991
+ }
3992
+ expectedBinding.readySent = sendJson(client, {
3993
+ type: 'RemoteFrameStreamReady',
3994
+ deviceId,
3995
+ sessionId: expectedBinding.sessionId,
3996
+ streamId: expectedBinding.streamId,
3997
+ streamPurpose: expectedBinding.streamPurpose,
3998
+ commandId: expectedBinding.commandId,
3999
+ captureGeneration: expectedBinding.captureGeneration,
4000
+ monitorIndex: expectedBinding.monitorIndex,
4001
+ readOnlyControlBorrow: expectedBinding.readOnlyControlBorrow === true,
4002
+ presentationPurpose: expectedBinding.presentationPurpose || '',
4003
+ effectiveProfile: expectedBinding.effectiveProfile || null
4004
+ });
4005
+ if (!expectedBinding.readySent) {
4006
+ continue;
4007
+ }
4008
+ }
3457
4009
  const lane = ensureFrameClientSendLane(client);
3458
4010
  if (client.liveDeskFrameBackpressured
3459
4011
  && lane.backpressuredDeviceIds.has(deviceId)
@@ -3954,7 +4506,7 @@ app.get('/api/settings', async (_req, res) => {
3954
4506
  }
3955
4507
  });
3956
4508
 
3957
- app.patch('/api/settings', async (req, res) => {
4509
+ app.patch('/api/settings', async (req, res) => {
3958
4510
  noStore(res);
3959
4511
  try {
3960
4512
  const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
@@ -3974,9 +4526,72 @@ app.patch('/api/settings', async (req, res) => {
3974
4526
  }
3975
4527
  res.status(error?.status || 400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
3976
4528
  }
3977
- });
3978
-
3979
- function sendCaptureError(res, error) {
4529
+ });
4530
+
4531
+ function wallPreferencesResponse(record) {
4532
+ return {
4533
+ ok: true,
4534
+ revision: Number(record?.revision || 0),
4535
+ updatedAt: String(record?.updatedAt || ''),
4536
+ wallPreferences: {
4537
+ cadence: String(record?.cadence || record?.settings?.wall?.cadence || 'fast'),
4538
+ viewScale: Number(record?.viewScale ?? record?.settings?.wall?.viewScale ?? 100),
4539
+ showThisComputer: Boolean(
4540
+ record?.settings?.connection?.showThisComputer
4541
+ ?? liveDeskSettingsStore.getCached()?.connection?.showThisComputer
4542
+ )
4543
+ }
4544
+ };
4545
+ }
4546
+
4547
+ app.get('/api/remote/wall-preferences', async (_req, res) => {
4548
+ noStore(res);
4549
+ try {
4550
+ res.json(wallPreferencesResponse(await liveDeskSettingsStore.getWallPreferencesRecord()));
4551
+ } catch (error) {
4552
+ res.status(500).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
4553
+ }
4554
+ });
4555
+
4556
+ app.patch('/api/remote/wall-preferences', async (req, res) => {
4557
+ noStore(res);
4558
+ try {
4559
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;
4560
+ const allowedKeys = new Set(['revision', 'cadence', 'viewScale']);
4561
+ if (!body || Object.keys(body).some(key => !allowedKeys.has(key))) {
4562
+ res.status(400).json({ ok: false, error: 'wall-preferences-invalid' });
4563
+ return;
4564
+ }
4565
+ const revision = Number(body.revision);
4566
+ const cadence = String(body.cadence || '').trim().toLowerCase();
4567
+ const viewScale = Number(body.viewScale);
4568
+ if (!Number.isSafeInteger(revision)
4569
+ || revision < 0
4570
+ || !['slow', 'fast'].includes(cadence)
4571
+ || !Number.isFinite(viewScale)
4572
+ || viewScale < 0
4573
+ || viewScale > 100) {
4574
+ res.status(400).json({ ok: false, error: 'wall-preferences-invalid' });
4575
+ return;
4576
+ }
4577
+ const record = await liveDeskSettingsStore.update({ wall: { cadence, viewScale } }, revision);
4578
+ res.json(wallPreferencesResponse(record));
4579
+ } catch (error) {
4580
+ if (error instanceof SettingsConflictError) {
4581
+ const wall = error.settings?.settings?.wall || {};
4582
+ res.status(409).json(wallPreferencesResponse({
4583
+ revision: error.settings.revision,
4584
+ updatedAt: error.settings.updatedAt,
4585
+ cadence: wall.cadence,
4586
+ viewScale: wall.viewScale
4587
+ }));
4588
+ return;
4589
+ }
4590
+ res.status(error?.status || 400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
4591
+ }
4592
+ });
4593
+
4594
+ function sendCaptureError(res, error) {
3980
4595
  const rawCode = String(error?.message || error?.code || 'capture-request-failed');
3981
4596
  const code = rawCode.replace(/[^a-z0-9-:]/gi, '-').toLowerCase().slice(0, 100);
3982
4597
  const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599
@@ -4227,9 +4842,9 @@ app.post('/api/settings/agent/run', async (req, res) => {
4227
4842
  return;
4228
4843
  }
4229
4844
  try {
4230
- if (!(await synchronizeAgentEnablement())) {
4231
- throw new AgentRuntimeError('agent-disabled', 'Enable Codex Agent in Settings before running commands.', { status: 409 });
4232
- }
4845
+ if (!(await synchronizeAgentEnablement())) {
4846
+ throw new AgentRuntimeError('agent-disabled', 'Codex Agent was explicitly turned off in Settings.', { status: 409 });
4847
+ }
4233
4848
  const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction.slice(0, 4000) : '';
4234
4849
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds).slice(0, 500);
4235
4850
  const connectedDeviceIds = new Set(connectedAgentDeviceIds());
@@ -4345,20 +4960,23 @@ app.post('/api/settings/agent/summary', async (req, res) => {
4345
4960
  }
4346
4961
  });
4347
4962
 
4348
- app.get('/api/remote/status', async (_req, res) => {
4963
+ app.get('/api/remote/status', async (req, res) => {
4349
4964
  noStore(res);
4350
4965
  if (runtimeRole !== 'hub') {
4351
4966
  res.status(403).json({ ok: false, error: 'role-not-allowed' });
4352
4967
  return;
4353
- }
4968
+ }
4354
4969
  try {
4970
+ const includePairingPin = authorizedWorkspaceRole(req) === 'owner';
4355
4971
  const [secretStatus, wallPreferences] = await Promise.all([
4356
- Promise.resolve(remoteHub.getStatus({ includeSecrets: true })),
4972
+ Promise.resolve(includePairingPin ? remoteHub.getStatus({ includeSecrets: true }) : {}),
4357
4973
  liveDeskSettingsStore.getWallPreferencesRecord()
4358
4974
  ]);
4975
+ const visibleStatus = { ...remoteHub.getStatus({ includeSecrets: false }) };
4976
+ if (!includePairingPin) delete visibleStatus.pairingPin;
4359
4977
  res.json({
4360
- ...remoteHub.getStatus({ includeSecrets: false }),
4361
- pairingPin: secretStatus.pairingPin,
4978
+ ...visibleStatus,
4979
+ ...(includePairingPin ? { pairingPin: secretStatus.pairingPin } : {}),
4362
4980
  product: 'LiveDesk',
4363
4981
  runtimeRole,
4364
4982
  deviceId: runtimeDeviceId,
@@ -4366,6 +4984,7 @@ app.get('/api/remote/status', async (_req, res) => {
4366
4984
  roleSource: runtimeRoleSource,
4367
4985
  agentPackage: '@livedesk/client',
4368
4986
  wallPreferences,
4987
+ workspace: workspaceAccessSnapshot(),
4369
4988
  consoleDirect: hubConsoleDirect?.inspect() || { enabled: false, state: 'starting' },
4370
4989
  frameLanes: snapshotFrameLaneResourceHealth(),
4371
4990
  update: getLiveDeskUpdateStatus()
@@ -4546,72 +5165,242 @@ app.post('/api/runtime/switch-role', (req, res) => {
4546
5165
  res.redirect(307, '/api/runtime/role');
4547
5166
  });
4548
5167
 
4549
- app.get('/api/auth/status', (_req, res) => {
4550
- noStore(res);
4551
- res.json({ ok: true, authenticated: runtimeManager.getSnapshot().authenticated, userId: runtimeManager.getSnapshot().userId || null, role: runtimeRole });
4552
- });
5168
+ app.get('/api/auth/status', (_req, res) => {
5169
+ noStore(res);
5170
+ res.json({
5171
+ ok: true,
5172
+ authenticated: runtimeManager.getSnapshot().authenticated,
5173
+ userId: runtimeManager.getSnapshot().userId || null,
5174
+ role: runtimeRole,
5175
+ workspace: workspaceAccessSnapshot()
5176
+ });
5177
+ });
4553
5178
 
4554
- app.post('/api/auth/session', async (req, res) => {
4555
- noStore(res);
4556
- const normalized = normalizeRuntimeAuthSession(req.body);
5179
+ app.post('/api/auth/session', async (req, res) => {
5180
+ noStore(res);
5181
+ const consoleProxyAuthorization = authorizeConsoleProxyRequest(req);
5182
+ if (consoleProxyAuthorization) {
5183
+ if (!consoleProxyAuthorization.ok) {
5184
+ res.status(consoleProxyAuthorization.status).json({ ok: false, error: consoleProxyAuthorization.error });
5185
+ return;
5186
+ }
5187
+ const validConsoleWorkspaceMember = consoleProxyAuthorization.workspaceKind === 'team'
5188
+ ? ['owner', 'operator'].includes(consoleProxyAuthorization.role)
5189
+ : consoleProxyAuthorization.workspaceKind === 'personal'
5190
+ && consoleProxyAuthorization.role === 'owner';
5191
+ if (!validConsoleWorkspaceMember
5192
+ || !workspaceEntitlementCanRequest(consoleProxyAuthorization, 'POST', '/api/auth/session')) {
5193
+ res.status(403).json({ ok: false, error: 'workspace-member-session-required' });
5194
+ return;
5195
+ }
5196
+ const accessExpiresAt = consoleProxyAuthorization.workspaceKind === 'team'
5197
+ ? Math.min(Number(consoleProxyAuthorization.accessExpiresAt || 0), Date.now() + TEAM_UI_ACCESS_MAX_AGE_MS)
5198
+ : 0;
5199
+ const uiSession = hubUiSessionAuthority.renew(req, consoleProxyAuthorization.userId, {
5200
+ workspaceId: consoleProxyAuthorization.workspaceId,
5201
+ workspaceKind: consoleProxyAuthorization.workspaceKind,
5202
+ role: consoleProxyAuthorization.role,
5203
+ plan: consoleProxyAuthorization.plan,
5204
+ entitlementStatus: consoleProxyAuthorization.entitlementStatus,
5205
+ membershipRevision: consoleProxyAuthorization.membershipRevision,
5206
+ accessExpiresAt
5207
+ });
5208
+ res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
5209
+ secure: isSecureHubHttpRequest(req)
5210
+ }));
5211
+ res.json({
5212
+ ok: true,
5213
+ authenticated: true,
5214
+ persisted: false,
5215
+ runtimeOwner: false,
5216
+ userId: consoleProxyAuthorization.userId,
5217
+ role: runtimeRole,
5218
+ workspace: {
5219
+ workspaceId: consoleProxyAuthorization.workspaceId,
5220
+ kind: consoleProxyAuthorization.workspaceKind,
5221
+ role: consoleProxyAuthorization.role,
5222
+ membershipRevision: consoleProxyAuthorization.membershipRevision,
5223
+ plan: consoleProxyAuthorization.plan,
5224
+ status: consoleProxyAuthorization.entitlementStatus
5225
+ },
5226
+ license: licenseSnapshot(),
5227
+ uiSession: {
5228
+ csrfToken: uiSession.csrfToken,
5229
+ expiresAt: uiSession.expiresAt,
5230
+ accessExpiresAt
5231
+ }
5232
+ });
5233
+ return;
5234
+ }
5235
+ const requestAuthEpoch = runtimeAuthEpoch;
5236
+ const requestBoundUserId = currentBoundHubUserId();
5237
+ const requestBoundWorkspaceId = currentBoundHubWorkspaceId();
5238
+ const normalized = normalizeRuntimeAuthSession(req.body);
4557
5239
  if (!normalized.ok) {
4558
5240
  res.status(400).json({ ok: false, error: normalized.error });
4559
5241
  return;
4560
- }
4561
- const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4562
- try {
4563
- const user = await verifySupabaseUser(accessToken);
4564
- const boundUserId = currentBoundHubUserId();
4565
- if (boundUserId && boundUserId !== user.id) {
4566
- res.status(403).json({ ok: false, error: 'hub-account-mismatch' });
4567
- return;
4568
- }
4569
- if (!boundUserId && !isLoopbackAddress(req.socket?.remoteAddress)) {
4570
- res.status(403).json({ ok: false, error: 'hub-first-account-binding-must-be-local' });
4571
- return;
4572
- }
4573
- runtimeAccessToken = accessToken;
4574
- runtimeRefreshToken = refreshToken;
4575
- runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
4576
- runtimeManager.setAuthenticated(true, user.id);
5242
+ }
5243
+ const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
5244
+ try {
5245
+ const user = await verifySupabaseUser(accessToken);
5246
+ const explicitlyRequestedWorkspaceId = req.body?.workspaceId ?? req.body?.workspace_id ?? '';
5247
+ const authoritativeWorkspaceId = await queryRuntimeDeviceWorkspace(accessToken);
5248
+ const nativeRuntimeWorkspaceFallback = !String(explicitlyRequestedWorkspaceId || '').trim()
5249
+ && isTrustedNativeLoopbackRequest(req)
5250
+ ? currentBoundHubWorkspaceId() || authoritativeWorkspaceId
5251
+ : '';
5252
+ const access = await verifyWorkspaceAccess(
5253
+ accessToken,
5254
+ user,
5255
+ explicitlyRequestedWorkspaceId || nativeRuntimeWorkspaceFallback
5256
+ );
5257
+ const boundUserId = currentBoundHubUserId();
5258
+ const boundWorkspaceId = currentBoundHubWorkspaceId();
5259
+ if (runtimeAuthEpoch !== requestAuthEpoch
5260
+ || boundUserId !== requestBoundUserId
5261
+ || boundWorkspaceId !== requestBoundWorkspaceId) {
5262
+ res.status(409).json({ ok: false, error: 'hub-auth-session-superseded' });
5263
+ return;
5264
+ }
5265
+ if (authoritativeWorkspaceId && authoritativeWorkspaceId !== access.workspaceId) {
5266
+ res.status(403).json({ ok: false, error: 'hub-device-workspace-mismatch' });
5267
+ return;
5268
+ }
5269
+ const sameRuntimeOwner = Boolean(boundUserId) && boundUserId === user.id;
5270
+ const memberUiOnly = Boolean(boundUserId)
5271
+ && boundWorkspaceId === access.workspaceId
5272
+ && ['owner', 'operator'].includes(access.role)
5273
+ && (!sameRuntimeOwner
5274
+ || access.role !== 'owner'
5275
+ || !isTrustedNativeLoopbackRequest(req));
5276
+ const ownerLocalWorkspaceRebind = sameRuntimeOwner
5277
+ && boundWorkspaceId
5278
+ && boundWorkspaceId !== access.workspaceId
5279
+ && access.role === 'owner'
5280
+ && isTrustedNativeLoopbackRequest(req)
5281
+ && authoritativeWorkspaceId === access.workspaceId;
5282
+ if (boundWorkspaceId && boundWorkspaceId !== access.workspaceId && !ownerLocalWorkspaceRebind) {
5283
+ res.status(403).json({ ok: false, error: 'hub-workspace-mismatch' });
5284
+ return;
5285
+ }
5286
+ if (!boundUserId
5287
+ && (access.role !== 'owner' || !isLoopbackAddress(req.socket?.remoteAddress))) {
5288
+ res.status(403).json({
5289
+ ok: false,
5290
+ error: access.role === 'owner'
5291
+ ? 'hub-first-account-binding-must-be-local'
5292
+ : 'hub-owner-session-required'
5293
+ });
5294
+ return;
5295
+ }
5296
+ if (!memberUiOnly && !refreshToken) {
5297
+ res.status(400).json({ ok: false, error: 'refresh-token-required' });
5298
+ return;
5299
+ }
5300
+ if (ownerLocalWorkspaceRebind) {
5301
+ hubUiSessionAuthority.revokeAll('hub-workspace-rebound');
5302
+ }
5303
+ if (access.workspaceKind === 'team' && !workspaceRemoteAccessActive(access)) {
5304
+ hubUiSessionAuthority.revokeWorkspaceMember(access.workspaceId, user.id);
5305
+ }
5306
+ const uiAccessExpiresAt = access.workspaceKind === 'team'
5307
+ ? Date.now() + TEAM_UI_ACCESS_MAX_AGE_MS
5308
+ : 0;
5309
+ const uiSession = hubUiSessionAuthority.renew(req, user.id, {
5310
+ workspaceId: access.workspaceId,
5311
+ workspaceKind: access.workspaceKind,
5312
+ role: access.role,
5313
+ plan: access.plan,
5314
+ entitlementStatus: access.entitlementStatus,
5315
+ membershipRevision: access.membershipRevision,
5316
+ accessExpiresAt: uiAccessExpiresAt
5317
+ });
5318
+ res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
5319
+ secure: isSecureHubHttpRequest(req)
5320
+ }));
5321
+ if (memberUiOnly) {
5322
+ res.json({
5323
+ ok: true,
5324
+ authenticated: true,
5325
+ persisted: false,
5326
+ runtimeOwner: false,
5327
+ userId: user.id,
5328
+ role: runtimeRole,
5329
+ workspace: workspaceAccessSnapshot(access),
5330
+ license: licenseSnapshot(),
5331
+ hostTarget: {
5332
+ ok: true,
5333
+ pending: false,
5334
+ active: remoteHub.getStatus({ includeSecrets: false }).hostTargetActive === true
5335
+ },
5336
+ uiSession: {
5337
+ csrfToken: uiSession.csrfToken,
5338
+ expiresAt: uiSession.expiresAt,
5339
+ accessExpiresAt: uiAccessExpiresAt
5340
+ }
5341
+ });
5342
+ return;
5343
+ }
5344
+ if (boundUserId && !sameRuntimeOwner) {
5345
+ res.status(403).json({ ok: false, error: 'hub-account-mismatch' });
5346
+ return;
5347
+ }
5348
+ runtimeAuthEpoch += 1;
5349
+ runtimeAccessToken = accessToken;
5350
+ runtimeRefreshToken = refreshToken;
5351
+ runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
5352
+ runtimeWorkspaceAccess = access;
5353
+ if (ownerLocalWorkspaceRebind) {
5354
+ hubConsoleDirect?.invalidateWorkspaceAccess('hub-workspace-rebound');
5355
+ }
5356
+ workspaceAccessLastAttemptAt = Date.now();
5357
+ licenseRefreshLastAttemptAt = Date.now();
5358
+ runtimeManager.setAuthenticated(true, user.id);
4577
5359
  const persisted = persistRuntimeSession({
4578
5360
  access_token: accessToken,
4579
5361
  refresh_token: refreshToken,
4580
5362
  expires_at: expiresAt,
4581
- user
4582
- });
4583
- const hostTarget = runtimeRole === 'hub'
4584
- ? {
4585
- ok: true,
4586
- pending: true,
4587
- active: remoteHub.getStatus({ includeSecrets: false }).hostTargetActive === true
4588
- }
4589
- : { ok: true, pending: false, active: false };
4590
- const uiSession = hubUiSessionAuthority.issue(user.id);
4591
- res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
4592
- secure: isSecureHubHttpRequest(req)
4593
- }));
4594
- res.json({
5363
+ user
5364
+ });
5365
+ const workspacePersisted = persistRuntimeWorkspace(access);
5366
+ applyVerifiedWorkspaceLicense(access);
5367
+ const teamRemoteActive = workspaceRemoteAccessActive(access);
5368
+ let hostTarget = runtimeRole === 'hub'
5369
+ ? {
5370
+ ok: true,
5371
+ pending: true,
5372
+ active: remoteHub.getStatus({ includeSecrets: false }).hostTargetActive === true
5373
+ }
5374
+ : { ok: true, pending: false, active: false };
5375
+ if (runtimeRole === 'hub' && !teamRemoteActive) {
5376
+ hubConsoleDirect?.close();
5377
+ const cleared = await clearHubHostTarget('workspace-entitlement-inactive');
5378
+ hostTarget = { ok: cleared.ok, pending: false, active: false, error: cleared.error || '' };
5379
+ }
5380
+ res.json({
4595
5381
  ok: true,
4596
- authenticated: true,
4597
- persisted,
4598
- userId: user.id,
4599
- role: runtimeRole,
5382
+ authenticated: true,
5383
+ persisted: persisted && workspacePersisted,
5384
+ runtimeOwner: true,
5385
+ userId: user.id,
5386
+ role: runtimeRole,
5387
+ workspace: workspaceAccessSnapshot(access),
5388
+ license: licenseSnapshot(),
4600
5389
  hostTarget,
4601
5390
  uiSession: {
4602
5391
  csrfToken: uiSession.csrfToken,
4603
5392
  expiresAt: uiSession.expiresAt
4604
5393
  }
4605
5394
  });
4606
- if (runtimeRole === 'hub') {
5395
+ if (runtimeRole === 'hub' && teamRemoteActive) {
4607
5396
  hubConsoleDirect?.refresh();
4608
5397
  scheduleAuthenticatedHubHostTargetPublication('session-received');
4609
5398
  }
4610
- } catch (error) {
4611
- const message = error instanceof Error ? error.message : String(error);
4612
- const status = authVerificationHttpStatus(message);
4613
- res.status(status).json({ ok: false, error: message });
4614
- }
5399
+ } catch (error) {
5400
+ const message = error instanceof Error ? error.message : String(error);
5401
+ const status = workspaceAccessHttpStatus(error);
5402
+ res.status(status).json({ ok: false, error: message });
5403
+ }
4615
5404
  });
4616
5405
 
4617
5406
  function updateHubHostTargetLeaseState(patch = {}) {
@@ -4643,19 +5432,20 @@ function hubWakeSignalUrl() {
4643
5432
  return url.toString();
4644
5433
  }
4645
5434
 
4646
- function buildHubWakeEventId(target, remoteStatus, endpointCandidates) {
4647
- return crypto.createHash('sha256').update(JSON.stringify({
4648
- deviceId: runtimeDeviceId,
5435
+ function buildHubWakeEventId(target, remoteStatus, endpointCandidates, workspaceId = currentBoundHubWorkspaceId()) {
5436
+ return crypto.createHash('sha256').update(JSON.stringify({
5437
+ workspaceId,
5438
+ deviceId: runtimeDeviceId,
4649
5439
  leaseId: target.leaseId || '',
4650
5440
  hostInstanceId: target.hostInstanceId || remoteStatus.hostInstanceId || '',
4651
5441
  endpointCandidates: [...endpointCandidates].sort()
4652
5442
  })).digest('hex').slice(0, 40);
4653
5443
  }
4654
5444
 
4655
- async function notifyHubOnline({ accessToken, target, remoteStatus, endpointCandidates }) {
4656
- const url = hubWakeSignalUrl();
4657
- if (!url) return { ok: false, skipped: true, reason: 'wake-disabled' };
4658
- const eventId = buildHubWakeEventId(target, remoteStatus, endpointCandidates);
5445
+ async function notifyHubOnline({ accessToken, target, remoteStatus, endpointCandidates, workspaceId }) {
5446
+ const url = hubWakeSignalUrl();
5447
+ if (!url) return { ok: false, skipped: true, reason: 'wake-disabled' };
5448
+ const eventId = buildHubWakeEventId(target, remoteStatus, endpointCandidates, workspaceId);
4659
5449
  if (hubWakeNotificationState.lastEventId === eventId && hubWakeNotificationState.state === 'notified') {
4660
5450
  return { ok: true, skipped: true, eventId };
4661
5451
  }
@@ -4678,7 +5468,11 @@ async function notifyHubOnline({ accessToken, target, remoteStatus, endpointCand
4678
5468
  'Content-Type': 'application/json',
4679
5469
  Accept: 'application/json'
4680
5470
  },
4681
- body: JSON.stringify({ deviceId: runtimeDeviceId, eventId })
5471
+ body: JSON.stringify({
5472
+ workspaceId,
5473
+ deviceId: runtimeDeviceId,
5474
+ eventId
5475
+ })
4682
5476
  }, SUPABASE_AUTH_TIMEOUT_MS);
4683
5477
  const data = await response.json().catch(() => null);
4684
5478
  if (!response.ok || data?.ok === false) {
@@ -4727,9 +5521,8 @@ async function callHubSupabaseRpc(name, payload, accessToken) {
4727
5521
  );
4728
5522
  const data = await response.json().catch(() => null);
4729
5523
  const result = Array.isArray(data) ? data[0] : data;
4730
- if (response.status === 401 || response.status === 403) {
4731
- if (!desktopMainAuthConfig) clearRuntimeSession();
4732
- throw new Error(`${name}-not-authenticated:${response.status}`);
5524
+ if (response.status === 401 || response.status === 403) {
5525
+ throw new Error(`${name}-not-authenticated:${response.status}`);
4733
5526
  }
4734
5527
  if (!response.ok) {
4735
5528
  throw new Error(`${name}-failed:${response.status}`);
@@ -4740,16 +5533,21 @@ async function callHubSupabaseRpc(name, payload, accessToken) {
4740
5533
  return result;
4741
5534
  }
4742
5535
 
4743
- async function promoteCurrentRuntimeToHub() {
4744
- if (runtimeRole !== 'hub' || !runtimeDeviceId) {
4745
- throw new Error('hub-runtime-identity-unavailable');
4746
- }
5536
+ async function promoteCurrentRuntimeToHub() {
5537
+ if (runtimeRole !== 'hub' || !runtimeDeviceId) {
5538
+ throw new Error('hub-runtime-identity-unavailable');
5539
+ }
5540
+ if (runtimeWorkspaceAccess?.workspaceKind === 'team'
5541
+ && !workspaceRemoteAccessActive(runtimeWorkspaceAccess)) {
5542
+ throw new Error('workspace-entitlement-inactive');
5543
+ }
4747
5544
  const accessToken = await getRuntimeAccessToken();
4748
5545
  if (!accessToken) {
4749
5546
  throw new Error('hub-session-required');
4750
5547
  }
4751
- const result = await callHubSupabaseRpc('set_livedesk_device_role', {
4752
- p_device_id: runtimeDeviceId,
5548
+ const result = await callHubSupabaseRpc('set_livedesk_workspace_device_role', {
5549
+ p_workspace_id: currentBoundHubWorkspaceId(),
5550
+ p_device_id: runtimeDeviceId,
4753
5551
  p_role: 'hub',
4754
5552
  p_assigned_hub_id: null,
4755
5553
  p_expected_role_version: null
@@ -4758,10 +5556,22 @@ async function promoteCurrentRuntimeToHub() {
4758
5556
  return result;
4759
5557
  }
4760
5558
 
4761
- async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {}) {
4762
- if (runtimeRole !== 'hub') {
4763
- throw new Error('hub-role-not-allowed');
4764
- }
5559
+ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {}) {
5560
+ const operationAuthEpoch = runtimeAuthEpoch;
5561
+ const operationUserId = currentBoundHubUserId();
5562
+ const operationWorkspaceId = currentBoundHubWorkspaceId();
5563
+ if (runtimeRole !== 'hub') {
5564
+ throw new Error('hub-role-not-allowed');
5565
+ }
5566
+ if (runtimeWorkspaceAccess?.workspaceKind === 'team'
5567
+ && !workspaceRemoteAccessActive(runtimeWorkspaceAccess)) {
5568
+ updateHubHostTargetLeaseState({
5569
+ state: 'inactive',
5570
+ lastReason: String(reason || 'renewal'),
5571
+ lastError: 'workspace-entitlement-inactive'
5572
+ });
5573
+ return { ok: false, active: false, error: 'workspace-entitlement-inactive' };
5574
+ }
4765
5575
  if (hubHostTargetRenewInFlight) {
4766
5576
  return { ok: false, error: 'host-target-publish-already-running', hostTarget: remoteHub.getStatus({ includeSecrets: false }) };
4767
5577
  }
@@ -4777,7 +5587,10 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
4777
5587
  if (!accessToken) {
4778
5588
  throw new Error('hub-session-required');
4779
5589
  }
4780
- const authoritativeRole = await queryAuthoritativeRuntimeRole();
5590
+ const authoritativeRole = await queryAuthoritativeRuntimeRole();
5591
+ if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
5592
+ return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
5593
+ }
4781
5594
  if (authoritativeRole !== 'hub') {
4782
5595
  throw new Error(authoritativeRole === 'client' ? 'hub-role-no-longer-authoritative' : 'hub-role-not-authoritative');
4783
5596
  }
@@ -4788,9 +5601,12 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
4788
5601
  leaseMs: HUB_HOST_TARGET_LEASE_MS,
4789
5602
  takeover
4790
5603
  });
4791
- if (!local?.ok || !local.hostTarget) {
4792
- throw new Error(local?.error || 'local-host-target-failed');
4793
- }
5604
+ if (!local?.ok || !local.hostTarget) {
5605
+ throw new Error(local?.error || 'local-host-target-failed');
5606
+ }
5607
+ if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
5608
+ return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
5609
+ }
4794
5610
 
4795
5611
  const remoteStatus = remoteHub.getStatus({ includeSecrets: true });
4796
5612
  if (!remoteStatus.agentEndpointAccountRouteReady) {
@@ -4800,8 +5616,9 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
4800
5616
  const endpointCandidates = Array.isArray(target.endpointCandidates) && target.endpointCandidates.length > 0
4801
5617
  ? target.endpointCandidates
4802
5618
  : [target.endpoint].filter(Boolean);
4803
- await callHubSupabaseRpc('set_livedesk_remote_host_target', {
4804
- p_node_id: runtimeDeviceId,
5619
+ await callHubSupabaseRpc('set_livedesk_workspace_remote_host_target', {
5620
+ p_workspace_id: operationWorkspaceId,
5621
+ p_node_id: runtimeDeviceId,
4805
5622
  p_lease_id: target.leaseId,
4806
5623
  p_host_instance_id: target.hostInstanceId || remoteStatus.hostInstanceId || runtimeDeviceId,
4807
5624
  p_endpoint: target.endpoint || remoteStatus.agentEndpoint,
@@ -4815,7 +5632,19 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
4815
5632
  p_endpoint_candidates: endpointCandidates,
4816
5633
  p_pairing_pin: remoteStatus.pairingPin || ''
4817
5634
  }, accessToken);
4818
- await notifyHubOnline({ accessToken, target, remoteStatus, endpointCandidates });
5635
+ if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
5636
+ return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
5637
+ }
5638
+ await notifyHubOnline({
5639
+ accessToken,
5640
+ target,
5641
+ remoteStatus,
5642
+ endpointCandidates,
5643
+ workspaceId: operationWorkspaceId
5644
+ });
5645
+ if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
5646
+ return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
5647
+ }
4819
5648
 
4820
5649
  updateHubHostTargetLeaseState({
4821
5650
  state: 'active',
@@ -4851,7 +5680,10 @@ async function publishHubHostTargetWithPendingRoleTakeover(reason = 'renewal') {
4851
5680
  return result;
4852
5681
  }
4853
5682
 
4854
- async function clearHubHostTarget(reason = 'shutdown') {
5683
+ async function clearHubHostTarget(reason = 'shutdown') {
5684
+ const operationAuthEpoch = runtimeAuthEpoch;
5685
+ const operationUserId = currentBoundHubUserId();
5686
+ const operationWorkspaceId = currentBoundHubWorkspaceId();
4855
5687
  if (hubHostTargetRenewTimer) {
4856
5688
  clearInterval(hubHostTargetRenewTimer);
4857
5689
  hubHostTargetRenewTimer = null;
@@ -4864,18 +5696,24 @@ async function clearHubHostTarget(reason = 'shutdown') {
4864
5696
  try {
4865
5697
  const accessToken = await getRuntimeAccessToken();
4866
5698
  if (accessToken && targetNodeId && targetLeaseId && targetInstanceId) {
4867
- await callHubSupabaseRpc('clear_livedesk_remote_host_target', {
4868
- p_node_id: targetNodeId,
5699
+ await callHubSupabaseRpc('clear_livedesk_workspace_remote_host_target', {
5700
+ p_workspace_id: operationWorkspaceId,
5701
+ p_node_id: targetNodeId,
4869
5702
  p_lease_id: targetLeaseId,
4870
5703
  p_host_instance_id: targetInstanceId,
4871
5704
  p_force: false
4872
5705
  }, accessToken);
4873
5706
  }
4874
- } catch (cause) {
4875
- error = cause instanceof Error ? cause.message : String(cause);
4876
- console.error(`[VuvoDesk Hub] Host target ${reason} clear failed: ${error}`);
4877
- }
4878
- try {
5707
+ } catch (cause) {
5708
+ error = cause instanceof Error ? cause.message : String(cause);
5709
+ console.error(`[VuvoDesk Hub] Host target ${reason} clear failed: ${error}`);
5710
+ }
5711
+ const currentRemoteStatus = remoteHub.getStatus({ includeSecrets: false });
5712
+ if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)
5713
+ || (targetLeaseId && String(currentRemoteStatus.hostTargetLeaseId || '') !== targetLeaseId)) {
5714
+ return { ok: true, superseded: true, error, lease: getHubHostTargetLeaseStatus() };
5715
+ }
5716
+ try {
4879
5717
  await remoteHub.setHostTarget({ enabled: false, nodeId: targetNodeId });
4880
5718
  } catch (cause) {
4881
5719
  error = error || (cause instanceof Error ? cause.message : String(cause));
@@ -4914,16 +5752,31 @@ function scheduleAuthenticatedHubHostTargetPublication(reason = 'session-receive
4914
5752
  });
4915
5753
  }
4916
5754
 
4917
- app.delete('/api/auth/session', async (req, res) => {
4918
- noStore(res);
5755
+ app.delete('/api/auth/session', async (req, res) => {
5756
+ noStore(res);
5757
+ const nativeOwnerLogout = isTrustedNativeLoopbackRequest(req)
5758
+ && !String(req.headers['x-livedesk-console-proxy'] || '').trim();
5759
+ if (!nativeOwnerLogout) {
5760
+ const revoked = hubUiSessionAuthority.revoke(req);
5761
+ res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
5762
+ res.json({
5763
+ ok: true,
5764
+ authenticated: runtimeManager.getSnapshot().authenticated,
5765
+ role: runtimeRole,
5766
+ runtimePreserved: true,
5767
+ signedOutUserId: revoked?.userId || null,
5768
+ workspaceId: revoked?.workspaceId || currentBoundHubWorkspaceId()
5769
+ });
5770
+ return;
5771
+ }
4919
5772
  const hostTarget = runtimeRole === 'hub'
4920
5773
  ? await clearHubHostTarget('logout')
4921
5774
  : { ok: true, active: false };
4922
5775
  clearRuntimeSession();
4923
5776
  hubConsoleDirect?.close();
4924
5777
  res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
4925
- res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
4926
- });
5778
+ res.json({ ok: true, authenticated: false, role: runtimeRole, runtimePreserved: false, hostTarget });
5779
+ });
4927
5780
 
4928
5781
  app.get('/api/hub/status', (_req, res) => {
4929
5782
  noStore(res);
@@ -5015,34 +5868,19 @@ app.post('/api/runtime/role', async (req, res) => {
5015
5868
  res.status(400).json({ ok: false, error: 'hub-can-only-transition-to-client' });
5016
5869
  return;
5017
5870
  }
5018
- const authorization = String(req.headers.authorization || '').trim();
5019
- const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
5871
+ const accessToken = await getRuntimeAccessToken().catch(() => '');
5020
5872
  if (!accessToken || !runtimeDeviceId) {
5021
5873
  res.status(401).json({ ok: false, error: 'supabase-access-token-and-device-id-required' });
5022
5874
  return;
5023
5875
  }
5024
5876
  try {
5025
- const response = await fetch(`${supabaseUrl}/rest/v1/rpc/set_livedesk_device_role`, {
5026
- method: 'POST',
5027
- headers: {
5028
- apikey: supabasePublishableKey,
5029
- Authorization: `Bearer ${accessToken}`,
5030
- 'Content-Type': 'application/json',
5031
- Accept: 'application/json'
5032
- },
5033
- body: JSON.stringify({
5034
- p_device_id: runtimeDeviceId,
5035
- p_role: 'client',
5036
- p_assigned_hub_id: String(req.body?.assignedHubId || '').trim() || null,
5037
- p_expected_role_version: Number.isInteger(Number(req.body?.expectedRoleVersion)) ? Number(req.body.expectedRoleVersion) : null
5038
- })
5039
- });
5040
- const data = await response.json().catch(() => null);
5041
- const result = Array.isArray(data) ? data[0] : data;
5042
- if (!response.ok || result?.ok === false) {
5043
- res.status(409).json({ ok: false, error: result?.reason || `role-change-rejected:${response.status}` });
5044
- return;
5045
- }
5877
+ const result = await callHubSupabaseRpc('set_livedesk_workspace_device_role', {
5878
+ p_workspace_id: currentBoundHubWorkspaceId(),
5879
+ p_device_id: runtimeDeviceId,
5880
+ p_role: 'client',
5881
+ p_assigned_hub_id: String(req.body?.assignedHubId || '').trim() || null,
5882
+ p_expected_role_version: Number.isInteger(Number(req.body?.expectedRoleVersion)) ? Number(req.body.expectedRoleVersion) : null
5883
+ }, accessToken);
5046
5884
  await clearHubHostTarget('role-transition');
5047
5885
  res.json({ ok: true, restarting: true, role: 'client', roleVersion: result?.role_version || 0 });
5048
5886
  setTimeout(() => process.exit(ROLE_TRANSITION_EXIT_CODE), 150);
@@ -5090,38 +5928,103 @@ app.get('/api/remote/registry-credentials', (_req, res) => {
5090
5928
  });
5091
5929
  });
5092
5930
 
5093
- app.get('/api/remote/license', (_req, res) => {
5931
+ app.get('/api/remote/license', (_req, res) => {
5094
5932
  noStore(res);
5095
- res.json(licenseSnapshot());
5096
- });
5097
-
5098
- app.post('/api/remote/license/sync', async (req, res) => {
5933
+ res.json(licenseSnapshot());
5934
+ });
5935
+
5936
+ app.post('/api/remote/workspace/members/:userId/revoke', async (req, res) => {
5937
+ noStore(res);
5938
+ const workspaceId = String(req.body?.workspaceId || '').trim();
5939
+ const memberUserId = String(req.params.userId || '').trim().slice(0, 160);
5940
+ if (authorizedWorkspaceRole(req) !== 'owner') {
5941
+ res.status(403).json({ ok: false, error: 'workspace-owner-required' });
5942
+ return;
5943
+ }
5944
+ if (!workspaceId || workspaceId !== currentBoundHubWorkspaceId() || !memberUserId) {
5945
+ res.status(400).json({ ok: false, error: 'workspace-and-member-required' });
5946
+ return;
5947
+ }
5948
+ const retiredPeers = hubConsoleDirect?.revokeWorkspaceMember(
5949
+ workspaceId,
5950
+ memberUserId,
5951
+ 'workspace-member-revoked'
5952
+ ) || 0;
5953
+ const revokedUiSessions = hubUiSessionAuthority.revokeWorkspaceMember(workspaceId, memberUserId);
5954
+ let wakeRevoked = false;
5955
+ let wakeError = '';
5956
+ try {
5957
+ const accessToken = await getRuntimeAccessToken();
5958
+ if (!accessToken) throw new Error('hub-session-required');
5959
+ const revokeUrl = new URL('/v2/rtc/revoke-member', hubWakeBaseUrl);
5960
+ const response = await fetchAuthResponse(fetch, revokeUrl.href, {
5961
+ method: 'POST',
5962
+ headers: {
5963
+ Authorization: `Bearer ${accessToken}`,
5964
+ 'Content-Type': 'application/json',
5965
+ Accept: 'application/json'
5966
+ },
5967
+ body: JSON.stringify({ workspaceId, memberUserId })
5968
+ }, SUPABASE_AUTH_TIMEOUT_MS);
5969
+ wakeRevoked = response.ok;
5970
+ if (!response.ok) wakeError = `wake-member-revoke-failed:${response.status}`;
5971
+ } catch (error) {
5972
+ wakeError = error instanceof Error ? error.message : String(error);
5973
+ }
5974
+ res.json({
5975
+ ok: true,
5976
+ workspaceId,
5977
+ memberUserId,
5978
+ retiredPeers,
5979
+ revokedUiSessions,
5980
+ wakeRevoked,
5981
+ ...(wakeError ? { wakeError } : {})
5982
+ });
5983
+ });
5984
+
5985
+ app.post('/api/remote/license/sync', async (req, res) => {
5099
5986
  noStore(res);
5100
5987
  try {
5101
- const authorization = String(req.headers.authorization || '');
5102
- const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
5103
- res.json(await syncVerifiedLicense(accessToken));
5104
- } catch (err) {
5105
- verifiedLicense = {
5106
- userId: '',
5107
- plan: 'free',
5108
- status: 'inactive',
5109
- expiresAt: '',
5110
- verifiedAt: Date.now()
5111
- };
5112
- res.status(401).json({ ok: false, error: err instanceof Error ? err.message : String(err), license: licenseSnapshot() });
5113
- }
5114
- });
5988
+ const authorization = String(req.headers.authorization || '');
5989
+ const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
5990
+ res.json(await syncVerifiedLicense(accessToken, req.body?.workspaceId ?? req.body?.workspace_id ?? ''));
5991
+ } catch (err) {
5992
+ const explicitAccessRejection = err instanceof WorkspaceAccessError && err.status < 500;
5993
+ if (explicitAccessRejection) {
5994
+ verifiedLicense = {
5995
+ userId: '',
5996
+ workspaceId: currentBoundHubWorkspaceId(),
5997
+ workspaceKind: runtimeWorkspaceAccess?.workspaceKind || 'personal',
5998
+ plan: 'free',
5999
+ status: 'inactive',
6000
+ expiresAt: '',
6001
+ memberLimit: 1,
6002
+ commercialUse: false,
6003
+ verifiedAt: Date.now()
6004
+ };
6005
+ }
6006
+ res.status(workspaceAccessHttpStatus(err)).json({
6007
+ ok: false,
6008
+ error: err instanceof Error ? err.message : String(err),
6009
+ preserved: !explicitAccessRejection && Date.now() - verifiedLicense.verifiedAt <= LICENSE_VERIFY_MAX_AGE_MS,
6010
+ license: licenseSnapshot()
6011
+ });
6012
+ }
6013
+ });
5115
6014
 
5116
6015
  app.delete('/api/remote/license', (_req, res) => {
5117
6016
  noStore(res);
5118
- verifiedLicense = {
5119
- userId: '',
5120
- plan: 'free',
5121
- status: 'inactive',
5122
- expiresAt: '',
5123
- verifiedAt: Date.now()
5124
- };
6017
+ verifiedLicense = {
6018
+ userId: '',
6019
+ workspaceId: currentBoundHubWorkspaceId(),
6020
+ workspaceKind: runtimeWorkspaceAccess?.workspaceKind || 'personal',
6021
+ plan: 'free',
6022
+ status: 'inactive',
6023
+ expiresAt: '',
6024
+ memberLimit: 1,
6025
+ commercialUse: false,
6026
+ verifiedAt: Date.now()
6027
+ };
5125
6028
  res.json(licenseSnapshot());
5126
6029
  });
5127
6030
 
@@ -5787,10 +6690,26 @@ httpServer.on('upgrade', (req, socket, head) => {
5787
6690
  const label = authorization.status === 403 ? 'Forbidden' : 'Unauthorized';
5788
6691
  socket.write(`HTTP/1.1 ${authorization.status} ${label}\r\nConnection: close\r\n\r\n`);
5789
6692
  socket.destroy();
5790
- return;
5791
- }
5792
- try {
5793
- const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
6693
+ return;
6694
+ }
6695
+ try {
6696
+ const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
6697
+ if (!workspaceEntitlementCanRequest(
6698
+ authorizationWorkspaceAccess(authorization),
6699
+ 'GET',
6700
+ parsed.pathname
6701
+ )) {
6702
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
6703
+ socket.destroy();
6704
+ return;
6705
+ }
6706
+ if (authorization.role
6707
+ && !workspaceRoleCanRequest(authorization.role, 'GET', parsed.pathname)) {
6708
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
6709
+ socket.destroy();
6710
+ return;
6711
+ }
6712
+ req.liveDeskAuthorization = authorization;
5794
6713
  if (parsed.pathname === '/api/remote/frames/ws') {
5795
6714
  frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
5796
6715
  return;
@@ -5815,7 +6734,8 @@ httpServer.on('upgrade', (req, socket, head) => {
5815
6734
  }
5816
6735
  });
5817
6736
 
5818
- frameWss.on('connection', (ws, req) => {
6737
+ frameWss.on('connection', (ws, req) => {
6738
+ bindHubUiSessionSocket(ws, req);
5819
6739
  frameClients.add(ws);
5820
6740
  ws.liveDeskFrameClientId = `rfws-${++frameClientSeq}`;
5821
6741
  try {
@@ -5834,6 +6754,7 @@ frameWss.on('connection', (ws, req) => {
5834
6754
  updateFrameSubscription(ws, {});
5835
6755
  }
5836
6756
  ws.on('message', data => {
6757
+ if (ws.liveDeskUiSessionRevoked === true) return;
5837
6758
  try {
5838
6759
  const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
5839
6760
  if (payload?.type === 'subscribe') {
@@ -5857,7 +6778,8 @@ frameWss.on('connection', (ws, req) => {
5857
6778
  });
5858
6779
  });
5859
6780
 
5860
- atlasWss.on('connection', ws => {
6781
+ atlasWss.on('connection', (ws, req) => {
6782
+ bindHubUiSessionSocket(ws, req);
5861
6783
  ws.liveDeskFrameClientId = `atlas-${++frameClientSeq}`;
5862
6784
  try { ws._socket?.setNoDelay?.(true); } catch {}
5863
6785
  const cleanup = () => {
@@ -5871,7 +6793,8 @@ atlasWss.on('connection', ws => {
5871
6793
  ws.liveDeskAtlasSession = null;
5872
6794
  retireFrameClientSendLane(ws);
5873
6795
  };
5874
- ws.on('message', data => {
6796
+ ws.on('message', data => {
6797
+ if (ws.liveDeskUiSessionRevoked === true) return;
5875
6798
  try {
5876
6799
  const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
5877
6800
  if (payload?.type === 'subscribe' || payload?.type === 'configure') {
@@ -5891,7 +6814,8 @@ atlasWss.on('connection', ws => {
5891
6814
  });
5892
6815
  });
5893
6816
 
5894
- audioWss.on('connection', (ws, req) => {
6817
+ audioWss.on('connection', (ws, req) => {
6818
+ bindHubUiSessionSocket(ws, req);
5895
6819
  audioClients.add(ws);
5896
6820
  ws.liveDeskAudioClientId = `raws-${++audioClientSeq}`;
5897
6821
  ws.liveDeskAudioCleanupComplete = false;
@@ -5904,7 +6828,8 @@ audioWss.on('connection', (ws, req) => {
5904
6828
  } catch {
5905
6829
  updateAudioSubscription(ws, {});
5906
6830
  }
5907
- ws.on('message', data => {
6831
+ ws.on('message', data => {
6832
+ if (ws.liveDeskUiSessionRevoked === true) return;
5908
6833
  try {
5909
6834
  const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
5910
6835
  if (payload?.type === 'subscribe') {
@@ -5925,7 +6850,8 @@ audioWss.on('connection', (ws, req) => {
5925
6850
  });
5926
6851
  });
5927
6852
 
5928
- inputWss.on('connection', ws => {
6853
+ inputWss.on('connection', (ws, req) => {
6854
+ bindHubUiSessionSocket(ws, req);
5929
6855
  inputClients.add(ws);
5930
6856
  ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
5931
6857
  ws.liveDeskInputDeviceIds = new Set();
@@ -5934,7 +6860,8 @@ inputWss.on('connection', ws => {
5934
6860
  } catch {
5935
6861
  // Best-effort latency hint for browser input sockets.
5936
6862
  }
5937
- ws.on('message', data => {
6863
+ ws.on('message', data => {
6864
+ if (ws.liveDeskUiSessionRevoked === true) return;
5938
6865
  let payload;
5939
6866
  try {
5940
6867
  payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
@@ -6103,9 +7030,10 @@ function shutdownHub(signal) {
6103
7030
  hubShutdownPromise = (async () => {
6104
7031
  const startedAt = Date.now();
6105
7032
  console.log(`[VuvoDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
6106
- if (roleWatchTimer) clearInterval(roleWatchTimer);
6107
- clearInterval(browserWebSocketHeartbeatTimer);
6108
- runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
7033
+ if (roleWatchTimer) clearInterval(roleWatchTimer);
7034
+ clearInterval(browserWebSocketHeartbeatTimer);
7035
+ runSynchronousShutdownStep('control presentation reconcile close', () => readOnlyControlPresentationReconcileCoordinator.close());
7036
+ runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
6109
7037
  runSynchronousShutdownStep('mobile console direct close', () => hubConsoleDirect?.close());
6110
7038
  atlasClients.clear();
6111
7039
  runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());