Files

101 lines
3.8 KiB
JavaScript

/**
* Network groups routes — DC-144.
*
* CRUD for DashCaddy-side groups (spec §2): a group is an id/name used in
* person.groups[] and access grants. HARD RULE (spec §2): DashCaddy
* groups are UI-side ONLY — they never map to Tailscale ACL groups (free
* plans cap TS groups at 3; compiled ACLs are per-user). Group ids are
* validated as DashCaddy-local slug strings, enforced in the service.
*
* GET /network/groups → list (derived from people store +
* names map at network-groups.json)
* POST /network/groups → create { id, name }
* PATCH /network/groups/:id → rename ({ id?, name? }; re-points
* person.groups[] + grant refs)
* DELETE /network/groups/:id → delete + cascade removal from
* people.groups[] and grants
*
* 'Admin' is the well-known group: cannot be renamed or deleted (409).
*
* Mounted on the authed apiRouter — auth middleware is the caller's job
* (same as every other route module in this repo). Factory pattern.
*/
'use strict';
const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors');
const { ok, created } = require('../src/utils/responses');
const { createGroupsService, GroupError } = require('../src/security/network-groups');
module.exports = function networkGroupsRoutesFactory({
groupsService,
peopleStore,
accessStore,
asyncHandler,
log = { info() {}, warn() {}, error() {} },
} = {}) {
const router = require('express').Router(); // eslint-disable-line global-require
const service = groupsService || createGroupsService({ peopleStore, accessStore, log });
if (!service || typeof service.listGroups !== 'function') {
if (process.env.NODE_ENV === 'test') {
log.warn && log.warn('network-groups', 'groupsService missing — routes returning 404 in this environment');
router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-144] groups unavailable' }));
return router;
}
throw new Error('networkGroupsRoutes requires groupsService (or peopleStore + accessStore)');
}
if (!asyncHandler) {
// Must always be wired in prod (see share.js); test fallback only.
if (process.env.NODE_ENV !== 'test') {
throw new Error('networkGroupsRoutes requires asyncHandler');
}
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign
}
// Map GroupError (statusCode-carrying, operational) onto the repo's
// error classes so the shared error handler renders the right status.
function _map(err) {
if (err instanceof GroupError) {
if (err.statusCode === 404) return new NotFoundError('group');
if (err.statusCode === 409) return new ConflictError(err.message);
return new ValidationError(err.message, 'group');
}
return err;
}
router.get('/', asyncHandler(async (req, res) => {
const groups = await service.listGroups();
ok(res, { data: groups, count: groups.length });
}, 'network-groups-list'));
router.post('/', asyncHandler(async (req, res) => {
try {
const group = await service.createGroup(req.body || {});
created(res, { group });
} catch (err) {
throw _map(err);
}
}, 'network-groups-create'));
router.patch('/:id', asyncHandler(async (req, res) => {
try {
const group = await service.renameGroup(req.params.id, req.body || {});
ok(res, { group });
} catch (err) {
throw _map(err);
}
}, 'network-groups-rename'));
router.delete('/:id', asyncHandler(async (req, res) => {
try {
const result = await service.deleteGroup(req.params.id);
ok(res, result);
} catch (err) {
throw _map(err);
}
}, 'network-groups-delete'));
return router;
};