P7 Private Network (DC-140..DC-154 + judge fixes) #1
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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