// Admin email invites. A staff member invites someone by email at a pre-chosen // access level; the invitee accepts via a tokened link that creates their website // user at that role. The opaque token lives only in the emailed link — the DB // stores just its sha256 hash (like mobile refresh tokens), so a DB read never // yields a usable invite. Invites are single-use and expiring. const crypto = require('crypto') const db = require('./invites.db') const DEFAULT_TTL_DAYS = 7 function hashToken(raw) { return crypto.createHash('sha256').update(String(raw)).digest('hex') } // Public-safe shape (never exposes the token hash). function toSafe(row) { if (!row) return null return { id: row.id, email: row.email, role: row.role, status: row.status, invitedBy: row.invited_by, acceptedUserId: row.accepted_user_id, expiresAt: row.expires_at, createdAt: row.created_at, acceptedAt: row.accepted_at, expired: new Date(row.expires_at).getTime() < Date.now(), } } // Create an invite. Returns { invite, token } — the plaintext token is returned // ONCE (for the email link) and never stored or recoverable afterwards. async function create({ email, role, invitedBy, ttlDays = DEFAULT_TTL_DAYS }) { const token = crypto.randomBytes(32).toString('base64url') const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000) const id = await db.insert({ tokenHash: hashToken(token), email, role, invitedBy, expiresAt }) return { invite: toSafe(await db.getById(id)), token } } // Resolve a pending, unexpired invite from its plaintext token, else null. Returns // the RAW row (incl. id) for the accept flow; callers sanitize with publicView. async function findValidByToken(token) { if (!token) return null const row = await db.findByTokenHash(hashToken(token)) if (!row || row.status !== 'pending') return null if (new Date(row.expires_at).getTime() < Date.now()) return null return row } // Atomically consume a pending invite (double-accept-safe). Returns true if this // call won the race and bound the invite to userId. async function accept(id, userId) { return (await db.markAccepted(id, userId)) === 1 } const revoke = (id) => db.revoke(id) async function list(limit = 100) { const n = Math.min(Math.max(Number(limit) || 100, 1), 500) const rows = await db.listRecent(n) return rows.map(toSafe) } // A minimal, safe view of an invite for the (unauthenticated) accept page — // only what the form needs, never the token or internal ids. function publicView(row) { if (!row) return null return { email: row.email, role: row.role } } module.exports = { create, findValidByToken, accept, revoke, list, publicView, toSafe, hashToken }