/** * Network people routes — DC-141. * * CRUD for the Private Network people store (spec §2/§5) plus a provider * sync endpoint. Mounted on the authed apiRouter under * /api/v1/network/people — auth middleware is the caller's job (same as * every other route module in this repo). * * GET /network/people → list all people * GET /network/people/:id → one person * POST /network/people → create (first person = bootstrap * admin: groups ['Admin'] + protectedAt) * PATCH /network/people/:id → update * DELETE /network/people/:id → delete (assertNotLastAdmin first) * POST /network/people/sync → merge provider.listPeople() into the * store (idempotent, guarded) * * The provider registry (DC-140, src/network/) is built in parallel; the * only dependency is deps.networkRegistry.getActiveProvider() — treated * as optional. When absent, POST /sync returns a clean "provider * inactive" response instead of throwing. */ 'use strict'; const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors'); const { ok, created } = require('../src/utils/responses'); const { createPeopleStore } = require('../src/security/network-people-store'); const { LastAdminError } = require('../src/security/network-people-store'); const VALID_STATUSES = new Set(['active', 'blocked', 'removed']); function _validDisplayName(s) { return typeof s === 'string' && s.trim().length > 0 && s.length <= 200; } function _validProviderUserId(s) { return typeof s === 'string' && s.length > 0 && s.length <= 256; } module.exports = function networkPeopleRoutesFactory({ peopleStore, networkRegistry, asyncHandler, log = { info() {}, warn() {}, error() {} }, } = {}) { const router = require('express').Router(); // eslint-disable-line global-require const store = peopleStore || createPeopleStore({}); if (!asyncHandler) { // Must always be wired in prod (see share.js); test fallback only. if (process.env.NODE_ENV !== 'test') { throw new Error('networkPeopleRoutes requires asyncHandler'); } asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign } // ─── Reads ────────────────────────────────────────────────────────────── router.get('/', asyncHandler(async (req, res) => { const people = await store.listPeople(); ok(res, { data: people, count: people.length }); }, 'network-people-list')); router.get('/:id', asyncHandler(async (req, res) => { const person = await store.getPerson(req.params.id); if (!person) throw new NotFoundError('person'); ok(res, { person }); }, 'network-people-get')); // ─── Create ───────────────────────────────────────────────────────────── router.post('/', asyncHandler(async (req, res) => { const { displayName, providerUserId, groups, devices } = req.body || {}; if (!_validDisplayName(displayName)) { throw new ValidationError('displayName is required (1-200 chars)', 'displayName'); } if (!_validProviderUserId(providerUserId)) { throw new ValidationError('providerUserId is required (1-256 chars)', 'providerUserId'); } if (groups !== undefined && !Array.isArray(groups)) { throw new ValidationError('groups must be an array of strings', 'groups'); } if (devices !== undefined && !Array.isArray(devices)) { throw new ValidationError('devices must be an array', 'devices'); } // First person created in an empty store becomes the bootstrap admin // (groups ['Admin'] + protectedAt) — enforced inside the store. const person = await store.upsertPerson({ displayName: displayName.trim(), providerUserId, ...(groups !== undefined ? { groups } : {}), ...(devices !== undefined ? { devices } : {}), }); log.info && log.info('people', 'person created via API', { id: person.id }); created(res, { person }); }, 'network-people-create')); // ─── Update ───────────────────────────────────────────────────────────── router.patch('/:id', asyncHandler(async (req, res) => { const patch = req.body || {}; if (patch.displayName !== undefined && !_validDisplayName(patch.displayName)) { throw new ValidationError('displayName must be 1-200 chars', 'displayName'); } if (patch.providerUserId !== undefined && !_validProviderUserId(patch.providerUserId)) { throw new ValidationError('providerUserId must be 1-256 chars', 'providerUserId'); } if (patch.groups !== undefined && !Array.isArray(patch.groups)) { throw new ValidationError('groups must be an array of strings', 'groups'); } if (patch.devices !== undefined && !Array.isArray(patch.devices)) { throw new ValidationError('devices must be an array', 'devices'); } if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) { throw new ValidationError(`status must be one of active|blocked|removed`, 'status'); } const updated = await store.updatePerson(req.params.id, patch); if (!updated) throw new NotFoundError('person'); ok(res, { person: updated }); }, 'network-people-update')); // ─── Delete ───────────────────────────────────────────────────────────── router.delete('/:id', asyncHandler(async (req, res) => { // Lockout guard first (spec §5): the last active admin is undeletable. try { await store.assertNotLastAdmin(req.params.id); } catch (err) { if (err instanceof LastAdminError) { throw new ConflictError('cannot remove the last active admin'); } throw err; } const deleted = await store.deletePerson(req.params.id); if (!deleted) throw new NotFoundError('person'); ok(res, { deleted: true, id: req.params.id }); }, 'network-people-delete')); // ─── Provider sync ────────────────────────────────────────────────────── // // Merge provider.listPeople() into the local store: // - match by providerUserId // - add people not present locally // - update displayName of existing people // - idempotent: running twice yields no further change // - never downgrades protectedAt (admin protection is sticky) // - blocked/removed people are NOT resurrected by sync — a local // terminal status wins over the provider saying the user exists. router.post('/sync', asyncHandler(async (req, res) => { const registry = networkRegistry; if (!registry || typeof registry.getActiveProvider !== 'function') { // Feature inactive (provider registry not built yet / no provider // configured) — report cleanly instead of 500ing. ok(res, { synced: false, reason: 'network provider not active', added: 0, updated: 0 }); return; } let provider = null; try { provider = registry.getActiveProvider(); } catch (_) { provider = null; } if (!provider || typeof provider.listPeople !== 'function') { ok(res, { synced: false, reason: 'network provider not active', added: 0, updated: 0 }); return; } const providerPeople = await provider.listPeople(); if (!Array.isArray(providerPeople)) { throw new ValidationError('provider listPeople() must return an array', 'provider'); } let added = 0; let updated = 0; for (const pp of providerPeople) { if (!pp || !_validProviderUserId(pp.providerUserId)) continue; // skip junk const existing = await store.findByProviderUserId(pp.providerUserId); if (!existing) { await store.upsertPerson({ displayName: _validDisplayName(pp.displayName) ? pp.displayName : pp.providerUserId, providerUserId: pp.providerUserId, groups: [], devices: [], }); added += 1; } else if (existing.status === 'active' && _validDisplayName(pp.displayName) && pp.displayName !== existing.displayName) { await store.updatePerson(existing.id, { displayName: pp.displayName }); updated += 1; } } log.info && log.info('people', 'provider sync complete', { added, updated }); ok(res, { synced: true, added, updated }); }, 'network-people-sync')); return router; };