From 695794987b772692c6cafb17d34c7da08bf310d3 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 21:37:13 -0700 Subject: [PATCH] DC-144: DashCaddy-side groups + resources model/routes with cascade delete + tests --- .../__tests__/network-groups.routes.test.js | 228 +++++++++++ .../network-resources.routes.test.js | 238 +++++++++++ dashcaddy-api/routes/network-groups.js | 100 +++++ dashcaddy-api/routes/network-resources.js | 103 +++++ dashcaddy-api/src/security/network-groups.js | 374 ++++++++++++++++++ .../src/security/network-resources.js | 308 +++++++++++++++ 6 files changed, 1351 insertions(+) create mode 100644 dashcaddy-api/__tests__/network-groups.routes.test.js create mode 100644 dashcaddy-api/__tests__/network-resources.routes.test.js create mode 100644 dashcaddy-api/routes/network-groups.js create mode 100644 dashcaddy-api/routes/network-resources.js create mode 100644 dashcaddy-api/src/security/network-groups.js create mode 100644 dashcaddy-api/src/security/network-resources.js diff --git a/dashcaddy-api/__tests__/network-groups.routes.test.js b/dashcaddy-api/__tests__/network-groups.routes.test.js new file mode 100644 index 0000000..78e89de --- /dev/null +++ b/dashcaddy-api/__tests__/network-groups.routes.test.js @@ -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); + }); +}); diff --git a/dashcaddy-api/__tests__/network-resources.routes.test.js b/dashcaddy-api/__tests__/network-resources.routes.test.js new file mode 100644 index 0000000..deaa125 --- /dev/null +++ b/dashcaddy-api/__tests__/network-resources.routes.test.js @@ -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); + }); +}); diff --git a/dashcaddy-api/routes/network-groups.js b/dashcaddy-api/routes/network-groups.js new file mode 100644 index 0000000..fa3933d --- /dev/null +++ b/dashcaddy-api/routes/network-groups.js @@ -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; +}; diff --git a/dashcaddy-api/routes/network-resources.js b/dashcaddy-api/routes/network-resources.js new file mode 100644 index 0000000..6020c5d --- /dev/null +++ b/dashcaddy-api/routes/network-resources.js @@ -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; +}; diff --git a/dashcaddy-api/src/security/network-groups.js b/dashcaddy-api/src/security/network-groups.js new file mode 100644 index 0000000..4800dc0 --- /dev/null +++ b/dashcaddy-api/src/security/network-groups.js @@ -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 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 }; diff --git a/dashcaddy-api/src/security/network-resources.js b/dashcaddy-api/src/security/network-resources.js new file mode 100644 index 0000000..0e841a7 --- /dev/null +++ b/dashcaddy-api/src/security/network-resources.js @@ -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 };