@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.40

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 (64) hide show
  1. package/bin/thub-admin.js +26 -5
  2. package/config.json +5 -6
  3. package/package.json +2 -2
  4. package/public/js/actions.js +58 -0
  5. package/public/js/log-viewer.js +188 -56
  6. package/public/js/settings.js +66 -0
  7. package/scripts/install-default-config.js +1 -1
  8. package/scripts/install-target.js +2 -2
  9. package/src/api/admin.js +1 -1
  10. package/src/api/agent.js +28 -22
  11. package/src/api/resource.js +11 -14
  12. package/src/api/sse.js +13 -10
  13. package/src/config.js +33 -10
  14. package/src/db/migrations/022_add_sessions.sql +10 -0
  15. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  16. package/src/db/migrations/024_add_settings.sql +10 -0
  17. package/src/db/migrations/025_add_job_artifacts.sql +4 -0
  18. package/src/dev/virtual.js +10 -6
  19. package/src/login-guard.js +111 -0
  20. package/src/rate-limit.js +137 -0
  21. package/src/security.js +104 -0
  22. package/src/server.js +53 -23
  23. package/src/services/admin-users.js +32 -19
  24. package/src/services/cleanup.js +2 -2
  25. package/src/services/heartbeat.js +23 -3
  26. package/src/services/job-artifacts.js +91 -0
  27. package/src/services/jobs.js +17 -9
  28. package/src/services/logs.js +12 -14
  29. package/src/services/registry.js +34 -0
  30. package/src/services/retention.js +5 -14
  31. package/src/services/session-store.js +127 -0
  32. package/src/services/settings.js +345 -0
  33. package/src/web/routes.js +230 -21
  34. package/test/admin-users.test.js +16 -16
  35. package/test/agent-visibility.test.js +85 -0
  36. package/test/cleanup.test.js +1 -5
  37. package/test/groups-tab.test.js +97 -0
  38. package/test/job-artifacts.test.js +115 -0
  39. package/test/login-guard.test.js +139 -0
  40. package/test/logs.test.js +150 -0
  41. package/test/rate-limit.test.js +106 -0
  42. package/test/scheduler.test.js +10 -12
  43. package/test/security.test.js +107 -0
  44. package/test/session.test.js +132 -0
  45. package/test/settings.test.js +206 -0
  46. package/views/admin/settings.pug +133 -0
  47. package/views/groups/list.pug +1 -1
  48. package/views/help/_agent-cli.pug +2 -2
  49. package/views/help/_agent-setup.pug +4 -1
  50. package/views/help/_client-setup.pug +1 -1
  51. package/views/help/_coordinator.pug +18 -7
  52. package/views/help/_docker.pug +1 -1
  53. package/views/help/_env.pug +2 -1
  54. package/views/help/_git.pug +3 -2
  55. package/views/help/_overview.pug +2 -2
  56. package/views/help/_troubleshooting.pug +4 -0
  57. package/views/jobs/list.pug +5 -5
  58. package/views/jobs/show.pug +33 -7
  59. package/views/layout.pug +4 -1
  60. package/views/mixins/client-config.pug +65 -3
  61. package/views/mixins/list-controls.pug +1 -1
  62. package/views/mixins/log-viewer.pug +14 -0
  63. package/views/mixins/resource.pug +1 -8
  64. package/src/services/artifacts.js +0 -149
