mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -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,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Bandwidth-Savings Configuration
|
||||
// Purpose: Defines the server-owned live-video and snapshot policy interpreted by this helper.
|
||||
// Scope: Exports configuration metadata without reading sessions or calculating policy.
|
||||
const { strictObject, string, boolean, integer } = require('../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'bandwidthSavings',
|
||||
defaultValue: {
|
||||
multiTabProtection: 'verifiedOnly',
|
||||
pauseHiddenRoverVideo: false,
|
||||
nonTurnVideo: { mode: 'snapshots', userThreshold: 0 },
|
||||
externalSpectatorVideo: 'snapshots',
|
||||
externalSpectatorAccess: 'on',
|
||||
},
|
||||
schema: strictObject({
|
||||
multiTabProtection: string({ enum: ['allowed', 'verifiedOnly', 'notAllowed'] }),
|
||||
pauseHiddenRoverVideo: boolean(),
|
||||
nonTurnVideo: strictObject({
|
||||
mode: string({ enum: ['snapshots', 'live'] }),
|
||||
userThreshold: integer({ minimum: 0, maximum: 100000 }),
|
||||
}, { required: ['mode', 'userThreshold'] }),
|
||||
externalSpectatorVideo: string({ enum: ['snapshots', 'live'] }),
|
||||
externalSpectatorAccess: string({ enum: ['off', 'on', 'verifiedOnly', 'admin'] }),
|
||||
}, { title: 'Bandwidth savings', required: ['multiTabProtection', 'pauseHiddenRoverVideo', 'nonTurnVideo', 'externalSpectatorVideo', 'externalSpectatorAccess'] }),
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
// Bandwidth Savings Helper
|
||||
// Purpose: Normalizes bandwidth-saving config and exposes tiny policy helpers.
|
||||
// Scope: Keeps cross-service video/tab/spectator decisions consistent without
|
||||
// making individual services know raw YAML defaults or legacy config shapes.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
// making individual services duplicate the validated database configuration contract.
|
||||
const { loadConfig } = require('../configuration');
|
||||
|
||||
const MULTI_TAB_MODES = new Set(['allowed', 'verifiedOnly', 'notAllowed']);
|
||||
const VIDEO_MODES = new Set(['snapshots', 'live']);
|
||||
@@ -21,9 +21,9 @@ const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
||||
|
||||
function normalizeEnum(value, allowed, fallback) {
|
||||
/*
|
||||
Config files are hand-edited on the server, so a typo should not crash the
|
||||
process or silently broaden access. Each option falls back to the current
|
||||
conservative behavior unless it exactly matches a known value.
|
||||
Tests and direct helper callers can still supply incomplete objects even
|
||||
though the database rejects invalid persisted values. Conservative fallback
|
||||
here keeps policy behavior safe at that secondary boundary.
|
||||
*/
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
return allowed.has(normalized) ? normalized : fallback;
|
||||
@@ -31,9 +31,9 @@ function normalizeEnum(value, allowed, fallback) {
|
||||
|
||||
function normalizeBoolean(value, fallback) {
|
||||
/*
|
||||
YAML booleans must stay real booleans. Treating strings such as "false" as
|
||||
truthy would silently enable a bandwidth policy that the operator intended
|
||||
to disable, so invalid values fall back to the documented server default.
|
||||
Treating strings such as "false" as truthy would silently enable a policy.
|
||||
Persisted values are schema-validated, while this guard protects direct
|
||||
helper calls and focused tests from the same JavaScript coercion trap.
|
||||
*/
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
||||
|
||||
function getBandwidthSavingsPolicy() {
|
||||
/*
|
||||
loadConfig() is cached by configLoader, so rebuilding this small object per
|
||||
loadConfig() is cached by configuration service, so rebuilding this small object per
|
||||
caller is cheap while still letting tests pass explicit config objects into
|
||||
buildBandwidthSavingsPolicy().
|
||||
*/
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// Config Loader Helper
|
||||
// Purpose: Loads and validates YAML server configuration from configured paths. Scope: Provides normalized config access with sane defaults and cache behavior.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const CONFIG_PATH = process.env.SERVER_CONFIG || path.join(__dirname, '..', '..', 'config.yaml');
|
||||
|
||||
let cachedConfig;
|
||||
|
||||
function loadConfig() {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
const file = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
cachedConfig = yaml.load(file);
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadConfig,
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
// Feature Flags Helper
|
||||
// Purpose: Normalizes optional server feature availability from config in one place.
|
||||
// Scope: Keeps hardware/social visibility decisions out of individual UI panels and service callers.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
|
||||
function asBoolean(value, fallback = false) {
|
||||
/*
|
||||
Optional feature config is intentionally explicit. A missing `enabled` flag
|
||||
means "off" for specialty hardware, which makes a fresh public install a
|
||||
rover-only server until the operator opts into extra devices.
|
||||
*/
|
||||
if (typeof value === 'boolean') return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function asTrimmedString(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getRoomCameraEntries(config) {
|
||||
const raw = config.roomCameras;
|
||||
/*
|
||||
The public config uses `{ enabled, cameras }` so the feature gate is obvious.
|
||||
Accepting the old array shape here keeps the rest of the server from needing
|
||||
to know which shape the local config file currently uses.
|
||||
*/
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && typeof raw === 'object' && Array.isArray(raw.cameras)) return raw.cameras;
|
||||
return [];
|
||||
}
|
||||
|
||||
function getConfiguredSocials(config) {
|
||||
/*
|
||||
Social links have an explicit feature switch. Entries under `links` are just
|
||||
available data; they do not enable the Links panel by existing.
|
||||
*/
|
||||
const links = config.socials && typeof config.socials === 'object' ? config.socials.links : [];
|
||||
return Array.isArray(links)
|
||||
? links.filter((entry) => asTrimmedString(entry?.url))
|
||||
: [];
|
||||
}
|
||||
|
||||
function buildFeatureFlags(config = loadConfig()) {
|
||||
const homeAssistantConfig = config.homeAssistant || {};
|
||||
const roomCameraConfig = config.roomCameras || {};
|
||||
const kinectConfig = config.kinect || {};
|
||||
const buttonBoxConfig = config.buttonBox || {};
|
||||
const barcodeScannerConfig = config.barcodeScanner || {};
|
||||
const balanceBoardConfig = config.balanceBoard || {};
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const socialsConfig = config.socials || {};
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
const ptzCameraConfig = config.ptzCamera || {};
|
||||
const discordConfig = config.discord || {};
|
||||
const fleetReportsConfig = config.fleetReports || {};
|
||||
const homeAssistant = Boolean(
|
||||
asBoolean(homeAssistantConfig.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.url) &&
|
||||
asTrimmedString(homeAssistantConfig.token),
|
||||
);
|
||||
const roomCameraEntries = getRoomCameraEntries(config);
|
||||
const roomCamerasEnabled = Array.isArray(config.roomCameras)
|
||||
? false
|
||||
: asBoolean(roomCameraConfig.enabled);
|
||||
const barcodeScanner = asBoolean(barcodeScannerConfig.enabled);
|
||||
|
||||
return {
|
||||
homeAssistant,
|
||||
roomCameras: Boolean(roomCamerasEnabled && roomCameraEntries.length),
|
||||
kinect: asBoolean(kinectConfig.enabled),
|
||||
buttonBox: asBoolean(buttonBoxConfig.enabled),
|
||||
barcodeScanner,
|
||||
// The worker performs its own runtime availability reporting. Advertising
|
||||
// the feature from the explicit config switch lets the UI show useful
|
||||
// commissioning and hardware-error states even before a board is paired.
|
||||
balanceBoard: asBoolean(balanceBoardConfig.enabled),
|
||||
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
|
||||
lift: Boolean(
|
||||
homeAssistant &&
|
||||
asBoolean(homeAssistantConfig.lift?.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.lift?.upSwitch) &&
|
||||
asTrimmedString(homeAssistantConfig.lift?.downSwitch),
|
||||
),
|
||||
neato: Boolean(
|
||||
homeAssistant &&
|
||||
asBoolean(homeAssistantConfig.neato?.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.neato?.device),
|
||||
),
|
||||
socials: Boolean(asBoolean(socialsConfig.enabled) && getConfiguredSocials(config).length > 0),
|
||||
interInstance: asBoolean(interInstanceConfig.enabled),
|
||||
ptzCamera: Boolean(
|
||||
asBoolean(ptzCameraConfig.enabled) &&
|
||||
asTrimmedString(ptzCameraConfig.host) &&
|
||||
asTrimmedString(ptzCameraConfig.username) &&
|
||||
asTrimmedString(ptzCameraConfig.password),
|
||||
),
|
||||
/*
|
||||
Discord is an optional transport, not a prerequisite for chat commands.
|
||||
Requiring both the explicit switch and a token prevents an old token from
|
||||
silently enabling external connections on installations that have chosen
|
||||
to run without the integration.
|
||||
*/
|
||||
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
|
||||
// Fleet reports are deliberately controlled by one explicit server switch.
|
||||
// Storage contents, Discord availability, or historical database files must
|
||||
// never cause the reporting UI to appear on an installation that has not
|
||||
// opted into the collector.
|
||||
fleetReports: asBoolean(fleetReportsConfig.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
function getFeatureFlags() {
|
||||
return buildFeatureFlags(loadConfig());
|
||||
}
|
||||
|
||||
function isFeatureEnabled(featureName) {
|
||||
return Boolean(getFeatureFlags()[featureName]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildFeatureFlags,
|
||||
getFeatureFlags,
|
||||
isFeatureEnabled,
|
||||
getRoomCameraEntries,
|
||||
getConfiguredSocials,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Site Metadata Helper
|
||||
// Purpose: Resolves the public name, description, and colors used before the web UI starts.
|
||||
// Scope: Keeps document/PWA branding server-rendered and independent of Socket.IO session state.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
const { loadConfig } = require('../configuration');
|
||||
|
||||
const DEFAULT_SITE_METADATA = Object.freeze({
|
||||
name: 'Multi Roomba Rover',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Administrative Configuration Service
|
||||
// Purpose: Exposes lockdown-only configuration, administrator, revision, and audit operations to the admin application.
|
||||
// Scope: Owns socket authorization and password confirmation while delegating persistence invariants to the configuration database.
|
||||
const bcrypt = require('bcrypt');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('adminConfigurationService');
|
||||
const {
|
||||
getConfigurationDatabase,
|
||||
getRuntimeConfigurationRevision,
|
||||
rootSchema,
|
||||
} = require('../../configuration');
|
||||
const { getRole } = require('../roleService');
|
||||
|
||||
const PASSWORD_CONFIRMATION_WINDOW_MS = 5 * 60 * 1000;
|
||||
const database = getConfigurationDatabase();
|
||||
|
||||
function requireLockdownAdministrator(socket) {
|
||||
if (getRole(socket) !== 'lockdown') throw new Error('Lockdown administrator required.');
|
||||
}
|
||||
|
||||
function requireRecentPassword(socket) {
|
||||
requireLockdownAdministrator(socket);
|
||||
const confirmedAt = Number(socket?.data?.adminPasswordConfirmedAt) || 0;
|
||||
if (Date.now() - confirmedAt > PASSWORD_CONFIRMATION_WINDOW_MS) {
|
||||
const error = new Error('Confirm your password to continue.');
|
||||
error.code = 'PASSWORD_CONFIRMATION_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function actorFor(socket) {
|
||||
return socket?.data?.user?.username || socket.id;
|
||||
}
|
||||
|
||||
function errorPayload(error) {
|
||||
return {
|
||||
error: error.message,
|
||||
code: error.code || null,
|
||||
validationErrors: error.validationErrors || null,
|
||||
currentRevision: error.currentRevision || null,
|
||||
};
|
||||
}
|
||||
|
||||
function ackHandler(socket, eventName, authorization, handler) {
|
||||
socket.on(eventName, (payload = {}, cb = () => {}) => {
|
||||
Promise.resolve()
|
||||
.then(() => authorization(socket))
|
||||
.then(() => handler(payload || {}))
|
||||
.then((result) => cb({ success: true, ...result }))
|
||||
.catch((error) => {
|
||||
logger.warn('Administrative configuration request failed', {
|
||||
eventName,
|
||||
socketId: socket.id,
|
||||
actor: actorFor(socket),
|
||||
error: error.message,
|
||||
});
|
||||
cb(errorPayload(error));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildAdminSnapshot() {
|
||||
const configuration = database.getClientConfiguration();
|
||||
return {
|
||||
/*
|
||||
The protected admin response carries the same schema used by server-side
|
||||
Ajv validation. It contains structure and help metadata but never stored
|
||||
values, allowing the browser to render configuration without maintaining
|
||||
a second field definition.
|
||||
*/
|
||||
configuration: { ...configuration, schema: rootSchema },
|
||||
restartRequired: configuration.revision !== getRuntimeConfigurationRevision(),
|
||||
administrators: database.listAdministrators(),
|
||||
revisions: database.listConfigurationRevisions(),
|
||||
auditEvents: database.listAuditEvents(),
|
||||
};
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
ackHandler(socket, 'adminConfig:get', requireLockdownAdministrator, () => buildAdminSnapshot());
|
||||
|
||||
ackHandler(socket, 'adminConfig:confirmPassword', requireLockdownAdministrator, async ({ password }) => {
|
||||
const admin = database.findAdministratorForAuthentication(socket?.data?.user?.username);
|
||||
if (!admin || !(await bcrypt.compare(String(password || ''), admin.passwordHash))) {
|
||||
throw new Error('Invalid credentials.');
|
||||
}
|
||||
socket.data.adminPasswordConfirmedAt = Date.now();
|
||||
return { confirmedUntil: socket.data.adminPasswordConfirmedAt + PASSWORD_CONFIRMATION_WINDOW_MS };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:updateConfiguration', requireRecentPassword, (payload) => {
|
||||
const revision = database.updateConfiguration({
|
||||
value: payload.value,
|
||||
expectedRevision: payload.expectedRevision,
|
||||
secretOperations: payload.secretOperations,
|
||||
actor: actorFor(socket),
|
||||
});
|
||||
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, (payload) => {
|
||||
const revision = database.restoreConfigurationRevision({
|
||||
revision: payload.revision,
|
||||
expectedRevision: payload.expectedRevision,
|
||||
actor: actorFor(socket),
|
||||
});
|
||||
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:createAdministrator', requireRecentPassword, async (payload) => {
|
||||
const password = String(payload.password || '');
|
||||
if (password.length < 10) throw new Error('Administrator password must be at least 10 characters.');
|
||||
const administrator = database.createAdministrator({
|
||||
username: payload.username,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
discordId: payload.discordId,
|
||||
role: payload.role,
|
||||
}, actorFor(socket));
|
||||
return { administrator, snapshot: buildAdminSnapshot() };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:updateAdministrator', requireRecentPassword, async (payload) => {
|
||||
const authenticatedAdministrator = database.findAdministratorForAuthentication(socket?.data?.user?.username);
|
||||
const changes = {
|
||||
username: payload.username,
|
||||
discordId: payload.discordId,
|
||||
role: payload.role,
|
||||
};
|
||||
if (payload.password) {
|
||||
if (String(payload.password).length < 10) throw new Error('Administrator password must be at least 10 characters.');
|
||||
changes.passwordHash = await bcrypt.hash(String(payload.password), 12);
|
||||
}
|
||||
const administrator = database.updateAdministrator(payload.id, changes, actorFor(socket));
|
||||
if (authenticatedAdministrator?.id === administrator.id) {
|
||||
/*
|
||||
Keep the current authenticated identity aligned after a self-edit. If
|
||||
the username changed but the socket retained the old name, its next
|
||||
password confirmation could never find the account it just updated.
|
||||
*/
|
||||
socket.data.user = {
|
||||
...(socket.data.user || {}),
|
||||
username: administrator.username,
|
||||
discordId: administrator.discordId,
|
||||
};
|
||||
}
|
||||
return { administrator, snapshot: buildAdminSnapshot() };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:deleteAdministrator', requireRecentPassword, ({ id }) => {
|
||||
database.deleteAdministrator(id, actorFor(socket));
|
||||
return { snapshot: buildAdminSnapshot() };
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
PASSWORD_CONFIRMATION_WINDOW_MS,
|
||||
requireLockdownAdministrator,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// Audio-Forwarding Configuration
|
||||
// Purpose: Defines upload bounds and the ffmpeg publishing command inputs.
|
||||
// Scope: Contains configuration metadata only and never creates runtime FIFOs.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'audioForward',
|
||||
defaultValue: { enabled: true, ffmpegBin: 'ffmpeg', streamSuffix: '-fwd', maxUploadBytes: 8388608 },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
ffmpegBin: string({ title: 'ffmpeg executable', minLength: 1, maxLength: 500 }),
|
||||
streamSuffix: string({ minLength: 1, maxLength: 80 }),
|
||||
maxUploadBytes: integer({ minimum: 262144, maximum: 1073741824 }),
|
||||
}, { title: 'Audio forwarding', required: ['enabled', 'ffmpegBin', 'streamSuffix', 'maxUploadBytes'] }),
|
||||
};
|
||||
@@ -5,7 +5,7 @@ const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('audioForwardService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveRuntimePath } = require('../../helpers/dataPaths');
|
||||
const roverManager = require('../roverManager');
|
||||
const turnService = require('../turnService');
|
||||
@@ -20,7 +20,10 @@ const audioForwardEvents = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const audioForwardConfig = config.audioForward || {};
|
||||
const mediaConfig = config.media || {};
|
||||
const serviceEnabled = audioForwardConfig.enabled !== false;
|
||||
// Configuration defaults always provide this boolean. Treat only an explicit
|
||||
// true as enabled so no credential, path, or historical fallback can opt the
|
||||
// service in on the operator's behalf.
|
||||
const serviceEnabled = Boolean(audioForwardConfig.enabled);
|
||||
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
|
||||
const streamSuffix =
|
||||
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Audio-Level Configuration
|
||||
// Purpose: Defines server base gains and the permitted personal adjustment range.
|
||||
// Scope: Exports only defaults and validation metadata.
|
||||
const { strictObject, number, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'audioLevels',
|
||||
defaultValue: { hornGain: 1, ttsGain: 1, forwardGain: 1, maxPersonalAdjustmentPercent: 50 },
|
||||
schema: strictObject({
|
||||
hornGain: number({ minimum: 0, maximum: 4 }),
|
||||
ttsGain: number({ title: 'TTS gain', minimum: 0, maximum: 4 }),
|
||||
forwardGain: number({ minimum: 0, maximum: 4 }),
|
||||
maxPersonalAdjustmentPercent: integer({ minimum: 0, maximum: 100 }),
|
||||
}, { title: 'Audio levels', required: ['hornGain', 'ttsGain', 'forwardGain', 'maxPersonalAdjustmentPercent'] }),
|
||||
};
|
||||
@@ -5,7 +5,7 @@ const fs = require('fs');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('audioLevelsService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isAdmin, roleEvents } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('authService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getConfigurationDatabase } = require('../../configuration');
|
||||
const { clearLockdownTimer } = require('../lockdownGuard');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { setRole } = require('../roleService');
|
||||
@@ -19,12 +19,11 @@ const {
|
||||
updateFeatureState,
|
||||
} = require('../identityService');
|
||||
|
||||
const config = loadConfig();
|
||||
const admins = config.admins || [];
|
||||
const configurationDatabase = getConfigurationDatabase();
|
||||
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
|
||||
|
||||
function findAdmin(username) {
|
||||
return admins.find((admin) => admin.username === username);
|
||||
return configurationDatabase.findAdministratorForAuthentication(username);
|
||||
}
|
||||
|
||||
async function authenticate(username, password) {
|
||||
@@ -32,7 +31,7 @@ async function authenticate(username, password) {
|
||||
if (!admin) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
const ok = await bcrypt.compare(password, admin.password_hash);
|
||||
const ok = await bcrypt.compare(password, admin.passwordHash);
|
||||
if (!ok) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
@@ -138,11 +137,14 @@ io.on('connection', (socket) => {
|
||||
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
|
||||
try {
|
||||
const admin = await authenticate(username, password);
|
||||
if (getMode() === MODES.LOCKDOWN && !admin.lockdown) {
|
||||
if (getMode() === MODES.LOCKDOWN && admin.role !== 'lockdown') {
|
||||
throw new Error('Lockdown admins only');
|
||||
}
|
||||
const role = admin.lockdown ? 'lockdown' : 'admin';
|
||||
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
||||
const role = admin.role;
|
||||
socket.data.user = { username: admin.username, discordId: admin.discordId };
|
||||
// A successful login is also recent proof of the account password. The
|
||||
// admin service expires this timestamp before allowing sensitive writes.
|
||||
socket.data.adminPasswordConfirmedAt = Date.now();
|
||||
setRole(socket, role);
|
||||
/*
|
||||
In admin-gated external spectator mode, logging in from /spectate is the
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Balance Board Configuration
|
||||
// Purpose: Defines optional hardware enablement and development simulation.
|
||||
// Scope: Contains configuration metadata only and never opens Bluetooth.
|
||||
const { strictObject, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'balanceBoard',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, simulate: false },
|
||||
schema: strictObject({ enabled: boolean(), simulate: boolean() }, { title: 'Balance Board', required: ['enabled', 'simulate'] }),
|
||||
};
|
||||
@@ -7,16 +7,15 @@ const { promisify } = require('util');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('balanceBoardService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { createBalanceBoardHardware } = require('./hardware');
|
||||
|
||||
const events = new EventEmitter();
|
||||
const enabled = isFeatureEnabled('balanceBoard');
|
||||
const rawConfig = loadConfig().balanceBoard || {};
|
||||
const enabled = Boolean(rawConfig.enabled);
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('balance-board.json');
|
||||
const FRAME_ROOM = 'balance-board-viewers';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Barcode Games Configuration
|
||||
// Purpose: Defines the optional barcode-games identity and presentation.
|
||||
// Scope: Contains configuration metadata only and does not initialize game state.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'barcodeGames',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, botName: 'Barcode Games', profileImageUrl: '' },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
botName: string({ minLength: 1, maxLength: 80 }),
|
||||
profileImageUrl: string({ title: 'Profile image URL', maxLength: 2048 }),
|
||||
}, { title: 'Barcode games', required: ['enabled', 'botName', 'profileImageUrl'] }),
|
||||
};
|
||||
@@ -5,8 +5,7 @@
|
||||
// remain thin IO surfaces that subscribe to state and send votes/scans.
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('barcodeGameService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const { sendSystemMessage } = require('../chatService');
|
||||
const { getActiveDrivers } = require('../turnService');
|
||||
@@ -31,8 +30,8 @@ const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems];
|
||||
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
||||
const config = loadConfig();
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const enabled = isFeatureEnabled('barcodeGames');
|
||||
const botName = String(barcodeGamesConfig.botName || barcodeGamesConfig.name || 'Barcode Games').trim() || 'Barcode Games';
|
||||
const enabled = Boolean(barcodeGamesConfig.enabled);
|
||||
const botName = String(barcodeGamesConfig.botName || 'Barcode Games').trim() || 'Barcode Games';
|
||||
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
|
||||
|
||||
function sendBarcodeGameChat(text) {
|
||||
@@ -1125,8 +1124,9 @@ function broadcastState() {
|
||||
if (enabled) {
|
||||
/*
|
||||
Barcode games are an optional layer on top of the physical scanner station.
|
||||
Keep sockets and scan subscriptions behind the feature gate so disabled
|
||||
installs do not run invisible game state.
|
||||
The game's own switch controls whether its sockets and subscriptions exist.
|
||||
Scanner availability is runtime state and must not silently override the
|
||||
operator's explicit choice to enable the game service.
|
||||
*/
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Barcode-Scanner Configuration
|
||||
// Purpose: Defines whether the optional physical barcode scanner is active.
|
||||
// Scope: Contains configuration metadata only and never initializes hardware.
|
||||
const { strictObject, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'barcodeScanner',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false },
|
||||
schema: strictObject({ enabled: boolean() }, { title: 'Barcode scanner', required: ['enabled'] }),
|
||||
};
|
||||
@@ -4,8 +4,8 @@
|
||||
const fs = require('fs');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('barcodeScannerService');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
|
||||
@@ -15,7 +15,7 @@ const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
|
||||
const RECENT_SCAN_LIMIT = 8;
|
||||
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
|
||||
const SCANNER_SOCKET_ROOM = 'barcode-scanner';
|
||||
const enabled = isFeatureEnabled('barcodeScanner');
|
||||
const enabled = Boolean(loadConfig().barcodeScanner?.enabled);
|
||||
|
||||
let lastKnownGoodRegistry = null;
|
||||
let lastRegistryError = null;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Button-Box Configuration
|
||||
// Purpose: Defines whether the optional physical button box is active.
|
||||
// Scope: Contains configuration metadata only and never initializes hardware.
|
||||
const { strictObject, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'buttonBox',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false },
|
||||
schema: strictObject({ enabled: boolean() }, { title: 'Button box', required: ['enabled'] }),
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
const { app } = require('../../globals/http');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('buttonBoxService');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { getRewardById, listRewards } = require('../../rewards');
|
||||
@@ -30,7 +30,7 @@ const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
||||
const BUTTON_COUNT = 4;
|
||||
const STORE_VERSION = 1;
|
||||
const enabled = isFeatureEnabled('buttonBox');
|
||||
const enabled = Boolean(loadConfig().buttonBox?.enabled);
|
||||
|
||||
const store = createButtonBoxStore({
|
||||
logger,
|
||||
|
||||
@@ -13,7 +13,7 @@ const homeAssistantService = require('../homeAssistantService');
|
||||
const greenModeService = require('../greenModeService');
|
||||
const liftService = require('../liftService');
|
||||
const neatoService = require('../neatoService');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { isFeatureEnabled } = require('../../configuration');
|
||||
const {
|
||||
listVerifiedUsers,
|
||||
removeVerifiedUser,
|
||||
@@ -32,7 +32,7 @@ const {
|
||||
} = require('../identityService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { createCommandHandlers } = require('../operatorCommandService');
|
||||
const { parseCommandText } = require('../operatorCommandService/config');
|
||||
const { createWebTransportHandlers } = require('../operatorCommandService/webTransport');
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Discord Bot Configuration
|
||||
// Purpose: Defines the optional bot connection and its guild channel and role mappings.
|
||||
// Scope: Contains configuration metadata only and never logs in to Discord.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'discord',
|
||||
feature: true,
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
token: '',
|
||||
guildId: '',
|
||||
siteUrl: '',
|
||||
channels: { general: '', announcements: '', adminAlerts: '', replay: '', humanAlerts: '' },
|
||||
roles: { stalkerPing: '', announcementPing: '', adminPing: '', humanAlertPing: '' },
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
token: string({ title: 'Bot token', writeOnly: true, maxLength: 10000 }),
|
||||
guildId: string({ title: 'Guild id', maxLength: 100 }),
|
||||
siteUrl: string({ title: 'Public site URL', maxLength: 2048 }),
|
||||
channels: strictObject({
|
||||
general: string({ maxLength: 100 }),
|
||||
announcements: string({ maxLength: 100 }),
|
||||
adminAlerts: string({ maxLength: 100 }),
|
||||
replay: string({ maxLength: 100 }),
|
||||
humanAlerts: string({ maxLength: 100 }),
|
||||
}, { required: ['general', 'announcements', 'adminAlerts', 'replay', 'humanAlerts'] }),
|
||||
roles: strictObject({
|
||||
stalkerPing: string({ maxLength: 100 }),
|
||||
announcementPing: string({ maxLength: 100 }),
|
||||
adminPing: string({ maxLength: 100 }),
|
||||
humanAlertPing: string({ maxLength: 100 }),
|
||||
}, { required: ['stalkerPing', 'announcementPing', 'adminPing', 'humanAlertPing'] }),
|
||||
}, { title: 'Discord', required: ['enabled', 'token', 'guildId', 'siteUrl', 'channels', 'roles'] }),
|
||||
};
|
||||
@@ -27,7 +27,14 @@ function formatNumber(value, digits = 1) {
|
||||
function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetReportService, roverManager, sendToChannel }) {
|
||||
let timer = null;
|
||||
const reportConfig = fleetConfig?.discord || {};
|
||||
const enabled = fleetReportService?.enabled && reportConfig.enabled !== false;
|
||||
const enabled = Boolean(reportConfig.enabled);
|
||||
/*
|
||||
Keep the configured choice separate from runtime availability. An operator
|
||||
can enable Discord delivery while the parent fleet collector is unhealthy
|
||||
or disabled; that dependency prevents work but does not rewrite the meaning
|
||||
of this switch.
|
||||
*/
|
||||
const fleetReportsAvailable = Boolean(fleetReportService?.enabled);
|
||||
const channelId = discordConfig?.channels?.adminAlerts;
|
||||
const zone = String(reportConfig.timezone || 'America/New_York');
|
||||
const { hour, minute } = parseSendTime(reportConfig.sendAt);
|
||||
@@ -85,7 +92,7 @@ function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetRepo
|
||||
}
|
||||
|
||||
async function deliverPreviousDay() {
|
||||
if (!enabled || !channelId) return;
|
||||
if (!enabled || !fleetReportsAvailable || !channelId) return;
|
||||
const range = completedDayRange();
|
||||
const existing = fleetReportService.storage.getDailyReport(range.reportDate);
|
||||
if (existing?.discordDeliveredAt) return;
|
||||
@@ -116,7 +123,7 @@ function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetRepo
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
if (!enabled || !channelId) return;
|
||||
if (!enabled || !fleetReportsAvailable || !channelId) return;
|
||||
const next = nextRunAt({ zone, hour, minute });
|
||||
const delay = Math.max(1000, next.toMillis() - Date.now());
|
||||
timer = setTimeout(async () => {
|
||||
|
||||
@@ -9,8 +9,7 @@ const {
|
||||
} = require('discord.js');
|
||||
const logger = require('../../globals/logger').child('discordBot');
|
||||
const io = require('../../globals/io');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig, getConfigurationDatabase, isFeatureEnabled } = require('../../configuration');
|
||||
const { parseCommandText } = require('../operatorCommandService/config');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRoster, lockRover, rovers } = roverManager;
|
||||
@@ -82,15 +81,16 @@ const {
|
||||
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
const enabled = isFeatureEnabled('discord');
|
||||
const enabled = Boolean(discordConfig.enabled);
|
||||
// These normalized command names mirror the command router. Bridge-channel
|
||||
// command replies are mirrored into web chat, so this entrypoint needs to know
|
||||
// the configured command names before it wraps message.reply.
|
||||
const adminIds = new Set((config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean));
|
||||
const lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean));
|
||||
const configuredAdministrators = getConfigurationDatabase().listAdministrators();
|
||||
const adminIds = new Set(configuredAdministrators.map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
|
||||
const lockdownAdminIds = new Set(configuredAdministrators.filter((admin) => admin.role === 'lockdown').map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
|
||||
|
||||
if (!enabled) {
|
||||
logger.info('Discord feature disabled or missing required token');
|
||||
logger.info('Discord disabled by config');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedS
|
||||
in-app navigation. The retired desktop composition is intentionally exposed
|
||||
at /old; the removed /newdrive route is intentionally absent.
|
||||
*/
|
||||
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
|
||||
/*
|
||||
Every top-level React application needs the same generated index document on
|
||||
a direct browser load. Keeping the setup and admin routes in this explicit
|
||||
allowlist prevents them from working only after client-side navigation.
|
||||
*/
|
||||
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports', '/setup', '/admin'], async (req, res) => {
|
||||
try {
|
||||
const html = await renderIndexHtml(req);
|
||||
res.type('html').send(html);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Fleet-Report Configuration
|
||||
// Purpose: Defines collection, retention, battery integration, delivery, and privacy behavior.
|
||||
// Scope: Contains configuration metadata only and never opens the reporting database.
|
||||
const { strictObject, string, boolean, integer, number } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'fleetReports',
|
||||
feature: true,
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
retention: { detailedDays: 0, minuteSamplesDays: 0 },
|
||||
battery: { enabled: true, maximumIntegrationGapSeconds: 5, minimumCapacityTestDepthPercent: 60 },
|
||||
discord: { enabled: true, sendAt: '08:00', timezone: 'America/New_York' },
|
||||
privacy: { retainChatBodies: true },
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
retention: strictObject({
|
||||
detailedDays: integer({ description: 'Zero retains indefinitely.', minimum: 0, maximum: 36500 }),
|
||||
minuteSamplesDays: integer({ description: 'Zero retains indefinitely.', minimum: 0, maximum: 36500 }),
|
||||
}, { required: ['detailedDays', 'minuteSamplesDays'] }),
|
||||
battery: strictObject({
|
||||
enabled: boolean(),
|
||||
maximumIntegrationGapSeconds: number({ minimum: 0.1, maximum: 3600 }),
|
||||
minimumCapacityTestDepthPercent: number({ minimum: 0, maximum: 100 }),
|
||||
}, { required: ['enabled', 'maximumIntegrationGapSeconds', 'minimumCapacityTestDepthPercent'] }),
|
||||
discord: strictObject({
|
||||
enabled: boolean(),
|
||||
sendAt: string({ pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }),
|
||||
timezone: string({ minLength: 1, maxLength: 100 }),
|
||||
}, { required: ['enabled', 'sendAt', 'timezone'] }),
|
||||
privacy: strictObject({ retainChatBodies: boolean() }, { required: ['retainChatBodies'] }),
|
||||
}, { title: 'Fleet reports', required: ['enabled', 'retention', 'battery', 'discord', 'privacy'] }),
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
// Fleet Report Service
|
||||
// Purpose: Composes optional passive collection, storage, analysis, retention, and read-only transport.
|
||||
// Scope: This is the sole feature boundary; disabled installations register no collectors, timers, database, or sockets.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const logger = require('../../globals/logger').child('fleetReportService');
|
||||
|
||||
if (!isFeatureEnabled('fleetReports')) {
|
||||
const config = loadConfig().fleetReports || {};
|
||||
|
||||
if (!config.enabled) {
|
||||
module.exports = {
|
||||
enabled: false,
|
||||
getDailyReport: () => null,
|
||||
@@ -20,7 +21,6 @@ if (!isFeatureEnabled('fleetReports')) {
|
||||
const { createReportBuilder } = require('./reportBuilder');
|
||||
const { registerSocketGateway } = require('./socketGateway');
|
||||
|
||||
const config = loadConfig().fleetReports || {};
|
||||
const batteryConfig = config.battery || {};
|
||||
const retentionConfig = config.retention || {};
|
||||
const maximumIntegrationGapMs = Math.max(
|
||||
@@ -31,7 +31,10 @@ if (!isFeatureEnabled('fleetReports')) {
|
||||
10,
|
||||
Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60),
|
||||
);
|
||||
const batteryEnabled = batteryConfig.enabled !== false;
|
||||
// Battery collection follows its own explicit nested switch. Defaults are
|
||||
// supplied by the validated configuration document, so a missing value does
|
||||
// not need a compatibility fallback that could accidentally enable it.
|
||||
const batteryEnabled = Boolean(batteryConfig.enabled);
|
||||
const storage = createStorage({ logger });
|
||||
const collector = createCollector({
|
||||
storage,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Home Assistant Configuration
|
||||
// Purpose: Defines the shared Home Assistant connection and the Neato, lift, entity, and button mappings that use it.
|
||||
// Scope: Keeps this connected configuration tree together without initializing any integration service.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
const neato = require('../neatoService/configuration');
|
||||
const lift = require('../liftService/configuration');
|
||||
|
||||
module.exports = {
|
||||
key: 'homeAssistant',
|
||||
feature: true,
|
||||
// Retain the actual child definitions so generic configuration metadata can
|
||||
// discover their feature switches without repeating nested paths centrally.
|
||||
nestedDefinitions: [neato, lift],
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
url: 'http://127.0.0.1:8123',
|
||||
token: '',
|
||||
[neato.key]: neato.defaultValue,
|
||||
[lift.key]: lift.defaultValue,
|
||||
entities: [],
|
||||
buttons: [],
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
url: string({ title: 'Server URL', format: 'uri', maxLength: 2048 }),
|
||||
token: string({ title: 'Long-lived access token', writeOnly: true, maxLength: 20000 }),
|
||||
[neato.key]: neato.schema,
|
||||
[lift.key]: lift.schema,
|
||||
entities: {
|
||||
type: 'array',
|
||||
title: 'Room entities',
|
||||
items: strictObject({
|
||||
id: string({ title: 'Entity id', minLength: 1, maxLength: 255 }),
|
||||
name: string({ minLength: 1, maxLength: 120 }),
|
||||
type: string({ enum: ['light', 'switch'] }),
|
||||
}, { required: ['id', 'name'] }),
|
||||
},
|
||||
buttons: {
|
||||
type: 'array',
|
||||
title: 'Physical button mappings',
|
||||
items: strictObject({
|
||||
entityId: string({ title: 'Entity id', minLength: 1, maxLength: 255 }),
|
||||
stateEquals: string({ minLength: 1, maxLength: 255 }),
|
||||
cooldownMs: integer({ minimum: 0, maximum: 86400000 }),
|
||||
action: string({ enum: ['humanAlert', 'modeTurns', 'modeAdmin', 'lightsLockToggle'] }),
|
||||
}, { required: ['entityId', 'stateEquals', 'cooldownMs', 'action'] }),
|
||||
},
|
||||
}, { title: 'Home Assistant', required: ['enabled', 'url', 'token', 'neato', 'lift', 'entities', 'buttons'] }),
|
||||
};
|
||||
@@ -2,8 +2,7 @@
|
||||
// Purpose: Composes Home Assistant transport, runtime automation engine, and event/socket hooks.
|
||||
// Scope: Exposes stable room-control APIs while delegating internals to focused modules.
|
||||
const logger = require('../../globals/logger').child('homeAssistantService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { events } = require('./state');
|
||||
const { createRuntimeEngine } = require('./runtimeEngine');
|
||||
const { createTransport } = require('./transport');
|
||||
@@ -11,7 +10,7 @@ const { registerHomeAssistantHooks } = require('./hooks');
|
||||
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
const enabled = isFeatureEnabled('homeAssistant');
|
||||
const enabled = Boolean(haConfig.enabled);
|
||||
|
||||
let callHomeAssistantServiceImpl = async () => {
|
||||
throw new Error('Home Assistant not connected');
|
||||
@@ -39,9 +38,9 @@ runtimeEngine.loadTriggerConfig();
|
||||
|
||||
if (enabled) {
|
||||
/*
|
||||
Loading the module should be harmless on rover-only installs. Only connect
|
||||
to Home Assistant when the central feature gate says the integration exists,
|
||||
so placeholder URLs/tokens in example config cannot start network traffic.
|
||||
Loading the module should be harmless on rover-only installs. The explicit
|
||||
service-owned switch alone decides whether connection should be attempted;
|
||||
missing credentials are then reported as a runtime connection failure.
|
||||
*/
|
||||
transport.connect();
|
||||
}
|
||||
|
||||
@@ -73,7 +73,10 @@ function createTransport(deps) {
|
||||
|
||||
async function connect() {
|
||||
if (!enabled) {
|
||||
logger.info('Home Assistant integration disabled; missing url/token in config');
|
||||
// Disabled and misconfigured are intentionally different states. The
|
||||
// explicit switch prevents connection attempts; missing credentials are
|
||||
// surfaced by buildAuth() as a runtime connection failure when enabled.
|
||||
logger.info('Home Assistant disabled by config');
|
||||
return;
|
||||
}
|
||||
if (runtime.connection) return;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Inter-Instance Configuration
|
||||
// Purpose: Defines directory participation and the public profile published to other instances.
|
||||
// Scope: Exports data-only defaults and schema without starting polling or networking.
|
||||
const { strictObject, string, boolean, integer, stringArray } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'interInstance',
|
||||
feature: true,
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
directoryUrls: [],
|
||||
pollIntervalMs: 30000,
|
||||
requestTimeoutMs: 5000,
|
||||
profile: { publicUrl: '', name: 'MultiRover', description: '', color: '#38bdf8' },
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
directoryUrls: stringArray({ item: { format: 'uri' } }),
|
||||
pollIntervalMs: integer({ minimum: 1000, maximum: 86400000 }),
|
||||
requestTimeoutMs: integer({ minimum: 250, maximum: 120000 }),
|
||||
profile: strictObject({
|
||||
publicUrl: string({ maxLength: 2048 }),
|
||||
name: string({ minLength: 1, maxLength: 120 }),
|
||||
description: string({ maxLength: 500 }),
|
||||
color: string({ pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
}, { required: ['publicUrl', 'name', 'description', 'color'] }),
|
||||
}, { title: 'Inter-instance directory', required: ['enabled', 'directoryUrls', 'pollIntervalMs', 'requestTimeoutMs', 'profile'] }),
|
||||
};
|
||||
@@ -6,8 +6,8 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { app } = require('../../globals/http');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('interInstanceService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getFeatureFlags, getConfiguredSocials } = require('../../helpers/features');
|
||||
const { loadConfig, getFeatureFlags } = require('../../configuration');
|
||||
const { getConfiguredSocials } = require('../sessionService/configuration');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getTurnQueues } = require('../turnService');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Kinect Configuration
|
||||
// Purpose: Defines optional Kinect capture and cooldown behavior.
|
||||
// Scope: Contains configuration metadata only and never opens the native worker.
|
||||
const { strictObject, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'kinect',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, captureCooldownMs: 10000 },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
captureCooldownMs: integer({ minimum: 0, maximum: 3600000 }),
|
||||
}, { title: 'Kinect', required: ['enabled', 'captureCooldownMs'] }),
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Kinect Service
|
||||
// Purpose: Composes Kinect hardware capture and browser socket delivery.
|
||||
// Scope: Exposes session-readable state while keeping startup side effects in this service folder.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const hardware = require('./hardware');
|
||||
const { registerKinectSocketGateway, kinectEvents } = require('./socketGateway');
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Lift Configuration
|
||||
// Purpose: Defines lift switch mappings and command timing nested beneath Home Assistant.
|
||||
// Scope: Exports a nested configuration fragment without initializing either service.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'lift',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, upSwitch: '', downSwitch: '', interlockMs: 2000, commandCooldownMs: 3000 },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
upSwitch: string({ maxLength: 255 }),
|
||||
downSwitch: string({ maxLength: 255 }),
|
||||
interlockMs: integer({ minimum: 0, maximum: 600000 }),
|
||||
commandCooldownMs: integer({ minimum: 0, maximum: 600000 }),
|
||||
}, { title: 'Lift', required: ['enabled', 'upSwitch', 'downSwitch', 'interlockMs', 'commandCooldownMs'] }),
|
||||
};
|
||||
@@ -4,8 +4,7 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('liftService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||
const {
|
||||
@@ -20,7 +19,7 @@ const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
const liftConfig = haConfig.lift || {};
|
||||
const featureEnabled = isFeatureEnabled('lift');
|
||||
const featureEnabled = Boolean(liftConfig.enabled);
|
||||
|
||||
const upSwitchId = String(liftConfig.upSwitch || '').trim();
|
||||
const downSwitchId = String(liftConfig.downSwitch || '').trim();
|
||||
@@ -73,7 +72,7 @@ function getState() {
|
||||
const configured = isConfigured();
|
||||
const connected = isHomeAssistantConnected();
|
||||
return {
|
||||
enabled: Boolean(featureEnabled && homeAssistantEnabled && configured),
|
||||
enabled: featureEnabled,
|
||||
configured,
|
||||
connected,
|
||||
entities: {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// LLM Commentary Configuration
|
||||
// Purpose: Defines the optional commentary model, endpoint, and cadence.
|
||||
// Scope: Contains configuration metadata only and never connects to Ollama.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'llmCommentary',
|
||||
defaultValue: { enabled: false, model: 'qwen2.5:7b-instruct', ollamaServer: 'http://127.0.0.1:11434', frequency: 120000 },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
model: string({ minLength: 1, maxLength: 200 }),
|
||||
ollamaServer: string({ title: 'Ollama server', format: 'uri', maxLength: 2048 }),
|
||||
frequency: integer({ description: 'Commentary interval in milliseconds.', minimum: 1000, maximum: 86400000 }),
|
||||
}, { title: 'LLM commentary', required: ['enabled', 'model', 'ollamaServer', 'frequency'] }),
|
||||
};
|
||||
@@ -5,7 +5,7 @@ const fsp = require('fs/promises');
|
||||
const { Ollama } = require('ollama');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('llmCommentary');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
@@ -37,10 +37,10 @@ const { createRunner } = require('./runner');
|
||||
const config = loadConfig();
|
||||
const commentaryConfig = config.llmCommentary || {};
|
||||
const enabled = Boolean(commentaryConfig.enabled);
|
||||
const ollamaUrl = String(commentaryConfig.ollamaUrl || commentaryConfig.ollamaServer || '').trim();
|
||||
const ollamaUrl = String(commentaryConfig.ollamaServer || '').trim();
|
||||
const model = String(commentaryConfig.model || '').trim();
|
||||
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
||||
const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency ?? commentaryConfig.frequencyMs));
|
||||
const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency));
|
||||
|
||||
const runtime = {
|
||||
timer: null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// MediaMTX Config Builder
|
||||
// Purpose: Converts the rover server's media settings into the complete MediaMTX runtime configuration.
|
||||
// Scope: Keeps deployment-specific hosts in config.yaml while keeping protocol policy owned by the application.
|
||||
// Scope: Keeps deployment-specific hosts in the configuration database while protocol policy remains application-owned.
|
||||
const path = require('path');
|
||||
|
||||
function normalizeAdditionalHosts(rawHosts) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Media Transport Configuration
|
||||
// Purpose: Defines browser WHEP addressing and additional MediaMTX ICE hosts.
|
||||
// Scope: Contains configuration metadata only and never starts MediaMTX.
|
||||
const { strictObject, string, stringArray } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'media',
|
||||
defaultValue: { whepBaseUrl: 'http://127.0.0.1:8889/video', additionalHosts: [] },
|
||||
schema: strictObject({
|
||||
whepBaseUrl: string({ title: 'WHEP base URL', format: 'uri', maxLength: 2048 }),
|
||||
additionalHosts: stringArray({ title: 'Additional ICE hosts', item: { minLength: 1, maxLength: 255 }, array: { uniqueItems: true } }),
|
||||
}, { title: 'Media', required: ['whepBaseUrl', 'additionalHosts'] }),
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// MediaMTX Service
|
||||
// Purpose: Composes server configuration, runtime paths, and child-process supervision.
|
||||
// Scope: Starts MediaMTX only after the HTTP auth endpoint is listening and stops it with the server.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const globalConfig = require('../../globals/config');
|
||||
const logger = require('../../globals/logger').child('mediamtx');
|
||||
const { createMediaMtxSupervisor } = require('./supervisor');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Neato Configuration
|
||||
// Purpose: Defines the Neato device mapping nested beneath the shared Home Assistant connection.
|
||||
// Scope: Exports a nested configuration fragment without initializing either service.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'neato',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, device: '' },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
device: string({ description: 'ESPHome device name.', maxLength: 255 }),
|
||||
}, { title: 'Neato', required: ['enabled', 'device'] }),
|
||||
};
|
||||
@@ -4,8 +4,7 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('neatoService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||
@@ -22,7 +21,7 @@ const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
const neatoConfig = haConfig.neato || {};
|
||||
const featureEnabled = isFeatureEnabled('neato');
|
||||
const featureEnabled = Boolean(neatoConfig.enabled);
|
||||
|
||||
function normalizeDeviceName(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
@@ -170,7 +169,7 @@ function buildState() {
|
||||
const requiredIds = requiredEntityIds();
|
||||
const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id));
|
||||
const connected = Boolean(haConnected && entitiesAvailable);
|
||||
const enabled = Boolean(featureEnabled && homeAssistantEnabled && configured);
|
||||
const enabled = featureEnabled;
|
||||
|
||||
const controls = {
|
||||
start: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Operator Command Configuration
|
||||
// Purpose: Owns transport-neutral command names used by site chat and optional integrations.
|
||||
// Scope: Prevents Discord configuration from defining whether core server commands can be parsed.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
|
||||
function getCommandConfig(config = loadConfig()) {
|
||||
const commandConfig = config.commands || {};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Operator Command Configuration
|
||||
// Purpose: Defines the shared command prefix and optional bare time-status command.
|
||||
// Scope: Contains configuration metadata only and never builds command handlers.
|
||||
const { strictObject, string, nullableString } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'commands',
|
||||
defaultValue: { prefix: 'rs', timeStatusCommand: 'ts' },
|
||||
schema: strictObject({
|
||||
prefix: string({ minLength: 1, maxLength: 20 }),
|
||||
timeStatusCommand: nullableString({ description: 'Leave empty to disable the bare shortcut.', maxLength: 20 }),
|
||||
}, { title: 'Commands', required: ['prefix', 'timeStatusCommand'] }),
|
||||
};
|
||||
@@ -102,7 +102,7 @@ function createCommandHandlers(deps) {
|
||||
const mode = getMode();
|
||||
const commandDefinition = registry[action];
|
||||
if (commandDefinition?.requiredFeature && !deps.isFeatureEnabled(commandDefinition.requiredFeature)) {
|
||||
await request.reply({ content: `${commandDefinition.unavailableLabel || commandDefinition.requiredFeature} feature is not configured.` });
|
||||
await request.reply({ content: `${commandDefinition.unavailableLabel || commandDefinition.requiredFeature} feature is disabled.` });
|
||||
return;
|
||||
}
|
||||
// Actions in this set can change operational safety or access policy, so
|
||||
|
||||
@@ -127,7 +127,7 @@ test('status and help survive lockdown', async () => {
|
||||
|
||||
test('a disabled required feature is reported before any permission check', async () => {
|
||||
const run = createRouter({ featureEnabled: false });
|
||||
assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is not configured/);
|
||||
assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is disabled/);
|
||||
});
|
||||
|
||||
test('green mode remains available without optional Home Assistant features', async () => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Overseer Control Configuration
|
||||
// Purpose: Defines optional Overseer behavior and its model connection.
|
||||
// Scope: Contains declarative configuration only and never starts an Overseer loop.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'overseerControl',
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
mode: 'autonomous',
|
||||
observeOnly: true,
|
||||
postToolsOnlyMessages: false,
|
||||
tiebreakerEnable: false,
|
||||
runWhileNoPeopleOnline: false,
|
||||
name: 'The Overseer',
|
||||
model: 'qwen2.5:7b-instruct',
|
||||
ollamaServer: 'http://127.0.0.1:11434',
|
||||
profileImageUrl: '',
|
||||
gateIntervalMs: 2000,
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
mode: string({ enum: ['autonomous', 'directAddress'] }),
|
||||
observeOnly: boolean(),
|
||||
postToolsOnlyMessages: boolean(),
|
||||
tiebreakerEnable: boolean(),
|
||||
runWhileNoPeopleOnline: boolean(),
|
||||
name: string({ minLength: 1, maxLength: 80 }),
|
||||
model: string({ minLength: 1, maxLength: 200 }),
|
||||
ollamaServer: string({ title: 'Ollama server', format: 'uri', maxLength: 2048 }),
|
||||
profileImageUrl: string({ title: 'Profile image URL', maxLength: 2048 }),
|
||||
gateIntervalMs: integer({ minimum: 250, maximum: 3600000 }),
|
||||
}, { title: 'Overseer Control', required: ['enabled', 'mode', 'observeOnly', 'postToolsOnlyMessages', 'tiebreakerEnable', 'runWhileNoPeopleOnline', 'name', 'model', 'ollamaServer', 'profileImageUrl', 'gateIntervalMs'] }),
|
||||
};
|
||||
@@ -2,7 +2,7 @@ const fsp = require('fs/promises');
|
||||
const { Ollama } = require('ollama');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('overseerControl');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { verificationEvents } = require('../verificationService');
|
||||
@@ -44,7 +44,7 @@ const runMode = RUN_MODES.has(configuredRunMode) ? configuredRunMode : RUN_MODE_
|
||||
const autonomousMode = runMode === RUN_MODE_AUTONOMOUS;
|
||||
const directAddressMode = runMode === RUN_MODE_DIRECT_ADDRESS;
|
||||
const model = String(overseerConfig.model || '').trim();
|
||||
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
||||
const ollamaUrl = String(overseerConfig.ollamaServer || '').trim();
|
||||
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
||||
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
|
||||
const tiebreakerEnable = Boolean(overseerConfig.tiebreakerEnable);
|
||||
|
||||
@@ -118,7 +118,7 @@ function createPtzAudioPlayback(deps) {
|
||||
|
||||
/*
|
||||
Write on every playback instead of trying to detect config drift. The file
|
||||
is small, and this guarantees a camera password/host change in config.yaml
|
||||
is small, and this guarantees a camera password/host configuration change
|
||||
is reflected without an extra migration path or manual cleanup.
|
||||
*/
|
||||
await fsp.writeFile(configPath, body, { mode: 0o600 });
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// PTZ Camera Configuration
|
||||
// Purpose: Defines the optional ONVIF camera connection and replay behavior.
|
||||
// Scope: Contains data-only metadata so validation never initializes camera hardware.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'ptzCamera',
|
||||
feature: true,
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
name: 'PTZ Camera',
|
||||
color: '#38bdf8',
|
||||
host: '',
|
||||
onvifPort: 8000,
|
||||
username: '',
|
||||
password: '',
|
||||
profileToken: '003',
|
||||
turnDurationMs: 300000,
|
||||
replayEnabled: false,
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
name: string({ minLength: 1, maxLength: 120 }),
|
||||
color: string({ pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
host: string({ maxLength: 255 }),
|
||||
onvifPort: integer({ title: 'ONVIF port', minimum: 1, maximum: 65535 }),
|
||||
username: string({ maxLength: 255 }),
|
||||
password: string({ writeOnly: true, maxLength: 10000 }),
|
||||
profileToken: string({ maxLength: 255 }),
|
||||
turnDurationMs: integer({ minimum: 1000, maximum: 86400000 }),
|
||||
replayEnabled: boolean(),
|
||||
}, { title: 'PTZ camera', required: ['enabled', 'name', 'color', 'host', 'onvifPort', 'username', 'password', 'profileToken', 'turnDurationMs', 'replayEnabled'] }),
|
||||
};
|
||||
@@ -9,9 +9,8 @@ const { Cam } = require('onvif');
|
||||
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('ptzCamera');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const {
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||
@@ -54,7 +53,7 @@ const PUBLISHER_RTSP_TIMEOUT_US = 10000000;
|
||||
const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const cameraConfig = config.ptzCamera || {};
|
||||
const enabled = isFeatureEnabled('ptzCamera');
|
||||
const enabled = Boolean(cameraConfig.enabled);
|
||||
|
||||
const state = {
|
||||
initialized: false,
|
||||
@@ -1058,8 +1057,8 @@ function requireOperator(socket) {
|
||||
function requirePtzUser(socket) {
|
||||
/*
|
||||
Listing presets does not move the camera, but it still reveals operational
|
||||
camera state. Use the same feature gate as queue entry so unverified users
|
||||
cannot query PTZ-only data through raw socket calls.
|
||||
camera state. Check the camera's own enabled switch just like queue entry so
|
||||
unverified users cannot query PTZ-only data through raw socket calls.
|
||||
*/
|
||||
if (!enabled) throw new Error('PTZ camera disabled');
|
||||
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
// Scope: Owns camera identity/url normalization and read-only accessors for room camera metadata.
|
||||
const EventEmitter = require('events');
|
||||
const logger = require('../../globals/logger').child('roomCameraService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getRoomCameraEntries } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
|
||||
const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
@@ -17,7 +16,7 @@ function normalizeCamera(camera) {
|
||||
logger.warn('Room camera missing id', camera);
|
||||
return null;
|
||||
}
|
||||
if (!camera.url && !camera.streamUrl && !camera.mjpegUrl) {
|
||||
if (!camera.url && !camera.streamUrl) {
|
||||
logger.warn('Room camera missing url/streamUrl', { id, camera });
|
||||
return null;
|
||||
}
|
||||
@@ -26,7 +25,7 @@ function normalizeCamera(camera) {
|
||||
name: camera.name || camera.id || String(id),
|
||||
description: camera.description || null,
|
||||
url: camera.url || null,
|
||||
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
|
||||
streamUrl: camera.streamUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +40,9 @@ function getRoomCamera(id) {
|
||||
|
||||
function loadFromConfig() {
|
||||
cameraMap.clear();
|
||||
const list = getRoomCameraEntries(config);
|
||||
// Schema validation guarantees the configured list shape. Keeping its
|
||||
// fallback local makes the camera catalog independent of feature projection.
|
||||
const list = Array.isArray(config.roomCameras?.cameras) ? config.roomCameras.cameras : [];
|
||||
list.forEach((camera) => {
|
||||
const normalized = normalizeCamera(camera);
|
||||
if (normalized) cameraMap.set(normalized.id, normalized);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Room-Camera Configuration
|
||||
// Purpose: Defines the optional named snapshot and stream camera catalog.
|
||||
// Scope: Contains configuration metadata only and never contacts a camera.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'roomCameras',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, cameras: [] },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
cameras: {
|
||||
type: 'array',
|
||||
items: strictObject({
|
||||
id: string({ minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||
name: string({ minLength: 1, maxLength: 120 }),
|
||||
description: string({ maxLength: 500 }),
|
||||
url: string({ title: 'Snapshot URL', format: 'uri', maxLength: 2048 }),
|
||||
streamUrl: string({ title: 'Stream URL', maxLength: 2048 }),
|
||||
}, { required: ['id', 'name', 'description', 'url', 'streamUrl'] }),
|
||||
},
|
||||
}, { title: 'Room cameras', required: ['enabled', 'cameras'] }),
|
||||
};
|
||||
@@ -5,9 +5,9 @@ const { loadFromConfig, getRoomCameras, getRoomCamera, roomCameraEvents } = requ
|
||||
const { createSnapshotEngine } = require('./snapshotEngine');
|
||||
const { registerRoomCameraSocketGateway } = require('./socketGateway');
|
||||
const replay = require('../replayEngineV2/roomCameraReplayBuilder');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
|
||||
const enabled = isFeatureEnabled('roomCameras');
|
||||
const enabled = Boolean(loadConfig().roomCameras?.enabled);
|
||||
|
||||
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
|
||||
if (enabled) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Session and Public Presentation Configuration
|
||||
// Purpose: Defines the global timezone and browser-facing metadata assembled into session payloads.
|
||||
// Scope: Contains configuration metadata only so the database can import it without initializing the session service.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
const timezone = {
|
||||
key: 'timezone',
|
||||
defaultValue: 'America/New_York',
|
||||
schema: string({ title: 'Timezone', description: 'IANA timezone used for server-facing dates and times.', minLength: 1, maxLength: 100 }),
|
||||
};
|
||||
|
||||
const socials = {
|
||||
key: 'socials',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, links: [] },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ title: 'Enabled' }),
|
||||
links: {
|
||||
type: 'array',
|
||||
title: 'Links',
|
||||
items: strictObject({
|
||||
id: string({ minLength: 1, maxLength: 60, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||
label: string({ minLength: 1, maxLength: 80 }),
|
||||
url: string({ format: 'uri', maxLength: 2048 }),
|
||||
icon: string({ maxLength: 80 }),
|
||||
color: string({ pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
}, { required: ['id', 'label', 'url', 'icon', 'color'] }),
|
||||
},
|
||||
}, { title: 'Social links', required: ['enabled', 'links'] }),
|
||||
};
|
||||
|
||||
const driverAd = {
|
||||
key: 'driverAd',
|
||||
defaultValue: { title: '', html: '' },
|
||||
schema: strictObject({
|
||||
title: string({ maxLength: 120 }),
|
||||
html: string({ title: 'HTML', description: 'Trusted operator HTML shown to drivers.', maxLength: 100000 }),
|
||||
}, { title: 'Driver content', required: ['title', 'html'] }),
|
||||
};
|
||||
|
||||
function getConfiguredSocials(config) {
|
||||
/*
|
||||
Link normalization belongs with the session-owned social configuration,
|
||||
not feature enablement. The explicit `socials.enabled` switch independently
|
||||
decides whether browsers should expose the resulting list.
|
||||
*/
|
||||
const links = config?.socials?.links;
|
||||
return Array.isArray(links)
|
||||
? links.filter((entry) => typeof entry?.url === 'string' && entry.url.trim())
|
||||
: [];
|
||||
}
|
||||
|
||||
module.exports = { timezone, socials, driverAd, getConfiguredSocials };
|
||||
@@ -1,19 +1,16 @@
|
||||
// session Service constants
|
||||
// Purpose: Defines timing and static social/config constants used by session synchronization behavior.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating constants from orchestration logic.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getConfiguredSocials } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { getConfiguredSocials } = require('./configuration');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordInvite = config.discord?.invite || null;
|
||||
const kofiLink = config.kofi?.link || null;
|
||||
const serverTimezone = config.timezone || null;
|
||||
const configuredSocials = getConfiguredSocials(config);
|
||||
/*
|
||||
The driver ad is trusted deployment content supplied by the server operator.
|
||||
Normalize both values at the server boundary so every browser receives a
|
||||
predictable string-only contract, even when the YAML keys are absent or were
|
||||
accidentally configured with another scalar type.
|
||||
predictable string-only contract at the session boundary.
|
||||
|
||||
Keep the title and markup together because they describe one optional card.
|
||||
An empty HTML string disables the card; the title alone must never leave an
|
||||
@@ -29,8 +26,6 @@ const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000;
|
||||
const PERIODIC_SYNC_MS = 20000;
|
||||
|
||||
module.exports = {
|
||||
discordInvite,
|
||||
kofiLink,
|
||||
serverTimezone,
|
||||
configuredSocials,
|
||||
driverAd,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('sessionService');
|
||||
const { getFeatureFlags } = require('../../configuration');
|
||||
const { getRole, isAdmin, roleEvents } = require('../roleService');
|
||||
const { getMode, modeEvents } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
@@ -43,7 +44,6 @@ const { getGlobalObjective } = require('../globalObjectiveService');
|
||||
const { getAdminReason } = require('../adminReasonService');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const { getFeatureFlags } = require('../../helpers/features');
|
||||
const {
|
||||
canUseExternalSpectatorAccess,
|
||||
getBandwidthSavingsPolicy,
|
||||
@@ -58,8 +58,6 @@ const { getAudioLevels, getAudioAdjustmentStateForSocket, audioLevelsEvents } =
|
||||
const { getButtonBoxState } = require('../buttonBoxService');
|
||||
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
||||
const {
|
||||
discordInvite,
|
||||
kofiLink,
|
||||
serverTimezone,
|
||||
configuredSocials,
|
||||
driverAd,
|
||||
@@ -73,8 +71,6 @@ const {
|
||||
filterActiveDriversForSocket,
|
||||
filterTurnQueuesForSocket,
|
||||
} = require('./filters');
|
||||
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
|
||||
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
|
||||
logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured');
|
||||
|
||||
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
|
||||
@@ -207,9 +203,9 @@ function buildSession(socket) {
|
||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
||||
/*
|
||||
Features is the single UI contract for optional server capabilities. A
|
||||
disabled feature should be absent from navigation/layout decisions even
|
||||
though the service module may still be loaded on the Node side.
|
||||
The configuration system derives this public map from service definitions
|
||||
marked as features. A false enabled switch keeps the corresponding UI out
|
||||
of navigation and layout without maintaining another feature registry.
|
||||
*/
|
||||
features,
|
||||
roster,
|
||||
@@ -245,13 +241,7 @@ function buildSession(socket) {
|
||||
truth and avoids a separate endpoint for one small optional card.
|
||||
*/
|
||||
driverAd,
|
||||
discord: {
|
||||
invite: discordInvite,
|
||||
},
|
||||
timezone: serverTimezone,
|
||||
kofi: {
|
||||
link: kofiLink,
|
||||
},
|
||||
identity: getIdentitySummary(socket),
|
||||
verification: getVerificationStateForSocket(socket),
|
||||
moderation: getModerationStateForSocket(socket),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// First-Run Setup Service
|
||||
// Purpose: Allows an empty data directory to create its first lockdown administrator or import legacy YAML safely.
|
||||
// Scope: Exposes setup-only socket operations and permanently closes them once a lockdown administrator exists.
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('setupService');
|
||||
const { getConfigurationDatabase } = require('../../configuration');
|
||||
const { importLegacyConfiguration } = require('../../configuration/legacyImporter');
|
||||
|
||||
const MAX_LEGACY_YAML_BYTES = 1024 * 1024;
|
||||
const database = getConfigurationDatabase();
|
||||
let setupCode = null;
|
||||
|
||||
function isSetupRequired() {
|
||||
return !database.isSetupComplete();
|
||||
}
|
||||
|
||||
function ensureSetupCode() {
|
||||
if (!isSetupRequired()) return null;
|
||||
if (!setupCode) {
|
||||
setupCode = crypto.randomBytes(6).toString('hex');
|
||||
/*
|
||||
The code is intentionally logged only on a server that has no lockdown
|
||||
administrator. It lives in process memory, changes on restart, and is
|
||||
permanently irrelevant as soon as setup succeeds, so it cannot become a
|
||||
recurring environment-variable authentication bypass.
|
||||
*/
|
||||
logger.warn('First-run setup is required', { setupCode });
|
||||
}
|
||||
return setupCode;
|
||||
}
|
||||
|
||||
function requireOpenSetup(candidateCode) {
|
||||
if (!isSetupRequired()) throw new Error('First-run setup is already complete.');
|
||||
const expected = ensureSetupCode();
|
||||
const supplied = String(candidateCode || '').trim().toLowerCase();
|
||||
const matches = supplied.length === expected.length
|
||||
&& crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(expected));
|
||||
if (!matches) throw new Error('Invalid setup code.');
|
||||
}
|
||||
|
||||
function respond(cb, work) {
|
||||
Promise.resolve()
|
||||
.then(work)
|
||||
.then((result) => cb({ success: true, ...result }))
|
||||
.catch((error) => {
|
||||
logger.warn('First-run setup request failed', { error: error.message });
|
||||
cb({
|
||||
error: error.message,
|
||||
code: error.code || null,
|
||||
validationErrors: error.validationErrors || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ensureSetupCode();
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('setup:status', (_payload = {}, cb = () => {}) => {
|
||||
cb({ success: true, required: isSetupRequired() });
|
||||
});
|
||||
|
||||
socket.on('setup:createAdministrator', (payload = {}, cb = () => {}) => {
|
||||
respond(cb, async () => {
|
||||
requireOpenSetup(payload.setupCode);
|
||||
const password = String(payload.password || '');
|
||||
if (password.length < 10) throw new Error('Administrator password must be at least 10 characters.');
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const administrator = database.createAdministrator({
|
||||
username: payload.username,
|
||||
passwordHash,
|
||||
discordId: payload.discordId,
|
||||
role: 'lockdown',
|
||||
}, 'first-run-setup');
|
||||
setupCode = null;
|
||||
return { administrator };
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('setup:importLegacy', (payload = {}, cb = () => {}) => {
|
||||
respond(cb, () => {
|
||||
requireOpenSetup(payload.setupCode);
|
||||
const yamlText = String(payload.yaml || '');
|
||||
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_LEGACY_YAML_BYTES) {
|
||||
throw new Error('Legacy YAML must be present and no larger than 1 MiB.');
|
||||
}
|
||||
const result = importLegacyConfiguration({
|
||||
text: yamlText,
|
||||
database,
|
||||
actor: 'first-run-setup',
|
||||
source: String(payload.fileName || 'uploaded-config.yaml').slice(0, 255),
|
||||
});
|
||||
setupCode = null;
|
||||
return result;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
isSetupRequired,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Video Auth Stream Parsing
|
||||
// Purpose: Parses MediaMTX path/body payloads into normalized stream targets for rover and room media checks.
|
||||
// Scope: Handles WHEP/WHP path-prefix trimming and SRT streamid extraction without performing auth decisions.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
|
||||
const config = loadConfig();
|
||||
const mediaConfig = config.media || {};
|
||||
|
||||
@@ -9,7 +9,7 @@ const videoSessions = require('../videoSessions');
|
||||
const roverManager = require('../roverManager');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const turnService = require('../turnService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const {
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
|
||||
Reference in New Issue
Block a user