ids.js (3001B)
1 // src/lib/ids.js - identifier and token generation 2 // 3 // Identifiers are generated by the application rather than by the database, 4 // because lastInsertId is not portable across the three supported dialects 5 // and because a conductor needs an id before it has written anything. 6 7 import crypto from 'node:crypto'; 8 9 // Crockford base32 without the ambiguous letters I, L, O and U. Case 10 // insensitive and safe to read aloud or paste into a URL. 11 const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'; 12 13 export function randomId(length = 24) { 14 // Rejection free: 256 is not a multiple of 32, so mask to 5 bits. 15 const bytes = crypto.randomBytes(length); 16 let out = ''; 17 for (let i = 0; i < length; i += 1) out += ALPHABET[bytes[i] & 31]; 18 return out; 19 } 20 21 // Sortable by creation time, which keeps job listings stable without relying 22 // on a clock skewed created_at. 8 chars of millisecond timestamp in base32 23 // followed by 12 random chars. 24 export function timeOrderedId(now = Date.now()) { 25 let stamp = ''; 26 let remaining = now; 27 for (let i = 0; i < 8; i += 1) { 28 stamp = ALPHABET[remaining % 32] + stamp; 29 remaining = Math.floor(remaining / 32); 30 } 31 return stamp + randomId(12); 32 } 33 34 export const newJobId = () => timeOrderedId(); 35 export const newProjectId = () => randomId(16); 36 export const newArtifactId = () => randomId(24); 37 export const newUserId = () => randomId(16); 38 export const newWorkerTokenId = () => randomId(16); 39 40 // Task ids carry no structure at all. A worker holding one cannot tell which 41 // job or project it belongs to, which is the point: a worker is given a 42 // context and a script, and has no business knowing what they are for. 43 // 44 // It also removes a class of nuisance. The previous form embedded the task 45 // name, so an id could contain ':' ',' and '=', and every use had to escape 46 // it for a URL, sanitize it for a container name and encode it for a storage 47 // key. Time ordered so that tasks of one job still group naturally. 48 export const newTaskId = () => timeOrderedId(); 49 50 // Worker and session tokens. The plaintext is shown once and only its hash 51 // is stored, so a database leak does not yield usable credentials. 52 export function newToken() { 53 return crypto.randomBytes(32).toString('hex'); 54 } 55 56 export function hashToken(token) { 57 return crypto.createHash('sha256').update(String(token)).digest('hex'); 58 } 59 60 // Compares two hex digests without leaking position through timing. 61 export function safeEqualHex(a, b) { 62 if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false; 63 return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); 64 } 65 66 // A project id derived from a human supplied name, for the common case where 67 // an operator wants a readable slug rather than a random string. 68 export function slugify(input, fallbackLength = 16) { 69 const slug = String(input) 70 .toLowerCase() 71 .replace(/[^a-z0-9]+/g, '-') 72 .replace(/^-+|-+$/g, '') 73 .slice(0, 64); 74 return slug || randomId(fallbackLength); 75 }