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,702 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import { WebSocketServer } from 'ws';
4
+ import crypto from 'crypto';
5
+ import path from 'path';
6
+ import fs from 'fs';
7
+ import { fileURLToPath } from 'url';
8
+ import config from '../utils/config.js';
9
+ import { getDownloadDir } from '../utils/downloadDir.js';
10
+ import os from 'os';
11
+ import { listShares, addShare, removeShare } from './shares.js';
12
+ import { getTransfers, trackTransfer, removeTransfer, setTransferPaused } from './transfers.js';
13
+ import { getFriends, saveFriends } from './users.js';
14
+ import { getGroups, getGroup, createGroup, deleteGroup, addMemberToGroup, removeMemberFromGroup, getShareComments, addShareComment, deleteShareComment } from './groups.js';
15
+ import { isPro } from './pro.js';
16
+
17
+ const TOKEN_KEY = 'webDashboardToken';
18
+
19
+ function getOrCreateToken() {
20
+ let token = config.get(TOKEN_KEY);
21
+ if (!token) {
22
+ token = crypto.randomBytes(32).toString('hex');
23
+ config.set(TOKEN_KEY, token);
24
+ }
25
+ return token;
26
+ }
27
+
28
+ function authMiddleware(req, res, next) {
29
+ const token = req.headers.authorization?.replace('Bearer ', '');
30
+ if (!token) return res.status(401).json({ error: 'Unauthorized' });
31
+ const expected = Buffer.from(getOrCreateToken(), 'hex');
32
+ const provided = Buffer.from(token, 'hex');
33
+ if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
34
+ return res.status(401).json({ error: 'Unauthorized' });
35
+ }
36
+ next();
37
+ }
38
+
39
+ export { getOrCreateToken };
40
+
41
+ export function startWebServer(port, torrentClient, { bindAddress = '127.0.0.1' } = {}) {
42
+ const app = express();
43
+ const server = createServer(app);
44
+ const wss = new WebSocketServer({ server, path: '/ws' });
45
+
46
+ app.use(express.json({ limit: '64kb' }));
47
+
48
+ // Security headers
49
+ app.use((req, res, next) => {
50
+ res.setHeader('X-Content-Type-Options', 'nosniff');
51
+ res.setHeader('X-Frame-Options', 'DENY');
52
+ res.setHeader('X-XSS-Protection', '0');
53
+ res.setHeader('Cache-Control', 'no-store');
54
+ next();
55
+ });
56
+
57
+ // CORS: localhost only
58
+ app.use((req, res, next) => {
59
+ const origin = req.headers.origin;
60
+ if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
61
+ res.setHeader('Access-Control-Allow-Origin', origin);
62
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
63
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
64
+ }
65
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
66
+ next();
67
+ });
68
+
69
+ // Serve static web UI (built dist preferred, raw web/ as fallback)
70
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
71
+ const webDistPath = path.join(__dirname, '..', '..', 'web', 'dist');
72
+ const webRootPath = path.join(__dirname, '..', '..', 'web');
73
+ if (fs.existsSync(webDistPath)) {
74
+ app.use(express.static(webDistPath));
75
+ } else if (fs.existsSync(webRootPath)) {
76
+ app.use(express.static(webRootPath));
77
+ }
78
+
79
+ // ── Mobile Auth (session-based, uses same token as Bearer) ──
80
+
81
+ app.get('/api/auth/status', (req, res) => {
82
+ res.json({ setup: true, version: '0.4.0' });
83
+ });
84
+
85
+ app.post('/api/auth/login', (req, res) => {
86
+ const { token } = req.body;
87
+ if (!token) return res.status(400).json({ error: 'token required' });
88
+ try {
89
+ const expected = Buffer.from(getOrCreateToken(), 'hex');
90
+ const provided = Buffer.from(token, 'hex');
91
+ if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
92
+ return res.status(401).json({ error: 'Invalid token' });
93
+ }
94
+ } catch {
95
+ return res.status(401).json({ error: 'Invalid token' });
96
+ }
97
+ res.json({ ok: true, token, expiresIn: 86400 * 7 });
98
+ });
99
+
100
+ app.post('/api/auth/logout', (req, res) => {
101
+ res.json({ ok: true });
102
+ });
103
+
104
+ app.post('/api/auth/refresh', (req, res) => {
105
+ const token = req.headers.authorization?.replace('Bearer ', '');
106
+ if (!token) return res.status(401).json({ error: 'Unauthorized' });
107
+ try {
108
+ const expected = Buffer.from(getOrCreateToken(), 'hex');
109
+ const provided = Buffer.from(token, 'hex');
110
+ if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
111
+ return res.status(401).json({ error: 'Invalid token' });
112
+ }
113
+ } catch {
114
+ return res.status(401).json({ error: 'Invalid token' });
115
+ }
116
+ res.json({ ok: true, token, expiresIn: 86400 * 7 });
117
+ });
118
+
119
+ // Health check (no auth)
120
+ app.get('/api/status', (req, res) => {
121
+ res.json({ status: 'ok', uptime: process.uptime() });
122
+ });
123
+
124
+ // P2P browse — returns one directory level of a non-private share
125
+ // HIGH-4 fix: require auth token, no CORS wildcard
126
+ app.get('/p2p/browse', authMiddleware, (req, res) => {
127
+ const { share: shareName, path: dirPath = '' } = req.query;
128
+ if (!shareName) return res.status(400).json({ error: 'share required' });
129
+ const shares = listShares();
130
+ const share = shares.find(s => s.name === shareName && s.visibility !== 'private');
131
+ if (!share?.path) return res.status(404).json({ error: 'Share not found' });
132
+ try {
133
+ const base = path.resolve(share.path);
134
+ const target = dirPath ? path.resolve(path.join(base, dirPath)) : base;
135
+ if (!target.startsWith(base)) return res.status(403).json({ error: 'Forbidden' });
136
+ const entries = fs.readdirSync(target, { withFileTypes: true });
137
+ res.json(entries.map(e => {
138
+ const entryPath = dirPath ? `${dirPath}/${e.name}` : e.name;
139
+ let size = null;
140
+ if (e.isFile()) { try { size = fs.statSync(path.join(target, e.name)).size; } catch {} }
141
+ return { name: e.name, path: entryPath, isDir: e.isDirectory(), size };
142
+ }));
143
+ } catch {
144
+ res.status(500).json({ error: 'Failed to read directory' });
145
+ }
146
+ });
147
+
148
+ // P2P file download — serves a single file from a non-private share
149
+ // HIGH-4 fix: require auth token, no CORS wildcard
150
+ app.get('/p2p/file', authMiddleware, (req, res) => {
151
+ const { share: shareName, path: filePath } = req.query;
152
+ if (!shareName || !filePath) return res.status(400).json({ error: 'share and path required' });
153
+ const shares = listShares();
154
+ const share = shares.find(s => s.name === shareName && s.visibility !== 'private');
155
+ if (!share?.path) return res.status(404).json({ error: 'Share not found' });
156
+ try {
157
+ const base = path.resolve(share.path);
158
+ const target = path.resolve(path.join(base, filePath));
159
+ if (!target.startsWith(base)) return res.status(403).json({ error: 'Forbidden' });
160
+ if (!fs.existsSync(target) || fs.statSync(target).isDirectory()) {
161
+ return res.status(404).json({ error: 'File not found' });
162
+ }
163
+ res.sendFile(target);
164
+ } catch {
165
+ res.status(500).json({ error: 'Failed to serve file' });
166
+ }
167
+ });
168
+
169
+ // All other API routes require auth
170
+ app.use('/api', authMiddleware);
171
+
172
+ // Identity (allowlist safe fields)
173
+ app.get('/api/identity', (req, res) => {
174
+ const identity = config.get('identity');
175
+ if (!identity) return res.json({ error: 'No identity configured' });
176
+ res.json({
177
+ publicKey: identity.publicKey,
178
+ name: identity.name,
179
+ handle: identity.handle || null,
180
+ createdAt: identity.createdAt || null,
181
+ });
182
+ });
183
+
184
+ // Shares
185
+ app.get('/api/shares', (req, res) => {
186
+ res.json(listShares());
187
+ });
188
+
189
+ app.post('/api/shares', (req, res) => {
190
+ try {
191
+ const { path: sharePath, visibility, recipients } = req.body;
192
+ if (!sharePath) return res.status(400).json({ error: 'path is required' });
193
+ const share = addShare(sharePath, 'file', visibility || 'global', { read: true, write: false }, recipients || []);
194
+ res.status(201).json(share);
195
+ broadcast({ type: 'share:created', share });
196
+ } catch (err) {
197
+ res.status(400).json({ error: err.message });
198
+ }
199
+ });
200
+
201
+ app.delete('/api/shares/:id', (req, res) => {
202
+ try {
203
+ removeShare(req.params.id);
204
+ res.json({ ok: true });
205
+ broadcast({ type: 'share:removed', id: req.params.id });
206
+ } catch (err) {
207
+ res.status(404).json({ error: err.message });
208
+ }
209
+ });
210
+
211
+ // Pals (per-user)
212
+ app.get('/api/pals', (req, res) => {
213
+ res.json(getFriends());
214
+ });
215
+
216
+ app.post('/api/pals', (req, res) => {
217
+ const { target, name } = req.body;
218
+ if (!target) return res.status(400).json({ error: 'target is required' });
219
+ if (!/^[0-9a-f]{64}$/i.test(target)) return res.status(400).json({ error: 'Invalid public key format' });
220
+
221
+ const friends = getFriends();
222
+ if (friends.find(f => f.id === target)) {
223
+ return res.status(409).json({ error: 'Pal already exists' });
224
+ }
225
+
226
+ const pal = { id: target, name: name || 'Unnamed Pal', handle: null, addedAt: new Date().toISOString() };
227
+ friends.push(pal);
228
+ saveFriends(friends);
229
+ res.status(201).json(pal);
230
+ broadcast({ type: 'pal:added', pal });
231
+ });
232
+
233
+ app.delete('/api/pals/:id', (req, res) => {
234
+ const friends = getFriends();
235
+ const index = friends.findIndex(f => f.id === req.params.id);
236
+ if (index === -1) return res.status(404).json({ error: 'Pal not found' });
237
+
238
+ const [removed] = friends.splice(index, 1);
239
+ saveFriends(friends);
240
+ res.json({ ok: true });
241
+ broadcast({ type: 'pal:removed', id: removed.id });
242
+ });
243
+
244
+ // Transfers
245
+ app.get('/api/transfers', (req, res) => {
246
+ res.json(getTransfers());
247
+ });
248
+
249
+ // Downloads
250
+ app.post('/api/downloads', (req, res) => {
251
+ const { magnet, name, encryptedShareKey } = req.body;
252
+ if (!magnet) return res.status(400).json({ error: 'magnet is required' });
253
+ if (!torrentClient) return res.status(503).json({ error: 'Torrent client not available' });
254
+
255
+ try {
256
+ const downloadDir = getDownloadDir();
257
+ trackTransfer(magnet, name || 'download', downloadDir, encryptedShareKey || null);
258
+ torrentClient.add(magnet, { path: downloadDir }, (torrent) => {
259
+ broadcast({ type: 'download:started', name: torrent.name, magnet });
260
+ });
261
+ res.status(202).json({ ok: true, magnet });
262
+ } catch (err) {
263
+ res.status(400).json({ error: err.message });
264
+ }
265
+ });
266
+
267
+ // Scheduled Tasks
268
+ app.get('/api/schedule', (req, res) => {
269
+ const tasks = config.get('scheduledTasks') || [];
270
+ res.json(tasks);
271
+ });
272
+
273
+ app.post('/api/schedule', (req, res) => {
274
+ const { type, executeAt, data } = req.body;
275
+ if (!type || !executeAt) return res.status(400).json({ error: 'type and executeAt required' });
276
+ if (!['share', 'download', 'revoke'].includes(type)) return res.status(400).json({ error: 'Invalid type' });
277
+ const ts = new Date(executeAt).getTime();
278
+ if (isNaN(ts) || ts <= Date.now()) return res.status(400).json({ error: 'executeAt must be a valid future datetime' });
279
+
280
+ const tasks = config.get('scheduledTasks') || [];
281
+ const task = {
282
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
283
+ type, executeAt: ts, data: data || {},
284
+ status: 'pending', createdAt: new Date().toISOString(),
285
+ };
286
+ tasks.push(task);
287
+ config.set('scheduledTasks', tasks);
288
+ res.status(201).json(task);
289
+ broadcast({ type: 'schedule:created', task });
290
+ });
291
+
292
+ app.delete('/api/schedule/:id', (req, res) => {
293
+ const tasks = config.get('scheduledTasks') || [];
294
+ const task = tasks.find(t => t.id === req.params.id);
295
+ if (!task) return res.status(404).json({ error: 'Task not found' });
296
+ if (task.status !== 'pending') return res.status(400).json({ error: `Cannot cancel task with status: ${task.status}` });
297
+ task.status = 'cancelled';
298
+ config.set('scheduledTasks', tasks);
299
+ res.json({ ok: true });
300
+ broadcast({ type: 'schedule:cancelled', id: req.params.id });
301
+ });
302
+
303
+ // Chat messages (local history)
304
+ app.get('/api/chat', (req, res) => {
305
+ const chatHistory = config.get('chatHistory') || {};
306
+ res.json(chatHistory);
307
+ });
308
+
309
+ app.get('/api/chat/:handle', (req, res) => {
310
+ const chatHistory = config.get('chatHistory') || {};
311
+ res.json(chatHistory[req.params.handle] || []);
312
+ });
313
+
314
+ app.post('/api/chat', async (req, res) => {
315
+ const { toHandle, text } = req.body;
316
+ if (!toHandle || !text) return res.status(400).json({ error: 'toHandle and text required' });
317
+
318
+ const identity = config.get('identity');
319
+ if (!identity?.handle) return res.status(400).json({ error: 'No handle registered' });
320
+
321
+ const discoveryUrl = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || 'http://localhost:3000';
322
+ const timestamp = Date.now();
323
+
324
+ try {
325
+ const r = await fetch(`${discoveryUrl}/api/v1/messages`, {
326
+ method: 'POST',
327
+ headers: { 'Content-Type': 'application/json' },
328
+ body: JSON.stringify({
329
+ toHandle, fromHandle: identity.handle,
330
+ payload: { type: 'chat', text, timestamp },
331
+ }),
332
+ signal: AbortSignal.timeout(10000),
333
+ });
334
+ if (!r.ok) return res.status(502).json({ error: 'Failed to send message' });
335
+
336
+ const chatHistory = config.get('chatHistory') || {};
337
+ if (!chatHistory[toHandle]) chatHistory[toHandle] = [];
338
+ chatHistory[toHandle].push({ id: timestamp, text, sent: true, timestamp, fromHandle: identity.handle, toHandle });
339
+ config.set('chatHistory', chatHistory);
340
+
341
+ res.status(201).json({ ok: true, timestamp });
342
+ broadcast({ type: 'chat:sent', toHandle, text, timestamp });
343
+ } catch (err) {
344
+ res.status(502).json({ error: err.message });
345
+ }
346
+ });
347
+
348
+ // Transfer management (pause/resume/cancel)
349
+ app.post('/api/transfers/:magnet/pause', (req, res) => {
350
+ try {
351
+ setTransferPaused(req.params.magnet, true);
352
+ res.json({ ok: true });
353
+ } catch (err) {
354
+ res.status(400).json({ error: err.message });
355
+ }
356
+ });
357
+
358
+ app.post('/api/transfers/:magnet/resume', (req, res) => {
359
+ try {
360
+ setTransferPaused(req.params.magnet, false);
361
+ res.json({ ok: true });
362
+ } catch (err) {
363
+ res.status(400).json({ error: err.message });
364
+ }
365
+ });
366
+
367
+ app.delete('/api/transfers/:magnet', (req, res) => {
368
+ try {
369
+ removeTransfer(req.params.magnet);
370
+ if (torrentClient) {
371
+ const torrent = torrentClient.torrents.find(t => t.magnetURI === req.params.magnet);
372
+ if (torrent) torrentClient.remove(torrent);
373
+ }
374
+ res.json({ ok: true });
375
+ } catch (err) {
376
+ res.status(400).json({ error: err.message });
377
+ }
378
+ });
379
+
380
+ // Stats
381
+ app.get('/api/stats', (req, res) => {
382
+ if (!torrentClient) return res.json({ torrents: 0, uploadSpeed: 0, downloadSpeed: 0 });
383
+ res.json({
384
+ torrents: torrentClient.torrents.length,
385
+ uploadSpeed: torrentClient.uploadSpeed,
386
+ downloadSpeed: torrentClient.downloadSpeed,
387
+ ratio: torrentClient.ratio,
388
+ torrentsDetail: torrentClient.torrents.map(t => ({
389
+ name: t.name,
390
+ progress: t.progress,
391
+ uploadSpeed: t.uploadSpeed,
392
+ downloadSpeed: t.downloadSpeed,
393
+ numPeers: t.numPeers,
394
+ uploaded: t.uploaded,
395
+ downloaded: t.downloaded,
396
+ length: t.length,
397
+ magnetURI: t.magnetURI
398
+ }))
399
+ });
400
+ });
401
+
402
+ // ── PAL Protocol API ──
403
+
404
+ app.get('/api/protocol', async (req, res) => {
405
+ try {
406
+ const { PROTOCOL_NAME, PROTOCOL_VERSION } = await import('../protocol/index.js');
407
+ const { isPro } = await import('./pro.js');
408
+ const { getRelayLimits } = await import('../protocol/router.js');
409
+ const { FREE_POLICY_LIMITS, PRO_POLICY_LIMITS } = await import('../protocol/policy.js');
410
+ const pro = isPro();
411
+ const caps = ['share', 'sync', 'chat', 'relay'];
412
+ if (pro) caps.push('delta-sync', 'receipts');
413
+ res.json({
414
+ name: PROTOCOL_NAME,
415
+ version: PROTOCOL_VERSION,
416
+ tier: pro ? 'pro' : 'free',
417
+ capabilities: caps,
418
+ relayLimits: getRelayLimits(),
419
+ policyLimits: pro ? PRO_POLICY_LIMITS : FREE_POLICY_LIMITS,
420
+ });
421
+ } catch (err) {
422
+ res.status(500).json({ error: 'Internal error' });
423
+ }
424
+ });
425
+
426
+ app.get('/api/protocol/policies', (req, res) => {
427
+ try {
428
+ const { listPolicies } = require('./sharePolicy.js');
429
+ res.json(listPolicies());
430
+ } catch (err) {
431
+ res.status(500).json({ error: 'Internal error' });
432
+ }
433
+ });
434
+
435
+ app.post('/api/protocol/policies/:shareId', async (req, res) => {
436
+ try {
437
+ const { validatePolicy } = await import('../protocol/policy.js');
438
+ const { setSharePolicy } = await import('./sharePolicy.js');
439
+ const validation = validatePolicy(req.body);
440
+ if (!validation.valid) return res.status(400).json({ errors: validation.errors });
441
+ setSharePolicy(req.params.shareId, req.body);
442
+ res.json({ success: true });
443
+ } catch (err) {
444
+ res.status(500).json({ error: 'Internal error' });
445
+ }
446
+ });
447
+
448
+ app.delete('/api/protocol/policies/:shareId', async (req, res) => {
449
+ try {
450
+ const { removeSharePolicy } = await import('./sharePolicy.js');
451
+ removeSharePolicy(req.params.shareId);
452
+ res.json({ success: true });
453
+ } catch (err) {
454
+ res.status(500).json({ error: 'Internal error' });
455
+ }
456
+ });
457
+
458
+ app.post('/api/protocol/probe/:peerPK', async (req, res) => {
459
+ try {
460
+ const { probeRoutes, selectRoute, buildRouteInfo } = await import('../protocol/router.js');
461
+ const results = await probeRoutes(req.params.peerPK);
462
+ res.json({
463
+ routes: results,
464
+ recommended: selectRoute(results),
465
+ routeInfo: buildRouteInfo(results),
466
+ });
467
+ } catch (err) {
468
+ res.status(500).json({ error: 'Internal error' });
469
+ }
470
+ });
471
+
472
+ // ── Groups ──
473
+
474
+ app.get('/api/groups', (req, res) => {
475
+ res.json(getGroups());
476
+ });
477
+
478
+ app.get('/api/groups/:id', (req, res) => {
479
+ const group = getGroup(req.params.id);
480
+ if (!group) return res.status(404).json({ error: 'Group not found' });
481
+ res.json(group);
482
+ });
483
+
484
+ app.post('/api/groups', (req, res) => {
485
+ const { name } = req.body;
486
+ if (!name) return res.status(400).json({ error: 'name required' });
487
+ const identity = config.get('identity');
488
+ try {
489
+ const group = createGroup(name, identity?.publicKey, req.body);
490
+ res.status(201).json(group);
491
+ broadcast({ type: 'group:created', group });
492
+ } catch (err) {
493
+ res.status(400).json({ error: err.message });
494
+ }
495
+ });
496
+
497
+ app.delete('/api/groups/:id', (req, res) => {
498
+ const identity = config.get('identity');
499
+ try {
500
+ deleteGroup(req.params.id, identity?.publicKey);
501
+ res.json({ ok: true });
502
+ broadcast({ type: 'group:deleted', id: req.params.id });
503
+ } catch (err) {
504
+ res.status(400).json({ error: err.message });
505
+ }
506
+ });
507
+
508
+ app.post('/api/groups/:id/members', (req, res) => {
509
+ const { palId, name, handle } = req.body;
510
+ if (!palId) return res.status(400).json({ error: 'palId required' });
511
+ try {
512
+ addMemberToGroup(req.params.id, { id: palId, name: name || 'Unknown', handle });
513
+ res.status(201).json({ ok: true });
514
+ } catch (err) {
515
+ res.status(400).json({ error: err.message });
516
+ }
517
+ });
518
+
519
+ app.delete('/api/groups/:id/members/:palId', (req, res) => {
520
+ try {
521
+ removeMemberFromGroup(req.params.id, req.params.palId);
522
+ res.json({ ok: true });
523
+ } catch (err) {
524
+ res.status(400).json({ error: err.message });
525
+ }
526
+ });
527
+
528
+ // ── Share Comments ──
529
+
530
+ app.get('/api/shares/:shareId/comments', (req, res) => {
531
+ res.json(getShareComments(req.params.shareId));
532
+ });
533
+
534
+ app.post('/api/shares/:shareId/comments', (req, res) => {
535
+ const { text } = req.body;
536
+ if (!text) return res.status(400).json({ error: 'text required' });
537
+ const identity = config.get('identity');
538
+ try {
539
+ const comment = addShareComment(req.params.shareId, {
540
+ authorHandle: identity?.handle,
541
+ authorName: identity?.name,
542
+ text,
543
+ });
544
+ res.status(201).json(comment);
545
+ } catch (err) {
546
+ res.status(400).json({ error: err.message });
547
+ }
548
+ });
549
+
550
+ app.delete('/api/shares/:shareId/comments/:commentId', (req, res) => {
551
+ try {
552
+ deleteShareComment(req.params.shareId, req.params.commentId);
553
+ res.json({ ok: true });
554
+ } catch (err) {
555
+ res.status(400).json({ error: err.message });
556
+ }
557
+ });
558
+
559
+ // ── Billing / Pro Status ──
560
+
561
+ app.get('/api/billing/status', (req, res) => {
562
+ const pro = isPro();
563
+ const license = config.get('license') || {};
564
+ res.json({
565
+ plan: pro ? 'pro' : 'free',
566
+ active: pro,
567
+ expiresAt: license.expiresAt || null,
568
+ licenseKey: license.key ? `${license.key.slice(0, 8)}...` : null,
569
+ });
570
+ });
571
+
572
+ // ── Chat by pal handle (mobile format) ──
573
+
574
+ app.get('/api/pals/messages', (req, res) => {
575
+ const { pal, unread } = req.query;
576
+ const chatHistory = config.get('chatHistory') || {};
577
+ if (pal) {
578
+ const messages = chatHistory[pal] || [];
579
+ if (unread === 'true') {
580
+ return res.json(messages.filter(m => !m.read && !m.sent));
581
+ }
582
+ return res.json(messages);
583
+ }
584
+ if (unread === 'true') {
585
+ const result = {};
586
+ for (const [handle, msgs] of Object.entries(chatHistory)) {
587
+ const unreadMsgs = msgs.filter(m => !m.read && !m.sent);
588
+ if (unreadMsgs.length > 0) result[handle] = unreadMsgs;
589
+ }
590
+ return res.json(result);
591
+ }
592
+ res.json(chatHistory);
593
+ });
594
+
595
+ app.post('/api/pals/messages', async (req, res) => {
596
+ const { to, message } = req.body;
597
+ if (!to || !message) return res.status(400).json({ error: 'to and message required' });
598
+ const identity = config.get('identity');
599
+ if (!identity?.handle) return res.status(400).json({ error: 'No handle registered' });
600
+
601
+ const discoveryUrl = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || 'http://localhost:3000';
602
+ const timestamp = Date.now();
603
+
604
+ try {
605
+ const r = await fetch(`${discoveryUrl}/api/v1/messages`, {
606
+ method: 'POST',
607
+ headers: { 'Content-Type': 'application/json' },
608
+ body: JSON.stringify({
609
+ toHandle: to, fromHandle: identity.handle,
610
+ payload: { type: 'chat', text: message, timestamp },
611
+ }),
612
+ signal: AbortSignal.timeout(10000),
613
+ });
614
+ if (!r.ok) return res.status(502).json({ error: 'Failed to send message' });
615
+
616
+ const chatHistory = config.get('chatHistory') || {};
617
+ if (!chatHistory[to]) chatHistory[to] = [];
618
+ chatHistory[to].push({ id: timestamp, text: message, sent: true, timestamp, fromHandle: identity.handle, toHandle: to });
619
+ config.set('chatHistory', chatHistory);
620
+
621
+ res.status(201).json({ ok: true, timestamp });
622
+ broadcast({ type: 'chat:sent', toHandle: to, text: message, timestamp });
623
+ } catch (err) {
624
+ res.status(502).json({ error: err.message });
625
+ }
626
+ });
627
+
628
+ // ── Download (mobile format) ──
629
+
630
+ app.post('/api/transfers/download', (req, res) => {
631
+ const { magnet, name, encryptedShareKey } = req.body;
632
+ if (!magnet) return res.status(400).json({ error: 'magnet is required' });
633
+ if (!torrentClient) return res.status(503).json({ error: 'Torrent client not available' });
634
+
635
+ try {
636
+ const downloadDir = getDownloadDir();
637
+ trackTransfer(magnet, name || 'download', downloadDir, encryptedShareKey || null);
638
+ torrentClient.add(magnet, { path: downloadDir }, (torrent) => {
639
+ broadcast({ type: 'download:started', name: torrent.name, magnet });
640
+ });
641
+ res.status(202).json({ ok: true, magnet });
642
+ } catch (err) {
643
+ res.status(400).json({ error: err.message });
644
+ }
645
+ });
646
+
647
+ // Fallback: serve index.html for SPA routing
648
+ app.get('{*path}', (req, res) => {
649
+ const indexPath = path.join(webDistPath, 'index.html');
650
+ if (fs.existsSync(indexPath)) {
651
+ res.sendFile(indexPath);
652
+ } else {
653
+ res.status(404).json({ error: 'Web UI not built. Run: cd web && npm run build' });
654
+ }
655
+ });
656
+
657
+ // WebSocket
658
+ const clients = new Set();
659
+
660
+ wss.on('connection', (ws, req) => {
661
+ const url = new URL(req.url, `http://localhost:${port}`);
662
+ const token = url.searchParams.get('token');
663
+ const wsExpected = Buffer.from(getOrCreateToken(), 'hex');
664
+ const wsProvided = Buffer.from(token || '', 'hex');
665
+ if (wsProvided.length !== wsExpected.length || !crypto.timingSafeEqual(wsProvided, wsExpected)) {
666
+ ws.close(4001, 'Unauthorized');
667
+ return;
668
+ }
669
+ clients.add(ws);
670
+ ws.on('close', () => clients.delete(ws));
671
+ });
672
+
673
+ function broadcast(data) {
674
+ const msg = JSON.stringify(data);
675
+ for (const ws of clients) {
676
+ if (ws.readyState === 1) ws.send(msg);
677
+ }
678
+ }
679
+
680
+ // Broadcast transfer progress every 2 seconds
681
+ const progressInterval = setInterval(() => {
682
+ if (clients.size === 0 || !torrentClient) return;
683
+ const transfers = torrentClient.torrents.map(t => ({
684
+ name: t.name,
685
+ progress: t.progress,
686
+ uploadSpeed: t.uploadSpeed,
687
+ downloadSpeed: t.downloadSpeed,
688
+ numPeers: t.numPeers,
689
+ done: t.done
690
+ }));
691
+ broadcast({ type: 'transfer:progress', transfers });
692
+ }, 2000);
693
+
694
+ server.on('close', () => clearInterval(progressInterval));
695
+
696
+ return new Promise((resolve, reject) => {
697
+ server.listen(port, bindAddress, () => {
698
+ resolve({ server, port, token: getOrCreateToken() });
699
+ });
700
+ server.on('error', (err) => { clearInterval(progressInterval); reject(err); });
701
+ });
702
+ }