@goodandready/dsh-remote-workspace 0.3.8 → 0.3.10

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,4 +1,24 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
+ import { contentDisposition, pipeWithProgress, MAX_BYTES } from '../file-transfer.js';
3
+
4
+
5
+ function transferTarget(req, store) {
6
+ const url = new URL(req.url || '/', 'http://localhost');
7
+ const profileId = url.searchParams.get('profileId') || '';
8
+ const filePath = url.searchParams.get('filePath') || '';
9
+ if (!filePath || filePath.length > 1024 || filePath.includes('\0')) {
10
+ const err = new Error('A file path is required');
11
+ err.status = 400;
12
+ throw err;
13
+ }
14
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
15
+ if (!profile) {
16
+ const err = new Error('Profile not found');
17
+ err.status = 404;
18
+ throw err;
19
+ }
20
+ return { profile, filePath };
21
+ }
2
22
 
3
23
  export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshService }) {
4
24
  // 7. POST /dsh-remote-workspace/browse
@@ -7,7 +27,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
7
27
  path: '/dsh-remote-workspace/browse',
8
28
  handler: async (req, res) => {
9
29
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
30
+ if (!authorizeRequest(sctx, req, res)) return;
11
31
  try {
12
32
  const body = await readBody(req);
13
33
  let profile = null;
@@ -28,7 +48,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
28
48
  const entries = await remoteFs.listDir(profile, targetPath);
29
49
  writeJson(res, 200, { ok: true, entries, items: entries, currentPath: targetPath });
30
50
  } catch (err) {
31
- writeJson(res, 500, { ok: false, error: err.message });
51
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
32
52
  }
33
53
  }
34
54
  }), 'dsh-remote-workspace: /browse');
@@ -39,7 +59,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
39
59
  path: '/dsh-remote-workspace/file/view',
40
60
  handler: async (req, res) => {
41
61
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
42
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
62
+ if (!authorizeRequest(sctx, req, res)) return;
43
63
  try {
44
64
  const { profileId, filePath } = await readBody(req);
45
65
  const profile = store.getProfile(profileId);
@@ -51,7 +71,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
51
71
  const buf = await remoteFs.readFile(profile, filePath);
52
72
  writeJson(res, 200, { ok: true, content: buf.toString('utf8'), size: stat.size });
53
73
  } catch (err) {
54
- writeJson(res, 500, { ok: false, error: err.message });
74
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
55
75
  }
56
76
  }
57
77
  }), 'dsh-remote-workspace: /file/view');
@@ -62,7 +82,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
62
82
  path: '/dsh-remote-workspace/file/save',
63
83
  handler: async (req, res) => {
64
84
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
65
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
85
+ if (!authorizeRequest(sctx, req, res)) return;
66
86
  try {
67
87
  const { profileId, filePath, content } = await readBody(req);
68
88
  const profile = store.getProfile(profileId);
@@ -70,18 +90,73 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
70
90
  await remoteFs.writeFile(profile, filePath, Buffer.from(content, 'utf8'));
71
91
  writeJson(res, 200, { ok: true });
72
92
  } catch (err) {
73
- writeJson(res, 500, { ok: false, error: err.message });
93
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
74
94
  }
75
95
  }
76
96
  }), 'dsh-remote-workspace: /file/save');
77
97
 
