Compare commits

...
23 Commits
Author SHA1 Message Date
legop3 470e95b0f9 fix inter instance config broken stuffs 2026-09-14 20:52:01 -04:00
legop3 9ce6550f13 fix sticky 2026-09-14 20:30:48 -04:00
legop3 ef9baf6063 some UI tweaking before testing in production lol 2026-09-14 20:12:11 -04:00
legop3 43274e7371 /video signaling proxy and a global public server http path config item 2026-09-14 19:49:37 -04:00
legop3 e7d7f2a270 backup restore slopfix 2026-09-14 19:17:48 -04:00
legop3 1c34849bd0 backup / restoreslop 2026-09-14 19:09:02 -04:00
legop3 edc1b825f2 server restart thingy yay 2026-09-14 18:42:42 -04:00
legop3 c56a8b1000 legacy importer in admin page aswell as setup. 2026-09-14 18:10:45 -04:00
legop3 91e0cc3886 service live configslop 2026-09-14 17:56:53 -04:00
legop3 881583ee0a yaml importer slopping it up 2026-09-14 13:48:58 -04:00
legop3 8ff3c39765 better colorses 2026-09-14 13:30:59 -04:00
legop3 9b5c187aac cardframing again 2026-09-14 13:27:48 -04:00
legop3 0266bd9568 leg 2026-09-14 13:04:41 -04:00
legop3 6edb6f6dd0 old defaults in new schema 2026-09-14 12:54:29 -04:00
legop3 ec8eb1c002 cardframing 2026-09-14 12:41:25 -04:00
legop3 3c385126ae descriptionslop 2026-09-14 12:34:22 -04:00
legop3 4635e1b40c slorp 2026-09-14 12:15:59 -04:00
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
legop3 17b1404157 converge everything into one data folder 2026-09-13 22:10:15 -04:00
legop3 b199f45eb2 add bars to thingy flingy 2026-09-13 15:43:41 -04:00
legop3 0f08fb3f0d Merge pull request #25 from legop3/newcontroller
Newcontroller
2026-09-13 02:09:38 -04:00
156 changed files with 8068 additions and 1525 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
+706
View File
@@ -0,0 +1,706 @@
# Server administration and container migration
## Status
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
- [x] Phase 1, steps 6-8: Restart, backup/restore, and internal video proxy
- [ ] Phase 1, step 9: Complete the remaining legacy-deployment integration and hardware verification
- [ ] 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:
1. Finish the server-side configuration, administration, persistence, backup, restore, and media-routing changes while the server still uses its current systemd deployment.
2. Containerize the already-finished application, publish images through GHCR, and add container-aware update and restart controls.
Phase 1 must be complete and verified before Phase 2 begins. Containerization must not become a second configuration migration or a reason to maintain two persistence layouts.
## Decision log
- 2026-09-14: Apply every committed configuration revision immediately. The configuration coordinator atomically replaces the process-wide snapshot, compares top-level service sections, serially reloads only affected service runtimes, and then refreshes all sessions. Long-lived HTTP/socket handlers remain registered once and delegate to the current runtime; integrations may reconnect or replace their own child process, worker, client, timers, and subscriptions without restarting Node.
- 2026-09-14: Render the schema-driven configuration editor as a YAML-like tree inside one `CardFrame`. Every object or array introduces an ordered header and one indentation guide, every scalar occupies one key/value row, and array operations remain beside their item instead of moving to the far edge. Keep all route-specific RJSF styling in `webui/src/admin/styles.css`, outside the shared global stylesheet.
- 2026-09-14: Restart only the application process, never the host. A lockdown administrator with recent password confirmation requests one audited restart, Node acknowledges and announces it, then sends itself SIGTERM. Existing service signal handlers clean up their owned children, while systemd `Restart=always` and the later container restart policy start the application again.
- 2026-09-14: Keep backup and restore together in one server service after application restart exists. Neither operation stops running services or writers, and no command-line interface is maintained. Backup uses online SQLite snapshots and stable copies of non-database files; restore validates and stages an uploaded archive, records a marker, and uses the normal application restart to replace the data directory during earliest startup.
- 2026-09-14: Define this server's canonical `publicUrl` once at the top of configuration beside `timezone`. Discord links, inter-instance identity, page metadata, and MediaMTX's primary public ICE hostname derive from it. Browser WHEP and WHIP signaling always uses the same-origin `/video` path; `media.additionalHosts` remains only for genuinely additional ICE names or addresses.
- 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 paths retained are operator-selected YAML uploads on `/setup` and the protected Configuration page. 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.
## Final goals
- `config.yaml` and `config.example.yaml` no longer exist.
- 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.
- `/setup` may initialize the database from a YAML file explicitly selected by the operator, and the protected Configuration page may explicitly replace configuration from one later; no automatic host migration or persistent YAML source 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.
- Release images are built automatically and published to GHCR.
- The admin UI can restart, update, health-check, and roll back the application container without giving the main application direct Docker access.
- The final host installation contains as little project-specific material as possible: a Compose file, a data directory, and unavoidable hardware preparation.
## Important boundary: application files versus server data
The data-directory rule applies to everything mutable or instance-specific that the server reads, writes, generates, or persists at runtime. It does not mean copying the application itself into the data directory.
Packaged, read-only application material remains with the application and later inside the image:
- Server source and production dependencies
- Built web UI assets
- Static sound and image assets shipped by the repository
- MediaMTX, ffmpeg, ffprobe, neolink, and TTS tools
- Kinect and Balance Board workers
- Helper scripts shipped as part of the application
The single data directory owns:
- Server configuration and secrets
- Administrator accounts
- Identity and permission records
- Fleet reports
- Persistent service state
- Audit history
- Generated MediaMTX configuration
- Snapshots and replay segments
- Finished replays
- PTZ-generated audio
- Barcode/TTS caches
- Disposable audio-forward and replay-build work under `runtime/`
- Backup and restore coordination state
- Any future file deliberately created or modified by the Node application
Application-owned scratch work must use `SERVER_DATA_DIR/runtime`, even when it is safe to lose on restart. Tests may use the operating system temporary directory because they are not the running server application. Packaged programs and host services can manage their own internal temporary state, but any output path explicitly selected by Node must follow the single-root rule.
# Phase 1: complete the application before containerization
Phase 1 is server, web UI, installer, and migration work only. The current systemd deployment remains the runtime while these contracts are changed and verified.
## 1. Establish the single data-directory contract
The normal development and legacy-install location remains `server/data`. The path continues to be overridable through `SERVER_DATA_DIR`, which will later be set to `/data` in the container.
A representative final layout is:
```text
server/data/
├── configuration.sqlite
├── identity.sqlite
├── fleet-reports.sqlite
├── mediamtx.yml
├── existing service JSON stores
├── barcode-tts-cache/
├── rover-snapshots/
├── replay-segments/
├── replays/
├── ptz-camera-audio/
├── runtime/
│ ├── audio-forward/
│ ├── replay-builds/
│ └── room-camera-replay-builds/
└── system/
├── backup-staging/
└── restore/
```
The exact number of databases is not important. A centralized admin UI does not require unrelated services to share one SQLite connection. Keeping identity and high-volume fleet reporting in their existing databases may remain simpler, provided every database is under the same data directory.
Required work:
- Audit every server filesystem read and write.
- Make every persistent path resolve from the shared data-path helper.
- Move rover and PTZ snapshots out of `/var/lib/rover-snapshots` and into the data directory.
- Remove separate persistent replay path configuration and keep replay segments and completed replays under the data directory.
- Keep generated MediaMTX configuration at its established `data/mediamtx.yml` path.
- Keep barcode speech, PTZ speech, and similar caches under the data directory.
- Check native workers and child-process scripts for hidden working-directory assumptions.
- Update health reporting to inspect the new paths.
- Update the legacy installer so the service receives one `SERVER_DATA_DIR` rather than several unrelated persistent paths.
- Add a focused test that runs services against a temporary data directory and proves that no test artifact escapes it.
- Document which files are durable and which cache directories may be discarded.
The audit must search direct filesystem calls as well as environment-variable defaults. Existing calls that default to `/var/lib`, the repository directory, or an implicit current working directory must be corrected.
## 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 live-application 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:
- The current complete configuration document
- A monotonically increasing configuration revision
- Previous configuration revisions
- The administrator account catalog and password hashes
- Persistent administrative audit events
- Database/schema migration state
The configuration schema must explicitly describe every supported field. A validation library should be used rather than assembling an ad hoc validator by hand.
Current configuration areas to migrate include:
- Server timezone and public instance identity
- Administrator accounts and Discord identities
- Inter-instance directories and profile
- LLM commentary and Overseer Control
- Barcode games
- Media and WebRTC ICE candidates
- Bandwidth-saving policy
- Audio forwarding and global audio levels
- Home Assistant, Neato, lift, entities, and button mappings
- Room cameras and PTZ camera
- Kinect and Balance Board
- Button box and barcode scanner
- Command names
- Discord bot, channels, and roles
- Social links and driver content
- Fleet-report collection, retention, privacy, and delivery
Required behavior:
- A missing value receives a documented safe default.
- Optional integrations default to disabled.
- Unknown fields are rejected rather than silently ignored.
- Invalid configuration never becomes the active revision.
- A complete revision is written atomically.
- Updates include the acting administrator and timestamp.
- Concurrent editors use revision checking so an older browser cannot overwrite a newer change silently.
- Secrets are never included in ordinary configuration responses, logs, diffs, or audit metadata.
- Secret inputs support replace and clear operations without returning the current value to the browser.
- At least one lockdown administrator must always remain.
- An administrator cannot accidentally remove the only account capable of repairing administration.
Configuration changes use one intentionally simple application rule:
1. Validate the complete proposed document.
2. Commit it as a new database revision.
3. Atomically replace the process-wide configuration snapshot.
4. Compare the old and new top-level sections.
5. Reload every service that owns a changed section, replacing its complete internal runtime when necessary.
6. Report per-service application failures without preventing unrelated services from applying the revision.
7. Refresh sessions only after all affected service reloads finish.
HTTP routes, Socket.IO connection handlers, and process signal handlers are registered once. They consult live state or delegate to the current service runtime, preventing duplicate listeners after repeated saves. Service reloads may reconnect an integration or restart an application-owned child such as MediaMTX, ffmpeg, Kinect, or the Balance Board worker, but never restart the Node application.
Operational actions such as changing server mode, locking a rover, or issuing a rover command remain direct live actions rather than configuration edits.
After migration is complete:
- Remove the YAML configuration loader.
- Remove `SERVER_CONFIG`.
- 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. Add explicit configuration-file upload to setup and administration
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. The protected Configuration page may later replace only the configuration from another explicitly selected legacy file. Neither path is a startup loader, installer migration, command-line workflow, or permanent second source of truth.
Every upload must:
- Accept only an explicitly selected YAML file from `/setup` or the protected Configuration page.
- Parse the complete document.
- Map every recognized field into the new configuration schema.
- Preserve secrets without printing them.
- Apply current defaults for absent fields.
- Ignore fields that do not exist in the current schema, while reporting invalid values supplied for current fields.
- Validate the entire result before writing anything.
- Record the uploaded filename without storing secret values in the audit event.
The setup upload additionally must:
- Require the one-time setup code before processing it.
- Preserve existing bcrypt administrator password hashes, lockdown roles, and Discord IDs.
- Refuse to replace an already-configured database.
- Write the configuration, administrators, and audit event atomically.
The initialized-server upload additionally must:
- Require a lockdown administrator with recent password confirmation.
- Use optimistic revision checking so it cannot overwrite an intervening edit.
- Ignore the entire legacy `admins` collection and leave all current accounts unchanged.
- Preserve stored secrets omitted from the file, replace supplied secrets, and clear explicitly empty secrets.
- Commit through the normal revision path and immediately reload affected services.
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
The server must boot safely with an empty data directory and without any YAML file.
Required flow:
1. Initialize the databases and safe default configuration.
2. Keep all optional external integrations disabled.
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 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.
A recovery command must be available for resetting or creating a lockdown administrator from the server console. Environment variables must not act as a recurring authentication bypass on every boot.
## 5. Build the centralized admin application
Create a dedicated `/admin` route instead of continuing to expand the existing driver-page admin panel.
The application should provide these top-level destinations:
- Overview and service health
- Fleet and rover operations
- Users, administrators, verification, and permissions
- Configuration, presented as one hierarchical page
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:
- Normal administrators may perform routine fleet operations.
- Lockdown administrators manage accounts, secrets, server configuration, backup restoration, and other destructive operations.
- 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 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. Standardize application restart
Replace the current host reboot operation with one deployment-neutral **Restart application** operation.
The restart operation must:
1. Require a lockdown administrator and recent password confirmation.
2. Reject a second request while one is already pending.
3. Persist an audit event, acknowledge the requester, and notify connected browsers.
4. Stop accepting new HTTP connections and send SIGTERM to the Node process after a short acknowledgement delay.
5. Reuse the cleanup hooks already owned by MediaMTX, ffmpeg, Kinect, Balance Board, and other child-process services.
6. Exit normally and rely on the process supervisor to start the application again.
During Phase 1, systemd uses `Restart=always`. During Phase 2, the container uses a restart policy such as `unless-stopped`. An explicit operator `systemctl stop` or container stop remains stopped; only a process exit is restarted. The browser shows the announced reconnect state and reloads the active administration snapshot after Socket.IO reconnects.
Host rebooting is a separate privilege and is not part of this application contract. The server never invokes `systemctl reboot`.
## 7. Implement complete backup and restore
Everything durable living under one data directory makes the backup boundary simple. Backup and restore remain together under one `backupRestoreService`; there is no generic maintenance framework and no command-line workflow.
### Full backup
The primary admin action is **Download full backup**. A full backup includes the entire durable data payload:
- Configuration and secrets
- Administrator accounts
- Identity and permissions
- Fleet history
- Persistent service state
- Snapshots and replay media
- Generated and cached files that are part of the current server state
- A manifest describing the application and schema versions
The backup operation must:
1. Require a lockdown administrator and recent password confirmation.
2. Leave every service and writer running.
3. Create consistent SQLite snapshots using SQLite's online backup support rather than copying active WAL files.
4. Copy non-database durable files and verify their size and modification time before and after each copy, retrying a file that changed during the copy.
5. Exclude `runtime/`, backup/restore staging, SQLite WAL/SHM files, and incomplete files that never become stable during bounded retries.
6. Produce a manifest containing creation time, application version, schema versions, included paths, sizes, and checksums.
7. Stream the completed archive to the authorized browser and remove temporary staging afterward.
The downloaded archive contains credentials and integration secrets. The UI must say so clearly. It must not be exposed through a permanent public URL or retained indefinitely inside the data directory.
`runtime/` is inside the filesystem boundary but is not durable backup content. Audio FIFOs, incomplete uploads, and in-progress replay builds have no restore value and are excluded without stopping their owners. The initial implementation provides only the authoritative full backup.
### Restore
Restore cannot safely overwrite databases underneath running services. It must be a staged, restart-bound operation.
The restore operation must:
1. Require a lockdown administrator and recent password confirmation.
2. Upload the archive into bounded staging controlled by the data directory.
3. Enforce an upload-size limit that is appropriate for full media-inclusive backups.
4. Reject absolute paths, `..` traversal, symlinks, device files, and unexpected archive structures.
5. Validate the manifest and every checksum before altering active data.
6. Check that the backup version has a supported forward migration path.
7. Display exactly what will be replaced.
8. Require a final explicit confirmation.
9. Record a pending-restore marker.
10. Request the normal application restart.
11. Apply the restore before ordinary services open their databases on the next start.
12. Run database migrations against the restored data when necessary.
13. Start the application and verify its health.
The startup restore path must preserve a local rollback snapshot until the restored server passes validation. If extraction, migration, or startup validation fails, it must put the prior data back and report the failure. Restore coordination files may live under `data/system/restore`, but they must be excluded from the restored payload where necessary to avoid recursively restoring an in-progress operation.
Restoring configuration also restores administrator accounts and secrets. The initiating browser may therefore lose authentication after restart; the reconnect UI must explain this and return to login normally. Backup and restore exist only in the protected admin application.
## 8. Internalize MediaMTX WHEP signaling
The current external proxy maps public `/video/<path>` requests to MediaMTX after stripping `/video`. Node should own that mapping directly.
The final request path is:
```text
Browser: /video/<stream>/whep
Node: strips /video and streams the request internally
MediaMTX: /<stream>/whep on 127.0.0.1:8889
```
Required work:
- Add a maintained HTTP proxy library rather than manually reproducing proxy semantics.
- Register the media proxy early enough that request bodies remain unmodified.
- Stream request and response bodies without buffering.
- Forward `POST`, `PATCH`, `DELETE`, authorization, content type, forwarded protocol, and relevant WHEP response headers.
- Apply bounded but media-appropriate proxy timeouts.
- Bind MediaMTX's WHEP listener to loopback.
- Generate browser WHEP URLs relative to the current site origin.
- Remove `media.whepBaseUrl` from configuration.
- Keep public and LAN ICE candidate hostnames as validated admin configuration.
- Test the exact `/video` prefix removal.
- Confirm that MediaMTX's internal HTTP authorization callback still reaches Node.
The actual WebRTC media does not pass through this HTTP proxy. MediaMTX's ICE TCP/UDP port must remain reachable by browsers.
Afterward, the public TLS proxy sends all paths for the site to Node and no longer needs a separate MediaMTX `/video` upstream.
## 9. Phase 1 verification and completion gate
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 explicitly selected YAML file can initialize the empty database exactly once.
- The setup upload ignores nonexistent fields and reports invalid values supplied for current fields.
- 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.
- Failed restore validation leaves the current server unchanged.
- Configuration, administrator accounts, and secrets survive restart.
- The final lockdown administrator cannot be removed accidentally.
- All administrative surfaces are available through `/admin` with server-side authorization.
- Configuration changes create auditable revisions and apply to the running services without an application restart.
- `/video` works through Node without a special public proxy rule for MediaMTX.
- Rover sockets, RTSP publishing, WHEP playback, snapshots, replays, PTZ, Discord, Home Assistant, Kinect, Balance Board, and reporting retain their intended behavior when enabled.
### Filesystem boundary implementation notes
Implemented on 2026-09-13:
- Removed the obsolete `server/src/data` fallback so there is one default data root.
- Added a shared rover-snapshot directory resolver beneath `SERVER_DATA_DIR`.
- Converted rover snapshot polling, PTZ snapshot reads, and health reporting to that resolver.
- Made the MediaMTX supervisor pass its resolved `SERVER_DATA_DIR` to runOnReady hooks.
- Converted the snapshot writer to require that data root and write to `rover-snapshots` beneath it.
- Removed the separate snapshot and replay-segment locations from the systemd unit generated by the installer.
- Made the installer create and own the canonical data and snapshot directories.
- Preserved the existing flat data layout; established SQLite, JSON, replay, cache, and generated MediaMTX paths were already within the boundary.
- Moved audio-forward FIFOs/uploads and both replay-rendering workspaces from the host temporary directory to `data/runtime`.
- Removed the configurable audio-forward runtime path so configuration cannot direct application writes outside `SERVER_DATA_DIR`.
- Kept prompts, public assets, helper binaries, native workers, and TTS assets with the packaged application because they are read-only application material.
- Left any old `/var/lib/rover-snapshots` and `/var/lib/replay-segments` directories untouched but reported during installation. Nothing reads or writes them after the upgraded service starts, and the operator can remove them after verifying the new paths on the actual server.
Local verification completed:
- Data-path helper tests passed for the default root and an overridden temporary root.
- MediaMTX supervisor testing confirmed that the resolved root reaches child hooks.
- The snapshot writer created `rover-snapshots` beneath a temporary data root and failed closed when no data root was supplied.
- All 91 server tests passed, including the new data-path and MediaMTX supervisor coverage.
- Installer and snapshot-writer shell syntax checks passed.
- 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 the same explicit legacy YAML picker to the protected Configuration page for replacing an initialized server's configuration. It requires recent lockdown-password confirmation, ignores every YAML administrator entry, filters nonexistent settings, validates current fields, preserves omitted secrets, applies explicitly supplied or empty secrets, uses optimistic revision checking, records the selected filename in audit history, and reloads affected services immediately.
- The one-time setup upload now passes its committed configuration through the same live-application coordinator, so a fresh installation does not need an immediate restart after importing YAML.
- Made setup-file import recursively retain only fields present in the current schema. Stale keys from the permissive YAML era are ignored without aliases or historical translations, while invalid values for real current settings still fail validation; stream-only and snapshot-only room-camera entries remain accepted as they were by the runtime.
- 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 a generic MultiRover tree renderer. The complete document now follows schema order as indented object, array, item, and key/value rows; array controls remain readable text beside each item, and the route-specific styling lives outside the global stylesheet.
- Replaced the editor's custom section borders, header backgrounds, and indentation guides with the application's shared `CardFrame` at every object, array, and array-item layer. Scalar settings remain compact key/value rows, descriptions use the wider value column, and collection actions stay beside their content instead of moving to the far edge.
- Disabled RJSF's internal checkbox label and description generically, leaving the shared field row as the single owner of each boolean setting's name, required marker, and description.
- Restored the former example YAML's installation-specific values as both schema-owned input examples and the actual initial values for non-secret settings and collection shapes. The only intentionally empty defaults are the three credentials and active driver HTML; their placeholders still explain the expected input without falsely marking credentials as configured or publishing sample content.
- Strengthened top-level hierarchy with a 1.5-rem sibling gap while retaining compact spacing within each configuration section.
- Extended `CardFrame` with an optional explicit accent while preserving its assigned-rover default, then gave every configuration nesting level its own complete header-and-border accent. Nested CardFrames themselves now carry the YAML-like indentation, scalar contents remain aligned with their owning card, and descriptions use a larger, higher-contrast treatment.
- Added an opt-in sticky-header behavior to the shared `CardFrame`. Top-level configuration titles use it beneath the independently sticky action toolbar while nested titles remain in normal flow to prevent overlap, and scalar key labels are now visually stronger than their descriptions.
- Traced all 156 schema nodes to their runtime consumers and added operator-facing descriptions for every root, section, collection, array item, and scalar option. A recursive configuration test now rejects any future schema node without a description; currently reserved settings explicitly state that they have no runtime effect.
- 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 immediate application reporting.
- Added a serialized live-configuration coordinator and converted configurable service runtimes to apply changed sections without restarting Node. Passive policies read the current immutable snapshot; network, hardware, timer, and child-process services replace or retune their owned runtime while stable HTTP/socket handlers continue delegating to it. The admin editor reports any service-specific reload failure after the revision is safely committed.
- Replaced the privileged host-reboot action with one lockdown-only, recently confirmed, audited application restart on the admin Overview. Node announces the restart, stops accepting new HTTP connections, and signals itself after acknowledging the browser; the existing service signal hooks clean up owned child processes, and systemd now restarts clean application exits without making `systemctl stop` ineffective.
- Added one protected backup-and-restore service and admin page. Backups keep the application online, use SQLite's online snapshot API for all three databases, make verified stable copies of the remaining durable files, and produce a checksummed archive through a short-lived one-use download. Restore uploads are size-limited, reject unsafe archive entries, verify the complete manifest, checksums, SQLite integrity, and supported schema versions, then remain staged until explicit recent-password confirmation.
- Restore now uses the normal application restart rather than stopping services itself. The earliest server startup swaps the validated replacement into the data directory, retains one rollback copy, and removes that copy only after the restored application reaches a stabilization point; an interrupted or failed first startup automatically puts the previous data back on the following start. Backup/restore control files and all staging remain inside `data/backup-restore`.
- Fixed production WAL-mode snapshots creating unmanifested SQLite `-wal` and `-shm` files during schema inspection. Backup and restore validation now remove only those temporary staged sidecars before archiving or applying data, and the regression fixture uses WAL mode to match the real databases.
- Added the early streaming `/video` middleware with `http-proxy-middleware`. Express removes the public prefix before forwarding WHEP/WHIP requests to `127.0.0.1:8889`, while root-relative MediaMTX session locations receive the prefix again so subsequent browser `PATCH` and `DELETE` requests follow the same path. MediaMTX signaling now binds to loopback; its ICE UDP/TCP listener remains directly reachable on port 8189.
- Replaced Discord's `siteUrl`, the inter-instance profile's `publicUrl`, and media `whepBaseUrl` with one top-level `publicUrl`. A numbered internal database migration transforms every saved configuration revision before current validation, and the media section now contains only optional additional ICE hosts. WHEP and microphone WHIP URLs are fixed relative paths, so they work through the current origin without knowing its hostname.
- Fixed inter-instance public payload generation to read feature flags and social links from the same live configuration revision. Social links enabled through the new configuration system no longer trigger an undefined legacy-config reference and an HTTP 500 response.
Local verification completed:
- All 119 server tests passed, including populated legacy-style default coverage, complete schema-description and input-example coverage, 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, setup and initialized-server YAML import safety, recursive removal of nonexistent fields, inter-instance payload generation with social links enabled, and the earlier filesystem coverage.
- All 27 server test files passed after live application, backup/restore, the internal media proxy, and the inter-instance regression coverage were added. The media tests stream exact SDP and trickle-ICE bodies through `POST`, `PATCH`, and `DELETE`, preserve headers, verify prefix and session-location rewriting, confirm loopback-only signaling, and derive the public ICE hostname from the canonical URL. The database migration and production-style WAL backup/restore paths are also covered. Application restart was not signaled on the development machine.
- 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.
- A second empty-data startup smoke test loaded every reloadable service and reached the HTTP listener without listener-limit warnings. A deliberately substituted failing MediaMTX executable then ended the process as expected; enabled hardware and external integrations still require verification on the actual server.
Testing-server verification completed:
- A full backup created from the running application successfully validated and restored through the admin UI after the WAL-sidecar fix.
- WHEP video playback works when the testing server is published through an ordinary whole-application reverse proxy. No special `/video` upstream, prefix rewrite, buffering rule, or direct public MediaMTX signaling route is present, confirming that Node now owns the complete public signaling path.
# 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.
## 10. Build the production application image
Use a Fedora-based multi-stage build to remain close to the dependencies already installed by the server installer and to avoid Alpine/musl compatibility problems with native modules and the ChromeOS TTS library.
### Web UI build stage
- Install locked web UI dependencies with `npm ci`.
- Run the production Vite build.
- Copy only the built assets into the final server tree.
### Server dependency stage
- Install locked server dependencies with `npm ci --omit=dev`.
- Supply compiler tooling only in the build stage for native Node modules.
- Copy production dependencies into the final image.
### Native worker stage
- Build the Kinect worker against libfreenect/libusb.
- Build the Balance Board worker against wiiuse/BlueZ.
- Build for the target image architecture rather than copying checked-in workstation binaries.
### Packaged runtime tools
At image-build time:
- Download pinned MediaMTX and neolink releases.
- Verify checksums.
- Install ffmpeg, ffprobe, TTS engines, required GStreamer libraries, and runtime native libraries.
- Install the ChromeOS TTS library and voice data.
- Install the TTS and snapshot helper scripts.
- Run reasonable build-time smoke checks.
The actual server must never download or compile these dependencies during container startup.
### Final image
The final image should:
- Contain no compiler toolchain, Git checkout, development dependencies, or build cache.
- Run Node as a dedicated non-root user.
- Use a minimal init process to reap child processes.
- Treat `/data` as its only persistent writable location.
- Use `/tmp` only for disposable work.
- Include release version and commit metadata.
- Handle `SIGTERM` through the Phase 1 graceful shutdown coordinator.
## 11. Compose deployment
The host-visible installation should be only:
```text
multirover/
├── compose.yaml
└── data/
```
The Compose project contains:
- The main Multirover application container
- A small lifecycle container used for application update and restart
The application mounts:
```text
./data:/data
```
Host networking is the initial preferred design because it most closely preserves current rover RTSP, WebRTC ICE, UDP media, camera, and LAN integration behavior. The exact listeners must be audited before finalizing the Compose file.
Expected externally relevant listeners are:
- Node HTTP, Socket.IO, and proxied WHEP signaling on TCP 8080
- Rover RTSP publishing on TCP 8554
- WebRTC media on TCP and UDP 8189
MediaMTX WHEP on 8889, API/metrics listeners, and server-local SRT should stay on loopback unless an identified remote consumer requires otherwise.
## 12. Hardware access with minimal host setup
The host must still provide the kernel and system services that containers cannot safely configure for themselves.
Kinect requirements:
- One host udev rule granting the intended device access
- The necessary USB device mount, likely `/dev/bus/usb` because Kinect device numbering can change
- libfreenect and the worker inside the image
Balance Board requirements:
- Host Bluetooth daemon configuration required by the existing raw HID design
- Access to the host BlueZ D-Bus socket
- Only the network capabilities required by the native worker
- No fully privileged main application container
The exact capabilities and device permissions must be proven on the real server hardware. This development machine is not the actual server and cannot complete that validation.
The host should not need Node, npm, MediaMTX, ffmpeg, neolink, application source, or a Multirover systemd unit after cutover.
## 13. Health checks
Add an internal health endpoint that verifies:
- Node is accepting requests.
- Configuration initialization and migrations succeeded.
- The data directory is readable and writable.
- Required SQLite databases are usable.
- MediaMTX is running and its internal endpoint responds.
Optional remote integrations should report degraded status to administrators without forcing a container restart loop. Home Assistant, Discord, a camera, or an LLM server being offline does not mean the application process itself is unhealthy.
Compose should use the health endpoint and a restart policy suitable for unattended operation.
## 14. Restricted lifecycle container
The main web application must not mount the Docker socket. Docker socket access is effectively host-root access.
A small lifecycle container should be the only component with Docker control. It should:
- Have no public network port.
- Accept requests only through a shared Unix socket under `data/system` or another private Compose-only channel.
- Operate only on the fixed Multirover application service.
- Reject arbitrary command lines, service names, image names, and Compose arguments.
- Persist update job state so it survives replacement of the application container.
- Report current version and image digest.
- Pull the configured release image.
- Restart or recreate the application container.
- Wait for the application health check.
- Retain and restore the previous image when the replacement fails.
The `/admin` System section should expose:
- Current version and image digest
- Check for update
- Update and restart
- Restart application
- Update progress and recent output
- Last update result
- Rollback result
These operations require a lockdown administrator and recent password confirmation. The browser must expect its socket to disappear, show a reconnect state, and retrieve the persistent job result after the new application becomes healthy.
The Compose contract should remain stable so ordinary application releases replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
## 15. GHCR release automation
Add repository automation that:
- Builds the production image from a clean checkout.
- Runs server tests, focused web UI tests/lint, and the production web build before publishing.
- Builds each explicitly supported server architecture.
- Publishes immutable commit/release tags to GHCR.
- Publishes one documented stable channel used by the lifecycle updater.
- Records image digests and source revision metadata.
- Avoids publishing when required verification fails.
The deployed server pulls a prebuilt image. It does not run `git pull`, `npm install`, native compilation, or web UI compilation.
## 16. Container cutover
The actual deployment migration should:
1. Download and validate a full Phase 1 backup.
2. Stop and disable the legacy Multirover systemd service.
3. Ensure no legacy MediaMTX service remains active.
4. Place the Compose file beside the existing data directory or move that directory once while the service is stopped.
5. Start the application and lifecycle containers.
6. Confirm that database migrations complete.
7. Confirm the active configuration revision and administrator access.
8. Verify rover connectivity, media publishing, WHEP playback, replay, cameras, and enabled hardware/integrations.
9. Exercise application restart through `/admin`.
10. Exercise an image update and health-check result.
11. Retain the Phase 1 backup until the container deployment has been accepted.
The old systemd application and the Compose application must never run concurrently because they would compete for HTTP and media ports.
## 17. Phase 2 completion gate
Containerization is complete when:
- A new host can start from one Compose file and an empty data directory.
- Existing state can be restored from a Phase 1 full backup.
- `./data:/data` is the only persistent application mount.
- Replacing the application container preserves all state.
- The special external `/video` MediaMTX route is unnecessary.
- The main container has no Docker socket access and is not fully privileged.
- Admin-triggered restart works.
- Admin-triggered update works and persists progress across reconnection.
- A failed image health check rolls back to the prior image.
- GHCR images are reproducibly built from repository releases.
- Kinect and Balance Board behavior has been verified on the actual host.
- Node, npm, application source, and media binaries are no longer installed directly on the host.
# Recommended implementation order
Within the two hard phase boundaries, the safest order is:
- [x] Complete the filesystem audit and single data-directory migration.
- [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.
- [x] Apply every configuration revision to running services without restarting the application.
- [x] Standardize graceful application restart.
- [x] Implement online backup and restart-bound staged restore in one service.
- [x] Add the internal `/video` proxy and make the special external route unnecessary.
- [ ] Run the full Phase 1 completion gate on the legacy deployment.
- [ ] Build and verify the production application image.
- [ ] Add Compose, data mounting, networking, and hardware access.
- [ ] Add GHCR build and publication automation.
- [ ] Add the restricted lifecycle container and connect the System UI.
- [ ] Test update, rollback, backup restore, and hardware on the actual server.
- [ ] Perform the final systemd-to-Compose cutover.
This order gives each invasive change one clear source of failures and leaves the container phase responsible for packaging and supervision rather than unfinished application architecture.
-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
+12
View File
@@ -1,3 +1,10 @@
const backupRestoreService = require('./src/services/backupRestoreService');
// A staged restore must replace data before configuration, identity, or report
// services open SQLite. Requiring the service here is safe because its runtime
// HTTP/socket dependencies remain lazy until register() is called below.
backupRestoreService.applyPendingRestore();
require('./src/globals/logger');
require('./src/globals/config');
require('./src/globals/http');
@@ -8,6 +15,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');
@@ -62,4 +73,5 @@ require('./src/services/replayEngineV2');
// Discord feature so web requests always have a local delivery path.
require('./src/services/replayDeliveryService');
require('./src/services/discordBotService');
backupRestoreService.register();
require('./src/services/httpServer');
+31 -23
View File
@@ -11,8 +11,6 @@ CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots"
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
@@ -30,9 +28,10 @@ fi
TARGET_USER="$SUDO_USER"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR="$SCRIPT_DIR"
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"
@@ -281,10 +274,23 @@ rm -f "$MEDIAMTX_SERVICE"
rm -f /etc/mediamtx/mediamtx.yml
echo "[4/6] Writing systemd units..."
mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
# The repository data directory is the legacy deployment's single persistence
# root and becomes the one bind-mounted /data directory during containerization.
# Create only the snapshot child eagerly because MediaMTX's hook writes there;
# the other services already create their own children when those features run.
mkdir -p "$DATA_DIR" "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$DATA_DIR" "$SNAPSHOT_DIR"
# Previous installers used these two /var/lib directories. Replay code already
# stopped reading its old location, and snapshots regenerate immediately, so do
# not merge possibly stale runtime media over the new canonical data tree. Keep
# an existing directory untouched and report it for deliberate cleanup after the
# operator verifies the upgraded server.
for legacy_dir in /var/lib/rover-snapshots /var/lib/replay-segments; do
if [[ -d "$legacy_dir" ]]; then
echo " Legacy runtime directory is no longer used: $legacy_dir"
fi
done
cat > "$MULTIROVER_SERVICE" <<EOF
[Unit]
Description=Multi-Roomba Rover control server
@@ -296,12 +302,13 @@ User=$TARGET_USER
Group=$TARGET_USER
WorkingDirectory=$SERVER_DIR
Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
Environment=SERVER_DATA_DIR=$DATA_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure
# Application-requested restarts use the same clean SIGTERM path as an
# operator stop. Restart=always lets that process exit come back automatically,
# while an explicit `systemctl stop` still remains stopped by systemd design.
Restart=always
RestartSec=2
SuccessExitStatus=130 143
@@ -321,8 +328,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."
+10 -1
View File
@@ -5,7 +5,16 @@
set -euo pipefail
PATH_NAME="${MTX_PATH:-}"
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
# Node resolves and supplies SERVER_DATA_DIR when it starts MediaMTX, and
# MediaMTX carries that environment into this runOnReady hook. Requiring that
# single root prevents the writer from silently recreating the former /var/lib
# snapshot store while the readers are looking inside the mounted data folder.
if [[ -z "${SERVER_DATA_DIR:-}" ]]; then
echo "SERVER_DATA_DIR is required for rover snapshot output" >&2
exit 1
fi
SNAP_DIR="${SERVER_DATA_DIR}/rover-snapshots"
# Ignore non-rover-video paths.
case "$PATH_NAME" in
+6 -1
View File
@@ -5,15 +5,19 @@
"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",
"express": "^4.19.2",
"fuse.js": "^7.4.2",
"home-assistant-js-websocket": "^3.1.2",
"http-proxy-middleware": "^3.0.7",
"js-yaml": "^4.1.1",
"kokoro-js": "^1.2.1",
"luxon": "^3.7.2",
@@ -24,6 +28,7 @@
"reolink-nvr-api": "^0.3.0",
"sharp": "^0.33.5",
"socket.io": "^4.7.5",
"tar": "^7.5.22",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},
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 @@
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.configuration-card{min-width:0px}.configuration-card-header{top:var(--configuration-sticky-top, 0px)}.configuration-card .configuration-card{margin-left:1rem;width:calc(100% - 1rem)}.configuration-card-body>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-card-body{padding:.125rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-key-label{font-size:1rem;line-height:1.5rem;font-weight:600;line-height:1.375;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.875rem;line-height:1.25rem;line-height:1.375;--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{max-width:56rem}.configuration-value-description{margin-bottom:.125rem;max-width:40rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-item-actions,.configuration-array-actions{display:flex;flex-wrap:wrap;gap:.125rem}
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-DyvmFiy4.js";import{l as q,m as D,n as I}from"./api-BVIrbPAA.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-BiB6Mx6k.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
function o(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const n=new Error(r.error);n.code=r.code||null,n.validationErrors=r.validationErrors||[],n.currentRevision=r.currentRevision||null,s(n);return}e(r)})})}const c=t=>o(t,"adminConfig:get"),u=(t,i)=>o(t,"adminConfig:confirmPassword",{password:i}),d=(t,i)=>o(t,"adminConfig:updateConfiguration",i),m=(t,i)=>o(t,"adminConfig:importConfigurationFile",i),f=(t,i)=>o(t,"adminConfig:restoreRevision",i),p=(t,i)=>o(t,"adminConfig:createAdministrator",i),l=(t,i)=>o(t,"adminConfig:updateAdministrator",i),g=(t,i)=>o(t,"adminConfig:deleteAdministrator",{id:i}),C=t=>o(t,"server:restartApplication"),A=t=>o(t,"backupRestore:status"),R=t=>o(t,"backupRestore:createBackup"),k=t=>o(t,"backupRestore:createRestoreUpload"),v=(t,i)=>o(t,"backupRestore:confirmRestore",{restoreId:i}),F=t=>o(t,"setup:status"),b=(t,i)=>o(t,"setup:createAdministrator",i),w=(t,i)=>o(t,"setup:importConfigurationFile",i);export{C as a,R as b,p as c,g as d,k as e,v as f,A as g,d as h,m as i,c as j,u as k,F as l,b as m,w as n,f as r,l as u};
//# sourceMappingURL=api-BVIrbPAA.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"api-BVIrbPAA.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 importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', 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 });\nexport const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');\nexport const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');\nexport const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');\nexport const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');\nexport const confirmFullRestore = (socket, restoreId) => emitAdminRequest(socket, 'backupRestore:confirmRestore', { restoreId });\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","importAdminConfigurationFile","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","restartApplication","getBackupRestoreStatus","createFullBackup","createRestoreUpload","confirmFullRestore","restoreId","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,sCAAuCE,CAAO,EAC3HU,EAA+B,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9Ga,EAAsB,CAACf,EAAQgB,IAAOjB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAgB,CAAE,CAAE,EACxGC,EAAsBjB,GAAWD,EAAiBC,EAAQ,2BAA2B,EACrFkB,EAA0BlB,GAAWD,EAAiBC,EAAQ,sBAAsB,EACpFmB,EAAoBnB,GAAWD,EAAiBC,EAAQ,4BAA4B,EACpFoB,EAAuBpB,GAAWD,EAAiBC,EAAQ,mCAAmC,EAC9FqB,EAAqB,CAACrB,EAAQsB,IAAcvB,EAAiBC,EAAQ,+BAAgC,CAAE,UAAAsB,CAAS,CAAE,EAElHC,EAAkBvB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEwB,EAA2B,CAACxB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GuB,EAA0B,CAACzB,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
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-BSetI9nD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJhimuQc.css">
<script type="module" crossorigin src="/assets/index-DyvmFiy4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D0j33plX.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,508 @@
// 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 { execFileSync } = require('child_process');
const Database = require('better-sqlite3');
const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation');
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
const { migrations } = require('./migrations');
const { getFeatureFlags } = require('./index');
const { createConfigurationDatabase } = require('./database');
const {
parseConfigurationFile,
buildSecretOperationsForImport,
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') });
}
function collectUndocumentedSchemaPaths(schema, pathLabel = '$') {
/*
The admin editor is entirely schema-generated, so missing schema prose is
missing operator documentation. Walk objects, arrays, array item schemas,
and scalar leaves instead of checking only named service definitions; this
makes every visible level of the hierarchy uphold the same contract.
*/
if (!schema || typeof schema !== 'object') return [];
const missing = typeof schema.description === 'string' && schema.description.trim()
? []
: [pathLabel];
if (schema.properties) {
Object.entries(schema.properties).forEach(([key, childSchema]) => {
missing.push(...collectUndocumentedSchemaPaths(childSchema, `${pathLabel}.${key}`));
});
}
if (schema.items) {
missing.push(...collectUndocumentedSchemaPaths(schema.items, `${pathLabel}[]`));
}
return missing;
}
function collectSchemaPathsMissingInputExamples(schema, value, pathLabel = '$', insideArray = false) {
/*
Universal defaults such as timeouts and modes are real saved values. Empty
strings and newly-created array items are different: they require an
installation-specific value, so the admin form must show an example without
persisting a fake hostname, credential, or hardware ID. This walk enforces
that distinction across both the current default document and array shapes.
*/
if (!schema || typeof schema !== 'object') return [];
if (schema.type === 'array') {
return collectSchemaPathsMissingInputExamples(schema.items, undefined, `${pathLabel}[]`, true);
}
if (schema.type === 'object') {
return Object.entries(schema.properties || {}).flatMap(([key, childSchema]) => (
collectSchemaPathsMissingInputExamples(childSchema, value?.[key], `${pathLabel}.${key}`, insideArray)
));
}
// Enumerations and checkboxes already communicate their accepted shape
// through their controls, so placeholder examples are only required for
// otherwise free-form empty scalar inputs.
const needsExample = (value === '' || insideArray)
&& !Array.isArray(schema.enum)
&& schema.type !== 'boolean';
if (!needsExample) return [];
return Array.isArray(schema.examples) && schema.examples.length ? [] : [pathLabel];
}
function collectEmptyStringPaths(value, pathLabel = '$') {
/*
Empty-string policy is intentionally tested by path because these four
fields are exceptional for security or visible behavior, not omissions in
the legacy-style default document. Walking the complete value also catches
an accidentally blank field inside a pre-populated example collection.
*/
if (Array.isArray(value)) {
return value.flatMap((item, index) => collectEmptyStringPaths(item, `${pathLabel}[${index}]`));
}
if (value && typeof value === 'object') {
return Object.entries(value).flatMap(([key, childValue]) => (
collectEmptyStringPaths(childValue, `${pathLabel}.${key}`)
));
}
return value === '' ? [pathLabel] : [];
}
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('legacy-style defaults populate every non-secret and inactive-content value', () => {
/*
Credentials must not masquerade as configured, and driver HTML would be
immediately visible without an enable switch. Every other free-form value
should match the populated template behavior operators had with YAML.
*/
assert.deepEqual(collectEmptyStringPaths(defaultConfig), [
'$.homeAssistant.token',
'$.ptzCamera.password',
'$.discord.token',
'$.driverAd.html',
]);
assert.ok(defaultConfig.interInstance.directoryUrls.length > 0);
assert.ok(defaultConfig.homeAssistant.entities.length > 0);
assert.ok(defaultConfig.homeAssistant.buttons.length > 0);
assert.ok(defaultConfig.roomCameras.cameras.length > 0);
assert.ok(defaultConfig.socials.links.length > 0);
});
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('every configuration section, collection, item, and option has an operator description', () => {
/*
New configuration remains self-documenting by default. Reporting every
dotted path in one assertion gives a contributor an exact repair list and
avoids recreating a separately maintained documentation registry.
*/
assert.deepEqual(collectUndocumentedSchemaPaths(rootSchema), []);
});
test('empty installation-specific fields and array item inputs provide schema-owned examples', () => {
/*
The frontend derives placeholders from these examples generically. Keeping
this assertion beside schema composition prevents an empty, unexplained box
from returning when a service adds configuration in the future.
*/
assert.deepEqual(collectSchemaPathsMissingInputExamples(rootSchema, defaultConfig), []);
});
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: { additionalHosts: [] } });
assert.equal(normalized.publicUrl, 'https://rover.example.com');
assert.deepEqual(normalized.media.additionalHosts, []);
assert.doesNotThrow(() => assertValidConfig(normalized));
const invalid = normalizeConfig({ media: { additionalHosts: [], 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('database migration consolidates existing public URLs and removes obsolete media addressing', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-public-url-migration-'));
temporaryRoots.push(root);
const databasePath = path.join(root, 'configuration.sqlite');
const legacyDatabase = new Database(databasePath);
legacyDatabase.exec(`
CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
${migrations[0].sql}
`);
legacyDatabase.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(Date.now());
const legacyConfig = structuredClone(defaultConfig);
delete legacyConfig.publicUrl;
legacyConfig.interInstance.profile.publicUrl = 'https://rover.example.com';
legacyConfig.discord.enabled = true;
legacyConfig.discord.siteUrl = 'https://canonical.example.com';
legacyConfig.media.whepBaseUrl = 'http://127.0.0.1:8889/video';
const inserted = legacyDatabase.prepare(`
INSERT INTO configuration_revisions (config_json, created_at, actor, source)
VALUES (?, ?, 'test', 'legacy-shape')
`).run(JSON.stringify(legacyConfig), Date.now());
legacyDatabase.prepare('INSERT INTO configuration_state (singleton, active_revision_id) VALUES (1, ?)')
.run(inserted.lastInsertRowid);
legacyDatabase.close();
const migrated = createConfigurationDatabase({ databasePath });
const active = migrated.getActiveConfigurationRecord().config;
assert.equal(active.publicUrl, 'https://canonical.example.com');
assert.equal(Object.hasOwn(active.interInstance.profile, 'publicUrl'), false);
assert.equal(Object.hasOwn(active.discord, 'siteUrl'), false);
assert.equal(Object.hasOwn(active.media, 'whepBaseUrl'), false);
migrated.close();
});
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 imports current fields, ignores obsolete keys, and preserves bcrypt hashes exactly once', () => {
const yamlText = `
admins:
- username: owner
password_hash: "$2b$10$preservedHash"
discord_id: "1234"
lockdown: true
publicUrl: https://production.example.com
timezone: America/Chicago
media:
whepBaseUrl: http://localhost:8889/video
overseerControl:
enabled: false
heartbeatMs: 30000
alwaysRunModel: false
homeAssistant:
neato:
enabled: false
brainslugHost: neato-vacuum.local
brainslugKey: retired-secret
brainslugLogFile: /tmp/retired.log
roomCameras:
enabled: true
cameras:
- id: stream-only
name: Stream-only camera
streamUrl: http://camera.local/stream.mjpg
discord:
channels:
chatBridge: "123456789012345678"
roles:
stalker: "123456789012345678"
fleetReports:
discord:
immediateCriticalAlerts: true
`;
const parsed = parseConfigurationFile(yamlText);
assert.equal(parsed.config.publicUrl, 'https://production.example.com');
assert.equal(parsed.config.timezone, 'America/Chicago');
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false);
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'alwaysRunModel'), false);
assert.equal(Object.hasOwn(parsed.config.homeAssistant.neato, 'brainslugHost'), false);
assert.equal(Object.hasOwn(parsed.config.discord.channels, 'chatBridge'), false);
assert.equal(Object.hasOwn(parsed.config.discord.roles, 'stalker'), false);
assert.equal(Object.hasOwn(parsed.config.fleetReports.discord, 'immediateCriticalAlerts'), false);
assert.deepEqual(parsed.config.roomCameras.cameras, [{
id: 'stream-only',
name: 'Stream-only camera',
streamUrl: 'http://camera.local/stream.mjpg',
}]);
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();
});
test('uploaded YAML still rejects invalid values for fields in the current schema', () => {
const yamlText = `
admins:
- username: owner
password_hash: "$2b$10$preservedHash"
lockdown: true
bandwidthSavings:
multiTabProtection: unsupported-mode
`;
assert.throws(() => parseConfigurationFile(yamlText), (error) => {
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
assert.ok(error.validationErrors.some((entry) => entry.path === '/bandwidthSavings/multiTabProtection'));
return true;
});
});
test('an administrative YAML replacement ignores accounts and only changes secrets present in the file', () => {
const database = createTestDatabase();
const initial = database.getClientConfiguration();
const seededRevision = database.updateConfiguration({
value: initial.config,
expectedRevision: initial.revision,
actor: 'secret-seed',
secretOperations: {
'homeAssistant.token': { action: 'replace', value: 'preserve-this-token' },
'ptzCamera.password': { action: 'replace', value: 'clear-this-password' },
'discord.token': { action: 'replace', value: 'replace-this-token' },
},
});
const yamlText = `
admins:
- this obsolete account entry is deliberately malformed
timezone: America/Chicago
ptzCamera:
password:
discord:
token: new-discord-token
`;
/*
An initialized installation treats the YAML as configuration data only.
Even malformed account data is ignored, while presence-aware secret
operations preserve an omitted credential, clear an explicit empty value,
and replace an explicit non-empty value.
*/
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
assert.deepEqual(parsed.administrators, []);
assert.equal(parsed.uploadedAdministratorCount, 1);
assert.deepEqual(parsed.providedSecretPaths, ['ptzCamera.password', 'discord.token']);
const revision = database.updateConfiguration({
value: parsed.config,
expectedRevision: seededRevision,
secretOperations: buildSecretOperationsForImport(parsed),
actor: 'admin-import-test',
source: 'admin-yaml:production.yaml',
});
const active = database.getActiveConfigurationRecord();
assert.equal(active.revision, revision);
assert.equal(active.source, 'admin-yaml:production.yaml');
assert.equal(active.config.homeAssistant.token, 'preserve-this-token');
assert.equal(active.config.ptzCamera.password, '');
assert.equal(active.config.discord.token, 'new-discord-token');
assert.equal(active.config.timezone, 'America/Chicago');
assert.equal(database.listAdministrators().length, 0);
database.close();
});
test('committed revisions replace the live snapshot and isolate service reload failures', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-live-configuration-'));
temporaryRoots.push(root);
const serverRoot = path.resolve(__dirname, '../..');
const script = `
const configuration = require('./src/configuration');
const applied = [];
configuration.registerConfigurationHandler('timezone', (next, previous) => {
applied.push({ section: 'timezone', next, previous });
});
configuration.registerConfigurationHandler('media', () => {
throw new Error('simulated media reload failure');
});
const database = configuration.getConfigurationDatabase();
const record = database.getClientConfiguration();
const next = structuredClone(record.config);
next.timezone = 'America/Chicago';
next.media.additionalHosts = ['media.example.test'];
database.updateConfiguration({
value: next,
expectedRevision: record.revision,
actor: 'live-configuration-test',
});
configuration.applyCommittedConfiguration().then((application) => {
console.log(JSON.stringify({
application,
applied,
liveTimezone: configuration.loadConfig().timezone,
liveRevision: configuration.getRuntimeConfigurationRevision(),
}));
database.close();
});
`;
const output = execFileSync(process.execPath, ['-e', script], {
cwd: serverRoot,
env: { ...process.env, SERVER_DATA_DIR: root },
encoding: 'utf8',
});
const result = JSON.parse(output.trim());
/*
A failing integration remains visible in application status but cannot
roll back the valid revision or prevent an unrelated service from seeing
it. This is the central guarantee that makes live application usable on a
server where optional hardware may be offline during an ordinary edit.
*/
assert.equal(result.liveTimezone, 'America/Chicago');
assert.equal(result.liveRevision, result.application.revision);
assert.deepEqual(result.application.changedSections, ['timezone', 'media']);
assert.deepEqual(result.applied, [{
section: 'timezone',
next: 'America/Chicago',
previous: defaultConfig.timezone,
}]);
assert.deepEqual(result.application.services, [
{ section: 'timezone', status: 'applied' },
{ section: 'media', status: 'failed', error: 'simulated media reload failure' },
]);
});
@@ -0,0 +1,165 @@
// Configuration File Importer
// Purpose: Validates a deliberately uploaded legacy YAML file for first-run setup or an explicit administrative replacement.
// Scope: Startup and installation never search for or consume configuration files; every import begins with a browser-selected file.
const yaml = require('js-yaml');
const {
rootSchema,
secretPaths,
normalizeConfig,
assertValidConfig,
} = require('./validation');
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
function getAtPath(object, dottedPath) {
return String(dottedPath || '').split('.').filter(Boolean)
.reduce((value, key) => value?.[key], object);
}
function setAtPath(object, dottedPath, value) {
const parts = String(dottedPath || '').split('.').filter(Boolean);
let cursor = object;
parts.slice(0, -1).forEach((key) => {
cursor = cursor[key];
});
cursor[parts.at(-1)] = value;
}
function hasAtPath(object, dottedPath) {
/*
Presence, rather than truthiness, distinguishes an omitted legacy secret
from an explicitly empty one. An omitted credential must preserve the
running installation's value, while an empty YAML value deliberately
clears it through the same operation used by the schema form.
*/
let cursor = object;
for (const key of String(dottedPath || '').split('.').filter(Boolean)) {
if (cursor === null || typeof cursor !== 'object' || !Object.hasOwn(cursor, key)) return false;
cursor = cursor[key];
}
return true;
}
function keepCurrentSchemaFields(value, schema) {
/*
An uploaded file is only a convenient seed for the current configuration;
it is not a second schema or a historical migration framework. Legacy YAML
was permissive, so real installations naturally contain keys left behind
by removed features. At object boundaries, copy only properties that exist
in today's schema and recursively apply the same rule to nested objects and
array items. Known fields retain their original values and are validated
normally afterward, so this cannot hide a malformed current setting.
*/
if (schema?.type === 'object') {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
return Object.fromEntries(Object.entries(schema.properties || {})
.filter(([key]) => Object.hasOwn(value, key))
.map(([key, childSchema]) => [key, keepCurrentSchemaFields(value[key], childSchema)]));
}
if (schema?.type === 'array') {
if (!Array.isArray(value)) return value;
return value.map((item) => keepCurrentSchemaFields(item, schema.items));
}
return value;
}
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, { includeAdministrators = true } = {}) {
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 uploadedAdministrators = Array.isArray(parsed.admins) ? parsed.admins : [];
/*
First-run setup is the sole workflow allowed to create accounts from the
legacy file. An initialized server ignores the entire admins collection,
including obsolete or malformed entries, so importing configuration can
never rename accounts, replace password hashes, or remove the last
lockdown administrator.
*/
const administrators = includeAdministrators
? uploadedAdministrators.map(normalizeUploadedAdministrator)
: [];
const configInput = Object.fromEntries(
Object.entries(parsed).filter(([key]) => key !== 'admins'),
);
secretPaths.forEach((secretPath) => {
/*
YAML commonly represents `token:` as null even though the application
models an unconfigured credential as an empty string. Translate null only
for known secret fields so a plainly empty legacy credential has the same
clear meaning as the admin form; null in any ordinary current field still
fails its schema normally.
*/
if (hasAtPath(configInput, secretPath) && getAtPath(configInput, secretPath) === null) {
setAtPath(configInput, secretPath, '');
}
});
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
const providedSecretPaths = secretPaths.filter((secretPath) => hasAtPath(configInput, secretPath));
// Filtering applies only to nonexistent keys. Values retained for current
// schema fields still have to satisfy every type, range, and format rule
// before the importer can atomically initialize the database.
assertValidConfig(config);
return {
config,
administrators,
uploadedAdministratorCount: uploadedAdministrators.length,
providedSecretPaths,
};
}
function buildSecretOperationsForImport({ config, providedSecretPaths = [] }) {
return Object.fromEntries(providedSecretPaths.map((secretPath) => {
const value = getAtPath(config, secretPath);
/*
Current secret schemas are strings. Keeping this conversion beside the
importer produces the database's narrow replace/clear contract and
avoids granting the import route a way around ordinary secret handling.
*/
return value === ''
? [secretPath, { action: 'clear' }]
: [secretPath, { action: 'replace', value }];
}));
}
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 = {
MAX_CONFIGURATION_FILE_BYTES,
parseConfigurationFile,
buildSecretOperationsForImport,
importConfigurationFile,
};
+410
View File
@@ -0,0 +1,410 @@
// 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,
source = 'admin-ui',
}) {
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,
/*
Administrative imports use this same safe update path but identify the
selected filename in revision and audit history. The source remains
server-controlled metadata and never contains configuration values.
*/
source,
});
}
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),
}));
}
function recordAuditEvent(actor, action, details = {}) {
/*
Operational admin services need the same persistent audit trail as
configuration changes, but they must not gain access to the underlying
statement or database handle. This narrow method retains the existing
redacted-details contract at the database boundary.
*/
writeAudit(actor, action, details);
}
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);
}
function backupDatabase(destinationPath) {
/*
SQLite's online backup API produces one coherent database file while the
live WAL-backed connection remains open. The backup service receives only
this narrow operation, never the private database handle.
*/
return db.backup(destinationPath);
}
return {
databasePath,
getActiveConfigurationRecord,
getClientConfiguration,
updateConfiguration,
listConfigurationRevisions,
restoreConfigurationRevision,
listAdministrators,
findAdministratorForAuthentication,
createAdministrator,
updateAdministrator,
deleteAdministrator,
countLockdownAdministrators,
isSetupComplete,
listAuditEvents,
recordAuditEvent,
importConfigurationFile,
backupDatabase,
close: () => db.close(),
};
}
module.exports = {
DEFAULT_DATABASE_PATH,
createConfigurationDatabase,
redactConfiguration,
};
+125
View File
@@ -0,0 +1,125 @@
// 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.publicUrl,
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',
description: 'Complete server configuration. Changes are validated, saved as one revision, and applied live by reloading affected services.',
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,
};
+147
View File
@@ -0,0 +1,147 @@
// Configuration Service
// Purpose: Exposes the process-wide live configuration snapshot, service reload registry, and administration store.
// Scope: Makes SQLite the durable source while applying each committed revision coherently to the running process.
const EventEmitter = require('events');
const { isDeepStrictEqual } = require('util');
const { createConfigurationDatabase } = require('./database');
const { definitions, rootSchema, featureDefinitions } = require('./definition');
let singleton;
let runtimeConfiguration;
let runtimeConfigurationRevision = null;
let applicationQueue = Promise.resolve();
let lastApplication = null;
const reloadHandlers = new Map();
const configurationEvents = new EventEmitter();
function getConfigurationDatabase() {
if (!singleton) {
singleton = createConfigurationDatabase();
// Durable state is read once at startup and then replaced atomically after
// each committed save or rollback. Every caller therefore sees one complete
// revision rather than independently rereading SQLite mid-application.
const active = singleton.getActiveConfigurationRecord();
runtimeConfiguration = Object.freeze(active.config);
runtimeConfigurationRevision = active.revision;
lastApplication = {
revision: active.revision,
changedSections: [],
services: [],
appliedAt: Date.now(),
};
}
return singleton;
}
function getRuntimeConfigurationRevision() {
getConfigurationDatabase();
return runtimeConfigurationRevision;
}
function loadConfig() {
getConfigurationDatabase();
return runtimeConfiguration;
}
function registerConfigurationHandler(section, handler) {
if (!rootSchema.properties?.[section]) {
throw new Error(`Cannot register configuration handler for unknown section ${section}.`);
}
if (typeof handler !== 'function') {
throw new Error(`Configuration handler for ${section} must be a function.`);
}
const handlers = reloadHandlers.get(section) || new Set();
handlers.add(handler);
reloadHandlers.set(section, handlers);
return () => handlers.delete(handler);
}
async function applyCommittedConfiguration() {
/*
Saves are serialized even though SQLite commits synchronously. A service
reload may need to close a worker or network client asynchronously, and a
later revision must never overtake that cleanup and start a second runtime.
*/
const apply = async () => {
const active = getConfigurationDatabase().getActiveConfigurationRecord();
const previous = loadConfig();
const next = Object.freeze(active.config);
const changedSections = definitions
.map(({ key }) => key)
.filter((key) => !isDeepStrictEqual(previous[key], next[key]));
// Swap the complete document before invoking handlers. Any service helper
// consulted during a reload consequently observes the same new revision.
runtimeConfiguration = next;
runtimeConfigurationRevision = active.revision;
const services = [];
for (const section of changedSections) {
for (const handler of reloadHandlers.get(section) || []) {
try {
// Sequential application preserves the server's existing dependency
// order, notably Home Assistant before its Neato and lift consumers.
await handler(next[section], previous[section], next, previous);
services.push({ section, status: 'applied' });
} catch (error) {
// One unavailable integration must not prevent unrelated services or
// the session feature map from receiving the committed revision.
services.push({ section, status: 'failed', error: error.message });
}
}
}
lastApplication = {
revision: active.revision,
changedSections,
services,
appliedAt: Date.now(),
};
configurationEvents.emit('applied', lastApplication);
return lastApplication;
};
const queued = applicationQueue.then(apply, apply);
// Retain a fulfilled tail even if an unexpected coordinator error escapes;
// otherwise one failure would permanently poison every later save.
applicationQueue = queued.catch(() => undefined);
return queued;
}
function getLastConfigurationApplication() {
getConfigurationDatabase();
return lastApplication;
}
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,
getLastConfigurationApplication,
registerConfigurationHandler,
applyCommittedConfiguration,
configurationEvents,
loadConfig,
getFeatureFlags,
isFeatureEnabled,
rootSchema,
};
+105
View File
@@ -0,0 +1,105 @@
// 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
);
`,
},
{
version: 2,
run(db) {
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
rows.forEach((row) => {
const config = JSON.parse(row.config_json);
/*
publicUrl was formerly repeated under inter-instance, Discord, and
media settings. Preserve the public identity already selected by the
operator: enabled consumers win first, then non-example values, with
inter-instance winning an otherwise equal conflict. Remove only the
three fields replaced by the root setting and fixed /video proxy.
*/
const previousInterInstanceUrl = config.interInstance?.profile?.publicUrl;
const previousDiscordUrl = config.discord?.siteUrl;
const previousCandidates = [
config.interInstance?.enabled ? previousInterInstanceUrl : '',
config.discord?.enabled ? previousDiscordUrl : '',
previousInterInstanceUrl !== 'https://rover.example.com' ? previousInterInstanceUrl : '',
previousDiscordUrl !== 'https://rover.example.com' ? previousDiscordUrl : '',
previousInterInstanceUrl,
previousDiscordUrl,
];
config.publicUrl = config.publicUrl
|| previousCandidates.find((value) => typeof value === 'string' && value.trim())
|| 'https://rover.example.com';
if (config.interInstance?.profile) delete config.interInstance.profile.publicUrl;
if (config.discord) delete config.discord.siteUrl;
if (config.media) delete config.media.whepBaseUrl;
update.run(JSON.stringify(config), row.id);
});
},
},
];
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(() => {
if (migration.sql) db.exec(migration.sql);
if (migration.run) migration.run(db);
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,
};
+8
View File
@@ -4,9 +4,17 @@ const http = require('http');
const express = require('express');
const morgan = require('morgan');
const config = require('./config');
const logger = require('./logger').child('mediaMtxProxy');
const { PUBLIC_MEDIA_PREFIX, createMediaMtxProxy } = require('../services/mediaMtxService/proxy');
const app = express();
app.use(morgan('dev'));
/*
Mount signaling before body parsers so SDP offers and trickle-ICE fragments
remain untouched streams. Express removes the /video mount prefix while the
proxy is active, giving MediaMTX its native /<path>/whep or /<path>/whip URL.
*/
app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({ logger }));
app.use(express.json());
app.use(express.static(config.staticDir, { index: false }));
+8 -3
View File
@@ -13,8 +13,13 @@ const io = new SocketIOServer(httpServer, {
maxHttpBufferSize: 16 * 1024 * 1024,
});
// Allow more service listeners without warnings.
io.sockets.setMaxListeners(30);
io.of('/').setMaxListeners(30);
/*
Optional feature gateways now remain registered while disabled so an admin
can enable them live without adding a second listener tree. Forty is a small
explicit allowance for those one-time service owners, not an unlimited value
that could hide duplicate registrations during repeated configuration saves.
*/
io.sockets.setMaxListeners(40);
io.of('/').setMaxListeners(40);
module.exports = io;
@@ -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({ description: 'Controls multiple active driver tabs: allowed permits everyone, verifiedOnly limits ordinary unverified users, and notAllowed limits all non-admin users.', enum: ['allowed', 'verifiedOnly', 'notAllowed'] }),
pauseHiddenRoverVideo: boolean({ description: 'Stops a rover video player while its browser surface is hidden, reducing unnecessary client and server bandwidth.' }),
nonTurnVideo: strictObject({
mode: string({ description: 'snapshots replaces non-turn live rover video after the threshold is exceeded; live always permits live video.', enum: ['snapshots', 'live'] }),
userThreshold: integer({ description: 'Maximum controllable-user count allowed before snapshot mode activates for non-turn viewers; zero activates it whenever any controllable user exists.', minimum: 0, maximum: 100000 }),
}, { description: 'Controls whether users who are not currently driving receive live rover video or periodic snapshots.', required: ['mode', 'userThreshold'] }),
externalSpectatorVideo: string({ description: 'Video delivered to non-local spectator pages: snapshots conserves upload bandwidth, while live permits continuous playback.', enum: ['snapshots', 'live'] }),
externalSpectatorAccess: string({ description: 'Access for ordinary non-local spectators: off denies them, on permits them, verifiedOnly requires verification, and admin requires the spectator access grant. Local users and administrators remain allowed.', enum: ['off', 'on', 'verifiedOnly', 'admin'] }),
}, { title: 'Bandwidth savings', description: 'Defines server-owned policies for duplicate driver tabs and when live video is replaced with snapshots.', required: ['multiTabProtection', 'pauseHiddenRoverVideo', 'nonTurnVideo', 'externalSpectatorVideo', 'externalSpectatorAccess'] }),
};
+11 -11
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,9 +83,9 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
function getBandwidthSavingsPolicy() {
/*
loadConfig() is cached by configLoader, so rebuilding this small object per
caller is cheap while still letting tests pass explicit config objects into
buildBandwidthSavingsPolicy().
The configuration service returns an in-memory snapshot, so rebuilding this
small normalized object per caller is cheap and immediately follows a newly
applied revision. Tests may still supply explicit documents directly.
*/
return buildBandwidthSavingsPolicy(loadConfig());
}
-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,
};
+30 -22
View File
@@ -1,41 +1,49 @@
// data Paths helper
// Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
// Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
const fs = require('fs');
// Purpose: Defines the single filesystem boundary for all mutable, persistent server data.
// Scope: Resolves the configured data root and every application-owned mutable path beneath it.
const path = require('path');
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
function pathExists(target) {
try {
fs.accessSync(target, fs.constants.F_OK);
return true;
} catch (_err) {
return false;
}
}
const ROVER_SNAPSHOT_DIR_NAME = 'rover-snapshots';
const RUNTIME_DIR_NAME = 'runtime';
function resolveDataDir() {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.resolve(configured);
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
return CANONICAL_DATA_DIR;
}
function resolveDataPath(fileName) {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.join(path.resolve(configured), fileName);
/*
Always join through resolveDataDir instead of repeating environment handling
in individual services. This is what makes one SERVER_DATA_DIR mount contain
every database, JSON store, generated file, and persistent media directory.
*/
return path.join(resolveDataDir(), fileName);
}
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
if (pathExists(canonicalPath)) return canonicalPath;
if (pathExists(legacyPath)) return legacyPath;
return canonicalPath;
function resolveRoverSnapshotDir() {
/*
Snapshot production, polling, PTZ reads, and health reporting must use the
exact same directory. Giving this shared directory a named resolver prevents
one of those consumers from drifting back to the former /var/lib location.
*/
return resolveDataPath(ROVER_SNAPSHOT_DIR_NAME);
}
function resolveRuntimePath(...pathSegments) {
/*
Disposable files are still files intentionally managed by the Node server.
Keeping them below a named runtime directory preserves the single-root
filesystem contract without confusing scratch files with durable stores.
Callers remain responsible for deleting their own completed work.
*/
return resolveDataPath(path.join(RUNTIME_DIR_NAME, ...pathSegments));
}
module.exports = {
resolveDataDir,
resolveDataPath,
resolveRoverSnapshotDir,
resolveRuntimePath,
};
+49
View File
@@ -0,0 +1,49 @@
// Data Paths Helper Tests
// Purpose: Pins the one-root persistence contract used by local, systemd, and future container deployments.
// Scope: Exercises path resolution only and never creates files in the real server data directory.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('os');
const path = require('path');
const {
resolveDataDir,
resolveDataPath,
resolveRoverSnapshotDir,
resolveRuntimePath,
} = require('./dataPaths');
const originalDataDir = process.env.SERVER_DATA_DIR;
test.afterEach(() => {
/*
Environment state is process-global. Restore the caller's value after each
assertion so this focused test remains safe when it is composed with other
tests in the same Node process later.
*/
if (originalDataDir === undefined) delete process.env.SERVER_DATA_DIR;
else process.env.SERVER_DATA_DIR = originalDataDir;
});
test('defaults every persistent path to the canonical server data directory', () => {
delete process.env.SERVER_DATA_DIR;
const expectedRoot = path.resolve(__dirname, '..', '..', 'data');
assert.equal(resolveDataDir(), expectedRoot);
assert.equal(resolveDataPath('identity.sqlite'), path.join(expectedRoot, 'identity.sqlite'));
assert.equal(resolveRoverSnapshotDir(), path.join(expectedRoot, 'rover-snapshots'));
assert.equal(resolveRuntimePath('replay-builds'), path.join(expectedRoot, 'runtime', 'replay-builds'));
});
test('moves every persistent path beneath SERVER_DATA_DIR when it is configured', () => {
const configuredRoot = path.join(os.tmpdir(), 'multirover-data-path-test');
process.env.SERVER_DATA_DIR = configuredRoot;
assert.equal(resolveDataDir(), path.resolve(configuredRoot));
assert.equal(resolveDataPath('fleet-reports.sqlite'), path.join(configuredRoot, 'fleet-reports.sqlite'));
assert.equal(resolveDataPath(path.join('replays', 'example.mp4')), path.join(configuredRoot, 'replays', 'example.mp4'));
assert.equal(resolveRoverSnapshotDir(), path.join(configuredRoot, 'rover-snapshots'));
assert.equal(
resolveRuntimePath('audio-forward', 'uploads'),
path.join(configuredRoot, 'runtime', 'audio-forward', 'uploads'),
);
});
-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,
};
+8 -3
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',
@@ -87,6 +87,7 @@ function resolveSiteMetadata(config = loadConfig()) {
const interInstance = config?.interInstance;
const profile = interInstance?.profile;
const profileName = asTrimmedString(profile?.name);
const publicUrl = normalizePublicUrl(config?.publicUrl);
/*
A partially filled profile must not unexpectedly rename the site. The
@@ -95,7 +96,11 @@ function resolveSiteMetadata(config = loadConfig()) {
the coherent default set above.
*/
if (interInstance?.enabled !== true || !profileName) {
return { ...DEFAULT_SITE_METADATA, accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor) };
return {
...DEFAULT_SITE_METADATA,
publicUrl,
accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor),
};
}
const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor;
@@ -110,7 +115,7 @@ function resolveSiteMetadata(config = loadConfig()) {
BACKGROUND_BLEND_AMOUNT,
),
accentTextColor: getReadableAccentText(accentColor),
publicUrl: normalizePublicUrl(profile.publicUrl),
publicUrl,
};
}
@@ -0,0 +1,212 @@
// 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,
getLastConfigurationApplication,
applyCommittedConfiguration,
rootSchema,
} = require('../../configuration');
const {
MAX_CONFIGURATION_FILE_BYTES,
parseConfigurationFile,
buildSecretOperationsForImport,
} = require('../../configuration/configurationFileImporter');
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 safeUploadedFileName(value) {
/*
The filename is audit metadata only and is never opened on the server.
Removing control characters keeps logs and history readable while
retaining the operator-visible name that identifies the imported file.
*/
return String(value || 'uploaded-config.yaml')
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.slice(0, 255) || 'uploaded-config.yaml';
}
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 },
appliedRevision: getRuntimeConfigurationRevision(),
configurationApplication: getLastConfigurationApplication(),
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, async (payload) => {
const revision = database.updateConfiguration({
value: payload.value,
expectedRevision: payload.expectedRevision,
secretOperations: payload.secretOperations,
actor: actorFor(socket),
});
const application = await applyCommittedConfiguration();
return { revision, application, snapshot: buildAdminSnapshot() };
});
ackHandler(socket, 'adminConfig:importConfigurationFile', requireRecentPassword, async (payload) => {
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.');
}
/*
Parsing deliberately excludes administrators on an initialized server.
Configuration is still filtered to today's schema and strictly
validated, then committed through the ordinary optimistic update path so
missing secrets survive and explicitly supplied secrets replace or clear
their current values.
*/
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
const fileName = safeUploadedFileName(payload.fileName);
const revision = database.updateConfiguration({
value: parsed.config,
expectedRevision: payload.expectedRevision,
secretOperations: buildSecretOperationsForImport(parsed),
actor: actorFor(socket),
source: `admin-yaml:${fileName}`,
});
const application = await applyCommittedConfiguration();
return {
revision,
application,
ignoredAdministratorCount: parsed.uploadedAdministratorCount,
snapshot: buildAdminSnapshot(),
};
});
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, async (payload) => {
const revision = database.restoreConfigurationRevision({
revision: payload.revision,
expectedRevision: payload.expectedRevision,
actor: actorFor(socket),
});
const application = await applyCommittedConfiguration();
return { revision, application, snapshot: buildAdminSnapshot() };
});
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,
requireRecentPassword,
};
@@ -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({ description: 'Enables verified current drivers to publish microphone or uploaded audio to their assigned rover.' }),
ffmpegBin: string({ title: 'ffmpeg executable', description: 'Executable name or path used to run the long-lived audio publishing and playback workers.', minLength: 1, maxLength: 500 }),
streamSuffix: string({ description: 'Suffix appended to each rover ID to form its internal MediaMTX forwarded-audio stream path.', minLength: 1, maxLength: 80 }),
maxUploadBytes: integer({ description: 'Maximum accepted size in bytes for one uploaded audio clip.', minimum: 262144, maximum: 1073741824 }),
}, { title: 'Audio forwarding', description: 'Controls browser-to-rover audio publishing, temporary uploaded clips, and the ffmpeg workers that feed MediaMTX.', required: ['enabled', 'ffmpegBin', 'streamSuffix', 'maxUploadBytes'] }),
};
@@ -7,7 +7,7 @@ function registerAudioForwardHooks(deps) {
roverManager,
turnService,
logger,
serviceEnabled,
isServiceEnabled,
workers,
whipOwners,
ensureWorker,
@@ -57,7 +57,7 @@ function registerAudioForwardHooks(deps) {
stopWorker(roverId);
return;
}
if (action === 'upsert' && serviceEnabled && !workers.has(roverId)) {
if (action === 'upsert' && isServiceEnabled() && !workers.has(roverId)) {
// A rover coming online should not create ffmpeg publishers by itself.
// The audio worker is intentionally lazy because uploads, mic forwarding,
// and automatic sounds are the moments that actually need a media pipe;
@@ -5,7 +5,8 @@ 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, registerConfigurationHandler } = require('../../configuration');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const turnService = require('../turnService');
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
@@ -16,20 +17,15 @@ const { registerAudioForwardHooks } = require('./hooks');
const { registerChargeCompleteSound } = require('./chargeCompleteSound');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
const audioForwardConfig = config.audioForward || {};
const mediaConfig = config.media || {};
const serviceEnabled = audioForwardConfig.enabled !== false;
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
let serviceEnabled = false;
/*
FIFOs and uploaded clips are disposable, but they are deliberately created
and managed by this application. A fixed path below SERVER_DATA_DIR keeps the
Node process from writing to an unrelated host temp directory and prevents a
configuration value from escaping the server's filesystem boundary.
*/
const runtimeDir = resolveRuntimePath('audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker
@@ -60,51 +56,65 @@ function getAudioForwardState() {
return payload;
}
const audioForwardPolicy = createAudioForwardPolicy({
isVerified,
isMuted,
roverManager,
turnService,
streamSuffix,
mediaConfig,
});
const {
ensureAudioForwardPermission,
resolveForwardUrl,
resolveForwardPathId,
buildWhipUrl,
} = audioForwardPolicy;
let operations;
const workerEngine = createAudioForwardWorkerEngine({
logger,
io,
roverManager,
turnService,
videoSessions,
serviceEnabled,
ffmpegBin,
runtimeDir,
uploadsDir,
maxUploadBytes,
workers,
whipOwners,
setState,
resolveForwardUrl,
resolveForwardPathId,
});
function replaceAudioForwardRuntime(fullConfig) {
operations?.stopAllWorkers('configuration-change');
const audioForwardConfig = fullConfig.audioForward || {};
serviceEnabled = Boolean(audioForwardConfig.enabled);
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const policy = createAudioForwardPolicy({
isVerified,
isMuted,
roverManager,
turnService,
streamSuffix,
});
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
operations = {
...policy,
...createAudioForwardWorkerEngine({
logger,
io,
roverManager,
turnService,
videoSessions,
serviceEnabled,
ffmpegBin: audioForwardConfig.ffmpegBin || 'ffmpeg',
runtimeDir,
uploadsDir,
maxUploadBytes,
workers,
whipOwners,
setState,
resolveForwardUrl: policy.resolveForwardUrl,
resolveForwardPathId: policy.resolveForwardPathId,
}),
};
}
const {
ensureWorker,
stopWorker,
stopAllWorkers,
playUploadedAudio,
playServerAudioFile,
stopPlayback,
revokeWhipSessionForRover,
stopWhipForRover,
stopOwnedAudioIfUnauthorized,
startSilenceWriter,
} = workerEngine;
replaceAudioForwardRuntime(loadConfig());
// Stable delegates keep the one-time socket/event registrations below pointed
// at the newest policy and worker engine after audio-forward changes.
const delegate = (name) => (...args) => operations[name](...args);
const ensureWorker = delegate('ensureWorker');
const stopWorker = delegate('stopWorker');
const stopAllWorkers = delegate('stopAllWorkers');
const playUploadedAudio = delegate('playUploadedAudio');
const playServerAudioFile = delegate('playServerAudioFile');
const stopPlayback = delegate('stopPlayback');
const revokeWhipSessionForRover = delegate('revokeWhipSessionForRover');
const stopWhipForRover = delegate('stopWhipForRover');
const stopOwnedAudioIfUnauthorized = delegate('stopOwnedAudioIfUnauthorized');
const startSilenceWriter = delegate('startSilenceWriter');
const ensureAudioForwardPermission = delegate('ensureAudioForwardPermission');
const resolveForwardPathId = delegate('resolveForwardPathId');
const buildWhipUrl = delegate('buildWhipUrl');
function installShutdownHooks() {
const shutdown = (signal) => {
@@ -126,7 +136,7 @@ registerAudioForwardHooks({
roverManager,
turnService,
logger,
serviceEnabled,
isServiceEnabled: () => serviceEnabled,
workers,
whipOwners,
ensureWorker,
@@ -151,6 +161,10 @@ registerChargeCompleteSound({
playServerAudioFile,
});
registerConfigurationHandler('audioForward', (_section, _previous, nextConfig) => {
replaceAudioForwardRuntime(nextConfig);
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
@@ -1,6 +1,8 @@
// audio Forward Service policy
// Purpose: Encapsulates permission checks and media path/url derivation helpers.
// Scope: Keeps runtime behavior unchanged while isolating validation and path-construction logic.
const { PUBLIC_MEDIA_PREFIX } = require('../mediaMtxService/proxy');
function createAudioForwardPolicy(deps) {
const {
isVerified,
@@ -8,7 +10,6 @@ function createAudioForwardPolicy(deps) {
roverManager,
turnService,
streamSuffix,
mediaConfig,
} = deps;
function ensureVipVerified(socket) {
@@ -43,23 +44,8 @@ function createAudioForwardPolicy(deps) {
return `${roverId}${streamSuffix}`;
}
function getMediaPrefix() {
const base = mediaConfig.whepBaseUrl;
if (!base) return '';
try {
const parsed = new URL(base);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
} catch {
return String(base).replace(/\/+$/, '');
}
}
function buildWhipUrl(pathId) {
const prefix = getMediaPrefix();
if (!prefix) {
throw new Error('Server media base URL missing');
}
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
return `${PUBLIC_MEDIA_PREFIX}/${encodeURIComponent(pathId)}/whip`;
}
return {
@@ -12,7 +12,6 @@ function createPolicy({ verified = true, muted = false, driver = true, canDrive
roverManager: { isDriver: () => driver },
turnService: { canDrive: () => canDrive },
streamSuffix: '-fwd',
mediaConfig: {},
});
}
@@ -30,3 +29,8 @@ test('publishes forwarded audio to the local MediaMTX RTSP path', () => {
const policy = createPolicy();
assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd');
});
test('publishes browser microphone signaling through the same-origin proxy', () => {
const policy = createPolicy();
assert.equal(policy.buildWhipUrl('rover one-fwd'), '/video/rover%20one-fwd/whip');
});
@@ -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({ description: 'Server base multiplier for external horn playback, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
ttsGain: number({ title: 'TTS gain', description: 'Server base multiplier for text-to-speech playback, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
forwardGain: number({ description: 'Server base multiplier for browser-forwarded and uploaded audio, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
maxPersonalAdjustmentPercent: integer({ description: 'Largest positive or negative percentage adjustment permitted for users granted personal audio controls; zero disables personal variation.', minimum: 0, maximum: 100 }),
}, { title: 'Audio levels', description: 'Sets the initial server-owned playback gains and the allowed range for per-user adjustments.', 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, registerConfigurationHandler } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin, roleEvents } = require('../roleService');
const roverManager = require('../roverManager');
@@ -344,6 +344,32 @@ io.on('connection', (socket) => {
loadState();
registerConfigurationHandler('audioLevels', async (nextConfig) => {
/*
Audio levels also have a durable operational store because administrators
can adjust them outside the configuration editor. Applying a configuration
revision intentionally updates that same live state, rather than changing
startup fallbacks that an existing store would immediately override.
*/
const current = loadState();
persistState({
...current,
hornGain: clampGain(nextConfig.hornGain, current.hornGain),
ttsGain: clampGain(nextConfig.ttsGain, current.ttsGain),
forwardGain: clampGain(nextConfig.forwardGain, current.forwardGain),
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
nextConfig.maxPersonalAdjustmentPercent,
current.maxPersonalAdjustmentPercent,
),
updatedAt: Date.now(),
updatedBy: 'configuration',
adjustmentRangeUpdatedAt: Date.now(),
adjustmentRangeUpdatedBy: 'configuration',
});
pushLevelsToAllRovers();
emitChange('configuration_applied');
});
module.exports = {
ADJUSTMENT_FIELDS,
PERSONAL_ADJUSTMENT_PERMISSION,
+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,199 @@
// Full Data Backup
// Purpose: Creates one validated archive of the durable server data without stopping live services or writers.
// Scope: Uses service-owned online database snapshots and stable file copies inside the canonical data directory.
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const tar = require('tar');
const Database = require('better-sqlite3');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const packageInfo = require('../../../package.json');
const FORMAT_VERSION = 1;
const CONTROL_DIR_NAME = 'backup-restore';
const EXCLUDED_TOP_LEVEL_NAMES = new Set([CONTROL_DIR_NAME, 'runtime']);
const DATABASE_NAMES = ['configuration.sqlite', 'identity.sqlite', 'fleet-reports.sqlite'];
const DATABASE_FILES = new Set(DATABASE_NAMES.flatMap((name) => [name, `${name}-wal`, `${name}-shm`]));
const FILE_COPY_ATTEMPTS = 3;
async function removeSnapshotSidecars(payloadDir) {
/*
SQLite online backup produces a complete standalone main database file.
Reopening that snapshot to inspect its schema can still create empty WAL
and shared-memory coordination files because the database retains WAL as
its journal mode. Those files describe no durable backup content and must
be removed before the manifest inventory and tar archive are produced.
*/
await Promise.all(DATABASE_NAMES.flatMap((name) => [
fsp.rm(path.join(payloadDir, `${name}-wal`), { force: true }),
fsp.rm(path.join(payloadDir, `${name}-shm`), { force: true }),
]));
}
function readDatabaseSchemaVersions(payloadDir) {
const configuration = new Database(path.join(payloadDir, 'configuration.sqlite'), { readonly: true });
const identity = new Database(path.join(payloadDir, 'identity.sqlite'), { readonly: true });
const fleetReports = new Database(path.join(payloadDir, 'fleet-reports.sqlite'), { readonly: true });
try {
return {
configuration: Number(configuration.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version) || 0,
identity: Number(identity.pragma('user_version', { simple: true })) || 0,
// Fleet reporting currently evolves with additive startup checks and has
// no numbered migration table, so zero accurately identifies that scheme.
fleetReports: Number(fleetReports.pragma('user_version', { simple: true })) || 0,
};
} finally {
configuration.close();
identity.close();
fleetReports.close();
}
}
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
async function copyStableFile(sourcePath, destinationPath) {
for (let attempt = 0; attempt < FILE_COPY_ATTEMPTS; attempt += 1) {
try {
const before = await fsp.stat(sourcePath);
if (!before.isFile()) throw new Error(`Backup source is not a regular file: ${sourcePath}`);
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });
await fsp.copyFile(sourcePath, destinationPath);
await fsp.chmod(destinationPath, before.mode & 0o777);
/*
A writer may replace or append to a media file while it is copied. Size
and timestamp checks catch ordinary changes, while comparing hashes
catches a same-size replacement. Only the unstable file is retried;
no owning service is paused.
*/
const [sourceHash, destinationHash] = await Promise.all([
sha256File(sourcePath),
sha256File(destinationPath),
]);
// Read the final metadata only after both hashes finish. Starting this
// stat concurrently could miss a write that occurred during hashing.
const after = await fsp.stat(sourcePath);
if (before.size === after.size
&& before.mtimeMs === after.mtimeMs
&& sourceHash === destinationHash) return true;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
// A rotating snapshot can disappear between directory enumeration and
// copying. Treat that exact race like any other unstable active file.
}
await fsp.rm(destinationPath, { force: true });
}
return false;
}
async function copyDurableTree(sourceDir, destinationDir, relativeDir = '') {
let entries;
try {
entries = await fsp.readdir(path.join(sourceDir, relativeDir), { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return [];
throw error;
}
const skipped = [];
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue;
if (!relativeDir && DATABASE_FILES.has(entry.name)) continue;
if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`);
if (entry.isDirectory()) {
skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath));
continue;
}
if (!entry.isFile()) throw new Error(`Backup cannot include special file: ${relativePath}`);
const copied = await copyStableFile(
path.join(sourceDir, relativePath),
path.join(destinationDir, relativePath),
);
if (!copied) skipped.push(relativePath.split(path.sep).join('/'));
}
return skipped;
}
async function listManifestFiles(rootDir, relativeDir = '') {
const entries = await fsp.readdir(path.join(rootDir, relativeDir), { withFileTypes: true });
const files = [];
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
if (entry.isDirectory()) {
files.push(...await listManifestFiles(rootDir, relativePath));
continue;
}
if (!entry.isFile()) throw new Error(`Backup staging contains a special file: ${relativePath}`);
const filePath = path.join(rootDir, relativePath);
const stat = await fsp.stat(filePath);
files.push({
path: relativePath.split(path.sep).join('/'),
size: stat.size,
sha256: await sha256File(filePath),
});
}
return files.sort((left, right) => left.path.localeCompare(right.path));
}
async function createFullBackup({ configurationDatabase, identityService, fleetReportService, jobId }) {
const dataDir = resolveDataDir();
const jobDir = resolveDataPath(path.join(CONTROL_DIR_NAME, `backup-${jobId}`));
const payloadDir = path.join(jobDir, 'data');
const archivePath = path.join(jobDir, 'multirover-backup.tar.gz');
await fsp.rm(jobDir, { recursive: true, force: true });
await fsp.mkdir(payloadDir, { recursive: true });
try {
// Each database owner remains live and writes a coherent SQLite snapshot
// directly into the same staging tree as the ordinary durable files.
await Promise.all([
configurationDatabase.backupDatabase(path.join(payloadDir, DATABASE_NAMES[0])),
identityService.backupDatabase(path.join(payloadDir, DATABASE_NAMES[1])),
fleetReportService.backupDatabase(path.join(payloadDir, DATABASE_NAMES[2])),
]);
const skippedUnstableFiles = await copyDurableTree(dataDir, payloadDir);
/*
Finish every operation that can create a staged file before inventorying
the payload. Production databases use WAL mode, so schema inspection must
precede both sidecar cleanup and the final immutable file list.
*/
const databaseSchemaVersions = readDatabaseSchemaVersions(payloadDir);
await removeSnapshotSidecars(payloadDir);
const files = await listManifestFiles(payloadDir);
const manifest = {
format: 'multirover-full-backup',
formatVersion: FORMAT_VERSION,
applicationVersion: packageInfo.version,
createdAt: Date.now(),
databaseSchemaVersions,
files,
skippedUnstableFiles,
};
await fsp.writeFile(path.join(jobDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
await tar.c({ cwd: jobDir, file: archivePath, gzip: true, portable: true }, ['manifest.json', 'data']);
return { archivePath, jobDir, manifest };
} catch (error) {
await fsp.rm(jobDir, { recursive: true, force: true });
throw error;
}
}
module.exports = {
CONTROL_DIR_NAME,
DATABASE_NAMES,
FORMAT_VERSION,
createFullBackup,
readDatabaseSchemaVersions,
removeSnapshotSidecars,
sha256File,
};
@@ -0,0 +1,146 @@
// Backup and Restore Service Tests
// Purpose: Verifies complete archive round trips, exclusions, malicious entry rejection, startup replacement, and rollback.
// Scope: Uses one isolated SERVER_DATA_DIR and injected SQLite owners; it never reads or changes development server data.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const tar = require('tar');
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-backup-restore-'));
process.env.SERVER_DATA_DIR = temporaryRoot;
const { createFullBackup } = require('./backup');
const { inspectArchive, prepareRestoreArchive, validateExtractedRestore } = require('./restore');
const startupRestore = require('./startupRestore');
const VALID_RESTORE_ID = 'a'.repeat(64);
function createSourceDatabase(name) {
/*
Real service databases use WAL mode. Keeping fixture sources under the
excluded runtime directory both mirrors that behavior and ensures their
own live WAL files are not mistaken for ordinary durable backup content.
*/
const sourceDirectory = path.join(temporaryRoot, 'runtime', 'database-sources');
fs.mkdirSync(sourceDirectory, { recursive: true });
const filePath = path.join(sourceDirectory, name);
const database = new Database(filePath);
database.pragma('journal_mode = WAL');
database.exec('CREATE TABLE example (value TEXT NOT NULL); INSERT INTO example VALUES (\'preserved\');');
if (name === 'configuration.sqlite') {
database.exec('CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); INSERT INTO schema_migrations VALUES (1);');
} else if (name === 'identity.sqlite') {
database.pragma('user_version = 4');
}
return database;
}
test.after(() => {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
});
test('creates and validates a complete backup while excluding runtime and control data', async () => {
await fsp.writeFile(path.join(temporaryRoot, 'state.json'), '{"preserved":true}\n');
await fsp.mkdir(path.join(temporaryRoot, 'replays'), { recursive: true });
await fsp.writeFile(path.join(temporaryRoot, 'replays', 'complete.mp4'), 'complete replay');
await fsp.mkdir(path.join(temporaryRoot, 'runtime'), { recursive: true });
await fsp.writeFile(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'discard me');
const sources = ['configuration.sqlite', 'identity.sqlite', 'fleet-reports.sqlite']
.map(createSourceDatabase);
const owners = sources.map((database) => ({
backupDatabase: (destinationPath) => database.backup(destinationPath),
}));
const result = await createFullBackup({
configurationDatabase: owners[0],
identityService: owners[1],
fleetReportService: owners[2],
jobId: 'test-backup',
});
assert.ok(fs.statSync(result.archivePath).size > 0);
assert.ok(result.manifest.files.some((entry) => entry.path === 'state.json'));
assert.ok(result.manifest.files.some((entry) => entry.path === 'replays/complete.mp4'));
assert.equal(result.manifest.files.some((entry) => entry.path.includes('runtime')), false);
assert.equal(result.manifest.files.some((entry) => entry.path.includes('backup-restore')), false);
assert.equal(result.manifest.files.some((entry) => entry.path.endsWith('-wal') || entry.path.endsWith('-shm')), false);
const restoreJob = path.join(temporaryRoot, 'backup-restore', `restore-${VALID_RESTORE_ID}`);
await fsp.mkdir(restoreJob, { recursive: true });
const uploadedArchive = path.join(restoreJob, 'upload.tar.gz');
await fsp.copyFile(result.archivePath, uploadedArchive);
const summary = await prepareRestoreArchive({ archivePath: uploadedArchive, jobDir: restoreJob, actor: 'test' });
assert.equal(summary.fileCount, result.manifest.files.length);
const restoredNames = await fsp.readdir(path.join(restoreJob, 'extracted', 'data'));
assert.equal(restoredNames.some((name) => name.endsWith('-wal') || name.endsWith('-shm')), false);
sources.forEach((database) => database.close());
});
test('rejects a staged restore whose contents no longer match the manifest', async () => {
const restoreJob = path.join(temporaryRoot, 'backup-restore', `restore-${VALID_RESTORE_ID}`);
const statePath = path.join(restoreJob, 'extracted', 'data', 'state.json');
await fsp.writeFile(statePath, '{"tampered":true}\n');
await assert.rejects(validateExtractedRestore(path.join(restoreJob, 'extracted')), /checksum or size mismatch/);
// Restore the known source content so the following startup-application test
// continues to exercise a genuinely validated replacement payload.
await fsp.writeFile(statePath, '{"preserved":true}\n');
});
test('rejects symbolic links before extracting an archive', async () => {
const unsafeRoot = path.join(temporaryRoot, 'unsafe-archive');
await fsp.mkdir(path.join(unsafeRoot, 'data'), { recursive: true });
await fsp.writeFile(path.join(unsafeRoot, 'manifest.json'), '{}');
await fsp.symlink('/etc/passwd', path.join(unsafeRoot, 'data', 'escape'));
const archivePath = path.join(temporaryRoot, 'unsafe.tar.gz');
await tar.c({ cwd: unsafeRoot, file: archivePath, gzip: true }, ['manifest.json', 'data']);
await assert.rejects(inspectArchive(archivePath), /unsupported entry type/);
});
test('applies validated replacement data and removes rollback only after startup succeeds', () => {
const restoreId = VALID_RESTORE_ID;
const jobDir = path.join(temporaryRoot, 'backup-restore', `restore-${restoreId}`);
fs.writeFileSync(path.join(temporaryRoot, 'old-state.txt'), 'old');
fs.mkdirSync(path.join(temporaryRoot, 'runtime'), { recursive: true });
fs.writeFileSync(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'discard during restore');
startupRestore.writeJson(startupRestore.pendingPath, {
restoreId,
state: 'pending',
requestedAt: Date.now(),
actor: 'test',
});
const applied = startupRestore.applyPendingRestore();
assert.equal(applied.status, 'awaiting-health');
assert.equal(fs.existsSync(path.join(temporaryRoot, 'old-state.txt')), false);
assert.equal(fs.existsSync(path.join(temporaryRoot, 'runtime')), false);
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'state.json'), 'utf8'), '{"preserved":true}\n');
assert.equal(fs.existsSync(path.join(temporaryRoot, 'backup-restore', 'rollback', 'old-state.txt')), true);
const completed = startupRestore.markStartupSuccessful();
assert.equal(completed.status, 'restored');
assert.equal(fs.existsSync(jobDir), false);
assert.equal(fs.existsSync(startupRestore.pendingPath), false);
});
test('restores the rollback copy when a replaced application did not reach health', () => {
const restoreId = 'b'.repeat(64);
const rollbackDir = path.join(temporaryRoot, 'backup-restore', 'rollback');
fs.mkdirSync(rollbackDir, { recursive: true });
fs.writeFileSync(path.join(rollbackDir, 'state.json'), 'previous state');
fs.writeFileSync(path.join(temporaryRoot, 'state.json'), 'failed replacement');
startupRestore.writeJson(startupRestore.pendingPath, {
restoreId,
state: 'awaiting-health',
requestedAt: Date.now(),
actor: 'test',
});
const result = startupRestore.applyPendingRestore();
assert.equal(result.status, 'rolled-back');
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'state.json'), 'utf8'), 'previous state');
assert.equal(startupRestore.getLastRestoreResult().status, 'rolled-back');
});
@@ -0,0 +1,267 @@
// Backup and Restore Service
// Purpose: Owns full-data archive creation, browser transfer, staged restore confirmation, startup replacement, and rollback.
// Scope: All backup/restore orchestration stays in this service; database owners expose only their online snapshot operations.
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const { Transform } = require('stream');
const { pipeline } = require('stream/promises');
const { resolveDataPath } = require('../../helpers/dataPaths');
const { CONTROL_DIR_NAME, createFullBackup } = require('./backup');
const { MAX_ARCHIVE_BYTES, prepareRestoreArchive } = require('./restore');
const startupRestore = require('./startupRestore');
const TOKEN_LIFETIME_MS = 10 * 60 * 1000;
const controlDir = resolveDataPath(CONTROL_DIR_NAME);
const downloads = new Map();
const uploads = new Map();
let backupInProgress = false;
let registered = false;
let app;
let io;
let logger;
let getConfigurationDatabase;
let identityService;
let fleetReportService;
let requireLockdownAdministrator;
let requireRecentPassword;
let requestApplicationRestart;
let isApplicationRestartPending;
function actorFor(socket) {
return socket?.data?.user?.username || socket.id;
}
function createToken() {
return crypto.randomBytes(32).toString('hex');
}
function pruneExpiredTransfers() {
const now = Date.now();
for (const [token, transfer] of [...downloads, ...uploads]) {
if (transfer.expiresAt > now) continue;
downloads.delete(token);
uploads.delete(token);
if (transfer.jobDir) fsp.rm(transfer.jobDir, { recursive: true, force: true }).catch(() => undefined);
}
}
function responseError(cb, error) {
logger.warn('Backup or restore request failed', { error: error.message });
cb({ error: error.message, code: error.code || null });
}
function removeOrphanedStaging() {
let pendingRestoreId = null;
try {
pendingRestoreId = JSON.parse(fs.readFileSync(startupRestore.pendingPath, 'utf8')).restoreId || null;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
for (const entry of fs.readdirSync(controlDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const preservedName = pendingRestoreId ? `restore-${pendingRestoreId}` : null;
if ((entry.name.startsWith('backup-') || entry.name.startsWith('restore-'))
&& entry.name !== preservedName) {
/*
Transfer authorization lives only in process memory. After a restart,
an unconfirmed upload or undownloaded archive can no longer be reached,
so deleting it prevents abandoned full-data copies from accumulating.
*/
fs.rmSync(path.join(controlDir, entry.name), { recursive: true, force: true });
}
}
}
function registerSocketApi() {
io.on('connection', (socket) => {
socket.on('backupRestore:status', (_payload = {}, cb = () => {}) => {
try {
requireLockdownAdministrator(socket);
cb({ success: true, lastRestore: startupRestore.getLastRestoreResult() });
} catch (error) {
responseError(cb, error);
}
});
socket.on('backupRestore:createBackup', (_payload = {}, cb = () => {}) => {
Promise.resolve().then(async () => {
requireRecentPassword(socket);
if (backupInProgress) throw new Error('A full backup is already being created.');
backupInProgress = true;
try {
pruneExpiredTransfers();
const jobId = createToken();
const result = await createFullBackup({
configurationDatabase: getConfigurationDatabase(),
identityService,
fleetReportService,
jobId,
});
const token = createToken();
downloads.set(token, {
archivePath: result.archivePath,
jobDir: result.jobDir,
expiresAt: Date.now() + TOKEN_LIFETIME_MS,
});
getConfigurationDatabase().recordAuditEvent(actorFor(socket), 'backup.created', {
fileCount: result.manifest.files.length,
skippedUnstableFileCount: result.manifest.skippedUnstableFiles.length,
});
cb({
success: true,
downloadUrl: `/admin-api/backup/${token}`,
fileCount: result.manifest.files.length,
skippedUnstableFiles: result.manifest.skippedUnstableFiles,
});
} finally {
backupInProgress = false;
}
}).catch((error) => responseError(cb, error));
});
socket.on('backupRestore:createRestoreUpload', (_payload = {}, cb = () => {}) => {
try {
requireRecentPassword(socket);
pruneExpiredTransfers();
const token = createToken();
uploads.set(token, {
actor: actorFor(socket),
expiresAt: Date.now() + TOKEN_LIFETIME_MS,
});
cb({ success: true, uploadUrl: `/admin-api/restore/${token}` });
} catch (error) {
responseError(cb, error);
}
});
socket.on('backupRestore:confirmRestore', ({ restoreId } = {}, cb = () => {}) => {
try {
requireRecentPassword(socket);
const safeRestoreId = String(restoreId || '');
if (!/^[a-f0-9]{64}$/.test(safeRestoreId)) throw new Error('Validated restore was not found.');
if (isApplicationRestartPending()) throw new Error('Application restart already pending.');
if (fs.existsSync(startupRestore.pendingPath)) throw new Error('A restore is already pending.');
const jobDir = path.join(controlDir, `restore-${safeRestoreId}`);
if (!fs.existsSync(path.join(jobDir, 'validated.json'))) throw new Error('Validated restore was not found.');
startupRestore.writeJson(startupRestore.pendingPath, {
restoreId: safeRestoreId,
state: 'pending',
requestedAt: Date.now(),
actor: actorFor(socket),
});
getConfigurationDatabase().recordAuditEvent(actorFor(socket), 'restore.requested', { restoreId: safeRestoreId });
requestApplicationRestart({ actor: actorFor(socket), reason: 'restore-requested' });
cb({ success: true });
} catch (error) {
responseError(cb, error);
}
});
});
}
function registerHttpApi() {
app.get('/admin-api/backup/:token', (req, res) => {
pruneExpiredTransfers();
const transfer = downloads.get(String(req.params.token || ''));
if (!transfer) {
res.status(404).send('Backup download is missing or expired.');
return;
}
downloads.delete(req.params.token);
res.set({
'Content-Type': 'application/gzip',
'Content-Disposition': `attachment; filename="multirover-backup-${new Date().toISOString().slice(0, 10)}.tar.gz"`,
'Cache-Control': 'no-store',
});
const cleanup = () => fsp.rm(transfer.jobDir, { recursive: true, force: true }).catch(() => undefined);
res.once('close', cleanup);
fs.createReadStream(transfer.archivePath).on('error', (error) => {
logger.warn('Backup download failed', { error: error.message });
if (!res.headersSent) res.status(500).end();
else res.destroy(error);
}).pipe(res);
});
app.put('/admin-api/restore/:token', async (req, res) => {
pruneExpiredTransfers();
const token = String(req.params.token || '');
const transfer = uploads.get(token);
uploads.delete(token);
if (!transfer) {
res.status(404).json({ error: 'Restore upload is missing or expired.' });
return;
}
const contentLength = Number(req.headers['content-length']);
if (Number.isFinite(contentLength) && contentLength > MAX_ARCHIVE_BYTES) {
res.status(413).json({ error: 'Backup archive exceeds the restore size limit.' });
return;
}
const restoreId = createToken();
const jobDir = path.join(controlDir, `restore-${restoreId}`);
const archivePath = path.join(jobDir, 'upload.tar.gz');
try {
await fsp.mkdir(jobDir, { recursive: true });
let receivedBytes = 0;
const limiter = new Transform({
transform(chunk, _encoding, callback) {
receivedBytes += chunk.length;
callback(receivedBytes > MAX_ARCHIVE_BYTES
? new Error('Backup archive exceeds the restore size limit.')
: null, chunk);
},
});
await pipeline(req, limiter, fs.createWriteStream(archivePath, { mode: 0o600 }));
const summary = await prepareRestoreArchive({ archivePath, jobDir, actor: transfer.actor });
getConfigurationDatabase().recordAuditEvent(transfer.actor, 'restore.validated', {
restoreId,
fileCount: summary.fileCount,
totalBytes: summary.totalBytes,
});
res.set('Cache-Control', 'no-store').json({ success: true, restoreId, summary });
} catch (error) {
await fsp.rm(jobDir, { recursive: true, force: true });
logger.warn('Restore upload failed validation', { actor: transfer.actor, error: error.message });
res.status(error.message.includes('size limit') ? 413 : 400).json({ error: error.message });
}
});
}
function register() {
if (registered) return;
registered = true;
/*
These runtime dependencies are deliberately loaded only after earliest
startup restore has run. Several of them open SQLite immediately, so
importing them at module scope would make replacement too late and unsafe.
*/
({ app } = require('../../globals/http'));
io = require('../../globals/io');
logger = require('../../globals/logger').child('backupRestoreService');
({ getConfigurationDatabase } = require('../../configuration'));
identityService = require('../identityService');
fleetReportService = require('../fleetReportService');
({ requireLockdownAdministrator, requireRecentPassword } = require('../adminConfigurationService'));
({ isApplicationRestartPending, requestApplicationRestart } = require('../serverControlService'));
fs.mkdirSync(controlDir, { recursive: true });
removeOrphanedStaging();
registerHttpApi();
registerSocketApi();
}
function markStartupSuccessful() {
const result = startupRestore.markStartupSuccessful();
if (result) {
const configuration = require('../../configuration');
configuration.getConfigurationDatabase().recordAuditEvent('system', 'restore.completed', { restoreId: result.restoreId });
}
return result;
}
module.exports = {
applyPendingRestore: startupRestore.applyPendingRestore,
markStartupSuccessful,
register,
};
@@ -0,0 +1,216 @@
// Full Data Restore Validation
// Purpose: Safely extracts and validates an uploaded MultiRover backup before it can become a pending restore.
// Scope: Never changes active data; startupRestore owns the later replacement and rollback transaction.
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const Database = require('better-sqlite3');
const tar = require('tar');
const {
DATABASE_NAMES,
FORMAT_VERSION,
readDatabaseSchemaVersions,
removeSnapshotSidecars,
sha256File,
} = require('./backup');
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 * 1024;
const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024 * 1024;
const MAX_ARCHIVE_ENTRIES = 100000;
const RESERVED_DATA_NAMES = new Set(['backup-restore', 'runtime']);
const SUPPORTED_DATABASE_SCHEMA_VERSIONS = {
configuration: 2,
identity: 4,
fleetReports: 0,
};
function normalizeArchivePath(value) {
const raw = String(value || '');
if (!raw || raw.includes('\\') || path.posix.isAbsolute(raw)) return null;
const withoutTrailingSlash = raw.replace(/\/+$/, '');
const normalized = path.posix.normalize(withoutTrailingSlash);
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) return null;
return normalized;
}
function assertAllowedArchiveEntry(entry) {
const normalized = normalizeArchivePath(entry.path);
if (!normalized || (normalized !== 'manifest.json' && normalized !== 'data' && !normalized.startsWith('data/'))) {
throw new Error(`Backup contains an invalid archive path: ${entry.path}`);
}
if (!['File', 'Directory'].includes(entry.type)) {
throw new Error(`Backup contains unsupported entry type ${entry.type}: ${entry.path}`);
}
if (normalized.startsWith('data/')) {
const topLevelName = normalized.slice('data/'.length).split('/')[0];
if (RESERVED_DATA_NAMES.has(topLevelName)) {
throw new Error(`Backup contains reserved data path: ${entry.path}`);
}
}
return normalized;
}
async function inspectArchive(archivePath) {
let entryCount = 0;
let extractedBytes = 0;
const paths = new Set();
let validationError = null;
await tar.t({
file: archivePath,
strict: true,
onentry: (entry) => {
if (validationError) return;
try {
entryCount += 1;
extractedBytes += Number(entry.size) || 0;
if (entryCount > MAX_ARCHIVE_ENTRIES) throw new Error('Backup contains too many files.');
if (extractedBytes > MAX_EXTRACTED_BYTES) throw new Error('Backup expands beyond the restore size limit.');
const normalized = assertAllowedArchiveEntry(entry);
if (paths.has(normalized)) throw new Error(`Backup contains duplicate path: ${normalized}`);
paths.add(normalized);
} catch (error) {
/*
tar invokes onentry from its parser event stack, where throwing would
become an uncaught exception instead of rejecting tar.t(). Retain the
first failure and raise it immediately after the bounded listing.
*/
validationError = error;
}
},
});
if (validationError) throw validationError;
if (!paths.has('manifest.json') || !paths.has('data')) {
throw new Error('Backup must contain manifest.json and one data directory.');
}
}
async function listExtractedFiles(rootDir, relativeDir = '') {
const entries = await fsp.readdir(path.join(rootDir, relativeDir), { withFileTypes: true });
const files = [];
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
const fullPath = path.join(rootDir, relativePath);
const stat = await fsp.lstat(fullPath);
if (stat.isSymbolicLink()) throw new Error(`Restored data contains symbolic link: ${relativePath}`);
if (stat.isDirectory()) {
files.push(...await listExtractedFiles(rootDir, relativePath));
continue;
}
if (!stat.isFile()) throw new Error(`Restored data contains special file: ${relativePath}`);
files.push({
path: relativePath.split(path.sep).join('/'),
size: stat.size,
sha256: await sha256File(fullPath),
});
}
return files.sort((left, right) => left.path.localeCompare(right.path));
}
function verifySqliteDatabase(filePath, name) {
const database = new Database(filePath, { readonly: true, fileMustExist: true });
try {
const result = database.pragma('quick_check', { simple: true });
if (result !== 'ok') throw new Error(`${name} failed SQLite integrity validation.`);
} finally {
database.close();
}
}
async function validateExtractedRestore(extractDir) {
const manifestPath = path.join(extractDir, 'manifest.json');
const payloadDir = path.join(extractDir, 'data');
const manifestStat = await fsp.stat(manifestPath);
if (manifestStat.size > 10 * 1024 * 1024) throw new Error('Backup manifest is unreasonably large.');
const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8'));
if (manifest.format !== 'multirover-full-backup' || manifest.formatVersion !== FORMAT_VERSION) {
throw new Error('Backup format or version is not supported.');
}
if (!Array.isArray(manifest.files)) throw new Error('Backup manifest has no file inventory.');
const expected = [...manifest.files].sort((left, right) => String(left.path).localeCompare(String(right.path)));
const actual = await listExtractedFiles(payloadDir);
if (expected.length !== actual.length) {
/*
Keep the strict complete-inventory check, but identify a few differences
so an operator can distinguish a missing file from an unexpected archive
entry without weakening restore validation or exposing file contents.
*/
const expectedPaths = new Set(expected.map((entry) => entry.path));
const actualPaths = new Set(actual.map((entry) => entry.path));
const missing = expected.filter((entry) => !actualPaths.has(entry.path)).map((entry) => entry.path).slice(0, 5);
const unexpected = actual.filter((entry) => !expectedPaths.has(entry.path)).map((entry) => entry.path).slice(0, 5);
const details = [
missing.length ? `missing: ${missing.join(', ')}` : '',
unexpected.length ? `unexpected: ${unexpected.join(', ')}` : '',
].filter(Boolean).join('; ');
throw new Error(`Backup file inventory does not match the archive${details ? ` (${details})` : ''}.`);
}
for (let index = 0; index < expected.length; index += 1) {
const wanted = expected[index];
const found = actual[index];
if (wanted.path !== found.path || wanted.size !== found.size || wanted.sha256 !== found.sha256) {
throw new Error(`Backup checksum or size mismatch: ${wanted.path || found.path}`);
}
}
for (const databaseName of DATABASE_NAMES) {
if (!actual.some((entry) => entry.path === databaseName)) {
throw new Error(`Backup is missing required database: ${databaseName}`);
}
verifySqliteDatabase(path.join(payloadDir, databaseName), databaseName);
}
const actualSchemaVersions = readDatabaseSchemaVersions(payloadDir);
for (const [databaseName, version] of Object.entries(actualSchemaVersions)) {
// JSON object property order has no meaning. Compare each known database by
// name so an otherwise valid manifest is not rejected merely because a
// different JSON writer emitted its keys in another order.
if (Number(manifest.databaseSchemaVersions?.[databaseName]) !== version) {
throw new Error('Backup database schema versions do not match its manifest.');
}
if (version > SUPPORTED_DATABASE_SCHEMA_VERSIONS[databaseName]) {
throw new Error(`Backup ${databaseName} database is newer than this application supports.`);
}
}
/*
Integrity and schema reads can create fresh WAL coordination files even
though the uploaded snapshot initially matched its manifest exactly. Remove
those validation-only files so startup applies only inventoried content.
*/
await removeSnapshotSidecars(payloadDir);
return manifest;
}
async function prepareRestoreArchive({ archivePath, jobDir, actor }) {
const archiveStat = await fsp.stat(archivePath);
if (!archiveStat.isFile() || archiveStat.size <= 0 || archiveStat.size > MAX_ARCHIVE_BYTES) {
throw new Error('Backup archive is empty or exceeds the restore size limit.');
}
await inspectArchive(archivePath);
const extractDir = path.join(jobDir, 'extracted');
await fsp.rm(extractDir, { recursive: true, force: true });
await fsp.mkdir(extractDir, { recursive: true });
// Data files never need executable or set-id permissions from an uploaded
// archive. Let the server account's umask choose safe extraction modes.
await tar.x({ cwd: extractDir, file: archivePath, strict: true, preservePaths: false, noChmod: true });
const manifest = await validateExtractedRestore(extractDir);
const summary = {
createdAt: manifest.createdAt,
applicationVersion: manifest.applicationVersion,
fileCount: manifest.files.length,
totalBytes: manifest.files.reduce((total, entry) => total + Number(entry.size || 0), 0),
skippedUnstableFiles: Array.isArray(manifest.skippedUnstableFiles) ? manifest.skippedUnstableFiles : [],
};
await fsp.writeFile(
path.join(jobDir, 'validated.json'),
`${JSON.stringify({ actor, validatedAt: Date.now(), summary }, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
return summary;
}
module.exports = {
MAX_ARCHIVE_BYTES,
inspectArchive,
prepareRestoreArchive,
validateExtractedRestore,
};
@@ -0,0 +1,143 @@
// Startup Data Restore
// Purpose: Applies a previously validated restore before any application database opens and rolls back an interrupted start.
// Scope: Operates only on top-level entries inside SERVER_DATA_DIR while preserving backupRestoreService control state.
const fs = require('fs');
const path = require('path');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { CONTROL_DIR_NAME } = require('./backup');
const controlDir = resolveDataPath(CONTROL_DIR_NAME);
const pendingPath = path.join(controlDir, 'pending.json');
const rollbackDir = path.join(controlDir, 'rollback');
const lastResultPath = path.join(controlDir, 'last-result.json');
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.tmp`;
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(temporaryPath, filePath);
}
function readPending() {
try {
return JSON.parse(fs.readFileSync(pendingPath, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
function listActiveDataEntries({ includeRuntime = true } = {}) {
fs.mkdirSync(resolveDataDir(), { recursive: true });
return fs.readdirSync(resolveDataDir()).filter((name) => (
name !== CONTROL_DIR_NAME && (includeRuntime || name !== 'runtime')
));
}
function removeActiveData() {
for (const name of listActiveDataEntries()) {
fs.rmSync(path.join(resolveDataDir(), name), { recursive: true, force: true });
}
}
function moveChildren(sourceDir, destinationDir) {
fs.mkdirSync(destinationDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) {
fs.renameSync(path.join(sourceDir, name), path.join(destinationDir, name));
}
}
function restoreRollback(pending, errorMessage) {
removeActiveData();
moveChildren(rollbackDir, resolveDataDir());
writeJson(lastResultPath, {
status: 'rolled-back',
restoreId: pending.restoreId,
completedAt: Date.now(),
error: errorMessage,
});
fs.rmSync(pendingPath, { force: true });
}
function applyPendingRestore() {
const pending = readPending();
if (!pending) return null;
/*
Reaching startup again in either state means the replacement process did
not reach the HTTP-listening success marker. The complete rollback copy was
created before active data was touched, so restoring it is deterministic.
*/
if (pending.state === 'applying' || pending.state === 'awaiting-health') {
restoreRollback(pending, 'The restored application did not finish starting.');
return { status: 'rolled-back', restoreId: pending.restoreId };
}
const jobDir = path.join(controlDir, `restore-${pending.restoreId}`);
const replacementDir = path.join(jobDir, 'extracted', 'data');
if (!fs.existsSync(path.join(jobDir, 'validated.json'))
|| !fs.existsSync(replacementDir)
|| !fs.statSync(replacementDir).isDirectory()) {
fs.rmSync(pendingPath, { force: true });
writeJson(lastResultPath, {
status: 'failed',
restoreId: pending.restoreId,
completedAt: Date.now(),
error: 'Validated restore staging is missing.',
});
return { status: 'failed', restoreId: pending.restoreId };
}
try {
fs.rmSync(rollbackDir, { recursive: true, force: true });
fs.mkdirSync(rollbackDir, { recursive: true });
// Startup runs before any database or writer opens. Copying the entire
// current payload first gives every later replacement step one complete,
// local rollback source even if the process is interrupted halfway through.
for (const name of listActiveDataEntries({ includeRuntime: false })) {
fs.cpSync(path.join(resolveDataDir(), name), path.join(rollbackDir, name), { recursive: true });
}
writeJson(pendingPath, { ...pending, state: 'applying', applyingAt: Date.now() });
removeActiveData();
moveChildren(replacementDir, resolveDataDir());
writeJson(pendingPath, { ...pending, state: 'awaiting-health', appliedAt: Date.now() });
return { status: 'awaiting-health', restoreId: pending.restoreId };
} catch (error) {
if (fs.existsSync(rollbackDir)) restoreRollback(pending, error.message);
else fs.rmSync(pendingPath, { force: true });
return { status: 'rolled-back', restoreId: pending.restoreId, error: error.message };
}
}
function markStartupSuccessful() {
const pending = readPending();
if (!pending || pending.state !== 'awaiting-health') return null;
const jobDir = path.join(controlDir, `restore-${pending.restoreId}`);
fs.rmSync(rollbackDir, { recursive: true, force: true });
fs.rmSync(jobDir, { recursive: true, force: true });
fs.rmSync(pendingPath, { force: true });
const result = {
status: 'restored',
restoreId: pending.restoreId,
completedAt: Date.now(),
};
writeJson(lastResultPath, result);
return result;
}
function getLastRestoreResult() {
try {
return JSON.parse(fs.readFileSync(lastResultPath, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
module.exports = {
applyPendingRestore,
getLastRestoreResult,
markStartupSuccessful,
pendingPath,
writeJson,
};
@@ -0,0 +1,18 @@
// 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({ description: 'Immediately starts the Wii Balance Board service and exposes its readings and controls.' }),
simulate: boolean({ description: 'Runs the native worker with generated cyclic sensor data instead of connecting to Bluetooth hardware.' }),
}, {
title: 'Balance Board',
description: 'Optional Wii Balance Board input service with a development simulation mode.',
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, registerConfigurationHandler } = 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 || {};
let rawConfig = loadConfig().balanceBoard || {};
let enabled = Boolean(rawConfig.enabled);
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('balance-board.json');
const FRAME_ROOM = 'balance-board-viewers';
@@ -453,12 +452,14 @@ function handleWorkerMessage(message = {}) {
io.on('connection', (socket) => {
socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
socket.join(FRAME_ROOM);
if (latestFrame) socket.emit('balanceBoard:frame', latestFrame);
cb({ success: true });
});
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
@@ -471,6 +472,7 @@ io.on('connection', (socket) => {
}
});
socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
@@ -485,6 +487,7 @@ io.on('connection', (socket) => {
}
});
socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
@@ -547,7 +550,7 @@ io.on('connection', (socket) => {
});
});
if (enabled) {
function startHardware() {
hardware = createBalanceBoardHardware({
logger,
address: store.address,
@@ -555,10 +558,38 @@ if (enabled) {
});
hardware.events.on('message', handleWorkerMessage);
hardware.start();
}
if (enabled) {
startHardware();
} else {
logger.info('Balance Board disabled by config');
}
registerConfigurationHandler('balanceBoard', (nextConfig = {}) => {
const wasEnabled = enabled;
hardware?.stop();
hardware = null;
clearZeroTimer();
rawConfig = nextConfig;
enabled = Boolean(rawConfig.enabled);
if (!wasEnabled && enabled) store = loadStore();
connected = false;
batteryPercent = null;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
if (enabled) {
status = store.address ? 'waiting' : 'starting';
detail = store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.';
startHardware();
} else {
status = 'disabled';
detail = 'Balance Board support is disabled.';
}
events.emit('change', getState());
});
function installShutdownHooks() {
const shutdown = () => {
clearZeroTimer();
@@ -0,0 +1,17 @@
// 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,
// Disabled-by-default feature state is independent from its complete visual
// identity, matching how the legacy YAML template represented this service.
defaultValue: { enabled: false, botName: 'Barcode Games', profileImageUrl: 'https://example.com/barcode-games.png' },
schema: strictObject({
enabled: boolean({ description: 'Enables shared barcode-game voting, participation, scoring, and game-state publication.' }),
botName: string({ description: 'Nickname used for barcode-game lifecycle messages posted into chat.', minLength: 1, maxLength: 80 }),
profileImageUrl: string({ title: 'Profile image URL', description: 'Optional image URL displayed beside barcode-game chat messages; leave blank for no custom image.', examples: ['https://example.com/barcode-games.png'], maxLength: 2048 }),
}, { title: 'Barcode games', description: 'Controls the multiplayer games driven by scans received from the barcode scanner service.', required: ['enabled', 'botName', 'profileImageUrl'] }),
};
+31 -25
View File
@@ -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, registerConfigurationHandler } = require('../../configuration');
const { subscribe } = require('../eventBus');
const { sendSystemMessage } = require('../chatService');
const { getActiveDrivers } = require('../turnService');
@@ -29,11 +28,17 @@ const RESULTS_WINDOW_MS = 45 * 1000;
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 botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
let enabled;
let botName;
let botProfileImageUrl;
function applyBarcodeGameConfig(barcodeGamesConfig = {}) {
enabled = Boolean(barcodeGamesConfig.enabled);
botName = String(barcodeGamesConfig.botName || 'Barcode Games').trim() || 'Barcode Games';
botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
}
applyBarcodeGameConfig(loadConfig().barcodeGames || {});
function sendBarcodeGameChat(text) {
const message = String(text || '').trim();
@@ -768,6 +773,7 @@ function settleActiveGameIfNeeded() {
}
function handleScan(scan) {
if (!enabled) return;
const now = Number.isFinite(scan?.scannedAt) ? scan.scannedAt : Date.now();
withGameStore((draft) => {
updateGlobalCounters(draft, scan, now);
@@ -1122,14 +1128,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.
*/
io.on('connection', (socket) => {
io.on('connection', (socket) => {
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode games disabled' });
socket.join(GAME_SOCKET_ROOM);
const state = buildStatePayload(socket);
socket.emit('barcodeGame:state', state);
@@ -1137,6 +1138,7 @@ if (enabled) {
});
socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode games disabled' });
try {
cb(setVote(socket, gameId));
} catch (err) {
@@ -1145,9 +1147,9 @@ if (enabled) {
}
});
});
});
subscribe('barcode.scanned', (event) => {
subscribe('barcode.scanned', (event) => {
try {
handleScan(event.payload);
} catch (err) {
@@ -1155,21 +1157,25 @@ if (enabled) {
// are logged and skipped so the scanner page can keep resolving barcodes.
logger.warn('Barcode game scan handling failed', { error: err.message });
}
});
} else {
});
if (!enabled) {
logger.info('Barcode games disabled by config');
}
registerConfigurationHandler('barcodeGames', (barcodeGamesConfig = {}) => {
applyBarcodeGameConfig(barcodeGamesConfig);
broadcastState();
});
module.exports = {
buildStatePayload,
handleScan,
setVote,
};
if (enabled) {
setInterval(() => {
if (settleActiveGameIfNeeded()) {
broadcastState();
}
}, GAME_TICK_MS).unref?.();
}
setInterval(() => {
if (enabled && settleActiveGameIfNeeded()) {
broadcastState();
}
}, GAME_TICK_MS).unref?.();
@@ -0,0 +1,17 @@
// 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({ description: 'Immediately enables barcode scanning, barcode administration, and scan-triggered server behavior.' }),
}, {
title: 'Barcode scanner',
description: 'Optional physical barcode scanning and barcode registry service.',
required: ['enabled'],
}),
};
@@ -4,8 +4,8 @@
const fs = require('fs');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeScannerService');
const { loadConfig, registerConfigurationHandler } = 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');
let enabled = Boolean(loadConfig().barcodeScanner?.enabled);
let lastKnownGoodRegistry = null;
let lastRegistryError = null;
@@ -312,19 +312,16 @@ async function applyScan(rawCode) {
return { result };
}
if (enabled) {
/*
Barcode scanning is tied to a physical scanner station. Disabled installs
should not create the registry file or expose scanner socket commands.
*/
io.on('connection', (socket) => {
io.on('connection', (socket) => {
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode scanner disabled' });
socket.join(SCANNER_SOCKET_ROOM);
socket.emit('barcode:state', buildStatePayload());
cb({ success: true, state: buildStatePayload() });
});
socket.on('barcode:scan', async ({ code } = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode scanner disabled' });
try {
const { result } = await applyScan(code);
cb({ success: true, result, state: buildStatePayload() });
@@ -336,20 +333,28 @@ if (enabled) {
cb({ error: err.message || 'barcode scan failed' });
}
});
});
});
modeEvents.on('change', () => {
modeEvents.on('change', () => {
// Access-mode changes affect whether the scanner page should beep when it
// submits a code, so scanner clients need a fresh state packet even without a
// new scan.
broadcastState();
});
});
if (enabled) {
loadRegistryForScan();
} else {
logger.info('Barcode scanner disabled by config');
}
registerConfigurationHandler('barcodeScanner', (scannerConfig = {}) => {
const wasEnabled = enabled;
enabled = Boolean(scannerConfig.enabled);
if (!wasEnabled && enabled) loadRegistryForScan();
broadcastState();
});
module.exports = {
REGISTRY_PATH,
applyScan: (...args) => {
@@ -0,0 +1,17 @@
// 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({ description: 'Immediately enables the physical button-box input route and its persistent button rewards and effects.' }),
}, {
title: 'Button box',
description: 'Optional physical button-box input and reward system.',
required: ['enabled'],
}),
};
@@ -10,6 +10,7 @@ function registerButtonBoxRoute(deps) {
buttonCount,
normalizeIp,
isLocalNetwork,
isEnabled,
applyPress,
} = deps;
@@ -39,6 +40,13 @@ function registerButtonBoxRoute(deps) {
}
app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => {
// The route stays registered for the life of Express, but the service gate
// is evaluated per request so the physical endpoint enables and disables
// immediately without accumulating duplicate routes.
if (!isEnabled()) {
res.status(503).json({ error: 'Button box is disabled' });
return;
}
if (denyIfNotLocal(req, res)) return;
const buttonId = parseButtonId(req.body);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > buttonCount) {
+25 -15
View File
@@ -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, registerConfigurationHandler } = 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');
let enabled = Boolean(loadConfig().buttonBox?.enabled);
const store = createButtonBoxStore({
logger,
@@ -73,28 +73,38 @@ const core = createButtonBoxCore({
store,
});
if (enabled) {
/*
The button box is physical local hardware, so disabled public installs
should not expose its LAN-only press endpoint or initialize its reward file.
*/
registerButtonBoxRoute({
app,
logger,
buttonCount: BUTTON_COUNT,
normalizeIp,
isLocalNetwork,
applyPress: core.applyPress,
});
registerButtonBoxRoute({
app,
logger,
buttonCount: BUTTON_COUNT,
normalizeIp,
isLocalNetwork,
isEnabled: () => enabled,
applyPress: core.applyPress,
});
function enableButtonBox() {
store.loadState();
core.recoverEffects().catch((err) => {
logger.warn('Button box effect recovery failed', err.message);
});
}
if (enabled) {
enableButtonBox();
} else {
logger.info('Button box disabled by config');
}
registerConfigurationHandler('buttonBox', (buttonBoxConfig = {}) => {
const wasEnabled = enabled;
enabled = Boolean(buttonBoxConfig.enabled);
// Persistent state is loaded only on the transition to enabled. The core has
// no long-running hardware client, so disabling is completely represented by
// the route and public-method gates.
if (!wasEnabled && enabled) enableButtonBox();
});
module.exports = {
getButtonBoxState: () => {
/*
@@ -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');
@@ -43,11 +43,8 @@ const {
createReplaySourceResolver,
} = require('../replayDeliveryService/workflow');
const config = loadConfig();
const discordConfig = config.discord || {};
function isTextCommand(text) {
return parseCommandText(text, config).matched;
return parseCommandText(text).matched;
}
function sanitizeMentions(text) {
@@ -142,6 +139,11 @@ function createChatCommandRequest({ socket, text, sendSystemMessage }) {
async function runChatTextCommand({ text, socket, sendSystemMessage }) {
if (!isTextCommand(text)) return false;
// Commands are assembled per message already, so reading the live snapshot
// here applies prefix, URL, and integration settings without retaining a
// stale dependency object between configuration revisions.
const config = loadConfig();
const discordConfig = config.discord || {};
// ReplayEngineV2 has startup side effects by design. Loading it lazily here
// keeps ordinary chatService initialization from changing the service boot
// order, while still letting `rs replay` use the existing replay pipeline.
@@ -201,7 +203,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
discordConfig,
siteUrl: String(discordConfig.siteUrl || ''),
publicUrl: String(config.publicUrl || ''),
config,
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
};
@@ -33,7 +33,7 @@ function createReplayCommand({
getActiveDrivers,
getNickname,
rovers,
discordConfig,
config,
}) {
const sourceResolver = createReplaySourceResolver({
rovers,
@@ -137,8 +137,8 @@ function createReplayCommand({
if (progressMessage?.edit) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
}
const siteUrl = String(discordConfig?.siteUrl || '').replace(/\/$/, '');
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url;
const publicBaseUrl = String(config?.publicUrl || '').replace(/\/$/, '');
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
await progressMessage.reply({ content: `Replay hosted by the rover server: ${publicUrl}`, allowedMentions: DEFAULT_ALLOWED_MENTIONS });
return;
} catch (fallbackError) {
@@ -3,12 +3,12 @@
// Scope: Builds a concise time embed for common zones and server local zone.
const { EmbedBuilder } = require('discord.js');
function createTimeStatusCommand({ config, discordConfig }) {
function createTimeStatusCommand({ config }) {
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : '';
if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description);
else if (siteUrl) embed.setDescription(siteUrl);
const publicUrl = includeSiteUrl && config.publicUrl ? String(config.publicUrl) : '';
if (description) embed.setDescription(publicUrl ? `${description}\n\n${publicUrl}` : description);
else if (publicUrl) embed.setDescription(publicUrl);
embed.setTimestamp(new Date());
return embed;
}
@@ -0,0 +1,59 @@
// 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,
// Channel and role IDs retain the fully populated legacy-template shape, but
// the credential remains empty and the bot cannot start until explicitly enabled.
defaultValue: {
enabled: false,
token: '',
guildId: '123456789012345678',
channels: {
general: '123456789012345678',
announcements: '123456789012345678',
adminAlerts: '123456789012345678',
replay: '123456789012345678',
humanAlerts: '123456789012345678',
},
roles: {
stalkerPing: '123456789012345678',
announcementPing: '123456789012345678',
adminPing: '123456789012345678',
humanAlertPing: '123456789012345678',
},
},
schema: strictObject({
enabled: boolean({ description: 'Logs the Discord bot in and immediately enables commands, chat bridges, replay delivery, and configured announcements.' }),
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
channels: strictObject({
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }),
replay: string({ description: 'Channel ID used to upload generated replay videos when Discord replay delivery is available.', examples: ['123456789012345678'], maxLength: 100 }),
humanAlerts: string({ description: 'Channel ID used for physical human-alert button notifications and captured images.', examples: ['123456789012345678'], maxLength: 100 }),
}, {
title: 'Channels',
description: 'Discord channel IDs that route each category of bot output.',
required: ['general', 'announcements', 'adminAlerts', 'replay', 'humanAlerts'],
}),
roles: strictObject({
stalkerPing: string({ description: 'Role ID mentioned by the button-box stalker-ping reward in the general channel.', examples: ['123456789012345678'], maxLength: 100 }),
announcementPing: string({ description: 'Role ID mentioned by configured user announcements.', examples: ['123456789012345678'], maxLength: 100 }),
adminPing: string({ description: 'Role ID mentioned for important administrative rover, battery, and help alerts.', examples: ['123456789012345678'], maxLength: 100 }),
humanAlertPing: string({ description: 'Role ID mentioned when the physical human-alert button is pressed.', examples: ['123456789012345678'], maxLength: 100 }),
}, {
title: 'Roles',
description: 'Discord role IDs mentioned for specific notification categories.',
required: ['stalkerPing', 'announcementPing', 'adminPing', 'humanAlertPing'],
}),
}, {
title: 'Discord',
description: 'Optional Discord bot credentials and notification routing; public links use the top-level public URL.',
required: ['enabled', 'token', 'guildId', '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 () => {
+85 -27
View File
@@ -9,8 +9,12 @@ 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,
registerConfigurationHandler,
} = require('../../configuration');
const { parseCommandText } = require('../operatorCommandService/config');
const roverManager = require('../roverManager');
const { getRoster, lockRover, rovers } = roverManager;
@@ -80,19 +84,20 @@ const {
buildStatusMessage,
} = require('../replayDeliveryService/workflow');
const config = loadConfig();
// Discord helper modules retain references to these objects. Mutating those
// references on configuration application updates command and integration
// behavior without registering a second tree of Discord/event listeners.
const config = structuredClone(loadConfig());
const discordConfig = config.discord || {};
const enabled = isFeatureEnabled('discord');
let 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');
return;
}
if (!enabled) logger.info('Discord disabled by config');
const intents = [
GatewayIntentBits.Guilds,
@@ -157,10 +162,10 @@ const replayCaption = createReplayCaptionBuilder({
// Discord is the preferred replay host only while this optional feature is
// active. The core replay delivery service owns generation and automatically
// falls back to its local media store when any operation below fails.
if (discordConfig?.channels?.replay) {
registerPreferredDeliveryProvider({
registerPreferredDeliveryProvider({
async begin(job) {
const channelId = discordConfig.channels.replay;
const channelId = discordConfig.channels?.replay;
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
const channel = await channelIO.fetchChannel(channelId);
@@ -201,8 +206,8 @@ if (discordConfig?.channels?.replay) {
}
},
async completeFallback({ context, media }) {
const siteUrl = String(discordConfig.siteUrl || '').replace(/\/$/, '');
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url;
const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, '');
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
if (context?.progressMessage?.reply) {
await context.progressMessage.reply({
content: `Replay hosted by the rover server: ${publicUrl}`,
@@ -210,8 +215,7 @@ if (discordConfig?.channels?.replay) {
});
}
},
});
}
});
const commandDependencies = {
logger,
@@ -366,24 +370,78 @@ client.on('messageCreate', async (message) => {
}
});
client.once('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
createFleetDailyReports({
let fleetDailyReports = null;
function restartFleetDailyReports() {
fleetDailyReports?.stop();
fleetDailyReports = createFleetDailyReports({
logger,
discordConfig,
fleetConfig: config.fleetReports || {},
fleetReportService,
roverManager,
sendToChannel: channelIO.sendToChannel,
}).start();
});
fleetDailyReports.start();
}
client.on('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
restartFleetDailyReports();
});
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed', err.message);
function replaceObject(target, source = {}) {
Object.keys(target).forEach((key) => delete target[key]);
Object.assign(target, structuredClone(source));
}
function applyDiscordConfig(nextDiscordConfig = {}) {
const wasEnabled = enabled;
const previousToken = discordConfig.token;
replaceObject(discordConfig, nextDiscordConfig);
config.discord = discordConfig;
enabled = Boolean(discordConfig.enabled);
if (!enabled) {
fleetDailyReports?.stop();
fleetDailyReports = null;
if (wasEnabled) client.destroy();
return;
}
if (!wasEnabled || previousToken !== discordConfig.token) {
if (wasEnabled) client.destroy();
// Login health is reported by Discord itself; do not hold the committed
// configuration request open while an external network service connects.
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed after configuration change', err.message);
});
} else if (client.isReady()) {
restartFleetDailyReports();
}
}
function applySharedConfigSection(section, value) {
config[section] = structuredClone(value);
// Fleet delivery owns a timer derived from both Discord and fleet settings.
// Reconnecting is unnecessary; rebuild only that scheduler when ready.
if (section === 'fleetReports' && client.isReady()) {
restartFleetDailyReports();
}
}
registerConfigurationHandler('discord', applyDiscordConfig);
['commands', 'publicUrl', 'timezone', 'fleetReports'].forEach((section) => {
registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value));
});
if (enabled) {
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed', err.message);
});
}
module.exports = {};
@@ -5,15 +5,15 @@ const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
function createBusEventHandler(deps) {
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
const { logger, config, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'rover.helpNeeded', 'rover.helpCleared', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
let skippedFirstModeAnnouncement = false;
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : '';
if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description);
else if (siteUrl) embed.setDescription(siteUrl);
const publicUrl = includeSiteUrl && config.publicUrl ? String(config.publicUrl) : '';
if (description) embed.setDescription(publicUrl ? `${description}\n\n${publicUrl}` : description);
else if (publicUrl) embed.setDescription(publicUrl);
embed.setTimestamp(new Date());
return embed;
}
@@ -14,6 +14,7 @@ const WATCHED_EVENT_TYPES = new Set([
function createUserAnnouncements(deps) {
const {
discordConfig,
config,
getMode,
rovers,
roverManager,
@@ -22,9 +23,12 @@ function createUserAnnouncements(deps) {
schedulePresenceRotation,
} = deps;
const announcementChannelId = discordConfig?.channels?.announcements || null;
const announcementRoleId = discordConfig?.roles?.announcementPing || null;
const siteUrl = discordConfig?.siteUrl ? String(discordConfig.siteUrl) : '';
// The parent Discord service preserves this object identity and updates its
// contents on live configuration application. Resolve individual values at
// send/render time so announcements do not retain stale channel or site data.
const getAnnouncementChannelId = () => discordConfig?.channels?.announcements || null;
const getAnnouncementRoleId = () => discordConfig?.roles?.announcementPing || null;
const getPublicUrl = () => (config?.publicUrl ? String(config.publicUrl) : '');
let previousSnapshot = buildSnapshot();
let skippedFirstModeChange = false;
@@ -124,10 +128,11 @@ function createUserAnnouncements(deps) {
});
}
if (siteUrl) {
const publicUrl = getPublicUrl();
if (publicUrl) {
embed.addFields({
name: 'Join',
value: siteUrl,
value: publicUrl,
inline: false,
});
}
@@ -136,6 +141,8 @@ function createUserAnnouncements(deps) {
}
async function sendAnnouncement({ content, embeds, ping = false }) {
const announcementChannelId = getAnnouncementChannelId();
const announcementRoleId = getAnnouncementRoleId();
if (!announcementChannelId) return;
const shouldPing = Boolean(ping && announcementRoleId);
const body = shouldPing ? `<@&${announcementRoleId}> ${content || ''}`.trim() : content;
@@ -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,56 @@
// 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({ description: 'Immediately starts persistent fleet metric collection, reports, retention cleanup, and configured daily delivery.' }),
retention: strictObject({
detailedDays: integer({ description: 'Days to retain detailed events, command observations, sessions, and other non-minute fleet records. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
minuteSamplesDays: integer({ description: 'Days to retain per-minute rover metric aggregates. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
}, {
title: 'Retention',
description: 'Automatic cleanup windows for the two classes of fleet-report records.',
required: ['detailedDays', 'minuteSamplesDays'],
}),
battery: strictObject({
enabled: boolean({ description: 'Collects high-frequency battery sensor readings and derives charging, discharge, energy, and capacity metrics.' }),
maximumIntegrationGapSeconds: number({ description: 'Largest allowed gap in seconds between battery readings before energy integration treats the telemetry as discontinuous.', minimum: 0.1, maximum: 3600 }),
minimumCapacityTestDepthPercent: number({ description: 'Minimum observed full-to-low discharge depth required before a continuous session qualifies as a high-confidence capacity test. Runtime enforces at least 10 percent.', minimum: 0, maximum: 100 }),
}, {
title: 'Battery',
description: 'Battery telemetry collection and quality thresholds used by fleet reports.',
required: ['enabled', 'maximumIntegrationGapSeconds', 'minimumCapacityTestDepthPercent'],
}),
discord: strictObject({
enabled: boolean({ description: 'Sends one completed daily fleet report to the configured Discord administrative-alert channel.' }),
sendAt: string({ description: 'Local time of day to send the daily report, written as 24-hour HH:mm.', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }),
timezone: string({ description: 'IANA timezone used to interpret the delivery time and determine each completed report day.', minLength: 1, maxLength: 100 }),
}, {
title: 'Discord delivery',
description: 'Schedule for sending completed daily fleet summaries through the Discord bot.',
required: ['enabled', 'sendAt', 'timezone'],
}),
privacy: strictObject({
retainChatBodies: boolean({ description: 'Reserved privacy preference. The current collector does not read this setting and preserves complete event payloads, including chat content, regardless of its value.' }),
}, {
title: 'Privacy',
description: 'Privacy controls reserved for any future fleet-report collection of message content.',
required: ['retainChatBodies'],
}),
}, {
title: 'Fleet reports',
description: 'Persistent fleet operations reporting, retention, battery analysis, delivery, and privacy preferences.',
required: ['enabled', 'retention', 'battery', 'discord', 'privacy'],
}),
};
+98 -65
View File
@@ -1,26 +1,45 @@
// 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');
// Purpose: Owns the replaceable collection/report runtime and its stable browser API.
// Scope: Applies the complete fleetReports section without restarting the Node process.
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const logger = require('../../globals/logger').child('fleetReportService');
const { subscribeAll } = require('../eventBus');
const roverManager = require('../roverManager');
const { commandEvents } = require('../commandService');
const { odometerEvents } = require('../odometerService');
const { createStorage } = require('./storage');
const { createCollector } = require('./collector');
const { createReportBuilder } = require('./reportBuilder');
const { registerSocketGateway } = require('./socketGateway');
if (!isFeatureEnabled('fleetReports')) {
module.exports = {
enabled: false,
getDailyReport: () => null,
};
} else {
const { subscribeAll } = require('../eventBus');
const roverManager = require('../roverManager');
const { commandEvents } = require('../commandService');
const { odometerEvents } = require('../odometerService');
const { createStorage } = require('./storage');
const { createCollector } = require('./collector');
const { createReportBuilder } = require('./reportBuilder');
const { registerSocketGateway } = require('./socketGateway');
let runtime = null;
let storage = null;
function retentionDays(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : fallback;
}
function stopRuntime() {
if (!runtime) return;
runtime.unsubscribeEvents();
if (runtime.batteryEnabled) roverManager.managerEvents.off('sensor', runtime.collector.collectSensor);
commandEvents.off('observation', runtime.collector.collectCommand);
odometerEvents.off('update', runtime.collector.collectOdometer);
runtime.managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
clearInterval(runtime.flushTimer);
clearInterval(runtime.retentionTimer);
runtime.collector.flushMinutes();
runtime = null;
}
function startRuntime(config = {}) {
stopRuntime();
if (!config.enabled) {
logger.info('Fleet reporting disabled by config');
return;
}
const config = loadConfig().fleetReports || {};
const batteryConfig = config.battery || {};
const retentionConfig = config.retention || {};
const maximumIntegrationGapMs = Math.max(
@@ -31,8 +50,14 @@ if (!isFeatureEnabled('fleetReports')) {
10,
Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60),
);
const batteryEnabled = batteryConfig.enabled !== false;
const storage = createStorage({ logger });
const batteryEnabled = Boolean(batteryConfig.enabled);
// Keep one SQLite connection for the process lifetime. Configuration reloads
// replace collectors and timers, not the durable database they share.
if (!storage) {
storage = createStorage({ logger });
storage.open();
}
const collector = createCollector({
storage,
logger,
@@ -40,8 +65,6 @@ if (!isFeatureEnabled('fleetReports')) {
minimumCapacityTestDepthPercent,
});
const reportBuilder = createReportBuilder({ storage, collector, roverManager });
storage.open();
const unsubscribeEvents = subscribeAll(collector.collectEvent);
if (batteryEnabled) roverManager.managerEvents.on('sensor', collector.collectSensor);
commandEvents.on('observation', collector.collectCommand);
@@ -52,18 +75,9 @@ if (!isFeatureEnabled('fleetReports')) {
roverManager.managerEvents.on(kind, handler);
return [kind, handler];
}));
registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger });
// Periodic upserts bound data-loss on an unclean shutdown while still
// avoiding writes at the 20 Hz sensor-frame rate.
const flushTimer = setInterval(() => collector.flushMinutes(), 30 * 1000);
flushTimer.unref?.();
function retentionDays(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : fallback;
}
function pruneNow() {
const now = Date.now();
const detailedDays = retentionDays(retentionConfig.detailedDays, 0);
@@ -77,43 +91,62 @@ if (!isFeatureEnabled('fleetReports')) {
const retentionTimer = setInterval(pruneNow, 6 * 60 * 60 * 1000);
retentionTimer.unref?.();
function getDailyReport({ since, until, roverIds } = {}) {
const end = Number(until) || Date.now();
return reportBuilder.build({
since: Number(since) || end - 24 * 60 * 60 * 1000,
until: end,
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
// Daily Discord output is intentionally metric-only. Avoiding the event
// query here also prevents irrelevant event volume from bloating the
// durable daily snapshot that supports delivery idempotency.
includeEvents: false,
});
}
runtime = {
batteryEnabled,
storage,
collector,
reportBuilder,
unsubscribeEvents,
managerEventHandlers,
flushTimer,
retentionTimer,
};
logger.info('Fleet reporting enabled', {
databaseAvailable: storage.getDiagnostics().available,
maximumIntegrationGapMs,
minimumCapacityTestDepthPercent,
batteryEnabled,
});
module.exports = {
enabled: true,
getDailyReport,
collector,
storage,
reportBuilder,
// Exposed for controlled tests and graceful future shutdown wiring. Normal
// runtime leaves subscriptions active for the lifetime of the server.
stop() {
unsubscribeEvents();
if (batteryEnabled) roverManager.managerEvents.off('sensor', collector.collectSensor);
commandEvents.off('observation', collector.collectCommand);
odometerEvents.off('update', collector.collectOdometer);
managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
clearInterval(flushTimer);
clearInterval(retentionTimer);
collector.flushMinutes();
},
};
}
registerSocketGateway({ roverManager, getRuntime: () => runtime, logger });
startRuntime(loadConfig().fleetReports || {});
registerConfigurationHandler('fleetReports', startRuntime);
module.exports = {
get enabled() {
return Boolean(runtime);
},
getDailyReport({ since, until, roverIds } = {}) {
if (!runtime) return null;
const end = Number(until) || Date.now();
return runtime.reportBuilder.build({
since: Number(since) || end - 24 * 60 * 60 * 1000,
until: end,
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
includeEvents: false,
});
},
get collector() {
return runtime?.collector || null;
},
get storage() {
return runtime?.storage || storage;
},
get reportBuilder() {
return runtime?.reportBuilder || null;
},
backupDatabase(destinationPath) {
/*
Backups include reporting history even when collection is currently
disabled. Lazily opening the existing store keeps this one operation
behind the report service's normal database ownership boundary.
*/
if (!storage) {
storage = createStorage({ logger });
storage.open();
}
return storage.backupDatabase(destinationPath);
},
stop: stopRuntime,
};
@@ -17,10 +17,13 @@ function normalizeRange(payload = {}) {
return { since, until: Math.max(since + 1, until) };
}
function registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger }) {
function registerSocketGateway({ roverManager, getRuntime, logger }) {
io.on('connection', (socket) => {
socket.on('fleetReports:get', (payload = {}, cb = () => {}) => {
try {
const runtime = getRuntime();
if (!runtime) throw new Error('Fleet reports are disabled');
const { reportBuilder } = runtime;
const { since, until } = normalizeRange(payload);
// getRosterForSocket is the canonical live private-rover visibility
// resolver. Historical queries use precisely those currently visible
@@ -59,6 +62,9 @@ function registerSocketGateway({ roverManager, reportBuilder, storage, collector
socket.on('fleetReports:replaceBattery', (payload = {}, cb = () => {}) => {
try {
const runtime = getRuntime();
if (!runtime) throw new Error('Fleet reports are disabled');
const { storage, collector } = runtime;
if (!isAdmin(socket)) throw new Error('Admin access required');
const roverId = String(payload.roverId || '').trim();
if (!roverId || !roverManager.rovers.has(roverId)) throw new Error('Known online rover required');
@@ -508,6 +508,16 @@ function createStorage({ logger }) {
}), { available: false, path: DB_PATH });
}
function backupDatabase(destinationPath) {
/*
Fleet collection may continue during an online SQLite backup. Each
resulting database file represents a valid point-in-time snapshot even
when new telemetry commits before the copy completes.
*/
if (!open()) throw new Error('Fleet report database is unavailable.');
return db.backup(destinationPath);
}
return {
open,
insertEvent,
@@ -525,6 +535,7 @@ function createStorage({ logger }) {
getActiveBattery,
replaceBattery,
getDiagnostics,
backupDatabase,
};
}
+2 -1
View File
@@ -3,12 +3,13 @@
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const fsp = require('fs/promises');
const path = require('path');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService');
const { getRoomCameraState } = require('../roomCameraService');
const { getReplayHealthSnapshot } = require('../replayEngineV2');
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const ROVER_SNAPSHOT_DIR = resolveRoverSnapshotDir();
const HEALTH_INTERVAL_MS = 5000;
const ROOM_CAMERA_STALE_MS = 5000;
const ROVER_SNAPSHOT_STALE_MS = 5000;
@@ -0,0 +1,92 @@
// 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],
// Example entities and triggers are real initial document values, as they
// were in the YAML template. Home Assistant stays inert until enabled and a
// real secret is deliberately installed by the operator.
defaultValue: {
enabled: false,
url: 'http://127.0.0.1:8123',
token: '',
[neato.key]: neato.defaultValue,
[lift.key]: lift.defaultValue,
entities: [
{ id: 'light.lab_main', name: 'Lab Lights' },
{ id: 'switch.dock_power', name: 'Dock Power' },
],
buttons: [
{
entityId: 'sensor.basement_rover_buttons_action',
stateEquals: 'on',
cooldownMs: 15000,
action: 'humanAlert',
},
{
entityId: 'sensor.basement_rover_buttons_action',
stateEquals: 'double',
cooldownMs: 2000,
action: 'modeTurns',
},
{
entityId: 'sensor.basement_rover_buttons_action',
stateEquals: 'hold',
cooldownMs: 2000,
action: 'modeAdmin',
},
{
entityId: 'sensor.basement_rover_buttons_action',
stateEquals: 'toggle',
cooldownMs: 1000,
action: 'lightsLockToggle',
},
],
},
schema: strictObject({
enabled: boolean({ description: 'Immediately connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls.' }),
url: string({ title: 'Server URL', description: 'Base URL of the Home Assistant server used for its REST and WebSocket APIs.', format: 'uri', maxLength: 2048 }),
token: string({ title: 'Long-lived access token', description: 'Home Assistant long-lived access token used to authenticate every API request. The saved value is never returned to the browser.', examples: ['REPLACE_WITH_LONG_LIVED_TOKEN'], writeOnly: true, maxLength: 20000 }),
[neato.key]: neato.schema,
[lift.key]: lift.schema,
entities: {
type: 'array',
title: 'Room entities',
description: 'Home Assistant lights and switches exposed to the room-light controls and button-box actions.',
items: strictObject({
id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID, such as light.rover_room or switch.floor_lamp.', examples: ['light.lab_main'], minLength: 1, maxLength: 255 }),
name: string({ description: 'Human-readable name shown for this entity in the rover UI.', examples: ['Lab Lights'], minLength: 1, maxLength: 120 }),
type: string({ description: 'Control behavior to expose: lights receive brightness-aware commands, while switches receive simple on and off commands.', enum: ['light', 'switch'] }),
}, {
description: 'One Home Assistant entity that the rover server can display and control.',
required: ['id', 'name'],
}),
},
buttons: {
type: 'array',
title: 'Physical button mappings',
description: 'Maps Home Assistant entity state changes to built-in rover-server actions.',
items: strictObject({
entityId: string({ title: 'Entity id', description: 'Home Assistant entity whose state changes are watched as button presses.', examples: ['sensor.basement_rover_buttons_action'], minLength: 1, maxLength: 255 }),
stateEquals: string({ description: 'Exact Home Assistant state that must be reached before the action fires.', examples: ['on'], minLength: 1, maxLength: 255 }),
cooldownMs: integer({ description: 'Minimum milliseconds between accepted activations of this mapping.', examples: [15000], minimum: 0, maximum: 86400000 }),
action: string({ description: 'Built-in action to run: raise a human alert, switch to turns mode, switch to admin mode, or toggle the room-light lock.', enum: ['humanAlert', 'modeTurns', 'modeAdmin', 'lightsLockToggle'] }),
}, {
description: 'One watched Home Assistant state transition and the server action it triggers.',
required: ['entityId', 'stateEquals', 'cooldownMs', 'action'],
}),
},
}, {
title: 'Home Assistant',
description: 'Connection, controllable entity catalog, and hardware-trigger mappings for the shared Home Assistant integration.',
required: ['enabled', 'url', 'token', 'neato', 'lift', 'entities', 'buttons'],
}),
};
@@ -8,7 +8,7 @@ const { isAdmin, isLockdownAdmin } = require('../roleService');
function registerHomeAssistantHooks(deps) {
const {
logger,
haConfig,
getHaConfig,
isLightControlLocked,
setLightsLockedOn,
toggleEntity,
@@ -110,7 +110,9 @@ function registerHomeAssistantHooks(deps) {
}
try {
if (!entityId) throw new Error('entityId required');
await setLightWhite(entityId, haConfig?.whiteKelvin);
// Resolve configuration at interaction time because the socket handler
// is intentionally registered once and survives service reloads.
await setLightWhite(entityId, getHaConfig()?.whiteKelvin);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
@@ -2,84 +2,88 @@
// 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, registerConfigurationHandler } = require('../../configuration');
const { events } = require('./state');
const { createRuntimeEngine } = require('./runtimeEngine');
const { createTransport } = require('./transport');
const { registerHomeAssistantHooks } = require('./hooks');
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const enabled = isFeatureEnabled('homeAssistant');
let current;
let callHomeAssistantServiceImpl = async () => {
throw new Error('Home Assistant not connected');
};
const runtimeEngine = createRuntimeEngine({
logger,
enabled,
haConfig,
callHomeAssistantService: (...args) => callHomeAssistantServiceImpl(...args),
});
const transport = createTransport({
logger,
enabled,
haConfig,
onSnapshot: runtimeEngine.handleEntitySnapshot,
onStatus: () => runtimeEngine.emitStatus(runtimeEngine.getState),
});
callHomeAssistantServiceImpl = transport.callHomeAssistantService;
runtimeEngine.loadEntityConfig();
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.
*/
transport.connect();
}
if (enabled) {
/*
Socket routes are part of the visible Home Assistant feature. Register them
only when enabled so disabled installs do not expose hidden controls that
the UI has intentionally removed.
*/
registerHomeAssistantHooks({
function createHomeAssistantRuntime(haConfig = {}) {
const enabled = Boolean(haConfig.enabled);
let callHomeAssistantServiceImpl = async () => {
throw new Error('Home Assistant not connected');
};
const runtimeEngine = createRuntimeEngine({
logger,
enabled,
haConfig,
isLightControlLocked: runtimeEngine.isLightControlLocked,
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleEntity: runtimeEngine.toggleEntity,
setEntityState: runtimeEngine.setEntityState,
setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite,
callHomeAssistantService: (...args) => callHomeAssistantServiceImpl(...args),
});
const transport = createTransport({
logger,
enabled,
haConfig,
onSnapshot: runtimeEngine.handleEntitySnapshot,
onStatus: () => runtimeEngine.emitStatus(runtimeEngine.getState),
});
callHomeAssistantServiceImpl = transport.callHomeAssistantService;
runtimeEngine.loadEntityConfig();
runtimeEngine.loadTriggerConfig();
if (enabled) {
// A service reload creates one fresh transport with the new credentials and
// entity schema. Disabled installations perform no network work.
transport.connect();
}
return { enabled, haConfig, runtimeEngine, transport };
}
function replaceHomeAssistantRuntime(haConfig) {
current?.transport.disconnect();
current = createHomeAssistantRuntime(haConfig);
}
replaceHomeAssistantRuntime(loadConfig().homeAssistant || {});
/*
Browser and mode hooks are registered exactly once. Their delegates resolve
`current` for every call, so a configuration save does not duplicate socket
listeners while still routing existing connections into the new runtime.
*/
registerHomeAssistantHooks({
logger,
getHaConfig: () => current.haConfig,
isLightControlLocked: (...args) => current.runtimeEngine.isLightControlLocked(...args),
setLightsLockedOn: (...args) => current.runtimeEngine.setLightsLockedOn(...args),
toggleEntity: (...args) => current.runtimeEngine.toggleEntity(...args),
setEntityState: (...args) => current.runtimeEngine.setEntityState(...args),
setLightColor: (...args) => current.runtimeEngine.setLightColor(...args),
setLightWhite: (...args) => current.runtimeEngine.setLightWhite(...args),
});
registerConfigurationHandler('homeAssistant', (haConfig) => {
replaceHomeAssistantRuntime(haConfig || {});
});
module.exports = {
getState: runtimeEngine.getState,
isConnected: transport.isConnected,
enabled,
getLightPolicyState: runtimeEngine.getLightPolicyState,
isLightControlLocked: runtimeEngine.isLightControlLocked,
getRawEntitySnapshot: runtimeEngine.getRawEntitySnapshot,
getControllableEntityIds: runtimeEngine.getControllableEntityIds,
callHomeAssistantService: transport.callHomeAssistantService,
toggleEntity: runtimeEngine.toggleEntity,
setEntityState: runtimeEngine.setEntityState,
setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite,
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
setRandomColorScene: runtimeEngine.setRandomColorScene,
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
getState: (...args) => current.runtimeEngine.getState(...args),
isConnected: (...args) => current.transport.isConnected(...args),
get enabled() {
return current.enabled;
},
getLightPolicyState: (...args) => current.runtimeEngine.getLightPolicyState(...args),
isLightControlLocked: (...args) => current.runtimeEngine.isLightControlLocked(...args),
getRawEntitySnapshot: (...args) => current.runtimeEngine.getRawEntitySnapshot(...args),
getControllableEntityIds: (...args) => current.runtimeEngine.getControllableEntityIds(...args),
callHomeAssistantService: (...args) => current.transport.callHomeAssistantService(...args),
toggleEntity: (...args) => current.runtimeEngine.toggleEntity(...args),
setEntityState: (...args) => current.runtimeEngine.setEntityState(...args),
setLightColor: (...args) => current.runtimeEngine.setLightColor(...args),
setLightWhite: (...args) => current.runtimeEngine.setLightWhite(...args),
setAllControllableEntitiesState: (...args) => current.runtimeEngine.setAllControllableEntitiesState(...args),
setRandomColorScene: (...args) => current.runtimeEngine.setRandomColorScene(...args),
setLightsLockedOn: (...args) => current.runtimeEngine.setLightsLockedOn(...args),
toggleLightsLockedOn: (...args) => current.runtimeEngine.toggleLightsLockedOn(...args),
homeAssistantEvents: events,
};
@@ -11,6 +11,9 @@ if (!global.WebSocket) {
function createTransport(deps) {
const { logger, enabled, haConfig, onSnapshot, onStatus } = deps;
let active = true;
let connection = null;
let unsubscribeEntities = null;
function getCallerFrame() {
const stack = new Error().stack || '';
const lines = stack.split('\n').slice(2).map((line) => line.trim());
@@ -37,33 +40,40 @@ function createTransport(deps) {
}
function teardownConnection() {
if (runtime.unsubscribeEntities) {
const ownedUnsubscribe = unsubscribeEntities;
unsubscribeEntities = null;
if (ownedUnsubscribe) {
try {
runtime.unsubscribeEntities();
ownedUnsubscribe();
} catch (err) {
logger.warn('Failed to unsubscribe entity stream', err.message);
}
}
runtime.unsubscribeEntities = null;
if (runtime.connection) {
const ownedConnection = connection;
connection = null;
if (ownedConnection) {
try {
runtime.connection.close();
ownedConnection.close();
} catch (err) {
logger.warn('Error closing Home Assistant connection', err.message);
}
}
runtime.connection = null;
const wasConnected = runtime.connected;
runtime.connected = false;
if (wasConnected) {
onStatus();
// An old transport's delayed disconnected event must not clear the newer
// transport stored in shared runtime state after a configuration reload.
if (runtime.connection === ownedConnection) {
runtime.connection = null;
runtime.unsubscribeEntities = null;
const wasConnected = runtime.connected;
runtime.connected = false;
if (wasConnected) onStatus();
}
}
function scheduleReconnect(delayMs = 5000) {
if (!enabled) return;
// A replaced transport must never reconnect after its successor has taken
// ownership of the shared Home Assistant connection state.
if (!active || !enabled) return;
if (runtime.reconnectTimer) return;
runtime.reconnectTimer = setTimeout(() => {
runtime.reconnectTimer = null;
@@ -72,20 +82,30 @@ function createTransport(deps) {
}
async function connect() {
if (!enabled) {
logger.info('Home Assistant integration disabled; missing url/token in config');
if (!active || !enabled) {
// 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;
if (connection) return;
try {
const auth = buildAuth();
runtime.connection = await createConnection({ auth, setupRetry: 0 });
const nextConnection = await createConnection({ auth, setupRetry: 0 });
if (!active) {
nextConnection.close();
return;
}
connection = nextConnection;
runtime.connection = connection;
runtime.connected = true;
onStatus();
logger.info('Connected to Home Assistant');
runtime.unsubscribeEntities = subscribeEntities(runtime.connection, onSnapshot);
runtime.connection.addEventListener('disconnected', () => {
unsubscribeEntities = subscribeEntities(connection, onSnapshot);
runtime.unsubscribeEntities = unsubscribeEntities;
connection.addEventListener('disconnected', () => {
logger.warn('Home Assistant connection lost');
teardownConnection();
scheduleReconnect();
@@ -98,12 +118,12 @@ function createTransport(deps) {
}
function isConnected() {
return Boolean(runtime.connection && runtime.connected);
return Boolean(connection && runtime.connection === connection && runtime.connected);
}
async function callHomeAssistantService(domain, service, serviceData = {}) {
if (!enabled) throw new Error('Home Assistant not configured');
if (!runtime.connection) throw new Error('Home Assistant not connected');
if (!active || !enabled) throw new Error('Home Assistant not configured');
if (!connection || runtime.connection !== connection) throw new Error('Home Assistant not connected');
if (!domain || !service) throw new Error('domain and service required');
logger.info('Home Assistant outbound service call', {
domain: String(domain),
@@ -111,11 +131,24 @@ function createTransport(deps) {
serviceData: serviceData && typeof serviceData === 'object' ? { ...serviceData } : serviceData,
caller: getCallerFrame(),
});
await callService(runtime.connection, String(domain), String(service), serviceData || {});
await callService(connection, String(domain), String(service), serviceData || {});
}
function disconnect() {
// Configuration reloads deliberately retire the complete transport. Clear
// its pending retry before closing so the old credentials cannot race the
// newly created transport and reclaim the shared connection.
active = false;
if (runtime.reconnectTimer) {
clearTimeout(runtime.reconnectTimer);
runtime.reconnectTimer = null;
}
teardownConnection();
}
return {
connect,
disconnect,
isConnected,
callHomeAssistantService,
};
+21
View File
@@ -5,6 +5,7 @@ const { httpServer } = require('../../globals/http');
const config = require('../../globals/config');
const logger = require('../../globals/logger').child('httpServer');
const { startMediaMtx } = require('../mediaMtxService');
const backupRestoreService = require('../backupRestoreService');
httpServer.listen(config.port, () => {
logger.info(`Server listening on :${config.port}`);
@@ -14,4 +15,24 @@ httpServer.listen(config.port, () => {
first publisher attempts to authenticate.
*/
startMediaMtx();
/*
Give child processes and startup integrations a short stabilization window
after restored databases migrate and HTTP begins listening. If the process
exits during that window, earliest startup sees the awaiting-health marker
and restores the prior data instead of accepting a broken replacement.
*/
setTimeout(() => backupRestoreService.markStartupSuccessful(), 5000);
});
function stopAcceptingConnections() {
/*
Child-process services already own their SIGTERM cleanup. The HTTP service
only stops accepting new work; MediaMTX's bounded signal handler remains
responsible for ending the Node process even if an existing socket keeps
the close callback waiting.
*/
if (httpServer.listening) httpServer.close();
}
process.once('SIGINT', stopAcceptingConnections);
process.once('SIGTERM', stopAcceptingConnections);
@@ -118,6 +118,15 @@ function getDb() {
return db;
}
function backupDatabase(destinationPath) {
/*
Keep identity writes live while SQLite copies a transactionally consistent
view into backup staging. Exposing the operation instead of the connection
preserves this service as the sole owner of identity.sqlite.
*/
return getDb().backup(destinationPath);
}
function ensureSchema(conn) {
conn.exec(`
create table if not exists users (
@@ -1087,6 +1096,7 @@ function createJsonStore({ path: filePath, normalizeStoreShape, cloneStore, logg
module.exports = {
identityEvents,
getDb,
backupDatabase,
sanitizeNickname,
normalizeCookieUserId,
isValidCookieUserId,
@@ -0,0 +1,40 @@
// 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,
// Optional behavior remains disabled, but a new configuration now starts
// with the same complete, editable template that the former YAML supplied.
defaultValue: {
enabled: false,
directoryUrls: ['https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json'],
pollIntervalMs: 30000,
requestTimeoutMs: 5000,
profile: {
name: 'Example Rover Server',
description: 'A short public description of this rover server.',
color: '#38bdf8',
},
},
schema: strictObject({
enabled: boolean({ description: 'Publishes this server\'s public instance information and polls the configured directories for peer servers.' }),
directoryUrls: stringArray({
item: {
description: 'Absolute URL returning an array of peer MultiRover instance entries.',
examples: ['https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json'],
format: 'uri',
},
array: { description: 'Directory endpoints polled to discover other public MultiRover servers.' },
}),
pollIntervalMs: integer({ description: 'Milliseconds between peer-directory refreshes.', minimum: 1000, maximum: 86400000 }),
requestTimeoutMs: integer({ description: 'Maximum milliseconds allowed for each directory or peer information request before it is aborted.', minimum: 250, maximum: 120000 }),
profile: strictObject({
name: string({ description: 'Public instance name advertised to peer servers.', minLength: 1, maxLength: 120 }),
description: string({ description: 'Short public summary advertised with this instance.', examples: ['A short public description of this rover server.'], maxLength: 500 }),
color: string({ description: 'Six-digit hexadecimal accent color advertised for this instance.', pattern: '^#[0-9a-fA-F]{6}$' }),
}, { description: 'Public identity this server publishes through the inter-instance information endpoint; its address comes from the top-level public URL.', required: ['name', 'description', 'color'] }),
}, { title: 'Inter-instance directory', description: 'Controls discovery and public information exchange between independent MultiRover servers.', 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, registerConfigurationHandler } = require('../../configuration');
const { getConfiguredSocials } = require('../sessionService/configuration');
const { getMode, MODES } = require('../modeManager');
const roverManager = require('../roverManager');
const { getTurnQueues } = require('../turnService');
@@ -20,13 +20,13 @@ const DEFAULT_POLL_INTERVAL_MS = 30000;
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
const INFO_PATH = '/api/inter-instance/info';
const INSTANCE_ID = uuidv4();
const config = loadConfig();
const interInstanceConfig = config.interInstance || {};
const profileConfig = interInstanceConfig.profile || {};
let interInstanceConfig = loadConfig().interInstance || {};
const interInstanceEvents = new EventEmitter();
const remoteInstances = new Map();
let polling = false;
let pollGeneration = 0;
let pollingGeneration = null;
let pollTimer = null;
function asTrimmedString(value) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -59,7 +59,7 @@ function pollIntervalMs() {
}
function ownPublicUrl() {
return normalizeBaseUrl(profileConfig.publicUrl);
return normalizeBaseUrl(loadConfig().publicUrl);
}
function ownInstanceId() {
@@ -79,6 +79,7 @@ function buildPublicUrl(pathname) {
function publicProfile() {
const publicUrl = ownPublicUrl();
const profileConfig = interInstanceConfig.profile || {};
return {
id: ownInstanceId(),
name: asTrimmedString(profileConfig.name) || publicUrl || 'Rover server',
@@ -189,7 +190,15 @@ function filterPublicUsers(users = [], publicIds) {
function buildLocalInfo() {
const mode = getMode();
const lockdown = isLockdownMode();
const features = getFeatureFlags();
/*
Build every configuration-derived part of one public response from the
same immutable revision. Besides preventing a revision change from mixing
feature flags with newer social links, this supplies the explicit snapshot
required by getConfiguredSocials instead of relying on the removed legacy
global configuration object.
*/
const config = loadConfig();
const features = getFeatureFlags(config);
const publicRoster = getPublicRoster();
const publicIds = publicRoverIdSet(publicRoster);
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
@@ -429,25 +438,40 @@ async function pollRemoteInstance(entry) {
}
}
async function pollNow() {
if (!isEnabled() || polling) return;
polling = true;
async function pollNow(expectedGeneration = pollGeneration) {
if (!isEnabled() || expectedGeneration !== pollGeneration || pollingGeneration === expectedGeneration) return;
pollingGeneration = expectedGeneration;
try {
const entries = await fetchDirectoryEntries();
const nextEntries = await Promise.all(entries.map((entry) => pollRemoteInstance(entry)));
// Ignore responses from the previous directory/profile after a live edit;
// otherwise a slow retired request could repopulate peers after disable or
// overwrite results produced by the newly configured directory.
if (!isEnabled() || expectedGeneration !== pollGeneration) return;
replaceRemoteInstances(nextEntries);
interInstanceEvents.emit('change');
} catch (err) {
logger.warn('Inter-instance poll failed', { error: err.message });
} finally {
polling = false;
if (pollingGeneration === expectedGeneration) pollingGeneration = null;
}
}
function startPolling() {
if (!isEnabled()) return;
pollNow();
setInterval(pollNow, pollIntervalMs());
pollGeneration += 1;
const expectedGeneration = pollGeneration;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (!isEnabled()) {
remoteInstances.clear();
interInstanceEvents.emit('change');
return;
}
pollNow(expectedGeneration);
pollTimer = setInterval(() => pollNow(expectedGeneration), pollIntervalMs());
pollTimer.unref?.();
}
function getState() {
@@ -462,6 +486,23 @@ function getState() {
startPolling();
registerConfigurationHandler('interInstance', (nextConfig = {}) => {
// Replacing this single reference updates request timeouts, identity fields,
// directory URLs, and peer lists together. Rebuilding the interval applies
// the new cadence immediately and clears stale peers when disabled.
interInstanceConfig = nextConfig;
startPolling();
});
registerConfigurationHandler('publicUrl', () => {
/*
The canonical URL participates in self-filtering as well as the published
profile. Start a fresh generation immediately so results from an in-flight
poll using the former identity cannot be committed afterward.
*/
startPolling();
});
module.exports = {
getState,
interInstanceEvents,
@@ -0,0 +1,76 @@
// Inter-Instance Public Payload Tests
// Purpose: Verifies that configuration-backed public metadata can be assembled for peer servers.
// Scope: Runs the service in an isolated child process so its socket gateways, timers, and configuration singleton never touch another test's runtime.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
test('builds the inter-instance payload when social links are enabled', () => {
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-inter-instance-'));
const serverRoot = path.resolve(__dirname, '../../..');
try {
/*
Enabling Social links is essential to this regression: disabled links
short-circuit before the configuration argument is read and therefore
cannot expose a stale or missing configuration reference. The child
process loads the real service graph and calls the same payload builder
used by GET /api/inter-instance/info.
*/
const script = `
const configuration = require('./src/configuration');
const database = configuration.getConfigurationDatabase();
const current = database.getClientConfiguration();
const next = structuredClone(current.config);
next.socials.enabled = true;
next.socials.links = [{
id: 'community',
label: 'Community',
url: 'https://community.example.test',
icon: 'FaUsers',
color: '#38bdf8'
}];
database.updateConfiguration({
value: next,
expectedRevision: current.revision,
actor: 'inter-instance-test'
});
configuration.applyCommittedConfiguration().then(() => {
const { buildLocalInfo } = require('./src/services/interInstanceService');
const payload = buildLocalInfo();
// Production services may write startup logs to stdout. A unique
// marker separates the assertion payload from that expected noise.
process.stdout.write('\\n__INTER_INSTANCE_RESULT__' + JSON.stringify(payload.socials));
database.close();
// Requiring the production service intentionally registers persistent
// Socket.IO gateways. The disposable child has completed its one real
// payload assertion, so it must not wait for those server-owned handles.
process.exit(0);
}).catch((error) => {
console.error(error);
process.exit(1);
});
`;
const output = execFileSync(process.execPath, ['-e', script], {
cwd: serverRoot,
env: { ...process.env, SERVER_DATA_DIR: dataRoot },
encoding: 'utf8',
});
const resultMarker = '__INTER_INSTANCE_RESULT__';
const resultOffset = output.lastIndexOf(resultMarker);
assert.notEqual(resultOffset, -1, 'child process did not emit its inter-instance result');
assert.deepEqual(JSON.parse(output.slice(resultOffset + resultMarker.length)), [{
id: 'community',
label: 'Community',
url: 'https://community.example.test',
icon: 'FaUsers',
color: '#38bdf8',
}]);
} finally {
fs.rmSync(dataRoot, { recursive: true, force: true });
}
});
@@ -0,0 +1,18 @@
// 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({ description: 'Immediately starts the Kinect worker and exposes authorized frame capture.' }),
captureCooldownMs: integer({ description: 'Minimum milliseconds between accepted Kinect frame-capture requests across all clients.', minimum: 0, maximum: 3600000 }),
}, {
title: 'Kinect',
description: 'Optional Kinect frame capture and its server-wide request cooldown.',
required: ['enabled', 'captureCooldownMs'],
}),
};
+5 -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, registerConfigurationHandler } = require('../../configuration');
const hardware = require('./hardware');
const { registerKinectSocketGateway, kinectEvents } = require('./socketGateway');
@@ -11,6 +11,10 @@ const gateway = registerKinectSocketGateway({
hardware,
});
registerConfigurationHandler('kinect', (kinectConfig) => {
gateway.reconfigure({ kinect: kinectConfig || {} });
});
module.exports = {
getState: gateway.getState,
kinectEvents,
@@ -33,7 +33,7 @@ function normalizeKinectConfig(config = {}) {
}
function registerKinectSocketGateway({ config, hardware }) {
const settings = normalizeKinectConfig(config);
let settings = normalizeKinectConfig(config);
let captureCooldownUntil = 0;
let busy = false;
let lastAction = null;
@@ -206,8 +206,31 @@ function registerKinectSocketGateway({ config, hardware }) {
}
}
function reconfigure(nextConfig) {
const previousEnabled = settings.enabled;
settings = normalizeKinectConfig(nextConfig);
lastError = null;
// The native worker is the Kinect service's complete hardware runtime.
// Restarting only when the enabled state changes avoids interrupting an
// unrelated cooldown edit while still making enable/disable immediate.
if (previousEnabled && !settings.enabled) {
hardware.stopWorker();
busy = false;
} else if (!previousEnabled && settings.enabled) {
try {
hardware.startWorker();
} catch (err) {
lastError = err.message || 'kinect worker failed to start';
logger.warn('Kinect worker startup failed after configuration change', { err: lastError });
}
}
emitStatusChange();
}
return {
getState: buildStatus,
reconfigure,
};
}
@@ -0,0 +1,29 @@
// 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,
// These inert example entity IDs preserve the complete former YAML shape;
// the explicit feature switch remains the only activation signal.
defaultValue: {
enabled: false,
upSwitch: 'switch.lift_up',
downSwitch: 'switch.lift_down',
interlockMs: 2000,
commandCooldownMs: 3000,
},
schema: strictObject({
enabled: boolean({ description: 'Immediately enables lift status and commands through the two configured Home Assistant switches.' }),
upSwitch: string({ description: 'Home Assistant switch entity that powers upward lift movement.', examples: ['switch.lift_up'], maxLength: 255 }),
downSwitch: string({ description: 'Home Assistant switch entity that powers downward lift movement.', examples: ['switch.lift_down'], maxLength: 255 }),
interlockMs: integer({ description: 'Milliseconds to wait after turning off the opposing direction before energizing the requested direction. Runtime always enforces at least 250 ms.', minimum: 0, maximum: 600000 }),
commandCooldownMs: integer({ description: 'Minimum milliseconds between lift commands. Runtime never allows this to be shorter than the interlock delay.', minimum: 0, maximum: 600000 }),
}, {
title: 'Lift',
description: 'Bidirectional lift control using interlocked Home Assistant switch entities.',
required: ['enabled', 'upSwitch', 'downSwitch', 'interlockMs', 'commandCooldownMs'],
}),
};
+34 -32
View File
@@ -4,28 +4,28 @@
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, registerConfigurationHandler } = require('../../configuration');
const { getMode, MODES } = require('../modeManager');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const {
homeAssistantEvents,
getRawEntitySnapshot,
callHomeAssistantService,
isConnected: isHomeAssistantConnected,
enabled: homeAssistantEnabled,
} = require('../homeAssistantService');
const homeAssistantService = require('../homeAssistantService');
const { homeAssistantEvents, getRawEntitySnapshot, callHomeAssistantService } = homeAssistantService;
const events = new EventEmitter();
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const liftConfig = haConfig.lift || {};
const featureEnabled = isFeatureEnabled('lift');
let featureEnabled;
let upSwitchId;
let downSwitchId;
let interlockMs;
let commandCooldownMs;
const upSwitchId = String(liftConfig.upSwitch || '').trim();
const downSwitchId = String(liftConfig.downSwitch || '').trim();
const interlockMs = Math.max(250, Number(liftConfig.interlockMs) || 9000);
const commandCooldownMs = Math.max(interlockMs, Number(liftConfig.commandCooldownMs) || 25000);
function applyLiftConfig(liftConfig = {}) {
featureEnabled = Boolean(liftConfig.enabled);
upSwitchId = String(liftConfig.upSwitch || '').trim();
downSwitchId = String(liftConfig.downSwitch || '').trim();
interlockMs = Math.max(250, Number(liftConfig.interlockMs) || 9000);
commandCooldownMs = Math.max(interlockMs, Number(liftConfig.commandCooldownMs) || 25000);
}
applyLiftConfig(loadConfig().homeAssistant?.lift || {});
const state = {
busy: false,
@@ -71,9 +71,9 @@ function isConfigured() {
function getState() {
const configured = isConfigured();
const connected = isHomeAssistantConnected();
const connected = homeAssistantService.isConnected();
return {
enabled: Boolean(featureEnabled && homeAssistantEnabled && configured),
enabled: featureEnabled,
configured,
connected,
entities: {
@@ -106,8 +106,8 @@ function emitUpdate() {
function assertReady() {
if (!featureEnabled) throw new Error('Lift is disabled');
if (!isConfigured()) throw new Error('Lift not configured');
if (!homeAssistantEnabled) throw new Error('Home Assistant not configured');
if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected');
if (!homeAssistantService.enabled) throw new Error('Home Assistant not configured');
if (!homeAssistantService.isConnected()) throw new Error('Home Assistant not connected');
}
async function applyPosition(target) {
@@ -176,16 +176,10 @@ async function moveDown(actor = 'unknown') {
return requestPosition('down', actor);
}
if (featureEnabled) {
/*
Lift state depends on Home Assistant switch snapshots. Subscribe only when
the lift exists so disabled installs do not maintain hardware-specific UI
sync paths.
*/
homeAssistantEvents.on('snapshot', emitUpdate);
homeAssistantEvents.on('status', emitUpdate);
homeAssistantEvents.on('snapshot', emitUpdate);
homeAssistantEvents.on('status', emitUpdate);
io.on('connection', (socket) => {
io.on('connection', (socket) => {
function assertFeatureAccess() {
const mode = getMode();
// Lift is a public activity feature in open and turns modes. Restricted
@@ -216,11 +210,19 @@ if (featureEnabled) {
cb({ error: err.message });
}
});
});
} else {
});
if (!featureEnabled) {
logger.info('Lift disabled by config');
}
registerConfigurationHandler('homeAssistant', (haConfig = {}) => {
// Lift is nested under the Home Assistant section, so it participates in the
// same section reload and immediately sees the replacement HA transport.
applyLiftConfig(haConfig.lift || {});
emitUpdate();
});
emitUpdate();
module.exports = {
@@ -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({ description: 'Starts periodic AI commentary generation from current rover, user, and chat activity.' }),
model: string({ description: 'Ollama model name used to generate commentary.', minLength: 1, maxLength: 200 }),
ollamaServer: string({ title: 'Ollama server', description: 'Base URL of the Ollama API used for commentary generation.', format: 'uri', maxLength: 2048 }),
frequency: integer({ description: 'Commentary interval in milliseconds.', minimum: 1000, maximum: 86400000 }),
}, { title: 'LLM commentary', description: 'Generates periodic AI-authored chat commentary from recent server activity through Ollama.', 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, registerConfigurationHandler } = require('../../configuration');
const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager');
@@ -34,13 +34,12 @@ const { createSnapshotEngine } = require('./snapshotEngine');
const { registerHooks } = require('./hooks');
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 model = String(commentaryConfig.model || '').trim();
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency ?? commentaryConfig.frequencyMs));
let enabled;
let ollamaUrl;
let model;
let ollamaClient;
let frequencyMs;
let runner;
const runtime = {
timer: null,
@@ -234,73 +233,67 @@ const snapshotEngine = createSnapshotEngine({
getSkipStreak: () => runtime.skipStreak,
});
const runner = createRunner({
logger,
enabled,
model,
ollamaUrl,
frequencyMs,
jitterMs: JITTER_MS,
postCooldownMs: POST_COOLDOWN_MS,
maxBotMessages: MAX_BOT_MESSAGES,
runtime,
snapshotEngine,
readSystemPrompt,
buildModelMessages,
generateCommentary,
normalizeDuplicateKey,
getRecentMessages,
sendSystemMessage,
buildFailureInfo,
updatePhase,
startRunRecord,
patchCurrentRun,
finalizeRunRecord,
updateStatus,
});
const canRunFromConfig = enabled && model && ollamaUrl;
if (canRunFromConfig) {
registerHooks({
io,
roleEvents,
roverManager,
emitStatusToSocket,
isAdminSocket,
clearRuntimeHistory: runner.clearRuntimeHistory,
getAdminState: () => buildAdminState(status, runtime.runHistory),
onDriverActivity: runner.wakeForDriverActivity,
onSensorEvent: snapshotEngine.onSensorEvent,
onRoverRemoved: snapshotEngine.removeRover,
function applyCommentaryConfig(commentaryConfig = {}) {
runner?.stop('configuration changed');
enabled = Boolean(commentaryConfig.enabled);
ollamaUrl = String(commentaryConfig.ollamaServer || '').trim();
model = String(commentaryConfig.model || '').trim();
ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency));
status = { ...status, enabled, model, ollamaUrl, frequencyMs };
runner = createRunner({
logger,
enabled,
model,
ollamaUrl,
frequencyMs,
jitterMs: JITTER_MS,
postCooldownMs: POST_COOLDOWN_MS,
maxBotMessages: MAX_BOT_MESSAGES,
runtime,
snapshotEngine,
readSystemPrompt,
buildModelMessages,
generateCommentary,
normalizeDuplicateKey,
getRecentMessages,
sendSystemMessage,
buildFailureInfo,
updatePhase,
startRunRecord,
patchCurrentRun,
finalizeRunRecord,
updateStatus,
});
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
if (getMode() === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
logger.info('LLM commentary paused due to lockdown mode');
} else {
runner.start();
}
modeEvents.on('change', (nextMode) => {
if (nextMode === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
logger.info('LLM commentary paused due to lockdown mode');
return;
}
runner.start();
});
} else {
const disabledReason = !enabled
? 'llmCommentary.enabled is false'
: 'model or ollama server missing';
updatePhase('disabled', {
running: false,
inFlight: false,
currentRunId: null,
lastOutcome: 'disabled',
lastReason: disabledReason,
});
logger.info('LLM commentary service not started', { reason: disabledReason });
}
applyCommentaryConfig(loadConfig().llmCommentary || {});
// Runtime hooks stay attached once and route actions through the newest runner.
registerHooks({
io,
roleEvents,
roverManager,
emitStatusToSocket,
isAdminSocket,
clearRuntimeHistory: (...args) => runner.clearRuntimeHistory(...args),
getAdminState: () => buildAdminState(status, runtime.runHistory),
onDriverActivity: (...args) => runner.wakeForDriverActivity(...args),
onSensorEvent: snapshotEngine.onSensorEvent,
onRoverRemoved: snapshotEngine.removeRover,
});
modeEvents.on('change', (nextMode) => {
if (nextMode === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
return;
}
runner.start();
});
registerConfigurationHandler('llmCommentary', applyCommentaryConfig);
@@ -26,12 +26,14 @@ function createRunner(deps) {
finalizeRunRecord,
updateStatus,
} = deps;
let active = false;
function defaultTickDelayMs() {
return frequencyMs + Math.floor(Math.random() * (jitterMs + 1));
}
function scheduleNextTick(runTick, delayMs = defaultTickDelayMs()) {
if (!active) return;
const safeDelay = Math.max(0, Number.isFinite(delayMs) ? Math.floor(delayMs) : defaultTickDelayMs());
const nextRunAt = Date.now() + safeDelay;
updateStatus({ nextRunAt });
@@ -39,6 +41,7 @@ function createRunner(deps) {
}
function wakeForDriverActivity(runTick) {
if (!active) return;
if (runtime.inFlight) return;
if (runtime.timer) {
clearTimeout(runtime.timer);
@@ -48,6 +51,7 @@ function createRunner(deps) {
}
function stop(reason = 'stopped') {
active = false;
if (runtime.timer) {
clearTimeout(runtime.timer);
runtime.timer = null;
@@ -335,6 +339,8 @@ function createRunner(deps) {
});
return;
}
if (active) return;
active = true;
logger.info('LLM commentary enabled', { model, ollamaUrl, frequencyMs });
updatePhase('idle', {
running: true,
+14 -13
View File
@@ -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) {
@@ -20,19 +20,17 @@ function normalizeAdditionalHosts(rawHosts) {
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
const media = config?.media || {};
let additionalHosts = normalizeAdditionalHosts(media.additionalHosts);
if (!additionalHosts.length && media.whepBaseUrl) {
try {
/*
Existing installations predate media.additionalHosts. Using the already-configured
WHEP hostname as a one-host migration default keeps them reachable on first restart;
administrators can still list every public and LAN candidate explicitly afterward.
*/
additionalHosts = [new URL(media.whepBaseUrl).hostname].filter(Boolean);
} catch {
throw new Error('media.whepBaseUrl must be a valid URL when media.additionalHosts is empty');
}
const configuredHosts = normalizeAdditionalHosts(media.additionalHosts);
let publicHostname = '';
try {
publicHostname = new URL(config?.publicUrl).hostname;
} catch {
// The assembled configuration schema normally prevents this. Keeping the
// builder tolerant makes its pure tests and startup error path explicit.
}
// The canonical public hostname is always advertised once. Operators only
// maintain genuinely additional LAN names, aliases, or fixed IP addresses.
const additionalHosts = [...new Set([publicHostname, ...configuredHosts].filter(Boolean))];
const authPort = Number(serverPort) || 8080;
return {
@@ -56,6 +54,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
hls: false,
webrtc: true,
// WHEP and WHIP signaling is public only through the Node /video proxy.
// ICE transport on 8189 remains directly reachable by browsers.
webrtcAddress: '127.0.0.1:8889',
webrtcLocalUDPAddress: ':8189',
webrtcLocalTCPAddress: ':8189',
webrtcAdditionalHosts: additionalHosts,
@@ -8,8 +8,8 @@ const { buildMediaMtxConfig, normalizeAdditionalHosts } = require('./config');
test('generates RTSP over TCP without deployment-specific hardcodes', () => {
const generated = buildMediaMtxConfig({
config: {
publicUrl: 'https://public.example.com',
media: {
whepBaseUrl: 'http://media.internal:8889/video',
additionalHosts: ['public.example.com', '10.20.30.40'],
},
},
@@ -23,12 +23,13 @@ test('generates RTSP over TCP without deployment-specific hardcodes', () => {
assert.equal(Object.hasOwn(generated, 'rtpAddress'), false);
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
assert.equal(generated.webrtcAddress, '127.0.0.1:8889');
assert.equal(generated.authHTTPAddress, 'http://127.0.0.1:8123/mediamtx/auth');
});
test('uses the configured WHEP hostname while an older config has no additionalHosts', () => {
test('derives the primary ICE hostname from the canonical public URL', () => {
const generated = buildMediaMtxConfig({
config: { media: { whepBaseUrl: 'https://second-server.example/video' } },
config: { publicUrl: 'https://second-server.example', media: { additionalHosts: [] } },
serverPort: 8080,
snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh',
});
@@ -0,0 +1,18 @@
// Media Transport Configuration
// Purpose: Defines additional MediaMTX ICE hosts not already derived from the server's public URL.
// Scope: Contains configuration metadata only and never starts MediaMTX.
const { strictObject, stringArray } = require('../../configuration/schemaHelpers');
module.exports = {
key: 'media',
defaultValue: {
additionalHosts: [],
},
schema: strictObject({
additionalHosts: stringArray({
title: 'Additional ICE hosts',
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
array: { description: 'Extra public or LAN hostnames and addresses browsers may use in addition to the hostname derived from the top-level public URL.', uniqueItems: true },
}),
}, { title: 'Media', description: 'Adds optional WebRTC network candidates to the public hostname and local interfaces generated automatically for MediaMTX.', required: ['additionalHosts'] }),
};
+28 -6
View File
@@ -1,21 +1,43 @@
// 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, registerConfigurationHandler } = require('../../configuration');
const globalConfig = require('../../globals/config');
const logger = require('../../globals/logger').child('mediamtx');
const { createMediaMtxSupervisor } = require('./supervisor');
const supervisor = createMediaMtxSupervisor({
config: loadConfig(),
serverPort: globalConfig.port,
logger,
});
let supervisor = createSupervisor();
let started = false;
function createSupervisor() {
return createMediaMtxSupervisor({
config: loadConfig(),
serverPort: globalConfig.port,
logger,
});
}
function startMediaMtx() {
started = true;
return supervisor.start();
}
function stopSupervisor() {
return new Promise((resolve) => supervisor.stop(resolve));
}
async function reloadMediaMtx() {
// MediaMTX consumes a generated document rather than the Node configuration
// object directly. Replace its child process when either explicit media
// hosts or the canonical public hostname changes.
await stopSupervisor();
supervisor = createSupervisor();
if (started) supervisor.start();
}
registerConfigurationHandler('media', reloadMediaMtx);
registerConfigurationHandler('publicUrl', reloadMediaMtx);
/*
Other services already use process signal hooks for their own workers. This hook performs
only synchronous signal delivery; systemd's default control-group cleanup remains the final

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