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,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