@andrian.yablonskyy/thub-coordinator 1.0.39 → 1.0.41

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 (45) hide show
  1. package/bin/thub-admin.js +23 -0
  2. package/config.json +4 -0
  3. package/package.json +2 -2
  4. package/public/js/actions.js +58 -0
  5. package/public/js/settings.js +66 -0
  6. package/src/api/agent.js +27 -15
  7. package/src/api/resource.js +5 -3
  8. package/src/config.js +17 -1
  9. package/src/db/migrations/024_add_settings.sql +10 -0
  10. package/src/db/migrations/025_add_job_artifacts.sql +4 -0
  11. package/src/dev/virtual.js +9 -5
  12. package/src/login-guard.js +111 -0
  13. package/src/rate-limit.js +137 -0
  14. package/src/security.js +106 -0
  15. package/src/server.js +17 -3
  16. package/src/services/admin-users.js +32 -19
  17. package/src/services/heartbeat.js +23 -3
  18. package/src/services/job-artifacts.js +91 -0
  19. package/src/services/jobs.js +13 -2
  20. package/src/services/registry.js +34 -0
  21. package/src/services/settings.js +345 -0
  22. package/src/web/routes.js +157 -10
  23. package/test/admin-users.test.js +16 -16
  24. package/test/agent-visibility.test.js +85 -0
  25. package/test/groups-tab.test.js +97 -0
  26. package/test/job-artifacts.test.js +115 -0
  27. package/test/login-guard.test.js +139 -0
  28. package/test/logs.test.js +1 -1
  29. package/test/rate-limit.test.js +106 -0
  30. package/test/security.test.js +108 -0
  31. package/test/settings.test.js +206 -0
  32. package/views/admin/settings.pug +133 -0
  33. package/views/groups/list.pug +1 -1
  34. package/views/help/_agent-cli.pug +1 -1
  35. package/views/help/_agent-setup.pug +4 -1
  36. package/views/help/_client-setup.pug +1 -1
  37. package/views/help/_coordinator.pug +9 -1
  38. package/views/help/_env.pug +1 -0
  39. package/views/help/_troubleshooting.pug +3 -1
  40. package/views/jobs/list.pug +3 -3
  41. package/views/jobs/show.pug +36 -0
  42. package/views/layout.pug +4 -1
  43. package/views/mixins/client-config.pug +65 -3
  44. package/views/mixins/list-controls.pug +1 -1
  45. package/views/mixins/resource.pug +1 -8
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @file packages/coordinator/src/security.js
3
+ * @description HTTP security headers for every response, and the same-origin check on the dashboard's state-changing requests (README §12)
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ // Everything the dashboard loads: its own files, plus Bootstrap and its
17
+ // icon font from jsDelivr (layout.pug). No inline script at all — the
18
+ // templates use data-* attributes (public/js/actions.js) — while inline
19
+ // style="" attributes, used throughout the templates, stay allowed.
20
+ const CDN = 'https://cdn.jsdelivr.net',
21
+ CONTENT_SECURITY_POLICY = [
22
+ 'default-src \'self\'',
23
+ `script-src 'self' ${CDN}`,
24
+ `style-src 'self' ${CDN} 'unsafe-inline'`,
25
+ `font-src 'self' ${CDN}`,
26
+ // data: — Bootstrap's CSS draws some icons (select arrow, close button)
27
+ // as inline SVG images.
28
+ 'img-src \'self\' data:',
29
+ // The CDN too: browser DevTools fetch Bootstrap's source maps (*.map)
30
+ // from it — a connection, not a script or style.
31
+ `connect-src 'self' ${CDN}`,
32
+ 'object-src \'none\'',
33
+ 'base-uri \'self\'',
34
+ 'form-action \'self\'',
35
+ 'frame-ancestors \'none\''
36
+ ].join('; '),
37
+ // 180 days, this host only: HTTPS is how a TLS-fronted Coordinator is
38
+ // reached; other hosts under the same domain are none of its business.
39
+ HSTS = 'max-age=15552000',
40
+ UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
41
+
42
+ // Sent on every response (dashboard, API, static files).
43
+ function securityHeaders(req, res, next){
44
+ res.set({
45
+ 'Content-Security-Policy': CONTENT_SECURITY_POLICY,
46
+ // Also frame-ancestors above; this one for browsers that predate it.
47
+ 'X-Frame-Options': 'DENY',
48
+ 'X-Content-Type-Options': 'nosniff',
49
+ // Full URLs (job ids, searches) only within the dashboard itself.
50
+ 'Referrer-Policy': 'same-origin',
51
+ 'Cross-Origin-Opener-Policy': 'same-origin'
52
+ });
53
+ // Only on HTTPS (behind a TLS proxy: X-Forwarded-Proto from a trusted
54
+ // proxy, trustProxy) — over plain HTTP a browser ignores it anyway.
55
+ if (req.secure){
56
+ res.set('Strict-Transport-Security', HSTS);
57
+ }
58
+ next();
59
+ }
60
+
61
+ function originOf(url){
62
+ try {
63
+ return new URL(url).origin;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ // CSRF defence for the session-authenticated routes (the dashboard, and
71
+ // /api/v1/admin), on top of the session cookie's SameSite=Lax: a request
72
+ // that changes something must come from this dashboard's own pages. The
73
+ // browser says where from in Origin (every modern browser sends it on a
74
+ // POST), else Referer; a request carrying neither isn't from a browser page
75
+ // (curl, a script) and couldn't have been forged by one, so it passes. The
76
+ // Agent/Client APIs use bearer tokens, which a browser never attaches by
77
+ // itself, and don't go through here.
78
+ function sameOriginOnly(config){
79
+ return (req, res, next) => {
80
+ if (!UNSAFE_METHODS.has(req.method)){
81
+ return next();
82
+ }
83
+ const origin = req.get('origin'),
84
+ from = origin !== undefined ? origin : req.get('referer') !== undefined ? originOf(req.get('referer')) : undefined;
85
+ if (from === undefined){
86
+ return next();
87
+ }
88
+ // The address the browser used (Host, as the reverse proxy passes it),
89
+ // or the configured publicUrl (a proxy that rewrites Host) — read on
90
+ // every request, since the dashboard can change it (§13.2).
91
+ const configured = originOf(config.publicUrl),
92
+ allowed = [configured, `${req.protocol}://${req.get('host')}`];
93
+ if (from && allowed.includes(from)){
94
+ return next();
95
+ }
96
+ const text = `Blocked: this request came from ${from || 'an unknown page'}, not from this dashboard (${configured}). ` +
97
+ 'Changes can only be made from the dashboard\'s own pages. If you opened the dashboard at a different address, ' +
98
+ 'set publicUrl to that address.';
99
+ if (req.originalUrl.startsWith('/api/')){ // req.path is relative to the router's mount point
100
+ return res.status(403).json({ error: text });
101
+ }
102
+ res.status(403).type('text/plain').send(text);
103
+ };
104
+ }
105
+
106
+ module.exports = { securityHeaders, sameOriginOnly, CONTENT_SECURITY_POLICY };
package/src/server.js CHANGED
@@ -39,6 +39,9 @@ const fs = require('node:fs'),
39
39
  { createUpdatesService } = require('./services/updates'),
40
40
  { createLiveService } = require('./services/live'),
41
41
  { SqliteSessionStore } = require('./services/session-store'),
42
+ { securityHeaders, sameOriginOnly } = require('./security'),
43
+ { createSettingsService } = require('./services/settings'),
44
+ { createRateLimiter } = require('./rate-limit'),
42
45
 
43
46
  { createAgentRouter } = require('./api/agent'),
44
47
  { createResourceRouter } = require('./api/resource'),
@@ -58,6 +61,9 @@ function removeLegacyArtifacts(config){
58
61
  function buildServices(config){
59
62
  removeLegacyArtifacts(config);
60
63
  const db = openDb(config.dbPath),
64
+ // First: dashboard settings (README §13.2) go into `config` before any
65
+ // service reads it — some read theirs only once, at creation.
66
+ settings = createSettingsService(db, { config }).applyAtStartup(),
61
67
  events = createEventsService(db),
62
68
  registry = createRegistryService(db, { bus, events }),
63
69
  agents = createAgentsService(db, { events }),
@@ -101,7 +107,8 @@ function buildServices(config){
101
107
  cleanup,
102
108
  updates,
103
109
  live,
104
- sessionStore
110
+ sessionStore,
111
+ settings
105
112
  };
106
113
  }
107
114
 
@@ -130,6 +137,9 @@ function createApp(config, services){
130
137
  app.set('view engine', 'pug');
131
138
  app.set('views', path.join(__dirname, '..', 'views'));
132
139
  app.set('trust proxy', config.trustProxy);
140
+ app.disable('x-powered-by');
141
+ // CSP, framing, nosniff, Referrer-Policy, HSTS on HTTPS (security.js, §12).
142
+ app.use(securityHeaders);
133
143
  app.locals.services = services;
134
144
  // Shown in every dashboard page's footer (layout.pug).
135
145
  app.locals.coordinatorVersion = require('../package.json').version;
@@ -174,12 +184,16 @@ function createApp(config, services){
174
184
  });
175
185
  app.use(express.static(path.join(__dirname, '..', 'public')));
176
186
  app.use('/avatars', express.static(config.avatarsDir));
187
+ // Everything below (API, dashboard, sign-in) — static files above aren't
188
+ // counted. Per credential, else per IP (rate-limit.js, §12).
189
+ app.use(createRateLimiter(config, services));
177
190
 
178
191
  app.use('/api/v1', createAgentRouter({ services, config }));
179
192
  app.use('/api/v1', createResourceRouter({ services, config }));
180
- app.use('/api/v1/admin', createAdminRouter({ services }));
193
+ // Session-authenticated, like the dashboard: same-origin requests only.
194
+ app.use('/api/v1/admin', sameOriginOnly(config), createAdminRouter({ services }));
181
195
 
182
- app.use('/', createWebRouter({ services, config }));
196
+ app.use('/', sameOriginOnly(config), createWebRouter({ services, config }));
183
197
 
184
198
  app.use((err, req, res, next) => {
185
199
  const status = err.status || 500;
@@ -14,25 +14,42 @@
14
14
  'use strict';
15
15
 
16
16
  const crypto = require('node:crypto'),
17
+ { promisify } = require('node:util'),
17
18
  { v4: uuid } = require('uuid'),
18
19
  { normalize: normalizeListPrefs } = require('./list-prefs');
19
20
 
21
+ // Stored as "<salt hex>:<scrypt hex>". The synchronous one is only for the
22
+ // CLI and startup (thub-admin create-admin, the bootstrap password); every
23
+ // request uses the async versions, which run scrypt on libuv's thread pool —
24
+ // scryptSync would stall every request for each sign-in attempt.
25
+ const scrypt = promisify(crypto.scrypt);
26
+
20
27
  function hashPassword(password){
21
28
  const salt = crypto.randomBytes(16).toString('hex'),
22
29
  derived = crypto.scryptSync(password, salt, 64).toString('hex');
23
30
  return `${salt}:${derived}`;
24
31
  }
25
32
 
26
- function verifyPassword(password, stored){
33
+ async function hashPasswordAsync(password){
34
+ const salt = crypto.randomBytes(16).toString('hex');
35
+ return `${salt}:${(await scrypt(password, salt, 64)).toString('hex')}`;
36
+ }
37
+
38
+ async function verifyPassword(password, stored){
27
39
  const [salt, derived] = stored.split(':'),
28
- check = crypto.scryptSync(password, salt, 64).toString('hex');
29
- return crypto.timingSafeEqual(Buffer.from(check, 'hex'), Buffer.from(derived, 'hex'));
40
+ check = await scrypt(String(password ?? ''), salt, 64);
41
+ return crypto.timingSafeEqual(check, Buffer.from(derived, 'hex'));
30
42
  }
31
43
 
32
- // Presets offered on the profile page (§10.1) — a fixed list rather than a
33
- // free-text field, so a user can't accidentally set a 3-second or 10-year
34
- // idle timeout.
35
- const SESSION_TIMEOUT_OPTIONS_MIN = [15, 30, 60, 120, 240, 480, 1440],
44
+ // Checked instead of a real hash when the username doesn't exist, so that
45
+ // answer takes as long as a wrong password and doesn't reveal which
46
+ // usernames exist.
47
+ const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex')),
48
+
49
+ // Presets offered on the profile page (§10.1) — a fixed list rather than a
50
+ // free-text field, so a user can't accidentally set a 3-second or 10-year
51
+ // idle timeout.
52
+ SESSION_TIMEOUT_OPTIONS_MIN = [15, 30, 60, 120, 240, 480, 1440],
36
53
  THEMES = ['auto', 'light', 'dark'];
37
54
 
38
55
  function toProfile(row){
@@ -105,26 +122,22 @@ function createAdminUsersService(db){
105
122
  // Self-service password change (§10.1) — requires knowing the *current*
106
123
  // password, unlike resetPassword() above (that's the admin/operator
107
124
  // break-glass path, which deliberately doesn't need it).
108
- function changePassword(id, currentPassword, newPassword){
125
+ async function changePassword(id, currentPassword, newPassword){
109
126
  const row = db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
110
- if (!row || !verifyPassword(currentPassword, row.password_hash)){
127
+ if (!row || !(await verifyPassword(currentPassword, row.password_hash))){
111
128
  throw Object.assign(new Error('Current password is incorrect'), { status: 401 });
112
129
  }
113
130
  if (!newPassword){
114
131
  throw Object.assign(new Error('New password must not be empty'), { status: 400 });
115
132
  }
116
- db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), id);
133
+ db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(await hashPasswordAsync(newPassword), id);
117
134
  }
118
135
 
119
- function verify(username, password){
120
- const user = getByUsername(username);
121
- if (!user){
122
- return null;
123
- }
124
- if (!verifyPassword(password, user.password_hash)){
125
- return null;
126
- }
127
- return toProfile(user);
136
+ // Async (thread pool); the same work whether or not the user exists.
137
+ async function verify(username, password){
138
+ const user = getByUsername(String(username ?? '')),
139
+ ok = await verifyPassword(password, user ? user.password_hash : DUMMY_HASH);
140
+ return user && ok ? toProfile(user) : null;
128
141
  }
129
142
 
130
143
  // Partial update — only the fields present in `fields` are touched, so a
@@ -18,9 +18,29 @@ const { RESOURCE_STATES, ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub
18
18
  // §5.1 sweeper: runs every sweepIntervalSec, marks resources OUT_OF_SERVICE
19
19
  // after missedLimit missed heartbeats and moves any active job to LOST.
20
20
  function createHeartbeatMonitor(db, { bus, events, registry, jobs, config }){
21
- function sweepOnce(){
22
- const { intervalSec, missedLimit } = config.heartbeat,
23
- cutoff = new Date(Date.now() - missedLimit * intervalSec * 1000).toISOString(),
21
+ // heartbeat.intervalSec can change while running (dashboard Settings,
22
+ // §13.2). Clients learn the new one from their next heartbeat reply, so
23
+ // right after it's *lowered* they're still on the old, longer one: keep
24
+ // judging by that until every Client has had time to switch over.
25
+ let seenInterval = config.heartbeat.intervalSec,
26
+ graceInterval = 0,
27
+ graceUntil = 0;
28
+ function effectiveInterval(now){
29
+ const current = config.heartbeat.intervalSec;
30
+ if (current !== seenInterval){
31
+ if (current < seenInterval){
32
+ graceInterval = seenInterval;
33
+ graceUntil = now + (config.heartbeat.missedLimit + 1) * seenInterval * 1000;
34
+ }
35
+ seenInterval = current;
36
+ }
37
+ return now < graceUntil ? Math.max(current, graceInterval) : current;
38
+ }
39
+
40
+ function sweepOnce(now = Date.now()){
41
+ const { missedLimit } = config.heartbeat,
42
+ intervalSec = effectiveInterval(now),
43
+ cutoff = new Date(now - missedLimit * intervalSec * 1000).toISOString(),
24
44
 
25
45
  stale = db
26
46
  .prepare(
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/job-artifacts.js
3
+ * @description Artifacts a finished job reports (metadata only): validation, normalization and size formatting
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ // README §7.3. The job's command publishes its files wherever it likes and
17
+ // lists them in $THUB_ARTIFACTS_FILE; the Client sends that list with the
18
+ // result, and the job keeps only this metadata:
19
+ // { name, size, link, timestamp }
20
+ // It comes from the job's own command, so it's treated as untrusted: only
21
+ // http(s) links (a `javascript:` one would run in the dashboard when
22
+ // clicked), bounded sizes, and entries that don't fit are dropped, not fatal.
23
+ const MAX_ARTIFACTS = 500,
24
+ MAX_NAME = 255,
25
+ MAX_LINK = 2048;
26
+
27
+ // Unix seconds, Unix milliseconds or an ISO date -> ISO string (or null).
28
+ function normalizeTimestamp(value){
29
+ let ms = NaN;
30
+ if (typeof value === 'number' && Number.isFinite(value)){
31
+ ms = value < 1e11 ? value * 1000 : value; // 1e11 s is the year 5138: smaller means seconds
32
+ }
33
+ else if (typeof value === 'string' && value.trim()){
34
+ ms = /^\d+(\.\d+)?$/.test(value.trim()) ? normalizeTimestampMs(Number(value)) : Date.parse(value);
35
+ }
36
+ return Number.isFinite(ms) && ms >= 0 && ms < 8.64e15 ? new Date(ms).toISOString() : null;
37
+ }
38
+
39
+ function normalizeTimestampMs(n){
40
+ return n < 1e11 ? n * 1000 : n;
41
+ }
42
+
43
+ function normalizeLink(value){
44
+ if (typeof value !== 'string' || value.length > MAX_LINK){
45
+ return null;
46
+ }
47
+ try {
48
+ const url = new URL(value.trim());
49
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // -> { artifacts: [...valid, normalized], dropped: <count> }
57
+ function sanitizeArtifacts(raw){
58
+ if (!Array.isArray(raw)){
59
+ return { artifacts: [], dropped: raw === undefined || raw === null ? 0 : 1 };
60
+ }
61
+ const artifacts = [];
62
+ let dropped = Math.max(0, raw.length - MAX_ARTIFACTS);
63
+ for (const item of raw.slice(0, MAX_ARTIFACTS)){
64
+ const name = typeof item?.name === 'string' ? item.name.trim().slice(0, MAX_NAME) : '',
65
+ link = normalizeLink(item?.link),
66
+ size = Number.isSafeInteger(item?.size) && item.size >= 0 ? item.size : null;
67
+ if (!name || !link){
68
+ dropped += 1;
69
+ continue;
70
+ }
71
+ artifacts.push({ name, size, link, timestamp: normalizeTimestamp(item.timestamp) });
72
+ }
73
+ return { artifacts, dropped };
74
+ }
75
+
76
+ // 1233 -> "1.2 KB" (1024-based, as file managers show it).
77
+ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
78
+ function formatBytes(bytes){
79
+ if (!Number.isFinite(bytes) || bytes < 0){
80
+ return '—';
81
+ }
82
+ let value = bytes,
83
+ unit = 0;
84
+ while (value >= 1024 && unit < UNITS.length - 1){
85
+ value /= 1024;
86
+ unit += 1;
87
+ }
88
+ return unit === 0 ? `${value} B` : `${value < 10 ? value.toFixed(1) : Math.round(value)} ${UNITS[unit]}`;
89
+ }
90
+
91
+ module.exports = { sanitizeArtifacts, normalizeTimestamp, formatBytes, MAX_ARTIFACTS };
@@ -16,6 +16,7 @@
16
16
  const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
17
17
  { paginate } = require('./list-prefs'),
18
18
  { parseTerms, likeClause } = require('./search'),
19
+ { sanitizeArtifacts } = require('./job-artifacts'),
19
20
  // Named in spec errors: a field the Agent sends but this thub-common
20
21
  // doesn't know means the Coordinator needs an update.
21
22
  VERSIONS = `Coordinator v${require('../../package.json').version}, ` +
@@ -28,7 +29,8 @@ function rowToJob(row){
28
29
  return {
29
30
  ...row,
30
31
  spec: JSON.parse(row.spec),
31
- summary: row.summary ? JSON.parse(row.summary) : null
32
+ summary: row.summary ? JSON.parse(row.summary) : null,
33
+ artifacts: row.artifacts ? JSON.parse(row.artifacts) : []
32
34
  };
33
35
  }
34
36
 
@@ -365,7 +367,9 @@ function createJobsService(db, { bus, events, registry, config }){
365
367
  }
366
368
 
367
369
  // Resource: POST /jobs/:id/result — final verdict from the test runner.
368
- function applyResult(id, resourceId, { state, exitCode, summary }){
370
+ // `artifacts`: what the job published elsewhere (job-artifacts.js) — kept
371
+ // as metadata, stored before the job turns final so its page shows them.
372
+ function applyResult(id, resourceId, { state, exitCode, summary, artifacts }){
369
373
  const job = get(id);
370
374
  if (!job || job.resource_id !== resourceId){
371
375
  throw Object.assign(new Error('Job not assigned to this resource'), { status: 403 });
@@ -373,6 +377,13 @@ function createJobsService(db, { bus, events, registry, config }){
373
377
  if (![JOB_STATES.PASSED, JOB_STATES.FAILED, JOB_STATES.ERROR].includes(state)){
374
378
  throw Object.assign(new Error('Invalid result state'), { status: 400 });
375
379
  }
380
+ if (artifacts !== undefined){
381
+ const { artifacts: list, dropped } = sanitizeArtifacts(artifacts);
382
+ db.prepare('UPDATE jobs SET artifacts = ? WHERE id = ?').run(list.length ? JSON.stringify(list) : null, id);
383
+ if (dropped){
384
+ events.record('job', id, 'job.artifacts_dropped', { dropped });
385
+ }
386
+ }
376
387
  return setState(id, state, { exitCode, summary });
377
388
  }
378
389
 
@@ -696,6 +696,39 @@ function createRegistryService(db, { bus, events }){
696
696
  return { resource: get(resourceId), ignored };
697
697
  }
698
698
 
699
+ // Groups tab (resource card): the Client's group membership. It belongs to
700
+ // the Client's config file (`groups`, which every registration reports),
701
+ // so it goes out as the next config revision — like an Import's top-level
702
+ // fields — for the Client to write there and restart with. The Coordinator
703
+ // applies it at once too, so scheduling follows without waiting for that.
704
+ // Fields of an Import not applied yet are carried along, not lost.
705
+ function setClientGroups(resourceId, groupIds, { by, knownGroupIds } = {}){
706
+ const r = get(resourceId);
707
+ if (!r){
708
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
709
+ }
710
+ const ids = [...new Set(groupIds)],
711
+ unknown = knownGroupIds ? ids.filter((id) => !knownGroupIds.includes(id) && !r.group_ids.includes(id)) : [];
712
+ if (unknown.length){
713
+ throw Object.assign(new Error(`Unknown group: ${unknown.join(', ')}`), { status: 400 });
714
+ }
715
+ // Writing top-level fields needs a Client that reports its config file
716
+ // (the same that Import needs); an older one would drop them.
717
+ const desired = r.config_desired || r.client_config;
718
+ if (!desired || !r.client_config_file){
719
+ throw Object.assign(new Error(`${r.name} is too old to take its groups from the dashboard — update its Client first`), { status: 409 });
720
+ }
721
+ const revision = r.config_revision + 1,
722
+ fields = { ...(pendingImport(r) || {}), groups: ids };
723
+ db.transaction(() => {
724
+ db.prepare(`UPDATE resources SET config_desired = ?, config_revision = ?, config_error = NULL, config_import = ?
725
+ WHERE id = ?`).run(JSON.stringify(desired), revision, JSON.stringify({ revision, fields }), resourceId);
726
+ setGroups(resourceId, ids);
727
+ })();
728
+ events.record('resource', resourceId, 'resource.groups_set', { by, revision, groups: ids });
729
+ return get(resourceId);
730
+ }
731
+
699
732
  // Export (resource card): the Client's config file as it last reported it
700
733
  // (secrets excluded), with what's saved for it but not applied yet — so
701
734
  // exporting right after an edit or import gives what the Client will run.
@@ -796,6 +829,7 @@ function createRegistryService(db, { bus, events }){
796
829
  setRebootSchedule,
797
830
  pendingRebootSchedule,
798
831
  setClientConfig,
832
+ setClientGroups,
799
833
  pendingConfig,
800
834
  importClientConfig,
801
835
  exportClientConfig,