livedesk 0.1.395 → 0.1.397

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/bin/livedesk.js CHANGED
@@ -15,6 +15,7 @@ import { createRoleBootstrapServer } from '../bootstrap/role-bootstrap-server.js
15
15
  import { parseLsofPids } from '../bootstrap/port-inspection.js';
16
16
  import { isKnownLiveDeskClientAgentProcess } from '../bootstrap/client-process-identity.js';
17
17
  import { runLegacyClientUpdateSupervisorCli } from '../bootstrap/legacy-client-update.js';
18
+ import { transferSavedClientSessionToHub } from '../bootstrap/hub-auth-handoff.js';
18
19
 
19
20
  const require = createRequire(import.meta.url);
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -1514,14 +1515,31 @@ async function runManager(args, resolvedRole = null) {
1514
1515
  });
1515
1516
  };
1516
1517
 
1517
- startHubProcess();
1518
- updatePollId = setInterval(pollUpdateRequest, HUB_UPDATE_POLL_MS);
1519
-
1520
- if (options.openBrowserOnStart) {
1521
- void waitForManager(openUrl).then(ok => {
1522
- if (ok) {
1523
- openBrowser(openUrl);
1524
- } else {
1518
+ startHubProcess();
1519
+ updatePollId = setInterval(pollUpdateRequest, HUB_UPDATE_POLL_MS);
1520
+
1521
+ const hubReady = waitForManager(hubProbeBaseUrl);
1522
+ const hubStartup = hubReady.then(async ok => {
1523
+ if (!ok) {
1524
+ return false;
1525
+ }
1526
+ const handoff = await transferSavedClientSessionToHub(hubProbeBaseUrl, MANAGER_STATE_DIR);
1527
+ if (handoff.ok) {
1528
+ reportLauncherUpdate('[LiveDesk Hub] Saved Client sign-in adopted by the Hub.');
1529
+ if (!handoff.hostTargetOk) {
1530
+ reportLauncherUpdate('[LiveDesk Hub] Sign-in was adopted; host publication will continue through lease renewal.', true);
1531
+ }
1532
+ } else if (!handoff.skipped) {
1533
+ reportLauncherUpdate(`[LiveDesk Hub] Saved Client sign-in could not be adopted: ${handoff.reason}. Continue sign-in in the Hub page.`, true);
1534
+ }
1535
+ return true;
1536
+ });
1537
+
1538
+ if (options.openBrowserOnStart) {
1539
+ void hubStartup.then(ok => {
1540
+ if (ok) {
1541
+ openBrowser(openUrl);
1542
+ } else {
1525
1543
  console.warn(`LiveDesk Hub did not answer at ${openUrl} yet. Open it manually when it is ready.`);
1526
1544
  }
1527
1545
  });
@@ -0,0 +1,189 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import {
3
+ chmodSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ writeFileSync
9
+ } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import {
12
+ PRODUCTION_SUPABASE_PUBLISHABLE_KEY,
13
+ PRODUCTION_SUPABASE_URL
14
+ } from '../runtime-core/src/auth-config.js';
15
+ import { fetchAuthResponse } from '../runtime-core/src/auth-http.js';
16
+ import { normalizeRuntimeAuthSession } from '../runtime-core/src/auth-session.js';
17
+
18
+ const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
19
+ const MINIMUM_SESSION_LIFETIME_SECONDS = 30;
20
+
21
+ function parseStoredSession(value) {
22
+ if (value && typeof value === 'object') {
23
+ return value;
24
+ }
25
+ if (typeof value !== 'string' || !value.trim()) {
26
+ return null;
27
+ }
28
+ try {
29
+ return JSON.parse(value);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function readAuthState(authPath) {
36
+ try {
37
+ const value = JSON.parse(readFileSync(authPath, 'utf8'));
38
+ return value && typeof value === 'object' ? value : {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ function writePrivateJsonAtomic(path, value) {
45
+ mkdirSync(dirname(path), { recursive: true });
46
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
47
+ try {
48
+ writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
49
+ try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
50
+ renameSync(temporaryPath, path);
51
+ try { chmodSync(path, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
52
+ } catch (error) {
53
+ try { rmSync(temporaryPath, { force: true }); } catch { /* best effort cleanup */ }
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ function sessionIsFresh(session, nowSeconds) {
59
+ const expiresAt = Number(session?.expires_at || 0);
60
+ return Number.isFinite(expiresAt)
61
+ && expiresAt - Number(nowSeconds || 0) > MINIMUM_SESSION_LIFETIME_SECONDS;
62
+ }
63
+
64
+ function resolveSupabaseAuthConfig(env = process.env) {
65
+ const supabaseUrl = String(
66
+ env.LIVEDESK_RUNTIME_SUPABASE_URL
67
+ || env.LIVEDESK_SUPABASE_URL
68
+ || PRODUCTION_SUPABASE_URL
69
+ ).trim().replace(/\/+$/, '');
70
+ const publishableKey = String(
71
+ env.LIVEDESK_RUNTIME_SUPABASE_PUBLISHABLE_KEY
72
+ || env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY
73
+ || PRODUCTION_SUPABASE_PUBLISHABLE_KEY
74
+ ).trim();
75
+ return { supabaseUrl, publishableKey };
76
+ }
77
+
78
+ /**
79
+ * Contract:
80
+ * - Reads only the unified local Client auth store.
81
+ * - Returns only a refreshable session; transfer refreshes stale access tokens.
82
+ * - Never logs or serializes token values outside the loopback handoff.
83
+ */
84
+ export async function readSavedClientSessionForHub(stateDir) {
85
+ const authState = readAuthState(join(stateDir, 'auth.json'));
86
+ const candidate = parseStoredSession(authState?.[CLIENT_AUTH_STORAGE_KEY]);
87
+ const normalized = normalizeRuntimeAuthSession(candidate, { requireRefreshToken: true });
88
+ return normalized.ok ? normalized.session : null;
89
+ }
90
+
91
+ async function refreshSavedClientSessionForHub(
92
+ session,
93
+ stateDir,
94
+ fetchImpl,
95
+ nowSeconds
96
+ ) {
97
+ const { supabaseUrl, publishableKey } = resolveSupabaseAuthConfig();
98
+ const response = await fetchAuthResponse(fetchImpl, `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
99
+ method: 'POST',
100
+ headers: {
101
+ apikey: publishableKey,
102
+ 'Content-Type': 'application/json',
103
+ Accept: 'application/json'
104
+ },
105
+ body: JSON.stringify({ refresh_token: session.refresh_token })
106
+ }, 5_000);
107
+ const data = await response.json().catch(() => null);
108
+ if (!response.ok) {
109
+ throw new Error(`saved-client-session-refresh-failed:${response.status}`);
110
+ }
111
+ const expiresAt = Number(data?.expires_at || 0)
112
+ || (Number(data?.expires_in || 0) > 0
113
+ ? Number(nowSeconds || 0) + Number(data.expires_in)
114
+ : 0);
115
+ const normalized = normalizeRuntimeAuthSession({
116
+ ...data,
117
+ expires_at: expiresAt,
118
+ refresh_token: data?.refresh_token || session.refresh_token,
119
+ user: data?.user || session.user
120
+ }, { requireRefreshToken: true });
121
+ if (!normalized.ok || !sessionIsFresh(normalized.session, nowSeconds)) {
122
+ throw new Error('saved-client-session-refresh-invalid');
123
+ }
124
+
125
+ const authPath = join(stateDir, 'auth.json');
126
+ const authState = readAuthState(authPath);
127
+ authState[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
128
+ writePrivateJsonAtomic(authPath, authState);
129
+ return normalized.session;
130
+ }
131
+
132
+ async function resolveTransferSession(stateDir, fetchImpl, nowSeconds) {
133
+ const saved = await readSavedClientSessionForHub(stateDir);
134
+ if (!saved) {
135
+ return null;
136
+ }
137
+ if (sessionIsFresh(saved, nowSeconds)) {
138
+ return { session: saved, refreshed: false };
139
+ }
140
+ const refreshed = await refreshSavedClientSessionForHub(saved, stateDir, fetchImpl, nowSeconds);
141
+ return { session: refreshed, refreshed: true };
142
+ }
143
+
144
+ export async function transferSavedClientSessionToHub(
145
+ baseUrl,
146
+ stateDir,
147
+ fetchImpl = fetch,
148
+ nowSeconds = Math.floor(Date.now() / 1000)
149
+ ) {
150
+ try {
151
+ const resolved = await resolveTransferSession(stateDir, fetchImpl, nowSeconds);
152
+ if (!resolved) {
153
+ return { ok: false, skipped: true, reason: 'no-refreshable-saved-client-session' };
154
+ }
155
+ const response = await fetchAuthResponse(fetchImpl, new URL('/api/auth/session', baseUrl), {
156
+ method: 'POST',
157
+ headers: {
158
+ 'Content-Type': 'application/json',
159
+ Accept: 'application/json'
160
+ },
161
+ body: JSON.stringify({
162
+ accessToken: resolved.session.access_token,
163
+ refreshToken: resolved.session.refresh_token,
164
+ expiresAt: resolved.session.expires_at
165
+ })
166
+ }, 5_000);
167
+ const data = await response.json().catch(() => null);
168
+ if (!response.ok || data?.authenticated !== true) {
169
+ return {
170
+ ok: false,
171
+ skipped: false,
172
+ reason: String(data?.error || `hub-auth-session-http-${response.status}`)
173
+ };
174
+ }
175
+ return {
176
+ ok: true,
177
+ skipped: false,
178
+ authenticated: true,
179
+ refreshed: resolved.refreshed,
180
+ hostTargetOk: data?.hostTarget?.ok !== false
181
+ };
182
+ } catch (error) {
183
+ return {
184
+ ok: false,
185
+ skipped: false,
186
+ reason: error instanceof Error ? error.message : String(error)
187
+ };
188
+ }
189
+ }
@@ -21,6 +21,7 @@ const packageRoot = resolve(__dirname, '..');
21
21
  const nodeAgentPath = join(__dirname, 'livedesk-client-node.js');
22
22
  const FAST_PREFLIGHT_TIMEOUT_MS = 5000;
23
23
  const DEFAULT_MANAGER = '127.0.0.1:5197';
24
+ const DEFAULT_HUB_CLIENT_PORT = 5197;
24
25
  const DEFAULT_AUTH_CALLBACK_HOST = '127.0.0.1';
25
26
  const DEFAULT_AUTH_CALLBACK_PORT = 5179;
26
27
  const ENDPOINT_PROBE_TIMEOUT_MS = 900;
@@ -369,15 +370,64 @@ function parseLauncherArgs(argv) {
369
370
  };
370
371
  }
371
372
 
372
- function normalizePort(value) {
373
+ function normalizePort(value) {
373
374
  const port = Number(String(value || '').trim());
374
375
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
375
376
  return 0;
376
377
  }
377
378
  return port;
378
- }
379
-
380
- function normalizeSlotNumber(value) {
379
+ }
380
+
381
+ export function resolveHubClientPort(env = process.env) {
382
+ return normalizePort(env.REMOTE_HUB_PORT || env.LIVEDESK_HUB_REMOTE_PORT) || DEFAULT_HUB_CLIENT_PORT;
383
+ }
384
+
385
+ export function preflightHubClientPort(port = resolveHubClientPort()) {
386
+ const normalizedPort = normalizePort(port);
387
+ if (!normalizedPort) {
388
+ return Promise.resolve({ ok: false, port: Number(port) || 0, code: 'invalid-hub-client-port' });
389
+ }
390
+ return new Promise(resolvePreflight => {
391
+ const probe = net.createServer();
392
+ let completed = false;
393
+ const finish = result => {
394
+ if (completed) return;
395
+ completed = true;
396
+ resolvePreflight({ port: normalizedPort, ...result });
397
+ };
398
+ probe.unref?.();
399
+ probe.once('error', error => {
400
+ finish({
401
+ ok: false,
402
+ code: error?.code === 'EADDRINUSE' ? 'hub-client-port-in-use' : 'hub-client-port-unavailable',
403
+ systemCode: String(error?.code || '')
404
+ });
405
+ });
406
+ probe.listen({ host: '0.0.0.0', port: normalizedPort, exclusive: true }, () => {
407
+ probe.close(error => {
408
+ if (error) {
409
+ finish({ ok: false, code: 'hub-client-port-unavailable', systemCode: String(error?.code || '') });
410
+ return;
411
+ }
412
+ finish({ ok: true, code: 'ok' });
413
+ });
414
+ });
415
+ });
416
+ }
417
+
418
+ function hubClientPortPreflightError(preflight) {
419
+ const port = Number(preflight?.port || resolveHubClientPort());
420
+ return {
421
+ ok: false,
422
+ code: String(preflight?.code || 'hub-client-port-unavailable'),
423
+ port,
424
+ error: preflight?.code === 'hub-client-port-in-use'
425
+ ? `LiveDesk cannot switch this computer to Hub because TCP ${port} is already in use. Change or stop the owning service, then try Switch to Hub again. The current Hub was not changed.`
426
+ : `LiveDesk cannot switch this computer to Hub because TCP ${port} is unavailable. Check the local port configuration, then try again. The current Hub was not changed.`
427
+ };
428
+ }
429
+
430
+ function normalizeSlotNumber(value) {
381
431
  const number = Number(String(value || '').trim());
382
432
  if (!Number.isInteger(number) || number < 1 || number > 999) {
383
433
  return '';
@@ -2648,12 +2698,18 @@ async function startConnectionChoiceServer(supabase, options = {}) {
2648
2698
  payload = Object.fromEntries(params.entries());
2649
2699
  }
2650
2700
  const nextRole = String(payload.role || '').trim().toLowerCase();
2651
- if (nextRole !== 'hub') {
2701
+ if (nextRole !== 'hub') {
2652
2702
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
2653
2703
  res.end(JSON.stringify({ ok: false, error: 'client-can-only-transition-to-hub' }));
2654
- return;
2655
- }
2656
- const session = await refreshSessionIfNeeded(supabase);
2704
+ return;
2705
+ }
2706
+ const portPreflight = await preflightHubClientPort();
2707
+ if (!portPreflight.ok) {
2708
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
2709
+ res.end(JSON.stringify(hubClientPortPreflightError(portPreflight)));
2710
+ return;
2711
+ }
2712
+ const session = await refreshSessionIfNeeded(supabase);
2657
2713
  const expectedRoleVersion = Number(dashboardState.roleVersion || 0);
2658
2714
  const { data, error } = await supabase.rpc('set_livedesk_device_role', {
2659
2715
  p_device_id: deviceId,
@@ -2948,6 +3004,10 @@ async function chooseClientConnection(supabase, options = {}) {
2948
3004
  if (!supabase) {
2949
3005
  return { ok: false, error: 'supabase-session-required' };
2950
3006
  }
3007
+ const portPreflight = await preflightHubClientPort();
3008
+ if (!portPreflight.ok) {
3009
+ return hubClientPortPreflightError(portPreflight);
3010
+ }
2951
3011
  const session = await refreshSessionIfNeeded(supabase);
2952
3012
  if (!session?.access_token) {
2953
3013
  return { ok: false, error: 'supabase-session-required' };
@@ -739,6 +739,7 @@ export function createClientRuntimeServer(options = {}) {
739
739
  osVersion: os.release(),
740
740
  pid: process.pid,
741
741
  cpuUsagePercent: Math.round(cpuUsagePercent * 10) / 10,
742
+ cpuLogicalCores: Math.max(1, os.cpus().length),
742
743
  memoryTotalBytes: totalMemoryBytes,
743
744
  memoryUsedBytes: Math.max(0, totalMemoryBytes - freeMemoryBytes),
744
745
  uptimeSeconds: Math.max(0, Math.floor(os.uptime())),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.395",
3
+ "version": "0.1.397",
4
4
  "livedeskClientVersion": "0.1.173",
5
5
  "buildFlavor": "production",
6
6
  "description": "LiveDesk Hub and client launcher",