merge dc/DC-144 into p7-wave1
This commit is contained in:
6 files changed
+1351
No files matched your search
@@ -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 };
|
||||
Reference in new issue
Block a user