202 lines
8.8 KiB
JavaScript
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;
|
|
};
|