@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,347 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/registry.js
3
+ * @description Resource registry: registration, identity matching by clientId, and lookup for scheduling (README §5.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 { v4: uuid } = require('uuid'),
17
+ { RESOURCE_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common'),
18
+ { generateToken, hashToken } = require('./tokens');
19
+
20
+ function rowToResource(row){
21
+ if (!row){
22
+ return row;
23
+ }
24
+ return {
25
+ ...row,
26
+ labels: JSON.parse(row.labels || '[]'),
27
+ group_ids: JSON.parse(row.group_ids || '[]'),
28
+ host_info: row.host_info ? JSON.parse(row.host_info) : null
29
+ };
30
+ }
31
+
32
+ function createRegistryService(db, { bus, events }){
33
+ function get(id){
34
+ return rowToResource(db.prepare('SELECT * FROM resources WHERE id = ?').get(id));
35
+ }
36
+
37
+ function getByName(name){
38
+ return rowToResource(db.prepare('SELECT * FROM resources WHERE name = ?').get(name));
39
+ }
40
+
41
+ function getByClientId(clientId){
42
+ return rowToResource(db.prepare('SELECT * FROM resources WHERE client_id = ?').get(clientId));
43
+ }
44
+
45
+ function getByTokenHash(tokenHash){
46
+ return rowToResource(db.prepare('SELECT * FROM resources WHERE token_hash = ?').get(tokenHash));
47
+ }
48
+
49
+ function list({ status, type } = {}){
50
+ let sql = 'SELECT * FROM resources WHERE 1=1';
51
+ const params = [];
52
+ if (status){
53
+ sql += ' AND status = ?';
54
+ params.push(status);
55
+ }
56
+ if (type){
57
+ sql += ' AND type = ?';
58
+ params.push(type);
59
+ }
60
+ sql += ' ORDER BY name ASC';
61
+ return db.prepare(sql).all(...params).map(rowToResource);
62
+ }
63
+
64
+ // Self-service registration: gated by the shared client join key (checked
65
+ // by the caller), not by an admin having pre-created the resource.
66
+ //
67
+ // Identity is `clientId` — a UUID the Client generates once and persists
68
+ // in its own .client-id file (§5.1/§8.6) — not `name`. That's what makes
69
+ // this a genuine update-in-place rather than a rename-creates-a-new-
70
+ // resource operation: name, type, labels and host_info are all fully
71
+ // overwritten (not merged) from what the Client declares *this* time,
72
+ // since the Client is the source of truth for its own current identity/
73
+ // capabilities, and status resets to REGISTERED (clearing any stale
74
+ // BUSY/OUT_OF_SERVICE left over from a crashed previous process) unless
75
+ // an admin had explicitly put it in MAINTENANCE, which re-registering
76
+ // doesn't override.
77
+ //
78
+ // A resource created before clientId existed (client_id IS NULL) is
79
+ // adopted by name the first time its Client presents one, rather than
80
+ // rejected as a name conflict. A name already claimed by a *different*,
81
+ // still-live clientId is a real conflict (409) — but if that claim is
82
+ // OUT_OF_SERVICE (no heartbeat in a while — e.g. its .client-id file was
83
+ // lost or its host is gone for good), it's abandoned, not squatted, and
84
+ // this registration is allowed to reclaim it. Anything else (IDLE, BUSY,
85
+ // MAINTENANCE, REGISTERED-but-just-created) means a process might still
86
+ // be actively using that identity, so it stays a hard conflict.
87
+ function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo }){
88
+ const resourceToken = generateToken('res');
89
+ let existing = getByClientId(clientId);
90
+
91
+ if (!existing){
92
+ const nameOwner = getByName(name);
93
+ if (nameOwner){
94
+ const abandoned = nameOwner.status === RESOURCE_STATES.OUT_OF_SERVICE;
95
+ if (nameOwner.client_id && nameOwner.client_id !== clientId && !abandoned){
96
+ throw Object.assign(
97
+ new Error(
98
+ `Resource name "${name}" is already registered by a different client (status: ${nameOwner.status})`
99
+ ),
100
+ { status: 409 }
101
+ );
102
+ }
103
+ existing = nameOwner; // legacy row, same client, or a reclaimed abandoned one
104
+ }
105
+ }
106
+ else if (existing.name !== name){
107
+ const nameOwner = getByName(name);
108
+ if (nameOwner && nameOwner.id !== existing.id){
109
+ if (nameOwner.status !== RESOURCE_STATES.OUT_OF_SERVICE){
110
+ throw Object.assign(
111
+ new Error(`Resource name "${name}" is already used by another client (status: ${nameOwner.status})`),
112
+ { status: 409 }
113
+ );
114
+ }
115
+ // nameOwner is a different, abandoned row squatting on the name
116
+ // `existing` wants to rename into — free it (not delete: keeps its
117
+ // job history intact) instead of hitting the UNIQUE constraint.
118
+ db.prepare('UPDATE resources SET name = ? WHERE id = ?').run(`${nameOwner.name}__stale-${nameOwner.id}`, nameOwner.id);
119
+ }
120
+ }
121
+
122
+ if (existing){
123
+ // A fresh process registering is a definitive signal that whatever
124
+ // the previous process was doing is abandoned — tell jobs.js (via
125
+ // the bus, to avoid a registry<->jobs circular dependency) so any
126
+ // job still pointing at this resource is marked LOST now, instead
127
+ // of sitting orphaned until the heartbeat sweeper eventually notices.
128
+ bus.emit('resource.reregistered', { resourceId: existing.id });
129
+
130
+ const nextStatus =
131
+ existing.status === RESOURCE_STATES.MAINTENANCE ? RESOURCE_STATES.MAINTENANCE : RESOURCE_STATES.REGISTERED;
132
+
133
+ db.prepare(
134
+ `UPDATE resources
135
+ SET token_hash = ?, client_id = ?, name = ?, type = ?, labels = ?, group_ids = ?, host_info = ?,
136
+ status = ?, busy_source = NULL, busy_reason = NULL
137
+ WHERE id = ?`
138
+ ).run(
139
+ hashToken(resourceToken),
140
+ clientId,
141
+ name,
142
+ type,
143
+ JSON.stringify(labels),
144
+ JSON.stringify(groups),
145
+ JSON.stringify(hostInfo || {}),
146
+ nextStatus,
147
+ existing.id
148
+ );
149
+
150
+ events.record('resource', existing.id, 'resource.registered', {
151
+ hostInfo,
152
+ name,
153
+ type,
154
+ labels,
155
+ groups,
156
+ reregistered: true
157
+ });
158
+ return { resourceId: existing.id, resourceToken };
159
+ }
160
+
161
+ const id = `res_${uuid()}`;
162
+ db.prepare(
163
+ `INSERT INTO resources (id, name, type, status, labels, group_ids, host_info, token_hash, client_id, created_at)
164
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
165
+ ).run(
166
+ id,
167
+ name,
168
+ type,
169
+ RESOURCE_STATES.REGISTERED,
170
+ JSON.stringify(labels),
171
+ JSON.stringify(groups),
172
+ JSON.stringify(hostInfo || {}),
173
+ hashToken(resourceToken),
174
+ clientId,
175
+ new Date().toISOString()
176
+ );
177
+
178
+ events.record('resource', id, 'resource.registered', { name, type, hostInfo, groups, reregistered: false });
179
+ return { resourceId: id, resourceToken };
180
+ }
181
+
182
+ function setGroups(resourceId, groupIds){
183
+ db.prepare('UPDATE resources SET group_ids = ? WHERE id = ?').run(JSON.stringify(groupIds), resourceId);
184
+ return get(resourceId);
185
+ }
186
+
187
+ // Reconcile status from a heartbeat's self-reported state (§5.1).
188
+ function heartbeat(resourceId, { state, activeJobId, localLock, metrics } = {}){
189
+ const resource = get(resourceId);
190
+ if (!resource){
191
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
192
+ }
193
+
194
+ let nextStatus = resource.status,
195
+ busySource = resource.busy_source,
196
+ busyReason = resource.busy_reason;
197
+
198
+ if (localLock){
199
+ nextStatus = RESOURCE_STATES.BUSY;
200
+ busySource = BUSY_SOURCES.LOCAL;
201
+ }
202
+ else if (state === 'busy' && activeJobId){
203
+ nextStatus = RESOURCE_STATES.BUSY;
204
+ // busy_source stays whatever the scheduler assigned.
205
+ }
206
+ else if (
207
+ resource.status === RESOURCE_STATES.OUT_OF_SERVICE ||
208
+ resource.status === RESOURCE_STATES.REGISTERED
209
+ ){
210
+ nextStatus = RESOURCE_STATES.IDLE;
211
+ busySource = null;
212
+ busyReason = null;
213
+ }
214
+ else if (resource.status !== RESOURCE_STATES.MAINTENANCE && state === 'idle'){
215
+ nextStatus = RESOURCE_STATES.IDLE;
216
+ busySource = null;
217
+ busyReason = null;
218
+ }
219
+
220
+ db.prepare(
221
+ `UPDATE resources
222
+ SET last_heartbeat_at = ?, status = ?, busy_source = ?, busy_reason = ?
223
+ WHERE id = ?`
224
+ ).run(new Date().toISOString(), nextStatus, busySource, busyReason, resourceId);
225
+
226
+ if (resource.status !== nextStatus){
227
+ events.record('resource', resourceId, 'resource.status_changed', {
228
+ from: resource.status,
229
+ to: nextStatus
230
+ });
231
+ if (nextStatus === RESOURCE_STATES.IDLE){
232
+ bus.emit('resource.idle', { resourceId });
233
+ }
234
+ }
235
+
236
+ return { resource: get(resourceId), statusChanged: resource.status !== nextStatus };
237
+ }
238
+
239
+ // §8.4 local lock / unlock via the Client's Unix socket -> daemon -> Coordinator.
240
+ function setLocalLock(resourceId, { locked, reason }){
241
+ const resource = get(resourceId);
242
+ if (!resource){
243
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
244
+ }
245
+
246
+ if (locked){
247
+ if (resource.status === RESOURCE_STATES.BUSY && resource.busy_source !== BUSY_SOURCES.LOCAL){
248
+ throw Object.assign(new Error('Resource is running a job'), { status: 409 });
249
+ }
250
+ db.prepare('UPDATE resources SET status = ?, busy_source = ?, busy_reason = ? WHERE id = ?').run(
251
+ RESOURCE_STATES.BUSY,
252
+ BUSY_SOURCES.LOCAL,
253
+ reason || null,
254
+ resourceId
255
+ );
256
+ events.record('resource', resourceId, 'resource.locked', { reason });
257
+ }
258
+ else {
259
+ db.prepare('UPDATE resources SET status = ?, busy_source = NULL, busy_reason = NULL WHERE id = ?').run(
260
+ RESOURCE_STATES.IDLE,
261
+ resourceId
262
+ );
263
+ events.record('resource', resourceId, 'resource.unlocked', {});
264
+ bus.emit('resource.idle', { resourceId });
265
+ }
266
+ return get(resourceId);
267
+ }
268
+
269
+ function markOutOfService(resourceId){
270
+ const resource = get(resourceId);
271
+ if (!resource || resource.status === RESOURCE_STATES.OUT_OF_SERVICE){
272
+ return resource;
273
+ }
274
+ db.prepare('UPDATE resources SET status = ? WHERE id = ?').run(RESOURCE_STATES.OUT_OF_SERVICE, resourceId);
275
+ events.record('resource', resourceId, 'resource.oos', {});
276
+ return get(resourceId);
277
+ }
278
+
279
+ function markIdleAfterJob(resourceId){
280
+ db.prepare(
281
+ `UPDATE resources
282
+ SET status = ?, busy_source = NULL, busy_reason = NULL, last_job_finished_at = ?
283
+ WHERE id = ? AND status != ?`
284
+ ).run(RESOURCE_STATES.IDLE, new Date().toISOString(), resourceId, RESOURCE_STATES.MAINTENANCE);
285
+ bus.emit('resource.idle', { resourceId });
286
+ }
287
+
288
+ function setMaintenance(resourceId, enabled){
289
+ const resource = get(resourceId);
290
+ if (!resource){
291
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
292
+ }
293
+ const status = enabled ? RESOURCE_STATES.MAINTENANCE : RESOURCE_STATES.IDLE;
294
+ db.prepare('UPDATE resources SET status = ? WHERE id = ?').run(status, resourceId);
295
+ events.record('resource', resourceId, enabled ? 'resource.maintenance_on' : 'resource.maintenance_off', {});
296
+ if (!enabled){
297
+ bus.emit('resource.idle', { resourceId });
298
+ }
299
+ return get(resourceId);
300
+ }
301
+
302
+ // Used by the scheduler under a single write transaction.
303
+ function assignToJob(resourceId, busySource){
304
+ db.prepare('UPDATE resources SET status = ?, busy_source = ? WHERE id = ?').run(
305
+ RESOURCE_STATES.BUSY,
306
+ busySource,
307
+ resourceId
308
+ );
309
+ }
310
+
311
+ function findIdleCandidates(type, labels, groupId){
312
+ const rows = db
313
+ .prepare('SELECT * FROM resources WHERE status = ? AND type = ?')
314
+ .all(RESOURCE_STATES.IDLE, type)
315
+ .map(rowToResource);
316
+ return rows.filter(
317
+ (r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
318
+ );
319
+ }
320
+
321
+ function everSatisfiable(type, labels, groupId){
322
+ const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
323
+ return rows.some(
324
+ (r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
325
+ );
326
+ }
327
+
328
+ return {
329
+ get,
330
+ getByName,
331
+ getByClientId,
332
+ getByTokenHash,
333
+ list,
334
+ registerAuto,
335
+ setGroups,
336
+ heartbeat,
337
+ setLocalLock,
338
+ markOutOfService,
339
+ markIdleAfterJob,
340
+ setMaintenance,
341
+ assignToJob,
342
+ findIdleCandidates,
343
+ everSatisfiable
344
+ };
345
+ }
346
+
347
+ module.exports = { createRegistryService };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/retention.js
3
+ * @description Deletes finished jobs' logs/artifacts past their retention window (README §9)
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
+
18
+ const DAY_MS = 86400 * 1000;
19
+
20
+ // §9: fold job_logs into a console.log artifact when a job finishes, purge
21
+ // old logs/artifacts on the configured retention windows, and take a nightly
22
+ // consistent backup via VACUUM INTO.
23
+ function createRetentionService(db, { bus, logs, artifacts, config }){
24
+ bus.on('job.finished', ({ jobId }) => {
25
+ const text = logs.renderConsoleLog(jobId);
26
+ if (text){
27
+ artifacts.storeGenerated(jobId, 'console.log', Buffer.from(text, 'utf8'), 'text/plain');
28
+ }
29
+ });
30
+
31
+ function runDaily(){
32
+ logs.purgeOlderThan(config.retention.logRetentionDays);
33
+ artifacts.purgeOlderThan(config.retention.artifactRetentionDays);
34
+ const backupPath = path.join(config.dataDir, `backup-${new Date().toISOString().slice(0, 10)}.db`);
35
+ db.exec(`VACUUM INTO '${backupPath.replace(/'/g, '\'\'')}'`);
36
+ }
37
+
38
+ const timer = setInterval(runDaily, DAY_MS);
39
+ timer.unref?.();
40
+
41
+ return { runDaily, stop: () => clearInterval(timer) };
42
+ }
43
+
44
+ module.exports = { createRetentionService };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/scheduler.js
3
+ * @description Scheduler: matches queued jobs to idle resources by type/labels/group (README §5.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 { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
17
+
18
+ function rowToJob(row){
19
+ return { ...row, spec: JSON.parse(row.spec) };
20
+ }
21
+
22
+ // §5.4: a single SQLite write transaction picks the oldest highest-priority
23
+ // queued job for each idle, label-matching resource. Running it inside
24
+ // BEGIN IMMEDIATE plus better-sqlite3's synchronous execution is what makes
25
+ // double-assignment impossible.
26
+ function createScheduler(db, { bus, events, registry, config }){
27
+ let scheduled = false;
28
+
29
+ function runPass(){
30
+ const tx = db.transaction(() => {
31
+ const queued = db
32
+ .prepare('SELECT * FROM jobs WHERE state = \'QUEUED\' ORDER BY priority DESC, created_at ASC')
33
+ .all()
34
+ .map(rowToJob),
35
+
36
+ assignments = [],
37
+ claimedResourceIds = new Set();
38
+
39
+ for (const job of queued){
40
+ const candidates = registry
41
+ .findIdleCandidates(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group)
42
+ .filter((r) => !claimedResourceIds.has(r.id));
43
+ if (candidates.length === 0){
44
+ continue;
45
+ }
46
+
47
+ // Least-recently-used: spread wear across benches (§5.4).
48
+ candidates.sort((a, b) => {
49
+ const ta = a.last_job_finished_at ? new Date(a.last_job_finished_at).getTime() : 0,
50
+ tb = b.last_job_finished_at ? new Date(b.last_job_finished_at).getTime() : 0;
51
+ return ta - tb;
52
+ });
53
+ const resource = candidates[0];
54
+ claimedResourceIds.add(resource.id);
55
+
56
+ const busySource = job.source === 'ci' ? BUSY_SOURCES.CI : BUSY_SOURCES.CLI,
57
+ now = new Date().toISOString();
58
+
59
+ db.prepare('UPDATE jobs SET state = ?, resource_id = ?, assigned_at = ? WHERE id = ?').run(
60
+ JOB_STATES.ASSIGNED,
61
+ resource.id,
62
+ now,
63
+ job.id
64
+ );
65
+ registry.assignToJob(resource.id, busySource);
66
+ events.record('job', job.id, 'job.state', { from: JOB_STATES.QUEUED, to: JOB_STATES.ASSIGNED, resourceId: resource.id });
67
+
68
+ assignments.push({ jobId: job.id, resourceId: resource.id });
69
+ }
70
+
71
+ return assignments;
72
+ }),
73
+
74
+ assignments = tx();
75
+ for (const { jobId, resourceId }of assignments){
76
+ bus.emit('job.assigned', { jobId, resourceId });
77
+ }
78
+ return assignments;
79
+ }
80
+
81
+ function scheduleSoon(){
82
+ if (scheduled){
83
+ return;
84
+ }
85
+ scheduled = true;
86
+ setImmediate(() => {
87
+ scheduled = false;
88
+ runPass();
89
+ });
90
+ }
91
+
92
+ bus.on('job.queued', scheduleSoon);
93
+ bus.on('resource.idle', scheduleSoon);
94
+
95
+ const tickInterval = setInterval(runPass, (config.scheduler.tickIntervalSec || 10) * 1000);
96
+ tickInterval.unref?.();
97
+
98
+ return { runPass, stop: () => clearInterval(tickInterval) };
99
+ }
100
+
101
+ module.exports = { createScheduler };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/tokens.js
3
+ * @description Generates and hashes bearer tokens (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
+
18
+ // §12: tokens are 256-bit random, stored as SHA-256 hashes, shown once.
19
+ function generateToken(prefix){
20
+ const raw = crypto.randomBytes(32).toString('base64url');
21
+ return prefix ? `${prefix}_${raw}` : raw;
22
+ }
23
+
24
+ function hashToken(token){
25
+ return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
26
+ }
27
+
28
+ module.exports = { generateToken, hashToken };