98
+ sctx.effect(() => webServer.register({
99
+ kind: 'exact',
100
+ path: '/dsh-remote-workspace/file/download',
101
+ handler: async (req, res) => {
102
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
103
+ if (!authorizeRequest(sctx, req, res)) return;
104
+ try {
105
+ const { profile, filePath } = transferTarget(req, store);
106
+ const stat = await remoteFs.stat(profile, filePath);
107
+ if (!stat.isFile) return writeJson(res, 400, { ok: false, error: 'Only a file can be downloaded' });
108
+ if (stat.size > MAX_BYTES) return writeJson(res, 413, { ok: false, error: 'File exceeds the 512MB transfer limit' });
109
+ const stream = await remoteFs.createReadStream(profile, filePath);
110
+ const stop = () => { try { stream.destroy(); } catch (err) { /* best-effort cleanup */ } };
111
+ req.on('close', stop);
112
+ stream.on('error', () => stop());
113
+ res.writeHead(200, {
114
+ 'Content-Type': 'application/octet-stream',
115
+ 'Content-Length': stat.size,
116
+ 'Content-Disposition': contentDisposition(filePath),
117
+ 'Cache-Control': 'no-store'
118
+ });
119
+ stream.pipe(res);
120
+ } catch (err) {
121
+ if (!res.headersSent) writeJson(res, err.status || 500, { ok: false, error: err.message });
122
+ }
123
+ }
124
+ }), 'dsh-remote-workspace: /file/download');
125
+
126
+ sctx.effect(() => webServer.register({
127
+ kind: 'exact',
128
+ path: '/dsh-remote-workspace/file/upload',
129
+ handler: async (req, res) => {
130
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
131
+ if (!authorizeRequest(sctx, req, res)) return;
132
+ let upload = null;
133
+ try {
134
+ const { profile, filePath } = transferTarget(req, store);
135
+ upload = await remoteFs.beginUpload(profile, filePath);
136
+ const bytes = await pipeWithProgress(req, upload.stream);
137
+ await upload.commit();
138
+ if (remoteFs.invalidateCache) remoteFs.invalidateCache(profile.id);
139
+ writeJson(res, 200, { ok: true, bytes });
140
+ } catch (err) {
141
+ if (upload) {
142
+ try { await upload.abort(); } catch (abortErr) { /* best-effort cleanup */ }
143
+ }
144
+ if (!res.headersSent) {
145
+ const status = err.code === 'ABORTED' ? 499 : (err.code === 'LIMIT' ? 413 : (err.status || 500));
146
+ writeJson(res, status, { ok: false, error: err.message });
147
+ }
148
+ }
149
+ }
150
+ }), 'dsh-remote-workspace: /file/upload');
151
+
152
+
78
153
  // 26. POST /dsh-remote-workspace/transfer
79
154
  sctx.effect(() => webServer.register({
80
155
  kind: 'exact',
81
156
  path: '/dsh-remote-workspace/transfer',
82
157
  handler: async (req, res) => {
83
158
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
84
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
159
+ if (!authorizeRequest(sctx, req, res)) return;
85
160
  try {
86
161
  const { sourceProfileId, sourcePath, destProfileId, destPath } = await readBody(req);
87
162
  const srcProfile = sourceProfileId ? store.getProfile(sourceProfileId) : store.getActiveProfile();
@@ -94,7 +169,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
94
169
  const result = await sshService.transferFileBetweenHosts(srcProfile, sourcePath, destProfile, destPath);
95
170
  writeJson(res, 200, { ok: result.success, ...result });
96
171
  } catch (err) {
97
- writeJson(res, 500, { ok: false, error: err.message });
172
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
98
173
  }
99
174
  }
100
175
  }), 'dsh-remote-workspace: /transfer');
@@ -10,18 +10,54 @@ export function writeJson(res, statusCode, body) {
10
10
  res.end(JSON.stringify(body));
11
11
  }
12
12
 
