workers.js (5971B)
1 // src/lib/workers.js - worker registration tokens 2 // 3 // A worker authenticates with a bearer token. Only the SHA-256 of the token 4 // is stored, so the database never holds a usable credential; the plaintext 5 // is shown once at creation and cannot be recovered afterwards. 6 // 7 // A token may belong to a user, in which case it is only ever offered jobs 8 // from that user's projects. A token with no owner is shared and can run 9 // anything, which is how an administrator provides general capacity. That 10 // distinction is enforced in the scheduler, not here. 11 // 12 // A token is not a machine: several hosts may share one, so what each 13 // advertises is kept per reported name in worker_agents. 14 15 import { newToken, hashToken, newWorkerTokenId, newWorkerAgentId } from './ids.js'; 16 import { paginateOffset } from './page.js'; 17 18 function parseList(value) { 19 try { 20 const parsed = JSON.parse(value); 21 return Array.isArray(parsed) ? parsed : []; 22 } catch { 23 return []; 24 } 25 } 26 27 export function createWorkerTokens({ db }) { 28 return { 29 async create(name, { ownerId = null } = {}) { 30 if (!name || typeof name !== 'string') throw new Error('worker token needs a name'); 31 const token = newToken(); 32 const id = newWorkerTokenId(); 33 await db.run( 34 `INSERT INTO worker_tokens (id, name, token_hash, enabled, owner_id, created_at) 35 VALUES ({id}, {name}, {hash}, 1, {owner}, {now})`, 36 { id, name, hash: hashToken(token), owner: ownerId, now: Date.now() } 37 ); 38 // The only time the plaintext exists outside the worker. 39 return { id, name, token, owner_id: ownerId }; 40 }, 41 42 async get(id) { 43 return db.get( 44 'SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip FROM worker_tokens WHERE id = {id}', 45 { id } 46 ); 47 }, 48 49 // Returns the token record, or null. Lookup is by hash, so a timing 50 // difference cannot reveal anything beyond whether a hash exists. 51 async verify(presented, { ip = null } = {}) { 52 if (typeof presented !== 'string' || presented.length === 0) return null; 53 const hash = hashToken(presented); 54 const row = await db.get( 55 'SELECT id, name, enabled, owner_id FROM worker_tokens WHERE token_hash = {hash}', 56 { hash } 57 ); 58 if (!row || row.enabled !== 1) return null; 59 60 await db.run( 61 'UPDATE worker_tokens SET last_seen_at = {now}, last_ip = {ip} WHERE id = {id}', 62 { id: row.id, now: Date.now(), ip } 63 ); 64 return { id: row.id, name: row.name, owner_id: row.owner_id }; 65 }, 66 67 // Upsert syntax is not portable, so read and write share a transaction. 68 async recordAgent({ tokenId, name, arches = [], features = [], ip = null }) { 69 const label = typeof name === 'string' ? name.trim().slice(0, 255) : ''; 70 if (!tokenId || !label) return; 71 72 const now = Date.now(); 73 const values = { arches: JSON.stringify(arches), features: JSON.stringify(features) }; 74 75 await db.transaction(async (tx) => { 76 const row = await tx.get( 77 'SELECT id FROM worker_agents WHERE token_id = {token} AND name = {name}', 78 { token: tokenId, name: label } 79 ); 80 if (row) { 81 await tx.run( 82 `UPDATE worker_agents 83 SET arches = {arches}, features = {features}, last_seen_at = {now}, last_ip = {ip} 84 WHERE id = {id}`, 85 { id: row.id, ...values, now, ip } 86 ); 87 return; 88 } 89 await tx.run( 90 `INSERT INTO worker_agents 91 (id, token_id, name, arches, features, first_seen_at, last_seen_at, last_ip) 92 VALUES ({id}, {token}, {name}, {arches}, {features}, {now}, {now}, {ip})`, 93 { id: newWorkerAgentId(), token: tokenId, name: label, ...values, now, ip } 94 ); 95 }); 96 }, 97 98 async agentsFor(tokenId) { 99 const rows = await db.all( 100 `SELECT id, name, arches, features, first_seen_at, last_seen_at, last_ip 101 FROM worker_agents WHERE token_id = {token} ORDER BY last_seen_at DESC`, 102 { token: tokenId } 103 ); 104 return rows.map((row) => ({ 105 ...row, 106 arches: parseList(row.arches), 107 features: parseList(row.features), 108 })); 109 }, 110 111 async list() { 112 return db.all( 113 `SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip 114 FROM worker_tokens ORDER BY created_at DESC` 115 ); 116 }, 117 118 async listVisible(user) { 119 if (user && user.role === 'admin') return this.list(); 120 if (!user) return []; 121 return db.all( 122 `SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip 123 FROM worker_tokens WHERE owner_id = {owner} ORDER BY created_at DESC`, 124 { owner: user.id } 125 ); 126 }, 127 128 // One page of listVisible. An anonymous caller sees nothing, so the 129 // window is empty rather than a query nobody needs. 130 async pageVisible(user, { offset, limit }) { 131 if (!user) return { rows: [], total: 0, offset, limit }; 132 const admin = user.role === 'admin'; 133 return paginateOffset(db, { 134 select: 'id, name, enabled, owner_id, created_at, last_seen_at, last_ip', 135 from: 'worker_tokens', 136 where: admin ? '1 = 1' : 'owner_id = {owner}', 137 order: 'created_at DESC', 138 params: admin ? {} : { owner: user.id }, 139 offset, 140 limit, 141 }); 142 }, 143 144 async setEnabled(id, enabled) { 145 const res = await db.run( 146 'UPDATE worker_tokens SET enabled = {enabled} WHERE id = {id}', 147 { id, enabled: enabled ? 1 : 0 } 148 ); 149 return res.changes > 0; 150 }, 151 152 async remove(id) { 153 const res = await db.run('DELETE FROM worker_tokens WHERE id = {id}', { id }); 154 return res.changes > 0; 155 }, 156 }; 157 } 158 159 export function canManageWorker(user, token) { 160 if (!user || !token) return false; 161 if (user.role === 'admin') return true; 162 return token.owner_id !== null && token.owner_id === user.id; 163 }