/** * 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(); // Resolve the existing record by id when given, else by providerUserId // (dogfood finding 2026-10-04: the wizard upserts a person AFTER a // provider sync already created one for the same Tailscale user with no // id — without this lookup the same providerUserId was duplicated). const existing = input.id ? data.people[input.id] : (input.providerUserId ? Object.values(data.people).find( (p) => p.providerUserId && p.providerUserId === input.providerUserId ) || null : 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 };