@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
package/src/config.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @file packages/coordinator/src/config.js
3
+ * @description Coordinator config resolution: config file, env var overrides, and defaults (README §13)
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 fs = require('node:fs'),
17
+ path = require('node:path');
18
+
19
+ // Defaults mirror README.md §13. Unlike the Client (config.js there), there
20
+ // is no FHS-path fallback here — a real deployment must set
21
+ // THUB_COORDINATOR_CONFIG explicitly to override the bundled default below.
22
+ const DEFAULTS = {
23
+ listen: '127.0.0.1:8080',
24
+ publicUrl: 'http://localhost:8080',
25
+ dataDir: path.join(process.cwd(), '.data'),
26
+ sessionSecret: 'dev-only-change-me',
27
+ // Shared secret Clients present to self-register (see api/resource.js).
28
+ // null disables auto-registration entirely — set it explicitly to turn it on.
29
+ clientJoinKey: null,
30
+ heartbeat: {
31
+ intervalSec: 10,
32
+ missedLimit: 3,
33
+ sweepIntervalSec: 5
34
+ },
35
+ scheduler: {
36
+ assignAckTimeoutSec: 15,
37
+ requeueOnLost: true,
38
+ maxQueuedPerAgent: 20,
39
+ tickIntervalSec: 10
40
+ },
41
+ jobs: {
42
+ defaultTimeoutSec: 1800,
43
+ maxTimeoutSec: 14400
44
+ },
45
+ retention: {
46
+ logRetentionDays: 14,
47
+ artifactRetentionDays: 30
48
+ },
49
+ artifacts: {
50
+ maxUploadMb: 512,
51
+ linkTtlHours: 168
52
+ }
53
+ };
54
+
55
+ function deepMerge(base, override){
56
+ if (!override){
57
+ return base;
58
+ }
59
+ const out = { ...base };
60
+ for (const [k, v]of Object.entries(override)){
61
+ out[k] = v && typeof v === 'object' && !Array.isArray(v) ? deepMerge(base[k] || {}, v) : v;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // Bundled with the package so the Coordinator has something sane to run
67
+ // with out of the box; a real deployment overrides it with
68
+ // THUB_COORDINATOR_CONFIG or /srv/thub/coordinator.json (§13).
69
+ const PACKAGE_DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config.json');
70
+
71
+ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
72
+ const candidate = [configPath, PACKAGE_DEFAULT_CONFIG_PATH].find(
73
+ (p) => p && fs.existsSync(p)
74
+ ),
75
+ fileConfig = candidate ? JSON.parse(fs.readFileSync(candidate, 'utf8')) || {} : {},
76
+ config = deepMerge(DEFAULTS, fileConfig);
77
+
78
+ // Environment overrides for the bits you don't want in a committed file.
79
+ if (process.env.THUB_LISTEN){
80
+ config.listen = process.env.THUB_LISTEN;
81
+ }
82
+ if (process.env.THUB_PUBLIC_URL){
83
+ config.publicUrl = process.env.THUB_PUBLIC_URL;
84
+ }
85
+ if (process.env.THUB_DATA_DIR){
86
+ config.dataDir = process.env.THUB_DATA_DIR;
87
+ }
88
+ if (process.env.THUB_SESSION_SECRET){
89
+ config.sessionSecret = process.env.THUB_SESSION_SECRET;
90
+ }
91
+ if (process.env.THUB_CLIENT_JOIN_KEY){
92
+ config.clientJoinKey = process.env.THUB_CLIENT_JOIN_KEY;
93
+ }
94
+
95
+ const [host, port] = config.listen.split(':');
96
+ config.host = host;
97
+ config.port = Number(port);
98
+ config.dbPath = path.join(config.dataDir, 'thub.db');
99
+ config.artifactsDir = path.join(config.dataDir, 'artifacts');
100
+ config.workDir = path.join(config.dataDir, 'work');
101
+ config.avatarsDir = path.join(config.dataDir, 'avatars');
102
+
103
+ fs.mkdirSync(config.dataDir, { recursive: true });
104
+ fs.mkdirSync(config.artifactsDir, { recursive: true });
105
+ fs.mkdirSync(config.avatarsDir, { recursive: true });
106
+
107
+ return config;
108
+ }
109
+
110
+ module.exports = { loadConfig, DEFAULTS };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @file packages/coordinator/src/db/index.js
3
+ * @description Opens the SQLite database and applies pending schema migrations on startup
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 fs = require('node:fs'),
17
+ path = require('node:path'),
18
+ Database = require('better-sqlite3');
19
+
20
+ function openDb(dbPath){
21
+ const db = new Database(dbPath);
22
+ db.pragma('journal_mode = WAL');
23
+ db.pragma('synchronous = NORMAL');
24
+ db.pragma('foreign_keys = ON');
25
+ migrate(db);
26
+ return db;
27
+ }
28
+
29
+ function migrate(db){
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS schema_migrations (
32
+ id TEXT PRIMARY KEY,
33
+ applied_at TEXT NOT NULL
34
+ );
35
+ `);
36
+
37
+ const migrationsDir = path.join(__dirname, 'migrations'),
38
+ applied = new Set(db.prepare('SELECT id FROM schema_migrations').all().map((r) => r.id)),
39
+
40
+ files = fs
41
+ .readdirSync(migrationsDir)
42
+ .filter((f) => f.endsWith('.sql'))
43
+ .sort();
44
+
45
+ for (const file of files){
46
+ if (applied.has(file)){
47
+ continue;
48
+ }
49
+ const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8'),
50
+ runMigration = db.transaction(() => {
51
+ db.exec(sql);
52
+ db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run(
53
+ file,
54
+ new Date().toISOString()
55
+ );
56
+ });
57
+ runMigration();
58
+ }
59
+ }
60
+
61
+ module.exports = { openDb };
@@ -0,0 +1,104 @@
1
+ -- Schema mirrors README.md §9 (SQLite ER diagram).
2
+
3
+ CREATE TABLE agents (
4
+ id TEXT PRIMARY KEY,
5
+ name TEXT NOT NULL,
6
+ kind TEXT NOT NULL CHECK (kind IN ('ci', 'cli')),
7
+ token_hash TEXT NOT NULL UNIQUE,
8
+ created_at TEXT NOT NULL,
9
+ last_used_at TEXT,
10
+ revoked_at TEXT
11
+ );
12
+
13
+ CREATE TABLE resources (
14
+ id TEXT PRIMARY KEY,
15
+ name TEXT NOT NULL UNIQUE,
16
+ type TEXT NOT NULL CHECK (type IN ('hw', 'sw')),
17
+ status TEXT NOT NULL DEFAULT 'REGISTERED',
18
+ busy_source TEXT CHECK (busy_source IN ('ci', 'cli', 'local')),
19
+ busy_reason TEXT,
20
+ labels TEXT NOT NULL DEFAULT '[]',
21
+ host_info TEXT,
22
+ token_hash TEXT UNIQUE,
23
+ -- Unused: resources self-register via the shared clientJoinKey
24
+ -- (registry.registerAuto) rather than an admin-issued enrollment token.
25
+ -- Left in place because SQLite can't drop a UNIQUE column without a
26
+ -- full table rebuild, which isn't worth it for two dead columns.
27
+ enrollment_hash TEXT UNIQUE,
28
+ enrollment_expires_at TEXT,
29
+ last_heartbeat_at TEXT,
30
+ last_job_finished_at TEXT,
31
+ created_at TEXT NOT NULL
32
+ );
33
+
34
+ CREATE TABLE jobs (
35
+ id TEXT PRIMARY KEY,
36
+ agent_id TEXT NOT NULL REFERENCES agents(id),
37
+ resource_id TEXT REFERENCES resources(id),
38
+ source TEXT NOT NULL CHECK (source IN ('ci', 'cli')),
39
+ state TEXT NOT NULL,
40
+ spec TEXT NOT NULL,
41
+ priority INTEGER NOT NULL DEFAULT 50,
42
+ attempt INTEGER NOT NULL DEFAULT 1,
43
+ exit_code INTEGER,
44
+ summary TEXT,
45
+ message TEXT,
46
+ created_at TEXT NOT NULL,
47
+ assigned_at TEXT,
48
+ started_at TEXT,
49
+ finished_at TEXT,
50
+ deadline_at TEXT
51
+ );
52
+
53
+ CREATE TABLE job_logs (
54
+ job_id TEXT NOT NULL REFERENCES jobs(id),
55
+ seq INTEGER NOT NULL,
56
+ ts TEXT NOT NULL,
57
+ stream TEXT NOT NULL,
58
+ line TEXT NOT NULL,
59
+ PRIMARY KEY (job_id, seq)
60
+ );
61
+
62
+ CREATE TABLE artifacts (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ job_id TEXT NOT NULL REFERENCES jobs(id),
65
+ name TEXT NOT NULL,
66
+ path TEXT NOT NULL,
67
+ size INTEGER NOT NULL,
68
+ sha256 TEXT,
69
+ content_type TEXT,
70
+ created_at TEXT NOT NULL
71
+ );
72
+
73
+ CREATE TABLE events (
74
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
75
+ ts TEXT NOT NULL,
76
+ entity TEXT NOT NULL CHECK (entity IN ('job', 'resource', 'agent')),
77
+ entity_id TEXT NOT NULL,
78
+ type TEXT NOT NULL,
79
+ data TEXT
80
+ );
81
+
82
+ -- Dashboard login (§10): separate from API tokens.
83
+ CREATE TABLE admin_users (
84
+ id TEXT PRIMARY KEY,
85
+ username TEXT NOT NULL UNIQUE,
86
+ password_hash TEXT NOT NULL,
87
+ role TEXT NOT NULL CHECK (role IN ('admin', 'viewer')),
88
+ created_at TEXT NOT NULL
89
+ );
90
+
91
+ CREATE TABLE counters (
92
+ name TEXT PRIMARY KEY,
93
+ value INTEGER NOT NULL DEFAULT 0
94
+ );
95
+ -- Unused: job IDs are now A-00001../M-00001.. (see migration 003 and
96
+ -- jobs.js's nextJobId), split by source rather than one shared sequence.
97
+ INSERT INTO counters (name, value) VALUES ('job_id', 0);
98
+
99
+ CREATE INDEX idx_jobs_scheduler ON jobs(state, priority DESC, created_at);
100
+ CREATE INDEX idx_resources_status_type ON resources(status, type);
101
+ CREATE INDEX idx_resources_heartbeat ON resources(last_heartbeat_at);
102
+ CREATE INDEX idx_jobs_agent ON jobs(agent_id);
103
+ CREATE INDEX idx_artifacts_job ON artifacts(job_id);
104
+ CREATE INDEX idx_events_entity ON events(entity, entity_id);
@@ -0,0 +1,5 @@
1
+ -- Job IDs split by source: A-00001.. for CI/CD, M-00001.. for manual
2
+ -- `thub run` from a developer's own machine (jobs.js's nextJobId). The
3
+ -- original 'job_id' counter is now unused — left in place, harmless.
4
+ INSERT INTO counters (name, value) VALUES ('job_id_a', 0);
5
+ INSERT INTO counters (name, value) VALUES ('job_id_m', 0);
@@ -0,0 +1,8 @@
1
+ -- Stable per-Client identity (a UUID a Client generates once and persists
2
+ -- in its .client-id file, README §5.1/§8.6), separate from `name` so a
3
+ -- Client can rename/retype itself on restart and still be recognized as
4
+ -- the same resource (registry.registerAuto matches on this, not name).
5
+ -- Nullable: resources created before this existed have no client_id and
6
+ -- get adopted by name on that Client's next registration.
7
+ ALTER TABLE resources ADD COLUMN client_id TEXT;
8
+ CREATE UNIQUE INDEX idx_resources_client_id ON resources(client_id) WHERE client_id IS NOT NULL;
@@ -0,0 +1,14 @@
1
+ -- Resource groups: an admin-managed way to partition the lab so a job can
2
+ -- be constrained to run only on resources in a specific group (§13.1,
3
+ -- `thub run --group <id>`). A resource declares which groups it belongs to
4
+ -- (0 or more) in its own Client config, same JSON-array-column pattern as
5
+ -- `labels` — not a join table, consistent with this schema's existing
6
+ -- style and this system's scale (§3.1: tens of runners, not thousands).
7
+ CREATE TABLE groups (
8
+ id TEXT PRIMARY KEY, -- UUID
9
+ name TEXT NOT NULL,
10
+ comment TEXT,
11
+ created_at TEXT NOT NULL
12
+ );
13
+
14
+ ALTER TABLE resources ADD COLUMN group_ids TEXT NOT NULL DEFAULT '[]';
@@ -0,0 +1,20 @@
1
+ -- events.entity's CHECK constraint didn't include 'group' (groups.js's
2
+ -- audit calls). SQLite can't alter a CHECK constraint in place, so this
3
+ -- rebuilds the table — safe here since nothing has a foreign key into
4
+ -- events, unlike resources/jobs.
5
+ CREATE TABLE events_new (
6
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ ts TEXT NOT NULL,
8
+ entity TEXT NOT NULL CHECK (entity IN ('job', 'resource', 'agent', 'group')),
9
+ entity_id TEXT NOT NULL,
10
+ type TEXT NOT NULL,
11
+ data TEXT
12
+ );
13
+
14
+ INSERT INTO events_new (id, ts, entity, entity_id, type, data)
15
+ SELECT id, ts, entity, entity_id, type, data FROM events;
16
+
17
+ DROP TABLE events;
18
+ ALTER TABLE events_new RENAME TO events;
19
+
20
+ CREATE INDEX idx_events_entity ON events(entity, entity_id);
@@ -0,0 +1,10 @@
1
+ -- Per-user dashboard profile settings (README §10.1): display name, avatar,
2
+ -- timezone (used to render every dashboard timestamp), theme preference
3
+ -- (synced across devices instead of living only in browser localStorage),
4
+ -- and an idle session timeout the user picks for their own account.
5
+ ALTER TABLE admin_users ADD COLUMN first_name TEXT;
6
+ ALTER TABLE admin_users ADD COLUMN last_name TEXT;
7
+ ALTER TABLE admin_users ADD COLUMN avatar_path TEXT;
8
+ ALTER TABLE admin_users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC';
9
+ ALTER TABLE admin_users ADD COLUMN theme TEXT NOT NULL DEFAULT 'auto' CHECK (theme IN ('auto', 'light', 'dark'));
10
+ ALTER TABLE admin_users ADD COLUMN session_timeout_min INTEGER NOT NULL DEFAULT 60;
package/src/server.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @file packages/coordinator/src/server.js
3
+ * @description Coordinator entry point: wires services, mounts routes, and starts the HTTP server
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 path = require('node:path'),
17
+ express = require('express'),
18
+ session = require('express-session'),
19
+
20
+ { loadConfig } = require('./config'),
21
+ { openDb } = require('./db'),
22
+ { bus } = require('./services/bus'),
23
+ { createEventsService } = require('./services/events'),
24
+ { createRegistryService } = require('./services/registry'),
25
+ { createAgentsService } = require('./services/agents'),
26
+ { createGroupsService } = require('./services/groups'),
27
+ { createAdminUsersService } = require('./services/admin-users'),
28
+ { createJobsService } = require('./services/jobs'),
29
+ { createScheduler } = require('./services/scheduler'),
30
+ { createHeartbeatMonitor } = require('./services/heartbeat'),
31
+ { createLogsService } = require('./services/logs'),
32
+ { createArtifactsService } = require('./services/artifacts'),
33
+ { createCommandsService } = require('./services/commands'),
34
+ { createRetentionService } = require('./services/retention'),
35
+
36
+ { createAgentRouter } = require('./api/agent'),
37
+ { createResourceRouter } = require('./api/resource'),
38
+ { createAdminRouter } = require('./api/admin'),
39
+ { createWebRouter } = require('./web/routes');
40
+
41
+ function buildServices(config){
42
+ const db = openDb(config.dbPath),
43
+ events = createEventsService(db),
44
+ registry = createRegistryService(db, { bus, events }),
45
+ agents = createAgentsService(db, { events }),
46
+ groups = createGroupsService(db, { events, registry }),
47
+ adminUsers = createAdminUsersService(db),
48
+ commands = createCommandsService({ bus }),
49
+ logs = createLogsService(db, { bus }),
50
+ artifacts = createArtifactsService(db, { config }),
51
+ jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
52
+ scheduler = createScheduler(db, { bus, events, registry, config }),
53
+ heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config }),
54
+ retention = createRetentionService(db, { bus, logs, artifacts, config });
55
+
56
+ jobs.reconcileOnStartup();
57
+
58
+ const sweepInterval = setInterval(() => {
59
+ jobs.checkAssignAcks();
60
+ jobs.checkTimeouts();
61
+ }, config.heartbeat.sweepIntervalSec * 1000);
62
+ sweepInterval.unref?.();
63
+
64
+ return {
65
+ db,
66
+ bus,
67
+ events,
68
+ registry,
69
+ agents,
70
+ groups,
71
+ adminUsers,
72
+ commands,
73
+ jobs,
74
+ logs,
75
+ artifacts,
76
+ scheduler,
77
+ heartbeatMonitor,
78
+ retention
79
+ };
80
+ }
81
+
82
+ function createApp(config, services){
83
+ const app = express();
84
+ app.set('view engine', 'pug');
85
+ app.set('views', path.join(__dirname, '..', 'views'));
86
+ app.locals.services = services;
87
+
88
+ app.use(express.json({ limit: '2mb' }));
89
+ app.use(express.urlencoded({ extended: true }));
90
+ app.use(
91
+ session({
92
+ secret: config.sessionSecret,
93
+ resave: false,
94
+ saveUninitialized: false,
95
+ // Idle timeout, not a fixed one (§10.1): `rolling` re-sends a fresh
96
+ // Set-Cookie on every response, so the session survives as long as
97
+ // the user stays active and only actually expires `sessionTimeoutMin`
98
+ // (routes.js) after their *last* request. Each user's own preference
99
+ // is applied per-request in web/routes.js, since it isn't known here.
100
+ rolling: true,
101
+ cookie: { httpOnly: true, sameSite: 'lax' }
102
+ })
103
+ );
104
+ app.use(express.static(path.join(__dirname, '..', 'public')));
105
+ app.use('/avatars', express.static(config.avatarsDir));
106
+
107
+ app.use('/api/v1', createAgentRouter({ services, config }));
108
+ app.use('/api/v1', createResourceRouter({ services, config }));
109
+ app.use('/api/v1/admin', createAdminRouter({ services }));
110
+
111
+ // HMAC-signed artifact downloads (§3.1, §12) — no session or bearer token.
112
+ app.get('/artifacts/download/:token', (req, res, next) => {
113
+ try {
114
+ const artifact = services.artifacts.verify(req.params.token);
115
+ res.download(artifact.path, artifact.name);
116
+ }
117
+ catch (err){
118
+ next(err);
119
+ }
120
+ });
121
+
122
+ app.use('/', createWebRouter({ services, config }));
123
+
124
+ app.use((err, req, res, next) => {
125
+ const status = err.status || 500;
126
+ if (status >= 500){
127
+ console.error(err);
128
+ }
129
+ if (req.path.startsWith('/api/')){
130
+ return res.status(status).json({ error: err.message });
131
+ }
132
+ res.status(status).send(err.message);
133
+ });
134
+
135
+ return app;
136
+ }
137
+
138
+ function start(configPath){
139
+ const config = loadConfig(configPath),
140
+ services = buildServices(config),
141
+ app = createApp(config, services);
142
+
143
+ ensureBootstrapAdmin(services);
144
+
145
+ const server = app.listen(config.port, config.host, () => {
146
+ console.log(`TestHub Coordinator listening on http://${config.host}:${config.port}`);
147
+ });
148
+
149
+ return { app, server, services, config };
150
+ }
151
+
152
+ // THUB_BOOTSTRAP_ADMIN_PASSWORD is an exceptional password reset, not just
153
+ // a first-run convenience (README §13.1): whenever it's set, it wins over
154
+ // whatever's in the DB for THUB_BOOTSTRAP_ADMIN_USER (or "admin") — reset
155
+ // that user's password if the account exists, or create it fresh as admin
156
+ // if it doesn't — on *every* startup, not only when admin_users is empty.
157
+ // Unset it again once you're back in; otherwise every restart re-applies
158
+ // it. With it unset, login uses whatever's already in the DB, as normal.
159
+ function ensureBootstrapAdmin(services){
160
+ const password = process.env.THUB_BOOTSTRAP_ADMIN_PASSWORD;
161
+ if (!password){
162
+ if (services.adminUsers.count() === 0){
163
+ console.warn(
164
+ 'No admin_users exist and THUB_BOOTSTRAP_ADMIN_PASSWORD is not set — ' +
165
+ 'create one with: node bin/thub-admin.js create-admin <user> <password>'
166
+ );
167
+ }
168
+ return;
169
+ }
170
+ const username = process.env.THUB_BOOTSTRAP_ADMIN_USER || 'admin';
171
+ services.adminUsers.resetPassword({ username, password });
172
+ console.log(`Reset password for admin user "${username}" from THUB_BOOTSTRAP_ADMIN_PASSWORD`);
173
+ }
174
+
175
+ if (require.main === module){
176
+ start();
177
+ }
178
+
179
+ module.exports = { start, buildServices, createApp };
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/admin-users.js
3
+ * @description Admin user accounts: password hashing/verification and per-user profile settings (§10.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 crypto = require('node:crypto'),
17
+ { v4: uuid } = require('uuid');
18
+
19
+ function hashPassword(password){
20
+ const salt = crypto.randomBytes(16).toString('hex'),
21
+ derived = crypto.scryptSync(password, salt, 64).toString('hex');
22
+ return `${salt}:${derived}`;
23
+ }
24
+
25
+ function verifyPassword(password, stored){
26
+ const [salt, derived] = stored.split(':'),
27
+ check = crypto.scryptSync(password, salt, 64).toString('hex');
28
+ return crypto.timingSafeEqual(Buffer.from(check, 'hex'), Buffer.from(derived, 'hex'));
29
+ }
30
+
31
+ // Presets offered on the profile page (§10.1) — a fixed list rather than a
32
+ // free-text field, so a user can't accidentally set a 3-second or 10-year
33
+ // idle timeout.
34
+ const SESSION_TIMEOUT_OPTIONS_MIN = [15, 30, 60, 120, 240, 480, 1440],
35
+ THEMES = ['auto', 'light', 'dark'];
36
+
37
+ function toProfile(row){
38
+ return {
39
+ id: row.id,
40
+ username: row.username,
41
+ role: row.role,
42
+ firstName: row.first_name,
43
+ lastName: row.last_name,
44
+ avatarPath: row.avatar_path,
45
+ timezone: row.timezone,
46
+ theme: row.theme,
47
+ sessionTimeoutMin: row.session_timeout_min
48
+ };
49
+ }
50
+
51
+ // §10: dashboard login uses its own admin/viewer account table with
52
+ // session cookies — deliberately separate from the API bearer tokens.
53
+ function createAdminUsersService(db){
54
+ function getByUsername(username){
55
+ return db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username);
56
+ }
57
+
58
+ function getById(id){
59
+ const row = db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
60
+ return row ? toProfile(row) : null;
61
+ }
62
+
63
+ function count(){
64
+ return db.prepare('SELECT COUNT(*) AS n FROM admin_users').get().n;
65
+ }
66
+
67
+ function create({ username, password, role }){
68
+ const id = `usr_${uuid()}`;
69
+ db.prepare('INSERT INTO admin_users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)').run(
70
+ id,
71
+ username,
72
+ hashPassword(password),
73
+ role,
74
+ new Date().toISOString()
75
+ );
76
+ return { id, username, role };
77
+ }
78
+
79
+ // Exceptional password reset (README §13.1): resets an *existing*
80
+ // user's password without touching their role (a reset shouldn't
81
+ // silently promote a viewer to admin), or creates one as admin if the
82
+ // named account doesn't exist yet — the original first-run bootstrap
83
+ // case, now handled by the same path.
84
+ function resetPassword({ username, password, role = 'admin' }){
85
+ const existing = getByUsername(username);
86
+ if (existing){
87
+ db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(hashPassword(password), existing.id);
88
+ return { id: existing.id, username, role: existing.role };
89
+ }
90
+ return create({ username, password, role });
91
+ }
92
+
93
+ // Self-service password change (§10.1) — requires knowing the *current*
94
+ // password, unlike resetPassword() above (that's the admin/operator
95
+ // break-glass path, which deliberately doesn't need it).
96
+ function changePassword(id, currentPassword, newPassword){
97
+ const row = db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
98
+ if (!row || !verifyPassword(currentPassword, row.password_hash)){
99
+ throw Object.assign(new Error('Current password is incorrect'), { status: 401 });
100
+ }
101
+ if (!newPassword){
102
+ throw Object.assign(new Error('New password must not be empty'), { status: 400 });
103
+ }
104
+ db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), id);
105
+ }
106
+
107
+ function verify(username, password){
108
+ const user = getByUsername(username);
109
+ if (!user){
110
+ return null;
111
+ }
112
+ if (!verifyPassword(password, user.password_hash)){
113
+ return null;
114
+ }
115
+ return toProfile(user);
116
+ }
117
+
118
+ // Partial update — only the fields present in `fields` are touched, so a
119
+ // theme-only PATCH (the navbar toggle, §10.1) doesn't require resending
120
+ // the whole profile. Username is unique like at creation time; changing
121
+ // it to one already held by a *different* account is a conflict, not a
122
+ // silent overwrite.
123
+ function updateProfile(id, fields){
124
+ if (fields.username !== undefined){
125
+ const owner = getByUsername(fields.username);
126
+ if (owner && owner.id !== id){
127
+ throw Object.assign(new Error(`Username "${fields.username}" is already taken`), { status: 409 });
128
+ }
129
+ }
130
+ if (fields.theme !== undefined && !THEMES.includes(fields.theme)){
131
+ throw Object.assign(new Error(`theme must be one of: ${THEMES.join(', ')}`), { status: 400 });
132
+ }
133
+ if (fields.sessionTimeoutMin !== undefined && !SESSION_TIMEOUT_OPTIONS_MIN.includes(fields.sessionTimeoutMin)){
134
+ throw Object.assign(new Error(`sessionTimeoutMin must be one of: ${SESSION_TIMEOUT_OPTIONS_MIN.join(', ')}`), {
135
+ status: 400
136
+ });
137
+ }
138
+
139
+ const columns = {
140
+ username: fields.username,
141
+ first_name: fields.firstName,
142
+ last_name: fields.lastName,
143
+ avatar_path: fields.avatarPath,
144
+ timezone: fields.timezone,
145
+ theme: fields.theme,
146
+ session_timeout_min: fields.sessionTimeoutMin
147
+ },
148
+ set = Object.entries(columns).filter(([, v]) => v !== undefined);
149
+ if (set.length === 0){
150
+ return getById(id);
151
+ }
152
+
153
+ db.prepare(`UPDATE admin_users SET ${set.map(([k]) => `${k} = ?`).join(', ')} WHERE id = ?`).run(
154
+ ...set.map(([, v]) => v),
155
+ id
156
+ );
157
+ return getById(id);
158
+ }
159
+
160
+ return {
161
+ getByUsername,
162
+ getById,
163
+ count,
164
+ create,
165
+ resetPassword,
166
+ changePassword,
167
+ verify,
168
+ updateProfile,
169
+ SESSION_TIMEOUT_OPTIONS_MIN,
170
+ THEMES
171
+ };
172
+ }
173
+
174
+ module.exports = { createAdminUsersService, SESSION_TIMEOUT_OPTIONS_MIN, THEMES };