13
- export function readBody(req) {
13
+ export const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MiB
14
+
15
+ export function readBody(req, maxBytes = MAX_BODY_BYTES) {
14
16
  return new Promise((resolve, reject) => {
17
+ const rawCl = req?.headers?.['content-length'];
18
+ const contentLength = rawCl !== undefined ? parseInt(Array.isArray(rawCl) ? rawCl[0] : rawCl, 10) : NaN;
19
+ if (!isNaN(contentLength) && contentLength > maxBytes) {
20
+ if (typeof req.destroy === 'function') req.destroy();
21
+ const error = new Error('Payload too large');
22
+ error.statusCode = 413;
23
+ return reject(error);
24
+ }
25
+
15
26
  let body = '';
16
- req.on('data', (chunk) => { body += chunk.toString(); });
27
+ let bytesReceived = 0;
28
+ let exceeded = false;
29
+
30
+ function onData(chunk) {
31
+ if (exceeded) return;
32
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
33
+ bytesReceived += buffer.length;
34
+ if (bytesReceived > maxBytes) {
35
+ exceeded = true;
36
+ if (typeof req.removeListener === 'function') {
37
+ req.removeListener('data', onData);
38
+ } else if (typeof req.off === 'function') {
39
+ req.off('data', onData);
40
+ }
41
+ if (typeof req.destroy === 'function') req.destroy();
42
+ const error = new Error('Payload too large');
43
+ error.statusCode = 413;
44
+ return reject(error);
45
+ }
46
+ body += buffer.toString('utf8');
47
+ }
48
+
49
+ req.on('data', onData);
17
50
  req.on('end', () => {
51
+ if (exceeded) return;
18
52
  try {
19
53
  resolve(body ? JSON.parse(body) : {});
20
54
  } catch (err) {
21
55
  reject(err);
22
56
  }
23
57
  });
24
- req.on('error', reject);
58
+ req.on('error', (err) => {
59
+ if (!exceeded) reject(err);
60
+ });
25
61
  });
26
62
  }
27
63
 
@@ -39,16 +75,24 @@ export function parseCookies(cookieHeader) {
39
75
  return cookies;
40
76
  }
41
77
 
78
+ export function isLoopbackAddress(address) {
79
+ if (!address || typeof address !== 'string') return false;
80
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.');
81
+ }
82
+
42
83
  export function isTrustedSettingsRequest(req) {
43
- const secFetch = req.headers['sec-fetch-site'];
84
+ const ip = req?.socket?.remoteAddress || '';
85
+ const isLoopback = isLoopbackAddress(ip);
86
+
87
+ const secFetch = req?.headers?.['sec-fetch-site'];
44
88
  // Fail-closed: unconditionally reject cross-site and cross-origin requests
45
89
  if (secFetch && secFetch !== 'same-origin') {
46
90
  return false;
47
91
  }
48
92
 
49
93
  // If origin is present, ensure it matches the request host
50
- const origin = req.headers['origin'];
51
- const host = req.headers['host'];
94
+ const origin = req?.headers?.['origin'];
95
+ const host = req?.headers?.['host'];
52
96
  if (origin && host) {
53
97
  try {
54
98
  const url = new URL(origin);
@@ -60,8 +104,8 @@ export function isTrustedSettingsRequest(req) {
60
104
  }
61
105
  }
62
106
 
63
- const rawAuth = req.headers['authorization'];
64
- const cookieHeader = req.headers['cookie'];
107
+ const rawAuth = req?.headers?.['authorization'];
108
+ const cookieHeader = req?.headers?.['cookie'];
65
109
  const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
66
110
 
67
111
  if (expectedToken) {
@@ -74,9 +118,53 @@ export function isTrustedSettingsRequest(req) {
74
118
  const token = cookies['token'] || cookies['dsh_token'];
75
119
  if (token && token === expectedToken) return true;
76
120
  }
121
+ return false;
77
122
  }
123
+
124
+ // Non-loopback requests without valid token must not pass simply on Sec-Fetch-Site: same-origin
125
+ if (!isLoopback) return false;
78
126
  if (secFetch === 'same-origin') return true;
79
- const ip = req.socket?.remoteAddress || '';
80
- if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
81
- return false;
127
+ return true;
128
+ }
129
+
130
+ export function getConnection(ctx) {
131
+ try {
132
+ return ctx?.reflect?.get ? ctx.reflect.get('connection') : (ctx?.get ? ctx.get('connection') : Reflect.get(ctx || {}, 'connection'));
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ export function authorizeRequest(sctx, req, res) {
139
+ let connection;
140
+ try {
141
+ connection = getConnection(sctx);
142
+ } catch {
143
+ writeJson(res, 503, { ok: false, error: 'DSH browser authentication is unavailable' });
144
+ return false;
145
+ }
146
+
147
+ if (!connection || typeof connection.requestRejection !== 'function') {
148
+ writeJson(res, 503, { ok: false, error: 'DSH browser authentication is unavailable' });
149
+ return false;
150
+ }
151
+
152
+ let rejection;
153
+ try {
154
+ rejection = connection.requestRejection(req);
155
+ } catch {
156
+ writeJson(res, 503, { ok: false, error: 'DSH browser authentication is unavailable' });
157
+ return false;
158
+ }
159
+
160
+ if (rejection !== undefined) {
161
+ const status = rejection === 401 ? 401 : 403;
162
+ writeJson(res, status, {
163
+ ok: false,
164
+ error: status === 401 ? 'DSH browser authentication is required' : 'Forbidden'
165
+ });
166
+ return false;
167
+ }
168
+
169
+ return true;
82
170
  }
@@ -1,5 +1,7 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
2
  import { SshConfigParser } from '../ssh-config-parser.js';
3
+ import { testProfileGroup } from '../profile-groups.js';
4
+ import { filterClusterProfiles, runCluster } from '../cluster-service.js';
3
5
 
4
6
  function maskProfile(p) {
5
7
  if (!p || typeof p !== 'object') return p;
@@ -17,17 +19,19 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
17
19
  path: '/dsh-remote-workspace/state',
18
20
  handler: async (req, res) => {
19
21
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
20
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
22
+ if (!authorizeRequest(sctx, req, res)) return;
21
23
  try {
22
24
  const profiles = store.getProfiles();
23
25
  const activeId = store.getActiveId() || (profiles[0]?.id || '');
24
26
  const tunnels = tunnelService.listActiveTunnels();
25
27
  const autoSync = store.isAutoSyncEnabled ? store.isAutoSyncEnabled() : false;
26
28
  const alerts = alertService ? alertService.getAlerts() : [];
29
+ const authPrompts = sshService.listKeyboardPrompts ? sshService.listKeyboardPrompts() : [];
27
30
  const sanitized = profiles.map((p) => (store.vault ? store.vault.sanitizeProfile(p) : maskProfile(p)));
28
- writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts });
31
+ const terminalFontFamily = store.getTerminalFont ? store.getTerminalFont() : '';
32
+ writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts, authPrompts, terminalFontFamily });
29
33
  } catch (err) {
30
- writeJson(res, 500, { ok: false, error: err.message });
34
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
31
35
  }
32
36
  }
33
37
  }), 'dsh-remote-workspace: /state');