@@ -0,0 +1,104 @@
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
+ 'connect-src \'self\'',
30
+ 'object-src \'none\'',
31
+ 'base-uri \'self\'',
32
+ 'form-action \'self\'',
33
+ 'frame-ancestors \'none\''
34
+ ].join('; '),
35
+ // 180 days, this host only: HTTPS is how a TLS-fronted Coordinator is
36
+ // reached; other hosts under the same domain are none of its business.
37
+ HSTS = 'max-age=15552000',
38
+ UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
39
+
40
+ // Sent on every response (dashboard, API, static files).
41
+ function securityHeaders(req, res, next){
42
+ res.set({
43
+ 'Content-Security-Policy': CONTENT_SECURITY_POLICY,
44
+ // Also frame-ancestors above; this one for browsers that predate it.
45
+ 'X-Frame-Options': 'DENY',
46
+ 'X-Content-Type-Options': 'nosniff',
47
+ // Full URLs (job ids, searches) only within the dashboard itself.
48
+ 'Referrer-Policy': 'same-origin',
49
+ 'Cross-Origin-Opener-Policy': 'same-origin'
50
+ });
51
+ // Only on HTTPS (behind a TLS proxy: X-Forwarded-Proto from a trusted
52
+ // proxy, trustProxy) — over plain HTTP a browser ignores it anyway.
53
+ if (req.secure){
54
+ res.set('Strict-Transport-Security', HSTS);
55
+ }
56
+ next();
57
+ }
58
+
59
+ function originOf(url){
60
+ try {
61
+ return new URL(url).origin;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ // CSRF defence for the session-authenticated routes (the dashboard, and
69
+ // /api/v1/admin), on top of the session cookie's SameSite=Lax: a request
70
+ // that changes something must come from this dashboard's own pages. The
71
+ // browser says where from in Origin (every modern browser sends it on a
72
+ // POST), else Referer; a request carrying neither isn't from a browser page
73
+ // (curl, a script) and couldn't have been forged by one, so it passes. The
74
+ // Agent/Client APIs use bearer tokens, which a browser never attaches by
75
+ // itself, and don't go through here.
76
+ function sameOriginOnly(config){
77
+ return (req, res, next) => {
78
+ if (!UNSAFE_METHODS.has(req.method)){
79
+ return next();
80
+ }
81
+ const origin = req.get('origin'),
82
+ from = origin !== undefined ? origin : req.get('referer') !== undefined ? originOf(req.get('referer')) : undefined;
83
+ if (from === undefined){
84
+ return next();
85
+ }
86
+ // The address the browser used (Host, as the reverse proxy passes it),
87
+ // or the configured publicUrl (a proxy that rewrites Host) — read on
88
+ // every request, since the dashboard can change it (§13.2).
89
+ const configured = originOf(config.publicUrl),
90
+ allowed = [configured, `${req.protocol}://${req.get('host')}`];
91
+ if (from && allowed.includes(from)){
92
+ return next();
93
+ }
94
+ const text = `Blocked: this request came from ${from || 'an unknown page'}, not from this dashboard (${configured}). ` +
95
+ 'Changes can only be made from the dashboard\'s own pages. If you opened the dashboard at a different address, ' +
96
+ 'set publicUrl to that address.';
97
+ if (req.originalUrl.startsWith('/api/')){ // req.path is relative to the router's mount point
98
+ return res.status(403).json({ error: text });
99
+ }
100
+ res.status(403).type('text/plain').send(text);
101
+ };
102
+ }
103
+
104
+ module.exports = { securityHeaders, sameOriginOnly, CONTENT_SECURITY_POLICY };
package/src/server.js CHANGED
@@ -15,7 +15,8 @@
15
15
 
16
16
  'use strict';
17
17
 
18
- const path = require('node:path'),
18
+ const fs = require('node:fs'),
19
+ path = require('node:path'),
19
20
  express = require('express'),
20
21
  session = require('express-session'),
21
22
 
@@ -31,21 +32,38 @@ const path = require('node:path'),
31
32
  { createScheduler } = require('./services/scheduler'),
32
33
  { createHeartbeatMonitor } = require('./services/heartbeat'),
33
34
  { createLogsService } = require('./services/logs'),
34
- { createArtifactsService } = require('./services/artifacts'),
35
35
  { createCommandsService } = require('./services/commands'),
36
36
  { createRetentionService } = require('./services/retention'),
37
37
  { createCleanupService } = require('./services/cleanup'),
38
38
  { startDevMode } = require('./dev/virtual'),
39
39
  { createUpdatesService } = require('./services/updates'),
40
40
  { createLiveService } = require('./services/live'),
41
+ { SqliteSessionStore } = require('./services/session-store'),
42
+ { securityHeaders, sameOriginOnly } = require('./security'),
43
+ { createSettingsService } = require('./services/settings'),
44
+ { createRateLimiter } = require('./rate-limit'),
41
45
 
42
46
  { createAgentRouter } = require('./api/agent'),
43
47
  { createResourceRouter } = require('./api/resource'),
44
48
  { createAdminRouter } = require('./api/admin'),
45
49
  { createWebRouter } = require('./web/routes');
46
50
 
51
+ // Artifacts are no longer stored on the Coordinator (README §9): an
52
+ // upgraded one frees the space the old <dataDir>/artifacts took, once.
53
+ function removeLegacyArtifacts(config){
54
+ const dir = path.join(config.dataDir, 'artifacts');
55
+ if (fs.existsSync(dir)){
56
+ fs.rmSync(dir, { recursive: true, force: true });
57
+ console.log(`Removed ${dir}: the Coordinator no longer stores job artifacts`);
58
+ }
59
+ }
60
+
47
61
  function buildServices(config){
62
+ removeLegacyArtifacts(config);
48
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(),
49
67
  events = createEventsService(db),
50
68
  registry = createRegistryService(db, { bus, events }),
51
69
  agents = createAgentsService(db, { events }),
@@ -53,14 +71,14 @@ function buildServices(config){
53
71
  adminUsers = createAdminUsersService(db),
54
72
  commands = createCommandsService({ bus }),
55
73
  logs = createLogsService(db, { bus }),
56
- artifacts = createArtifactsService(db, { config }),
57
- jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
74
+ jobs = createJobsService(db, { bus, events, registry, config }),
58
75
  scheduler = createScheduler(db, { bus, events, registry, config }),
59
76
  heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config }),
60
- retention = createRetentionService(db, { bus, logs, artifacts, config }),
77
+ retention = createRetentionService(db, { config }),
61
78
  cleanup = createCleanupService(db, { jobs, config }),
62
79
  updates = createUpdatesService({ config }),
63
- live = createLiveService(db, { bus, updates });
80
+ live = createLiveService(db, { bus, updates }),
81
+ sessionStore = new SqliteSessionStore(db);
64
82
 
65
83
  jobs.reconcileOnStartup();
66
84
  updates.start();
@@ -83,13 +101,14 @@ function buildServices(config){
83
101
  commands,
84
102
  jobs,
85
103
  logs,
86
- artifacts,
87
104
  scheduler,
88
105
  heartbeatMonitor,
89
106
  retention,
90
107
  cleanup,
91
108
  updates,
92
- live
109
+ live,
110
+ sessionStore,
111
+ settings
93
112
  };
94
113
  }
95
114
 
@@ -101,11 +120,26 @@ function commonVersion(){
101
120
  return require('@andrian.yablonskyy/thub-common/package.json').version;
102
121
  }
103
122
 
123
+ // The session cookie's Secure flag (config session.secureCookie, §12).
124
+ // "auto": always Secure for an https:// publicUrl — the cookie must never
125
+ // travel over plain HTTP there — else express-session's per-request 'auto'
126
+ // (Secure on HTTPS connections), so plain-HTTP development still works.
127
+ function cookieSecure(config){
128
+ const setting = config.session?.secureCookie ?? 'auto';
129
+ if (setting === true || setting === false){
130
+ return setting;
131
+ }
132
+ return /^https:\/\//i.test(config.publicUrl || '') ? true : 'auto';
133
+ }
134
+
104
135
  function createApp(config, services){
105
136
  const app = express();
106
137
  app.set('view engine', 'pug');
107
138
  app.set('views', path.join(__dirname, '..', 'views'));
108
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);
109
143
  app.locals.services = services;
110
144
  // Shown in every dashboard page's footer (layout.pug).
111
145
  app.locals.coordinatorVersion = require('../package.json').version;
@@ -119,6 +153,9 @@ function createApp(config, services){
119
153
  app.use(
120
154
  session({
121
155
  secret: config.sessionSecret,
156
+ // In the SQLite database (services/session-store.js), not in memory:
157
+ // survives restarts, and expired sessions are pruned.
158
+ store: services.sessionStore,
122
159
  resave: false,
123
160
  saveUninitialized: false,
124
161
  // Idle timeout, not a fixed one (§10.1): `rolling` re-sends a fresh
@@ -127,7 +164,7 @@ function createApp(config, services){
127
164
  // (routes.js) after their *last* request. Each user's own preference
128
165
  // is applied per-request in web/routes.js, since it isn't known here.
129
166
  rolling: true,
130
- cookie: { httpOnly: true, sameSite: 'lax' }
167
+ cookie: { httpOnly: true, sameSite: 'lax', secure: cookieSecure(config) }
131
168
  })
132
169
  );
133
170
  // Background requests of the dashboard's live updates (public/js/live.js):
@@ -147,23 +184,16 @@ function createApp(config, services){
147
184
  });
148
185
  app.use(express.static(path.join(__dirname, '..', 'public')));
149
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));
150
190
 
