/** * Private Network wizard + status routes — DC-152 * (specs/PRIVATE-NETWORK-SPEC.md §9). * * Mounted on the authed apiRouter under /api/v1/network/wizard (auth * middleware is the caller's job, same as every route module here). * * GET /state → current step + which checks pass * POST /mode → pick tailscale|wireguard + credentials * POST /domain → private TLD pick (default .internal) * POST /connect → health check + first sync * POST /dns → technitium checkSetup + split-DNS (gated) * POST /ca → named root CA (locked-in warning) * POST /admin → first person (bootstrap admin) * POST /complete → gate: all checks green → completedAt * * Status surface (same file, mounted separately under /network/status): * GET /status → who's online, recent connections, * ACL/firewall sync state, drift banner */ 'use strict'; const express = require('express'); const { createNetworkWizard, WizardError } = require('../src/network/wizard'); const { ok, created, errorResponse } = require('../src/utils/responses'); const os = require('os'); const path = require('path'); const noopLog = { info() {}, warn() {}, error() {} }; function _asyncHandlerFallback(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } /** * Wire a WizardError (or anything statusCode-shaped) to an HTTP response. */ function _wizardErrorHandler(res, err) { const status = err instanceof WizardError ? err.statusCode : (err.statusCode || 500); const payload = { success: false, error: err.message || 'wizard error', code: err.code || null, }; if (err.extra && typeof err.extra === 'object' && Object.keys(err.extra).length) { payload.details = err.extra; } return errorResponse(res, status, payload.error, { code: payload.code, details: payload.details }); } /** * Build a wizard instance from loose route deps (prod path) or accept a * pre-built one (tests / composition root reuse). */ function _buildWizard(wizardDeps, stateFile) { return createNetworkWizard({ registry: wizardDeps.registry, providers: wizardDeps.providers || {}, peopleStore: wizardDeps.peopleStore, accessStore: wizardDeps.accessStore || null, technitium: wizardDeps.technitium || null, caSetup: wizardDeps.caSetup || null, credentialStore: wizardDeps.credentialStore || null, logger: wizardDeps.log || noopLog, ...(stateFile ? { stateFile } : {}), }); } module.exports = function networkWizardRoutesFactory({ wizard = null, // pre-built orchestrator (optional) registry = null, providers = {}, peopleStore = null, accessStore = null, technitium = null, caSetup = null, credentialStore = null, stateFile = null, // default: data/network-wizard-state.json connectionLogReader = null, // GET /status injectable aclStateReader = null, // GET /status injectable (acl-push state) firewallStateReader = null, // GET /status injectable (wg-firewall state) asyncHandler = null, log = noopLog, } = {}) { const router = express.Router(); const wrap = asyncHandler || _asyncHandlerFallback; if (!asyncHandler && process.env.NODE_ENV !== 'test') { // Must always be wired in prod (see share.js); fallback is test-only. } const w = wizard || _buildWizard( { registry, providers, peopleStore, accessStore, technitium, caSetup, credentialStore, log }, stateFile ); // ─── Wizard state ────────────────────────────────────────────────────── router.get('/state', wrap(async (req, res) => { ok(res, w.getState()); }, 'network-wizard-state')); // ─── Step 1: mode + credentials ──────────────────────────────────────── router.post('/mode', wrap(async (req, res) => { try { const result = await w.setMode(req.body || {}); ok(res, result); } catch (err) { _wizardErrorHandler(res, err); } }, 'network-wizard-mode')); // ─── Step 2: private domain ──────────────────────────────────────────── router.post('/domain', wrap(async (req, res) => { try { const result = w.setDomain(req.body || {}); ok(res, result); } catch (err) { _wizardErrorHandler(res, err); } }, 'network-wizard-domain')); // ─── Step 3: connect + first sync ────────────────────────────────────── router.post('/connect', wrap(async (req, res) => { try { const result = await w.connect(); ok(res, result); } catch (err) { _wizardErrorHandler(res, err); } }, 'network-wizard-connect')); // ─── Step 4: DNS (technitium, capability-gated) ─────────────────────── router.post('/dns', wrap(async (req, res) => { try { const result = await w.configureDns(); ok(res, result); } catch (err) { _wizardErrorHandler(res, err); } }, 'network-wizard-dns')); // ─── Step 5: CA ─────────────────────────────────────────────────────── router.post('/ca', wrap(async (req, res) => { try { const result = await w.setupCa(req.body || {}); ok(res, result); } catch (err) { _wizardErrorHandler(res, err); } }, 'network-wizard-ca')); // ─── Step 6: first admin ────────────────────────────────────────────── router.post('/admin', wrap(async (req, res) => { try { const result = await w.createAdmin(req.body || {}); created(res, result); } catch (err) { _wizardErrorHandler(res, err); } }, 'network-wizard-admin')); // ─── Completion gate ────────────────────────────────────────────────── router.post('/complete', wrap(async (req, res) => { try { const result = w.complete(); ok(res, result); } catch (err) { _wizardErrorHandler(res, err); } }, 'network-wizard-complete')); // ─── Status (GET /status mounted here too; also exported standalone) ── router.get('/status', wrap(async (req, res) => { ok(res, await w.getStatus({ connectionLogReader, aclStateReader, firewallStateReader, })); }, 'network-wizard-status')); return router; }; /** * Standalone status router for mounting at /api/v1/network/status without * the wizard routes. Same aggregation, same injectables. */ module.exports.networkStatusRoutesFactory = function networkStatusRoutesFactory({ wizard = null, connectionLogReader = null, aclStateReader = null, firewallStateReader = null, asyncHandler = null, } = {}) { const router = express.Router(); const wrap = asyncHandler || _asyncHandlerFallback; const w = wizard; // required for the standalone form router.get('/', wrap(async (req, res) => { if (!w) { return ok(res, { wizard: { completedAt: null, mode: null, privateTld: null }, online: { devices: [], peopleOnline: 0 }, recentConnections: { events: [], note: 'no connection log source configured' }, sync: { acl: null, firewall: null }, driftBanner: false, }); } ok(res, await w.getStatus({ connectionLogReader, aclStateReader, firewallStateReader, })); }, 'network-status')); return router; }; // Default state-file path resolution exported for the composition root. module.exports.defaultWizardStateFile = function defaultWizardStateFile(dataDir) { return path.join(dataDir || os.tmpdir(), 'network-wizard-state.json'); };