@@ -38,7 +42,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
38
42
  path: '/dsh-remote-workspace/profiles/save',
39
43
  handler: async (req, res) => {
40
44
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
41
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
45
+ if (!authorizeRequest(sctx, req, res)) return;
42
46
  try {
43
47
  const profile = await readBody(req);
44
48
  if (!profile.id || !profile.host) {
@@ -49,7 +53,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
49
53
  const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : maskProfile(profile);
50
54
  writeJson(res, 200, { ok: true, profile: sanitized });
51
55
  } catch (err) {
52
- writeJson(res, 500, { ok: false, error: err.message });
56
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
53
57
  }
54
58
  }
55
59
  }), 'dsh-remote-workspace: /profiles/save');
@@ -60,7 +64,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
60
64
  path: '/dsh-remote-workspace/profiles/delete',
61
65
  handler: async (req, res) => {
62
66
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
63
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
67
+ if (!authorizeRequest(sctx, req, res)) return;
64
68
  try {
65
69
  const { id } = await readBody(req);
66
70
  if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
@@ -68,7 +72,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
68
72
  await store.deleteProfile(id);
69
73
  writeJson(res, 200, { ok: true });
70
74
  } catch (err) {
71
- writeJson(res, 500, { ok: false, error: err.message });
75
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
72
76
  }
73
77
  }
74
78
  }), 'dsh-remote-workspace: /profiles/delete');
@@ -79,14 +83,14 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
79
83
  path: '/dsh-remote-workspace/profiles/active',
80
84
  handler: async (req, res) => {
81
85
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
82
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
86
+ if (!authorizeRequest(sctx, req, res)) return;
83
87
  try {
84
88
  const { id } = await readBody(req);
85
89
  if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
86
90
  await store.setActiveId(id);
87
91
  writeJson(res, 200, { ok: true, activeId: id });
88
92
  } catch (err) {
89
- writeJson(res, 500, { ok: false, error: err.message });
93
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
90
94
  }
91
95
  }
92
96
  }), 'dsh-remote-workspace: /profiles/active');
@@ -97,30 +101,100 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
97
101
  path: '/dsh-remote-workspace/profiles/import-ssh-config',
98
102
  handler: async (req, res) => {
99
103
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
100
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
104
+ if (!authorizeRequest(sctx, req, res)) return;
101
105
  try {
102
- const { content } = await readBody(req);
103
- if (!content || typeof content !== 'string') {
104
- return writeJson(res, 400, { ok: false, error: 'SSH config content is required' });
105
- }
106
- const importedProfiles = SshConfigParser.parse(content);
107
- for (const p of importedProfiles) {
106
+ const body = await readBody(req);
107
+ const content = typeof body.content === 'string' && body.content.trim()
108
+ ? body.content
109
+ : SshConfigParser.readDefaultConfig();
110
+ if (!content) return writeJson(res, 400, { ok: false, error: 'SSH config content is required' });
111
+ const report = SshConfigParser.parseReport(content, { baseDir: body.baseDir });
112
+ for (const p of report.profiles) {
108
113
  await store.saveProfile(p);
109
114
  }
110
- writeJson(res, 200, { ok: true, count: importedProfiles.length, profiles: importedProfiles });
115
+ writeJson(res, 200, {
116
+ ok: true,
117
+ count: report.profiles.length,
118
+ profiles: report.profiles,
119
+ skipped: report.skipped
120
+ });
111
121
  } catch (err) {
112
- writeJson(res, 500, { ok: false, error: err.message });
122
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
113
123
  }
114
124
  }
115
125
  }), 'dsh-remote-workspace: /profiles/import-ssh-config');
