/** * Invite store — DC-048. * * Single-use invite tokens with TTL. Admin generates an invite for an email; * the system emails (or logs in dev) a magic-link-style URL containing the * raw token. The recipient clicks → accepts → becomes an authorized user. * * Storage: data/invites.json. Atomic writes via tmp+rename. * * Token shape: * - 32 random bytes, base64url-encoded (256 bits of entropy). * - We store ONLY the SHA-256 hash on disk. The raw token lives in the * email + in the URL query string; on the server we hash and look up. * A read-only compromise of invites.json cannot forge acceptance. * * Lifecycle: * - issue({ email, role, ttlMs, invitedBy }) → { id, token, expiresAt, ... } * token is the only time the raw token will ever be returned. * - peek(token) → { email, role, expiresAt, usedAt } | null * (returns the public-safe info without consuming the token) * - accept(token) → { ok: true, invite } | { ok: false, reason } * reasons: 'not_found', 'expired', 'already_used' * - revoke(id) → removes the invite by id (admin-only). * - list() → all outstanding invites (admin-only). */ 'use strict'; const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const platformPaths = require('../../platform-paths'); const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d function _nowMs() { return Date.now(); } function _nowIso() { return new Date().toISOString(); } function _atomicWriteJSON(filePath, data) { const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now(); fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); fs.renameSync(tmp, filePath); } function _readJSON(filePath, fallback) { try { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } catch (err) { if (err && err.code === 'ENOENT') return fallback; return fallback; } } function _defaultData() { return { invites: {} }; } function _sha256(s) { return crypto.createHash('sha256').update(s, 'utf8').digest('hex'); } function createInviteStore(opts = {}) { // Same defensive resolver as user-store — universal-deps test proxies // can return function-typed values for property access. const candidates = [ opts.dataDir, opts.platformPaths && opts.platformPaths.dataDir, platformPaths && platformPaths.dataDir, ]; const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0) || require('os').tmpdir(); const log = opts.log || { info() {}, warn() {}, error() {} }; const file = path.join(dataDir, 'invites.json'); let _mutex = Promise.resolve(); function _enqueue(fn) { const next = _mutex.then(fn, fn); _mutex = next.catch(() => {}); return next; } function _load() { const data = _readJSON(file, _defaultData()); if (!data.invites || typeof data.invites !== 'object') data.invites = {}; return data; } function _save(data) { _atomicWriteJSON(file, data); } function _prune(data) { const cutoff = _nowMs() - PRUNE_AFTER_MS; for (const id of Object.keys(data.invites)) { const inv = data.invites[id]; if (!inv) { delete data.invites[id]; continue; } const isTerminal = inv.usedAt || (inv.expiresAt && new Date(inv.expiresAt).getTime() < cutoff); if (isTerminal) delete data.invites[id]; } return data; } /** * Issue a new invite. Returns the raw token (only time it leaves the system). */ function issue({ email, role = 'operator', ttlMs = DEFAULT_TTL_MS, invitedBy = 'admin' } = {}) { return _enqueue(() => { if (typeof email !== 'string' || !email.includes('@')) { return { ok: false, reason: 'invalid_email' }; } const normalized = email.toLowerCase().trim(); const id = crypto.randomUUID(); const token = crypto.randomBytes(32).toString('base64url'); const hash = _sha256(token); const issuedAt = _nowIso(); const expiresAt = new Date(_nowMs() + ttlMs).toISOString(); const data = _load(); _prune(data); data.invites[id] = { id, hash, email: normalized, role, invitedBy, issuedAt, expiresAt, usedAt: null, usedBy: null, }; _save(data); log.info && log.info('invite', 'invite issued', { id, email: normalized, role, invitedBy, ttlMs, }); return { ok: true, id, token, // raw token — caller emails it email: normalized, role, expiresAt, ttlMs, }; }); } /** * Public-safe peek. Does NOT consume the token. * Returns null if not found, expired, or already used (same response * for all three — enumeration prevention). */ function peek(token) { if (!token || typeof token !== 'string') return null; return _enqueue(() => { const data = _load(); const hash = _sha256(token); const inv = _findByHash(data, hash); if (!inv) return null; if (inv.usedAt) return null; if (new Date(inv.expiresAt).getTime() < _nowMs()) return null; return { id: inv.id, email: inv.email, role: inv.role, expiresAt: inv.expiresAt, issuedAt: inv.issuedAt, }; }); } /** * Consume an invite token. Returns the invite record on success. * After accept(), the invite is marked used (NOT deleted) so the admin * can see who redeemed what. The auto-prune reaps it after 7 days. */ function accept(token, { acceptedBy } = {}) { return _enqueue(() => { if (!token || typeof token !== 'string') { return { ok: false, reason: 'not_found' }; } const data = _load(); const hash = _sha256(token); const inv = _findByHash(data, hash); if (!inv) return { ok: false, reason: 'not_found' }; if (inv.usedAt) return { ok: false, reason: 'already_used' }; if (new Date(inv.expiresAt).getTime() < _nowMs()) { return { ok: false, reason: 'expired' }; } inv.usedAt = _nowIso(); inv.usedBy = acceptedBy || null; _save(data); log.info && log.info('invite', 'invite accepted', { id: inv.id, email: inv.email, role: inv.role, acceptedBy, }); return { ok: true, invite: { id: inv.id, email: inv.email, role: inv.role, expiresAt: inv.expiresAt, usedAt: inv.usedAt, }, }; }); } /** * Admin-only. Revoke an outstanding invite by id. */ function revoke(id) { return _enqueue(() => { const data = _load(); if (!data.invites[id]) return { ok: false, reason: 'not_found' }; delete data.invites[id]; _save(data); log.info && log.info('invite', 'invite revoked', { id }); return { ok: true }; }); } /** * Admin-only. List outstanding invites (excludes used/expired). */ function listOutstanding() { return _enqueue(() => { const data = _load(); _prune(data); _save(data); const now = _nowMs(); return Object.values(data.invites) .filter(inv => !inv.usedAt && new Date(inv.expiresAt).getTime() > now) .sort((a, b) => new Date(a.expiresAt) - new Date(b.expiresAt)) .map(inv => ({ id: inv.id, email: inv.email, role: inv.role, invitedBy: inv.invitedBy, issuedAt: inv.issuedAt, expiresAt: inv.expiresAt, })); }); } function _findByHash(data, hash) { for (const id of Object.keys(data.invites)) { const inv = data.invites[id]; if (inv && inv.hash === hash) return inv; } return null; } return { issue, peek, accept, revoke, listOutstanding, DEFAULT_TTL_MS, }; } module.exports = { createInviteStore, DEFAULT_TTL_MS };