merge dc/DC-144 into p7-wave1

This commit is contained in:
Hermes committed 2026-10-03 21:38:34 -07:00
commit de6a2fa4ae
6 files changed
+1351

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
/**
* 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;
};
+103
View File
@@ -0,0 +1,103 @@
/**
* Network resources routes — DC-144.
*
* CRUD for Private Network resources (spec §2): apps or devices reachable
* by name (`plex.internal`, `nas.internal`) — the dst side of the access
* map (DC-145) and the compiled ACLs (DC-148).
*
* GET /network/resources → list
* GET /network/resources/:name → one resource
* POST /network/resources → create (name slug, port
* 1-65535, host non-empty)
* PATCH /network/resources/:name → update (name immutable)
* DELETE /network/resources/:name → delete
*
* `sensitive` resources additionally require DashCaddy login + TOTP at
* the forward_auth layer (spec §4 step-up); here it just round-trips.
*
* 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 { createResourcesStore, ResourceError } = require('../src/security/network-resources');
module.exports = function networkResourcesRoutesFactory({
resourcesStore,
asyncHandler,
log = { info() {}, warn() {}, error() {} },
} = {}) {
const router = require('express').Router(); // eslint-disable-line global-require
const store = resourcesStore || createResourcesStore({ log });
if (!store || typeof store.listResources !== 'function') {
if (process.env.NODE_ENV === 'test') {
log.warn && log.warn('network-resources', 'resourcesStore missing — routes returning 404 in this environment');
router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-144] resources unavailable' }));
return router;
}
throw new Error('networkResourcesRoutes requires resourcesStore');
}
if (!asyncHandler) {
// Must always be wired in prod (see share.js); test fallback only.
if (process.env.NODE_ENV !== 'test') {
throw new Error('networkResourcesRoutes requires asyncHandler');
}
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign
}
// Map ResourceError (statusCode-carrying, operational) onto the repo's
// error classes so the shared error handler renders the right status.
function _map(err) {
if (err instanceof ResourceError) {
if (err.statusCode === 404) return new NotFoundError('resource');
if (err.statusCode === 409) return new ConflictError(err.message);
return new ValidationError(err.message, 'resource');
}
return err;
}
router.get('/', asyncHandler(async (req, res) => {
const resources = await store.listResources();
ok(res, { data: resources, count: resources.length });
}, 'network-resources-list'));
router.get('/:name', asyncHandler(async (req, res) => {
const resource = await store.getResource(req.params.name);
if (!resource) throw new NotFoundError('resource');
ok(res, { resource });
}, 'network-resources-get'));
router.post('/', asyncHandler(async (req, res) => {
try {
const resource = await store.createResource(req.body || {});
log.info && log.info('network-resources', 'resource created via API', { name: resource.name });
created(res, { resource });
} catch (err) {
throw _map(err);
}
}, 'network-resources-create'));
router.patch('/:name', asyncHandler(async (req, res) => {
try {
const resource = await store.updateResource(req.params.name, req.body || {});
ok(res, { resource });
} catch (err) {
throw _map(err);
}
}, 'network-resources-update'));
router.delete('/:name', asyncHandler(async (req, res) => {
try {
const result = await store.deleteResource(req.params.name);
ok(res, result);
} catch (err) {
throw _map(err);
}
}, 'network-resources-delete'));
return router;
};