'use strict'; const express = require('express'); const config = require('../config'); const { invites, clients, audit } = require('../db'); const { uuid, randomToken, sha256 } = require('../crypto'); const { requireAuth, requireAdmin } = require('../auth'); const router = express.Router(); const { baseUrl } = config; function inviteLink(req, kind, token) { return `${baseUrl(req)}/${kind === 'enroll' ? 'enroll' : 's'}/${token}`; } function publicInvite(i) { return { id: i.id, kind: i.kind, clientId: i.client_id, clientName: i.client_name, label: i.label, role: i.role, maxUses: i.max_uses, uses: i.uses, expiresAt: i.expires_at, revokedAt: i.revoked_at, lastUsedAt: i.last_used_at, createdBy: i.created_by, createdAt: i.created_at, status: i.revoked_at ? 'revoked' : i.expires_at && i.expires_at < Date.now() ? 'expired' : i.max_uses > 0 && i.uses >= i.max_uses ? 'used up' : 'active', }; } router.use(requireAuth, requireAdmin); router.get('/', (req, res) => { res.json({ invites: invites.list().map(publicInvite) }); }); /** * Two kinds of invite: * - enroll: hand to a *machine*. Redeeming it registers a new client. * - session: hand to a *person*. Redeeming it grants time-boxed access to one client. */ router.post('/', (req, res) => { const body = req.body || {}; const kind = body.kind === 'enroll' ? 'enroll' : 'session'; let clientId = null; let role = null; let prefill = null; if (kind === 'session') { if (!config.allowSessionInvites) { return res.status(403).json({ error: 'session invites are disabled on this server' }); } const client = clients.get(String(body.clientId || '')); if (!client) return res.status(400).json({ error: 'a valid clientId is required for a session invite' }); clientId = client.id; role = body.role === 'operator' ? 'operator' : 'viewer'; } else { // Defaults applied to whatever machine redeems this enrollment link. prefill = JSON.stringify({ name: body.name ? String(body.name).trim() : null, tags: body.tags ? String(body.tags) : '', requireConsent: body.requireConsent ? 1 : 0, }); if (body.clientId) { // Re-enrolling an existing client (replace a machine, rotate its key). const client = clients.get(String(body.clientId)); if (!client) return res.status(400).json({ error: 'no such client' }); clientId = client.id; } } const ttlMs = Number(body.ttlMs) > 0 ? Number(body.ttlMs) : config.inviteDefaultTtlMs; // An enrollment link is meant for exactly one machine. A support link should // survive a dropped connection, so it defaults to unlimited uses until it expires. const defaultMaxUses = kind === 'enroll' ? 1 : 0; const maxUses = body.maxUses === undefined || body.maxUses === null || body.maxUses === '' ? defaultMaxUses : Math.max(0, Number.parseInt(body.maxUses, 10) || 0); const token = randomToken(32); const invite = invites.create({ id: uuid(), token_hash: sha256(token), kind, client_id: clientId, label: body.label ? String(body.label).slice(0, 200) : null, role, prefill, max_uses: maxUses, expires_at: Date.now() + ttlMs, created_by: req.username, }); audit(req.username, 'invite.create', invite.id, { kind, clientId, role, maxUses, ttlMs }); // The token itself is shown exactly once; only its hash is persisted. res.status(201).json({ invite: publicInvite({ ...invite, client_name: clientId ? clients.get(clientId)?.name : null }), url: inviteLink(req, kind, token), token, }); }); router.post('/:id/revoke', (req, res) => { const invite = invites.get(req.params.id); if (!invite) return res.status(404).json({ error: 'no such invite' }); invites.revoke(invite.id); audit(req.username, 'invite.revoke', invite.id, null); res.json({ ok: true }); }); router.delete('/:id', (req, res) => { const invite = invites.get(req.params.id); if (!invite) return res.status(404).json({ error: 'no such invite' }); invites.remove(invite.id); audit(req.username, 'invite.delete', invite.id, null); res.json({ ok: true }); }); module.exports = { router, baseUrl, publicInvite };