From 4b560b9c407fd95bdd61546e58cae3689977a8f0 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 21:14:12 -0700 Subject: [PATCH] DC-141: people store (atomic JSON) + /network/people routes + last-admin guard + tests --- .../__tests__/network-people-store.test.js | 275 ++++++++++++++++ .../__tests__/network-people.routes.test.js | 310 ++++++++++++++++++ dashcaddy-api/routes/network-people.js | 201 ++++++++++++ .../src/security/network-people-store.js | 300 +++++++++++++++++ 4 files changed, 1086 insertions(+) create mode 100644 dashcaddy-api/__tests__/network-people-store.test.js create mode 100644 dashcaddy-api/__tests__/network-people.routes.test.js create mode 100644 dashcaddy-api/routes/network-people.js create mode 100644 dashcaddy-api/src/security/network-people-store.js diff --git a/dashcaddy-api/__tests__/network-people-store.test.js b/dashcaddy-api/__tests__/network-people-store.test.js new file mode 100644 index 0000000..8d1c715 --- /dev/null +++ b/dashcaddy-api/__tests__/network-people-store.test.js @@ -0,0 +1,275 @@ +/** + * Tests for network-people-store (DC-141). + * Coverage: + * - CRUD: upsert/get/update/delete/list round-trips + * - atomicity: tmp+rename writes (no torn file), cache coherence + * - first-person-admin rule: first person gets groups ['Admin'] + + * protectedAt; later people do not + * - last-admin guard: single active admin throws; passes when a second + * active admin exists; throws again when the other admins are blocked + * - findByProviderUserId match + miss + * - corruption recovery: bad JSON restarts empty, .bak written + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { + createPeopleStore, + LastAdminError, + ADMIN_GROUP, +} = require('../src/security/network-people-store'); + +function _tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-peopletest-')); +} + +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +function _admin(name, opts = {}) { + return { + displayName: name, + providerUserId: opts.puid || `puid-${name}`, + ...opts, + }; +} + +describe('network-people-store: CRUD', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('empty store lists zero people', async () => { + const list = await store.listPeople(); + expect(list).toEqual([]); + }); + + test('upsert creates a person with defaults', async () => { + // Seed a bootstrap admin first so the subject is a NON-first person + // (first-person behavior is covered in its own describe block). + await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' }); + const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + expect(p.id).toBeTruthy(); + expect(p.displayName).toBe('Alice'); + expect(p.providerUserId).toBe('ts-1'); + expect(Array.isArray(p.groups)).toBe(true); + expect(Array.isArray(p.devices)).toBe(true); + expect(p.status).toBe('active'); + expect(p.createdAt).toBeTruthy(); + expect(p.protectedAt).toBe(null); + }); + + test('getPerson returns stored person; null for unknown id', async () => { + const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + const got = await store.getPerson(p.id); + expect(got.id).toBe(p.id); + expect(await store.getPerson('nope')).toBe(null); + }); + + test('upsertPerson merges by id (update path)', async () => { + const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + const p2 = await store.upsertPerson({ id: p.id, displayName: 'Alice II', groups: ['Friends'] }); + expect(p2.id).toBe(p.id); + expect(p2.displayName).toBe('Alice II'); + expect(p2.providerUserId).toBe('ts-1'); // retained + expect(p2.createdAt).toBe(p.createdAt); // retained + expect(p2.groups).toEqual(['Friends']); + const list = await store.listPeople(); + expect(list).toHaveLength(1); // merged, not duplicated + }); + + test('updatePerson patches only provided fields', async () => { + const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['G'] }); + const u = await store.updatePerson(p.id, { displayName: 'Bobby' }); + expect(u.displayName).toBe('Bobby'); + expect(u.groups).toEqual(['G']); // untouched by patch + expect(await store.updatePerson('nope', { displayName: 'x' })).toBe(null); + }); + + test('updatePerson cannot change id', async () => { + const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); + const u = await store.updatePerson(p.id, { id: 'evil', displayName: 'B' }); + expect(u.id).toBe(p.id); + const list = await store.listPeople(); + expect(list).toHaveLength(1); + }); + + test('deletePerson removes; false for unknown id', async () => { + const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); + expect(await store.deletePerson(p.id)).toBe(true); + expect(await store.getPerson(p.id)).toBe(null); + expect(await store.deletePerson(p.id)).toBe(false); + }); + + test('returned persons are copies (mutating them does not corrupt store)', async () => { + await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' }); // bootstrap admin + const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + p.groups.push('Hacked'); + const fresh = await store.getPerson(p.id); + expect(fresh.groups).toEqual([]); + }); +}); + +describe('network-people-store: persistence + atomicity', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('people survive a new store instance (round-trip through disk)', async () => { + const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + const store2 = createPeopleStore({ dataDir: dir }); + const got = await store2.getPerson(p.id); + expect(got.displayName).toBe('Alice'); + expect(got.groups).toEqual(p.groups); + }); + + test('write is tmp+rename: no leftover tmp files after save', async () => { + await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + const files = fs.readdirSync(dir).filter(f => f.includes('.tmp-')); + expect(files).toEqual([]); + expect(fs.existsSync(path.join(dir, 'network-people.json'))).toBe(true); + }); + + test('file contents are complete JSON (never torn)', async () => { + await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); + const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-people.json'), 'utf8')); + expect(Object.keys(raw.people)).toHaveLength(2); + }); + + test('rapid sequential writes are all durable (mutex serialization)', async () => { + const names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; + await Promise.all(names.map(n => store.upsertPerson({ displayName: n, providerUserId: `p-${n}` }))); + const list = await store.listPeople(); + expect(list).toHaveLength(names.length); + const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-people.json'), 'utf8')); + expect(Object.keys(raw.people)).toHaveLength(names.length); + }); + + test('corrupt file: store recovers empty and writes .bak', async () => { + await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + fs.writeFileSync(path.join(dir, 'network-people.json'), '{corrupted!!'); + const store2 = createPeopleStore({ dataDir: dir }); + const list = await store2.listPeople(); + expect(list).toEqual([]); + expect(fs.existsSync(path.join(dir, 'network-people.json.bak'))).toBe(true); + }); +}); + +describe('network-people-store: first-person-admin rule', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('first person created gets Admin group + protectedAt', async () => { + const first = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); + expect(first.groups).toEqual([ADMIN_GROUP]); + expect(first.protectedAt).toBeTruthy(); + expect(new Date(first.protectedAt).getTime()).not.toBeNaN(); + }); + + test('second person created does NOT get Admin/protectedAt', async () => { + await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); + const second = await store.upsertPerson({ displayName: 'Friend', providerUserId: 'ts-2' }); + expect(second.groups).toEqual([]); + expect(second.protectedAt).toBe(null); + }); + + test('rule applies only to a truly empty store (delete-then-create does not re-trigger)', async () => { + const first = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); + const second = await store.upsertPerson({ displayName: 'Friend', providerUserId: 'ts-2' }); + await store.deletePerson(first.id); + const third = await store.upsertPerson({ displayName: 'Late', providerUserId: 'ts-3' }); + expect(second.groups).toEqual([]); + expect(third.groups).toEqual([]); + expect(third.protectedAt).toBe(null); + }); +}); + +describe('network-people-store: last-admin guard', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('throws LastAdminError for the only active admin', async () => { + const solo = await store.upsertPerson(_admin('Sami')); // bootstrap admin + await expect(store.assertNotLastAdmin(solo.id)).rejects.toBeInstanceOf(LastAdminError); + }); + + test('passes when a second active admin exists', async () => { + const a = await store.upsertPerson(_admin('Sami')); + const b = await store.upsertPerson(_admin('Deputy')); + await store.updatePerson(b.id, { groups: [ADMIN_GROUP] }); + await expect(store.assertNotLastAdmin(a.id)).resolves.toBeUndefined(); + await expect(store.assertNotLastAdmin(b.id)).resolves.toBeUndefined(); + }); + + test('throws when the other admins are blocked (only one active left)', async () => { + const a = await store.upsertPerson(_admin('Sami')); + const b = await store.upsertPerson(_admin('Deputy')); + await store.updatePerson(b.id, { groups: [ADMIN_GROUP] }); + // Block every admin except a. + await store.updatePerson(b.id, { status: 'blocked' }); + await expect(store.assertNotLastAdmin(a.id)).rejects.toBeInstanceOf(LastAdminError); + // The blocked admin himself is not protected by the guard (he's not + // an ACTIVE admin of consequence — but he IS still the last *listed* + // admin member; the guard protects active access, so blocking b again + // is fine). + await expect(store.assertNotLastAdmin(b.id)).resolves.toBeUndefined(); + }); + + test('removed admins also do not count as active', async () => { + const a = await store.upsertPerson(_admin('Sami')); + const b = await store.upsertPerson(_admin('Deputy')); + await store.updatePerson(b.id, { groups: [ADMIN_GROUP] }); + await store.updatePerson(b.id, { status: 'removed' }); + await expect(store.assertNotLastAdmin(a.id)).rejects.toBeInstanceOf(LastAdminError); + }); + + test('non-admins pass freely', async () => { + await store.upsertPerson(_admin('Sami')); + const user = await store.upsertPerson({ displayName: 'Pleb', providerUserId: 'ts-pleb' }); + await expect(store.assertNotLastAdmin(user.id)).resolves.toBeUndefined(); + }); + + test('unknown ids pass (nothing to protect)', async () => { + await expect(store.assertNotLastAdmin('ghost')).resolves.toBeUndefined(); + }); + + test('LastAdminError carries code + personId', async () => { + const solo = await store.upsertPerson(_admin('Sami')); + try { + await store.assertNotLastAdmin(solo.id); + throw new Error('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(LastAdminError); + expect(err.code).toBe('DC_LAST_ADMIN'); + expect(err.personId).toBe(solo.id); + expect(err.statusCode).toBe(409); + } + }); +}); + +describe('network-people-store: findByProviderUserId', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('finds the person with that provider identity', async () => { + await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1234' }); + const found = await store.findByProviderUserId('ts-1234'); + expect(found).toBeTruthy(); + expect(found.displayName).toBe('Alice'); + }); + + test('returns null on miss and for empty/invalid input', async () => { + await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1234' }); + expect(await store.findByProviderUserId('ts-9999')).toBe(null); + expect(await store.findByProviderUserId('')).toBe(null); + expect(await store.findByProviderUserId(null)).toBe(null); + }); +}); diff --git a/dashcaddy-api/__tests__/network-people.routes.test.js b/dashcaddy-api/__tests__/network-people.routes.test.js new file mode 100644 index 0000000..9db26f7 --- /dev/null +++ b/dashcaddy-api/__tests__/network-people.routes.test.js @@ -0,0 +1,310 @@ +/** + * Route tests for network-people routes (DC-141). + * Coverage: + * - contract for all verbs (GET list, GET one, POST, PATCH, DELETE, POST /sync) + * - first-person-admin bootstrap through the API + * - 404s for unknown ids + * - validation errors (400) for bad bodies + * - DELETE refuses the last active admin (409) + * - sync: mocked provider merge, idempotency, protectedAt never + * downgraded, blocked/removed not resurrected, missing registry → + * clean inactive response + * Hermetic — supertest against an in-process express app, 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, ADMIN_GROUP } = require('../src/security/network-people-store'); +const networkPeopleRoutes = require('../routes/network-people'); + +function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +function createApp({ peopleStore, networkRegistry } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-peopleroutes-')); + const store = peopleStore || createPeopleStore({ dataDir: dir }); + const factory = networkPeopleRoutes; + const router = factory({ + peopleStore: store, + networkRegistry, + asyncHandler, + log: { info: () => {}, warn: () => {}, error: () => {} }, + }); + const app = express(); + app.use(express.json()); + app.use('/network/people', 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, store, dir }; +} + +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +function _mockRegistry(people) { + return { + getActiveProvider: () => ({ + id: () => 'mock', + listPeople: () => Promise.resolve(people), + }), + }; +} + +describe('network-people routes: contract', () => { + let dir, app, store; + beforeEach(() => { + ({ app, store, dir } = createApp()); + }); + afterEach(() => _cleanup(dir)); + + test('GET / lists people', async () => { + await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + const res = await request(app).get('/network/people'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.count).toBe(1); + expect(res.body.data[0].displayName).toBe('Alice'); + }); + + test('GET /:id returns a person; 404 for unknown', async () => { + const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + const good = await request(app).get(`/network/people/${p.id}`); + expect(good.status).toBe(200); + expect(good.body.person.id).toBe(p.id); + const bad = await request(app).get('/network/people/ghost'); + expect(bad.status).toBe(404); + expect(bad.body.success).toBe(false); + }); + + test('POST / creates a person (201)', async () => { + const res = await request(app) + .post('/network/people') + .send({ displayName: 'Alice', providerUserId: 'ts-1' }); + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.person.displayName).toBe('Alice'); + expect(res.body.person.status).toBe('active'); + }); + + test('POST / first person ever gets Admin group + protectedAt', async () => { + const res = await request(app) + .post('/network/people') + .send({ displayName: 'Sami', providerUserId: 'ts-owner' }); + expect(res.status).toBe(201); + expect(res.body.person.groups).toEqual([ADMIN_GROUP]); + expect(res.body.person.protectedAt).toBeTruthy(); + const second = await request(app) + .post('/network/people') + .send({ displayName: 'Friend', providerUserId: 'ts-2' }); + expect(second.body.person.groups).toEqual([]); + expect(second.body.person.protectedAt).toBe(null); + }); + + test('POST / validation errors: missing displayName (400)', async () => { + const res = await request(app) + .post('/network/people') + .send({ providerUserId: 'ts-1' }); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toMatch(/displayName/); + }); + + test('POST / validation errors: missing providerUserId (400)', async () => { + const res = await request(app) + .post('/network/people') + .send({ displayName: 'Alice' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/providerUserId/); + }); + + test('POST / validation errors: groups not an array (400)', async () => { + const res = await request(app) + .post('/network/people') + .send({ displayName: 'Alice', providerUserId: 'ts-1', groups: 'Admin' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/groups/); + }); + + test('PATCH /:id updates fields', async () => { + const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); + const res = await request(app) + .patch(`/network/people/${p.id}`) + .send({ displayName: 'Bobby', groups: ['Friends'] }); + expect(res.status).toBe(200); + expect(res.body.person.displayName).toBe('Bobby'); + expect(res.body.person.groups).toEqual(['Friends']); + }); + + test('PATCH /:id 404 for unknown id; 400 for bad status', async () => { + const missing = await request(app).patch('/network/people/ghost').send({ displayName: 'X' }); + expect(missing.status).toBe(404); + const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); + const bad = await request(app) + .patch(`/network/people/${p.id}`) + .send({ status: 'zombie' }); + expect(bad.status).toBe(400); + expect(bad.body.error).toMatch(/status/); + }); + + test('PATCH /:id 400 for bad displayName', async () => { + const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); + const res = await request(app) + .patch(`/network/people/${p.id}`) + .send({ displayName: '' }); + expect(res.status).toBe(400); + }); + + test('DELETE /:id deletes a person', async () => { + await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' }); // bootstrap admin + const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); + const res = await request(app).delete(`/network/people/${p.id}`); + expect(res.status).toBe(200); + expect(res.body.deleted).toBe(true); + expect(await store.getPerson(p.id)).toBe(null); + }); + + test('DELETE /:id 404 for unknown id', async () => { + const res = await request(app).delete('/network/people/ghost'); + expect(res.status).toBe(404); + }); + + test('DELETE /:id refuses the last active admin (409)', async () => { + const solo = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); + expect(solo.groups).toEqual([ADMIN_GROUP]); // bootstrap admin + const res = await request(app).delete(`/network/people/${solo.id}`); + expect(res.status).toBe(409); + expect(res.body.success).toBe(false); + expect(res.body.error).toMatch(/last active admin/); + // Person is still there. + expect(await store.getPerson(solo.id)).toBeTruthy(); + }); + + test('DELETE /:id succeeds when another active admin exists', async () => { + const a = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); + const b = await store.upsertPerson({ displayName: 'Deputy', providerUserId: 'ts-2' }); + await store.updatePerson(b.id, { groups: [ADMIN_GROUP] }); + const res = await request(app).delete(`/network/people/${a.id}`); + expect(res.status).toBe(200); + }); +}); + +describe('network-people routes: sync', () => { + let dir, app, store; + afterEach(() => _cleanup(dir)); + + test('no networkRegistry → synced:false, no throw', async () => { + ({ app, store, dir } = createApp()); + const res = await request(app).post('/network/people/sync'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.synced).toBe(false); + expect(res.body.added).toBe(0); + }); + + test('registry without getActiveProvider → synced:false', async () => { + ({ app, store, dir } = createApp({ networkRegistry: {} })); + const res = await request(app).post('/network/people/sync'); + expect(res.status).toBe(200); + expect(res.body.synced).toBe(false); + }); + + test('getActiveProvider returning undefined → synced:false', async () => { + ({ app, store, dir } = createApp({ networkRegistry: { getActiveProvider: () => undefined } })); + const res = await request(app).post('/network/people/sync'); + expect(res.status).toBe(200); + expect(res.body.synced).toBe(false); + }); + + test('adds missing people and updates displayNames of existing', async () => { + ({ app, store, dir } = createApp({ + networkRegistry: _mockRegistry([ + { providerUserId: 'ts-1', displayName: 'Alice Updated' }, + { providerUserId: 'ts-9', displayName: 'New Person' }, + ]), + })); + await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); + const res = await request(app).post('/network/people/sync'); + expect(res.status).toBe(200); + expect(res.body.synced).toBe(true); + expect(res.body.added).toBe(1); + expect(res.body.updated).toBe(1); + const list = await store.listPeople(); + const alice = list.find(p => p.providerUserId === 'ts-1'); + const newbie = list.find(p => p.providerUserId === 'ts-9'); + expect(alice.displayName).toBe('Alice Updated'); + expect(newbie.displayName).toBe('New Person'); + expect(newbie.groups).toEqual([]); + }); + + test('idempotent: second sync changes nothing', async () => { + ({ app, store, dir } = createApp({ + networkRegistry: _mockRegistry([ + { providerUserId: 'ts-1', displayName: 'Alice' }, + { providerUserId: 'ts-2', displayName: 'Bob' }, + ]), + })); + const first = await request(app).post('/network/people/sync'); + expect(first.body.added).toBe(2); + const second = await request(app).post('/network/people/sync'); + expect(second.status).toBe(200); + expect(second.body.added).toBe(0); + expect(second.body.updated).toBe(0); + const list = await store.listPeople(); + expect(list).toHaveLength(2); + }); + + test('never downgrades protectedAt on the bootstrap admin', async () => { + ({ app, store, dir } = createApp({ + networkRegistry: _mockRegistry([ + { providerUserId: 'ts-owner', displayName: 'Sami' }, + ]), + })); + const owner = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); + expect(owner.protectedAt).toBeTruthy(); + const before = owner.protectedAt; + await request(app).post('/network/people/sync'); + await request(app).post('/network/people/sync'); + const after = await store.getPerson(owner.id); + expect(after.protectedAt).toBe(before); + expect(after.groups).toEqual([ADMIN_GROUP]); + }); + + test('blocked/removed people are NOT resurrected by sync', async () => { + ({ app, store, dir } = createApp({ + networkRegistry: _mockRegistry([ + { providerUserId: 'ts-1', displayName: 'Blocked Person' }, + ]), + })); + const p = await store.upsertPerson({ displayName: 'Blocked Person', providerUserId: 'ts-1' }); + await store.updatePerson(p.id, { status: 'blocked' }); + const res = await request(app).post('/network/people/sync'); + expect(res.body.added).toBe(0); + expect(res.body.updated).toBe(0); + const after = await store.getPerson(p.id); + expect(after.status).toBe('blocked'); + expect((await store.listPeople())).toHaveLength(1); // no duplicate either + }); + + test('skips malformed provider entries without failing the sync', async () => { + ({ app, store, dir } = createApp({ + networkRegistry: _mockRegistry([ + null, + { providerUserId: '', displayName: 'NoId' }, + { providerUserId: 'ts-5', displayName: 'Good' }, + ]), + })); + const res = await request(app).post('/network/people/sync'); + expect(res.status).toBe(200); + expect(res.body.added).toBe(1); + }); +}); diff --git a/dashcaddy-api/routes/network-people.js b/dashcaddy-api/routes/network-people.js new file mode 100644 index 0000000..99ca17f --- /dev/null +++ b/dashcaddy-api/routes/network-people.js @@ -0,0 +1,201 @@ +/** + * Network people routes — DC-141. + * + * CRUD for the Private Network people store (spec §2/§5) plus a provider + * sync endpoint. Mounted on the authed apiRouter under + * /api/v1/network/people — auth middleware is the caller's job (same as + * every other route module in this repo). + * + * GET /network/people → list all people + * GET /network/people/:id → one person + * POST /network/people → create (first person = bootstrap + * admin: groups ['Admin'] + protectedAt) + * PATCH /network/people/:id → update + * DELETE /network/people/:id → delete (assertNotLastAdmin first) + * POST /network/people/sync → merge provider.listPeople() into the + * store (idempotent, guarded) + * + * The provider registry (DC-140, src/network/) is built in parallel; the + * only dependency is deps.networkRegistry.getActiveProvider() — treated + * as optional. When absent, POST /sync returns a clean "provider + * inactive" response instead of throwing. + */ + +'use strict'; + +const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors'); +const { ok, created } = require('../src/utils/responses'); +const { createPeopleStore } = require('../src/security/network-people-store'); +const { LastAdminError } = require('../src/security/network-people-store'); + +const VALID_STATUSES = new Set(['active', 'blocked', 'removed']); + +function _validDisplayName(s) { + return typeof s === 'string' && s.trim().length > 0 && s.length <= 200; +} + +function _validProviderUserId(s) { + return typeof s === 'string' && s.length > 0 && s.length <= 256; +} + +module.exports = function networkPeopleRoutesFactory({ + peopleStore, + networkRegistry, + asyncHandler, + log = { info() {}, warn() {}, error() {} }, +} = {}) { + const router = require('express').Router(); // eslint-disable-line global-require + + const store = peopleStore || createPeopleStore({}); + if (!asyncHandler) { + // Must always be wired in prod (see share.js); test fallback only. + if (process.env.NODE_ENV !== 'test') { + throw new Error('networkPeopleRoutes requires asyncHandler'); + } + asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign + } + + // ─── Reads ────────────────────────────────────────────────────────────── + + router.get('/', asyncHandler(async (req, res) => { + const people = await store.listPeople(); + ok(res, { data: people, count: people.length }); + }, 'network-people-list')); + + router.get('/:id', asyncHandler(async (req, res) => { + const person = await store.getPerson(req.params.id); + if (!person) throw new NotFoundError('person'); + ok(res, { person }); + }, 'network-people-get')); + + // ─── Create ───────────────────────────────────────────────────────────── + + router.post('/', asyncHandler(async (req, res) => { + const { displayName, providerUserId, groups, devices } = req.body || {}; + if (!_validDisplayName(displayName)) { + throw new ValidationError('displayName is required (1-200 chars)', 'displayName'); + } + if (!_validProviderUserId(providerUserId)) { + throw new ValidationError('providerUserId is required (1-256 chars)', 'providerUserId'); + } + if (groups !== undefined && !Array.isArray(groups)) { + throw new ValidationError('groups must be an array of strings', 'groups'); + } + if (devices !== undefined && !Array.isArray(devices)) { + throw new ValidationError('devices must be an array', 'devices'); + } + // First person created in an empty store becomes the bootstrap admin + // (groups ['Admin'] + protectedAt) — enforced inside the store. + const person = await store.upsertPerson({ + displayName: displayName.trim(), + providerUserId, + ...(groups !== undefined ? { groups } : {}), + ...(devices !== undefined ? { devices } : {}), + }); + log.info && log.info('people', 'person created via API', { id: person.id }); + created(res, { person }); + }, 'network-people-create')); + + // ─── Update ───────────────────────────────────────────────────────────── + + router.patch('/:id', asyncHandler(async (req, res) => { + const patch = req.body || {}; + if (patch.displayName !== undefined && !_validDisplayName(patch.displayName)) { + throw new ValidationError('displayName must be 1-200 chars', 'displayName'); + } + if (patch.providerUserId !== undefined && !_validProviderUserId(patch.providerUserId)) { + throw new ValidationError('providerUserId must be 1-256 chars', 'providerUserId'); + } + if (patch.groups !== undefined && !Array.isArray(patch.groups)) { + throw new ValidationError('groups must be an array of strings', 'groups'); + } + if (patch.devices !== undefined && !Array.isArray(patch.devices)) { + throw new ValidationError('devices must be an array', 'devices'); + } + if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) { + throw new ValidationError(`status must be one of active|blocked|removed`, 'status'); + } + const updated = await store.updatePerson(req.params.id, patch); + if (!updated) throw new NotFoundError('person'); + ok(res, { person: updated }); + }, 'network-people-update')); + + // ─── Delete ───────────────────────────────────────────────────────────── + + router.delete('/:id', asyncHandler(async (req, res) => { + // Lockout guard first (spec §5): the last active admin is undeletable. + try { + await store.assertNotLastAdmin(req.params.id); + } catch (err) { + if (err instanceof LastAdminError) { + throw new ConflictError('cannot remove the last active admin'); + } + throw err; + } + const deleted = await store.deletePerson(req.params.id); + if (!deleted) throw new NotFoundError('person'); + ok(res, { deleted: true, id: req.params.id }); + }, 'network-people-delete')); + + // ─── Provider sync ────────────────────────────────────────────────────── + // + // Merge provider.listPeople() into the local store: + // - match by providerUserId + // - add people not present locally + // - update displayName of existing people + // - idempotent: running twice yields no further change + // - never downgrades protectedAt (admin protection is sticky) + // - blocked/removed people are NOT resurrected by sync — a local + // terminal status wins over the provider saying the user exists. + + router.post('/sync', asyncHandler(async (req, res) => { + const registry = networkRegistry; + if (!registry || typeof registry.getActiveProvider !== 'function') { + // Feature inactive (provider registry not built yet / no provider + // configured) — report cleanly instead of 500ing. + ok(res, { synced: false, reason: 'network provider not active', added: 0, updated: 0 }); + return; + } + let provider = null; + try { + provider = registry.getActiveProvider(); + } catch (_) { + provider = null; + } + if (!provider || typeof provider.listPeople !== 'function') { + ok(res, { synced: false, reason: 'network provider not active', added: 0, updated: 0 }); + return; + } + + const providerPeople = await provider.listPeople(); + if (!Array.isArray(providerPeople)) { + throw new ValidationError('provider listPeople() must return an array', 'provider'); + } + + let added = 0; + let updated = 0; + for (const pp of providerPeople) { + if (!pp || !_validProviderUserId(pp.providerUserId)) continue; // skip junk + const existing = await store.findByProviderUserId(pp.providerUserId); + if (!existing) { + await store.upsertPerson({ + displayName: _validDisplayName(pp.displayName) ? pp.displayName : pp.providerUserId, + providerUserId: pp.providerUserId, + groups: [], + devices: [], + }); + added += 1; + } else if (existing.status === 'active' + && _validDisplayName(pp.displayName) + && pp.displayName !== existing.displayName) { + await store.updatePerson(existing.id, { displayName: pp.displayName }); + updated += 1; + } + } + + log.info && log.info('people', 'provider sync complete', { added, updated }); + ok(res, { synced: true, added, updated }); + }, 'network-people-sync')); + + return router; +}; diff --git a/dashcaddy-api/src/security/network-people-store.js b/dashcaddy-api/src/security/network-people-store.js new file mode 100644 index 0000000..71b9e6a --- /dev/null +++ b/dashcaddy-api/src/security/network-people-store.js @@ -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 };