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

@@ -0,0 +1,228 @@
/**
* Route tests for network-groups routes (DC-144).
* Coverage:
* - CRUD contract (GET list, POST create, PATCH rename, DELETE)
* - derived listing: 'Admin' always present; groups referenced from
* person.groups[] appear automatically
* - slug validation (bad ids rejected, DashCaddy-local strings only)
* - duplicate create → 409
* - 'Admin' cannot be renamed or deleted (409)
* - rename cascade: grant references re-pointed to the new id
* - delete cascade: group stripped from person.groups[] AND from
* every access grant referencing it
* Hermetic — supertest against an in-process express app over real
* stores in a temp dir, no network.
*/
'use strict';
const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createPeopleStore } = require('../src/security/network-people-store');
const { createNetworkAccessStore } = require('../src/security/network-access-store');
const { createGroupsService } = require('../src/security/network-groups');
const networkGroupsRoutes = require('../routes/network-groups');
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
function createApp() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-grouproutes-'));
const peopleStore = createPeopleStore({ dataDir: dir });
const accessStore = createNetworkAccessStore({ dataDir: dir });
const groupsService = createGroupsService({ dataDir: dir, peopleStore, accessStore });
const router = networkGroupsRoutes({
groupsService,
peopleStore,
accessStore,
asyncHandler,
log: { info: () => {}, warn: () => {}, error: () => {} },
});
const app = express();
app.use(express.json());
app.use('/network/groups', router);
app.use((err, _req, res, _next) => {
const status = err.statusCode || 500;
res.status(status).json({ success: false, error: err.message, code: err.code || null });
});
return { app, dir, peopleStore, accessStore, groupsService };
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('network-groups routes: contract', () => {
let dir, app, peopleStore;
beforeEach(() => {
({ app, dir, peopleStore } = createApp());
});
afterEach(() => _cleanup(dir));
test('GET / lists groups, Admin always present', async () => {
const res = await request(app).get('/network/groups');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
const ids = res.body.data.map(g => g.id);
expect(ids).toContain('Admin');
expect(res.body.count).toBe(ids.length);
});
test('GET / derives groups referenced from person.groups[]', async () => {
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family', 'Admin'] });
const res = await request(app).get('/network/groups');
const ids = res.body.data.map(g => g.id);
expect(ids).toContain('family');
const family = res.body.data.find(g => g.id === 'family');
expect(family.memberCount).toBe(1);
});
test('POST / creates a group', async () => {
const res = await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.group).toMatchObject({ id: 'family', name: 'Family', memberCount: 0 });
});
test('POST / rejects invalid slugs (DashCaddy-local ids only)', async () => {
for (const bad of ['Family', 'has space', 'UPPER', 'under_score', '', 'a/b']) {
const res = await request(app).post('/network/groups').send({ id: bad, name: 'X' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
}
});
test('POST / rejects missing/blank name', async () => {
const res = await request(app).post('/network/groups').send({ id: 'family', name: ' ' });
expect(res.status).toBe(400);
});
test('POST / duplicate id → 409 (including Admin)', async () => {
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
const dup = await request(app).post('/network/groups').send({ id: 'family', name: 'Other' });
expect(dup.status).toBe(409);
const admin = await request(app).post('/network/groups').send({ id: 'Admin', name: 'Admin2' });
expect(admin.status).toBe(409);
});
test('PATCH /:id renames display name', async () => {
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
const res = await request(app).patch('/network/groups/family').send({ name: 'Fam' });
expect(res.status).toBe(200);
expect(res.body.group.name).toBe('Fam');
expect(res.body.group.id).toBe('family');
});
test('PATCH /:id unknown group → 404', async () => {
const res = await request(app).patch('/network/groups/nope').send({ name: 'X' });
expect(res.status).toBe(404);
});
test('DELETE /:id deletes an empty group', async () => {
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
const res = await request(app).delete('/network/groups/family');
expect(res.status).toBe(200);
expect(res.body.deleted).toBe(true);
const after = await request(app).get('/network/groups');
expect(after.body.data.map(g => g.id)).not.toContain('family');
});
test('DELETE /:id unknown → 404', async () => {
const res = await request(app).delete('/network/groups/nope');
expect(res.status).toBe(404);
});
});
describe('network-groups routes: Admin protection', () => {
let dir, app;
beforeEach(() => {
({ app, dir } = createApp());
});
afterEach(() => _cleanup(dir));
test('DELETE /Admin → 409', async () => {
const res = await request(app).delete('/network/groups/Admin');
expect(res.status).toBe(409);
expect(res.body.success).toBe(false);
});
test('PATCH /Admin → 409', async () => {
const res = await request(app).patch('/network/groups/Admin').send({ name: 'Root' });
expect(res.status).toBe(409);
});
});
describe('network-groups routes: cascades', () => {
let dir, app, peopleStore, accessStore;
beforeEach(() => {
({ app, dir, peopleStore, accessStore } = createApp());
});
afterEach(() => _cleanup(dir));
test('DELETE /:id removes the group from person.groups[] and grants', async () => {
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family'] });
await peopleStore.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['family', 'friends'] });
await accessStore.setGrant({ type: 'group', id: 'family' }, 'plex', true);
await accessStore.setGrant({ type: 'group', id: 'friends' }, 'plex', true); // must survive
const res = await request(app).delete('/network/groups/family');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ deleted: true, id: 'family', removedFromPeople: 2, removedGrants: 1 });
const people = await peopleStore.listPeople();
for (const p of people) expect(p.groups).not.toContain('family');
const bob = people.find(p => p.displayName === 'Bob');
expect(bob.groups).toEqual(['friends']);
const map = await accessStore.getAccessMap();
const refs = map.grants.filter(g => g.principal.type === 'group' && g.principal.id === 'family');
expect(refs).toHaveLength(0);
expect(map.grants.some(g => g.principal.id === 'friends')).toBe(true);
});
test('PATCH /:id with new id re-points grant references and person.groups[]', async () => {
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family'] });
await accessStore.setGrant({ type: 'group', id: 'family' }, 'plex', true);
await accessStore.setGrant({ type: 'person', id: 'someone' }, 'plex', true); // untouched
const res = await request(app).patch('/network/groups/family').send({ id: 'household' });
expect(res.status).toBe(200);
expect(res.body.group.id).toBe('household');
expect(res.body.group.peopleUpdated).toBe(1);
expect(res.body.group.grantsUpdated).toBe(1);
const people = await peopleStore.listPeople();
expect(people[0].groups).toEqual(['household']);
const map = await accessStore.getAccessMap();
expect(map.grants.some(g => g.principal.type === 'group' && g.principal.id === 'household')).toBe(true);
expect(map.grants.some(g => g.principal.type === 'group' && g.principal.id === 'family')).toBe(false);
expect(map.grants.some(g => g.principal.type === 'person' && g.principal.id === 'someone')).toBe(true);
});
test('PATCH /:id refuses renaming onto an existing id (409)', async () => {
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family'] });
await peopleStore.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['friends'] });
const res = await request(app).patch('/network/groups/family').send({ id: 'friends' });
expect(res.status).toBe(409);
// and nothing moved
const people = await peopleStore.listPeople();
expect(people.find(p => p.displayName === 'Alice').groups).toEqual(['family']);
});
test('groups never leak into Tailscale ACL semantics: ids stay DashCaddy-local strings', async () => {
// The slug gate is the enforcement point — any id that could be
// interpreted as a foreign ACL tag/group reference (colons, emails,
// tag:prefixes) is rejected at create AND rename.
const badCreate = await request(app).post('/network/groups').send({ id: 'tag:server', name: 'X' });
expect(badCreate.status).toBe(400);
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
const badRename = await request(app).patch('/network/groups/family').send({ id: 'user@corp' });
expect(badRename.status).toBe(400);
});
});
@@ -0,0 +1,238 @@
/**
* Route tests for network-resources routes (DC-144).
* Coverage:
* - CRUD contract (GET list, GET one, POST, PATCH, DELETE)
* - validation errors: bad slug name, port out of range / non-integer,
* empty host, bad type/protocol/sensitive
* - duplicate create → 409; unknown name → 404; name immutable on PATCH
* - sensitive flag round-trip (create true → read true → flip → false)
* - defaults: displayName falls back to name, type 'app', manual true
* - services-registry seeding: skipped silently when the services file
* is missing/unreadable; seeded entries are manual:false
* Hermetic — supertest against an in-process express app over a real
* store in a temp dir, no network.
*/
'use strict';
const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createResourcesStore } = require('../src/security/network-resources');
const networkResourcesRoutes = require('../routes/network-resources');
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
function createApp({ servicesFile } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-resourceroutes-'));
const resourcesStore = createResourcesStore({ dataDir: dir, servicesFile });
const router = networkResourcesRoutes({
resourcesStore,
asyncHandler,
log: { info: () => {}, warn: () => {}, error: () => {} },
});
const app = express();
app.use(express.json());
app.use('/network/resources', router);
app.use((err, _req, res, _next) => {
const status = err.statusCode || 500;
res.status(status).json({ success: false, error: err.message, code: err.code || null });
});
return { app, dir, resourcesStore };
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('network-resources routes: contract', () => {
let dir, app;
beforeEach(() => {
({ app, dir } = createApp());
});
afterEach(() => _cleanup(dir));
test('GET / lists resources (empty initially)', async () => {
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual([]);
expect(res.body.count).toBe(0);
});
test('POST / creates a resource with defaults', async () => {
const res = await request(app).post('/network/resources')
.send({ name: 'plex', host: 'plex.internal', port: 32400 });
expect(res.status).toBe(201);
expect(res.body.resource).toMatchObject({
name: 'plex',
displayName: 'plex',
type: 'app',
host: 'plex.internal',
port: 32400,
protocol: 'http',
sensitive: false,
manual: true,
});
});
test('POST / accepts explicit displayName/type/protocol/sensitive', async () => {
const res = await request(app).post('/network/resources')
.send({ name: 'nas', displayName: 'NAS Box', type: 'device', host: 'nas.internal', port: 5000, protocol: 'https', sensitive: true });
expect(res.status).toBe(201);
expect(res.body.resource).toMatchObject({ displayName: 'NAS Box', type: 'device', protocol: 'https', sensitive: true });
});
test('GET /:name returns one resource; 404 unknown', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const hit = await request(app).get('/network/resources/plex');
expect(hit.status).toBe(200);
expect(hit.body.resource.name).toBe('plex');
const miss = await request(app).get('/network/resources/nope');
expect(miss.status).toBe(404);
});
test('POST / duplicate name → 409', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const res = await request(app).post('/network/resources').send({ name: 'plex', host: 'other', port: 1 });
expect(res.status).toBe(409);
});
test('PATCH /:name updates fields', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const res = await request(app).patch('/network/resources/plex').send({ port: 32401, displayName: 'Plex!' });
expect(res.status).toBe(200);
expect(res.body.resource).toMatchObject({ port: 32401, displayName: 'Plex!' });
});
test('PATCH /:name: name is immutable; 404 unknown', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const rename = await request(app).patch('/network/resources/plex').send({ name: 'plex2' });
expect(rename.status).toBe(400);
const missing = await request(app).patch('/network/resources/nope').send({ port: 80 });
expect(missing.status).toBe(404);
});
test('DELETE /:name deletes; second delete → 404', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const res = await request(app).delete('/network/resources/plex');
expect(res.status).toBe(200);
expect(res.body.deleted).toBe(true);
const again = await request(app).delete('/network/resources/plex');
expect(again.status).toBe(404);
});
});
describe('network-resources routes: validation', () => {
let dir, app;
beforeEach(() => {
({ app, dir } = createApp());
});
afterEach(() => _cleanup(dir));
test('rejects bad name slugs', async () => {
for (const bad of ['Plex', 'has space', 'under_score', '', 'a/b', 'dot.name']) {
const res = await request(app).post('/network/resources').send({ name: bad, host: 'h', port: 80 });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
}
});
test('rejects ports outside 1-65535 and non-integers', async () => {
for (const bad of [0, -1, 65536, 99999, 80.5, '80', null]) {
const res = await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: bad });
expect(res.status).toBe(400);
}
});
test('rejects empty/missing host', async () => {
const missing = await request(app).post('/network/resources').send({ name: 'plex', port: 80 });
expect(missing.status).toBe(400);
const blank = await request(app).post('/network/resources').send({ name: 'plex', host: ' ', port: 80 });
expect(blank.status).toBe(400);
});
test('rejects bad type/protocol/sensitive', async () => {
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, type: 'vm' })).status).toBe(400);
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, protocol: 'grpc' })).status).toBe(400);
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, sensitive: 'yes' })).status).toBe(400);
});
test('accepts boundary ports 1 and 65535', async () => {
const lo = await request(app).post('/network/resources').send({ name: 'lo', host: 'h', port: 1 });
expect(lo.status).toBe(201);
const hi = await request(app).post('/network/resources').send({ name: 'hi', host: 'h', port: 65535 });
expect(hi.status).toBe(201);
});
});
describe('network-resources routes: sensitive round-trip + seeding', () => {
let dir, app;
afterEach(() => _cleanup(dir));
test('sensitive flag round-trips through create, read, update, re-read', async () => {
({ app, dir } = createApp());
await request(app).post('/network/resources')
.send({ name: 'vault', host: 'vault.internal', port: 8200, sensitive: true });
let res = await request(app).get('/network/resources/vault');
expect(res.status).toBe(200);
expect(res.body.resource.sensitive).toBe(true);
await request(app).patch('/network/resources/vault').send({ sensitive: false });
res = await request(app).get('/network/resources/vault');
expect(res.body.resource.sensitive).toBe(false);
// …and persists on disk (round-trip through a fresh store read).
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-resources.json'), 'utf8'));
expect(raw.resources.find(r => r.name === 'vault').sensitive).toBe(false);
});
test('seed skipped silently when services file is missing (manual entries only)', async () => {
const servicesFile = path.join(os.tmpdir(), `no-such-services-${Date.now()}.json`);
({ app, dir } = createApp({ servicesFile }));
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.data).toEqual([]);
expect(res.body.count).toBe(0);
// manual creation still works after a skipped seed
const create = await request(app).post('/network/resources')
.send({ name: 'manual', host: 'm.internal', port: 8080 });
expect(create.status).toBe(201);
expect(create.body.resource.manual).toBe(true);
});
test('seed skipped when services file is corrupt JSON (no crash)', async () => {
const badDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-badseed-'));
const servicesFile = path.join(badDir, 'services.json');
fs.writeFileSync(servicesFile, '{not json', 'utf8');
({ app, dir } = createApp({ servicesFile }));
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.data).toEqual([]);
_cleanup(badDir);
});
test('seeds from services registry when present (manual:false)', async () => {
const seedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-seed-'));
const servicesFile = path.join(seedDir, 'services.json');
fs.writeFileSync(servicesFile, JSON.stringify([
{ id: 'jellyfin', port: 8096 },
{ id: 'sonarr', port: 8989, host: 'sonarr.internal' },
{ id: 'bad id!', port: 80 }, // non-slug id → skipped
{ id: 'noport' }, // no port → skipped
]), 'utf8');
({ app, dir } = createApp({ servicesFile }));
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
const jf = res.body.data.find(r => r.name === 'jellyfin');
expect(jf).toMatchObject({ host: 'localhost', port: 8096, manual: false, sensitive: false });
const so = res.body.data.find(r => r.name === 'sonarr');
expect(so).toMatchObject({ host: 'sonarr.internal', port: 8989, manual: false });
_cleanup(seedDir);
});
});
+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;
};
@@ -0,0 +1,374 @@
/**
* Network groups service — DC-144.
*
* DashCaddy-side groups (spec §2): a group is just an id/name used in
* person.groups[] and in access grants. Groups are a UI-side concept ONLY —
* they NEVER map to Tailscale ACL groups (free TS plans cap groups at 3;
* compiled ACLs reference users directly, see DC-148). Group ids are
* DashCaddy-local slug strings and are validated as such.
*
* Because groups are not a separate entity store, the set of groups is
* DERIVED: the union of every id appearing in person.groups[] plus a
* display-names map persisted at data/network-groups.json
* ({ groups: [{ id, name }] }). 'Admin' is the well-known group (matches
* the people-store bootstrap admin) and can never be renamed or deleted.
*
* renameGroup(fromId, { id: toId, name }) re-points every reference —
* person.groups[] entries AND access grants with principal
* { type: 'group', id: fromId } — by orchestrating the people store and
* the access store through their public APIs (listPeople/updatePerson,
* getAccessMap/saveAccessMap). deleteGroup(id) cascades the same way:
* the group is removed from every person.groups[] entry and every grant
* referencing it is dropped.
*
* Storage: same atomic JSON pattern as the other network stores
* (fsync'd tmp+rename via the shared atomic-write util).
*/
'use strict';
const path = require('path');
const fs = require('fs');
const platformPaths = require('../../platform-paths');
const { atomicWriteJSON } = require('../utils/atomic-write');
const ADMIN_GROUP = 'Admin'; // must match network-people-store.ADMIN_GROUP
const SLUG_RE = /^[a-z0-9-]+$/;
/**
* Operational error thrown by the groups service. Carries an HTTP
* statusCode so route error handlers can map it without instanceof
* coupling (same convention as LastAdminError).
*/
class GroupError extends Error {
constructor(message, statusCode = 400, code = 'DC_GROUP_ERROR') {
super(message);
this.name = 'GroupError';
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
}
}
function _readJSON(filePath, fallback) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
if (err && err.code === 'ENOENT') return fallback;
// Corrupt file: quarantine best-effort, restart from fallback.
try {
fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`);
} catch (_) { /* best-effort */ }
return fallback;
}
}
function _normalizeNames(data) {
const out = new Map();
if (data && Array.isArray(data.groups)) {
for (const g of data.groups) {
if (g && typeof g === 'object' && typeof g.id === 'string' && g.id.length > 0) {
out.set(g.id, typeof g.name === 'string' && g.name.length > 0 ? g.name : g.id);
}
}
}
return out;
}
function createGroupsService(opts = {}) {
const candidates = [
opts.dataDir,
opts.platformPaths && opts.platformPaths.dataDir,
platformPaths && platformPaths.dataDir,
];
const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
|| require('os').tmpdir(); // eslint-disable-line global-require
const log = opts.log || { info() {}, warn() {}, error() {} };
const peopleStore = opts.peopleStore;
const accessStore = opts.accessStore;
if (!peopleStore || typeof peopleStore.listPeople !== 'function') {
throw new Error('createGroupsService requires a peopleStore');
}
if (!accessStore || typeof accessStore.getAccessMap !== 'function') {
throw new Error('createGroupsService requires an accessStore');
}
const file = path.join(dataDir, 'network-groups.json');
// Serialized mutation mutex — renames/deletes touch multiple stores and
// must not interleave with each other.
let _mutex = Promise.resolve();
function _enqueue(fn) {
const next = _mutex.then(fn, fn);
_mutex = next.catch(() => {});
return next;
}
function _loadNames() {
return _normalizeNames(_readJSON(file, { groups: [] }));
}
function _saveNames(names) {
const groups = [...names.entries()]
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.id.localeCompare(b.id));
atomicWriteJSON(file, { groups });
}
/**
* Derived group set: union of ids in person.groups[], ids in the names
* map, ids referenced by group grants, plus the well-known 'Admin'.
* Returns Map<id, {name, memberCount}> sorted by id.
*/
async function _derived() {
const names = _loadNames();
const people = await peopleStore.listPeople();
const map = new Map(); // id -> memberCount
map.set(ADMIN_GROUP, 0);
for (const p of people) {
for (const gid of Array.isArray(p.groups) ? p.groups : []) {
if (typeof gid !== 'string' || gid.length === 0) continue;
map.set(gid, (map.get(gid) || 0) + 1);
}
}
for (const id of names.keys()) {
if (!map.has(id)) map.set(id, 0);
}
const access = await accessStore.getAccessMap();
for (const g of access.grants) {
if (g.principal && g.principal.type === 'group' && !map.has(g.principal.id)) {
map.set(g.principal.id, 0);
}
}
const out = new Map();
for (const id of [...map.keys()].sort((a, b) => a.localeCompare(b))) {
out.set(id, { id, name: names.get(id) || id, memberCount: map.get(id) });
}
return out;
}
/**
* List all groups (derived), sorted by id: [{ id, name, memberCount }].
*/
function listGroups() {
return _enqueue(async () => [...(await _derived()).values()]);
}
/**
* One group by id, or null.
*/
async function getGroup(id) {
const all = await listGroups();
return all.find(g => g.id === id) || null;
}
/**
* Create a group ({ id, name }). Validation: id must be a DashCaddy-local
* slug (/^[a-z0-9-]+$/, lowercase — the well-known 'Admin' id is rejected
* as a duplicate of the always-present group), unique across the derived
* set; name is a non-empty string (≤ 200 chars). The group becomes
* visible immediately even with zero members because its id is persisted
* in the names map.
*/
function createGroup(input = {}) {
return _enqueue(async () => {
const { id, name } = input;
// Well-known group is checked BEFORE the slug gate — its id ('Admin')
// is intentionally not a lowercase slug; creating it means duplicate.
if (id === ADMIN_GROUP) {
throw new GroupError(`group '${ADMIN_GROUP}' already exists (well-known group)`, 409, 'DC_GROUP_EXISTS');
}
if (typeof id !== 'string' || !SLUG_RE.test(id)) {
throw new GroupError(
`group id must match ${SLUG_RE.toString()} (DashCaddy-local slug; never a Tailscale ACL group)`,
400, 'DC_GROUP_INVALID_ID');
}
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 200) {
throw new GroupError('group name is required (1-200 chars)', 400, 'DC_GROUP_INVALID_NAME');
}
const derived = await _derived();
if (derived.has(id)) {
throw new GroupError(`group '${id}' already exists`, 409, 'DC_GROUP_EXISTS');
}
const names = _loadNames();
names.set(id, name.trim());
_saveNames(names);
log.info && log.info('network-groups', 'group created', { id });
return { id, name: name.trim(), memberCount: 0 };
});
}
/**
* Re-point every reference from one group id to another: each
* person.groups[] entry and each access grant with principal
* { type: 'group', id: fromId } — driven through the stores' public
* APIs. Returns { peopleUpdated, grantsUpdated }.
*/
async function _repointReferences(fromId, toId) {
let peopleUpdated = 0;
let grantsUpdated = 0;
// Cascade: re-point person.groups[] …
const people = await peopleStore.listPeople();
for (const p of people) {
if ((p.groups || []).includes(fromId)) {
await peopleStore.updatePerson(p.id, {
groups: p.groups.map(g => (g === fromId ? toId : g)),
});
peopleUpdated += 1;
}
}
// … and group-principal grant references.
const access = await accessStore.getAccessMap();
const grants = access.grants.map(g => (
g.principal && g.principal.type === 'group' && g.principal.id === fromId
? { ...g, principal: { type: 'group', id: toId } }
: g
));
if (grants.some((g, i) => g !== access.grants[i])) {
const res = await accessStore.saveAccessMap({ grants });
if (!res.ok) {
throw new GroupError(res.reason || 'access map rejected the rename cascade', 500, 'DC_GROUP_CASCADE_FAILED');
}
grantsUpdated = access.grants.filter(
g => g.principal && g.principal.type === 'group' && g.principal.id === fromId
).length;
}
return { peopleUpdated, grantsUpdated };
}
/**
* Validate the rename patch for a group that exists in `derived`.
* Returns the resolved { toId, newName } or throws GroupError.
*/
function _validateRenamePatch(fromId, patch, derived) {
const toId = patch.id !== undefined ? patch.id : fromId;
const newName = patch.name !== undefined ? patch.name : null;
if (toId !== fromId) {
if (toId === ADMIN_GROUP) {
throw new GroupError(`group id '${ADMIN_GROUP}' is reserved`, 409, 'DC_GROUP_PROTECTED');
}
if (typeof toId !== 'string' || !SLUG_RE.test(toId)) {
throw new GroupError(
`group id must match ${SLUG_RE.toString()} (DashCaddy-local slug; never a Tailscale ACL group)`,
400, 'DC_GROUP_INVALID_ID');
}
if (derived.has(toId)) {
throw new GroupError(`group '${toId}' already exists`, 409, 'DC_GROUP_EXISTS');
}
}
if (newName !== null
&& (typeof newName !== 'string' || newName.trim().length === 0 || newName.length > 200)) {
throw new GroupError('group name must be 1-200 chars', 400, 'DC_GROUP_INVALID_NAME');
}
return { toId, newName };
}
/**
* Rename a group. patch: { id?: newId, name?: newName }.
*
* - 'Admin' is well-known: rename refused (409).
* - Changing the id re-points EVERY reference: each person.groups[] entry
* and each access grant with principal { type:'group', id: fromId }
* (via the stores' public APIs — peopleStore.updatePerson,
* accessStore.saveAccessMap).
* - Changing only the display name touches the names map alone.
* Returns the updated group { id, name, memberCount }.
*/
function renameGroup(fromId, patch = {}) {
return _enqueue(async () => {
if (fromId === ADMIN_GROUP) {
throw new GroupError(`group '${ADMIN_GROUP}' is well-known and cannot be renamed`, 409, 'DC_GROUP_PROTECTED');
}
const derived = await _derived();
if (!derived.has(fromId)) {
throw new GroupError(`group '${fromId}' not found`, 404, 'DC_GROUP_NOT_FOUND');
}
const { toId, newName } = _validateRenamePatch(fromId, patch, derived);
const memberCount = derived.get(fromId).memberCount;
const refs = toId !== fromId
? await _repointReferences(fromId, toId)
: { peopleUpdated: 0, grantsUpdated: 0 };
// Names map: move/rename the entry (create one if the group only
// ever existed implicitly via person.groups[]).
const names = _loadNames();
names.delete(fromId);
names.set(toId, newName !== null ? newName.trim() : (names.get(toId) || toId));
_saveNames(names);
log.info && log.info('network-groups', 'group renamed', {
fromId, toId, ...refs,
});
return { id: toId, name: names.get(toId), memberCount, ...refs };
});
}
/**
* Delete a group and cascade: REMOVE the id from every person.groups[]
* entry and DROP every access grant whose principal is
* { type: 'group', id } — one orchestrating function driving the stores'
* public APIs. 'Admin' is protected (409). Returns
* { deleted: true, id, removedFromPeople, removedGrants }.
*/
function deleteGroup(id) {
return _enqueue(async () => {
if (id === ADMIN_GROUP) {
throw new GroupError(`group '${ADMIN_GROUP}' is well-known and cannot be deleted`, 409, 'DC_GROUP_PROTECTED');
}
const derived = await _derived();
if (!derived.has(id)) {
throw new GroupError(`group '${id}' not found`, 404, 'DC_GROUP_NOT_FOUND');
}
let removedFromPeople = 0;
let removedGrants = 0;
// Cascade 1: strip the group from every person.groups[].
const people = await peopleStore.listPeople();
for (const p of people) {
if ((p.groups || []).includes(id)) {
const next = p.groups.filter(g => g !== id);
await peopleStore.updatePerson(p.id, { groups: next });
removedFromPeople += 1;
}
}
// Cascade 2: drop every group grant referencing it.
const access = await accessStore.getAccessMap();
const kept = [];
for (const g of access.grants) {
if (g.principal && g.principal.type === 'group' && g.principal.id === id) {
removedGrants += 1;
} else {
kept.push(g);
}
}
if (removedGrants > 0) {
const res = await accessStore.saveAccessMap({ grants: kept });
if (!res.ok) throw new GroupError(res.reason || 'access map rejected the delete cascade', 500, 'DC_GROUP_CASCADE_FAILED');
}
// Finally remove the names-map entry (if any).
const names = _loadNames();
names.delete(id);
_saveNames(names);
log.info && log.info('network-groups', 'group deleted', { id, removedFromPeople, removedGrants });
return { deleted: true, id, removedFromPeople, removedGrants };
});
}
return {
listGroups,
getGroup,
createGroup,
renameGroup,
deleteGroup,
ADMIN_GROUP,
};
}
module.exports = { createGroupsService, GroupError, ADMIN_GROUP };
@@ -0,0 +1,308 @@
/**
* Network resources store — DC-144.
*
* A resource is an app or device reachable by name on the DashCaddy
* Private Network (spec §2): `plex.internal`, `nas.internal`. Resources
* feed the access map (DC-145) and the ACL compiler's dst entries
* (DC-148).
*
* Storage: data/network-resources.json — atomic JSON pattern (fsync'd
* tmp+rename via the shared atomic-write util), promise-serialized
* mutations:
*
* { resources: [{ name, displayName, type: 'app'|'device', host, port,
* protocol, sensitive: bool, manual: bool }] }
*
* - name: DashCaddy-local slug /^[a-z0-9-]+$/ (immutable, the key).
* - port: integer 1-65535. host: non-empty string.
* - sensitive: resources flagged sensitive additionally require DashCaddy
* login + TOTP at the forward_auth layer (spec §4 step-up).
* - manual: true for entries created via the API; false for entries
* seeded from the services registry.
*
* Seeding: on first load, if `servicesFile` is provided and readable, the
* store best-effort seeds one resource per services.json entry that has
* an id and a usable port (host defaults to 'localhost'). Any failure —
* missing file, unreadable JSON, no seedable entries — is skipped
* silently: the store starts empty and manual entries only. Without a
* `servicesFile` (tests, fresh installs) seeding is skipped entirely.
*/
'use strict';
const path = require('path');
const fs = require('fs');
const platformPaths = require('../../platform-paths');
const { atomicWriteJSON } = require('../utils/atomic-write');
const SLUG_RE = /^[a-z0-9-]+$/;
const TYPES = new Set(['app', 'device']);
const PROTOCOLS = new Set(['http', 'https', 'tcp', 'udp']);
class ResourceError extends Error {
constructor(message, statusCode = 400, code = 'DC_RESOURCE_ERROR') {
super(message);
this.name = 'ResourceError';
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
}
}
function _readJSON(filePath, fallback) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
if (err && err.code === 'ENOENT') return null; // caller distinguishes "absent"
return fallback;
}
}
function _normalizeResource(r) {
return {
name: r.name,
displayName: typeof r.displayName === 'string' && r.displayName.length > 0 ? r.displayName : r.name,
type: TYPES.has(r.type) ? r.type : 'app',
host: r.host,
port: r.port,
protocol: typeof r.protocol === 'string' && r.protocol.length > 0 ? r.protocol : 'http',
sensitive: r.sensitive === true,
manual: r.manual !== false, // default true — manual entry
};
}
/**
* Validate identity fields (name/displayName/type). `partial=true` allows
* an omitted name (PATCH). Returns null when valid, else a problem.
*/
function _validateIdentity(input, partial) {
if (!partial || input.name !== undefined) {
if (typeof input.name !== 'string' || !SLUG_RE.test(input.name)) {
return { message: `name must match ${SLUG_RE.toString()}`, field: 'name' };
}
}
if (input.displayName !== undefined
&& (typeof input.displayName !== 'string' || input.displayName.length === 0 || input.displayName.length > 200)) {
return { message: 'displayName must be 1-200 chars', field: 'displayName' };
}
if (input.type !== undefined && !TYPES.has(input.type)) {
return { message: "type must be 'app' or 'device'", field: 'type' };
}
return null;
}
/**
* Validate connectivity/flag fields (host/port/protocol/sensitive/manual).
* `partial=true` allows omitted host/port (PATCH). Returns null when
* valid, else a problem.
*/
function _validateConnectivity(input, partial) {
if (!partial || input.host !== undefined) {
if (typeof input.host !== 'string' || input.host.trim().length === 0 || input.host.length > 253) {
return { message: 'host is required (non-empty, ≤253 chars)', field: 'host' };
}
}
if (!partial || input.port !== undefined) {
const p = input.port;
if (typeof p !== 'number' || !Number.isInteger(p) || p < 1 || p > 65535) {
return { message: 'port must be an integer between 1 and 65535', field: 'port' };
}
}
if (input.protocol !== undefined && !PROTOCOLS.has(input.protocol)) {
return { message: 'protocol must be one of http|https|tcp|udp', field: 'protocol' };
}
if (input.sensitive !== undefined && typeof input.sensitive !== 'boolean') {
return { message: 'sensitive must be a boolean', field: 'sensitive' };
}
if (input.manual !== undefined && typeof input.manual !== 'boolean') {
return { message: 'manual must be a boolean', field: 'manual' };
}
return null;
}
/**
* Validate a resource payload. `partial=true` allows omitted fields
* (PATCH). Returns null when valid, else a { message, field } problem.
*/
function validateResourcePayload(input, { partial = false } = {}) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return { message: 'body must be a JSON object', field: null };
}
return _validateIdentity(input, partial) || _validateConnectivity(input, partial);
}
function createResourcesStore(opts = {}) {
const candidates = [
opts.dataDir,
opts.platformPaths && opts.platformPaths.dataDir,
platformPaths && platformPaths.dataDir,
];
const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
|| require('os').tmpdir(); // eslint-disable-line global-require
const log = opts.log || { info() {}, warn() {}, error() {} };
const file = path.join(dataDir, 'network-resources.json');
let _mutex = Promise.resolve();
function _enqueue(fn) {
const next = _mutex.then(fn, fn);
_mutex = next.catch(() => {});
return next;
}
function _load() {
const data = _readJSON(file, { resources: [] });
if (!data || !Array.isArray(data.resources)) return { resources: [] };
return {
resources: data.resources
.filter(r => r && typeof r === 'object' && typeof r.name === 'string')
.map(_normalizeResource),
};
}
function _save(data) {
atomicWriteJSON(file, data);
}
/**
* Normalize one services.json entry into a seedable resource, or null
* when the entry is unusable (no slug id / no integer port 1-65535).
* host falls back through host/ip/container to 'localhost'.
*/
function _seedableServiceEntry(s) {
if (!s || typeof s !== 'object') return null;
let name = null;
if (typeof s.id === 'string') name = s.id;
else if (typeof s.name === 'string') name = s.name;
if (!name || !SLUG_RE.test(name)) return null;
const port = s.port;
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) return null;
const host = [s.host, s.ip, s.container].find(h => typeof h === 'string' && h.length > 0) || 'localhost';
return _normalizeResource({
name,
displayName: name,
type: 'app',
host,
port,
protocol: s.protocol || 'http',
sensitive: false,
manual: false,
});
}
/**
* Best-effort one-time seed from the services registry (services.json —
* the same file routes/services.js reads via StateManager). Only entries
* with an id and an integer port 1-65535 are seedable. Any problem ⇒
* skip seeding entirely (manual entries only) — never throw at startup.
*/
function _seedFromServices() {
if (!opts.servicesFile || typeof opts.servicesFile !== 'string') return 0;
let services = null;
try {
const raw = JSON.parse(fs.readFileSync(opts.servicesFile, 'utf8'));
if (Array.isArray(raw)) services = raw;
else if (raw && Array.isArray(raw.services)) services = raw.services;
} catch (_) {
return 0; // missing/unreadable — skip seeding
}
if (!services) return 0;
const seeded = [];
for (const s of services) {
const entry = _seedableServiceEntry(s);
if (entry) seeded.push(entry);
}
if (seeded.length === 0) return 0;
_save({ resources: seeded });
log.info && log.info('network-resources', 'seeded from services registry', { count: seeded.length });
return seeded.length;
}
function listResources() {
return _enqueue(() => {
if (!fs.existsSync(file)) _seedFromServices();
return [..._load().resources];
});
}
async function getResource(name) {
const all = await listResources();
return all.find(r => r.name === name) || null;
}
function createResource(input = {}) {
return _enqueue(() => {
const problem = validateResourcePayload(input, { partial: false });
if (problem) throw new ResourceError(problem.message, 400, 'DC_RESOURCE_INVALID');
const data = _load();
if (data.resources.some(r => r.name === input.name)) {
throw new ResourceError(`resource '${input.name}' already exists`, 409, 'DC_RESOURCE_EXISTS');
}
const resource = _normalizeResource({
name: input.name,
displayName: input.displayName !== undefined ? input.displayName : input.name,
type: input.type !== undefined ? input.type : 'app',
host: input.host.trim ? input.host.trim() : input.host,
port: input.port,
protocol: input.protocol !== undefined ? input.protocol : 'http',
sensitive: input.sensitive === true,
manual: input.manual !== undefined ? input.manual : true,
});
data.resources.push(resource);
_save(data);
log.info && log.info('network-resources', 'resource created', { name: resource.name });
return { ...resource };
});
}
function updateResource(name, patch = {}) {
return _enqueue(() => {
const problem = validateResourcePayload(patch, { partial: true });
if (problem) throw new ResourceError(problem.message, 400, 'DC_RESOURCE_INVALID');
if (patch.name !== undefined && patch.name !== name) {
throw new ResourceError('resource name is immutable', 400, 'DC_RESOURCE_IMMUTABLE_NAME');
}
const data = _load();
const idx = data.resources.findIndex(r => r.name === name);
if (idx < 0) throw new ResourceError(`resource '${name}' not found`, 404, 'DC_RESOURCE_NOT_FOUND');
const merged = _normalizeResource({
...data.resources[idx],
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
...(patch.type !== undefined ? { type: patch.type } : {}),
...(patch.host !== undefined ? { host: patch.host.trim ? patch.host.trim() : patch.host } : {}),
...(patch.port !== undefined ? { port: patch.port } : {}),
...(patch.protocol !== undefined ? { protocol: patch.protocol } : {}),
...(patch.sensitive !== undefined ? { sensitive: patch.sensitive } : {}),
...(patch.manual !== undefined ? { manual: patch.manual } : {}),
});
data.resources[idx] = merged;
_save(data);
return { ...merged };
});
}
function deleteResource(name) {
return _enqueue(() => {
const data = _load();
const before = data.resources.length;
data.resources = data.resources.filter(r => r.name !== name);
if (data.resources.length === before) {
throw new ResourceError(`resource '${name}' not found`, 404, 'DC_RESOURCE_NOT_FOUND');
}
_save(data);
log.info && log.info('network-resources', 'resource deleted', { name });
return { deleted: true, name };
});
}
return {
listResources,
getResource,
createResource,
updateResource,
deleteResource,
};
}
module.exports = { createResourcesStore, ResourceError, validateResourcePayload };