/** * Network lifecycle routes — DC-143 (specs/PRIVATE-NETWORK-SPEC.md §5). * * Standalone router (same factory pattern as routes/network-people.js); * auth middleware is the caller's job: * * POST /network/people/:id/block → block person (deny network, expire * device keys, kill sessions, purge * grants with snapshot for restore) * POST /network/people/:id/unblock → honest unblock (restore grants + * status; Tailscale devices must sign * in again — response says so) * POST /network/people/:id/remove → remove person + provider devices + * grants + sessions * POST /network/devices/:id/revoke → revoke one device (body: {personId}) * * Errors: LastAdminError → 409, ProviderNotActiveError → 503, other * ProviderError → 502, NotFound → 404, ValidationError → 400. */ 'use strict'; const { ProviderError, ProviderNotActiveError } = require('../src/network/errors'); const { LastAdminError } = require('../src/security/network-people-store'); const { ok } = require('../src/utils/responses'); function _statusFor(err) { if (err instanceof LastAdminError) return 409; if (err instanceof ProviderNotActiveError) return 503; if (err instanceof ProviderError) return 502; if (err && err.statusCode) return err.statusCode; if (err && err.name === 'ValidationError') return 400; if (err && err.name === 'NotFoundError') return 404; return 500; } module.exports = function networkLifecycleRoutesFactory({ lifecycle, asyncHandler, log = { info() {}, warn() {}, error() {} }, } = {}) { const router = require('express').Router(); // eslint-disable-line global-require if (!lifecycle) { throw new Error('networkLifecycleRoutes requires a lifecycle service'); } if (!asyncHandler) { if (process.env.NODE_ENV !== 'test') { throw new Error('networkLifecycleRoutes requires asyncHandler'); } /* eslint-disable no-param-reassign */ asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); /* eslint-enable no-param-reassign */ } const wrap = (fn) => asyncHandler(async (req, res) => { try { await fn(req, res); } catch (err) { log.warn && log.warn('network-lifecycle', 'route error', { path: req.path, err: err && err.message, code: err && err.code, }); const status = _statusFor(err); res.status(status).json({ success: false, error: err && err.message ? err.message : 'lifecycle failure', code: (err && err.code) || null, }); } }); router.post('/people/:id/block', wrap(async (req, res) => { const result = await lifecycle.blockPerson(req.params.id); ok(res, { success: true, ...result }); })); router.post('/people/:id/unblock', wrap(async (req, res) => { const result = await lifecycle.unblockPerson(req.params.id); // Honest-unblock (spec §5): the note is part of the contract — // Tailscale users must be told their devices need to sign in again; // WireGuard users are told peers reconnect immediately. ok(res, { success: true, ...result }); })); router.post('/people/:id/remove', wrap(async (req, res) => { const result = await lifecycle.removePerson(req.params.id); ok(res, { success: true, ...result }); })); router.post('/devices/:id/revoke', wrap(async (req, res) => { const personId = req.body && req.body.personId; if (!personId || typeof personId !== 'string') { res.status(400).json({ success: false, error: 'personId is required in the body', code: 'bad_input', }); return; } const result = await lifecycle.revokeDevice(personId, req.params.id); ok(res, { success: true, ...result }); })); return router; };