151
191
  app.use('/api/v1', createAgentRouter({ services, config }));
152
192
  app.use('/api/v1', createResourceRouter({ services, config }));
153
- app.use('/api/v1/admin', createAdminRouter({ services }));
154
-
155
- // HMAC-signed artifact downloads (§3.1, §12) — no session or bearer token.
156
- app.get('/artifacts/download/:token', (req, res, next) => {
157
- try {
158
- const artifact = services.artifacts.verify(req.params.token);
159
- res.download(artifact.path, artifact.name);
160
- }
161
- catch (err){
162
- next(err);
163
- }
164
- });
193
+ // Session-authenticated, like the dashboard: same-origin requests only.
194
+ app.use('/api/v1/admin', sameOriginOnly(config), createAdminRouter({ services }));
165
195
 
166
- app.use('/', createWebRouter({ services, config }));
196
+ app.use('/', sameOriginOnly(config), createWebRouter({ services, config }));
167
197
 
168
198
  app.use((err, req, res, next) => {
169
199
  const status = err.status || 500;
@@ -225,4 +255,4 @@ if (require.main === module){
225
255
  start();
226
256
  }
227
257
 
228
- module.exports = { start, buildServices, createApp };
258
+ module.exports = { start, buildServices, createApp, cookieSecure };
@@ -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
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/src/services/cleanup.js
3
- * @description Database cleanup: wipe finished jobs (logs, artifacts, events) and old events before a cutoff, then
3
+ * @description Database cleanup: wipe finished jobs (logs, events) and old events before a cutoff, then
4
4
  * VACUUM to shrink the file — on demand from the dashboard, and hourly per retention.jobRetention (README §9)
5
5
  *
6
6
  * @author Andrian Yablonskyy
@@ -81,7 +81,7 @@ function createCleanupService(db, { jobs, config, now = () => new Date() }){
81
81
  }
82
82
 
83
83
  // Wipe everything older than `before` (a Date; default now): finished
84
- // jobs that ended before it, with their logs, artifacts and events, and
84
+ // jobs that ended before it, with their logs and events, and
85
85
  // the other audit events (resource/agent/group) logged before it. Events
86
86
  // of jobs that stay (still active, or newer) are kept. `vacuum`: true,
87
87
  // false, or 'auto' (only if something was deleted and the last VACUUM is
@@ -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
 
@@ -56,7 +58,7 @@ function nextJobId(db, source){
56
58
  return `${prefix}-${String(n).padStart(5, '0')}`;
57
59
  }
58
60
 
59
- function createJobsService(db, { bus, events, registry, artifacts, config }){
61
+ function createJobsService(db, { bus, events, registry, config }){
60
62
  function get(id){
61
63
  return rowToJob(db.prepare('SELECT * FROM jobs WHERE id = ?').get(id));
62
64
  }
@@ -365,7 +367,9 @@ function createJobsService(db, { bus, events, registry, artifacts, 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, artifacts, 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
 
@@ -483,7 +494,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
483
494
  }
484
495
 
485
496
  // Admin: "clean the queue" — permanently delete finished jobs (and their
486
- // logs/artifacts, on disk and in the DB), for when the history itself,
497
+ // logs, in the DB), for when the history itself,
487
498
  // not just active work, needs clearing out. Unlike the nightly retention
488
499
  // sweep (§9), this runs on demand and isn't limited to old jobs.
489
500
  function cleanHistory(){
@@ -491,7 +502,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
491
502
  }
492
503
 
493
504
  // Finished jobs that ended before `beforeIso` (null: all of them), with
494
- // their logs, artifacts (files too) and events — manual cleanup and the
505
+ // their logs and events — manual cleanup and the
495
506
  // job retention task (services/cleanup.js). Active jobs are never touched.
496
507
  function purgeFinishedBefore(beforeIso){
497
508
  const placeholders = [...TERMINAL_JOB_STATES].map(() => '?').join(','),
@@ -504,14 +515,11 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
504
515
  return 0;
505
516
  }
506
517
 
507
- const deleteArtifacts = db.prepare('DELETE FROM artifacts WHERE job_id = ?'),
508
- deleteLogs = db.prepare('DELETE FROM job_logs WHERE job_id = ?'),
518
+ const deleteLogs = db.prepare('DELETE FROM job_logs WHERE job_id = ?'),
509
519
  deleteEvents = db.prepare('DELETE FROM events WHERE entity = \'job\' AND entity_id = ?'),
510
520
  deleteJob = db.prepare('DELETE FROM jobs WHERE id = ?'),
511
521
  tx = db.transaction(() => {
512
522
  for (const id of ids){
513
- artifacts.deleteJobArtifacts(id);
514
- deleteArtifacts.run(id);
515
523
  deleteLogs.run(id);
516
524
  deleteEvents.run(id);
517
525
  deleteJob.run(id);
@@ -15,7 +15,7 @@
15
15
 
16
16
  // §3.1 Log service: accepts batched log lines, assigns a monotonically
17
17
  // increasing seq per job, stores them, and fans them out over the bus for
18
- // SSE subscribers (§6.4). Retention is handled separately (§9).
18
+ // SSE subscribers (§6.4). Lines are deleted with their job (§9).
19
19
  function createLogsService(db, { bus }){
20
20
  const insert = db.prepare(
21
21
  'INSERT INTO job_logs (job_id, seq, ts, stream, line) VALUES (?, ?, ?, ?, ?)'
@@ -47,24 +47,22 @@ function createLogsService(db, { bus }){
47
47
  .all(jobId, afterSeq, limit);
48
48
  }
49
49
 
50
- // Used when a job finishes: fold job_logs into a flat console.log artifact.
51
- function renderConsoleLog(jobId){
50
+ // The dashboard log viewer's pages (views/mixins/log-viewer.pug): the
51
+ // `limit` lines just before `beforeSeq` (the end of the log when omitted),
52
+ // oldest first, and whether there are earlier ones to scroll up to.
53
+ function pageBefore(jobId, beforeSeq, limit){
52
54
  const rows = db
53
- .prepare('SELECT * FROM job_logs WHERE job_id = ? ORDER BY seq ASC')
54
- .all(jobId);
55
- return rows.map((r) => `[${r.ts}] [${r.stream}] ${r.line}`).join('\n') + (rows.length ? '\n' : '');
55
+ .prepare('SELECT seq, ts, stream, line FROM job_logs WHERE job_id = ? AND seq < ? ORDER BY seq DESC LIMIT ?')
56
+ .all(jobId, beforeSeq ?? Number.MAX_SAFE_INTEGER, limit + 1),
57
+ hasMore = rows.length > limit;
58
+ return { lines: rows.slice(0, limit).reverse(), hasMore };
56
59
  }
57
60
 
58
- function purgeOlderThan(days){
59
- const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString();
60
- db.prepare(
61
- `DELETE FROM job_logs WHERE job_id IN (
62
- SELECT id FROM jobs WHERE finished_at IS NOT NULL AND finished_at <= ?
63
- )`
64
- ).run(cutoff);
61
+ function count(jobId){
62
+ return db.prepare('SELECT COUNT(*) AS n FROM job_logs WHERE job_id = ?').get(jobId).n;
65
63
  }
66
64
 
67
- return { appendBatch, listSince, renderConsoleLog, purgeOlderThan };
65
+ return { appendBatch, listSince, pageBefore, count };
68
66
  }
69
67
 
70
68
  module.exports = { createLogsService };