116
126
 
127
+
128
+ sctx.effect(() => webServer.register({
129
+ kind: 'exact',
130
+ path: '/dsh-remote-workspace/auth/keyboard',
131
+ handler: async (req, res) => {
132
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
133
+ if (!authorizeRequest(sctx, req, res)) return;
134
+ try {
135
+ const body = await readBody(req);
136
+ const answers = Array.isArray(body.answers) ? body.answers.slice(0, 20).map((item) => String(item ?? '').slice(0, 256)) : [];
137
+ const accepted = sshService.submitKeyboardPrompt(body.id || '', answers);
138
+ if (!accepted) return writeJson(res, 404, { ok: false, error: 'No authentication prompt is waiting' });
139
+ writeJson(res, 200, { ok: true });
140
+ } catch (err) {
141
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
142
+ }
143
+ }
144
+ }), 'dsh-remote-workspace: /auth/keyboard');
145
+
146
+
147
+ sctx.effect(() => webServer.register({
148
+ kind: 'exact',
149
+ path: '/dsh-remote-workspace/profiles/test-group',
150
+ handler: async (req, res) => {
151
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
152
+ if (!authorizeRequest(sctx, req, res)) return;
153
+ try {
154
+ const body = await readBody(req);
155
+ const ids = Array.isArray(body.ids) ? body.ids.map((item) => String(item)) : [];
156
+ const selected = ids.map((id) => store.getProfile(id)).filter(Boolean);
157
+ const results = await testProfileGroup(selected, (profile) => sshService.testConnection(profile));
158
+ writeJson(res, 200, { ok: true, results });
159
+ } catch (err) {
160
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
161
+ }
162
+ }
163
+ }), 'dsh-remote-workspace: /profiles/test-group');
164
+
165
+
166
+ sctx.effect(() => webServer.register({
167
+ kind: 'exact',
168
+ path: '/dsh-remote-workspace/cluster',
169
+ handler: async (req, res) => {
170
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
171
+ if (!authorizeRequest(sctx, req, res)) return;
172
+ try {
173
+ const body = await readBody(req);
174
+ if (!body.command || typeof body.command !== 'string') return writeJson(res, 400, { ok: false, error: 'Command is required' });
175
+ const selected = filterClusterProfiles(store.getProfiles(), {
176
+ environment: body.environment,
177
+ tags: Array.isArray(body.tags) ? body.tags : [],
178
+ aliases: Array.isArray(body.aliases) ? body.aliases : []
179
+ });
180
+ const results = await runCluster(selected, body.command, {
181
+ maxWorkers: body.maxWorkers,
182
+ exec: (profile, command) => sshService.exec(profile, command, { timeout: 60, cleanAnsi: true })
183
+ });
184
+ writeJson(res, 200, { ok: true, results });
185
+ } catch (err) {
186
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
187
+ }
188
+ }
189
+ }), 'dsh-remote-workspace: /cluster');
190
+
117
191
  // 6. POST /dsh-remote-workspace/test
118
192
  sctx.effect(() => webServer.register({
119
193
  kind: 'exact',
120
194
  path: '/dsh-remote-workspace/test',
121
195
  handler: async (req, res) => {
122
196
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
123
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
197
+ if (!authorizeRequest(sctx, req, res)) return;
124
198
  try {
125
199
  const profile = await readBody(req);
126
200
  if (!profile.host) return writeJson(res, 400, { ok: false, error: 'Host is required' });
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
2
 
3
3
  export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService, watcherService, store }) {
4
4
  // 17. POST /dsh-remote-workspace/sync
@@ -7,7 +7,7 @@ export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService
7
7
  path: '/dsh-remote-workspace/sync',
8
8
  handler: async (req, res) => {
9
9
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
10
+ if (!authorizeRequest(sctx, req, res)) return;
11
11
  try {
12
12
  const { profileId, direction, force, mode } = await readBody(req);
13
13
  const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
@@ -28,7 +28,7 @@ export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService
28
28
  : await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath);
29
29
  writeJson(res, 200, { ok: resSync.success, ...resSync });
30
30
  } catch (err) {
31
- writeJson(res, 500, { ok: false, error: err.message });
31
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
32
32
  }
33
33
  }
34
34
  }), 'dsh-remote-workspace: /sync');
@@ -39,13 +39,13 @@ export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService
39
39
  path: '/dsh-remote-workspace/watcher/toggle',
