Files
dashcaddy/dashcaddy-api/routes/network-people.js

202 lines
8.8 KiB
JavaScript

/**
* 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;
};