@goodandready/dsh-remote-workspace 0.1.0 → 0.2.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.
package/lib/routes.js CHANGED
@@ -1,63 +1,88 @@
1
- function writeJson(res, code, data) {
2
- try {
3
- res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
4
- res.end(JSON.stringify(data));
5
- } catch {}
6
- }
1
+ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
2
+ function writeJson(res, statusCode, body) {
3
+ if (typeof res.setHeader === 'function') {
4
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
5
+ res.statusCode = statusCode;
6
+ } else if (typeof res.writeHead === 'function') {
7
+ res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
8
+ } else {
9
+ res.statusCode = statusCode;
10
+ }
11
+ res.end(JSON.stringify(body));
12
+ }
7
13
 
8
- function readBody(req, maxBytes = 256 * 1024) {
9
- return new Promise((resolve, reject) => {
10
- const chunks = [];
11
- let size = 0;
12
- req.on('data', (c) => {
13
- size += c.length;
14
- if (size > maxBytes) {
15
- reject(new Error('Payload too large'));
16
- req.destroy();
17
- return;
18
- }
19
- chunks.push(c);
20
- });
21
- req.on('end', () => {
22
- try {
23
- const raw = Buffer.concat(chunks).toString('utf8');
24
- resolve(raw ? JSON.parse(raw) : {});
25
- } catch (err) {
26
- reject(new Error('Invalid JSON'));
27
- }
14
+ function readBody(req) {
15
+ return new Promise((resolve, reject) => {
16
+ let body = '';
17
+ req.on('data', (chunk) => { body += chunk.toString(); });
18
+ req.on('end', () => {
19
+ try {
20
+ resolve(body ? JSON.parse(body) : {});
21
+ } catch (err) {
22
+ reject(err);
23
+ }
24
+ });
25
+ req.on('error', reject);
28
26
  });
29
- req.on('error', reject);
30
- });
31
- }
27
+ }
28
+
29
+ function isTrustedSettingsRequest(req) {
30
+ const rawAuth = req.headers['authorization'];
31
+ if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) return true;
32
+ const cookieHeader = req.headers['cookie'];
33
+ if (typeof cookieHeader === 'string' && (cookieHeader.includes('token=') || cookieHeader.includes('dsh_token='))) {
34
+ return true;
35
+ }
36
+ const secFetch = req.headers['sec-fetch-site'];
37
+ if (secFetch === 'same-origin') return true;
38
+ const ip = req.socket?.remoteAddress || '';
39
+ if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
40
+ return false;
41
+ }
32
42
 
33
- export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
34
43
  ctx.inject(['webServer'], (sctx) => {
44
+ const webServer = sctx.get ? (sctx.get('webServer') ?? sctx.webServer) : sctx.webServer;
45
+ if (!webServer || typeof webServer.register !== 'function') return;
46
+
35
47
  // 1. GET /dsh-remote-workspace/state
36
- sctx.effect(() => sctx.webServer.register({
48
+ sctx.effect(() => webServer.register({
37
49
  kind: 'exact',
38
50
  path: '/dsh-remote-workspace/state',
39
51
  handler: async (req, res) => {
40
- if (req.method !== 'GET') return writeJson(res, 405, { error: 'GET only' });
41
- const profiles = store.getProfiles();
42
- const activeId = store.getActiveId();
43
- const tunnels = tunnelService.listActiveTunnels();
44
- writeJson(res, 200, { ok: true, profiles, activeId, tunnels });
52
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
53
+ try {
54
+ const profiles = store.getProfiles();
55
+ const activeId = store.getActiveId() || (profiles[0]?.id || '');
56
+ const tunnels = tunnelService.listActiveTunnels();
57
+ writeJson(res, 200, {
58
+ ok: true,
59
+ profiles,
60
+ activeId,
61
+ tunnels,
62
+ vaultProtected: true
63
+ });
64
+ } catch (err) {
65
+ writeJson(res, 500, { ok: false, error: err.message });
66
+ }
45
67
  }
46
68
  }), 'dsh-remote-workspace: /state');
47
69
 
48
70
  // 2. POST /dsh-remote-workspace/profiles/save
49
- sctx.effect(() => sctx.webServer.register({
71
+ sctx.effect(() => webServer.register({
50
72
  kind: 'exact',
51
73
  path: '/dsh-remote-workspace/profiles/save',
52
74
  handler: async (req, res) => {
53
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
75
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
76
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
54
77
  try {
55
78
  const profile = await readBody(req);
56
79
  if (!profile.id || !profile.host) {
57
80
  return writeJson(res, 400, { ok: false, error: 'ID and Host are required' });
58
81
  }
82
+ sshService.invalidate(profile.id);
59
83
  await store.saveProfile(profile);
60
- writeJson(res, 200, { ok: true, profile });
84
+ const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : profile;
85
+ writeJson(res, 200, { ok: true, profile: sanitized });
61
86
  } catch (err) {
62
87
  writeJson(res, 500, { ok: false, error: err.message });
63
88
  }
@@ -65,14 +90,16 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
65
90
  }), 'dsh-remote-workspace: /profiles/save');
66
91
 
67
92
  // 3. POST /dsh-remote-workspace/profiles/delete
68
- sctx.effect(() => sctx.webServer.register({
93
+ sctx.effect(() => webServer.register({
69
94
  kind: 'exact',
70
95
  path: '/dsh-remote-workspace/profiles/delete',
71
96
  handler: async (req, res) => {
72
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
97
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
98
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
73
99
  try {
74
100
  const { id } = await readBody(req);
75
101
  if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
102
+ sshService.disconnect(id);
76
103
  await store.deleteProfile(id);
77
104
  writeJson(res, 200, { ok: true });
78
105
  } catch (err) {
@@ -82,11 +109,12 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
82
109
  }), 'dsh-remote-workspace: /profiles/delete');
83
110
 
84
111
  // 4. POST /dsh-remote-workspace/profiles/active
85
- sctx.effect(() => sctx.webServer.register({
112
+ sctx.effect(() => webServer.register({
86
113
  kind: 'exact',
87
114
  path: '/dsh-remote-workspace/profiles/active',
88
115
  handler: async (req, res) => {
89
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
116
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
117
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
90
118
  try {
91
119
  const { id } = await readBody(req);
92
120
  await store.setActiveId(id || null);
@@ -98,19 +126,30 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
98
126
  }), 'dsh-remote-workspace: /profiles/active');
99
127
 
100
128
  // 5. POST /dsh-remote-workspace/test
101
- sctx.effect(() => sctx.webServer.register({
129
+ sctx.effect(() => webServer.register({
102
130
  kind: 'exact',
103
131
  path: '/dsh-remote-workspace/test',
104
132
  handler: async (req, res) => {
105
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
133
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
134
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
106
135
  try {
107
136
  const body = await readBody(req);
108
- const profile = body.profile || (body.id ? store.getProfile(body.id) : body);
137
+ let profile = body.profile || (body.id ? store.getProfile(body.id) : body);
138
+ if (store.vault && profile) {
139
+ profile = store.vault.hydrateProfile(profile);
140
+ }
109
141
  if (!profile || !profile.host) {
110
142
  return writeJson(res, 400, { ok: false, error: 'Invalid profile configuration' });
111
143
  }
112
144
  const result = await sshService.testConnection(profile);
113
- writeJson(res, 200, { ok: result.success, ...result });
145
+ writeJson(res, 200, {
146
+ ok: result.success,
147
+ success: result.success,
148
+ latency: result.latency,
149
+ latencyMs: result.latency,
150
+ os: result.os,
151
+ remoteOs: result.os
152
+ });
114
153
  } catch (err) {
115
154
  writeJson(res, 200, { ok: false, error: err.message });
116
155
  }
@@ -118,11 +157,12 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
118
157
  }), 'dsh-remote-workspace: /test');
119
158
 
120
159
  // 6. POST /dsh-remote-workspace/browse
121
- sctx.effect(() => sctx.webServer.register({
160
+ sctx.effect(() => webServer.register({
122
161
  kind: 'exact',
123
162
  path: '/dsh-remote-workspace/browse',
124
163
  handler: async (req, res) => {
125
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
164
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
165
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
126
166
  try {
127
167
  const body = await readBody(req);
128
168
  const profile = body.profile || (body.profileId ? store.getProfile(body.profileId) : store.getActiveProfile());
@@ -130,7 +170,7 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
130
170
  return writeJson(res, 400, { ok: false, error: 'No profile specified' });
131
171
  }
132
172
  const remotePath = body.remotePath || '/';
133
- const entries = await remoteFs.listDir(profile, remotePath);
173
+ const entries = await remoteFs.listDir(profile, remotePath, Boolean(body.refresh));
134
174
  writeJson(res, 200, { ok: true, currentPath: remotePath, entries });
135
175
  } catch (err) {
136
176
  writeJson(res, 500, { ok: false, error: err.message });
@@ -138,21 +178,159 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
138
178
  }
139
179
  }), 'dsh-remote-workspace: /browse');
140
180
 
141
- // 7. POST /dsh-remote-workspace/sync
142
- sctx.effect(() => sctx.webServer.register({
181
+ // 7. POST /dsh-remote-workspace/file/view
182
+ sctx.effect(() => webServer.register({
183
+ kind: 'exact',
184
+ path: '/dsh-remote-workspace/file/view',
185
+ handler: async (req, res) => {
186
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
187
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
188
+ try {
189
+ const { profileId, filePath } = await readBody(req);
190
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
191
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
192
+ if (!filePath) return writeJson(res, 400, { ok: false, error: 'filePath required' });
193
+
194
+ const stat = await remoteFs.stat(profile, filePath);
195
+ if (stat.size > 1024 * 512) {
196
+ return writeJson(res, 400, { ok: false, error: `File too large for preview (${Math.round(stat.size / 1024)} KB). Max 512 KB.` });
197
+ }
198
+
199
+ const content = await remoteFs.readFile(profile, filePath, 'utf8');
200
+ writeJson(res, 200, { ok: true, filePath, content, size: stat.size, mtime: stat.mtime });
201
+ } catch (err) {
202
+ writeJson(res, 500, { ok: false, error: err.message });
203
+ }
204
+ }
205
+ }), 'dsh-remote-workspace: /file/view');
206
+
207
+ // 8. POST /dsh-remote-workspace/file/save
208
+ sctx.effect(() => webServer.register({
209
+ kind: 'exact',
210
+ path: '/dsh-remote-workspace/file/save',
211
+ handler: async (req, res) => {
212
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
213
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
214
+ try {
215
+ const { profileId, filePath, content } = await readBody(req);
216
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
217
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
218
+ if (!filePath || content === undefined) return writeJson(res, 400, { ok: false, error: 'filePath and content required' });
219
+
220
+ const result = await remoteFs.writeFile(profile, filePath, content, 'utf8');
221
+ writeJson(res, 200, { ok: true, ...result });
222
+ } catch (err) {
223
+ writeJson(res, 500, { ok: false, error: err.message });
224
+ }
225
+ }
226
+ }), 'dsh-remote-workspace: /file/save');
227
+
228
+ // 9. POST /dsh-remote-workspace/terminal/create
229
+ sctx.effect(() => webServer.register({
230
+ kind: 'exact',
231
+ path: '/dsh-remote-workspace/terminal/create',
232
+ handler: async (req, res) => {
233
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
234
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
235
+ try {
236
+ const { profileId, cols, rows } = await readBody(req);
237
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
238
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
239
+
240
+ const session = await sshService.createTerminalSession(profile, { cols, rows });
241
+ writeJson(res, 200, { ok: true, sessionId: session.id });
242
+ } catch (err) {
243
+ writeJson(res, 500, { ok: false, error: err.message });
244
+ }
245
+ }
246
+ }), 'dsh-remote-workspace: /terminal/create');
247
+
248
+ // 10. POST /dsh-remote-workspace/terminal/input
249
+ sctx.effect(() => webServer.register({
250
+ kind: 'exact',
251
+ path: '/dsh-remote-workspace/terminal/input',
252
+ handler: async (req, res) => {
253
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
254
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
255
+ try {
256
+ const { sessionId, data } = await readBody(req);
257
+ const session = sshService.getTerminalSession(sessionId);
258
+ if (!session) return writeJson(res, 404, { ok: false, error: 'Terminal session not found' });
259
+ session.write(data || '');
260
+ writeJson(res, 200, { ok: true });
261
+ } catch (err) {
262
+ writeJson(res, 500, { ok: false, error: err.message });
263
+ }
264
+ }
265
+ }), 'dsh-remote-workspace: /terminal/input');
266
+
267
+ // 11. GET /dsh-remote-workspace/terminal/stream (SSE)
268
+ sctx.effect(() => webServer.register({
269
+ kind: 'exact',
270
+ path: '/dsh-remote-workspace/terminal/stream',
271
+ handler: async (req, res) => {
272
+ const url = new URL(req.url, 'http://localhost');
273
+ const sessionId = url.searchParams.get('sessionId');
274
+ const session = sshService.getTerminalSession(sessionId);
275
+ if (!session) return writeJson(res, 404, { ok: false, error: 'Terminal session not found' });
276
+
277
+ if (typeof res.writeHead === 'function') {
278
+ res.writeHead(200, {
279
+ 'Content-Type': 'text/event-stream',
280
+ 'Cache-Control': 'no-cache, no-transform',
281
+ 'Connection': 'keep-alive'
282
+ });
283
+ }
284
+
285
+ // Send historical buffer
286
+ for (const chunk of session.buffer) {
287
+ res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
288
+ }
289
+
290
+ const listener = (chunk) => {
291
+ res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
292
+ };
293
+ session.subscribers.add(listener);
294
+
295
+ req.on('close', () => {
296
+ session.subscribers.delete(listener);
297
+ });
298
+ }
299
+ }), 'dsh-remote-workspace: /terminal/stream');
300
+
301
+ // 12. POST /dsh-remote-workspace/terminal/close
302
+ sctx.effect(() => webServer.register({
303
+ kind: 'exact',
304
+ path: '/dsh-remote-workspace/terminal/close',
305
+ handler: async (req, res) => {
306
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
307
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
308
+ try {
309
+ const { sessionId } = await readBody(req);
310
+ const ok = sshService.closeTerminalSession(sessionId);
311
+ writeJson(res, 200, { ok });
312
+ } catch (err) {
313
+ writeJson(res, 500, { ok: false, error: err.message });
314
+ }
315
+ }
316
+ }), 'dsh-remote-workspace: /terminal/close');
317
+
318
+ // 13. POST /dsh-remote-workspace/sync
319
+ sctx.effect(() => webServer.register({
143
320
  kind: 'exact',
144
321
  path: '/dsh-remote-workspace/sync',
145
322
  handler: async (req, res) => {
146
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
323
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
324
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
147
325
  try {
148
- const { profileId, direction } = await readBody(req);
149
- const profile = store.getProfile(profileId) || store.getActiveProfile();
326
+ const { profileId, direction, force } = await readBody(req);
327
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
150
328
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
151
329
  if (!profile.remoteWorkspace || !profile.localMirrorPath) {
152
- return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be set' });
330
+ return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be configured' });
153
331
  }
154
332
  const resSync = direction === 'push'
155
- ? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace)
333
+ ? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace, Boolean(force))
156
334
  : await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath);
157
335
  writeJson(res, 200, { ok: resSync.success, ...resSync });
158
336
  } catch (err) {
@@ -160,5 +338,61 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
160
338
  }
161
339
  }
162
340
  }), 'dsh-remote-workspace: /sync');
341
+
342
+ // 14. POST /dsh-remote-workspace/tunnels/start
343
+ sctx.effect(() => webServer.register({
344
+ kind: 'exact',
345
+ path: '/dsh-remote-workspace/tunnels/start',
346
+ handler: async (req, res) => {
347
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
348
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
349
+ try {
350
+ const { profileId, localPort, targetHost, remotePort } = await readBody(req);
351
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
352
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
353
+ const lPort = parseInt(localPort, 10);
354
+ const rPort = parseInt(remotePort, 10);
355
+ if (!lPort || !rPort) {
356
+ return writeJson(res, 400, { ok: false, error: 'Valid localPort and remotePort required' });
357
+ }
358
+ const result = await tunnelService.startLocalTunnel(profile, lPort, targetHost || '127.0.0.1', rPort);
359
+ writeJson(res, 200, { ok: result.success, ...result });
360
+ } catch (err) {
361
+ writeJson(res, 500, { ok: false, error: err.message });
362
+ }
363
+ }
364
+ }), 'dsh-remote-workspace: /tunnels/start');
365
+
366
+ // 15. POST /dsh-remote-workspace/tunnels/stop
367
+ sctx.effect(() => webServer.register({
368
+ kind: 'exact',
369
+ path: '/dsh-remote-workspace/tunnels/stop',
370
+ handler: async (req, res) => {
371
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
372
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
373
+ try {
374
+ const { tunnelId } = await readBody(req);
375
+ if (!tunnelId) return writeJson(res, 400, { ok: false, error: 'tunnelId required' });
376
+ const stopped = tunnelService.stopTunnel(tunnelId);
377
+ writeJson(res, 200, { ok: stopped });
378
+ } catch (err) {
379
+ writeJson(res, 500, { ok: false, error: err.message });
380
+ }
381
+ }
382
+ }), 'dsh-remote-workspace: /tunnels/stop');
383
+
384
+ // 16. GET /dsh-remote-workspace/tunnels/telemetry
385
+ sctx.effect(() => webServer.register({
386
+ kind: 'exact',
387
+ path: '/dsh-remote-workspace/tunnels/telemetry',
388
+ handler: async (req, res) => {
389
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
390
+ try {
391
+ writeJson(res, 200, { ok: true, tunnels: tunnelService.listActiveTunnels() });
392
+ } catch (err) {
393
+ writeJson(res, 500, { ok: false, error: err.message });
394
+ }
395
+ }
396
+ }), 'dsh-remote-workspace: /tunnels/telemetry');
163
397
  });
164
398
  }