merge dc/DC-141 into p7-wave1
This commit is contained in:
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 };
|
||||
Reference in new issue
Block a user