this is a big slop that might backfire lol... new config system and UI!

This commit is contained in:
legop3
2026-09-14 02:31:12 -04:00
parent 17b1404157
commit bfdb6555d8
108 changed files with 3212 additions and 701 deletions
@@ -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');
+10 -8
View File
@@ -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 -1
View File
@@ -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'] }),
};
+3 -4
View File
@@ -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 -1
View File
@@ -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'] }),
};
+3 -4
View File
@@ -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,
+4 -14
View File
@@ -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),
+102
View File
@@ -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,