@andrian.yablonskyy/thub-coordinator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +157 -0
  2. package/bin/thub-admin.js +190 -0
  3. package/config.json +30 -0
  4. package/eslint.config.js +115 -0
  5. package/package.json +32 -0
  6. package/public/css/thub.css +6 -0
  7. package/public/js/copy-to-clipboard.js +36 -0
  8. package/public/js/log-viewer.js +76 -0
  9. package/public/js/profile-theme.js +28 -0
  10. package/public/js/theme.js +51 -0
  11. package/public/js/tooltips.js +18 -0
  12. package/src/api/admin.js +112 -0
  13. package/src/api/agent.js +110 -0
  14. package/src/api/resource.js +211 -0
  15. package/src/api/sse.js +96 -0
  16. package/src/auth.js +94 -0
  17. package/src/config.js +110 -0
  18. package/src/db/index.js +61 -0
  19. package/src/db/migrations/001_init.sql +104 -0
  20. package/src/db/migrations/003_split_job_id_counters.sql +5 -0
  21. package/src/db/migrations/004_add_client_id.sql +8 -0
  22. package/src/db/migrations/005_add_groups.sql +14 -0
  23. package/src/db/migrations/006_events_allow_group.sql +20 -0
  24. package/src/db/migrations/007_add_user_profile.sql +10 -0
  25. package/src/server.js +179 -0
  26. package/src/services/admin-users.js +174 -0
  27. package/src/services/agents.js +56 -0
  28. package/src/services/artifacts.js +149 -0
  29. package/src/services/bus.js +23 -0
  30. package/src/services/commands.js +43 -0
  31. package/src/services/events.js +37 -0
  32. package/src/services/groups.js +79 -0
  33. package/src/services/heartbeat.js +56 -0
  34. package/src/services/jobs.js +355 -0
  35. package/src/services/logs.js +70 -0
  36. package/src/services/registry.js +347 -0
  37. package/src/services/retention.js +44 -0
  38. package/src/services/scheduler.js +101 -0
  39. package/src/services/tokens.js +28 -0
  40. package/src/web/routes.js +343 -0
  41. package/test/admin-users.test.js +127 -0
  42. package/test/scheduler.test.js +375 -0
  43. package/views/admin/agents.pug +53 -0
  44. package/views/groups/list.pug +94 -0
  45. package/views/index.pug +37 -0
  46. package/views/jobs/list.pug +45 -0
  47. package/views/jobs/show.pug +42 -0
  48. package/views/layout.pug +73 -0
  49. package/views/login.pug +16 -0
  50. package/views/mixins/log-viewer.pug +14 -0
  51. package/views/mixins/status-badge.pug +7 -0
  52. package/views/profile.pug +77 -0
  53. package/views/resources/list.pug +65 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/theme.js
