ids.js (3106B)
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 export const newWorkerAgentId = () => randomId(16); 40 export const newRefreshTokenId = () => randomId(16); 41 42 // Task ids carry no structure at all. A worker holding one cannot tell which 43 // job or project it belongs to, which is the point: a worker is given a 44 // context and a script, and has no business knowing what they are for. 45 // 46 // It also removes a class of nuisance. The previous form embedded the task 47 // name, so an id could contain ':' ',' and '=', and every use had to escape 48 // it for a URL, sanitize it for a container name and encode it for a storage 49 // key. Time ordered so that tasks of one job still group naturally. 50 export const newTaskId = () => timeOrderedId(); 51 52 // Worker and session tokens. The plaintext is shown once and only its hash 53 // is stored, so a database leak does not yield usable credentials. 54 export function newToken() { 55 return crypto.randomBytes(32).toString('hex'); 56 } 57 58 export function hashToken(token) { 59 return crypto.createHash('sha256').update(String(token)).digest('hex'); 60 } 61 62 // Compares two hex digests without leaking position through timing. 63 export function safeEqualHex(a, b) { 64 if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false; 65 return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); 66 } 67 68 // A project id derived from a human supplied name, for the common case where 69 // an operator wants a readable slug rather than a random string. 70 export function slugify(input, fallbackLength = 16) { 71 const slug = String(input) 72 .toLowerCase() 73 .replace(/[^a-z0-9]+/g, '-') 74 .replace(/^-+|-+$/g, '') 75 .slice(0, 64); 76 return slug || randomId(fallbackLength); 77 }