merge dc/DC-141 into p7-wave1

This commit is contained in:
Hermes committed 2026-10-03 21:20:40 -07:00
commit d3c1dfd996
4 files changed
+1086

No files matched your search

@@ -0,0 +1,300 @@
/**
* People store — DC-141.
*
* Canonical store of Person records for the DashCaddy Private Network
* (spec §2): a human with an identity at the network provider
* (Tailscale user id or WireGuard pubkey identity), DashCaddy-side
* groups, a lifecycle status, and devices.
*
* Storage: data/network-people.json — atomic durable writes via the
* canonical shared atomic-write util (DC-099/DC-100): fsync'd tmp+rename.
* An in-memory cache fronts the file; a serialized write mutex keeps
* read-modify-write cycles atomic. On corrupt JSON the bad file is moved
* aside to network-people.json.bak (best-effort) and the store restarts
* empty rather than crash-looping.
*
* Person shape (spec §2):
* { id, displayName, providerUserId, groups[], status,
* devices[], createdAt, protectedAt }
* status ∈ 'active' | 'blocked' | 'removed'
* protectedAt: nullable ISO string — set when the person becomes the
* bootstrap admin (first person) or is otherwise admin-protected.
*
* Last-admin guard: assertNotLastAdmin(personId) throws LastAdminError
* when the person is the ONLY active member of group 'Admin'. Exported
* for reuse by DC-143 (block/unblock/remove) and DC-148 (ACL compiler).
*/
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');
const { atomicWriteJSON } = require('../utils/atomic-write');
const ADMIN_GROUP = 'Admin';
const STATUSES = new Set(['active', 'blocked', 'removed']);
/**
* Thrown by assertNotLastAdmin() when removing/blocking the person would
* leave the tailnet with zero active admins (lockout guard, spec §4/§5).
*/
class LastAdminError extends Error {
constructor(personId) {
super(`refusing to remove the last active admin (${personId})`);
this.name = 'LastAdminError';
this.code = 'DC_LAST_ADMIN';
this.personId = personId;
this.statusCode = 409;
this.isOperational = true;
}
}
function _nowIso() { return new Date().toISOString(); }
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.copyFileSync(filePath, `${filePath}.bak`);
fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`);
} catch (_) { /* best-effort quarantine; read fails again → fallback */ }
try { fs.readFileSync(filePath, 'utf8'); } catch (_) { return fallback; }
return fallback;
}
}
function _defaultData() { return { people: {} }; }
function _normalizePerson(p) {
return {
id: p.id,
displayName: p.displayName || '',
providerUserId: p.providerUserId || '',
groups: Array.isArray(p.groups) ? p.groups : [],
status: STATUSES.has(p.status) ? p.status : 'active',
devices: Array.isArray(p.devices) ? p.devices : [],
createdAt: p.createdAt || _nowIso(),
protectedAt: typeof p.protectedAt === 'string' ? p.protectedAt : null,
};
}
function createPeopleStore(opts = {}) {
// Same defensive resolver as invite-store / user-store — universal-deps
// test proxies can return function-typed values for property access.
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-people.json');
// In-memory cache: populated on first load, invalidated on every save so
// a second store instance on the same file (or an external edit) is
// re-read rather than served stale.
let _cache = null;
// Serialized write mutex (invite-store pattern) — every mutation runs
// load→modify→save inside _enqueue so concurrent callers cannot lose
// writes (last-writer-wins whole-file clobber).
let _mutex = Promise.resolve();
function _enqueue(fn) {
const next = _mutex.then(fn, fn);
_mutex = next.catch(() => {});
return next;
}
function _load() {
if (_cache) return _cache;
const data = _readJSON(file, _defaultData());
if (!data.people || typeof data.people !== 'object' || Array.isArray(data.people)) {
data.people = {};
}
for (const id of Object.keys(data.people)) {
const p = data.people[id];
if (!p || typeof p !== 'object') { delete data.people[id]; continue; }
data.people[id] = _normalizePerson(p);
}
_cache = data;
return data;
}
function _save(data) {
atomicWriteJSON(file, data);
_cache = data;
}
/**
* All people, active-status first then alphabetical, as plain objects.
*/
function listPeople() {
return _enqueue(() => Object.values(_load().people).map(p => ({ ...p, devices: [...p.devices], groups: [...p.groups] })));
}
/**
* Person by id, or null.
*/
function getPerson(id) {
return _enqueue(() => {
const p = _load().people[id];
return p ? { ...p, devices: [...p.devices], groups: [...p.groups] } : null;
});
}
/**
* Find by provider identity (Tailscale user id / WG pubkey). Returns the
* person or null. Used by the sync endpoint to match provider users to
* local records.
*/
function findByProviderUserId(providerUserId) {
return _enqueue(() => {
if (typeof providerUserId !== 'string' || providerUserId.length === 0) return null;
const people = _load().people;
for (const id of Object.keys(people)) {
if (people[id].providerUserId === providerUserId) {
const p = people[id];
return { ...p, devices: [...p.devices], groups: [...p.groups] };
}
}
return null;
});
}
/**
* Create or update a person keyed by id (id optional on create — a
* randomUUID is minted when absent). The FIRST person ever stored in an
* empty store is auto-promoted: groups ['Admin'] and protectedAt set
* (bootstrap admin, spec §2). Merging never drops protectedAt.
*
* Returns the stored person.
*/
function upsertPerson(input = {}) {
return _enqueue(() => {
const data = _load();
const existing = input.id ? data.people[input.id] : null;
const isFirst = Object.keys(data.people).length === 0;
const id = existing ? existing.id : (input.id || crypto.randomUUID());
function _resolveGroups() {
if (Array.isArray(input.groups)) return [...input.groups];
if (existing) return [...existing.groups];
return isFirst ? [ADMIN_GROUP] : [];
}
function _resolveDevices() {
if (Array.isArray(input.devices)) return [...input.devices];
if (existing) return [...existing.devices];
return [];
}
function _resolveProtectedAt() {
if (input.protectedAt !== undefined) return input.protectedAt;
if (existing && existing.protectedAt) return existing.protectedAt;
return isFirst ? _nowIso() : null;
}
const person = _normalizePerson({
...existing,
...input,
id,
// preserve guarded/lifecycle fields unless explicitly provided
groups: _resolveGroups(),
devices: _resolveDevices(),
createdAt: existing ? existing.createdAt : (input.createdAt || _nowIso()),
protectedAt: _resolveProtectedAt(),
});
data.people[id] = person;
_save(data);
log.info && log.info('people', existing ? 'person updated' : 'person created', { id });
return { ...person, devices: [...person.devices], groups: [...person.groups] };
});
}
/**
* Patch a person by id. Returns the updated person or null if missing.
* `id` is immutable. `protectedAt` can only be set, never cleared, by a
* patch (admin protection outlives profile edits).
*/
function updatePerson(id, patch = {}) {
return _enqueue(() => {
const data = _load();
const existing = data.people[id];
if (!existing) return null;
const { id: _ignored, ...rest } = patch; // id immutable
const merged = _normalizePerson({
...existing,
...rest,
id,
groups: Array.isArray(patch.groups) ? [...patch.groups] : existing.groups,
devices: Array.isArray(patch.devices) ? [...patch.devices] : existing.devices,
createdAt: existing.createdAt,
protectedAt: patch.protectedAt !== undefined ? patch.protectedAt : existing.protectedAt,
});
data.people[id] = merged;
_save(data);
return { ...merged, devices: [...merged.devices], groups: [...merged.groups] };
});
}
/**
* Delete a person by id. Returns true if deleted, false if not found.
*/
function deletePerson(id) {
return _enqueue(() => {
const data = _load();
if (!data.people[id]) return false;
delete data.people[id];
_save(data);
return true;
});
}
/**
* Active members of a group (status === 'active').
*/
function _activeMembersOf(data, group) {
return Object.values(data.people).filter(p => p.groups.includes(group) && p.status === 'active');
}
/**
* Last-admin lockout guard (spec §4/§5): throws LastAdminError when
* `personId` is the only ACTIVE member of the 'Admin' group. Blocking or
* removing such a person would leave the network adminless, so DC-143
* (block/remove) and DC-148 (ACL compiler) must call this first.
*
* Non-admins and admins with at least one other active admin pass freely.
* Unknown ids pass (nothing to protect).
*/
function assertNotLastAdmin(personId) {
return _enqueue(() => {
const data = _load();
const person = data.people[personId];
if (!person) return;
if (!person.groups.includes(ADMIN_GROUP)) return;
const activeAdmins = _activeMembersOf(data, ADMIN_GROUP);
if (activeAdmins.length === 1 && activeAdmins[0].id === personId) {
throw new LastAdminError(personId);
}
});
}
return {
listPeople,
getPerson,
findByProviderUserId,
upsertPerson,
updatePerson,
deletePerson,
assertNotLastAdmin,
ADMIN_GROUP,
};
}
module.exports = { createPeopleStore, LastAdminError, ADMIN_GROUP };