3
+ * @description Dashboard: theme toggle, persisted to the account (§10.1) with localStorage as a pre-login fallback
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
+ (function (){
17
+ const root = document.documentElement,
18
+ key = 'thub-theme';
19
+
20
+ function apply(theme){
21
+ root.setAttribute('data-bs-theme', theme);
22
+ }
23
+
24
+ // Server-rendered value (layout.pug: html(data-bs-theme=user.theme)) wins
25
+ // once logged in — it's the account's saved preference, synced across
26
+ // devices (§10.1). "auto" means no account preference yet (or logged
27
+ // out, e.g. the login page), so localStorage is still the fallback there.
28
+ if (root.getAttribute('data-bs-theme') === 'auto'){
29
+ const stored = localStorage.getItem(key);
30
+ if (stored){
31
+ apply(stored);
32
+ }
33
+ }
34
+
35
+ // Shared by the navbar toggle below and the profile page's theme buttons
36
+ // (public/js/profile-theme.js) — one place that applies it locally and
37
+ // persists it to the account, so both call sites can't drift.
38
+ window.thubSetTheme = function thubSetTheme(theme){
39
+ apply(theme);
40
+ localStorage.setItem(key, theme);
41
+ fetch('/profile/theme', {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ theme })
45
+ }).catch(() => {}); // best effort — logged-out visitors can still toggle locally
46
+ };
47
+
48
+ document.getElementById('theme-toggle')?.addEventListener('click', () => {
49
+ window.thubSetTheme(root.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark');
50
+ });
51
+ })();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/tooltips.js
3
+ * @description Dashboard: initializes Bootstrap tooltips on data-bs-toggle elements
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
+ (function (){
17
+ document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => new bootstrap.Tooltip(el));
18
+ })();
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @file packages/coordinator/src/api/admin.js
3
+ * @description Admin API routes: agents, join key, resource maintenance, job queue reset/clean (README §6.3)
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
+ const express = require('express'),
17
+ { requireAdminSession, requireAdminRole } = require('../auth'),
18
+ { generateToken, hashToken } = require('../services/tokens');
19
+
20
+ // §6.3 Admin endpoints. Authenticated via the dashboard session (§10),
21
+ // not an API bearer token.
22
+ function createAdminRouter({ services }){
23
+ const router = express.Router();
24
+ router.use(requireAdminSession, requireAdminRole);
25
+
26
+ router.post('/agents', (req, res, next) => {
27
+ try {
28
+ const { name, kind } = req.body;
29
+ if (!name || !['ci', 'cli'].includes(kind)){
30
+ return res.status(400).json({ error: 'name and kind (ci|cli) are required' });
31
+ }
32
+ const { agent, token } = services.agents.create({ name, kind });
33
+ res.status(201).json({ agent, token });
34
+ }
35
+ catch (err){
36
+ next(err);
37
+ }
38
+ });
39
+
40
+ router.delete('/agents/:id', (req, res) => {
41
+ services.agents.revoke(req.params.id);
42
+ res.status(204).end();
43
+ });
44
+
45
+ router.post('/resources/:id/maintenance', (req, res, next) => {
46
+ try {
47
+ const resource = services.registry.setMaintenance(req.params.id, !!req.body.enabled);
48
+ res.json(resource);
49
+ }
50
+ catch (err){
51
+ next(err);
52
+ }
53
+ });
54
+
55
+ // Referenced in §10's dashboard resource actions.
56
+ router.post('/resources/:id/rotate-token', (req, res) => {
57
+ const token = generateToken('res');
58
+ services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
59
+ services.events.record('resource', req.params.id, 'resource.token_rotated', {});
60
+ res.json({ resourceToken: token });
61
+ });
62
+
63
+ // Cancels every QUEUED/ASSIGNED/PREPARING/RUNNING job (§13.1).
64
+ router.post('/jobs/reset-queue', (req, res) => {
65
+ const canceled = services.jobs.resetQueue();
66
+ res.json({ canceled });
67
+ });
68
+
69
+ // Permanently deletes finished jobs and their logs/artifacts (§13.1).
70
+ router.post('/jobs/clean-history', (req, res) => {
71
+ const deleted = services.jobs.cleanHistory();
72
+ res.json({ deleted });
73
+ });
74
+
75
+ // Resource groups (§13.1) — membership itself is declared by each
76
+ // Client's own config (`groups: [...]`), not managed here.
77
+ router.get('/groups', (req, res) => {
78
+ res.json({ groups: services.groups.list() });
79
+ });
80
+
81
+ router.post('/groups', (req, res, next) => {
82
+ try {
83
+ const { name, comment } = req.body;
84
+ if (!name){
85
+ return res.status(400).json({ error: 'name is required' });
86
+ }
87
+ res.status(201).json(services.groups.create({ name, comment }));
88
+ }
89
+ catch (err){
90
+ next(err);
91
+ }
92
+ });
93
+
94
+ router.post('/groups/:id', (req, res, next) => {
95
+ try {
96
+ const { name, comment } = req.body;
97
+ res.json(services.groups.update(req.params.id, { name, comment }));
98
+ }
99
+ catch (err){
100
+ next(err);
101
+ }
102
+ });
103
+
104
+ router.delete('/groups/:id', (req, res) => {
105
+ services.groups.remove(req.params.id);
106
+ res.status(204).end();
107
+ });
108
+
109
+ return router;
110
+ }
111
+
112
+ module.exports = { createAdminRouter };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @file packages/coordinator/src/api/agent.js
3
+ * @description Agent API routes: job submission, status, cancel, log streaming (README §6.1)
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
+ const express = require('express'),
17
+ { requireRole } = require('../auth'),
18
+ { attachJobStream } = require('./sse');
19
+
20
+ // §6.1 Agent endpoints.
21
+ function createAgentRouter({ services, config }){
22
+ const router = express.Router(),
23
+ // Applied per-route (not via router.use()) because this router shares
24
+ // the /api/v1 prefix with the resource and admin routers — a blanket
25
+ // router-level middleware would run for their paths too, before route
26
+ // matching even happens, and reject them for lacking an agent token.
27
+ auth = requireRole('agent');
28
+
29
+ router.post('/jobs', auth, (req, res, next) => {
30
+ try {
31
+ const job = services.jobs.create({ agentId: req.agent.id, source: req.body.source || 'cli', spec: req.body });
32
+ res.status(201).json({ jobId: job.id, state: job.state, webUrl: `${config.publicUrl}/jobs/${job.id}` });
33
+ }
34
+ catch (err){
35
+ next(err);
36
+ }
37
+ });
38
+
39
+ router.get('/jobs/:id', auth, (req, res) => {
40
+ const job = services.jobs.get(req.params.id);
41
+ if (!job){
42
+ return res.status(404).json({ error: 'Unknown job' });
43
+ }
44
+ const resource = job.resource_id ? services.registry.get(job.resource_id) : null;
45
+ res.json({ ...job, resource: resource ? { id: resource.id, name: resource.name } : null });
46
+ });
47
+
48
+ router.get('/jobs', auth, (req, res) => {
49
+ const jobs = services.jobs.list({
50
+ state: req.query.state,
51
+ source: req.query.source,
52
+ agentId: req.agent.id,
53
+ mine: req.query.mine === 'true' || req.query.mine === '1',
54
+ limit: req.query.limit ? Number(req.query.limit) : undefined
55
+ });
56
+ res.json({ jobs });
57
+ });
58
+
59
+ router.post('/jobs/:id/cancel', auth, (req, res, next) => {
60
+ try {
61
+ const job = services.jobs.cancel(req.params.id, { agentId: req.agent.id, isAdmin: false });
62
+ res.json(job);
63
+ }
64
+ catch (err){
65
+ next(err);
66
+ }
67
+ });
68
+
69
+ router.get('/jobs/:id/logs', auth, (req, res) => {
70
+ const after = Number(req.query.after || 0),
71
+ limit = req.query.limit ? Number(req.query.limit) : undefined;
72
+ res.json({ lines: services.logs.listSince(req.params.id, after, limit) });
73
+ });
74
+
75
+ router.get('/jobs/:id/logs/stream', auth, (req, res) => {
76
+ attachJobStream(req, res, { jobId: req.params.id, services, config });
77
+ });
78
+
79
+ router.get('/jobs/:id/artifacts', auth, (req, res) => {
80
+ const artifacts = services.artifacts.listForJob(req.params.id).map((a) => ({
81
+ id: a.id,
82
+ name: a.name,
83
+ size: a.size,
84
+ sha256: a.sha256,
85
+ contentType: a.content_type,
86
+ url: services.artifacts.signedUrl(a)
87
+ }));
88
+ res.json({ artifacts });
89
+ });
90
+
91
+ router.get('/resources', auth, (req, res) => {
92
+ res.json({ resources: services.registry.list().map(publicResource) });
93
+ });
94
+
95
+ return router;
96
+ }
97
+
98
+ function publicResource(r){
99
+ return {
100
+ id: r.id,
101
+ name: r.name,
102
+ type: r.type,
103
+ status: r.status,
104
+ busySource: r.busy_source,
105
+ labels: r.labels,
106
+ lastHeartbeatAt: r.last_heartbeat_at
107
+ };
108
+ }
109
+
110
+ module.exports = { createAgentRouter };
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @file packages/coordinator/src/api/resource.js
3
+ * @description Resource (Client) API routes: register, heartbeat, job long-poll, result/log upload (README §6.2)
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
+ const express = require('express'),
17
+ multer = require('multer'),
18
+ { requireRole, requireJoinKey } = require('../auth'),
19
+ { JOB_STATES } = require('@andrian.yablonskyy/thub-common');
20
+
21
+ const upload = multer({ dest: require('node:os').tmpdir() });
22
+
23
+ function requireOwnResource(req, res, next){
24
+ if (req.resource.id !== req.params.id){
25
+ return res.status(403).json({ error: 'Token does not match resource' });
26
+ }
27
+ next();
28
+ }
29
+
30
+ function requireOwnJob(services){
31
+ return (req, res, next) => {
32
+ const job = services.jobs.get(req.params.id);
33
+ if (!job){
34
+ return res.status(404).json({ error: 'Unknown job' });
35
+ }
36
+ if (job.resource_id !== req.resource.id){
37
+ return res.status(403).json({ error: 'Job is not assigned to this resource' });
38
+ }
39
+ req.job = job;
40
+ next();
41
+ };
42
+ }
43
+
44
+ // §6.2 Resource (Client) endpoints.
45
+ function createResourceRouter({ services, config }){
46
+ const router = express.Router();
47
+
48
+ // Self-service registration: no admin action required. A Client proves
49
+ // it belongs by presenting the shared clientJoinKey (config/env on its
50
+ // side) and declares its own name/type/labels; the Coordinator upserts
51
+ // the resource by name (see registry.registerAuto).
52
+ router.post('/resources/register', requireJoinKey(config), (req, res, next) => {
53
+ try {
54
+ const { clientId, name, type, labels, groups, hostInfo, capabilities } = req.body;
55
+ if (!clientId){
56
+ return res.status(400).json({ error: 'clientId is required (persisted in the Client\'s .client-id file)' });
57
+ }
58
+ if (!name || !['hw', 'sw'].includes(type)){
59
+ return res.status(400).json({ error: 'name and type (hw|sw) are required' });
60
+ }
61
+ const { resourceId, resourceToken } = services.registry.registerAuto({
62
+ clientId,
63
+ name,
64
+ type,
65
+ labels: labels || capabilities?.labels || [],
66
+ groups: groups || [],
67
+ hostInfo
68
+ });
69
+ res.json({ resourceId, resourceToken, heartbeatIntervalSec: config.heartbeat.intervalSec });
70
+ }
71
+ catch (err){
72
+ next(err);
73
+ }
74
+ });
75
+
76
+ // Applied per-route, not via router.use() — this router shares the
77
+ // /api/v1 prefix with the agent and admin routers, and a blanket
78
+ // router-level middleware would run for their paths too before route
79
+ // matching happens (see the same note in api/agent.js).
80
+ const auth = requireRole('resource');
81
+
82
+ router.post('/resources/:id/heartbeat', auth, requireOwnResource, (req, res, next) => {
83
+ try {
84
+ const { state, activeJobId, localLock, metrics } = req.body;
85
+ services.registry.heartbeat(req.params.id, { state, activeJobId, localLock, metrics });
86
+ const commands = services.commands.drain(req.params.id);
87
+ res.json({ serverTime: new Date().toISOString(), commands });
88
+ }
89
+ catch (err){
90
+ next(err);
91
+ }
92
+ });
93
+
94
+ router.post('/resources/:id/status', auth, requireOwnResource, (req, res, next) => {
95
+ try {
96
+ const { busy, source, reason } = req.body;
97
+ if (source && source !== 'local'){
98
+ return res.status(400).json({ error: 'Only source=local may be set explicitly' });
99
+ }
100
+ const resource = services.registry.setLocalLock(req.params.id, { locked: !!busy, reason });
101
+ res.json(resource);
102
+ }
103
+ catch (err){
104
+ next(err);
105
+ }
106
+ });
107
+
108
+ router.get('/resources/:id/jobs/next', auth, requireOwnResource, async (req, res) => {
109
+ const waitSec = Math.min(Number(req.query.wait) || 0, 60),
110
+
111
+ existing = services.jobs.list({ state: JOB_STATES.ASSIGNED, resourceId: req.params.id, limit: 1 })[0];
112
+ if (existing){
113
+ return res.json(existing);
114
+ }
115
+
116
+ if (waitSec <= 0){
117
+ return res.status(204).end();
118
+ }
119
+
120
+ const jobId = await waitForAssignment(services.bus, req.params.id, waitSec);
121
+ if (!jobId){
122
+ return res.status(204).end();
123
+ }
124
+ res.json(services.jobs.get(jobId));
125
+ });
126
+
127
+ router.post('/jobs/:id/accept', auth, requireOwnJob(services), (req, res, next) => {
128
+ try {
129
+ if (req.job.state !== JOB_STATES.ASSIGNED){
130
+ return res.status(409).json({ error: `Cannot accept job in state ${req.job.state}` });
131
+ }
132
+ const job = services.jobs.setState(req.params.id, JOB_STATES.PREPARING);
133
+ res.json(job);
134
+ }
135
+ catch (err){
136
+ next(err);
137
+ }
138
+ });
139
+
140
+ router.post('/jobs/:id/state', auth, requireOwnJob(services), (req, res, next) => {
141
+ try {
142
+ const { state, message } = req.body;
143
+ if (![JOB_STATES.PREPARING, JOB_STATES.RUNNING].includes(state)){
144
+ return res.status(400).json({ error: 'state must be PREPARING or RUNNING' });
145
+ }
146
+ const job = services.jobs.setState(req.params.id, state, { message });
147
+ res.json(job);
148
+ }
149
+ catch (err){
150
+ next(err);
151
+ }
152
+ });
153
+
154
+ router.post('/jobs/:id/logs', auth, requireOwnJob(services), (req, res, next) => {
155
+ try {
156
+ const lines = Array.isArray(req.body) ? req.body : req.body.lines;
157
+ if (!Array.isArray(lines)){
158
+ return res.status(400).json({ error: 'Expected an array of log lines' });
159
+ }
160
+ const inserted = services.logs.appendBatch(req.params.id, lines);
161
+ res.status(202).json({ accepted: inserted.length });
162
+ }
163
+ catch (err){
164
+ next(err);
165
+ }
166
+ });
167
+
168
+ router.post('/jobs/:id/artifacts', auth, requireOwnJob(services), upload.any(), (req, res, next) => {
169
+ try {
170
+ const stored = services.artifacts.storeUploaded(req.params.id, req.files || []);
171
+ res.status(201).json({ artifacts: stored.map((a) => ({ id: a.id, name: a.name, size: a.size })) });
172
+ }
173
+ catch (err){
174
+ next(err);
175
+ }
176
+ });
177
+
178
+ router.post('/jobs/:id/result', auth, requireOwnJob(services), (req, res, next) => {
179
+ try {
180
+ const { state, exitCode, summary } = req.body,
181
+ job = services.jobs.applyResult(req.params.id, req.resource.id, { state, exitCode, summary });
182
+ res.json(job);
183
+ }
184
+ catch (err){
185
+ next(err);
186
+ }
187
+ });
188
+
189
+ return router;
190
+ }
191
+
192
+ function waitForAssignment(bus, resourceId, waitSec){
193
+ return new Promise((resolve) => {
194
+ const timer = setTimeout(() => {
195
+ bus.off('job.assigned', onAssigned);
196
+ resolve(null);
197
+ }, waitSec * 1000);
198
+
199
+ function onAssigned(evt){
200
+ if (evt.resourceId !== resourceId){
201
+ return;
202
+ }
203
+ clearTimeout(timer);
204
+ bus.off('job.assigned', onAssigned);
205
+ resolve(evt.jobId);
206
+ }
207
+ bus.on('job.assigned', onAssigned);
208
+ });
209
+ }
210
+
211
+ module.exports = { createResourceRouter };
package/src/api/sse.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @file packages/coordinator/src/api/sse.js
3
+ * @description Server-Sent Events helper: streams a job's log/state/end events, resumable by seq (README §6.4)
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
+ const { TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
17
+
18
+ // §6.4: log / state / end events, resumable via Last-Event-ID (§7: "the
19
+ // Agent reconnects with exponential backoff and resumes from the last seq").
20
+ function attachJobStream(req, res, { jobId, services, config }){
21
+ const { jobs, logs, registry, bus } = services,
22
+
23
+ job = jobs.get(jobId);
24
+ if (!job){
25
+ res.status(404).json({ error: 'Unknown job' });
26
+ return;
27
+ }
28
+
29
+ res.writeHead(200, {
30
+ 'Content-Type': 'text/event-stream',
31
+ 'Cache-Control': 'no-cache, no-transform',
32
+ Connection: 'keep-alive',
33
+ 'X-Accel-Buffering': 'no'
34
+ });
35
+ res.flushHeaders?.();
36
+
37
+ const lastEventId = req.get('Last-Event-ID'),
38
+ afterSeq = Number(lastEventId ?? req.query.after ?? 0) || 0;
39
+
40
+ function writeEvent(event, data, id){
41
+ if (id !== undefined){
42
+ res.write(`id: ${id}\n`);
43
+ }
44
+ res.write(`event: ${event}\n`);
45
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
46
+ }
47
+
48
+ for (const line of logs.listSince(jobId, afterSeq)){
49
+ writeEvent('log', { ts: line.ts, stream: line.stream, line: line.line }, line.seq);
50
+ }
51
+
52
+ function artifactsUrl(){
53
+ return `${config.publicUrl}/jobs/${jobId}#artifacts`;
54
+ }
55
+
56
+ const current = jobs.get(jobId);
57
+ if (TERMINAL_JOB_STATES.has(current.state)){
58
+ writeEvent('end', { state: current.state, artifactsUrl: artifactsUrl() });
59
+ return res.end();
60
+ }
61
+
62
+ const resourceName = (id) => (id ? registry.get(id)?.name : undefined);
63
+ writeEvent('state', { state: current.state, resource: resourceName(current.resource_id) });
64
+
65
+ const onLog = (entry) => {
66
+ if (entry.jobId === jobId){
67
+ writeEvent('log', { ts: entry.ts, stream: entry.stream, line: entry.line }, entry.seq);
68
+ }
69
+ },
70
+ onState = (evt) => {
71
+ if (evt.jobId === jobId){
72
+ writeEvent('state', { state: evt.state, resource: resourceName(evt.resourceId) });
73
+ }
74
+ },
75
+ onFinished = (evt) => {
76
+ if (evt.jobId !== jobId){
77
+ return;
78
+ }
79
+ writeEvent('end', { state: evt.state, artifactsUrl: artifactsUrl() });
80
+ cleanup();
81
+ res.end();
82
+ };
83
+
84
+ function cleanup(){
85
+ bus.off('job.log', onLog);
86
+ bus.off('job.state', onState);
87
+ bus.off('job.finished', onFinished);
88
+ }
89
+
90
+ bus.on('job.log', onLog);
91
+ bus.on('job.state', onState);
92
+ bus.on('job.finished', onFinished);
93
+ req.on('close', cleanup);
94
+ }
95
+
96
+ module.exports = { attachJobStream };
package/src/auth.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @file packages/coordinator/src/auth.js
3
+ * @description Bearer token and admin session authentication/authorization middleware (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
+ const crypto = require('node:crypto'),
17
+ { hashToken } = require('./services/tokens');
18
+
19
+ // §3.1 / §12: bearer tokens are hashed with SHA-256 and carry a role
20
+ // (agent | resource); admin is authenticated via the dashboard session
21
+ // instead of an API token (§10).
22
+ function requireRole(role){
23
+ return (req, res, next) => {
24
+ const header = req.headers.authorization || '',
25
+ [scheme, token] = header.split(' ');
26
+ if (scheme !== 'Bearer' || !token){
27
+ return res.status(401).json({ error: 'Missing bearer token' });
28
+ }
29
+ const tokenHash = hashToken(token);
30
+
31
+ if (role === 'agent'){
32
+ const agent = req.app.locals.services.agents.getByTokenHash(tokenHash);
33
+ if (!agent){
34
+ return res.status(401).json({ error: 'Invalid or revoked agent token' });
35
+ }
36
+ req.app.locals.services.agents.touchLastUsed(agent.id);
37
+ req.agent = agent;
38
+ return next();
39
+ }
40
+
41
+ if (role === 'resource'){
42
+ const resource = req.app.locals.services.registry.getByTokenHash(tokenHash);
43
+ if (!resource){
44
+ return res.status(401).json({ error: 'Invalid resource token' });
45
+ }
46
+ req.resource = resource;
47
+ return next();
48
+ }
49
+
50
+ return res.status(500).json({ error: `Unknown role ${role}` });
51
+ };
52
+ }
53
+
54
+ // Gates self-service Client registration (see api/resource.js). A shared
55
+ // secret rather than a per-resource token, since the whole point is that
56
+ // no admin action is needed to let a new Client in — every Client just
57
+ // carries the same key from its own config/env.
58
+ function requireJoinKey(config){
59
+ return (req, res, next) => {
60
+ if (!config.clientJoinKey){
61
+ return res.status(503).json({ error: 'Coordinator has no clientJoinKey configured; auto-registration is disabled' });
62
+ }
63
+ const header = req.headers.authorization || '',
64
+ [scheme, key] = header.split(' ');
65
+ if (scheme !== 'Bearer' || !key){
66
+ return res.status(401).json({ error: 'Missing bearer join key' });
67
+ }
68
+ const a = crypto.createHash('sha256').update(key).digest(),
69
+ b = crypto.createHash('sha256').update(config.clientJoinKey).digest();
70
+ if (!crypto.timingSafeEqual(a, b)){
71
+ return res.status(401).json({ error: 'Invalid join key' });
72
+ }
73
+ next();
74
+ };
75
+ }
76
+
77
+ function requireAdminSession(req, res, next){
78
+ if (!req.session?.user){
79
+ if (req.accepts('html')){
80
+ return res.redirect('/login');
81
+ }
82
+ return res.status(401).json({ error: 'Login required' });
83
+ }
84
+ next();
85
+ }
86
+
87
+ function requireAdminRole(req, res, next){
88
+ if (req.session?.user?.role !== 'admin'){
89
+ return res.status(403).json({ error: 'Admin role required' });
90
+ }
91
+ next();
92
+ }
93
+
94
+ module.exports = { requireRole, requireJoinKey, requireAdminSession, requireAdminRole };