pal-explorer-cli 0.4.0

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.
Files changed (156) hide show
  1. package/LICENSE.md +18 -0
  2. package/README.md +314 -0
  3. package/bin/pal.js +230 -0
  4. package/extensions/@palexplorer/analytics/README.md +45 -0
  5. package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
  6. package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
  7. package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
  8. package/extensions/@palexplorer/analytics/extension.json +27 -0
  9. package/extensions/@palexplorer/analytics/index.js +186 -0
  10. package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
  11. package/extensions/@palexplorer/audit/extension.json +17 -0
  12. package/extensions/@palexplorer/audit/index.js +2 -0
  13. package/extensions/@palexplorer/auth-email/extension.json +17 -0
  14. package/extensions/@palexplorer/auth-email/index.js +102 -0
  15. package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
  16. package/extensions/@palexplorer/auth-oauth/index.js +199 -0
  17. package/extensions/@palexplorer/chat/extension.json +17 -0
  18. package/extensions/@palexplorer/chat/index.js +2 -0
  19. package/extensions/@palexplorer/discovery/extension.json +16 -0
  20. package/extensions/@palexplorer/discovery/index.js +111 -0
  21. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  22. package/extensions/@palexplorer/email-notifications/index.js +242 -0
  23. package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
  24. package/extensions/@palexplorer/explorer-integration/index.js +122 -0
  25. package/extensions/@palexplorer/groups/extension.json +17 -0
  26. package/extensions/@palexplorer/groups/index.js +2 -0
  27. package/extensions/@palexplorer/networks/extension.json +17 -0
  28. package/extensions/@palexplorer/networks/index.js +2 -0
  29. package/extensions/@palexplorer/share-links/extension.json +17 -0
  30. package/extensions/@palexplorer/share-links/index.js +2 -0
  31. package/extensions/@palexplorer/sync/extension.json +17 -0
  32. package/extensions/@palexplorer/sync/index.js +2 -0
  33. package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
  34. package/extensions/@palexplorer/user-mgmt/index.js +2 -0
  35. package/extensions/@palexplorer/vfs/extension.json +17 -0
  36. package/extensions/@palexplorer/vfs/index.js +167 -0
  37. package/lib/capabilities.js +263 -0
  38. package/lib/commands/analytics.js +175 -0
  39. package/lib/commands/api-keys.js +131 -0
  40. package/lib/commands/audit.js +235 -0
  41. package/lib/commands/auth.js +137 -0
  42. package/lib/commands/backup.js +76 -0
  43. package/lib/commands/billing.js +148 -0
  44. package/lib/commands/chat.js +217 -0
  45. package/lib/commands/cloud-backup.js +231 -0
  46. package/lib/commands/comment.js +99 -0
  47. package/lib/commands/completion.js +203 -0
  48. package/lib/commands/compliance.js +218 -0
  49. package/lib/commands/config.js +136 -0
  50. package/lib/commands/connect.js +44 -0
  51. package/lib/commands/dept.js +294 -0
  52. package/lib/commands/device.js +146 -0
  53. package/lib/commands/download.js +226 -0
  54. package/lib/commands/explorer.js +178 -0
  55. package/lib/commands/extension.js +970 -0
  56. package/lib/commands/favorite.js +90 -0
  57. package/lib/commands/federation.js +270 -0
  58. package/lib/commands/file.js +533 -0
  59. package/lib/commands/group.js +271 -0
  60. package/lib/commands/gui-share.js +29 -0
  61. package/lib/commands/init.js +61 -0
  62. package/lib/commands/invite.js +59 -0
  63. package/lib/commands/list.js +59 -0
  64. package/lib/commands/log.js +116 -0
  65. package/lib/commands/nearby.js +108 -0
  66. package/lib/commands/network.js +251 -0
  67. package/lib/commands/notify.js +198 -0
  68. package/lib/commands/org.js +273 -0
  69. package/lib/commands/pal.js +180 -0
  70. package/lib/commands/permissions.js +216 -0
  71. package/lib/commands/pin.js +97 -0
  72. package/lib/commands/protocol.js +357 -0
  73. package/lib/commands/rbac.js +147 -0
  74. package/lib/commands/recover.js +36 -0
  75. package/lib/commands/register.js +171 -0
  76. package/lib/commands/relay.js +131 -0
  77. package/lib/commands/remote.js +368 -0
  78. package/lib/commands/revoke.js +50 -0
  79. package/lib/commands/scanner.js +280 -0
  80. package/lib/commands/schedule.js +344 -0
  81. package/lib/commands/scim.js +203 -0
  82. package/lib/commands/search.js +181 -0
  83. package/lib/commands/serve.js +438 -0
  84. package/lib/commands/server.js +350 -0
  85. package/lib/commands/share-link.js +199 -0
  86. package/lib/commands/share.js +323 -0
  87. package/lib/commands/sso.js +200 -0
  88. package/lib/commands/status.js +136 -0
  89. package/lib/commands/stream.js +562 -0
  90. package/lib/commands/su.js +187 -0
  91. package/lib/commands/sync.js +827 -0
  92. package/lib/commands/transfers.js +152 -0
  93. package/lib/commands/uninstall.js +188 -0
  94. package/lib/commands/update.js +204 -0
  95. package/lib/commands/user.js +276 -0
  96. package/lib/commands/vfs.js +84 -0
  97. package/lib/commands/web.js +52 -0
  98. package/lib/commands/webhook.js +180 -0
  99. package/lib/commands/whoami.js +59 -0
  100. package/lib/commands/workspace.js +121 -0
  101. package/lib/core/accessLog.js +54 -0
  102. package/lib/core/analytics.js +99 -0
  103. package/lib/core/backup.js +84 -0
  104. package/lib/core/billing.js +336 -0
  105. package/lib/core/bitfieldStore.js +53 -0
  106. package/lib/core/connectionManager.js +182 -0
  107. package/lib/core/dhtDiscovery.js +148 -0
  108. package/lib/core/discoveryClient.js +408 -0
  109. package/lib/core/extensionAnalyzer.js +357 -0
  110. package/lib/core/extensionSandbox.js +250 -0
  111. package/lib/core/extensionWorkerHost.js +166 -0
  112. package/lib/core/extensions.js +1082 -0
  113. package/lib/core/fileDiff.js +69 -0
  114. package/lib/core/groups.js +119 -0
  115. package/lib/core/identity.js +340 -0
  116. package/lib/core/mdnsService.js +126 -0
  117. package/lib/core/networks.js +81 -0
  118. package/lib/core/permissions.js +109 -0
  119. package/lib/core/pro.js +27 -0
  120. package/lib/core/resolver.js +74 -0
  121. package/lib/core/serverList.js +224 -0
  122. package/lib/core/sharePolicy.js +69 -0
  123. package/lib/core/shares.js +325 -0
  124. package/lib/core/signalingServer.js +441 -0
  125. package/lib/core/streamTransport.js +106 -0
  126. package/lib/core/su.js +55 -0
  127. package/lib/core/syncEngine.js +264 -0
  128. package/lib/core/syncState.js +159 -0
  129. package/lib/core/transfers.js +259 -0
  130. package/lib/core/users.js +225 -0
  131. package/lib/core/vfs.js +216 -0
  132. package/lib/core/webServer.js +702 -0
  133. package/lib/core/webrtcStream.js +396 -0
  134. package/lib/crypto/chatEncryption.js +57 -0
  135. package/lib/crypto/shareEncryption.js +195 -0
  136. package/lib/crypto/sharePassword.js +35 -0
  137. package/lib/crypto/streamEncryption.js +189 -0
  138. package/lib/package.json +1 -0
  139. package/lib/protocol/envelope.js +271 -0
  140. package/lib/protocol/handler.js +191 -0
  141. package/lib/protocol/index.js +27 -0
  142. package/lib/protocol/messages.js +247 -0
  143. package/lib/protocol/negotiation.js +127 -0
  144. package/lib/protocol/policy.js +142 -0
  145. package/lib/protocol/router.js +86 -0
  146. package/lib/protocol/sync.js +122 -0
  147. package/lib/utils/cli.js +15 -0
  148. package/lib/utils/config.js +123 -0
  149. package/lib/utils/configIntegrity.js +87 -0
  150. package/lib/utils/downloadDir.js +9 -0
  151. package/lib/utils/explorer.js +83 -0
  152. package/lib/utils/format.js +12 -0
  153. package/lib/utils/help.js +357 -0
  154. package/lib/utils/logger.js +103 -0
  155. package/lib/utils/torrent.js +203 -0
  156. package/package.json +71 -0
