conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }