// ============================================================ // REGISTRATION INVITES // registration_enabled is open-or-closed. This is the middle setting: open to // people you invited. One module owns the rules so the register route and the // admin routes cannot disagree about what "valid" means. // // A code is single-use, expires, and can be revoked. It is stored hashed, // because an invite grants account creation and a database dump should not // hand someone a working one. // ============================================================ var crypto = require('crypto'); // Required lazily. The code-generation and hashing helpers are pure, and // requiring the database at module load made simply importing this file open a // connection — which is why a test that only checked those helpers hung. function db() { return require('../db/database'); } var DEFAULT_TTL_DAYS = 7; var MAX_TTL_DAYS = 90; // Crockford-style: no I, L, O or U, so a code read aloud or copied off a screen // is hard to mistype and cannot spell anything unfortunate. var ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ'; function generateCode() { var bytes = crypto.randomBytes(16); var out = ''; for (var i = 0; i < 16; i++) { out += ALPHABET[bytes[i] % ALPHABET.length]; if (i % 4 === 3 && i !== 15) out += '-'; } return out; // XXXX-XXXX-XXXX-XXXX } function normalize(code) { return String(code == null ? '' : code).toUpperCase().replace(/[^0-9A-Z]/g, ''); } function hash(code) { return crypto.createHash('sha256').update(normalize(code)).digest('hex'); } function ttlDays(requested) { var days = Number(requested); if (!Number.isFinite(days) || days < 1) return DEFAULT_TTL_DAYS; return Math.min(Math.floor(days), MAX_TTL_DAYS); } // Returns the code once. It is never recoverable afterwards — only its hash and // the last four characters are kept. async function create(adminUserId, options) { var opts = options || {}; var code = generateCode(); var days = ttlDays(opts.days); var note = String(opts.note || '').trim().slice(0, 200); await db().run( "INSERT INTO registration_invites (code_hash, code_hint, note, created_by, expires_at) " + "VALUES ($1, $2, $3, $4, NOW() + ($5 || ' days')::interval)", [hash(code), normalize(code).slice(-4), note, adminUserId, String(days)] ); return { code: code, days: days, note: note }; } async function list() { return db().all( "SELECT i.id, i.code_hint, i.note, i.created_at, i.expires_at, i.used_at, i.revoked_at, " + " c.email AS created_by_email, u.email AS used_by_email, " + " CASE WHEN i.revoked_at IS NOT NULL THEN 'revoked' " + " WHEN i.used_at IS NOT NULL THEN 'used' " + " WHEN i.expires_at <= NOW() THEN 'expired' " + " ELSE 'active' END AS status " + "FROM registration_invites i " + "LEFT JOIN users c ON c.id = i.created_by " + "LEFT JOIN users u ON u.id = i.used_by " + "ORDER BY i.created_at DESC LIMIT 200", [] ); } async function revoke(id, adminUserId) { // Revoking an already-used code would rewrite history, so only a live one. var result = await db().run( 'UPDATE registration_invites SET revoked_at = NOW(), revoked_by = $1 ' + 'WHERE id = $2 AND revoked_at IS NULL AND used_at IS NULL', [adminUserId, id] ); return result.changes > 0; } /** * Delete a spent invitation. * * Used codes only. A code that has not been used yet is one somebody may still * be holding: deleting it takes it out of the list without taking it out of * their inbox, and there is then no record of who it went to or why it stopped * working. Revoke does that job — it leaves the row, marked. This is only for * clearing away codes whose whole story is already told. * * Returns false for a code that is not spent, which the caller reports rather * than treating as a missing row. */ async function remove(id) { var result = await db().run( 'DELETE FROM registration_invites WHERE id = $1 AND used_at IS NOT NULL', [id]); return result.changes > 0; } // Every spent invitation at once, which is what "they clutter the list" asks // for. Same rule: nothing unused is touched. async function removeUsed() { var result = await db().run('DELETE FROM registration_invites WHERE used_at IS NOT NULL'); return result.changes || 0; } /** * Claim a code for a registration, atomically. * * The UPDATE carries every condition, so two registrations racing the same code * cannot both succeed: the second matches no row. Checking first and updating * after would leave exactly that gap. * * Returns the invite id, or null if the code is unusable for any reason — * unknown, expired, revoked or already used. The caller must not say which: * distinguishing them tells someone probing codes which guesses were closer. */ async function claim(code, userId) { var normalized = normalize(code); if (!normalized) return null; var rows = await db().all( 'UPDATE registration_invites SET used_at = NOW(), used_by = $1 ' + 'WHERE code_hash = $2 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > NOW() ' + 'RETURNING id', [userId, hash(normalized)] ); return rows && rows.length ? rows[0].id : null; } // True when a code must be supplied to register at all. async function inviteOnly() { return String(await db().getSetting('registration_invite_only') || 'false') === 'true'; } module.exports = { DEFAULT_TTL_DAYS, MAX_TTL_DAYS, generateCode, normalize, hash, ttlDays, create, list, revoke, remove, removeUsed, claim, inviteOnly };