40
40
  handler: async (req, res) => {
41
41
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
42
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
42
+ if (!authorizeRequest(sctx, req, res)) return;
43
43
  try {
44
44
  const { enabled } = await readBody(req);
45
45
  await store.setAutoSyncEnabled(Boolean(enabled));
46
46
  writeJson(res, 200, { ok: true, autoSync: store.isAutoSyncEnabled() });
47
47
  } catch (err) {
48
- writeJson(res, 500, { ok: false, error: err.message });
48
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
49
49
  }
50
50
  }
51
51
  }), 'dsh-remote-workspace: /watcher/toggle');
@@ -1,13 +1,32 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
+ import { normalizeTerminalFont } from '../terminal-font.js';
2
3
 
3
4
  export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
5
+
6
+ sctx.effect(() => webServer.register({
7
+ kind: 'exact',
8
+ path: '/dsh-remote-workspace/terminal/font',
9
+ handler: async (req, res) => {
10
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
11
+ if (!authorizeRequest(sctx, req, res)) return;
12
+ try {
13
+ const body = await readBody(req);
14
+ const fontFamily = await store.setTerminalFont(body.fontFamily);
15
+ writeJson(res, 200, { ok: true, terminalFontFamily: fontFamily });
16
+ } catch (err) {
17
+ const status = err.code === 'INVALID_FONT' ? 400 : 500;
18
+ writeJson(res, status, { ok: false, error: err.message });
19
+ }
20
+ }
21
+ }), 'dsh-remote-workspace: /terminal/font');
22
+
4
23
  // 13. POST /dsh-remote-workspace/terminal/create
5
24
  sctx.effect(() => webServer.register({
6
25
  kind: 'exact',
7
26
  path: '/dsh-remote-workspace/terminal/create',
8
27
  handler: async (req, res) => {
9
28
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
29
+ if (!authorizeRequest(sctx, req, res)) return;
11
30
  try {
12
31
  const { profileId, cols, rows } = await readBody(req);
13
32
  const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
@@ -15,7 +34,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
15
34
  const session = await sshService.createTerminalSession(profile, { cols: cols || 80, rows: rows || 24 });
16
35
  writeJson(res, 200, { ok: true, sessionId: session.id });
17
36
  } catch (err) {
18
- writeJson(res, 500, { ok: false, error: err.message });
37
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
19
38
  }
20
39
  }
21
40
  }), 'dsh-remote-workspace: /terminal/create');
@@ -26,7 +45,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
26
45
  path: '/dsh-remote-workspace/terminal/input',
27
46
  handler: async (req, res) => {
28
47
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
29
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
48
+ if (!authorizeRequest(sctx, req, res)) return;
30
49
  try {
31
50
  const { sessionId, data, resize } = await readBody(req);
32
51
  const session = sshService.getTerminalSession(sessionId);
@@ -39,7 +58,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
39
58
  }
40
59
  writeJson(res, 200, { ok: true });
41
60
  } catch (err) {
42
- writeJson(res, 500, { ok: false, error: err.message });
61
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
43
62
  }
44
63
  }
45
64
  }), 'dsh-remote-workspace: /terminal/input');
@@ -50,7 +69,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
50
69
  path: '/dsh-remote-workspace/terminal/stream',
51
70
  handler: async (req, res) => {
52
71
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
53
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
72
+ if (!authorizeRequest(sctx, req, res)) return;
54
73
  try {
55
74
  const url = new URL(req.url, 'http://localhost');
56
75
  const sessionId = url.searchParams.get('sessionId');
@@ -80,7 +99,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
80
99
  session.subscribers.delete(sub);
81
100
  });
82
101
  } catch (err) {
83
- writeJson(res, 500, { ok: false, error: err.message });
102
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
84
103
  }
85
104
  }
86
105
  }), 'dsh-remote-workspace: /terminal/stream');
@@ -91,13 +110,13 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
91
110
  path: '/dsh-remote-workspace/terminal/close',
92
111
  handler: async (req, res) => {
93
112
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
94
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
113
+ if (!authorizeRequest(sctx, req, res)) return;
95
114
  try {
96
115
  const { sessionId } = await readBody(req);
97
116
  sshService.closeTerminalSession(sessionId);
98
117
  writeJson(res, 200, { ok: true });
99
118
  } catch (err) {
100
- writeJson(res, 500, { ok: false, error: err.message });
119
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
101
120
  }
102
121
  }
103
122
  }), 'dsh-remote-workspace: /terminal/close');