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