livedesk 0.1.396 → 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.
@@ -1,5 +1,18 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
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';
3
16
  import { normalizeRuntimeAuthSession } from '../runtime-core/src/auth-session.js';
4
17
 
5
18
  const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
@@ -19,33 +32,113 @@ function parseStoredSession(value) {
19
32
  }
20
33
  }
21
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
+
22
78
  /**
23
79
  * Contract:
24
80
  * - Reads only the unified local Client auth store.
25
- * - Returns only a refreshable access session that will remain valid long
26
- * enough for the new local Hub to verify it.
81
+ * - Returns only a refreshable session; transfer refreshes stale access tokens.
27
82
  * - Never logs or serializes token values outside the loopback handoff.
28
83
  */
29
- export async function readSavedClientSessionForHub(
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,
30
93
  stateDir,
31
- nowSeconds = Math.floor(Date.now() / 1000)
94
+ fetchImpl,
95
+ nowSeconds
32
96
  ) {
33
- try {
34
- const authState = JSON.parse(await readFile(join(stateDir, 'auth.json'), 'utf8'));
35
- const candidate = parseStoredSession(authState?.[CLIENT_AUTH_STORAGE_KEY]);
36
- const normalized = normalizeRuntimeAuthSession(candidate, { requireRefreshToken: true });
37
- if (!normalized.ok) {
38
- return null;
39
- }
40
- const expiresAt = Number(normalized.session.expires_at || 0);
41
- if (!Number.isFinite(expiresAt)
42
- || expiresAt - Number(nowSeconds || 0) <= MINIMUM_SESSION_LIFETIME_SECONDS) {
43
- return null;
44
- }
45
- return normalized.session;
46
- } catch {
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) {
47
135
  return null;
48
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 };
49
142
  }
50
143
 
51
144
  export async function transferSavedClientSessionToHub(
@@ -54,25 +147,23 @@ export async function transferSavedClientSessionToHub(
54
147
  fetchImpl = fetch,
55
148
  nowSeconds = Math.floor(Date.now() / 1000)
56
149
  ) {
57
- const session = await readSavedClientSessionForHub(stateDir, nowSeconds);
58
- if (!session) {
59
- return { ok: false, skipped: true, reason: 'no-valid-saved-client-session' };
60
- }
61
-
62
150
  try {
63
- const response = await fetchImpl(new URL('/api/auth/session', baseUrl), {
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), {
64
156
  method: 'POST',
65
157
  headers: {
66
158
  'Content-Type': 'application/json',
67
159
  Accept: 'application/json'
68
160
  },
69
161
  body: JSON.stringify({
70
- accessToken: session.access_token,
71
- refreshToken: session.refresh_token,
72
- expiresAt: session.expires_at
73
- }),
74
- signal: AbortSignal.timeout(5_000)
75
- });
162
+ accessToken: resolved.session.access_token,
163
+ refreshToken: resolved.session.refresh_token,
164
+ expiresAt: resolved.session.expires_at
165
+ })
166
+ }, 5_000);
76
167
  const data = await response.json().catch(() => null);
77
168
  if (!response.ok || data?.authenticated !== true) {
78
169
  return {
@@ -85,6 +176,7 @@ export async function transferSavedClientSessionToHub(
85
176
  ok: true,
86
177
  skipped: false,
87
178
  authenticated: true,
179
+ refreshed: resolved.refreshed,
88
180
  hostTargetOk: data?.hostTarget?.ok !== false
89
181
  };
90
182
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.396",
3
+ "version": "0.1.397",
4
4
  "livedeskClientVersion": "0.1.173",
5
5
  "buildFlavor": "production",
6
6
  "description": "LiveDesk Hub and client launcher",