@@ -0,0 +1,408 @@
1
+ import crypto from 'crypto';
2
+ import config from '../utils/config.js';
3
+ import { getBootstrapServers, getServersForRole, addServerWithRoles, SERVER_ROLES, getHealthSortedServers, getWriteServers } from './serverList.js';
4
+
5
+ export function isPrivateUrl(urlStr) {
6
+ try {
7
+ const parsed = new URL(urlStr);
8
+ const host = parsed.hostname;
9
+ if (host === 'localhost' || host === '0.0.0.0' || host === '[::]') return true;
10
+ if (host.startsWith('127.') || host.startsWith('10.') || host.startsWith('192.168.') || host.startsWith('169.254.')) return true;
11
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
12
+ if (host.startsWith('[') && (host.includes('::1') || host.startsWith('[fc') || host.startsWith('[fd') || host.startsWith('[fe80'))) return true;
13
+ return false;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ export function parseHandle(handle) {
20
+ const at = handle.lastIndexOf('@');
21
+ if (at <= 0) return { name: handle, server: null };
22
+ const name = handle.slice(0, at);
23
+ let server = handle.slice(at + 1);
24
+ if (!/^https?:\/\//.test(server)) server = `https://${server}`;
25
+ server = server.replace(/\/+$/, '');
26
+ return { name, server };
27
+ }
28
+
29
+ export function getServers(role) {
30
+ if (role) {
31
+ const roleServers = getServersForRole(role);
32
+ if (roleServers.length > 0) return roleServers;
33
+ // fallback: return all servers (backward compat with servers that have no roles)
34
+ }
35
+ const settings = config.get('settings') || {};
36
+ const servers = settings.discovery_servers;
37
+ if (Array.isArray(servers) && servers.length > 0) return [...servers];
38
+ if (typeof servers === 'string' && servers.trim()) return servers.split(',').map(s => s.trim()).filter(Boolean);
39
+ const single = settings.discovery_url || process.env.PAL_DISCOVERY_URL;
40
+ if (single) return [single];
41
+ return getBootstrapServers();
42
+ }
43
+
44
+ export function getPrimaryServer() {
45
+ return getServers()[0];
46
+ }
47
+
48
+ export function getHealthyPrimary() {
49
+ const sorted = getHealthSortedServers();
50
+ if (sorted && sorted.length > 0) return sorted[0];
51
+ return getPrimaryServer();
52
+ }
53
+
54
+ export function addServerRaw(url) {
55
+ const settings = config.get('settings') || {};
56
+ const servers = Array.isArray(settings.discovery_servers) ? [...settings.discovery_servers] : [];
57
+ const normalized = url.replace(/\/+$/, '');
58
+ if (servers.includes(normalized)) return servers;
59
+ servers.push(normalized);
60
+ settings.discovery_servers = servers;
61
+ config.set('settings', settings);
62
+ return servers;
63
+ }
64
+
65
+ export async function addServer(url, { skipVerify = false } = {}) {
66
+ const normalized = url.replace(/\/+$/, '');
67
+ if (skipVerify) return addServerRaw(normalized);
68
+
69
+ const result = await fetchServerKey(normalized);
70
+ if (!result) {
71
+ throw new Error(`Server at ${normalized} did not provide a public key at /server-key`);
72
+ }
73
+
74
+ const health = await checkServer(normalized);
75
+ if (!health.reachable) {
76
+ throw new Error(`Server at ${normalized} is not reachable (health check failed)`);
77
+ }
78
+
79
+ const pinned = config.get('pinnedServerKeys') || {};
80
+ pinned[normalized] = result.publicKey;
81
+ config.set('pinnedServerKeys', pinned);
82
+
83
+ return { servers: addServerRaw(normalized), fingerprint: result.fingerprint, publicKey: result.publicKey };
84
+ }
85
+
86
+ export function keyFingerprint(hexKey) {
87
+ const hash = crypto.createHash('sha256').update(Buffer.from(hexKey, 'hex')).digest('hex');
88
+ return hash.slice(0, 8).toUpperCase().match(/.{2}/g).join(':');
89
+ }
90
+
91
+ export async function fetchServerKey(serverUrl) {
92
+ try {
93
+ const res = await fetch(`${serverUrl}/server-key`, { signal: AbortSignal.timeout(5000) });
94
+ if (!res.ok) return null;
95
+ const { publicKey } = await res.json();
96
+ if (!publicKey) return null;
97
+ return { publicKey, fingerprint: keyFingerprint(publicKey) };
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export async function verifyServer(serverUrl) {
104
+ const pinned = config.get('pinnedServerKeys') || {};
105
+ const pinnedKey = pinned[serverUrl];
106
+ if (!pinnedKey) {
107
+ return { url: serverUrl, status: 'no-pinned-key', message: 'No pinned key found. Re-add this server to pin its key.' };
108
+ }
109
+
110
+ const result = await fetchServerKey(serverUrl);
111
+ if (!result) {
112
+ return { url: serverUrl, status: 'unreachable', message: 'Could not fetch server key.' };
113
+ }
114
+
115
+ if (result.publicKey === pinnedKey) {
116
+ return { url: serverUrl, status: 'ok', fingerprint: result.fingerprint };
117
+ }
118
+
119
+ return {
120
+ url: serverUrl,
121
+ status: 'key-changed',
122
+ pinnedFingerprint: keyFingerprint(pinnedKey),
123
+ currentFingerprint: result.fingerprint,
124
+ message: 'SERVER KEY HAS CHANGED. This could indicate a MITM attack or server migration.',
125
+ };
126
+ }
127
+
128
+ export async function verifyAllServers() {
129
+ const servers = getServers();
130
+ return Promise.all(servers.map(s => verifyServer(s)));
131
+ }
132
+
133
+ export function acceptServerKey(serverUrl, newKey) {
134
+ const pinned = config.get('pinnedServerKeys') || {};
135
+ pinned[serverUrl] = newKey;
136
+ config.set('pinnedServerKeys', pinned);
137
+ }
138
+
139
+ export function removeServer(url) {
140
+ const settings = config.get('settings') || {};
141
+ const servers = Array.isArray(settings.discovery_servers) ? [...settings.discovery_servers] : [];
142
+ const normalized = url.replace(/\/+$/, '');
143
+ const filtered = servers.filter(s => s !== normalized);
144
+ settings.discovery_servers = filtered;
145
+ config.set('settings', settings);
146
+ return filtered;
147
+ }
148
+
149
+ export function setPrimaryServer(url) {
150
+ const settings = config.get('settings') || {};
151
+ const servers = Array.isArray(settings.discovery_servers) ? [...settings.discovery_servers] : [];
152
+ const normalized = url.replace(/\/+$/, '');
153
+ const filtered = servers.filter(s => s !== normalized);
154
+ filtered.unshift(normalized);
155
+ settings.discovery_servers = filtered;
156
+ config.set('settings', settings);
157
+ return filtered;
158
+ }
159
+
160
+ export async function checkServer(url) {
161
+ try {
162
+ if (isPrivateUrl(url)) return { url, reachable: false, status: null, error: 'private IP blocked' };
163
+ const res = await fetch(`${url}/status`, { signal: AbortSignal.timeout(3000) });
164
+ return { url, reachable: res.ok, status: res.status };
165
+ } catch {
166
+ return { url, reachable: false, status: null };
167
+ }
168
+ }
169
+
170
+ export async function fetchFirst(path, opts = {}, { role, allowPrivate } = {}) {
171
+ const servers = getServers(role);
172
+ for (const server of servers) {
173
+ const url = `${server}${path}`;
174
+ if (!allowPrivate && isPrivateUrl(url)) continue;
175
+ try {
176
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000), ...opts });
177
+ if (res.ok) return res;
178
+ } catch {
179
+ // try next
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+
185
+ export async function fetchAll(path, opts = {}, { role, allowPrivate } = {}) {
186
+ const servers = getServers(role);
187
+ const filtered = allowPrivate ? servers : servers.filter(s => !isPrivateUrl(`${s}${path}`));
188
+ const results = await Promise.allSettled(
189
+ filtered.map(server =>
190
+ fetch(`${server}${path}`, { signal: AbortSignal.timeout(5000), ...opts })
191
+ .then(async res => ({ server, res, ok: res.ok }))
192
+ )
193
+ );
194
+ return results
195
+ .filter(r => r.status === 'fulfilled')
196
+ .map(r => r.value);
197
+ }
198
+
199
+ export async function postTo(serverUrl, path, body) {
200
+ const fullUrl = `${serverUrl}${path}`;
201
+ if (isPrivateUrl(fullUrl)) throw new Error('SSRF blocked: private/internal URL');
202
+ const res = await fetch(fullUrl, {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: JSON.stringify(body),
206
+ });
207
+ return res;
208
+ }
209
+
210
+ export async function resolveFromServer(serverUrl, handle) {
211
+ const res = await fetch(`${serverUrl}/resolve/${encodeURIComponent(handle)}`, {
212
+ signal: AbortSignal.timeout(5000),
213
+ });
214
+ if (!res.ok) return null;
215
+ const data = await res.json();
216
+ if (!data) return null;
217
+
218
+ const serverKey = await pinServerKey(serverUrl);
219
+ const sig = res.headers.get('x-server-signature');
220
+ if (serverKey && sig) {
221
+ const sodium = (await import('sodium-native')).default;
222
+ const pk = Buffer.from(serverKey, 'hex');
223
+ const signature = Buffer.from(sig, 'hex');
224
+ if (pk.length === sodium.crypto_sign_PUBLICKEYBYTES && signature.length === sodium.crypto_sign_BYTES) {
225
+ const valid = sodium.crypto_sign_verify_detached(signature, Buffer.from(JSON.stringify(data)), pk);
226
+ if (!valid) return null;
227
+ }
228
+ }
229
+
230
+ return data;
231
+ }
232
+
233
+ let turnCredCache = null;
234
+
235
+ export async function fetchTurnCredentials() {
236
+ if (turnCredCache && turnCredCache.expiresAt > Date.now()) return turnCredCache;
237
+ const identity = config.get('identity');
238
+ const turnServers = getServers('turn');
239
+ const pk = identity?.publicKey || '';
240
+ for (const server of turnServers) {
241
+ try {
242
+ const url = `${server}/api/v1/turn-credentials${pk ? `?publicKey=${pk}` : ''}`;
243
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
244
+ if (!res.ok) continue;
245
+ const data = await res.json();
246
+ turnCredCache = { ...data, expiresAt: Date.now() + (data.ttl * 800) }; // refresh at 80% TTL
247
+ return turnCredCache;
248
+ } catch {
249
+ // try next
250
+ }
251
+ }
252
+ return null;
253
+ }
254
+
255
+ export async function announceShares(shares, { serveUrl } = {}) {
256
+ const identity = config.get('identity');
257
+ if (!identity?.handle) return null;
258
+ const device = config.get('device');
259
+ const writeServers = getWriteServers();
260
+ const servers = writeServers.length > 0 ? writeServers : getServers('discovery');
261
+ const body = JSON.stringify({
262
+ handle: identity.handle,
263
+ deviceId: device?.id || null,
264
+ serveUrl: serveUrl || null,
265
+ shares: shares.map(s => ({
266
+ id: s.id,
267
+ name: s.name,
268
+ magnet: s.magnet,
269
+ visibility: s.visibility || 'global',
270
+ createdAt: s.createdAt,
271
+ size: s.size || undefined,
272
+ type: s.type || undefined,
273
+ extension: s.extension || undefined,
274
+ modifiedAt: s.modifiedAt || undefined,
275
+ files: Array.isArray(s.files) ? s.files.slice(0, 5000) : undefined,
276
+ })),
277
+ });
278
+ const results = await Promise.allSettled(
279
+ servers.map(server =>
280
+ fetch(`${server}/api/v1/shares/announce`, {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body,
284
+ signal: AbortSignal.timeout(10000),
285
+ }).then(res => res.ok ? res.json() : null)
286
+ )
287
+ );
288
+ const first = results.find(r => r.status === 'fulfilled' && r.value);
289
+ return first?.value || null;
290
+ }
291
+
292
+ export async function announceGroups(groups) {
293
+ const identity = config.get('identity');
294
+ if (!identity?.publicKey) return null;
295
+ const publicGroups = groups.filter(g => g.visibility === 'public');
296
+ if (publicGroups.length === 0) return { success: true, count: 0 };
297
+
298
+ const writeServers = getWriteServers();
299
+ const servers = writeServers.length > 0 ? writeServers : getServers('discovery');
300
+ const body = JSON.stringify({
301
+ ownerPublicKey: identity.publicKey,
302
+ groups: publicGroups.map(g => ({
303
+ id: g.id,
304
+ name: g.name,
305
+ description: g.description,
306
+ visibility: g.visibility,
307
+ memberCount: (g.members || []).length,
308
+ createdAt: g.createdAt
309
+ }))
310
+ });
311
+ const results = await Promise.allSettled(
312
+ servers.map(server =>
313
+ fetch(`${server}/api/v1/groups/announce`, {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body,
317
+ signal: AbortSignal.timeout(10000),
318
+ }).then(res => res.ok ? res.json() : null)
319
+ )
320
+ );
321
+ const first = results.find(r => r.status === 'fulfilled' && r.value);
322
+ return first?.value || null;
323
+ }
324
+
325
+ export async function fetchSeeders(infohash) {
326
+ const res = await fetchFirst(`/api/v1/seeders/${infohash}`, {}, { role: 'discovery' });
327
+ if (!res) return [];
328
+ try {
329
+ const data = await res.json();
330
+ return Array.isArray(data.peers) ? data.peers : [];
331
+ } catch {
332
+ return [];
333
+ }
334
+ }
335
+
336
+ export async function addUserServer(url, roles = [], { trustWrites = false } = {}) {
337
+ const normalized = url.replace(/\/+$/, '');
338
+ const result = await addServerWithRoles(normalized, roles, { trustWrites });
339
+ addServerRaw(normalized);
340
+ return result;
341
+ }
342
+
343
+ // ── Offline queue ──
344
+
345
+ export function getOfflineQueue() {
346
+ return config.get('offlineQueue') || [];
347
+ }
348
+
349
+ export function queueOfflineCall(path, opts) {
350
+ const queue = getOfflineQueue();
351
+ queue.push({ path, opts, queuedAt: new Date().toISOString() });
352
+ config.set('offlineQueue', queue);
353
+ }
354
+
355
+ export async function replayOfflineQueue() {
356
+ const queue = getOfflineQueue();
357
+ if (queue.length === 0) return 0;
358
+ const server = getPrimaryServer();
359
+ let replayed = 0;
360
+ const remaining = [];
361
+ for (const item of queue) {
362
+ try {
363
+ const res = await fetch(`${server}${item.path}`, {
364
+ signal: AbortSignal.timeout(5000),
365
+ ...item.opts,
366
+ });
367
+ if (res.ok) replayed++;
368
+ else remaining.push(item);
369
+ } catch {
370
+ remaining.push(item);
371
+ }
372
+ }
373
+ config.set('offlineQueue', remaining);
374
+ return replayed;
375
+ }
376
+
377
+ export async function pinServerKey(serverUrl) {
378
+ const pinned = config.get('pinnedServerKeys') || {};
379
+ const existing = pinned[serverUrl];
380
+
381
+ try {
382
+ const res = await fetch(`${serverUrl}/server-key`, { signal: AbortSignal.timeout(3000) });
383
+ if (!res.ok) return existing || null;
384
+ const { publicKey } = await res.json();
385
+ if (!publicKey) return existing || null;
386
+
387
+ if (existing && existing !== publicKey) {
388
+ const err = new Error(
389
+ `Server key changed for ${serverUrl}! ` +
390
+ `Pinned: ${keyFingerprint(existing)}, Current: ${keyFingerprint(publicKey)}. ` +
391
+ `Run 'pe server verify' to inspect and accept the new key.`
392
+ );
393
+ err.code = 'SERVER_KEY_CHANGED';
394
+ err.pinnedKey = existing;
395
+ err.currentKey = publicKey;
396
+ throw err;
397
+ }
398
+
399
+ if (!existing) {
400
+ pinned[serverUrl] = publicKey;
401
+ config.set('pinnedServerKeys', pinned);
402
+ }
403
+ return publicKey;
404
+ } catch (e) {
405
+ if (e.code === 'SERVER_KEY_CHANGED') throw e;
406
+ return existing || null;
407
+ }
408
+ }