mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
this is a big slop that might backfire lol... new config system and UI!
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// Configuration System Tests
|
||||
// Purpose: Verifies strict defaults, immutable revisions, secret handling, legacy import, and administrator safety.
|
||||
// Scope: Uses isolated temporary databases and never opens the development server's data store.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation');
|
||||
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
|
||||
const { getFeatureFlags } = require('./index');
|
||||
const { createConfigurationDatabase } = require('./database');
|
||||
const { parseLegacyConfiguration, importLegacyConfiguration } = require('./legacyImporter');
|
||||
|
||||
const temporaryRoots = [];
|
||||
|
||||
function createTestDatabase() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-configuration-'));
|
||||
temporaryRoots.push(root);
|
||||
return createConfigurationDatabase({ databasePath: path.join(root, 'configuration.sqlite') });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
|
||||
});
|
||||
|
||||
test('safe defaults form a complete valid configuration with integrations disabled', () => {
|
||||
assert.doesNotThrow(() => assertValidConfig(defaultConfig));
|
||||
assert.equal(defaultConfig.discord.enabled, false);
|
||||
assert.equal(defaultConfig.homeAssistant.enabled, false);
|
||||
assert.equal(defaultConfig.ptzCamera.enabled, false);
|
||||
assert.equal(defaultConfig.balanceBoard.enabled, false);
|
||||
});
|
||||
|
||||
test('service definitions determine document order and write-only secret handling', () => {
|
||||
/*
|
||||
The generic browser form and backend persistence both consume this one
|
||||
assembled schema. Guarding composition order and derived secret paths here
|
||||
prevents either consumer from needing its own parallel registry.
|
||||
*/
|
||||
assert.deepEqual(Object.keys(defaultConfig), definitions.map(({ key }) => key));
|
||||
assert.deepEqual(Object.keys(rootSchema.properties), Object.keys(defaultConfig));
|
||||
assert.deepEqual(secretPaths, ['homeAssistant.token', 'ptzCamera.password', 'discord.token']);
|
||||
assert.equal(rootSchema.properties.homeAssistant.properties.token.writeOnly, true);
|
||||
assert.equal(rootSchema.properties.ptzCamera.properties.password.writeOnly, true);
|
||||
assert.equal(rootSchema.properties.discord.properties.token.writeOnly, true);
|
||||
});
|
||||
|
||||
test('service definitions generate public feature paths without a separate registry', () => {
|
||||
/*
|
||||
This order follows the one configuration document, including nested Neato
|
||||
and lift definitions beneath Home Assistant. The assertion makes duplicate,
|
||||
omitted, or centrally reintroduced feature names visible during review.
|
||||
*/
|
||||
assert.deepEqual(featureDefinitions, [
|
||||
{ key: 'interInstance', path: ['interInstance', 'enabled'] },
|
||||
{ key: 'barcodeGames', path: ['barcodeGames', 'enabled'] },
|
||||
{ key: 'homeAssistant', path: ['homeAssistant', 'enabled'] },
|
||||
{ key: 'neato', path: ['homeAssistant', 'neato', 'enabled'] },
|
||||
{ key: 'lift', path: ['homeAssistant', 'lift', 'enabled'] },
|
||||
{ key: 'roomCameras', path: ['roomCameras', 'enabled'] },
|
||||
{ key: 'ptzCamera', path: ['ptzCamera', 'enabled'] },
|
||||
{ key: 'kinect', path: ['kinect', 'enabled'] },
|
||||
{ key: 'balanceBoard', path: ['balanceBoard', 'enabled'] },
|
||||
{ key: 'buttonBox', path: ['buttonBox', 'enabled'] },
|
||||
{ key: 'barcodeScanner', path: ['barcodeScanner', 'enabled'] },
|
||||
{ key: 'discord', path: ['discord', 'enabled'] },
|
||||
{ key: 'socials', path: ['socials', 'enabled'] },
|
||||
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('generated feature flags use only each declared enabled switch', () => {
|
||||
/*
|
||||
This deliberately describes services without usable credentials, devices,
|
||||
or enabled parents. Readiness belongs to runtime health, so the generated
|
||||
public flags must still preserve each operator-selected switch exactly.
|
||||
*/
|
||||
const flags = getFeatureFlags({
|
||||
homeAssistant: {
|
||||
enabled: false,
|
||||
lift: { enabled: true },
|
||||
neato: { enabled: true },
|
||||
},
|
||||
roomCameras: { enabled: true, cameras: [] },
|
||||
barcodeScanner: { enabled: false },
|
||||
barcodeGames: { enabled: true },
|
||||
socials: { enabled: true, links: [] },
|
||||
ptzCamera: { enabled: true, host: '', username: '', password: '' },
|
||||
discord: { enabled: true, token: '' },
|
||||
});
|
||||
|
||||
assert.equal(flags.homeAssistant, false);
|
||||
assert.equal(flags.lift, true);
|
||||
assert.equal(flags.neato, true);
|
||||
assert.equal(flags.roomCameras, true);
|
||||
assert.equal(flags.barcodeScanner, false);
|
||||
assert.equal(flags.barcodeGames, true);
|
||||
assert.equal(flags.socials, true);
|
||||
assert.equal(flags.ptzCamera, true);
|
||||
assert.equal(flags.discord, true);
|
||||
});
|
||||
|
||||
test('normalization fills missing legacy fields but strict validation rejects unknown fields', () => {
|
||||
const normalized = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video' } });
|
||||
assert.deepEqual(normalized.media.additionalHosts, []);
|
||||
assert.doesNotThrow(() => assertValidConfig(normalized));
|
||||
|
||||
const invalid = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video', misspelledHost: 'x' } });
|
||||
assert.throws(() => assertValidConfig(invalid), (error) => {
|
||||
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
|
||||
assert.ok(error.validationErrors.some((entry) => entry.path.includes('misspelledHost')));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('full-document updates preserve secrets and reject a stale browser revision', () => {
|
||||
const database = createTestDatabase();
|
||||
const initial = database.getActiveConfigurationRecord();
|
||||
const tokenRevision = database.updateConfiguration({
|
||||
value: database.getClientConfiguration().config,
|
||||
expectedRevision: initial.revision,
|
||||
actor: 'test',
|
||||
secretOperations: {
|
||||
'discord.token': { action: 'replace', value: 'super-secret-token' },
|
||||
},
|
||||
});
|
||||
|
||||
const client = database.getClientConfiguration();
|
||||
assert.equal(client.config.discord.token, '');
|
||||
assert.equal(client.configuredSecrets['discord.token'], true);
|
||||
const editedConfiguration = structuredClone(client.config);
|
||||
editedConfiguration.discord.enabled = true;
|
||||
const nextRevision = database.updateConfiguration({
|
||||
value: editedConfiguration,
|
||||
expectedRevision: tokenRevision,
|
||||
actor: 'test',
|
||||
});
|
||||
assert.equal(database.getActiveConfigurationRecord().config.discord.token, 'super-secret-token');
|
||||
assert.throws(() => database.updateConfiguration({
|
||||
value: editedConfiguration,
|
||||
expectedRevision: tokenRevision,
|
||||
actor: 'stale-test',
|
||||
}), (error) => error.code === 'CONFIG_REVISION_CONFLICT' && error.currentRevision === nextRevision);
|
||||
|
||||
const rollbackRevision = database.restoreConfigurationRevision({
|
||||
revision: tokenRevision,
|
||||
expectedRevision: nextRevision,
|
||||
actor: 'rollback-test',
|
||||
});
|
||||
assert.ok(rollbackRevision > nextRevision);
|
||||
assert.equal(database.getActiveConfigurationRecord().config.discord.enabled, false);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('administrator storage never exposes hashes or removes the final lockdown administrator', () => {
|
||||
const database = createTestDatabase();
|
||||
const lockdown = database.createAdministrator({
|
||||
username: 'owner',
|
||||
passwordHash: '$2b$10$example',
|
||||
role: 'lockdown',
|
||||
});
|
||||
const listed = database.listAdministrators();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(Object.hasOwn(listed[0], 'passwordHash'), false);
|
||||
assert.throws(() => database.deleteAdministrator(lockdown.id, 'test'), /final lockdown administrator/);
|
||||
assert.throws(() => database.updateAdministrator(lockdown.id, { role: 'admin' }, 'test'), /final lockdown administrator/);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('legacy YAML imports configuration and bcrypt hashes exactly once', () => {
|
||||
const yamlText = `
|
||||
admins:
|
||||
- username: owner
|
||||
password_hash: "$2b$10$preservedHash"
|
||||
discord_id: "1234"
|
||||
lockdown: true
|
||||
timezone: America/Chicago
|
||||
media:
|
||||
whepBaseUrl: http://localhost:8889/video
|
||||
`;
|
||||
const parsed = parseLegacyConfiguration(yamlText);
|
||||
assert.equal(parsed.config.timezone, 'America/Chicago');
|
||||
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
|
||||
|
||||
const database = createTestDatabase();
|
||||
const result = importLegacyConfiguration({ text: yamlText, database, dryRun: false });
|
||||
assert.equal(result.administratorCount, 1);
|
||||
assert.equal(database.findAdministratorForAuthentication('OWNER').passwordHash, '$2b$10$preservedHash');
|
||||
assert.throws(() => importLegacyConfiguration({ text: yamlText, database }), /cannot replace an initialized installation/);
|
||||
database.close();
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
// Configuration Database
|
||||
// Purpose: Persists complete immutable configuration revisions, administrator accounts, and administrative audit history.
|
||||
// Scope: Owns SQLite transactions and invariants; transport authorization and password hashing remain service concerns.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const { resolveDataPath } = require('../helpers/dataPaths');
|
||||
const { applySchemaMigrations } = require('./migrations');
|
||||
const {
|
||||
defaultConfig,
|
||||
secretPaths,
|
||||
clone,
|
||||
normalizeConfig,
|
||||
assertValidConfig,
|
||||
} = require('./validation');
|
||||
|
||||
const DEFAULT_DATABASE_PATH = resolveDataPath('configuration.sqlite');
|
||||
|
||||
function normalizeUsername(value) {
|
||||
const username = String(value || '').trim();
|
||||
if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(username)) {
|
||||
throw new Error('Administrator username must be 1-64 letters, numbers, dots, underscores, or hyphens.');
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
function normalizeRole(value) {
|
||||
if (value === 'admin' || value === 'lockdown') return value;
|
||||
throw new Error('Administrator role must be admin or lockdown.');
|
||||
}
|
||||
|
||||
function splitPath(value) {
|
||||
return String(value || '').split('.').filter(Boolean);
|
||||
}
|
||||
|
||||
function getAtPath(object, dottedPath) {
|
||||
return splitPath(dottedPath).reduce((value, key) => value?.[key], object);
|
||||
}
|
||||
|
||||
function setAtPath(object, dottedPath, value) {
|
||||
const parts = splitPath(dottedPath);
|
||||
let cursor = object;
|
||||
parts.slice(0, -1).forEach((key) => {
|
||||
if (!cursor[key] || typeof cursor[key] !== 'object') cursor[key] = {};
|
||||
cursor = cursor[key];
|
||||
});
|
||||
cursor[parts.at(-1)] = value;
|
||||
}
|
||||
|
||||
function redactConfiguration(config) {
|
||||
const redacted = clone(config);
|
||||
const configuredSecrets = {};
|
||||
secretPaths.forEach((secretPath) => {
|
||||
configuredSecrets[secretPath] = Boolean(getAtPath(config, secretPath));
|
||||
setAtPath(redacted, secretPath, '');
|
||||
});
|
||||
return { config: redacted, configuredSecrets };
|
||||
}
|
||||
|
||||
function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } = {}) {
|
||||
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
|
||||
const db = new Database(databasePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
applySchemaMigrations(db);
|
||||
|
||||
const readActiveStatement = db.prepare(`
|
||||
SELECT r.id, r.config_json, r.created_at, r.actor, r.source
|
||||
FROM configuration_state s
|
||||
JOIN configuration_revisions r ON r.id = s.active_revision_id
|
||||
WHERE s.singleton = 1
|
||||
`);
|
||||
const insertRevisionStatement = db.prepare(`
|
||||
INSERT INTO configuration_revisions (config_json, created_at, actor, source)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const activateRevisionStatement = db.prepare(`
|
||||
INSERT INTO configuration_state (singleton, active_revision_id)
|
||||
VALUES (1, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET active_revision_id = excluded.active_revision_id
|
||||
`);
|
||||
const insertAuditStatement = db.prepare(`
|
||||
INSERT INTO administrative_audit_events (created_at, actor, action, details_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
function writeAudit(actor, action, details = {}) {
|
||||
/*
|
||||
Callers pass deliberately small, already-redacted metadata. Configuration
|
||||
values and password hashes never belong in audit details because audit
|
||||
history is routinely displayed and retained longer than request bodies.
|
||||
*/
|
||||
insertAuditStatement.run(Date.now(), String(actor || 'system'), String(action), JSON.stringify(details));
|
||||
}
|
||||
|
||||
const commitRevisionTransaction = db.transaction((config, metadata) => {
|
||||
const current = readActiveStatement.get();
|
||||
if (metadata.expectedRevision != null && Number(metadata.expectedRevision) !== Number(current?.id)) {
|
||||
const error = new Error('Configuration changed in another session. Reload before saving.');
|
||||
error.code = 'CONFIG_REVISION_CONFLICT';
|
||||
error.currentRevision = current?.id || null;
|
||||
throw error;
|
||||
}
|
||||
assertValidConfig(config);
|
||||
const createdAt = Date.now();
|
||||
const inserted = insertRevisionStatement.run(
|
||||
JSON.stringify(config),
|
||||
createdAt,
|
||||
String(metadata.actor || 'system'),
|
||||
String(metadata.source || 'admin'),
|
||||
);
|
||||
activateRevisionStatement.run(inserted.lastInsertRowid);
|
||||
writeAudit(metadata.actor, 'configuration.saved', {
|
||||
revision: Number(inserted.lastInsertRowid),
|
||||
source: String(metadata.source || 'admin'),
|
||||
});
|
||||
return Number(inserted.lastInsertRowid);
|
||||
});
|
||||
|
||||
const initialActiveRow = readActiveStatement.get();
|
||||
if (!initialActiveRow) {
|
||||
commitRevisionTransaction(clone(defaultConfig), {
|
||||
actor: 'system',
|
||||
source: 'first-boot-defaults',
|
||||
});
|
||||
} else {
|
||||
/*
|
||||
New service-owned fields receive their declared defaults as a new revision on
|
||||
startup. Unknown or newly invalid fields still fail validation; this is a
|
||||
forward schema evolution path, not a compatibility layer that discards
|
||||
data it no longer understands.
|
||||
*/
|
||||
const storedConfig = JSON.parse(initialActiveRow.config_json);
|
||||
const normalizedConfig = normalizeConfig(storedConfig);
|
||||
assertValidConfig(normalizedConfig);
|
||||
if (JSON.stringify(normalizedConfig) !== JSON.stringify(storedConfig)) {
|
||||
commitRevisionTransaction(normalizedConfig, {
|
||||
expectedRevision: Number(initialActiveRow.id),
|
||||
actor: 'system',
|
||||
source: 'registered-defaults',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveConfigurationRecord() {
|
||||
const row = readActiveStatement.get();
|
||||
if (!row) throw new Error('Active configuration revision is missing.');
|
||||
return {
|
||||
revision: Number(row.id),
|
||||
config: JSON.parse(row.config_json),
|
||||
createdAt: Number(row.created_at),
|
||||
actor: row.actor,
|
||||
source: row.source,
|
||||
};
|
||||
}
|
||||
|
||||
function getClientConfiguration() {
|
||||
const record = getActiveConfigurationRecord();
|
||||
const redacted = redactConfiguration(record.config);
|
||||
return { ...record, ...redacted };
|
||||
}
|
||||
|
||||
function updateConfiguration({ value, expectedRevision, secretOperations = {}, actor }) {
|
||||
const active = getActiveConfigurationRecord();
|
||||
const candidate = clone(value);
|
||||
|
||||
/*
|
||||
The browser edits one complete document, but its copy contains blank
|
||||
placeholders in place of every stored secret. Restore all current secret
|
||||
values first, then apply only explicit replace or clear operations. This
|
||||
keeps the full-document save model simple without ever sending an
|
||||
existing credential back to the browser.
|
||||
*/
|
||||
secretPaths.forEach((secretPath) => {
|
||||
setAtPath(candidate, secretPath, getAtPath(active.config, secretPath));
|
||||
const operation = secretOperations[secretPath];
|
||||
if (!operation) return;
|
||||
if (operation.action === 'clear') setAtPath(candidate, secretPath, '');
|
||||
else if (operation.action === 'replace' && typeof operation.value === 'string' && operation.value.length > 0) {
|
||||
setAtPath(candidate, secretPath, operation.value);
|
||||
} else {
|
||||
throw new Error(`Invalid secret operation for ${secretPath}.`);
|
||||
}
|
||||
});
|
||||
|
||||
return commitRevisionTransaction(candidate, {
|
||||
expectedRevision,
|
||||
actor,
|
||||
source: 'admin-ui',
|
||||
});
|
||||
}
|
||||
|
||||
function listConfigurationRevisions({ limit = 100 } = {}) {
|
||||
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 100)));
|
||||
return db.prepare(`
|
||||
SELECT id, created_at, actor, source
|
||||
FROM configuration_revisions
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit).map((row) => ({
|
||||
revision: Number(row.id),
|
||||
createdAt: Number(row.created_at),
|
||||
actor: row.actor,
|
||||
source: row.source,
|
||||
}));
|
||||
}
|
||||
|
||||
function restoreConfigurationRevision({ revision, expectedRevision, actor }) {
|
||||
const row = db.prepare('SELECT config_json FROM configuration_revisions WHERE id = ?').get(Number(revision));
|
||||
if (!row) throw new Error('Configuration revision not found.');
|
||||
const restoredConfig = JSON.parse(row.config_json);
|
||||
return commitRevisionTransaction(restoredConfig, {
|
||||
expectedRevision,
|
||||
actor,
|
||||
source: `rollback-from-${Number(revision)}`,
|
||||
});
|
||||
}
|
||||
|
||||
function listAdministrators() {
|
||||
return db.prepare(`
|
||||
SELECT id, username, discord_id, role, created_at, updated_at
|
||||
FROM administrators
|
||||
ORDER BY username COLLATE NOCASE
|
||||
`).all().map((row) => ({
|
||||
id: Number(row.id),
|
||||
username: row.username,
|
||||
discordId: row.discord_id || '',
|
||||
role: row.role,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
}));
|
||||
}
|
||||
|
||||
function findAdministratorForAuthentication(username) {
|
||||
const normalized = String(username || '').trim();
|
||||
if (!normalized) return null;
|
||||
const row = db.prepare(`
|
||||
SELECT id, username, password_hash, discord_id, role
|
||||
FROM administrators
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
`).get(normalized);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: Number(row.id),
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
discordId: row.discord_id || '',
|
||||
role: row.role,
|
||||
};
|
||||
}
|
||||
|
||||
function countLockdownAdministrators() {
|
||||
return Number(db.prepare("SELECT COUNT(*) AS count FROM administrators WHERE role = 'lockdown'").get().count);
|
||||
}
|
||||
|
||||
const createAdministratorTransaction = db.transaction((admin, actor, audit = true) => {
|
||||
const username = normalizeUsername(admin.username);
|
||||
const role = normalizeRole(admin.role);
|
||||
const passwordHash = String(admin.passwordHash || '').trim();
|
||||
if (!passwordHash) throw new Error('Administrator password hash is required.');
|
||||
const now = Date.now();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO administrators (username, password_hash, discord_id, role, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(username, passwordHash, String(admin.discordId || '').trim() || null, role, now, now);
|
||||
if (audit) writeAudit(actor, 'administrator.created', { administratorId: Number(result.lastInsertRowid), username, role });
|
||||
return Number(result.lastInsertRowid);
|
||||
});
|
||||
|
||||
function createAdministrator(admin, actor = 'system') {
|
||||
const id = createAdministratorTransaction(admin, actor, true);
|
||||
return listAdministrators().find((entry) => entry.id === id);
|
||||
}
|
||||
|
||||
const updateAdministratorTransaction = db.transaction((id, changes, actor) => {
|
||||
const current = db.prepare('SELECT * FROM administrators WHERE id = ?').get(Number(id));
|
||||
if (!current) throw new Error('Administrator not found.');
|
||||
const username = changes.username == null ? current.username : normalizeUsername(changes.username);
|
||||
const role = changes.role == null ? current.role : normalizeRole(changes.role);
|
||||
const discordId = changes.discordId == null ? current.discord_id : String(changes.discordId || '').trim() || null;
|
||||
const passwordHash = changes.passwordHash == null ? current.password_hash : String(changes.passwordHash || '').trim();
|
||||
if (!passwordHash) throw new Error('Administrator password hash is required.');
|
||||
if (current.role === 'lockdown' && role !== 'lockdown' && countLockdownAdministrators() <= 1) {
|
||||
throw new Error('The final lockdown administrator cannot be demoted.');
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE administrators
|
||||
SET username = ?, password_hash = ?, discord_id = ?, role = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(username, passwordHash, discordId, role, Date.now(), Number(id));
|
||||
writeAudit(actor, 'administrator.updated', { administratorId: Number(id), username, role, passwordChanged: changes.passwordHash != null });
|
||||
});
|
||||
|
||||
function updateAdministrator(id, changes, actor) {
|
||||
updateAdministratorTransaction(id, changes || {}, actor || 'system');
|
||||
return listAdministrators().find((entry) => entry.id === Number(id));
|
||||
}
|
||||
|
||||
const deleteAdministratorTransaction = db.transaction((id, actor) => {
|
||||
const current = db.prepare('SELECT * FROM administrators WHERE id = ?').get(Number(id));
|
||||
if (!current) throw new Error('Administrator not found.');
|
||||
if (current.role === 'lockdown' && countLockdownAdministrators() <= 1) {
|
||||
throw new Error('The final lockdown administrator cannot be removed.');
|
||||
}
|
||||
db.prepare('DELETE FROM administrators WHERE id = ?').run(Number(id));
|
||||
writeAudit(actor, 'administrator.deleted', { administratorId: Number(id), username: current.username, role: current.role });
|
||||
});
|
||||
|
||||
function deleteAdministrator(id, actor = 'system') {
|
||||
deleteAdministratorTransaction(id, actor);
|
||||
}
|
||||
|
||||
function isSetupComplete() {
|
||||
return countLockdownAdministrators() > 0;
|
||||
}
|
||||
|
||||
function listAuditEvents({ limit = 200 } = {}) {
|
||||
const safeLimit = Math.max(1, Math.min(1000, Math.floor(Number(limit) || 200)));
|
||||
return db.prepare(`
|
||||
SELECT id, created_at, actor, action, details_json
|
||||
FROM administrative_audit_events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit).map((row) => ({
|
||||
id: Number(row.id),
|
||||
createdAt: Number(row.created_at),
|
||||
actor: row.actor,
|
||||
action: row.action,
|
||||
details: JSON.parse(row.details_json),
|
||||
}));
|
||||
}
|
||||
|
||||
const importLegacyTransaction = db.transaction(({ config, administrators, actor, source }) => {
|
||||
if (isSetupComplete()) throw new Error('Legacy configuration cannot replace an initialized installation.');
|
||||
const normalized = assertValidConfig(normalizeConfig(config));
|
||||
const revision = commitRevisionTransaction(normalized, {
|
||||
expectedRevision: getActiveConfigurationRecord().revision,
|
||||
actor,
|
||||
source,
|
||||
});
|
||||
administrators.forEach((admin) => createAdministratorTransaction(admin, actor, false));
|
||||
if (!isSetupComplete()) throw new Error('Legacy import must contain at least one lockdown administrator.');
|
||||
writeAudit(actor, 'legacy-import.completed', { revision, administratorCount: administrators.length, source });
|
||||
return revision;
|
||||
});
|
||||
|
||||
function importLegacy(payload) {
|
||||
return importLegacyTransaction(payload);
|
||||
}
|
||||
|
||||
return {
|
||||
databasePath,
|
||||
getActiveConfigurationRecord,
|
||||
getClientConfiguration,
|
||||
updateConfiguration,
|
||||
listConfigurationRevisions,
|
||||
restoreConfigurationRevision,
|
||||
listAdministrators,
|
||||
findAdministratorForAuthentication,
|
||||
createAdministrator,
|
||||
updateAdministrator,
|
||||
deleteAdministrator,
|
||||
countLockdownAdministrators,
|
||||
isSetupComplete,
|
||||
listAuditEvents,
|
||||
importLegacy,
|
||||
close: () => db.close(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_DATABASE_PATH,
|
||||
createConfigurationDatabase,
|
||||
redactConfiguration,
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
// Complete Configuration Definition
|
||||
// Purpose: Assembles service-owned configuration fragments into the one ordered document used by storage, validation, and the admin UI.
|
||||
// Scope: Controls top-level order and composition only; each owning service defines the meaning, defaults, and schema of its own values.
|
||||
const { strictObject } = require('./schemaHelpers');
|
||||
const sessionConfiguration = require('../services/sessionService/configuration');
|
||||
const interInstance = require('../services/interInstanceService/configuration');
|
||||
const llmCommentary = require('../services/llmCommentaryService/configuration');
|
||||
const overseerControl = require('../services/overseerControlService/configuration');
|
||||
const barcodeGames = require('../services/barcodeGameService/configuration');
|
||||
const media = require('../services/mediaMtxService/configuration');
|
||||
const bandwidthSavings = require('../helpers/bandwidthSavings.configuration');
|
||||
const audioForward = require('../services/audioForwardService/configuration');
|
||||
const audioLevels = require('../services/audioLevelsService/configuration');
|
||||
const homeAssistant = require('../services/homeAssistantService/configuration');
|
||||
const roomCameras = require('../services/roomCameraService/configuration');
|
||||
const ptzCamera = require('../services/ptzCameraService/configuration');
|
||||
const kinect = require('../services/kinectService/configuration');
|
||||
const balanceBoard = require('../services/balanceBoardService/configuration');
|
||||
const buttonBox = require('../services/buttonBoxService/configuration');
|
||||
const barcodeScanner = require('../services/barcodeScannerService/configuration');
|
||||
const commands = require('../services/operatorCommandService/configuration');
|
||||
const discord = require('../services/discordBotService/configuration');
|
||||
const fleetReports = require('../services/fleetReportService/configuration');
|
||||
|
||||
/*
|
||||
Object property order is preserved by JSON serialization and JSON Schema
|
||||
consumers. Keeping this explicit list in legacy-YAML order makes the generic
|
||||
admin form predictable without creating a second frontend ordering system.
|
||||
The session service owns three non-adjacent public-presentation values, so
|
||||
those fragments are placed independently at their historical positions.
|
||||
*/
|
||||
const definitions = [
|
||||
sessionConfiguration.timezone,
|
||||
interInstance,
|
||||
llmCommentary,
|
||||
overseerControl,
|
||||
barcodeGames,
|
||||
media,
|
||||
bandwidthSavings,
|
||||
audioForward,
|
||||
audioLevels,
|
||||
homeAssistant,
|
||||
roomCameras,
|
||||
ptzCamera,
|
||||
kinect,
|
||||
balanceBoard,
|
||||
buttonBox,
|
||||
barcodeScanner,
|
||||
commands,
|
||||
discord,
|
||||
sessionConfiguration.socials,
|
||||
sessionConfiguration.driverAd,
|
||||
fleetReports,
|
||||
];
|
||||
|
||||
const defaultConfig = Object.fromEntries(
|
||||
definitions.map(({ key, defaultValue }) => [key, defaultValue]),
|
||||
);
|
||||
const properties = Object.fromEntries(
|
||||
definitions.map(({ key, schema }) => [key, schema]),
|
||||
);
|
||||
const rootSchema = strictObject(properties, {
|
||||
title: 'Configuration',
|
||||
required: definitions.map(({ key }) => key),
|
||||
});
|
||||
|
||||
function collectFeatureDefinitions(definition, parentPath = []) {
|
||||
const configPath = [...parentPath, definition.key];
|
||||
const features = [];
|
||||
|
||||
if (definition.feature === true) {
|
||||
/*
|
||||
A feature declaration is intentionally only a boolean marker. Its public
|
||||
name is the configuration item's key and its value is that item's own
|
||||
enabled field, so a service cannot introduce a second enablement rule in
|
||||
metadata. Failing during definition assembly catches an invalid marker at
|
||||
startup instead of publishing an undefined capability to browsers.
|
||||
*/
|
||||
if (definition.schema?.properties?.enabled?.type !== 'boolean') {
|
||||
throw new Error(`Configuration feature ${definition.key} must define a boolean enabled field.`);
|
||||
}
|
||||
features.push({ key: definition.key, path: [...configPath, 'enabled'] });
|
||||
}
|
||||
|
||||
const nestedDefinitions = Array.isArray(definition.nestedDefinitions)
|
||||
? definition.nestedDefinitions
|
||||
: [];
|
||||
nestedDefinitions.forEach((nestedDefinition) => {
|
||||
features.push(...collectFeatureDefinitions(nestedDefinition, configPath));
|
||||
});
|
||||
return features;
|
||||
}
|
||||
|
||||
/*
|
||||
This derived list replaces the old hand-maintained feature registry. Top-level
|
||||
and nested configuration owners opt in beside their schema, while this module
|
||||
only preserves their already-declared document paths.
|
||||
*/
|
||||
const featureDefinitions = definitions.flatMap((definition) => collectFeatureDefinitions(definition));
|
||||
|
||||
function collectWriteOnlyPaths(schema, prefix = '') {
|
||||
/*
|
||||
Secrets are declared once, beside the service field that consumes them.
|
||||
Walking object properties produces the dotted paths needed for redaction
|
||||
and update handling without maintaining a parallel secret registry.
|
||||
*/
|
||||
if (!schema || typeof schema !== 'object') return [];
|
||||
if (schema.writeOnly === true) return prefix ? [prefix] : [];
|
||||
if (schema.type !== 'object' || !schema.properties) return [];
|
||||
return Object.entries(schema.properties).flatMap(([key, childSchema]) => (
|
||||
collectWriteOnlyPaths(childSchema, prefix ? `${prefix}.${key}` : key)
|
||||
));
|
||||
}
|
||||
|
||||
const secretPaths = collectWriteOnlyPaths(rootSchema);
|
||||
|
||||
module.exports = {
|
||||
definitions,
|
||||
defaultConfig,
|
||||
rootSchema,
|
||||
secretPaths,
|
||||
featureDefinitions,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Configuration Service
|
||||
// Purpose: Exposes the process-wide synchronous configuration snapshot and the underlying administration store.
|
||||
// Scope: Keeps existing require-time startup semantics while making SQLite the only runtime configuration source.
|
||||
const { createConfigurationDatabase } = require('./database');
|
||||
const { rootSchema, featureDefinitions } = require('./definition');
|
||||
|
||||
let singleton;
|
||||
let runtimeConfigurationRevision = null;
|
||||
|
||||
function getConfigurationDatabase() {
|
||||
if (!singleton) {
|
||||
singleton = createConfigurationDatabase();
|
||||
/*
|
||||
Capture the active revision once when the process opens its configuration
|
||||
store. Later admin saves are intentionally restart-bound, so comparing
|
||||
against this value gives every reconnecting browser an authoritative
|
||||
pending-restart indicator.
|
||||
*/
|
||||
runtimeConfigurationRevision = singleton.getActiveConfigurationRecord().revision;
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
|
||||
function getRuntimeConfigurationRevision() {
|
||||
getConfigurationDatabase();
|
||||
return runtimeConfigurationRevision;
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
/*
|
||||
Services intentionally receive one coherent snapshot for this process.
|
||||
Configuration commits are restart-bound, so re-reading during runtime would
|
||||
let only some modules observe the new revision and create a split-brain
|
||||
process. The database remains queryable through its administrative API.
|
||||
*/
|
||||
if (!loadConfig.cached) {
|
||||
loadConfig.cached = Object.freeze(getConfigurationDatabase().getActiveConfigurationRecord().config);
|
||||
}
|
||||
return loadConfig.cached;
|
||||
}
|
||||
|
||||
function getValueAtPath(value, path) {
|
||||
return path.reduce((current, key) => current?.[key], value);
|
||||
}
|
||||
|
||||
function getFeatureFlags(config = loadConfig()) {
|
||||
/*
|
||||
Feature definitions come directly from service-owned configuration metadata.
|
||||
Returning an explicit boolean map preserves the existing public session
|
||||
contract while ensuring the item's enabled field is its only source.
|
||||
*/
|
||||
return Object.fromEntries(featureDefinitions.map(({ key, path }) => [
|
||||
key,
|
||||
Boolean(getValueAtPath(config, path)),
|
||||
]));
|
||||
}
|
||||
|
||||
function isFeatureEnabled(featureName) {
|
||||
return Boolean(getFeatureFlags()[featureName]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfigurationDatabase,
|
||||
getRuntimeConfigurationRevision,
|
||||
loadConfig,
|
||||
getFeatureFlags,
|
||||
isFeatureEnabled,
|
||||
rootSchema,
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
// Legacy YAML Configuration Importer
|
||||
// Purpose: Converts one explicitly supplied config.yaml document into the database-backed configuration model.
|
||||
// Scope: Parses and validates legacy input without becoming a runtime fallback or watcher.
|
||||
const yaml = require('js-yaml');
|
||||
const { normalizeConfig, assertValidConfig } = require('./validation');
|
||||
|
||||
function normalizeLegacyAdministrator(entry, index) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
throw new Error(`Legacy administrator ${index + 1} must be an object.`);
|
||||
}
|
||||
const username = String(entry.username || '').trim();
|
||||
const passwordHash = String(entry.password_hash || '').trim();
|
||||
if (!username || !passwordHash) {
|
||||
throw new Error(`Legacy administrator ${index + 1} requires username and password_hash.`);
|
||||
}
|
||||
return {
|
||||
username,
|
||||
passwordHash,
|
||||
discordId: String(entry.discord_id || '').trim(),
|
||||
role: entry.lockdown ? 'lockdown' : 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyConfiguration(text) {
|
||||
const parsed = yaml.load(String(text || ''));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Legacy configuration must contain a YAML object.');
|
||||
}
|
||||
|
||||
const administrators = Array.isArray(parsed.admins)
|
||||
? parsed.admins.map(normalizeLegacyAdministrator)
|
||||
: [];
|
||||
const configInput = Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => key !== 'admins'),
|
||||
);
|
||||
const config = normalizeConfig(configInput);
|
||||
|
||||
/*
|
||||
Unknown fields are retained by normalizeConfig and therefore appear in
|
||||
Ajv's precise validation errors instead of being silently discarded during
|
||||
the one-time import.
|
||||
*/
|
||||
assertValidConfig(config);
|
||||
if (!administrators.some((admin) => admin.role === 'lockdown')) {
|
||||
throw new Error('Legacy configuration must contain at least one lockdown administrator.');
|
||||
}
|
||||
return { config, administrators };
|
||||
}
|
||||
|
||||
function importLegacyConfiguration({ text, database, actor = 'legacy-import', source = 'config.yaml', dryRun = false }) {
|
||||
const result = parseLegacyConfiguration(text);
|
||||
if (dryRun) {
|
||||
return {
|
||||
dryRun: true,
|
||||
administratorCount: result.administrators.length,
|
||||
};
|
||||
}
|
||||
const revision = database.importLegacy({
|
||||
config: result.config,
|
||||
administrators: result.administrators,
|
||||
actor,
|
||||
source,
|
||||
});
|
||||
return {
|
||||
dryRun: false,
|
||||
revision,
|
||||
administratorCount: result.administrators.length,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseLegacyConfiguration,
|
||||
importLegacyConfiguration,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Configuration Database Migrations
|
||||
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
||||
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation.
|
||||
const migrations = [
|
||||
{
|
||||
version: 1,
|
||||
sql: `
|
||||
CREATE TABLE configuration_revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
config_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
source TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE configuration_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
active_revision_id INTEGER NOT NULL REFERENCES configuration_revisions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE administrators (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
discord_id TEXT,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'lockdown')),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE administrative_audit_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
function applySchemaMigrations(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
|
||||
const record = db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)');
|
||||
|
||||
migrations.forEach((migration) => {
|
||||
if (applied.has(migration.version)) return;
|
||||
/*
|
||||
Schema SQL and its version marker are one transaction. A process failure
|
||||
can therefore retry the migration cleanly instead of finding a partially
|
||||
changed database whose version incorrectly appears current.
|
||||
*/
|
||||
db.transaction(() => {
|
||||
db.exec(migration.sql);
|
||||
record.run(migration.version, Date.now());
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
migrations,
|
||||
applySchemaMigrations,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// Configuration Schema Helpers
|
||||
// Purpose: Keeps repetitive declarations in the complete strict JSON Schema readable.
|
||||
// Scope: Defines schema-building helpers only; validation and default application remain separate responsibilities.
|
||||
|
||||
function strictObject(properties, options = {}) {
|
||||
/*
|
||||
Configuration objects reject unknown keys at every level. A misspelled
|
||||
operator setting must fail loudly instead of looking saved while the server
|
||||
silently falls back to another value.
|
||||
*/
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
...(options.title ? { title: options.title } : {}),
|
||||
...(options.description ? { description: options.description } : {}),
|
||||
...(Array.isArray(options.required) ? { required: options.required } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function string(options = {}) {
|
||||
return { type: 'string', ...options };
|
||||
}
|
||||
|
||||
function nullableString(options = {}) {
|
||||
return { type: ['string', 'null'], ...options };
|
||||
}
|
||||
|
||||
function boolean(options = {}) {
|
||||
return { type: 'boolean', ...options };
|
||||
}
|
||||
|
||||
function integer(options = {}) {
|
||||
return { type: 'integer', ...options };
|
||||
}
|
||||
|
||||
function number(options = {}) {
|
||||
return { type: 'number', ...options };
|
||||
}
|
||||
|
||||
function stringArray(options = {}) {
|
||||
return {
|
||||
type: 'array',
|
||||
items: string(options.item || {}),
|
||||
...options.array,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
strictObject,
|
||||
string,
|
||||
nullableString,
|
||||
boolean,
|
||||
integer,
|
||||
number,
|
||||
stringArray,
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
// Configuration Validation
|
||||
// Purpose: Validates and normalizes the one hierarchical configuration document.
|
||||
// Scope: Owns reusable validation behavior for the complete schema assembled from service definitions.
|
||||
const Ajv = require('ajv');
|
||||
const addFormats = require('ajv-formats');
|
||||
const { defaultConfig, rootSchema, secretPaths } = require('./definition');
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function mergeDefaults(defaultValue, suppliedValue) {
|
||||
/*
|
||||
Arrays are complete ordered values and must never be merged item-by-item.
|
||||
Plain objects recurse so a stored document can omit a newly introduced
|
||||
field and receive its safe default without discarding neighboring values.
|
||||
Unknown supplied keys are retained here so strict schema validation can
|
||||
report them instead of silently deleting operator input.
|
||||
*/
|
||||
if (Array.isArray(suppliedValue)) return clone(suppliedValue);
|
||||
if (!suppliedValue || typeof suppliedValue !== 'object' || Array.isArray(defaultValue)) {
|
||||
return suppliedValue === undefined ? clone(defaultValue) : suppliedValue;
|
||||
}
|
||||
|
||||
const result = clone(defaultValue);
|
||||
for (const [key, value] of Object.entries(suppliedValue)) {
|
||||
const fallback = defaultValue && typeof defaultValue === 'object' ? defaultValue[key] : undefined;
|
||||
result[key] = mergeDefaults(fallback, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: true });
|
||||
addFormats(ajv);
|
||||
const validate = ajv.compile(rootSchema);
|
||||
|
||||
function formatValidationErrors(errors = []) {
|
||||
return errors.map((error) => ({
|
||||
/*
|
||||
Ajv uses JSON Pointer instance paths. Prefixing an additional-property
|
||||
name makes the error point at the actual rejected field rather than only
|
||||
its containing object, which is more useful in the hierarchical form.
|
||||
*/
|
||||
path: error.keyword === 'additionalProperties'
|
||||
? `${error.instancePath}/${error.params.additionalProperty}`
|
||||
: error.instancePath || '/',
|
||||
message: error.message || 'Invalid value',
|
||||
keyword: error.keyword,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeConfig(input = {}) {
|
||||
return mergeDefaults(defaultConfig, input);
|
||||
}
|
||||
|
||||
function assertValidConfig(input) {
|
||||
if (validate(input)) return input;
|
||||
const error = new Error('Configuration validation failed.');
|
||||
error.code = 'CONFIG_VALIDATION_FAILED';
|
||||
error.validationErrors = formatValidationErrors(validate.errors);
|
||||
throw error;
|
||||
}
|
||||
|
||||
/*
|
||||
Defaults are executable configuration, not documentation. Validate them at
|
||||
module load so a definition edit cannot make first boot fail later in an
|
||||
unrelated service require chain.
|
||||
*/
|
||||
assertValidConfig(defaultConfig);
|
||||
|
||||
module.exports = {
|
||||
defaultConfig,
|
||||
secretPaths,
|
||||
rootSchema,
|
||||
clone,
|
||||
normalizeConfig,
|
||||
assertValidConfig,
|
||||
};
|
||||
Reference in New Issue
Block a user