@livedesk/hub 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -0,0 +1,119 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export const HUB_UI_SESSION_COOKIE = 'livedesk_hub_ui';
4
+ export const HUB_UI_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
5
+
6
+ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
7
+
8
+ function secureEqual(left, right) {
9
+ const leftBuffer = Buffer.from(String(left || ''), 'utf8');
10
+ const rightBuffer = Buffer.from(String(right || ''), 'utf8');
11
+ return leftBuffer.length === rightBuffer.length
12
+ && leftBuffer.length > 0
13
+ && crypto.timingSafeEqual(leftBuffer, rightBuffer);
14
+ }
15
+
16
+ function parseCookies(value) {
17
+ const result = new Map();
18
+ for (const part of String(value || '').split(';')) {
19
+ const separator = part.indexOf('=');
20
+ if (separator <= 0) continue;
21
+ const key = part.slice(0, separator).trim();
22
+ const rawValue = part.slice(separator + 1).trim();
23
+ if (!key) continue;
24
+ try {
25
+ result.set(key, decodeURIComponent(rawValue));
26
+ } catch {
27
+ result.set(key, rawValue);
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+
33
+ function randomToken() {
34
+ return crypto.randomBytes(32).toString('base64url');
35
+ }
36
+
37
+ export function createHubUiSessionAuthority({
38
+ ttlMs = HUB_UI_SESSION_TTL_MS,
39
+ maxSessions = 32,
40
+ now = () => Date.now()
41
+ } = {}) {
42
+ const sessions = new Map();
43
+
44
+ function purgeExpired() {
45
+ const current = now();
46
+ for (const [sessionId, session] of sessions) {
47
+ if (session.expiresAt <= current) sessions.delete(sessionId);
48
+ }
49
+ while (sessions.size >= maxSessions) {
50
+ sessions.delete(sessions.keys().next().value);
51
+ }
52
+ }
53
+
54
+ function issue(userId) {
55
+ const normalizedUserId = String(userId || '').trim();
56
+ if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
57
+ purgeExpired();
58
+ const sessionId = randomToken();
59
+ const csrfToken = randomToken();
60
+ const expiresAt = now() + ttlMs;
61
+ sessions.set(sessionId, { userId: normalizedUserId, csrfToken, expiresAt });
62
+ return { sessionId, csrfToken, expiresAt };
63
+ }
64
+
65
+ function authorize(req, currentUserId, {
66
+ requireCsrf = !SAFE_HTTP_METHODS.has(String(req.method || 'GET').toUpperCase())
67
+ } = {}) {
68
+ purgeExpired();
69
+ const sessionId = parseCookies(req.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
70
+ if (!sessionId) {
71
+ return { ok: false, status: 401, error: 'hub-ui-session-required' };
72
+ }
73
+ const session = sessions.get(sessionId);
74
+ if (!session) {
75
+ return { ok: false, status: 401, error: 'hub-ui-session-expired' };
76
+ }
77
+ const normalizedUserId = String(currentUserId || '').trim();
78
+ if (!normalizedUserId || session.userId !== normalizedUserId) {
79
+ sessions.delete(sessionId);
80
+ return { ok: false, status: 403, error: 'hub-ui-session-account-mismatch' };
81
+ }
82
+ if (requireCsrf && !secureEqual(req.headers?.['x-livedesk-csrf'], session.csrfToken)) {
83
+ return { ok: false, status: 403, error: 'hub-ui-csrf-invalid' };
84
+ }
85
+ return { ok: true, sessionId, userId: session.userId, expiresAt: session.expiresAt };
86
+ }
87
+
88
+ return {
89
+ issue,
90
+ authorize,
91
+ revokeAll() {
92
+ sessions.clear();
93
+ },
94
+ sessionCount: () => sessions.size
95
+ };
96
+ }
97
+
98
+ export function serializeHubUiSessionCookie(sessionId, expiresAt, { secure = false } = {}) {
99
+ const maxAgeSeconds = Math.max(1, Math.floor((Number(expiresAt || 0) - Date.now()) / 1000));
100
+ return [
101
+ `${HUB_UI_SESSION_COOKIE}=${encodeURIComponent(String(sessionId || ''))}`,
102
+ 'Path=/',
103
+ 'HttpOnly',
104
+ 'SameSite=Strict',
105
+ `Max-Age=${maxAgeSeconds}`,
106
+ secure ? 'Secure' : ''
107
+ ].filter(Boolean).join('; ');
108
+ }
109
+
110
+ export function clearHubUiSessionCookie({ secure = false } = {}) {
111
+ return [
112
+ `${HUB_UI_SESSION_COOKIE}=`,
113
+ 'Path=/',
114
+ 'HttpOnly',
115
+ 'SameSite=Strict',
116
+ 'Max-Age=0',
117
+ secure ? 'Secure' : ''
118
+ ].filter(Boolean).join('; ');
119
+ }
package/src/remote-hub.js CHANGED
@@ -9705,10 +9705,39 @@ export function createRemoteHub(options = {}) {
9705
9705
  liveStreamReplacementStillPending(activeLiveStream)
9706
9706
  ? 'stream-restart-superseded'
9707
9707
  : 'stream-start-timeout');
9708
- }
9709
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
9710
- if (activeLiveStream
9711
- && options.forceRestart !== true
9708
+ }
9709
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
9710
+ const restartToken = safeString(options.restartToken, 128);
9711
+ if (activeLiveStream
9712
+ && options.forceRestart === true
9713
+ && restartToken
9714
+ && safeString(activeLiveStream.restartToken, 128) === restartToken
9715
+ && liveStreamMatchesOptions(activeLiveStream, normalized)
9716
+ && liveStreamIsReusable(activeLiveStream)) {
9717
+ emitRemoteEvent('RemoteLiveStreamRestartTokenReused', device, {
9718
+ streamId,
9719
+ commandId: activeLiveStream.commandId,
9720
+ restartToken,
9721
+ captureGeneration: Number(activeLiveStream.captureGeneration || 0)
9722
+ });
9723
+ return {
9724
+ ok: true,
9725
+ commandId: activeLiveStream.commandId,
9726
+ sessionId: device.sessionId,
9727
+ streamId,
9728
+ streamPurpose,
9729
+ fps: Number(activeLiveStream.fps || fps),
9730
+ mode: activeLiveStream.mode || transfer.mode,
9731
+ frameMode: activeLiveStream.frameMode || transfer.frameMode,
9732
+ monitorIndex: Number(activeLiveStream.monitorIndex ?? monitorIndex),
9733
+ captureGeneration: Number(activeLiveStream.captureGeneration || 0),
9734
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
9735
+ pending: activeLiveStream.open !== true,
9736
+ reused: true
9737
+ };
9738
+ }
9739
+ if (activeLiveStream
9740
+ && options.forceRestart !== true
9712
9741
  && options.reuseExisting === true
9713
9742
  && liveStreamMatchesOptions(activeLiveStream, normalized)
9714
9743
  && liveStreamIsReusable(activeLiveStream)) {
@@ -9838,7 +9867,7 @@ export function createRemoteHub(options = {}) {
9838
9867
  monitorIndex,
9839
9868
  monitorCount: 1,
9840
9869
  startedAt: now,
9841
- restartToken: safeString(options.restartToken, 128),
9870
+ restartToken,
9842
9871
  stoppedAt: '',
9843
9872
  stopReason: '',
9844
9873
  stopPending: false,
package/src/server.js CHANGED
@@ -51,8 +51,13 @@ import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-cor
51
51
  import { fetchAuthResponse, AUTH_REQUEST_TIMEOUT_MS } from '../../runtime-core/src/auth-http.js';
52
52
  import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '../../runtime-core/src/auth-config.js';
53
53
  import { createHubRuntime } from './runtime/hub-runtime.js';
54
- import { hubRuntimeStatus } from './runtime/hub-runtime-status.js';
55
- import { roleGuardResponse } from './http/hub-role-guard.js';
54
+ import { hubRuntimeStatus } from './runtime/hub-runtime-status.js';
55
+ import { roleGuardResponse } from './http/hub-role-guard.js';
56
+ import {
57
+ clearHubUiSessionCookie,
58
+ createHubUiSessionAuthority,
59
+ serializeHubUiSessionCookie
60
+ } from './http/hub-ui-session.js';
56
61
 
57
62
  const __dirname = dirname(fileURLToPath(import.meta.url));
58
63
  const webDistCandidates = [
@@ -182,9 +187,10 @@ const persistentSessionGcToken = persistentSessionGcEnabled
182
187
  ? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
183
188
  : '';
184
189
  let connectedDeviceCount = 0;
185
- let runtimeAccessToken = '';
186
- let runtimeRefreshToken = '';
187
- let runtimeAccessTokenExpiresAt = 0;
190
+ let runtimeAccessToken = '';
191
+ let runtimeRefreshToken = '';
192
+ let runtimeAccessTokenExpiresAt = 0;
193
+ const hubUiSessionAuthority = createHubUiSessionAuthority();
188
194
  let roleWatchInFlight = false;
189
195
  let verifiedLicense = {
190
196
  userId: '',
@@ -485,11 +491,12 @@ function clearPersistedRuntimeSession() {
485
491
  return writePrivateRuntimeAuthState(state);
486
492
  }
487
493
 
488
- function clearRuntimeSession() {
489
- runtimeAccessToken = '';
490
- runtimeRefreshToken = '';
491
- runtimeAccessTokenExpiresAt = 0;
492
- runtimeManager.setAuthenticated(false);
494
+ function clearRuntimeSession() {
495
+ runtimeAccessToken = '';
496
+ runtimeRefreshToken = '';
497
+ runtimeAccessTokenExpiresAt = 0;
498
+ hubUiSessionAuthority.revokeAll();
499
+ runtimeManager.setAuthenticated(false);
493
500
  try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
494
501
  }
495
502
 
@@ -1405,7 +1412,7 @@ function requestHostname(req) {
1405
1412
  }
1406
1413
  }
1407
1414
 
1408
- function isTrustedBrowserRequest(req) {
1415
+ function isTrustedBrowserRequest(req) {
1409
1416
  const origin = String(req.headers.origin || '').trim();
1410
1417
  if (!origin) {
1411
1418
  const fetchSite = String(req.headers['sec-fetch-site'] || '').toLowerCase();
@@ -1422,22 +1429,59 @@ function isTrustedBrowserRequest(req) {
1422
1429
  const targetHost = requestHostname(req);
1423
1430
  return (originHost === targetHost && isPrivateHubHostname(targetHost))
1424
1431
  || (isLoopbackHostname(originHost) && isLoopbackHostname(targetHost));
1425
- } catch {
1426
- return false;
1427
- }
1428
- }
1429
-
1430
- app.use((req, res, next) => {
1432
+ } catch {
1433
+ return false;
1434
+ }
1435
+ }
1436
+
1437
+ function isSecureHubHttpRequest(req) {
1438
+ return req.socket?.encrypted === true
1439
+ || String(req.headers['x-forwarded-proto'] || '').trim().toLowerCase() === 'https';
1440
+ }
1441
+
1442
+ function currentBoundHubUserId() {
1443
+ return String(
1444
+ runtimeManager.getSnapshot().userId
1445
+ || readPersistedRuntimeSession()?.user?.id
1446
+ || ''
1447
+ ).trim();
1448
+ }
1449
+
1450
+ function isPublicHubApiRequest(req) {
1451
+ const method = String(req.method || 'GET').toUpperCase();
1452
+ const path = String(req.path || '');
1453
+ return (method === 'GET' && (path === '/api/health' || path === '/api/runtime/status' || path === '/api/auth/status'))
1454
+ || (method === 'POST' && path === '/api/auth/session');
1455
+ }
1456
+
1457
+ function isTrustedNativeLoopbackRequest(req) {
1458
+ return isLoopbackAddress(req.socket?.remoteAddress)
1459
+ && !String(req.headers.origin || '').trim()
1460
+ && !String(req.headers['sec-fetch-site'] || '').trim();
1461
+ }
1462
+
1463
+ function authorizeHubUiRequest(req) {
1464
+ if (process.env.LIVEDESK_TEST_MODE === '1') {
1465
+ return { ok: true, explicitTestMode: true };
1466
+ }
1467
+ if (isTrustedNativeLoopbackRequest(req)) {
1468
+ return { ok: true, nativeLoopback: true };
1469
+ }
1470
+ return hubUiSessionAuthority.authorize(req, currentBoundHubUserId());
1471
+ }
1472
+
1473
+ app.use((req, res, next) => {
1431
1474
  if (!isTrustedBrowserRequest(req)) {
1432
1475
  res.status(403).json({ ok: false, error: 'untrusted-hub-origin' });
1433
1476
  return;
1434
1477
  }
1435
1478
  const origin = String(req.headers.origin || '').trim();
1436
1479
  if (origin) {
1437
- res.setHeader('Access-Control-Allow-Origin', origin);
1438
- res.setHeader('Access-Control-Allow-Private-Network', 'true');
1480
+ res.setHeader('Access-Control-Allow-Origin', origin);
1481
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
1482
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
1439
1483
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
1440
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
1484
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-CSRF');
1441
1485
  res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
1442
1486
  }
1443
1487
  if (req.method === 'OPTIONS') {
@@ -1446,8 +1490,21 @@ app.use((req, res, next) => {
1446
1490
  }
1447
1491
  next();
1448
1492
  });
1449
- app.use(express.json({ limit: '32mb' }));
1450
- app.use((req, res, next) => {
1493
+ app.use(express.json({ limit: '32mb' }));
1494
+ app.use((req, res, next) => {
1495
+ if (!req.path.startsWith('/api/') || isPublicHubApiRequest(req)) {
1496
+ next();
1497
+ return;
1498
+ }
1499
+ const authorization = authorizeHubUiRequest(req);
1500
+ if (!authorization.ok) {
1501
+ noStore(res);
1502
+ res.status(authorization.status).json({ ok: false, error: authorization.error });
1503
+ return;
1504
+ }
1505
+ next();
1506
+ });
1507
+ app.use((req, res, next) => {
1451
1508
  if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
1452
1509
  res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
1453
1510
  return;
@@ -4351,10 +4408,19 @@ app.post('/api/auth/session', async (req, res) => {
4351
4408
  res.status(400).json({ ok: false, error: normalized.error });
4352
4409
  return;
4353
4410
  }
4354
- const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4355
- try {
4356
- const user = await verifySupabaseUser(accessToken);
4357
- runtimeAccessToken = accessToken;
4411
+ const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4412
+ try {
4413
+ const user = await verifySupabaseUser(accessToken);
4414
+ const boundUserId = currentBoundHubUserId();
4415
+ if (boundUserId && boundUserId !== user.id) {
4416
+ res.status(403).json({ ok: false, error: 'hub-account-mismatch' });
4417
+ return;
4418
+ }
4419
+ if (!boundUserId && !isLoopbackAddress(req.socket?.remoteAddress)) {
4420
+ res.status(403).json({ ok: false, error: 'hub-first-account-binding-must-be-local' });
4421
+ return;
4422
+ }
4423
+ runtimeAccessToken = accessToken;
4358
4424
  runtimeRefreshToken = refreshToken;
4359
4425
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
4360
4426
  runtimeManager.setAuthenticated(true, user.id);
@@ -4364,13 +4430,32 @@ app.post('/api/auth/session', async (req, res) => {
4364
4430
  expires_at: expiresAt,
4365
4431
  user
4366
4432
  });
4367
- const hostTarget = runtimeRole === 'hub'
4368
- ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
4369
- : { ok: true, active: false };
4370
- if (runtimeRole === 'hub') {
4371
- startHubHostTargetLeaseRenewal();
4372
- }
4373
- res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4433
+ const hostTarget = runtimeRole === 'hub'
4434
+ ? {
4435
+ ok: true,
4436
+ pending: true,
4437
+ active: remoteHub.getStatus({ includeSecrets: false }).hostTargetActive === true
4438
+ }
4439
+ : { ok: true, pending: false, active: false };
4440
+ const uiSession = hubUiSessionAuthority.issue(user.id);
4441
+ res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
4442
+ secure: isSecureHubHttpRequest(req)
4443
+ }));
4444
+ res.json({
4445
+ ok: true,
4446
+ authenticated: true,
4447
+ persisted,
4448
+ userId: user.id,
4449
+ role: runtimeRole,
4450
+ hostTarget,
4451
+ uiSession: {
4452
+ csrfToken: uiSession.csrfToken,
4453
+ expiresAt: uiSession.expiresAt
4454
+ }
4455
+ });
4456
+ if (runtimeRole === 'hub') {
4457
+ scheduleAuthenticatedHubHostTargetPublication('session-received');
4458
+ }
4374
4459
  } catch (error) {
4375
4460
  const message = error instanceof Error ? error.message : String(error);
4376
4461
  const status = authVerificationHttpStatus(message);
@@ -4656,24 +4741,36 @@ async function clearHubHostTarget(reason = 'shutdown') {
4656
4741
  return { ok: !error, error, lease: getHubHostTargetLeaseStatus() };
4657
4742
  }
4658
4743
 
4659
- function startHubHostTargetLeaseRenewal() {
4744
+ function startHubHostTargetLeaseRenewal() {
4660
4745
  if (hubHostTargetRenewTimer || runtimeRole !== 'hub') {
4661
4746
  return;
4662
4747
  }
4663
4748
  hubHostTargetRenewTimer = setInterval(() => {
4664
4749
  void publishHubHostTargetWithPendingRoleTakeover('renewal');
4665
4750
  }, HUB_HOST_TARGET_RENEW_MS);
4666
- hubHostTargetRenewTimer.unref?.();
4667
- }
4751
+ hubHostTargetRenewTimer.unref?.();
4752
+ }
4753
+
4754
+ function scheduleAuthenticatedHubHostTargetPublication(reason = 'session-received') {
4755
+ startHubHostTargetLeaseRenewal();
4756
+ setImmediate(() => {
4757
+ void publishHubHostTargetWithPendingRoleTakeover(reason).catch(error => {
4758
+ const message = error instanceof Error ? error.message : String(error);
4759
+ updateHubHostTargetLeaseState({ state: 'error', lastError: message });
4760
+ console.error(`[LiveDesk Hub] Host target ${reason} background publish failed: ${message}`);
4761
+ });
4762
+ });
4763
+ }
4668
4764
 
4669
- app.delete('/api/auth/session', async (_req, res) => {
4765
+ app.delete('/api/auth/session', async (req, res) => {
4670
4766
  noStore(res);
4671
4767
  const hostTarget = runtimeRole === 'hub'
4672
4768
  ? await clearHubHostTarget('logout')
4673
4769
  : { ok: true, active: false };
4674
- clearRuntimeSession();
4675
- res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
4676
- });
4770
+ clearRuntimeSession();
4771
+ res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
4772
+ res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
4773
+ });
4677
4774
 
4678
4775
  app.get('/api/hub/status', (_req, res) => {
4679
4776
  noStore(res);
@@ -5474,13 +5571,20 @@ if (existsSync(webIndexPath)) {
5474
5571
  });
5475
5572
  }
5476
5573
 
5477
- httpServer.on('upgrade', (req, socket, head) => {
5478
- if (!isTrustedBrowserRequest(req)) {
5479
- socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
5480
- socket.destroy();
5481
- return;
5482
- }
5483
- try {
5574
+ httpServer.on('upgrade', (req, socket, head) => {
5575
+ if (!isTrustedBrowserRequest(req)) {
5576
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
5577
+ socket.destroy();
5578
+ return;
5579
+ }
5580
+ const authorization = authorizeHubUiRequest(req);
5581
+ if (!authorization.ok) {
5582
+ const label = authorization.status === 403 ? 'Forbidden' : 'Unauthorized';
5583
+ socket.write(`HTTP/1.1 ${authorization.status} ${label}\r\nConnection: close\r\n\r\n`);
5584
+ socket.destroy();
5585
+ return;
5586
+ }
5587
+ try {
5484
5588
  const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
5485
5589
  if (parsed.pathname === '/api/remote/frames/ws') {
5486
5590
  frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));