Compare commits

...
3 Commits
Author SHA1 Message Date
legop3 3d2e75572f config uislopping 2026-09-14 03:06:41 -04:00
legop3 81994f8a56 remove useless slop stuff 2026-09-14 02:49:22 -04:00
legop3 bfdb6555d8 this is a big slop that might backfire lol... new config system and UI! 2026-09-14 02:31:12 -04:00
109 changed files with 3408 additions and 721 deletions
+3
View File
@@ -38,3 +38,6 @@ server/src/services/balanceBoardService/native/balance_board_worker
server/data/fleet-reports.sqlite
server/data/fleet-reports.sqlite-shm
server/data/fleet-reports.sqlite-wal
server/data/configuration.sqlite
server/data/configuration.sqlite-shm
server/data/configuration.sqlite-wal
+81 -42
View File
@@ -2,7 +2,14 @@
## Status
This document records the agreed design and implementation order. None of the work described here is implemented merely by this document.
This document is the live implementation tracker for the migration.
- [x] Phase 1, step 1: Establish the single data-directory contract
- [x] Phase 1, steps 2-5: Configuration database, manual setup-file import, setup, and centralized admin UI
- [ ] Phase 1, steps 6-9: Backup/restore, restart, and internal video proxy
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
The single data-directory implementation and local verification are complete. Real snapshot generation, legacy-directory cleanup, and runtime filesystem tracing remain deployment checks for the actual server; they do not leave the implementation step open.
The work is deliberately split into two phases:
@@ -13,6 +20,11 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
## Decision log
- 2026-09-14: Render the schema-driven configuration editor through one generic MultiRover RJSF theme. Top-level objects use `CardFrame`, nested objects and array items have explicit boundaries, and array operations use visible text controls beside their item. Responsive field columns remain, but no toolbar or item action is pushed to the far edge of a wide section.
- 2026-09-14: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file path retained is an operator-selected YAML upload on `/setup`. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation.
- 2026-09-14: Keep the one-time first-run setup code in `data/setup-code.txt` with owner-only permissions instead of writing the credential into server logs. Reuse it across restarts and delete it permanently when setup completes.
- 2026-09-14: Feature enablement is exactly the service-owned `enabled` boolean. A service-owned configuration definition marks itself with `feature: true` when that switch belongs in the public feature map; the configuration system derives the map for sessions and command availability, including nested service definitions, without a separate feature registry. Missing credentials, hardware, connections, data, or enabled dependencies are runtime health conditions and never silently change that choice.
- 2026-09-14: Keep configuration as one ordered hierarchical document, matching the former YAML layout. The admin application presents one continuous configuration page and saves the complete document as one revision. There are no artificial Hardware, Integrations, Media, or similar configuration categories and no backend or frontend section registries.
- 2026-09-13: Use an internal Node `/video` proxy. The public reverse proxy will send every site path to Node, Node will strip `/video` and stream WHEP signaling to MediaMTX on loopback, and MediaMTX port 8889 will not be exposed publicly. MediaMTX cannot independently add a WHEP base-path prefix; making `video` part of every stream name would still leave two HTTP servers competing for the public HTTPS listener.
- 2026-09-13: Preserve the existing flat `server/data` layout instead of moving established stores into decorative `state`, `cache`, or `generated` parents. Packaged application assets remain with the application.
- 2026-09-13: "The server" in the filesystem rule specifically means the main Node.js application. Every file it intentionally creates or modifies, including disposable scratch work, must be beneath `SERVER_DATA_DIR`. Installers, systemd, Docker, BlueZ, and unavoidable internal behavior of external libraries are outside that application boundary.
@@ -23,7 +35,7 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
- All operator-controlled server configuration is stored in a validated database and managed through the web UI.
- All mutable runtime state, generated files, caches, snapshots, recordings, and databases live under one server data directory.
- A complete backup can capture that one data directory consistently, and a restore can safely replace it.
- A one-time legacy importer moves an existing `config.yaml` installation into the new configuration database.
- `/setup` may initialize the database from a YAML file explicitly selected by the operator; no automatic host migration exists.
- A dedicated `/admin` application contains all server administration.
- The public `/video` route is proxied to MediaMTX by the Node server, eliminating the special external MediaMTX proxy rule.
- The completed server is packaged as a replaceable container whose only persistent mount is the data directory.
@@ -114,6 +126,16 @@ The audit must search direct filesystem calls as well as environment-variable de
## 2. Replace YAML with a configuration database
Implementation architecture:
- Each configurable service owns a side-effect-free fragment containing its key, safe default, and strict schema. One short composition list assembles those fragments into the ordered hierarchical document.
- The database validates and commits that complete document as one coherent immutable revision.
- The admin UI presents one continuous configuration page in the same top-to-bottom order as the former YAML file.
- Nested cards make object relationships readable, but do not create separate categories, navigation destinations, persistence boundaries, or registries.
- Shared editor infrastructure owns loading, dirty state, validation errors, revision conflicts, secret operations, and restart-required status for the whole document.
- The browser receives this same schema from the protected admin endpoint and renders it with a maintained JSON Schema form library.
- Standard JSON Schema types drive ordinary fields, nested objects, enums, and arrays. One field-agnostic widget handles every `writeOnly` secret; there are no feature-specific configuration components in React.
Create a synchronous configuration service backed by `better-sqlite3`. Synchronous reads preserve the server's current startup model, in which many services load their configuration while modules are required.
The configuration database should own at least:
@@ -177,30 +199,27 @@ After migration is complete:
- Remove `config.yaml` and `config.example.yaml` from the repository and installation process.
- Remove `js-yaml` if MediaMTX generation is changed to avoid it or if it is otherwise no longer needed. Generated MediaMTX YAML is an internal artifact, not operator configuration, so retaining `js-yaml` solely for that generator is acceptable.
## 3. Build the one-time legacy configuration importer
## 3. Add optional configuration-file upload to setup
Existing installations need an explicit, bounded migration from their old `config.yaml`. This importer is not a compatibility loader and must never become a permanent second source of truth.
Container deployment starts with a new data directory and never discovers an old installation automatically. As a convenience, the first-run setup page may initialize the empty database from a YAML configuration file deliberately selected by the operator. This is not a startup loader, installer migration, command-line workflow, or permanent second source of truth.
The importer must:
The setup upload must:
- Accept an explicitly selected legacy YAML file.
- Parse the complete legacy document.
- Accept only an explicitly selected YAML file from `/setup`.
- Require the one-time setup code before processing it.
- Parse the complete document.
- Map every recognized field into the new configuration schema.
- Preserve existing bcrypt administrator password hashes.
- Preserve lockdown roles and Discord IDs.
- Preserve secrets without printing them.
- Apply new defaults for fields absent from an older configuration.
- Detect unknown fields and show them in the migration report.
- Apply current defaults for absent fields.
- Report unknown or invalid fields instead of discarding them.
- Validate the entire result before writing anything.
- Refuse to overwrite an already-configured database unless an explicit replacement workflow is used.
- Support a dry-run that reports changes without writing.
- Write the imported configuration and migration metadata atomically.
- Record the source format and migration time without storing secret values in the audit event.
- Verify that the resulting configuration can be read back before considering the import successful.
- Refuse to replace an already-configured database.
- Write the configuration, administrators, and audit event atomically.
- Record the uploaded filename without storing secret values in the audit event.
The first-run UI should recognize that a legacy configuration is available and offer the import after the operator proves possession of the one-time setup code. A command-line import path should also exist for recovery and unattended migration.
After a successful import, the server must use only the database. The legacy YAML file should not be watched, re-read, or used as fallback. Removal of the old file should be an explicit final migration step after the operator has downloaded a backup or otherwise confirmed the import.
The browser uploads the selected contents directly. The server never scans the host for a file, and it does not retain, watch, remove, or reuse the uploaded YAML after the database transaction completes.
## 4. Add first-run setup
@@ -210,10 +229,10 @@ Required flow:
1. Initialize the databases and safe default configuration.
2. Keep all optional external integrations disabled.
3. Generate a one-time setup code and print it to the server log.
3. Generate a one-time setup code in `data/setup-code.txt` with owner-only permissions. Logs report the file location but never the credential.
4. Serve a restricted `/setup` application.
5. Require the setup code before creating the first lockdown administrator.
6. Offer legacy configuration import when a legacy source was explicitly provided.
6. Offer manual YAML configuration-file upload as an alternative to creating the first administrator from scratch.
7. Otherwise collect only the minimum information needed to establish the instance.
8. Permanently disable setup after the first lockdown administrator exists.
@@ -223,26 +242,16 @@ A recovery command must be available for resetting or creating a lockdown admini
Create a dedicated `/admin` route instead of continuing to expand the existing driver-page admin panel.
The application should organize existing and new controls into:
The application should provide these top-level destinations:
- Overview and service health
- Fleet and rover operations
- Users, administrators, verification, and permissions
- Media and bandwidth
- Discord and Home Assistant
- PTZ and room cameras
- Kinect and Balance Board
- Button box, barcode scanner, barcode games, and lift
- LLM commentary and Overseer Control
- Social links and driver content
- Fleet reports
- Application and administrative logs
- Persistent audit history
- Configuration revisions
- Backup and restore
- System restart, and later container update
- Configuration, presented as one hierarchical page
Existing components and server operations should be moved or reused rather than duplicated. The identity database page and other isolated administrative pages should become sections of this centralized application where doing so preserves their existing behavior.
Overview may include application logs, persistent audit history, configuration revisions, backup and restore, and system restart or later container-update state. These operational views do not divide the configuration document into categories.
Existing components and server operations should be moved or reused rather than duplicated. The identity database page and other isolated administrative pages should become destinations within this centralized application where doing so preserves their existing behavior.
Authorization rules:
@@ -251,7 +260,7 @@ Authorization rules:
- Sensitive changes require recent password confirmation.
- Server-side authorization remains authoritative for every operation; hiding a control in React is not an access check.
Configuration forms should be explicit, typed forms. There should be no raw YAML editor and no generic JSON editor for ordinary configuration. Repeatable definitions such as cameras, entities, links, and buttons need simple add, remove, reorder, and test workflows.
Configuration uses one schema-generated typed form rather than a raw YAML or JSON text editor. Repeatable values such as cameras, entities, links, and buttons receive the form library's generic add, remove, and reorder workflow.
## 6. Implement complete backup and restore
@@ -379,8 +388,9 @@ Phase 1 is complete only when all of the following are true:
- The current systemd installation runs without `config.yaml`.
- A completely empty data directory can be initialized through `/setup`.
- An existing YAML installation can be imported exactly once.
- The importer reports unknown or invalid legacy values instead of discarding them.
- An explicitly selected YAML file can initialize the empty database exactly once.
- The setup upload reports unknown or invalid values instead of discarding them.
- Startup and installation do not search for or modify an old `config.yaml`.
- All mutable server state is contained by the configured data directory.
- A complete backup can be downloaded and validated.
- A restore replaces the server state only after validation and survives restart.
@@ -419,6 +429,34 @@ Local verification completed:
- Source inventory found no remaining application runtime use of the operating system temporary directory, the old snapshot/replay environment variables, or `/var/lib` paths; only tests use OS temporary directories and the installer retains a deliberate legacy-directory notice.
- Real snapshot generation and legacy-directory cleanup still require verification on the actual server during deployment.
### Configuration and administration implementation notes
Implemented on 2026-09-14:
- Added one ordered, strictly validated hierarchical configuration assembled from side-effect-free definitions owned by the services that consume each value.
- Added immutable SQLite configuration revisions, active-revision tracking, administrator accounts, schema migrations, and persistent administrative audit events under the shared data directory.
- Added full-document saves with optimistic revision checking. A stale browser cannot overwrite a newer revision, and invalid or unknown fields cannot become active.
- Redacted secrets from browser responses and audit data. The one complete save operation preserves stored secrets unless the administrator explicitly replaces or clears them.
- Converted every runtime configuration consumer to the synchronous database-backed configuration service and removed the YAML loader, `SERVER_CONFIG`, and the tracked example YAML.
- Added an explicit one-time YAML upload to `/setup`. Existing bcrypt hashes, lockdown roles, Discord identities, configuration, and secrets can be imported only when the operator selects the file; the installer and startup perform no automatic discovery or migration, and there is no command-line importer.
- Added safe empty-data startup, a file-backed one-time setup code, the restricted `/setup` route, and a console administrator-recovery command. The credential persists at `data/setup-code.txt` across restarts with `0600` permissions, never appears in logs, and is deleted when setup completes.
- Added the centralized `/admin` route with Overview, Fleet operations, Users and administrators, and one schema-generated hierarchical Configuration page in legacy YAML order.
- Replaced every feature-specific configuration form with `@rjsf/core`; the protected admin snapshot supplies the server's assembled schema, and one generic widget handles all schema-declared secrets.
- Replaced RJSF's unthemed Bootstrap markup with generic MultiRover object, field, and array templates. Configuration sections now use the same cards and surfaces as the driver UI, array controls are readable text beside each item, and the shared responsive grid never sends actions to the far edge of a wide panel.
- Converged feature control into service-owned configuration: each public feature opts in beside its own schema, and the configuration system derives those exact `enabled` switches for sessions and command discovery. The former server feature registry was removed; configuration completeness and hardware availability remain visible as runtime status instead of becoming hidden enablement rules.
- Lazy-loaded setup and administration so the schema-form dependency is not included in ordinary driver-page downloads.
- Reused the existing fleet and identity administration surfaces, added password reconfirmation for sensitive operations, and prevented removal or demotion of the final lockdown administrator.
- Added configuration revision history, rollback, audit history, and restart-required reporting. Graceful restart itself remains step 7.
Local verification completed:
- All 103 server tests passed, including file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, explicit setup-file import, and the earlier filesystem coverage.
- Focused admin, route, and identity UI lint passed.
- All 20 existing focused web UI tests passed.
- The production web UI build completed successfully and regenerated the checked-in server assets.
- Installer syntax and repository whitespace checks passed.
- A local startup smoke test reached listener initialization. MediaMTX then exited because `/usr/local/bin/mediamtx` is intentionally absent on this development machine; actual enabled integrations and media remain deployment checks for the real server.
# Phase 2: containerization and image delivery
Phase 2 packages the completed Phase 1 application. It must not introduce a second configuration source or a second persistent-data layout.
@@ -621,11 +659,12 @@ Containerization is complete when:
Within the two hard phase boundaries, the safest order is:
- [x] Complete the filesystem audit and single data-directory migration.
- [ ] Add the configuration schema/database and administrator storage.
- [ ] Add first-run setup and the legacy YAML importer.
- [ ] Convert every configuration consumer and remove YAML runtime loading.
- [ ] Build the centralized admin configuration UI.
- [ ] Add persistent audit history.
- [x] Add the configuration schema/database and administrator storage.
- [x] Add first-run setup and explicit YAML configuration-file upload.
- [x] Convert every configuration consumer and remove YAML runtime loading.
- [x] Converge optional feature control into service-owned `enabled` switches and derive the public feature map from those definitions.
- [x] Build the centralized admin configuration UI.
- [x] Add persistent audit history.
- [ ] Implement coordinated backup and staged restore.
- [ ] Standardize graceful application restart.
- [ ] Add the internal `/video` proxy and remove the special external route.
-278
View File
@@ -1,278 +0,0 @@
admins:
- username: admin
password_hash: "$2b$10$ZW4Jy7ctIt7k9V1AogFky.v4wedLF92t4/ZlT9kWPlIiCmdQNzJ.C" # password: adminpass
discord_id: "1234567890"
lockdown: false
- username: lockdown
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
discord_id: "0987654321"
lockdown: true
timezone: "America/New_York"
interInstance:
enabled: false
directoryUrls:
- "https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json"
pollIntervalMs: 30000
requestTimeoutMs: 5000
profile:
publicUrl: "https://rover.example.com"
name: "Example Rover Server"
description: "A short public description of this rover server."
color: "#38bdf8"
llmCommentary:
enabled: false
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
frequency: 120000
overseerControl:
enabled: false
# autonomous runs the existing vote-gated loop forever; directAddress only
# runs one cycle when a chat message mentions the configured name.
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: "https://example.com/overseer.png"
gateIntervalMs: 2000
barcodeGames:
enabled: false
botName: "Barcode Games"
profileImageUrl: "https://example.com/barcode-games.png"
media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep
# Example: http://media-server.local:8889/video
whepBaseUrl: "http://media-server.local:8889/video"
# MediaMTX advertises these instance-specific DNS names or IP addresses as WebRTC ICE
# candidates. Include every public and LAN address browsers use to reach this server.
# The server generates MediaMTX's runtime configuration from this list; never edit a
# separate mediamtx.yml for a new installation.
additionalHosts:
- "rover.example.com"
- "media-server.local"
bandwidthSavings:
# Duplicate driver-tab handling for the same browser identity.
# allowed: no duplicate-tab protection
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
# notAllowed: every identity is limited to one driver tab
multiTabProtection: "verifiedOnly"
# Disconnect rover video when its player is outside the viewport or the web
# page is in a background browser tab. Rover audio is a separate stream and
# remains connected. /mini intentionally keeps its existing always-warm video
# behavior regardless of this option.
pauseHiddenRoverVideo: false
# Video for users who are attached to a source but do not currently own its
# active turn. "snapshots" saves upload bandwidth; "live" allows full video
# whenever the normal mode/visibility rules allow it.
nonTurnVideo:
mode: "snapshots"
# Snapshot mode activates only when controllable users exceed this number.
# A controllable user is attached to a rover or PTZ as operator/queue, not a
# plain spectator. 0 preserves always-on non-turn snapshots once anyone is
# actually attached to a controllable source.
userThreshold: 0
# Live video for spectators outside the local network. Local spectators are
# not restricted by this switch because LAN traffic is not the upload limit.
externalSpectatorVideo: "snapshots"
# Whether non-local users may enter the spectator page.
# off: block external spectators
# on: allow external spectators
# verifiedOnly: require a verified identity, but no separate spectator grant
# admin: require an identity feature-state grant at spectatorAccess.external
externalSpectatorAccess: "on"
audioForward:
enabled: true
ffmpegBin: "ffmpeg"
streamSuffix: "-fwd"
maxUploadBytes: 8388608
audioLevels:
# Base multipliers (0.0 - 4.0) applied before any approved user's signed
# personal adjustment. The server clamps every final rover gain to this same
# hard multiplier range.
hornGain: 1.0
ttsGain: 1.0
forwardGain: 1.0
# Approved users may move each personal slider this far below or above the
# base multiplier. Browser cookies store percentages, never raw multipliers.
maxPersonalAdjustmentPercent: 50
homeAssistant:
enabled: false
url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
neato:
enabled: false
# ESPHome device name, used to derive gen3 entities:
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
device: "neato_vacuum"
lift:
enabled: false
# Two Home Assistant switches controlling lift direction.
# Raise sequence: down off -> wait interlockMs -> up on
# Lower sequence: up off -> wait interlockMs -> down on
upSwitch: "switch.lift_up"
downSwitch: "switch.lift_down"
interlockMs: 2000
commandCooldownMs: 3000
entities:
- id: "light.lab_main"
name: "Lab Lights"
- id: "switch.dock_power"
name: "Dock Power"
# type is optional; if omitted it is inferred from the entity id (light/switch)
# For room-light policy, all configured entities are treated as room lights (including switches).
buttons:
# Legacy action entities only (for example sensor.<button>_action from Zigbee2MQTT).
- entityId: "sensor.basement_rover_buttons_action"
# Human alert button
stateEquals: "on"
cooldownMs: 15000
action: "humanAlert"
- entityId: "sensor.basement_rover_buttons_action"
# Mode button: turns
stateEquals: "double"
cooldownMs: 2000
action: "modeTurns"
- entityId: "sensor.basement_rover_buttons_action"
# Mode button: admin
stateEquals: "hold"
cooldownMs: 2000
action: "modeAdmin"
- entityId: "sensor.basement_rover_buttons_action"
# Room lights lock toggle
stateEquals: "toggle"
cooldownMs: 1000
action: "lightsLockToggle"
roomCameras:
enabled: false
cameras:
- id: "lobby"
name: "Lobby Camera"
description: "Wide shot of the staging area."
url: "http://192.168.0.50/snapshot.jpg"
streamUrl: "http://192.168.0.50/stream.mjpg"
- id: "workshop"
name: "Workshop Bench"
description: "Shows the workbench and charging docks."
url: "http://192.168.0.51/snapshot.jpg"
streamUrl: "http://192.168.0.51/stream.mjpg"
ptzCamera:
enabled: false
name: "PTZ Camera"
host: "192.168.0.8"
onvifPort: 8000
username: "admin"
password: "REPLACE_WITH_CAMERA_PASSWORD"
# The Reolink TrackMix autotrack profile was token 003 during commissioning.
# Keeping this configurable lets firmware/profile resets be fixed without code
# changes while the integration still remains a single-camera feature.
profileToken: "003"
turnDurationMs: 300000
# PTZ replay capture needs a known-good replay encoder on the server. Keep it
# off by default so adding live PTZ does not start a broken replay worker loop.
replayEnabled: false
kinect:
enabled: false
# Capture requests are global across 3d/color so one person cannot spam room
# uploads for everyone else. This does not affect the native worker's local
# camera cache; it only gates browser-requested broadcasts.
captureCooldownMs: 10000
balanceBoard:
# The server installer always prepares Bluetooth and the kernel driver. This
# switch only starts the service and shows its small live-weight panel.
enabled: false
buttonBox:
enabled: false
barcodeScanner:
enabled: false
commands:
# Commands are a core server capability shared by site chat and optional
# transports. Their names therefore do not belong to Discord configuration.
prefix: "rs"
# Set this to null to disable the legacy bare time-status shortcut.
timeStatusCommand: "ts"
discord:
# Discord is optional. A token by itself never enables an external login.
enabled: false
token: "DISCORD_BOT_TOKEN"
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
siteUrl: "https://rover.example.com"
channels:
general: "123456789012345678"
announcements: "123456789012345678"
adminAlerts: "123456789012345678"
# chat bridge is configured per guild via the shared `commands.prefix`
replay: "123456789012345678"
humanAlerts: "123456789012345678"
roles:
stalkerPing: "123456789012345678"
announcementPing: "123456789012345678"
adminPing: "123456789012345678"
humanAlertPing: "123456789012345678"
socials:
enabled: false
links:
- id: "discord"
label: "Discord"
url: "https://discord.gg/your-invite"
icon: "FaDiscord"
color: "#5865F2"
- id: "kofi"
label: "Ko-fi"
url: "https://ko-fi.com/your-handle"
icon: "FaCoffee"
color: "#29ABE0"
# Optional trusted HTML card shown at the bottom of the desktop driver page's
# left column. Leave html empty (or omit this section) to hide the card. This
# content is sent to driver browsers without sanitization, so only place markup
# here that is controlled by the server operator.
driverAd:
title: "Advertisement"
html: |
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
<img src="https://example.com/ad.png" alt="Advertisement" style="display:block;width:100%;height:auto;">
</a>
# Optional passive fleet telemetry, history, and daily reporting. The collector
# observes existing server events and rover sensor frames but never participates
# in command, assignment, docking, or safety decisions.
fleetReports:
enabled: false
retention:
# Zero retains evidence indefinitely. Set explicit day counts on servers
# that prefer bounded storage over complete long-term history.
detailedDays: 0
minuteSamplesDays: 0
battery:
enabled: true
maximumIntegrationGapSeconds: 5
minimumCapacityTestDepthPercent: 60
discord:
enabled: true
sendAt: "08:00"
timezone: "America/New_York"
privacy:
retainChatBodies: true
+4
View File
@@ -8,6 +8,10 @@ require('./src/helpers/sensorDecoder');
require('./src/services/alertService');
require('./src/services/authService');
// Setup remains available only until the first lockdown administrator exists;
// the administrative configuration gateway then owns all subsequent changes.
require('./src/services/setupService');
require('./src/services/adminConfigurationService');
require('./src/services/eventBus');
require('./src/services/modeManager');
require('./src/services/lockdownGuard');
+7 -14
View File
@@ -32,7 +32,6 @@ DATA_DIR="$SERVER_DIR/data"
SNAPSHOT_DIR="$DATA_DIR/rover-snapshots"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml"
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
@@ -185,12 +184,6 @@ if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then
setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER"
fi
if [[ ! -f "$CONFIG_PATH" ]]; then
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
fi
# Bluetoothd remains responsible for discovery and the one-time bond, but its
# generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM
# 0x13 before the Balance Board worker can listen for the board's front-button
@@ -263,11 +256,11 @@ fi
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
# Validate the new source of truth before disabling a working legacy service. The validator
# performs the same build and YAML serialization as server startup without opening listeners
# or leaving a process behind.
# Validate database-backed MediaMTX inputs before disabling a working legacy
# service. This performs the same build and serialization as startup without
# opening listeners or leaving a process behind.
runuser -u "$TARGET_USER" -- env \
SERVER_CONFIG="$CONFIG_PATH" \
SERVER_DATA_DIR="$DATA_DIR" \
ROVER_SNAPSHOT_WRITER_BIN="$ROVER_SNAPSHOT_WRITER_BIN" \
"$NODE_BIN" "$SERVER_DIR/scripts/validateMediaMtxConfig.js"
@@ -309,7 +302,6 @@ User=$TARGET_USER
Group=$TARGET_USER
WorkingDirectory=$SERVER_DIR
Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=SERVER_DATA_DIR=$DATA_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
ExecStart=$NODE_BIN $SERVER_DIR/index.js
@@ -333,8 +325,9 @@ echo
echo "Services installed:"
echo " multirover.service (Node.js control server with MediaMTX child)"
echo
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
echo "Open /setup to initialize the installation, then use /admin for administration."
echo "For fresh setup, read the one-time code from $DATA_DIR/setup-code.txt."
echo "Kinect/libfreenect packages and udev permissions were installed."
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
echo "Wii Balance Board direct Bluetooth bridge and front-button listener were installed."
echo "Enable balanceBoard in config.yaml, press red Sync once, then use the front button for later wakes."
echo "Enable Balance Board support in /admin, press red Sync once, then use the front button for later wakes."
+4 -1
View File
@@ -5,9 +5,12 @@
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"check:media": "node scripts/checkMedia.js"
"check:media": "node scripts/checkMedia.js",
"admin:recover": "node scripts/adminAccount.js"
},
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.11.1",
"discord.js": "^14.25.1",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DA_yf5GI.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-CFDe8tZ-.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
function n(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const o=new Error(r.error);o.code=r.code||null,o.validationErrors=r.validationErrors||[],o.currentRevision=r.currentRevision||null,s(o);return}e(r)})})}const d=t=>n(t,"adminConfig:get"),m=(t,i)=>n(t,"adminConfig:confirmPassword",{password:i}),u=(t,i)=>n(t,"adminConfig:updateConfiguration",i),c=(t,i)=>n(t,"adminConfig:restoreRevision",i),f=(t,i)=>n(t,"adminConfig:createAdministrator",i),g=(t,i)=>n(t,"adminConfig:updateAdministrator",i),C=(t,i)=>n(t,"adminConfig:deleteAdministrator",{id:i}),A=t=>n(t,"setup:status"),l=(t,i)=>n(t,"setup:createAdministrator",i),p=(t,i)=>n(t,"setup:importConfigurationFile",i);export{u as a,m as b,f as c,C as d,A as e,l as f,d as g,p as i,c as r,g as u};
//# sourceMappingURL=api-C0PIt_OP.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"api-C0PIt_OP.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHU,EAAsB,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQe,IAAOhB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAe,CAAE,CAAE,EAExGC,EAAkBhB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEiB,EAA2B,CAACjB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GgB,EAA0B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-_6Wi5B-Z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJhimuQc.css">
<script type="module" crossorigin src="/assets/index-DA_yf5GI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DAc-5H0K.css">
</head>
<body>
<div id="root"></div>
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
// Administrator Recovery Command
// Purpose: Creates or resets a lockdown administrator when web authentication cannot be repaired through /admin.
// Scope: Performs one explicit local database mutation and never creates a recurring startup bypass.
const bcrypt = require('bcrypt');
const { getConfigurationDatabase } = require('../src/configuration');
function usage() {
process.stderr.write('Usage: node scripts/adminAccount.js <username> <password> [discord-id]\n');
}
async function main() {
const [username, password, discordId = ''] = process.argv.slice(2);
if (!username || !password) {
usage();
process.exitCode = 2;
return;
}
if (password.length < 10) throw new Error('Administrator password must be at least 10 characters.');
const database = getConfigurationDatabase();
const existing = database.findAdministratorForAuthentication(username);
const passwordHash = await bcrypt.hash(password, 12);
if (existing) {
database.updateAdministrator(existing.id, { passwordHash, role: 'lockdown', discordId }, 'command-line-recovery');
process.stdout.write(`Reset lockdown administrator ${existing.username}.\n`);
return;
}
const created = database.createAdministrator({ username, passwordHash, discordId, role: 'lockdown' }, 'command-line-recovery');
process.stdout.write(`Created lockdown administrator ${created.username}.\n`);
}
main().catch((error) => {
process.stderr.write(`Administrator recovery failed: ${error.message}\n`);
process.exitCode = 1;
});
+1 -1
View File
@@ -3,7 +3,7 @@
// Purpose: Lets the installer validate server-owned MediaMTX inputs before disabling the legacy service.
// Scope: Builds and serializes the runtime YAML without starting MediaMTX or changing external state.
const yaml = require('js-yaml');
const { loadConfig } = require('../src/helpers/configLoader');
const { loadConfig } = require('../src/configuration');
const { buildMediaMtxConfig } = require('../src/services/mediaMtxService/config');
const config = loadConfig();
@@ -0,0 +1,192 @@
// Configuration System Tests
// Purpose: Verifies strict defaults, immutable revisions, secret handling, explicit setup-file import, and administrator safety.
// Scope: Uses isolated temporary databases and never opens the development server's data store.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation');
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
const { getFeatureFlags } = require('./index');
const { createConfigurationDatabase } = require('./database');
const { parseConfigurationFile, importConfigurationFile } = require('./configurationFileImporter');
const temporaryRoots = [];
function createTestDatabase() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-configuration-'));
temporaryRoots.push(root);
return createConfigurationDatabase({ databasePath: path.join(root, 'configuration.sqlite') });
}
test.after(() => {
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
});
test('safe defaults form a complete valid configuration with integrations disabled', () => {
assert.doesNotThrow(() => assertValidConfig(defaultConfig));
assert.equal(defaultConfig.discord.enabled, false);
assert.equal(defaultConfig.homeAssistant.enabled, false);
assert.equal(defaultConfig.ptzCamera.enabled, false);
assert.equal(defaultConfig.balanceBoard.enabled, false);
});
test('service definitions determine document order and write-only secret handling', () => {
/*
The generic browser form and backend persistence both consume this one
assembled schema. Guarding composition order and derived secret paths here
prevents either consumer from needing its own parallel registry.
*/
assert.deepEqual(Object.keys(defaultConfig), definitions.map(({ key }) => key));
assert.deepEqual(Object.keys(rootSchema.properties), Object.keys(defaultConfig));
assert.deepEqual(secretPaths, ['homeAssistant.token', 'ptzCamera.password', 'discord.token']);
assert.equal(rootSchema.properties.homeAssistant.properties.token.writeOnly, true);
assert.equal(rootSchema.properties.ptzCamera.properties.password.writeOnly, true);
assert.equal(rootSchema.properties.discord.properties.token.writeOnly, true);
});
test('service definitions generate public feature paths without a separate registry', () => {
/*
This order follows the one configuration document, including nested Neato
and lift definitions beneath Home Assistant. The assertion makes duplicate,
omitted, or centrally reintroduced feature names visible during review.
*/
assert.deepEqual(featureDefinitions, [
{ key: 'interInstance', path: ['interInstance', 'enabled'] },
{ key: 'barcodeGames', path: ['barcodeGames', 'enabled'] },
{ key: 'homeAssistant', path: ['homeAssistant', 'enabled'] },
{ key: 'neato', path: ['homeAssistant', 'neato', 'enabled'] },
{ key: 'lift', path: ['homeAssistant', 'lift', 'enabled'] },
{ key: 'roomCameras', path: ['roomCameras', 'enabled'] },
{ key: 'ptzCamera', path: ['ptzCamera', 'enabled'] },
{ key: 'kinect', path: ['kinect', 'enabled'] },
{ key: 'balanceBoard', path: ['balanceBoard', 'enabled'] },
{ key: 'buttonBox', path: ['buttonBox', 'enabled'] },
{ key: 'barcodeScanner', path: ['barcodeScanner', 'enabled'] },
{ key: 'discord', path: ['discord', 'enabled'] },
{ key: 'socials', path: ['socials', 'enabled'] },
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
]);
});
test('generated feature flags use only each declared enabled switch', () => {
/*
This deliberately describes services without usable credentials, devices,
or enabled parents. Readiness belongs to runtime health, so the generated
public flags must still preserve each operator-selected switch exactly.
*/
const flags = getFeatureFlags({
homeAssistant: {
enabled: false,
lift: { enabled: true },
neato: { enabled: true },
},
roomCameras: { enabled: true, cameras: [] },
barcodeScanner: { enabled: false },
barcodeGames: { enabled: true },
socials: { enabled: true, links: [] },
ptzCamera: { enabled: true, host: '', username: '', password: '' },
discord: { enabled: true, token: '' },
});
assert.equal(flags.homeAssistant, false);
assert.equal(flags.lift, true);
assert.equal(flags.neato, true);
assert.equal(flags.roomCameras, true);
assert.equal(flags.barcodeScanner, false);
assert.equal(flags.barcodeGames, true);
assert.equal(flags.socials, true);
assert.equal(flags.ptzCamera, true);
assert.equal(flags.discord, true);
});
test('normalization fills missing legacy fields but strict validation rejects unknown fields', () => {
const normalized = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video' } });
assert.deepEqual(normalized.media.additionalHosts, []);
assert.doesNotThrow(() => assertValidConfig(normalized));
const invalid = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video', misspelledHost: 'x' } });
assert.throws(() => assertValidConfig(invalid), (error) => {
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
assert.ok(error.validationErrors.some((entry) => entry.path.includes('misspelledHost')));
return true;
});
});
test('full-document updates preserve secrets and reject a stale browser revision', () => {
const database = createTestDatabase();
const initial = database.getActiveConfigurationRecord();
const tokenRevision = database.updateConfiguration({
value: database.getClientConfiguration().config,
expectedRevision: initial.revision,
actor: 'test',
secretOperations: {
'discord.token': { action: 'replace', value: 'super-secret-token' },
},
});
const client = database.getClientConfiguration();
assert.equal(client.config.discord.token, '');
assert.equal(client.configuredSecrets['discord.token'], true);
const editedConfiguration = structuredClone(client.config);
editedConfiguration.discord.enabled = true;
const nextRevision = database.updateConfiguration({
value: editedConfiguration,
expectedRevision: tokenRevision,
actor: 'test',
});
assert.equal(database.getActiveConfigurationRecord().config.discord.token, 'super-secret-token');
assert.throws(() => database.updateConfiguration({
value: editedConfiguration,
expectedRevision: tokenRevision,
actor: 'stale-test',
}), (error) => error.code === 'CONFIG_REVISION_CONFLICT' && error.currentRevision === nextRevision);
const rollbackRevision = database.restoreConfigurationRevision({
revision: tokenRevision,
expectedRevision: nextRevision,
actor: 'rollback-test',
});
assert.ok(rollbackRevision > nextRevision);
assert.equal(database.getActiveConfigurationRecord().config.discord.enabled, false);
database.close();
});
test('administrator storage never exposes hashes or removes the final lockdown administrator', () => {
const database = createTestDatabase();
const lockdown = database.createAdministrator({
username: 'owner',
passwordHash: '$2b$10$example',
role: 'lockdown',
});
const listed = database.listAdministrators();
assert.equal(listed.length, 1);
assert.equal(Object.hasOwn(listed[0], 'passwordHash'), false);
assert.throws(() => database.deleteAdministrator(lockdown.id, 'test'), /final lockdown administrator/);
assert.throws(() => database.updateAdministrator(lockdown.id, { role: 'admin' }, 'test'), /final lockdown administrator/);
database.close();
});
test('an explicitly uploaded YAML file imports configuration and bcrypt hashes exactly once', () => {
const yamlText = `
admins:
- username: owner
password_hash: "$2b$10$preservedHash"
discord_id: "1234"
lockdown: true
timezone: America/Chicago
media:
whepBaseUrl: http://localhost:8889/video
`;
const parsed = parseConfigurationFile(yamlText);
assert.equal(parsed.config.timezone, 'America/Chicago');
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
const database = createTestDatabase();
const result = importConfigurationFile({ text: yamlText, database });
assert.equal(result.administratorCount, 1);
assert.equal(database.findAdministratorForAuthentication('OWNER').passwordHash, '$2b$10$preservedHash');
assert.throws(() => importConfigurationFile({ text: yamlText, database }), /cannot replace an initialized installation/);
database.close();
});
@@ -0,0 +1,67 @@
// Configuration File Importer
// Purpose: Validates one YAML file deliberately uploaded during first-run setup and stores it in the configuration database.
// Scope: This is an explicit setup action only; startup and installation never search for or consume configuration files.
const yaml = require('js-yaml');
const { normalizeConfig, assertValidConfig } = require('./validation');
function normalizeUploadedAdministrator(entry, index) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`Administrator ${index + 1} must be an object.`);
}
const username = String(entry.username || '').trim();
const passwordHash = String(entry.password_hash || '').trim();
if (!username || !passwordHash) {
throw new Error(`Administrator ${index + 1} requires username and password_hash.`);
}
return {
username,
passwordHash,
discordId: String(entry.discord_id || '').trim(),
role: entry.lockdown ? 'lockdown' : 'admin',
};
}
function parseConfigurationFile(text) {
const parsed = yaml.load(String(text || ''));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('The configuration file must contain a YAML object.');
}
const administrators = Array.isArray(parsed.admins)
? parsed.admins.map(normalizeUploadedAdministrator)
: [];
const configInput = Object.fromEntries(
Object.entries(parsed).filter(([key]) => key !== 'admins'),
);
const config = normalizeConfig(configInput);
/*
Unknown fields remain in the normalized document so strict schema
validation reports them to the operator instead of silently losing data
from the explicitly selected file.
*/
assertValidConfig(config);
if (!administrators.some((admin) => admin.role === 'lockdown')) {
throw new Error('The configuration file must contain at least one lockdown administrator.');
}
return { config, administrators };
}
function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) {
const result = parseConfigurationFile(text);
const revision = database.importConfigurationFile({
config: result.config,
administrators: result.administrators,
actor,
source,
});
return {
revision,
administratorCount: result.administrators.length,
};
}
module.exports = {
parseConfigurationFile,
importConfigurationFile,
};
+378
View File
@@ -0,0 +1,378 @@
// Configuration Database
// Purpose: Persists complete immutable configuration revisions, administrator accounts, and administrative audit history.
// Scope: Owns SQLite transactions and invariants; transport authorization and password hashing remain service concerns.
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const { resolveDataPath } = require('../helpers/dataPaths');
const { applySchemaMigrations } = require('./migrations');
const {
defaultConfig,
secretPaths,
clone,
normalizeConfig,
assertValidConfig,
} = require('./validation');
const DEFAULT_DATABASE_PATH = resolveDataPath('configuration.sqlite');
function normalizeUsername(value) {
const username = String(value || '').trim();
if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(username)) {
throw new Error('Administrator username must be 1-64 letters, numbers, dots, underscores, or hyphens.');
}
return username;
}
function normalizeRole(value) {
if (value === 'admin' || value === 'lockdown') return value;
throw new Error('Administrator role must be admin or lockdown.');
}
function splitPath(value) {
return String(value || '').split('.').filter(Boolean);
}
function getAtPath(object, dottedPath) {
return splitPath(dottedPath).reduce((value, key) => value?.[key], object);
}
function setAtPath(object, dottedPath, value) {
const parts = splitPath(dottedPath);
let cursor = object;
parts.slice(0, -1).forEach((key) => {
if (!cursor[key] || typeof cursor[key] !== 'object') cursor[key] = {};
cursor = cursor[key];
});
cursor[parts.at(-1)] = value;
}
function redactConfiguration(config) {
const redacted = clone(config);
const configuredSecrets = {};
secretPaths.forEach((secretPath) => {
configuredSecrets[secretPath] = Boolean(getAtPath(config, secretPath));
setAtPath(redacted, secretPath, '');
});
return { config: redacted, configuredSecrets };
}
function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } = {}) {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const db = new Database(databasePath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
applySchemaMigrations(db);
const readActiveStatement = db.prepare(`
SELECT r.id, r.config_json, r.created_at, r.actor, r.source
FROM configuration_state s
JOIN configuration_revisions r ON r.id = s.active_revision_id
WHERE s.singleton = 1
`);
const insertRevisionStatement = db.prepare(`
INSERT INTO configuration_revisions (config_json, created_at, actor, source)
VALUES (?, ?, ?, ?)
`);
const activateRevisionStatement = db.prepare(`
INSERT INTO configuration_state (singleton, active_revision_id)
VALUES (1, ?)
ON CONFLICT(singleton) DO UPDATE SET active_revision_id = excluded.active_revision_id
`);
const insertAuditStatement = db.prepare(`
INSERT INTO administrative_audit_events (created_at, actor, action, details_json)
VALUES (?, ?, ?, ?)
`);
function writeAudit(actor, action, details = {}) {
/*
Callers pass deliberately small, already-redacted metadata. Configuration
values and password hashes never belong in audit details because audit
history is routinely displayed and retained longer than request bodies.
*/
insertAuditStatement.run(Date.now(), String(actor || 'system'), String(action), JSON.stringify(details));
}
const commitRevisionTransaction = db.transaction((config, metadata) => {
const current = readActiveStatement.get();
if (metadata.expectedRevision != null && Number(metadata.expectedRevision) !== Number(current?.id)) {
const error = new Error('Configuration changed in another session. Reload before saving.');
error.code = 'CONFIG_REVISION_CONFLICT';
error.currentRevision = current?.id || null;
throw error;
}
assertValidConfig(config);
const createdAt = Date.now();
const inserted = insertRevisionStatement.run(
JSON.stringify(config),
createdAt,
String(metadata.actor || 'system'),
String(metadata.source || 'admin'),
);
activateRevisionStatement.run(inserted.lastInsertRowid);
writeAudit(metadata.actor, 'configuration.saved', {
revision: Number(inserted.lastInsertRowid),
source: String(metadata.source || 'admin'),
});
return Number(inserted.lastInsertRowid);
});
const initialActiveRow = readActiveStatement.get();
if (!initialActiveRow) {
commitRevisionTransaction(clone(defaultConfig), {
actor: 'system',
source: 'first-boot-defaults',
});
} else {
/*
New service-owned fields receive their declared defaults as a new revision on
startup. Unknown or newly invalid fields still fail validation; this is a
forward schema evolution path, not a compatibility layer that discards
data it no longer understands.
*/
const storedConfig = JSON.parse(initialActiveRow.config_json);
const normalizedConfig = normalizeConfig(storedConfig);
assertValidConfig(normalizedConfig);
if (JSON.stringify(normalizedConfig) !== JSON.stringify(storedConfig)) {
commitRevisionTransaction(normalizedConfig, {
expectedRevision: Number(initialActiveRow.id),
actor: 'system',
source: 'registered-defaults',
});
}
}
function getActiveConfigurationRecord() {
const row = readActiveStatement.get();
if (!row) throw new Error('Active configuration revision is missing.');
return {
revision: Number(row.id),
config: JSON.parse(row.config_json),
createdAt: Number(row.created_at),
actor: row.actor,
source: row.source,
};
}
function getClientConfiguration() {
const record = getActiveConfigurationRecord();
const redacted = redactConfiguration(record.config);
return { ...record, ...redacted };
}
function updateConfiguration({ value, expectedRevision, secretOperations = {}, actor }) {
const active = getActiveConfigurationRecord();
const candidate = clone(value);
/*
The browser edits one complete document, but its copy contains blank
placeholders in place of every stored secret. Restore all current secret
values first, then apply only explicit replace or clear operations. This
keeps the full-document save model simple without ever sending an
existing credential back to the browser.
*/
secretPaths.forEach((secretPath) => {
setAtPath(candidate, secretPath, getAtPath(active.config, secretPath));
const operation = secretOperations[secretPath];
if (!operation) return;
if (operation.action === 'clear') setAtPath(candidate, secretPath, '');
else if (operation.action === 'replace' && typeof operation.value === 'string' && operation.value.length > 0) {
setAtPath(candidate, secretPath, operation.value);
} else {
throw new Error(`Invalid secret operation for ${secretPath}.`);
}
});
return commitRevisionTransaction(candidate, {
expectedRevision,
actor,
source: 'admin-ui',
});
}
function listConfigurationRevisions({ limit = 100 } = {}) {
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 100)));
return db.prepare(`
SELECT id, created_at, actor, source
FROM configuration_revisions
ORDER BY id DESC
LIMIT ?
`).all(safeLimit).map((row) => ({
revision: Number(row.id),
createdAt: Number(row.created_at),
actor: row.actor,
source: row.source,
}));
}
function restoreConfigurationRevision({ revision, expectedRevision, actor }) {
const row = db.prepare('SELECT config_json FROM configuration_revisions WHERE id = ?').get(Number(revision));
if (!row) throw new Error('Configuration revision not found.');
const restoredConfig = JSON.parse(row.config_json);
return commitRevisionTransaction(restoredConfig, {
expectedRevision,
actor,
source: `rollback-from-${Number(revision)}`,
});
}
function listAdministrators() {
return db.prepare(`
SELECT id, username, discord_id, role, created_at, updated_at
FROM administrators
ORDER BY username COLLATE NOCASE
`).all().map((row) => ({
id: Number(row.id),
username: row.username,
discordId: row.discord_id || '',
role: row.role,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}));
}
function findAdministratorForAuthentication(username) {
const normalized = String(username || '').trim();
if (!normalized) return null;
const row = db.prepare(`
SELECT id, username, password_hash, discord_id, role
FROM administrators
WHERE username = ? COLLATE NOCASE
`).get(normalized);
if (!row) return null;
return {
id: Number(row.id),
username: row.username,
passwordHash: row.password_hash,
discordId: row.discord_id || '',
role: row.role,
};
}
function countLockdownAdministrators() {
return Number(db.prepare("SELECT COUNT(*) AS count FROM administrators WHERE role = 'lockdown'").get().count);
}
const createAdministratorTransaction = db.transaction((admin, actor, audit = true) => {
const username = normalizeUsername(admin.username);
const role = normalizeRole(admin.role);
const passwordHash = String(admin.passwordHash || '').trim();
if (!passwordHash) throw new Error('Administrator password hash is required.');
const now = Date.now();
const result = db.prepare(`
INSERT INTO administrators (username, password_hash, discord_id, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(username, passwordHash, String(admin.discordId || '').trim() || null, role, now, now);
if (audit) writeAudit(actor, 'administrator.created', { administratorId: Number(result.lastInsertRowid), username, role });
return Number(result.lastInsertRowid);
});
function createAdministrator(admin, actor = 'system') {
const id = createAdministratorTransaction(admin, actor, true);
return listAdministrators().find((entry) => entry.id === id);
}
const updateAdministratorTransaction = db.transaction((id, changes, actor) => {
const current = db.prepare('SELECT * FROM administrators WHERE id = ?').get(Number(id));
if (!current) throw new Error('Administrator not found.');
const username = changes.username == null ? current.username : normalizeUsername(changes.username);
const role = changes.role == null ? current.role : normalizeRole(changes.role);
const discordId = changes.discordId == null ? current.discord_id : String(changes.discordId || '').trim() || null;
const passwordHash = changes.passwordHash == null ? current.password_hash : String(changes.passwordHash || '').trim();
if (!passwordHash) throw new Error('Administrator password hash is required.');
if (current.role === 'lockdown' && role !== 'lockdown' && countLockdownAdministrators() <= 1) {
throw new Error('The final lockdown administrator cannot be demoted.');
}
db.prepare(`
UPDATE administrators
SET username = ?, password_hash = ?, discord_id = ?, role = ?, updated_at = ?
WHERE id = ?
`).run(username, passwordHash, discordId, role, Date.now(), Number(id));
writeAudit(actor, 'administrator.updated', { administratorId: Number(id), username, role, passwordChanged: changes.passwordHash != null });
});
function updateAdministrator(id, changes, actor) {
updateAdministratorTransaction(id, changes || {}, actor || 'system');
return listAdministrators().find((entry) => entry.id === Number(id));
}
const deleteAdministratorTransaction = db.transaction((id, actor) => {
const current = db.prepare('SELECT * FROM administrators WHERE id = ?').get(Number(id));
if (!current) throw new Error('Administrator not found.');
if (current.role === 'lockdown' && countLockdownAdministrators() <= 1) {
throw new Error('The final lockdown administrator cannot be removed.');
}
db.prepare('DELETE FROM administrators WHERE id = ?').run(Number(id));
writeAudit(actor, 'administrator.deleted', { administratorId: Number(id), username: current.username, role: current.role });
});
function deleteAdministrator(id, actor = 'system') {
deleteAdministratorTransaction(id, actor);
}
function isSetupComplete() {
return countLockdownAdministrators() > 0;
}
function listAuditEvents({ limit = 200 } = {}) {
const safeLimit = Math.max(1, Math.min(1000, Math.floor(Number(limit) || 200)));
return db.prepare(`
SELECT id, created_at, actor, action, details_json
FROM administrative_audit_events
ORDER BY id DESC
LIMIT ?
`).all(safeLimit).map((row) => ({
id: Number(row.id),
createdAt: Number(row.created_at),
actor: row.actor,
action: row.action,
details: JSON.parse(row.details_json),
}));
}
const importConfigurationFileTransaction = db.transaction(({ config, administrators, actor, source }) => {
// A setup upload initializes an empty installation; it is deliberately not
// a general-purpose replacement path for a running server's configuration.
if (isSetupComplete()) throw new Error('A configuration file cannot replace an initialized installation.');
const normalized = assertValidConfig(normalizeConfig(config));
const revision = commitRevisionTransaction(normalized, {
expectedRevision: getActiveConfigurationRecord().revision,
actor,
source,
});
administrators.forEach((admin) => createAdministratorTransaction(admin, actor, false));
if (!isSetupComplete()) throw new Error('The configuration file must contain at least one lockdown administrator.');
writeAudit(actor, 'setup.configuration-file-imported', { revision, administratorCount: administrators.length, source });
return revision;
});
function importConfigurationFile(payload) {
return importConfigurationFileTransaction(payload);
}
return {
databasePath,
getActiveConfigurationRecord,
getClientConfiguration,
updateConfiguration,
listConfigurationRevisions,
restoreConfigurationRevision,
listAdministrators,
findAdministratorForAuthentication,
createAdministrator,
updateAdministrator,
deleteAdministrator,
countLockdownAdministrators,
isSetupComplete,
listAuditEvents,
importConfigurationFile,
close: () => db.close(),
};
}
module.exports = {
DEFAULT_DATABASE_PATH,
createConfigurationDatabase,
redactConfiguration,
};
+123
View File
@@ -0,0 +1,123 @@
// Complete Configuration Definition
// Purpose: Assembles service-owned configuration fragments into the one ordered document used by storage, validation, and the admin UI.
// Scope: Controls top-level order and composition only; each owning service defines the meaning, defaults, and schema of its own values.
const { strictObject } = require('./schemaHelpers');
const sessionConfiguration = require('../services/sessionService/configuration');
const interInstance = require('../services/interInstanceService/configuration');
const llmCommentary = require('../services/llmCommentaryService/configuration');
const overseerControl = require('../services/overseerControlService/configuration');
const barcodeGames = require('../services/barcodeGameService/configuration');
const media = require('../services/mediaMtxService/configuration');
const bandwidthSavings = require('../helpers/bandwidthSavings.configuration');
const audioForward = require('../services/audioForwardService/configuration');
const audioLevels = require('../services/audioLevelsService/configuration');
const homeAssistant = require('../services/homeAssistantService/configuration');
const roomCameras = require('../services/roomCameraService/configuration');
const ptzCamera = require('../services/ptzCameraService/configuration');
const kinect = require('../services/kinectService/configuration');
const balanceBoard = require('../services/balanceBoardService/configuration');
const buttonBox = require('../services/buttonBoxService/configuration');
const barcodeScanner = require('../services/barcodeScannerService/configuration');
const commands = require('../services/operatorCommandService/configuration');
const discord = require('../services/discordBotService/configuration');
const fleetReports = require('../services/fleetReportService/configuration');
/*
Object property order is preserved by JSON serialization and JSON Schema
consumers. Keeping this explicit list in legacy-YAML order makes the generic
admin form predictable without creating a second frontend ordering system.
The session service owns three non-adjacent public-presentation values, so
those fragments are placed independently at their historical positions.
*/
const definitions = [
sessionConfiguration.timezone,
interInstance,
llmCommentary,
overseerControl,
barcodeGames,
media,
bandwidthSavings,
audioForward,
audioLevels,
homeAssistant,
roomCameras,
ptzCamera,
kinect,
balanceBoard,
buttonBox,
barcodeScanner,
commands,
discord,
sessionConfiguration.socials,
sessionConfiguration.driverAd,
fleetReports,
];
const defaultConfig = Object.fromEntries(
definitions.map(({ key, defaultValue }) => [key, defaultValue]),
);
const properties = Object.fromEntries(
definitions.map(({ key, schema }) => [key, schema]),
);
const rootSchema = strictObject(properties, {
title: 'Configuration',
required: definitions.map(({ key }) => key),
});
function collectFeatureDefinitions(definition, parentPath = []) {
const configPath = [...parentPath, definition.key];
const features = [];
if (definition.feature === true) {
/*
A feature declaration is intentionally only a boolean marker. Its public
name is the configuration item's key and its value is that item's own
enabled field, so a service cannot introduce a second enablement rule in
metadata. Failing during definition assembly catches an invalid marker at
startup instead of publishing an undefined capability to browsers.
*/
if (definition.schema?.properties?.enabled?.type !== 'boolean') {
throw new Error(`Configuration feature ${definition.key} must define a boolean enabled field.`);
}
features.push({ key: definition.key, path: [...configPath, 'enabled'] });
}
const nestedDefinitions = Array.isArray(definition.nestedDefinitions)
? definition.nestedDefinitions
: [];
nestedDefinitions.forEach((nestedDefinition) => {
features.push(...collectFeatureDefinitions(nestedDefinition, configPath));
});
return features;
}
/*
This derived list replaces the old hand-maintained feature registry. Top-level
and nested configuration owners opt in beside their schema, while this module
only preserves their already-declared document paths.
*/
const featureDefinitions = definitions.flatMap((definition) => collectFeatureDefinitions(definition));
function collectWriteOnlyPaths(schema, prefix = '') {
/*
Secrets are declared once, beside the service field that consumes them.
Walking object properties produces the dotted paths needed for redaction
and update handling without maintaining a parallel secret registry.
*/
if (!schema || typeof schema !== 'object') return [];
if (schema.writeOnly === true) return prefix ? [prefix] : [];
if (schema.type !== 'object' || !schema.properties) return [];
return Object.entries(schema.properties).flatMap(([key, childSchema]) => (
collectWriteOnlyPaths(childSchema, prefix ? `${prefix}.${key}` : key)
));
}
const secretPaths = collectWriteOnlyPaths(rootSchema);
module.exports = {
definitions,
defaultConfig,
rootSchema,
secretPaths,
featureDefinitions,
};
+69
View File
@@ -0,0 +1,69 @@
// Configuration Service
// Purpose: Exposes the process-wide synchronous configuration snapshot and the underlying administration store.
// Scope: Keeps existing require-time startup semantics while making SQLite the only runtime configuration source.
const { createConfigurationDatabase } = require('./database');
const { rootSchema, featureDefinitions } = require('./definition');
let singleton;
let runtimeConfigurationRevision = null;
function getConfigurationDatabase() {
if (!singleton) {
singleton = createConfigurationDatabase();
/*
Capture the active revision once when the process opens its configuration
store. Later admin saves are intentionally restart-bound, so comparing
against this value gives every reconnecting browser an authoritative
pending-restart indicator.
*/
runtimeConfigurationRevision = singleton.getActiveConfigurationRecord().revision;
}
return singleton;
}
function getRuntimeConfigurationRevision() {
getConfigurationDatabase();
return runtimeConfigurationRevision;
}
function loadConfig() {
/*
Services intentionally receive one coherent snapshot for this process.
Configuration commits are restart-bound, so re-reading during runtime would
let only some modules observe the new revision and create a split-brain
process. The database remains queryable through its administrative API.
*/
if (!loadConfig.cached) {
loadConfig.cached = Object.freeze(getConfigurationDatabase().getActiveConfigurationRecord().config);
}
return loadConfig.cached;
}
function getValueAtPath(value, path) {
return path.reduce((current, key) => current?.[key], value);
}
function getFeatureFlags(config = loadConfig()) {
/*
Feature definitions come directly from service-owned configuration metadata.
Returning an explicit boolean map preserves the existing public session
contract while ensuring the item's enabled field is its only source.
*/
return Object.fromEntries(featureDefinitions.map(({ key, path }) => [
key,
Boolean(getValueAtPath(config, path)),
]));
}
function isFeatureEnabled(featureName) {
return Boolean(getFeatureFlags()[featureName]);
}
module.exports = {
getConfigurationDatabase,
getRuntimeConfigurationRevision,
loadConfig,
getFeatureFlags,
isFeatureEnabled,
rootSchema,
};
+69
View File
@@ -0,0 +1,69 @@
// Configuration Database Migrations
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation.
const migrations = [
{
version: 1,
sql: `
CREATE TABLE configuration_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
config_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
actor TEXT NOT NULL,
source TEXT NOT NULL
);
CREATE TABLE configuration_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
active_revision_id INTEGER NOT NULL REFERENCES configuration_revisions(id)
);
CREATE TABLE administrators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
password_hash TEXT NOT NULL,
discord_id TEXT,
role TEXT NOT NULL CHECK (role IN ('admin', 'lockdown')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE administrative_audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
details_json TEXT NOT NULL
);
`,
},
];
function applySchemaMigrations(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
`);
const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
const record = db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)');
migrations.forEach((migration) => {
if (applied.has(migration.version)) return;
/*
Schema SQL and its version marker are one transaction. A process failure
can therefore retry the migration cleanly instead of finding a partially
changed database whose version incorrectly appears current.
*/
db.transaction(() => {
db.exec(migration.sql);
record.run(migration.version, Date.now());
})();
});
}
module.exports = {
migrations,
applySchemaMigrations,
};
+57
View File
@@ -0,0 +1,57 @@
// Configuration Schema Helpers
// Purpose: Keeps repetitive declarations in the complete strict JSON Schema readable.
// Scope: Defines schema-building helpers only; validation and default application remain separate responsibilities.
function strictObject(properties, options = {}) {
/*
Configuration objects reject unknown keys at every level. A misspelled
operator setting must fail loudly instead of looking saved while the server
silently falls back to another value.
*/
return {
type: 'object',
additionalProperties: false,
properties,
...(options.title ? { title: options.title } : {}),
...(options.description ? { description: options.description } : {}),
...(Array.isArray(options.required) ? { required: options.required } : {}),
};
}
function string(options = {}) {
return { type: 'string', ...options };
}
function nullableString(options = {}) {
return { type: ['string', 'null'], ...options };
}
function boolean(options = {}) {
return { type: 'boolean', ...options };
}
function integer(options = {}) {
return { type: 'integer', ...options };
}
function number(options = {}) {
return { type: 'number', ...options };
}
function stringArray(options = {}) {
return {
type: 'array',
items: string(options.item || {}),
...options.array,
};
}
module.exports = {
strictObject,
string,
nullableString,
boolean,
integer,
number,
stringArray,
};
+78
View File
@@ -0,0 +1,78 @@
// Configuration Validation
// Purpose: Validates and normalizes the one hierarchical configuration document.
// Scope: Owns reusable validation behavior for the complete schema assembled from service definitions.
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const { defaultConfig, rootSchema, secretPaths } = require('./definition');
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function mergeDefaults(defaultValue, suppliedValue) {
/*
Arrays are complete ordered values and must never be merged item-by-item.
Plain objects recurse so a stored document can omit a newly introduced
field and receive its safe default without discarding neighboring values.
Unknown supplied keys are retained here so strict schema validation can
report them instead of silently deleting operator input.
*/
if (Array.isArray(suppliedValue)) return clone(suppliedValue);
if (!suppliedValue || typeof suppliedValue !== 'object' || Array.isArray(defaultValue)) {
return suppliedValue === undefined ? clone(defaultValue) : suppliedValue;
}
const result = clone(defaultValue);
for (const [key, value] of Object.entries(suppliedValue)) {
const fallback = defaultValue && typeof defaultValue === 'object' ? defaultValue[key] : undefined;
result[key] = mergeDefaults(fallback, value);
}
return result;
}
const ajv = new Ajv({ allErrors: true, strict: true });
addFormats(ajv);
const validate = ajv.compile(rootSchema);
function formatValidationErrors(errors = []) {
return errors.map((error) => ({
/*
Ajv uses JSON Pointer instance paths. Prefixing an additional-property
name makes the error point at the actual rejected field rather than only
its containing object, which is more useful in the hierarchical form.
*/
path: error.keyword === 'additionalProperties'
? `${error.instancePath}/${error.params.additionalProperty}`
: error.instancePath || '/',
message: error.message || 'Invalid value',
keyword: error.keyword,
}));
}
function normalizeConfig(input = {}) {
return mergeDefaults(defaultConfig, input);
}
function assertValidConfig(input) {
if (validate(input)) return input;
const error = new Error('Configuration validation failed.');
error.code = 'CONFIG_VALIDATION_FAILED';
error.validationErrors = formatValidationErrors(validate.errors);
throw error;
}
/*
Defaults are executable configuration, not documentation. Validate them at
module load so a definition edit cannot make first boot fail later in an
unrelated service require chain.
*/
assertValidConfig(defaultConfig);
module.exports = {
defaultConfig,
secretPaths,
rootSchema,
clone,
normalizeConfig,
assertValidConfig,
};
@@ -0,0 +1,25 @@
// Bandwidth-Savings Configuration
// Purpose: Defines the server-owned live-video and snapshot policy interpreted by this helper.
// Scope: Exports configuration metadata without reading sessions or calculating policy.
const { strictObject, string, boolean, integer } = require('../configuration/schemaHelpers');
module.exports = {
key: 'bandwidthSavings',
defaultValue: {
multiTabProtection: 'verifiedOnly',
pauseHiddenRoverVideo: false,
nonTurnVideo: { mode: 'snapshots', userThreshold: 0 },
externalSpectatorVideo: 'snapshots',
externalSpectatorAccess: 'on',
},
schema: strictObject({
multiTabProtection: string({ enum: ['allowed', 'verifiedOnly', 'notAllowed'] }),
pauseHiddenRoverVideo: boolean(),
nonTurnVideo: strictObject({
mode: string({ enum: ['snapshots', 'live'] }),
userThreshold: integer({ minimum: 0, maximum: 100000 }),
}, { required: ['mode', 'userThreshold'] }),
externalSpectatorVideo: string({ enum: ['snapshots', 'live'] }),
externalSpectatorAccess: string({ enum: ['off', 'on', 'verifiedOnly', 'admin'] }),
}, { title: 'Bandwidth savings', required: ['multiTabProtection', 'pauseHiddenRoverVideo', 'nonTurnVideo', 'externalSpectatorVideo', 'externalSpectatorAccess'] }),
};
+9 -9
View File
@@ -1,8 +1,8 @@
// Bandwidth Savings Helper
// Purpose: Normalizes bandwidth-saving config and exposes tiny policy helpers.
// Scope: Keeps cross-service video/tab/spectator decisions consistent without
// making individual services know raw YAML defaults or legacy config shapes.
const { loadConfig } = require('./configLoader');
// making individual services duplicate the validated database configuration contract.
const { loadConfig } = require('../configuration');
const MULTI_TAB_MODES = new Set(['allowed', 'verifiedOnly', 'notAllowed']);
const VIDEO_MODES = new Set(['snapshots', 'live']);
@@ -21,9 +21,9 @@ const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
function normalizeEnum(value, allowed, fallback) {
/*
Config files are hand-edited on the server, so a typo should not crash the
process or silently broaden access. Each option falls back to the current
conservative behavior unless it exactly matches a known value.
Tests and direct helper callers can still supply incomplete objects even
though the database rejects invalid persisted values. Conservative fallback
here keeps policy behavior safe at that secondary boundary.
*/
const normalized = typeof value === 'string' ? value.trim() : '';
return allowed.has(normalized) ? normalized : fallback;
@@ -31,9 +31,9 @@ function normalizeEnum(value, allowed, fallback) {
function normalizeBoolean(value, fallback) {
/*
YAML booleans must stay real booleans. Treating strings such as "false" as
truthy would silently enable a bandwidth policy that the operator intended
to disable, so invalid values fall back to the documented server default.
Treating strings such as "false" as truthy would silently enable a policy.
Persisted values are schema-validated, while this guard protects direct
helper calls and focused tests from the same JavaScript coercion trap.
*/
return typeof value === 'boolean' ? value : fallback;
}
@@ -83,7 +83,7 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
function getBandwidthSavingsPolicy() {
/*
loadConfig() is cached by configLoader, so rebuilding this small object per
loadConfig() is cached by configuration service, so rebuilding this small object per
caller is cheap while still letting tests pass explicit config objects into
buildBandwidthSavingsPolicy().
*/
-22
View File
@@ -1,22 +0,0 @@
// Config Loader Helper
// Purpose: Loads and validates YAML server configuration from configured paths. Scope: Provides normalized config access with sane defaults and cache behavior.
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const CONFIG_PATH = process.env.SERVER_CONFIG || path.join(__dirname, '..', '..', 'config.yaml');
let cachedConfig;
function loadConfig() {
if (cachedConfig) {
return cachedConfig;
}
const file = fs.readFileSync(CONFIG_PATH, 'utf8');
cachedConfig = yaml.load(file);
return cachedConfig;
}
module.exports = {
loadConfig,
};
-126
View File
@@ -1,126 +0,0 @@
// Feature Flags Helper
// Purpose: Normalizes optional server feature availability from config in one place.
// Scope: Keeps hardware/social visibility decisions out of individual UI panels and service callers.
const { loadConfig } = require('./configLoader');
function asBoolean(value, fallback = false) {
/*
Optional feature config is intentionally explicit. A missing `enabled` flag
means "off" for specialty hardware, which makes a fresh public install a
rover-only server until the operator opts into extra devices.
*/
if (typeof value === 'boolean') return value;
return fallback;
}
function asTrimmedString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function getRoomCameraEntries(config) {
const raw = config.roomCameras;
/*
The public config uses `{ enabled, cameras }` so the feature gate is obvious.
Accepting the old array shape here keeps the rest of the server from needing
to know which shape the local config file currently uses.
*/
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === 'object' && Array.isArray(raw.cameras)) return raw.cameras;
return [];
}
function getConfiguredSocials(config) {
/*
Social links have an explicit feature switch. Entries under `links` are just
available data; they do not enable the Links panel by existing.
*/
const links = config.socials && typeof config.socials === 'object' ? config.socials.links : [];
return Array.isArray(links)
? links.filter((entry) => asTrimmedString(entry?.url))
: [];
}
function buildFeatureFlags(config = loadConfig()) {
const homeAssistantConfig = config.homeAssistant || {};
const roomCameraConfig = config.roomCameras || {};
const kinectConfig = config.kinect || {};
const buttonBoxConfig = config.buttonBox || {};
const barcodeScannerConfig = config.barcodeScanner || {};
const balanceBoardConfig = config.balanceBoard || {};
const barcodeGamesConfig = config.barcodeGames || {};
const socialsConfig = config.socials || {};
const interInstanceConfig = config.interInstance || {};
const ptzCameraConfig = config.ptzCamera || {};
const discordConfig = config.discord || {};
const fleetReportsConfig = config.fleetReports || {};
const homeAssistant = Boolean(
asBoolean(homeAssistantConfig.enabled) &&
asTrimmedString(homeAssistantConfig.url) &&
asTrimmedString(homeAssistantConfig.token),
);
const roomCameraEntries = getRoomCameraEntries(config);
const roomCamerasEnabled = Array.isArray(config.roomCameras)
? false
: asBoolean(roomCameraConfig.enabled);
const barcodeScanner = asBoolean(barcodeScannerConfig.enabled);
return {
homeAssistant,
roomCameras: Boolean(roomCamerasEnabled && roomCameraEntries.length),
kinect: asBoolean(kinectConfig.enabled),
buttonBox: asBoolean(buttonBoxConfig.enabled),
barcodeScanner,
// The worker performs its own runtime availability reporting. Advertising
// the feature from the explicit config switch lets the UI show useful
// commissioning and hardware-error states even before a board is paired.
balanceBoard: asBoolean(balanceBoardConfig.enabled),
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
lift: Boolean(
homeAssistant &&
asBoolean(homeAssistantConfig.lift?.enabled) &&
asTrimmedString(homeAssistantConfig.lift?.upSwitch) &&
asTrimmedString(homeAssistantConfig.lift?.downSwitch),
),
neato: Boolean(
homeAssistant &&
asBoolean(homeAssistantConfig.neato?.enabled) &&
asTrimmedString(homeAssistantConfig.neato?.device),
),
socials: Boolean(asBoolean(socialsConfig.enabled) && getConfiguredSocials(config).length > 0),
interInstance: asBoolean(interInstanceConfig.enabled),
ptzCamera: Boolean(
asBoolean(ptzCameraConfig.enabled) &&
asTrimmedString(ptzCameraConfig.host) &&
asTrimmedString(ptzCameraConfig.username) &&
asTrimmedString(ptzCameraConfig.password),
),
/*
Discord is an optional transport, not a prerequisite for chat commands.
Requiring both the explicit switch and a token prevents an old token from
silently enabling external connections on installations that have chosen
to run without the integration.
*/
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
// Fleet reports are deliberately controlled by one explicit server switch.
// Storage contents, Discord availability, or historical database files must
// never cause the reporting UI to appear on an installation that has not
// opted into the collector.
fleetReports: asBoolean(fleetReportsConfig.enabled),
};
}
function getFeatureFlags() {
return buildFeatureFlags(loadConfig());
}
function isFeatureEnabled(featureName) {
return Boolean(getFeatureFlags()[featureName]);
}
module.exports = {
buildFeatureFlags,
getFeatureFlags,
isFeatureEnabled,
getRoomCameraEntries,
getConfiguredSocials,
};
+1 -1
View File
@@ -1,7 +1,7 @@
// Site Metadata Helper
// Purpose: Resolves the public name, description, and colors used before the web UI starts.
// Scope: Keeps document/PWA branding server-rendered and independent of Socket.IO session state.
const { loadConfig } = require('./configLoader');
const { loadConfig } = require('../configuration');
const DEFAULT_SITE_METADATA = Object.freeze({
name: 'Multi Roomba Rover',
@@ -0,0 +1,158 @@
// Administrative Configuration Service
// Purpose: Exposes lockdown-only configuration, administrator, revision, and audit operations to the admin application.
// Scope: Owns socket authorization and password confirmation while delegating persistence invariants to the configuration database.
const bcrypt = require('bcrypt');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('adminConfigurationService');
const {
getConfigurationDatabase,
getRuntimeConfigurationRevision,
rootSchema,
} = require('../../configuration');
const { getRole } = require('../roleService');
const PASSWORD_CONFIRMATION_WINDOW_MS = 5 * 60 * 1000;
const database = getConfigurationDatabase();
function requireLockdownAdministrator(socket) {
if (getRole(socket) !== 'lockdown') throw new Error('Lockdown administrator required.');
}
function requireRecentPassword(socket) {
requireLockdownAdministrator(socket);
const confirmedAt = Number(socket?.data?.adminPasswordConfirmedAt) || 0;
if (Date.now() - confirmedAt > PASSWORD_CONFIRMATION_WINDOW_MS) {
const error = new Error('Confirm your password to continue.');
error.code = 'PASSWORD_CONFIRMATION_REQUIRED';
throw error;
}
}
function actorFor(socket) {
return socket?.data?.user?.username || socket.id;
}
function errorPayload(error) {
return {
error: error.message,
code: error.code || null,
validationErrors: error.validationErrors || null,
currentRevision: error.currentRevision || null,
};
}
function ackHandler(socket, eventName, authorization, handler) {
socket.on(eventName, (payload = {}, cb = () => {}) => {
Promise.resolve()
.then(() => authorization(socket))
.then(() => handler(payload || {}))
.then((result) => cb({ success: true, ...result }))
.catch((error) => {
logger.warn('Administrative configuration request failed', {
eventName,
socketId: socket.id,
actor: actorFor(socket),
error: error.message,
});
cb(errorPayload(error));
});
});
}
function buildAdminSnapshot() {
const configuration = database.getClientConfiguration();
return {
/*
The protected admin response carries the same schema used by server-side
Ajv validation. It contains structure and help metadata but never stored
values, allowing the browser to render configuration without maintaining
a second field definition.
*/
configuration: { ...configuration, schema: rootSchema },
restartRequired: configuration.revision !== getRuntimeConfigurationRevision(),
administrators: database.listAdministrators(),
revisions: database.listConfigurationRevisions(),
auditEvents: database.listAuditEvents(),
};
}
io.on('connection', (socket) => {
ackHandler(socket, 'adminConfig:get', requireLockdownAdministrator, () => buildAdminSnapshot());
ackHandler(socket, 'adminConfig:confirmPassword', requireLockdownAdministrator, async ({ password }) => {
const admin = database.findAdministratorForAuthentication(socket?.data?.user?.username);
if (!admin || !(await bcrypt.compare(String(password || ''), admin.passwordHash))) {
throw new Error('Invalid credentials.');
}
socket.data.adminPasswordConfirmedAt = Date.now();
return { confirmedUntil: socket.data.adminPasswordConfirmedAt + PASSWORD_CONFIRMATION_WINDOW_MS };
});
ackHandler(socket, 'adminConfig:updateConfiguration', requireRecentPassword, (payload) => {
const revision = database.updateConfiguration({
value: payload.value,
expectedRevision: payload.expectedRevision,
secretOperations: payload.secretOperations,
actor: actorFor(socket),
});
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true };
});
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, (payload) => {
const revision = database.restoreConfigurationRevision({
revision: payload.revision,
expectedRevision: payload.expectedRevision,
actor: actorFor(socket),
});
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true };
});
ackHandler(socket, 'adminConfig:createAdministrator', requireRecentPassword, async (payload) => {
const password = String(payload.password || '');
if (password.length < 10) throw new Error('Administrator password must be at least 10 characters.');
const administrator = database.createAdministrator({
username: payload.username,
passwordHash: await bcrypt.hash(password, 12),
discordId: payload.discordId,
role: payload.role,
}, actorFor(socket));
return { administrator, snapshot: buildAdminSnapshot() };
});
ackHandler(socket, 'adminConfig:updateAdministrator', requireRecentPassword, async (payload) => {
const authenticatedAdministrator = database.findAdministratorForAuthentication(socket?.data?.user?.username);
const changes = {
username: payload.username,
discordId: payload.discordId,
role: payload.role,
};
if (payload.password) {
if (String(payload.password).length < 10) throw new Error('Administrator password must be at least 10 characters.');
changes.passwordHash = await bcrypt.hash(String(payload.password), 12);
}
const administrator = database.updateAdministrator(payload.id, changes, actorFor(socket));
if (authenticatedAdministrator?.id === administrator.id) {
/*
Keep the current authenticated identity aligned after a self-edit. If
the username changed but the socket retained the old name, its next
password confirmation could never find the account it just updated.
*/
socket.data.user = {
...(socket.data.user || {}),
username: administrator.username,
discordId: administrator.discordId,
};
}
return { administrator, snapshot: buildAdminSnapshot() };
});
ackHandler(socket, 'adminConfig:deleteAdministrator', requireRecentPassword, ({ id }) => {
database.deleteAdministrator(id, actorFor(socket));
return { snapshot: buildAdminSnapshot() };
});
});
module.exports = {
PASSWORD_CONFIRMATION_WINDOW_MS,
requireLockdownAdministrator,
};
@@ -0,0 +1,15 @@
// Audio-Forwarding Configuration
// Purpose: Defines upload bounds and the ffmpeg publishing command inputs.
// Scope: Contains configuration metadata only and never creates runtime FIFOs.
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
module.exports = {
key: 'audioForward',
defaultValue: { enabled: true, ffmpegBin: 'ffmpeg', streamSuffix: '-fwd', maxUploadBytes: 8388608 },
schema: strictObject({
enabled: boolean(),
ffmpegBin: string({ title: 'ffmpeg executable', minLength: 1, maxLength: 500 }),
streamSuffix: string({ minLength: 1, maxLength: 80 }),
maxUploadBytes: integer({ minimum: 262144, maximum: 1073741824 }),
}, { title: 'Audio forwarding', required: ['enabled', 'ffmpegBin', 'streamSuffix', 'maxUploadBytes'] }),
};
@@ -5,7 +5,7 @@ const path = require('path');
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../helpers/configLoader');
const { loadConfig } = require('../../configuration');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const turnService = require('../turnService');
@@ -20,7 +20,10 @@ const audioForwardEvents = new EventEmitter();
const config = loadConfig();
const audioForwardConfig = config.audioForward || {};
const mediaConfig = config.media || {};
const serviceEnabled = audioForwardConfig.enabled !== false;
// Configuration defaults always provide this boolean. Treat only an explicit
// true as enabled so no credential, path, or historical fallback can opt the
// service in on the operator's behalf.
const serviceEnabled = Boolean(audioForwardConfig.enabled);
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
@@ -0,0 +1,15 @@
// Audio-Level Configuration
// Purpose: Defines server base gains and the permitted personal adjustment range.
// Scope: Exports only defaults and validation metadata.
const { strictObject, number, integer } = require('../../configuration/schemaHelpers');
module.exports = {
key: 'audioLevels',
defaultValue: { hornGain: 1, ttsGain: 1, forwardGain: 1, maxPersonalAdjustmentPercent: 50 },
schema: strictObject({
hornGain: number({ minimum: 0, maximum: 4 }),
ttsGain: number({ title: 'TTS gain', minimum: 0, maximum: 4 }),
forwardGain: number({ minimum: 0, maximum: 4 }),
maxPersonalAdjustmentPercent: integer({ minimum: 0, maximum: 100 }),
}, { title: 'Audio levels', required: ['hornGain', 'ttsGain', 'forwardGain', 'maxPersonalAdjustmentPercent'] }),
};
@@ -5,7 +5,7 @@ const fs = require('fs');
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../../helpers/configLoader');
const { loadConfig } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin, roleEvents } = require('../roleService');
const roverManager = require('../roverManager');
+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),
+106
View File
@@ -0,0 +1,106 @@
// First-Run Setup Service
// Purpose: Allows an empty data directory to create its first lockdown administrator or import an explicitly uploaded YAML file.
// 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 { importConfigurationFile } = require('../../configuration/configurationFileImporter');
const { createSetupCodeFile } = require('./setupCodeFile');
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
const database = getConfigurationDatabase();
const setupCodeFile = createSetupCodeFile();
let setupNoticeLogged = false;
function isSetupRequired() {
return !database.isSetupComplete();
}
function ensureSetupCode() {
if (!isSetupRequired()) {
// Setup authorization permanently closes when the first lockdown account
// exists. Remove a stale credential left by an interrupted final response.
setupCodeFile.remove();
return null;
}
const code = setupCodeFile.ensure();
// Logs may be retained or shipped elsewhere, so they identify the local file
// containing the credential without ever including the credential itself.
if (!setupNoticeLogged) {
logger.warn('First-run setup is required', { setupCodePath: setupCodeFile.filePath });
setupNoticeLogged = true;
}
return code;
}
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');
setupCodeFile.remove();
return { administrator };
});
});
socket.on('setup:importConfigurationFile', (payload = {}, cb = () => {}) => {
respond(cb, () => {
requireOpenSetup(payload.setupCode);
const yamlText = String(payload.yaml || '');
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_CONFIGURATION_FILE_BYTES) {
throw new Error('The YAML configuration file must be present and no larger than 1 MiB.');
}
const result = importConfigurationFile({
text: yamlText,
database,
actor: 'first-run-setup',
source: String(payload.fileName || 'uploaded-config.yaml').slice(0, 255),
});
setupCodeFile.remove();
return result;
});
});
});
module.exports = {
isSetupRequired,
};
@@ -0,0 +1,74 @@
// Setup Code File
// Purpose: Persists the one-time first-run credential inside the server data directory.
// Scope: Owns secure file creation, validation, reuse, and removal without knowing whether setup is complete.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { resolveDataPath } = require('../../helpers/dataPaths');
const DEFAULT_SETUP_CODE_PATH = resolveDataPath('setup-code.txt');
const SETUP_CODE_PATTERN = /^[0-9a-f]{12}$/;
function createSetupCodeFile({ filePath = DEFAULT_SETUP_CODE_PATH } = {}) {
function read() {
const fileStats = fs.lstatSync(filePath);
if (!fileStats.isFile()) {
// In particular, reject symbolic links before chmod or read operations so
// a writable data directory cannot redirect setup handling to another file.
throw new Error(`Setup code path is not a regular file: ${filePath}`);
}
fs.chmodSync(filePath, 0o600);
const code = fs.readFileSync(filePath, 'utf8').trim().toLowerCase();
if (!SETUP_CODE_PATTERN.test(code)) {
/*
Never silently replace a malformed credential. An operator may already
be reading that file, and changing it behind their back would make setup
failures mysterious while concealing possible filesystem corruption.
*/
throw new Error(`Setup code file is invalid: ${filePath}`);
}
return code;
}
function ensure() {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
if (fs.existsSync(filePath)) {
return read();
}
const code = crypto.randomBytes(6).toString('hex');
try {
/*
Exclusive creation prevents two accidentally overlapping server starts
from overwriting one another's setup credential. The file is the source
an operator reads, so the accepted code must always match its contents.
*/
fs.writeFileSync(filePath, `${code}\n`, {
encoding: 'utf8',
flag: 'wx',
mode: 0o600,
});
return code;
} catch (error) {
if (error.code !== 'EEXIST') throw error;
return read();
}
}
function remove() {
fs.rmSync(filePath, { force: true });
}
return {
filePath,
ensure,
read,
remove,
};
}
module.exports = {
DEFAULT_SETUP_CODE_PATH,
SETUP_CODE_PATTERN,
createSetupCodeFile,
};
@@ -0,0 +1,61 @@
// Setup Code File Tests
// Purpose: Verifies the first-run credential remains private, stable, and removable.
// Scope: Uses an isolated operating-system temporary directory and never touches development server data.
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const test = require('node:test');
const { SETUP_CODE_PATTERN, createSetupCodeFile } = require('./setupCodeFile');
const temporaryRoots = [];
function createTestStore() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-setup-code-'));
temporaryRoots.push(root);
return createSetupCodeFile({ filePath: path.join(root, 'setup-code.txt') });
}
test.after(() => {
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
});
test('creates one owner-readable code and reuses it across startup initialization', () => {
const store = createTestStore();
const firstCode = store.ensure();
const secondCode = store.ensure();
assert.match(firstCode, SETUP_CODE_PATTERN);
assert.equal(secondCode, firstCode);
assert.equal(fs.readFileSync(store.filePath, 'utf8'), `${firstCode}\n`);
// Mask off file-type bits so this assertion checks only Unix permissions.
assert.equal(fs.statSync(store.filePath).mode & 0o777, 0o600);
});
test('rejects a malformed existing credential instead of replacing it', () => {
const store = createTestStore();
fs.writeFileSync(store.filePath, 'not-a-valid-code\n', { mode: 0o600 });
assert.throws(() => store.ensure(), /Setup code file is invalid/);
assert.equal(fs.readFileSync(store.filePath, 'utf8'), 'not-a-valid-code\n');
});
test('rejects a setup-code symlink without reading or changing its target', () => {
const store = createTestStore();
const targetPath = path.join(path.dirname(store.filePath), 'unrelated.txt');
fs.writeFileSync(targetPath, 'unrelated-content\n', { mode: 0o644 });
fs.symlinkSync(targetPath, store.filePath);
assert.throws(() => store.ensure(), /not a regular file/);
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'unrelated-content\n');
assert.equal(fs.statSync(targetPath).mode & 0o777, 0o644);
});
test('removes the credential after setup completes', () => {
const store = createTestStore();
store.ensure();
store.remove();
assert.equal(fs.existsSync(store.filePath), false);
});
@@ -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,
+238 -2
View File
@@ -9,6 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@rjsf/core": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
@@ -1111,6 +1113,96 @@
"node": ">=14"
}
},
"node_modules/@rjsf/core": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.0.tgz",
"integrity": "sha512-fdyaPnhIe+NzTFZcYcYcp/QqVcAJhhloIsU6vN+hp/IhiGJrzgB9REMVVUlKYNzP37xZ/BsEq7u7RC24KKdLAQ==",
"license": "Apache-2.0",
"dependencies": {
"markdown-to-jsx": "^9.8.2"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.10.0",
"react": ">=18"
}
},
"node_modules/@rjsf/utils": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz",
"integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@x0k/json-schema-merge": "^1.0.3",
"fast-equals": "^6.0.0",
"fast-uri": "^4.1.4",
"jsonpointer": "^5.0.1",
"react-is": "^18.3.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"react": ">=18"
}
},
"node_modules/@rjsf/validator-ajv8": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.0.tgz",
"integrity": "sha512-B/cNuPIQNBiJo4ByoJUXwGylu51JA5Jhkj4gsZ794dl85xxfcQ43HKfR1Lg55SoCUOzHc0Sf7zI/0q3R3sKEEQ==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^2.1.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.10.0"
}
},
"node_modules/@rjsf/validator-ajv8/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@rjsf/validator-ajv8/node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/@rjsf/validator-ajv8/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@@ -1523,7 +1615,6 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
@@ -1617,6 +1708,16 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@x0k/json-schema-merge": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.5.tgz",
"integrity": "sha512-bPvwYpTkhBOXkvbC8KMNhkgzv+gWu6Yx6FVho3mL+vMf8Fy3gdn9YrlrBrXphznvOHhB85BudH/ywtVRZ/HG3A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1657,6 +1758,61 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -2428,9 +2584,18 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.3.tgz",
"integrity": "sha512-IECu4C0fFZFoUC2cA2rDaNUuVIfWIZMwh3tY+ng924vOuD9OaUFFrcIrVwCKbV7TJDkGmrHfhwZ7R0AnHfVhyw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2475,6 +2640,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -2955,6 +3137,16 @@
"node": ">=6"
}
},
"node_modules/jsonpointer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3039,6 +3231,34 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/markdown-to-jsx": {
"version": "9.10.2",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz",
"integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"react": ">= 16.0.0",
"solid-js": ">=1.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-native": {
"optional": true
},
"solid-js": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3618,6 +3838,13 @@
"react": "*"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT",
"peer": true
},
"node_modules/react-joystick-component": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/react-joystick-component/-/react-joystick-component-6.2.1.tgz",
@@ -3712,6 +3939,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+2
View File
@@ -12,6 +12,8 @@
},
"dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@rjsf/core": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
+169
View File
@@ -0,0 +1,169 @@
// Central Administration Application
// Purpose: Composes authentication, operational controls, users, and one complete configuration editor under /admin.
// Scope: Reuses existing admin/identity components while shared infrastructure owns revisions and sensitive-action behavior.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import AuthPanel from '../components/AuthPanel/index.jsx';
import AdminPanelContent from '../components/AdminPanel/AdminPanelContent.jsx';
import CardFrame from '../components/CardFrame/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import Tabs, { Tab, TabList, TabPanels } from '../components/Tabs/index.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import IdentityDatabasePanel from '../database/IdentityDatabasePanel.jsx';
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
import { useSettingsNamespace } from '../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../themes/index.js';
import { confirmAdminPassword, getAdminSnapshot } from './api.js';
import AdministratorAccounts from './components/AdministratorAccounts.jsx';
import AdminOverview from './components/AdminOverview.jsx';
import ConfigurationEditor from './components/ConfigurationEditor.jsx';
import PasswordConfirmationDialog from './components/PasswordConfirmationDialog.jsx';
const TOP_LEVEL_SECTIONS = [
{ key: 'overview', label: 'Overview' },
{ key: 'fleet', label: 'Fleet operations' },
{ key: 'users', label: 'Users and administrators', lockdownOnly: true },
{ key: 'configuration', label: 'Configuration', lockdownOnly: true },
];
export default function AdminApp() {
useUserIdentitySync({ identitySurface: 'passive' });
const socket = useSocket();
const role = useSessionSelector((state) => state.session?.role || 'user');
const connected = useSessionSelector((state) => state.connected);
const [searchParams, setSearchParams] = useSearchParams();
const selected = searchParams.get('section') || 'overview';
const [snapshot, setSnapshot] = useState(null);
const [loadingError, setLoadingError] = useState('');
const pendingSensitiveAction = useRef(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [confirmationBusy, setConfirmationBusy] = useState(false);
const [confirmationError, setConfirmationError] = useState('');
const { value: pageSettings } = useSettingsNamespace('page', { backgroundTheme: DEFAULT_PAGE_THEME_KEY });
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
const isAdmin = role === 'admin' || role === 'lockdown';
const isLockdown = role === 'lockdown';
const loadSnapshot = useCallback(async () => {
if (!isLockdown) {
setSnapshot(null);
return;
}
setLoadingError('');
try {
setSnapshot(await getAdminSnapshot(socket));
} catch (error) {
setLoadingError(error.message);
}
}, [isLockdown, socket]);
useEffect(() => {
loadSnapshot();
}, [loadSnapshot, connected]);
const runSensitive = useCallback(async (operation) => {
try {
return await operation();
} catch (error) {
if (error.code !== 'PASSWORD_CONFIRMATION_REQUIRED') throw error;
/*
Suspend exactly the rejected operation. After password confirmation the
same closure reruns with its original revision and payload, so normal
conflict detection still protects against changes made while waiting.
*/
return new Promise((resolve, reject) => {
pendingSensitiveAction.current = { operation, resolve, reject };
setConfirmationError('');
setConfirmationOpen(true);
});
}
}, []);
async function submitPasswordConfirmation(password) {
setConfirmationBusy(true);
setConfirmationError('');
try {
await confirmAdminPassword(socket, password);
const pending = pendingSensitiveAction.current;
pendingSensitiveAction.current = null;
setConfirmationOpen(false);
if (pending) {
try {
pending.resolve(await pending.operation());
} catch (error) {
pending.reject(error);
}
}
} catch (error) {
setConfirmationError(error.message);
} finally {
setConfirmationBusy(false);
}
}
function cancelPasswordConfirmation() {
const pending = pendingSensitiveAction.current;
pendingSensitiveAction.current = null;
setConfirmationOpen(false);
if (pending) pending.reject(new Error('Sensitive action cancelled.'));
}
function selectSection(key) {
setSearchParams(key === 'overview' ? {} : { section: key });
}
const navigationOptions = TOP_LEVEL_SECTIONS.filter((entry) => !entry.lockdownOnly || isLockdown);
const activeSection = navigationOptions.some((entry) => entry.key === selected) ? selected : 'overview';
let content;
if (!isAdmin) {
content = <div className="mx-auto w-full max-w-md"><AuthPanel /></div>;
} else if (activeSection === 'fleet') {
content = <AdminPanelContent />;
} else if (!isLockdown) {
content = (
<CardFrame title="Administrator access" bodyClassName="p-1 text-sm text-slate-300">
<p>Routine fleet operations are available. Configuration, users, secrets, and system administration require a lockdown administrator.</p>
</CardFrame>
);
} else if (!snapshot) {
content = <CardFrame title="Loading administration" bodyClassName="p-1 text-sm text-slate-300"><p>{loadingError || 'Loading configuration and audit state…'}</p></CardFrame>;
} else if (activeSection === 'users') {
content = (
<div className="space-y-0.5">
<AdministratorAccounts administrators={snapshot.administrators} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} />
<IdentityDatabasePanel />
</div>
);
} else if (activeSection === 'configuration') {
content = <ConfigurationEditor snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} onReload={loadSnapshot} />;
} else {
content = <AdminOverview snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} />;
}
return (
<div className={`${pageBackgroundClass} min-h-screen text-slate-100`}>
<SocketConnectionPill />
<PasswordConfirmationDialog open={confirmationOpen} busy={confirmationBusy} error={confirmationError} onCancel={cancelPasswordConfirmation} onConfirm={submitPasswordConfirmation} />
<main className="mx-auto min-h-screen w-full max-w-[100rem] p-1">
<CardFrame title="MultiRover administration" meta={connected ? role : 'offline'} bodyClassName="p-0.5 text-xs text-slate-400">
<p>{snapshot ? `Active configuration revision ${snapshot.configuration.revision}.${snapshot.restartRequired ? ' An application restart is required to apply saved changes.' : ' The running application has loaded this revision.'}` : 'Central server administration and configuration.'}</p>
</CardFrame>
{isAdmin ? (
<Tabs currentTab={activeSection} onTabChange={selectSection}>
{/* Reusing the same responsive tab surface as the driver page makes
administration feel like another MultiRover workspace instead
of a separate desktop-oriented application. */}
<nav aria-label="Administration sections">
<TabList className="my-0.5">
{navigationOptions.map((entry) => <Tab key={entry.key} id={entry.key}>{entry.label}</Tab>)}
</TabList>
</nav>
<TabPanels><section className="min-w-0">{content}</section></TabPanels>
</Tabs>
) : <section className="mt-0.5 min-w-0">{content}</section>}
</main>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
// First-Run Setup Application
// Purpose: Initializes a fresh server or imports an explicitly selected YAML configuration file through the restricted setup channel.
// Scope: Exists only while the server reports setup required; ordinary administration belongs to /admin.
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import CardFrame from '../components/CardFrame/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { createFirstAdministrator, getSetupStatus, importConfigurationFile } from './api.js';
export default function SetupApp() {
const socket = useSocket();
const [required, setRequired] = useState(null);
const [setupCode, setSetupCode] = useState('');
const [username, setUsername] = useState('');
const [discordId, setDiscordId] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [configurationFile, setConfigurationFile] = useState(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
useEffect(() => {
getSetupStatus(socket).then((response) => setRequired(response.required)).catch((error) => setMessage(error.message));
}, [socket]);
async function run(work) {
setBusy(true);
setMessage('');
try {
await work();
setRequired(false);
setMessage('Setup completed. You can now open the administration application and log in.');
} catch (error) {
const details = Array.isArray(error.validationErrors)
? ` ${error.validationErrors.map((entry) => `${entry.path}: ${entry.message}`).join('; ')}`
: '';
setMessage(`${error.message}${details}`);
} finally {
setBusy(false);
}
}
function createAdministrator(event) {
event.preventDefault();
if (password !== confirmPassword) {
setMessage('Passwords do not match.');
return;
}
run(() => createFirstAdministrator(socket, { setupCode, username, discordId, password }));
}
function importSelectedConfiguration(event) {
event.preventDefault();
if (!configurationFile) return;
run(async () => importConfigurationFile(socket, {
setupCode,
fileName: configurationFile.name,
yaml: await configurationFile.text(),
}));
}
return (
<div className="min-h-screen bg-neutral-950 p-1 text-slate-100">
<SocketConnectionPill />
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5">
<CardFrame title="MultiRover setup" meta={required === null ? 'checking' : required ? 'required' : 'complete'} bodyClassName="space-y-0.5 p-1 text-sm">
{required ? <p>Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration.</p> : null}
{required === false ? <Link className="button-dark inline-block" to="/admin">Open administration</Link> : null}
{message ? <p className="surface p-1 text-sm text-slate-200">{message}</p> : null}
</CardFrame>
{required ? (
<>
<CardFrame title="Setup authorization" bodyClassName="p-1">
<label className="block text-xs font-semibold text-slate-200">One-time setup code</label>
<input className="field-input mt-0.5 w-full font-mono" value={setupCode} onChange={(event) => setSetupCode(event.target.value)} />
</CardFrame>
<CardFrame title="Create first administrator" bodyClassName="p-1">
<form className="grid gap-0.5 md:grid-cols-2" onSubmit={createAdministrator}>
<input className="field-input" placeholder="Username" value={username} onChange={(event) => setUsername(event.target.value)} />
<input className="field-input" placeholder="Discord id (optional)" value={discordId} onChange={(event) => setDiscordId(event.target.value)} />
<input className="field-input" type="password" placeholder="Password" value={password} onChange={(event) => setPassword(event.target.value)} />
<input className="field-input" type="password" placeholder="Confirm password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} />
<button className="button-dark md:col-span-2" type="submit" disabled={busy || !setupCode || !username || !password}>Create lockdown administrator</button>
</form>
</CardFrame>
<CardFrame title="Import configuration file" bodyClassName="space-y-0.5 p-1 text-sm">
<p className="text-xs text-slate-400">Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser.</p>
<form className="flex flex-col gap-0.5 md:flex-row" onSubmit={importSelectedConfiguration}>
<input className="field-input flex-1" type="file" accept=".yaml,.yml,text/yaml" onChange={(event) => setConfigurationFile(event.target.files?.[0] || null)} />
<button className="button-dark" type="submit" disabled={busy || !setupCode || !configurationFile}>Import selected YAML</button>
</form>
</CardFrame>
</>
) : null}
</main>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
// Admin Socket API
// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.
// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.
export function emitAdminRequest(socket, eventName, payload = {}) {
return new Promise((resolve, reject) => {
socket.emit(eventName, payload, (response = {}) => {
if (response?.error) {
const error = new Error(response.error);
error.code = response.code || null;
error.validationErrors = response.validationErrors || [];
error.currentRevision = response.currentRevision || null;
reject(error);
return;
}
resolve(response);
});
});
}
export const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');
export const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });
export const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);
export const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);
export const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);
export const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);
export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });
export const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');
export const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);
export const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);
@@ -0,0 +1,56 @@
// Administration Overview
// Purpose: Summarizes configuration state, revision history, audit history, and links to existing health/report surfaces.
// Scope: Presents persisted administration metadata without duplicating operational service implementations.
import { Link } from 'react-router-dom';
import CardFrame from '../../components/CardFrame/index.jsx';
import { restoreConfigurationRevision } from '../api.js';
function formatDate(value) {
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : 'unknown';
}
export default function AdminOverview({ snapshot, socket, runSensitive, onSnapshot }) {
const config = snapshot.configuration;
async function restore(revision) {
if (!window.confirm(`Restore configuration revision ${revision}? This creates a new active revision and requires a restart.`)) return;
try {
const response = await runSensitive(() => restoreConfigurationRevision(socket, {
revision,
expectedRevision: config.revision,
}));
onSnapshot(response.snapshot);
} catch (error) {
window.alert(error.message);
}
}
return (
<div className="space-y-0.5">
<CardFrame title="Administration overview" meta={`revision ${config.revision}`} bodyClassName="grid gap-0.5 p-0.5 md:grid-cols-3">
<div className="surface p-1"><p className="text-xs text-slate-400">Active revision</p><p className="text-xl font-semibold">{config.revision}</p></div>
<div className="surface p-1"><p className="text-xs text-slate-400">Administrators</p><p className="text-xl font-semibold">{snapshot.administrators.length}</p></div>
<div className="surface p-1"><p className="text-xs text-slate-400">Last configuration save</p><p className="text-sm font-semibold">{formatDate(config.createdAt)}</p></div>
</CardFrame>
<CardFrame title="Existing administration surfaces" bodyClassName="flex flex-wrap gap-0.5 p-0.5 text-sm">
<Link className="button-dark" to="/reports">Open fleet reports</Link>
<Link className="button-dark" to="/">Open driver application</Link>
</CardFrame>
<CardFrame title="Configuration revisions" meta={snapshot.revisions.length} bodyClassName="max-h-64 overflow-y-auto p-0.5 text-xs">
{snapshot.revisions.map((revision) => (
<div key={revision.revision} className="surface mb-0.5 grid items-center gap-0.5 p-0.5 md:grid-cols-[5rem_1fr_1fr_1fr_auto]">
<span>#{revision.revision}</span><span>{formatDate(revision.createdAt)}</span><span>{revision.actor}</span><span>{revision.source}</span>
<button type="button" className="button-dark text-xs" disabled={revision.revision === config.revision} onClick={() => restore(revision.revision)}>Restore</button>
</div>
))}
</CardFrame>
<CardFrame title="Persistent audit history" meta={snapshot.auditEvents.length} bodyClassName="max-h-96 overflow-y-auto p-0.5 text-xs">
{snapshot.auditEvents.map((event) => (
<div key={event.id} className="surface mb-0.5 grid gap-0.5 p-0.5 md:grid-cols-[10rem_10rem_1fr]">
<span>{formatDate(event.createdAt)}</span><span>{event.actor}</span><span>{event.action}</span>
</div>
))}
</CardFrame>
</div>
);
}
@@ -0,0 +1,93 @@
// Administrator Accounts
// Purpose: Provides lockdown administrators with explicit account creation, editing, password replacement, and deletion controls.
// Scope: Never receives or displays password hashes; final-lockdown safety is enforced by the server.
import { useEffect, useState } from 'react';
import CardFrame from '../../components/CardFrame/index.jsx';
import { createAdministrator, deleteAdministrator, updateAdministrator } from '../api.js';
function AdministratorRow({ administrator, socket, runSensitive, onSnapshot }) {
const [draft, setDraft] = useState({
username: administrator.username,
discordId: administrator.discordId,
role: administrator.role,
password: '',
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setDraft({ username: administrator.username, discordId: administrator.discordId, role: administrator.role, password: '' });
}, [administrator]);
async function perform(work) {
setBusy(true);
setError('');
try {
const response = await runSensitive(work);
onSnapshot(response.snapshot);
} catch (actionError) {
setError(actionError.message);
} finally {
setBusy(false);
}
}
return (
<div className="surface space-y-0.5 p-0.5">
<div className="grid gap-0.5 md:grid-cols-4">
<input className="field-input" value={draft.username} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
<input className="field-input" placeholder="Discord id" value={draft.discordId} onChange={(event) => setDraft({ ...draft, discordId: event.target.value })} />
<select className="field-input" value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value })}>
<option value="admin">Administrator</option>
<option value="lockdown">Lockdown administrator</option>
</select>
<input className="field-input" type="password" autoComplete="new-password" placeholder="New password (optional)" value={draft.password} onChange={(event) => setDraft({ ...draft, password: event.target.value })} />
</div>
{error ? <p className="text-xs text-red-300">{error}</p> : null}
<div className="flex justify-end gap-0.5">
<button type="button" className="button-danger text-xs" disabled={busy} onClick={() => perform(() => deleteAdministrator(socket, administrator.id))}>Delete</button>
<button type="button" className="button-dark text-xs" disabled={busy} onClick={() => perform(() => updateAdministrator(socket, { id: administrator.id, ...draft }))}>Save account</button>
</div>
</div>
);
}
export default function AdministratorAccounts({ administrators, socket, runSensitive, onSnapshot }) {
const [draft, setDraft] = useState({ username: '', discordId: '', role: 'admin', password: '' });
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function create(event) {
event.preventDefault();
setBusy(true);
setError('');
try {
const response = await runSensitive(() => createAdministrator(socket, draft));
onSnapshot(response.snapshot);
setDraft({ username: '', discordId: '', role: 'admin', password: '' });
} catch (actionError) {
setError(actionError.message);
} finally {
setBusy(false);
}
}
return (
<CardFrame title="Administrator accounts" meta={administrators.length} bodyClassName="space-y-0.5 p-0.5 text-sm">
<form className="surface grid gap-0.5 p-0.5 md:grid-cols-4" onSubmit={create}>
<input className="field-input" placeholder="Username" value={draft.username} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
<input className="field-input" placeholder="Discord id (optional)" value={draft.discordId} onChange={(event) => setDraft({ ...draft, discordId: event.target.value })} />
<select className="field-input" value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value })}>
<option value="admin">Administrator</option>
<option value="lockdown">Lockdown administrator</option>
</select>
<input className="field-input" type="password" autoComplete="new-password" placeholder="Password" value={draft.password} onChange={(event) => setDraft({ ...draft, password: event.target.value })} />
{error ? <p className="text-xs text-red-300 md:col-span-3">{error}</p> : <span className="md:col-span-3" />}
<button className="button-dark" type="submit" disabled={busy || !draft.username || !draft.password}>{busy ? 'Creating…' : 'Create account'}</button>
</form>
{(administrators || []).map((administrator) => (
<AdministratorRow key={administrator.id} administrator={administrator} socket={socket} runSensitive={runSensitive} onSnapshot={onSnapshot} />
))}
</CardFrame>
);
}
@@ -0,0 +1,102 @@
// Complete Configuration Editor
// Purpose: Connects the one hierarchical configuration form to revision, secret, validation, and save behavior.
// Scope: Edits and saves one complete configuration document as one immutable revision.
import { useEffect, useMemo, useState } from 'react';
import CardFrame from '../../components/CardFrame/index.jsx';
import { updateConfiguration } from '../api.js';
import SchemaConfigurationForm from './SchemaConfigurationForm.jsx';
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
export default function ConfigurationEditor({ snapshot, socket, runSensitive, onSnapshot, onReload }) {
const serverValue = snapshot?.configuration?.config;
const schema = snapshot?.configuration?.schema;
const revision = snapshot?.configuration?.revision;
const [draft, setDraft] = useState(() => clone(serverValue));
const [secretOperations, setSecretOperations] = useState({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [validationErrors, setValidationErrors] = useState([]);
useEffect(() => {
setDraft(clone(serverValue));
setSecretOperations({});
setError('');
setValidationErrors([]);
}, [revision, serverValue]);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(serverValue) || Object.keys(secretOperations).length > 0,
[draft, secretOperations, serverValue],
);
if (!serverValue || !schema) {
return <p className="surface p-1 text-sm text-red-200">The configuration document is unavailable.</p>;
}
const setSecretOperation = (path, operation) => setSecretOperations((current) => {
const next = { ...current };
if (operation) next[path] = operation;
else delete next[path];
return next;
});
async function save() {
setSaving(true);
setError('');
setValidationErrors([]);
try {
const response = await runSensitive(() => updateConfiguration(socket, {
value: draft,
expectedRevision: revision,
secretOperations,
}));
onSnapshot(response.snapshot);
} catch (saveError) {
setError(saveError.message);
setValidationErrors(saveError.validationErrors || []);
} finally {
setSaving(false);
}
}
return (
<div className="space-y-0.5">
<CardFrame className="sticky top-0 z-20 bg-neutral-900/95 backdrop-blur" title="Configuration" meta={`revision ${revision}`} bodyClassName="space-y-0.5 p-0.5">
<p className="text-[0.7rem] text-slate-400">Saved changes apply after an application restart.</p>
{/* All document actions stay together at the start of the toolbar. The
editor may use a wide canvas, but width is never used to separate a
control from the content that explains it. */}
<div className="flex flex-wrap gap-0.5">
<button type="button" className="button-dark" disabled={saving} onClick={onReload}>Reload</button>
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={() => {
setDraft(clone(serverValue));
setSecretOperations({});
}}>Reset</button>
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={save}>{saving ? 'Saving…' : 'Save configuration'}</button>
</div>
</CardFrame>
{error ? <p className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">{error}</p> : null}
{validationErrors.length ? (
<div className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">
<p className="font-semibold">Configuration could not be saved</p>
<ul className="mt-0.5 list-disc space-y-0.25 pl-4">
{validationErrors.map((validationError, index) => (
<li key={`${validationError.path}-${index}`}>{validationError.path}: {validationError.message}</li>
))}
</ul>
</div>
) : null}
<SchemaConfigurationForm
schema={schema}
value={draft}
onChange={setDraft}
configuredSecrets={snapshot.configuration.configuredSecrets}
secretOperations={secretOperations}
setSecretOperation={setSecretOperation}
/>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More