remove useless slop stuff

This commit is contained in:
legop3
2026-09-14 02:49:22 -04:00
parent bfdb6555d8
commit 81994f8a56
27 changed files with 301 additions and 233 deletions
+25 -25
View File
@@ -5,7 +5,7 @@
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, legacy import, setup, and centralized admin UI
- [x] Phase 1, steps 2-5: Configuration database, manual setup-file import, setup, and centralized admin UI
- [ ] Phase 1, steps 6-9: Backup/restore, restart, and internal video proxy
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
@@ -20,6 +20,8 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
## Decision log
- 2026-09-14: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file path retained is an operator-selected YAML upload on `/setup`. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation.
- 2026-09-14: Keep the one-time first-run setup code in `data/setup-code.txt` with owner-only permissions instead of writing the credential into server logs. Reuse it across restarts and delete it permanently when setup completes.
- 2026-09-14: Feature enablement is exactly the service-owned `enabled` boolean. A service-owned configuration definition marks itself with `feature: true` when that switch belongs in the public feature map; the configuration system derives the map for sessions and command availability, including nested service definitions, without a separate feature registry. Missing credentials, hardware, connections, data, or enabled dependencies are runtime health conditions and never silently change that choice.
- 2026-09-14: Keep configuration as one ordered hierarchical document, matching the former YAML layout. The admin application presents one continuous configuration page and saves the complete document as one revision. There are no artificial Hardware, Integrations, Media, or similar configuration categories and no backend or frontend section registries.
- 2026-09-13: Use an internal Node `/video` proxy. The public reverse proxy will send every site path to Node, Node will strip `/video` and stream WHEP signaling to MediaMTX on loopback, and MediaMTX port 8889 will not be exposed publicly. MediaMTX cannot independently add a WHEP base-path prefix; making `video` part of every stream name would still leave two HTTP servers competing for the public HTTPS listener.
@@ -32,7 +34,7 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
- All operator-controlled server configuration is stored in a validated database and managed through the web UI.
- All mutable runtime state, generated files, caches, snapshots, recordings, and databases live under one server data directory.
- A complete backup can capture that one data directory consistently, and a restore can safely replace it.
- A one-time legacy importer moves an existing `config.yaml` installation into the new configuration database.
- `/setup` may initialize the database from a YAML file explicitly selected by the operator; no automatic host migration exists.
- A dedicated `/admin` application contains all server administration.
- The public `/video` route is proxied to MediaMTX by the Node server, eliminating the special external MediaMTX proxy rule.
- The completed server is packaged as a replaceable container whose only persistent mount is the data directory.
@@ -196,30 +198,27 @@ After migration is complete:
- Remove `config.yaml` and `config.example.yaml` from the repository and installation process.
- Remove `js-yaml` if MediaMTX generation is changed to avoid it or if it is otherwise no longer needed. Generated MediaMTX YAML is an internal artifact, not operator configuration, so retaining `js-yaml` solely for that generator is acceptable.
## 3. Build the one-time legacy configuration importer
## 3. Add optional configuration-file upload to setup
Existing installations need an explicit, bounded migration from their old `config.yaml`. This importer is not a compatibility loader and must never become a permanent second source of truth.
Container deployment starts with a new data directory and never discovers an old installation automatically. As a convenience, the first-run setup page may initialize the empty database from a YAML configuration file deliberately selected by the operator. This is not a startup loader, installer migration, command-line workflow, or permanent second source of truth.
The importer must:
The setup upload must:
- Accept an explicitly selected legacy YAML file.
- Parse the complete legacy document.
- Accept only an explicitly selected YAML file from `/setup`.
- Require the one-time setup code before processing it.
- Parse the complete document.
- Map every recognized field into the new configuration schema.
- Preserve existing bcrypt administrator password hashes.
- Preserve lockdown roles and Discord IDs.
- Preserve secrets without printing them.
- Apply new defaults for fields absent from an older configuration.
- Detect unknown fields and show them in the migration report.
- Apply current defaults for absent fields.
- Report unknown or invalid fields instead of discarding them.
- Validate the entire result before writing anything.
- Refuse to overwrite an already-configured database unless an explicit replacement workflow is used.
- Support a dry-run that reports changes without writing.
- Write the imported configuration and migration metadata atomically.
- Record the source format and migration time without storing secret values in the audit event.
- Verify that the resulting configuration can be read back before considering the import successful.
- Refuse to replace an already-configured database.
- Write the configuration, administrators, and audit event atomically.
- Record the uploaded filename without storing secret values in the audit event.
The first-run UI should recognize that a legacy configuration is available and offer the import after the operator proves possession of the one-time setup code. A command-line import path should also exist for recovery and unattended migration.
After a successful import, the server must use only the database. The legacy YAML file should not be watched, re-read, or used as fallback. Removal of the old file should be an explicit final migration step after the operator has downloaded a backup or otherwise confirmed the import.
The browser uploads the selected contents directly. The server never scans the host for a file, and it does not retain, watch, remove, or reuse the uploaded YAML after the database transaction completes.
## 4. Add first-run setup
@@ -229,10 +228,10 @@ Required flow:
1. Initialize the databases and safe default configuration.
2. Keep all optional external integrations disabled.
3. Generate a one-time setup code and print it to the server log.
3. Generate a one-time setup code in `data/setup-code.txt` with owner-only permissions. Logs report the file location but never the credential.
4. Serve a restricted `/setup` application.
5. Require the setup code before creating the first lockdown administrator.
6. Offer legacy configuration import when a legacy source was explicitly provided.
6. Offer manual YAML configuration-file upload as an alternative to creating the first administrator from scratch.
7. Otherwise collect only the minimum information needed to establish the instance.
8. Permanently disable setup after the first lockdown administrator exists.
@@ -388,8 +387,9 @@ Phase 1 is complete only when all of the following are true:
- The current systemd installation runs without `config.yaml`.
- A completely empty data directory can be initialized through `/setup`.
- An existing YAML installation can be imported exactly once.
- The importer reports unknown or invalid legacy values instead of discarding them.
- An explicitly selected YAML file can initialize the empty database exactly once.
- The setup upload reports unknown or invalid values instead of discarding them.
- Startup and installation do not search for or modify an old `config.yaml`.
- All mutable server state is contained by the configured data directory.
- A complete backup can be downloaded and validated.
- A restore replaces the server state only after validation and survives restart.
@@ -437,8 +437,8 @@ Implemented on 2026-09-14:
- 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 importer for both the setup UI and command line. Existing bcrypt hashes, lockdown roles, Discord identities, configuration, and secrets are migrated without creating a runtime YAML fallback.
- Added safe empty-data startup, a logged one-time setup code, the restricted `/setup` route, and a console administrator-recovery command.
- Added an explicit one-time YAML upload to `/setup`. Existing bcrypt hashes, lockdown roles, Discord identities, configuration, and secrets can be imported only when the operator selects the file; the installer and startup perform no automatic discovery or migration, and there is no command-line importer.
- Added safe empty-data startup, a file-backed one-time setup code, the restricted `/setup` route, and a console administrator-recovery command. The credential persists at `data/setup-code.txt` across restarts with `0600` permissions, never appears in logs, and is deleted when setup completes.
- Added the centralized `/admin` route with Overview, Fleet operations, Users and administrators, and one schema-generated hierarchical Configuration page in legacy YAML order.
- Replaced every feature-specific configuration form with `@rjsf/core`; the protected admin snapshot supplies the server's assembled schema, and one generic widget handles all schema-declared secrets.
- 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.
@@ -448,7 +448,7 @@ Implemented on 2026-09-14:
Local verification completed:
- All 99 server tests passed, including direct enabled-switch feature projection, service-definition composition, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, legacy import, and the earlier filesystem coverage.
- All 103 server tests passed, including file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, explicit setup-file import, and the earlier filesystem coverage.
- Focused admin, route, and identity UI lint passed.
- All 20 existing focused web UI tests passed.
- The production web UI build completed successfully and regenerated the checked-in server assets.
@@ -658,7 +658,7 @@ 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 the legacy YAML importer.
- [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.
+2 -17
View File
@@ -256,22 +256,6 @@ fi
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
# Import an existing legacy file only when this installation does not yet have
# its configuration database. The importer validates the complete document and
# preserves administrator password hashes without printing secrets. Fresh
# installations intentionally skip this branch and complete setup through the
# one-time code printed by the server.
if [[ -f "$SERVER_DIR/config.yaml" && ! -f "$DATA_DIR/configuration.sqlite" ]]; then
echo " Importing legacy config.yaml into configuration.sqlite"
runuser -u "$TARGET_USER" -- env SERVER_DATA_DIR="$DATA_DIR" \
"$NODE_BIN" "$SERVER_DIR/scripts/importLegacyConfig.js" "$SERVER_DIR/config.yaml"
# The importer exits successfully only after the complete configuration and
# administrator catalog have committed and can be read back. Remove this
# exact obsolete source file afterward so secrets do not remain in a second,
# unmanaged configuration source on upgraded installations.
rm -f "$SERVER_DIR/config.yaml"
fi
# 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.
@@ -341,7 +325,8 @@ echo
echo "Services installed:"
echo " multirover.service (Node.js control server with MediaMTX child)"
echo
echo "Open /setup for a fresh installation or /admin for an imported installation."
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."
-1
View File
@@ -6,7 +6,6 @@
"start": "node index.js",
"dev": "nodemon index.js",
"check:media": "node scripts/checkMedia.js",
"config:import": "node scripts/importLegacyConfig.js",
"admin:recover": "node scripts/adminAccount.js"
},
"dependencies": {
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
@@ -1,2 +0,0 @@
import{e as E,r as s,j as e,S as P,C as c,L as F}from"./index-B3PBxGLi.js";import{e as I,f as $,i as q}from"./api-C8p-OT8N.js";function R(){const i=E(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,N]=s.useState(""),[p,C]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,l]=s.useState("");s.useEffect(()=>{I(i).then(t=>m(t.required)).catch(t=>l(t.message))},[i]);async function y(t){h(!0),l("");try{await t(),m(!1),l("Setup completed. You can now open the administration application and log in.")}catch(u){const L=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(j=>`${j.path}: ${j.message}`).join("; ")}`:"";l(`${u.message}${L}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){l("Passwords do not match.");return}y(()=>$(i,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&y(async()=>q(i,{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 setup code printed in the server log, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(F,{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=>N(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>C(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 legacy configuration",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"The selected YAML is uploaded directly for one-time validation and import. 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-Ct8z7xZT.js.map
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-CRbxLzE1.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-Dv9kbAgD.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
function n(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const o=new Error(r.error);o.code=r.code||null,o.validationErrors=r.validationErrors||[],o.currentRevision=r.currentRevision||null,s(o);return}e(r)})})}const d=t=>n(t,"adminConfig:get"),m=(t,i)=>n(t,"adminConfig:confirmPassword",{password:i}),u=(t,i)=>n(t,"adminConfig:updateConfiguration",i),c=(t,i)=>n(t,"adminConfig:restoreRevision",i),f=(t,i)=>n(t,"adminConfig:createAdministrator",i),g=(t,i)=>n(t,"adminConfig:updateAdministrator",i),C=(t,i)=>n(t,"adminConfig:deleteAdministrator",{id:i}),A=t=>n(t,"setup:status"),l=(t,i)=>n(t,"setup:createAdministrator",i),p=(t,i)=>n(t,"setup:importConfigurationFile",i);export{u as a,m as b,f as c,C as d,A as e,l as f,d as g,p as i,c as r,g as u};
//# sourceMappingURL=api-C0PIt_OP.js.map
@@ -1 +1 @@
{"version":3,"file":"api-C8p-OT8N.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importLegacyConfiguration = (socket, payload) => emitAdminRequest(socket, 'setup:importLegacy', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importLegacyConfiguration"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHU,EAAsB,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQe,IAAOhB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAe,CAAE,CAAE,EAExGC,EAAkBhB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEiB,EAA2B,CAACjB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GgB,EAA4B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,qBAAsBE,CAAO"}
{"version":3,"file":"api-C0PIt_OP.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHU,EAAsB,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQe,IAAOhB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAe,CAAE,CAAE,EAExGC,EAAkBhB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEiB,EAA2B,CAACjB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GgB,EAA0B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
-2
View File
@@ -1,2 +0,0 @@
function r(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(n={})=>{if(n?.error){const o=new Error(n.error);o.code=n.code||null,o.validationErrors=n.validationErrors||[],o.currentRevision=n.currentRevision||null,s(o);return}e(n)})})}const d=t=>r(t,"adminConfig:get"),c=(t,i)=>r(t,"adminConfig:confirmPassword",{password:i}),m=(t,i)=>r(t,"adminConfig:updateConfiguration",i),u=(t,i)=>r(t,"adminConfig:restoreRevision",i),g=(t,i)=>r(t,"adminConfig:createAdministrator",i),f=(t,i)=>r(t,"adminConfig:updateAdministrator",i),A=(t,i)=>r(t,"adminConfig:deleteAdministrator",{id:i}),C=t=>r(t,"setup:status"),l=(t,i)=>r(t,"setup:createAdministrator",i),p=(t,i)=>r(t,"setup:importLegacy",i);export{m as a,c as b,g as c,A as d,C as e,l as f,d as g,p as i,u as r,f as u};
//# sourceMappingURL=api-C8p-OT8N.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-B3PBxGLi.js"></script>
<script type="module" crossorigin src="/assets/index-CRbxLzE1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C935Fjdg.css">
</head>
<body>
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env node
// Legacy Configuration Import Command
// Purpose: Imports or validates an explicitly selected YAML file before normal database-only startup.
// Scope: Provides recovery/unattended migration using the same importer as first-run setup.
const fs = require('fs');
const path = require('path');
const { getConfigurationDatabase } = require('../src/configuration');
const { importLegacyConfiguration } = require('../src/configuration/legacyImporter');
function usage() {
process.stderr.write('Usage: node scripts/importLegacyConfig.js <config.yaml> [--dry-run]\n');
}
function main() {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const selectedPath = args.find((arg) => arg !== '--dry-run');
if (!selectedPath) {
usage();
process.exitCode = 2;
return;
}
const absolutePath = path.resolve(selectedPath);
const text = fs.readFileSync(absolutePath, 'utf8');
const result = importLegacyConfiguration({
text,
database: getConfigurationDatabase(),
actor: 'command-line-import',
source: path.basename(absolutePath),
dryRun,
});
/*
Never print parsed configuration values: the legacy document commonly
contains Discord, Home Assistant, and camera credentials. A concise count
and revision are sufficient for an unattended migration log.
*/
process.stdout.write(`${dryRun ? 'Legacy configuration is valid' : 'Legacy configuration imported'}: ${result.administratorCount} administrator(s)${result.revision ? `, revision ${result.revision}` : ''}.\n`);
}
try {
main();
} catch (error) {
process.stderr.write(`Legacy configuration import failed: ${error.message}\n`);
if (Array.isArray(error.validationErrors)) {
error.validationErrors.forEach((entry) => process.stderr.write(`- ${entry.path}: ${entry.message}\n`));
}
process.exitCode = 1;
}
@@ -1,5 +1,5 @@
// Configuration System Tests
// Purpose: Verifies strict defaults, immutable revisions, secret handling, legacy import, and administrator safety.
// 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');
@@ -10,7 +10,7 @@ const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validat
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
const { getFeatureFlags } = require('./index');
const { createConfigurationDatabase } = require('./database');
const { parseLegacyConfiguration, importLegacyConfiguration } = require('./legacyImporter');
const { parseConfigurationFile, importConfigurationFile } = require('./configurationFileImporter');
const temporaryRoots = [];
@@ -168,7 +168,7 @@ test('administrator storage never exposes hashes or removes the final lockdown a
database.close();
});
test('legacy YAML imports configuration and bcrypt hashes exactly once', () => {
test('an explicitly uploaded YAML file imports configuration and bcrypt hashes exactly once', () => {
const yamlText = `
admins:
- username: owner
@@ -179,14 +179,14 @@ timezone: America/Chicago
media:
whepBaseUrl: http://localhost:8889/video
`;
const parsed = parseLegacyConfiguration(yamlText);
const parsed = parseConfigurationFile(yamlText);
assert.equal(parsed.config.timezone, 'America/Chicago');
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
const database = createTestDatabase();
const result = importLegacyConfiguration({ text: yamlText, database, dryRun: false });
const result = importConfigurationFile({ text: yamlText, database });
assert.equal(result.administratorCount, 1);
assert.equal(database.findAdministratorForAuthentication('OWNER').passwordHash, '$2b$10$preservedHash');
assert.throws(() => importLegacyConfiguration({ text: yamlText, database }), /cannot replace an initialized installation/);
assert.throws(() => importConfigurationFile({ text: yamlText, database }), /cannot replace an initialized installation/);
database.close();
});
@@ -0,0 +1,67 @@
// Configuration File Importer
// Purpose: Validates one YAML file deliberately uploaded during first-run setup and stores it in the configuration database.
// Scope: This is an explicit setup action only; startup and installation never search for or consume configuration files.
const yaml = require('js-yaml');
const { normalizeConfig, assertValidConfig } = require('./validation');
function normalizeUploadedAdministrator(entry, index) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`Administrator ${index + 1} must be an object.`);
}
const username = String(entry.username || '').trim();
const passwordHash = String(entry.password_hash || '').trim();
if (!username || !passwordHash) {
throw new Error(`Administrator ${index + 1} requires username and password_hash.`);
}
return {
username,
passwordHash,
discordId: String(entry.discord_id || '').trim(),
role: entry.lockdown ? 'lockdown' : 'admin',
};
}
function parseConfigurationFile(text) {
const parsed = yaml.load(String(text || ''));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('The configuration file must contain a YAML object.');
}
const administrators = Array.isArray(parsed.admins)
? parsed.admins.map(normalizeUploadedAdministrator)
: [];
const configInput = Object.fromEntries(
Object.entries(parsed).filter(([key]) => key !== 'admins'),
);
const config = normalizeConfig(configInput);
/*
Unknown fields remain in the normalized document so strict schema
validation reports them to the operator instead of silently losing data
from the explicitly selected file.
*/
assertValidConfig(config);
if (!administrators.some((admin) => admin.role === 'lockdown')) {
throw new Error('The configuration file must contain at least one lockdown administrator.');
}
return { config, administrators };
}
function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) {
const result = parseConfigurationFile(text);
const revision = database.importConfigurationFile({
config: result.config,
administrators: result.administrators,
actor,
source,
});
return {
revision,
administratorCount: result.administrators.length,
};
}
module.exports = {
parseConfigurationFile,
importConfigurationFile,
};
+9 -7
View File
@@ -331,8 +331,10 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
}));
}
const importLegacyTransaction = db.transaction(({ config, administrators, actor, source }) => {
if (isSetupComplete()) throw new Error('Legacy configuration cannot replace an initialized installation.');
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,
@@ -340,13 +342,13 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
source,
});
administrators.forEach((admin) => createAdministratorTransaction(admin, actor, false));
if (!isSetupComplete()) throw new Error('Legacy import must contain at least one lockdown administrator.');
writeAudit(actor, 'legacy-import.completed', { revision, administratorCount: administrators.length, source });
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 importLegacy(payload) {
return importLegacyTransaction(payload);
function importConfigurationFile(payload) {
return importConfigurationFileTransaction(payload);
}
return {
@@ -364,7 +366,7 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
countLockdownAdministrators,
isSetupComplete,
listAuditEvents,
importLegacy,
importConfigurationFile,
close: () => db.close(),
};
}
@@ -1,74 +0,0 @@
// Legacy YAML Configuration Importer
// Purpose: Converts one explicitly supplied config.yaml document into the database-backed configuration model.
// Scope: Parses and validates legacy input without becoming a runtime fallback or watcher.
const yaml = require('js-yaml');
const { normalizeConfig, assertValidConfig } = require('./validation');
function normalizeLegacyAdministrator(entry, index) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`Legacy administrator ${index + 1} must be an object.`);
}
const username = String(entry.username || '').trim();
const passwordHash = String(entry.password_hash || '').trim();
if (!username || !passwordHash) {
throw new Error(`Legacy administrator ${index + 1} requires username and password_hash.`);
}
return {
username,
passwordHash,
discordId: String(entry.discord_id || '').trim(),
role: entry.lockdown ? 'lockdown' : 'admin',
};
}
function parseLegacyConfiguration(text) {
const parsed = yaml.load(String(text || ''));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Legacy configuration must contain a YAML object.');
}
const administrators = Array.isArray(parsed.admins)
? parsed.admins.map(normalizeLegacyAdministrator)
: [];
const configInput = Object.fromEntries(
Object.entries(parsed).filter(([key]) => key !== 'admins'),
);
const config = normalizeConfig(configInput);
/*
Unknown fields are retained by normalizeConfig and therefore appear in
Ajv's precise validation errors instead of being silently discarded during
the one-time import.
*/
assertValidConfig(config);
if (!administrators.some((admin) => admin.role === 'lockdown')) {
throw new Error('Legacy configuration must contain at least one lockdown administrator.');
}
return { config, administrators };
}
function importLegacyConfiguration({ text, database, actor = 'legacy-import', source = 'config.yaml', dryRun = false }) {
const result = parseLegacyConfiguration(text);
if (dryRun) {
return {
dryRun: true,
administratorCount: result.administrators.length,
};
}
const revision = database.importLegacy({
config: result.config,
administrators: result.administrators,
actor,
source,
});
return {
dryRun: false,
revision,
administratorCount: result.administrators.length,
};
}
module.exports = {
parseLegacyConfiguration,
importLegacyConfiguration,
};
+25 -21
View File
@@ -1,34 +1,38 @@
// First-Run Setup Service
// Purpose: Allows an empty data directory to create its first lockdown administrator or import legacy YAML safely.
// Purpose: Allows an empty data directory to create its first lockdown administrator or import an explicitly uploaded YAML file.
// Scope: Exposes setup-only socket operations and permanently closes them once a lockdown administrator exists.
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('setupService');
const { getConfigurationDatabase } = require('../../configuration');
const { importLegacyConfiguration } = require('../../configuration/legacyImporter');
const { importConfigurationFile } = require('../../configuration/configurationFileImporter');
const { createSetupCodeFile } = require('./setupCodeFile');
const MAX_LEGACY_YAML_BYTES = 1024 * 1024;
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
const database = getConfigurationDatabase();
let setupCode = null;
const setupCodeFile = createSetupCodeFile();
let setupNoticeLogged = false;
function isSetupRequired() {
return !database.isSetupComplete();
}
function ensureSetupCode() {
if (!isSetupRequired()) return null;
if (!setupCode) {
setupCode = crypto.randomBytes(6).toString('hex');
/*
The code is intentionally logged only on a server that has no lockdown
administrator. It lives in process memory, changes on restart, and is
permanently irrelevant as soon as setup succeeds, so it cannot become a
recurring environment-variable authentication bypass.
*/
logger.warn('First-run setup is required', { setupCode });
if (!isSetupRequired()) {
// Setup authorization permanently closes when the first lockdown account
// exists. Remove a stale credential left by an interrupted final response.
setupCodeFile.remove();
return null;
}
return setupCode;
const code = setupCodeFile.ensure();
// Logs may be retained or shipped elsewhere, so they identify the local file
// containing the credential without ever including the credential itself.
if (!setupNoticeLogged) {
logger.warn('First-run setup is required', { setupCodePath: setupCodeFile.filePath });
setupNoticeLogged = true;
}
return code;
}
function requireOpenSetup(candidateCode) {
@@ -73,25 +77,25 @@ io.on('connection', (socket) => {
discordId: payload.discordId,
role: 'lockdown',
}, 'first-run-setup');
setupCode = null;
setupCodeFile.remove();
return { administrator };
});
});
socket.on('setup:importLegacy', (payload = {}, cb = () => {}) => {
socket.on('setup:importConfigurationFile', (payload = {}, cb = () => {}) => {
respond(cb, () => {
requireOpenSetup(payload.setupCode);
const yamlText = String(payload.yaml || '');
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_LEGACY_YAML_BYTES) {
throw new Error('Legacy YAML must be present and no larger than 1 MiB.');
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_CONFIGURATION_FILE_BYTES) {
throw new Error('The YAML configuration file must be present and no larger than 1 MiB.');
}
const result = importLegacyConfiguration({
const result = importConfigurationFile({
text: yamlText,
database,
actor: 'first-run-setup',
source: String(payload.fileName || 'uploaded-config.yaml').slice(0, 255),
});
setupCode = null;
setupCodeFile.remove();
return result;
});
});
@@ -0,0 +1,74 @@
// Setup Code File
// Purpose: Persists the one-time first-run credential inside the server data directory.
// Scope: Owns secure file creation, validation, reuse, and removal without knowing whether setup is complete.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { resolveDataPath } = require('../../helpers/dataPaths');
const DEFAULT_SETUP_CODE_PATH = resolveDataPath('setup-code.txt');
const SETUP_CODE_PATTERN = /^[0-9a-f]{12}$/;
function createSetupCodeFile({ filePath = DEFAULT_SETUP_CODE_PATH } = {}) {
function read() {
const fileStats = fs.lstatSync(filePath);
if (!fileStats.isFile()) {
// In particular, reject symbolic links before chmod or read operations so
// a writable data directory cannot redirect setup handling to another file.
throw new Error(`Setup code path is not a regular file: ${filePath}`);
}
fs.chmodSync(filePath, 0o600);
const code = fs.readFileSync(filePath, 'utf8').trim().toLowerCase();
if (!SETUP_CODE_PATTERN.test(code)) {
/*
Never silently replace a malformed credential. An operator may already
be reading that file, and changing it behind their back would make setup
failures mysterious while concealing possible filesystem corruption.
*/
throw new Error(`Setup code file is invalid: ${filePath}`);
}
return code;
}
function ensure() {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
if (fs.existsSync(filePath)) {
return read();
}
const code = crypto.randomBytes(6).toString('hex');
try {
/*
Exclusive creation prevents two accidentally overlapping server starts
from overwriting one another's setup credential. The file is the source
an operator reads, so the accepted code must always match its contents.
*/
fs.writeFileSync(filePath, `${code}\n`, {
encoding: 'utf8',
flag: 'wx',
mode: 0o600,
});
return code;
} catch (error) {
if (error.code !== 'EEXIST') throw error;
return read();
}
}
function remove() {
fs.rmSync(filePath, { force: true });
}
return {
filePath,
ensure,
read,
remove,
};
}
module.exports = {
DEFAULT_SETUP_CODE_PATH,
SETUP_CODE_PATTERN,
createSetupCodeFile,
};
@@ -0,0 +1,61 @@
// Setup Code File Tests
// Purpose: Verifies the first-run credential remains private, stable, and removable.
// Scope: Uses an isolated operating-system temporary directory and never touches development server data.
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const test = require('node:test');
const { SETUP_CODE_PATTERN, createSetupCodeFile } = require('./setupCodeFile');
const temporaryRoots = [];
function createTestStore() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-setup-code-'));
temporaryRoots.push(root);
return createSetupCodeFile({ filePath: path.join(root, 'setup-code.txt') });
}
test.after(() => {
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
});
test('creates one owner-readable code and reuses it across startup initialization', () => {
const store = createTestStore();
const firstCode = store.ensure();
const secondCode = store.ensure();
assert.match(firstCode, SETUP_CODE_PATTERN);
assert.equal(secondCode, firstCode);
assert.equal(fs.readFileSync(store.filePath, 'utf8'), `${firstCode}\n`);
// Mask off file-type bits so this assertion checks only Unix permissions.
assert.equal(fs.statSync(store.filePath).mode & 0o777, 0o600);
});
test('rejects a malformed existing credential instead of replacing it', () => {
const store = createTestStore();
fs.writeFileSync(store.filePath, 'not-a-valid-code\n', { mode: 0o600 });
assert.throws(() => store.ensure(), /Setup code file is invalid/);
assert.equal(fs.readFileSync(store.filePath, 'utf8'), 'not-a-valid-code\n');
});
test('rejects a setup-code symlink without reading or changing its target', () => {
const store = createTestStore();
const targetPath = path.join(path.dirname(store.filePath), 'unrelated.txt');
fs.writeFileSync(targetPath, 'unrelated-content\n', { mode: 0o644 });
fs.symlinkSync(targetPath, store.filePath);
assert.throws(() => store.ensure(), /not a regular file/);
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'unrelated-content\n');
assert.equal(fs.statSync(targetPath).mode & 0o777, 0o644);
});
test('removes the credential after setup completes', () => {
const store = createTestStore();
store.ensure();
store.remove();
assert.equal(fs.existsSync(store.filePath), false);
});
+14 -14
View File
@@ -1,12 +1,12 @@
// First-Run Setup Application
// Purpose: Initializes a fresh server or imports an explicitly selected legacy config.yaml through the restricted setup channel.
// Purpose: Initializes a fresh server or imports an explicitly selected YAML configuration file through the restricted setup channel.
// Scope: Exists only while the server reports setup required; ordinary administration belongs to /admin.
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import CardFrame from '../components/CardFrame/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { createFirstAdministrator, getSetupStatus, importLegacyConfiguration } from './api.js';
import { createFirstAdministrator, getSetupStatus, importConfigurationFile } from './api.js';
export default function SetupApp() {
const socket = useSocket();
@@ -16,7 +16,7 @@ export default function SetupApp() {
const [discordId, setDiscordId] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [legacyFile, setLegacyFile] = useState(null);
const [configurationFile, setConfigurationFile] = useState(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
@@ -50,13 +50,13 @@ export default function SetupApp() {
run(() => createFirstAdministrator(socket, { setupCode, username, discordId, password }));
}
function importLegacy(event) {
function importSelectedConfiguration(event) {
event.preventDefault();
if (!legacyFile) return;
run(async () => importLegacyConfiguration(socket, {
if (!configurationFile) return;
run(async () => importConfigurationFile(socket, {
setupCode,
fileName: legacyFile.name,
yaml: await legacyFile.text(),
fileName: configurationFile.name,
yaml: await configurationFile.text(),
}));
}
@@ -65,7 +65,7 @@ export default function SetupApp() {
<SocketConnectionPill />
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5">
<CardFrame title="MultiRover setup" meta={required === null ? 'checking' : required ? 'required' : 'complete'} bodyClassName="space-y-0.5 p-1 text-sm">
{required ? <p>Enter the one-time setup code printed in the server log, then create the first lockdown administrator or import an existing configuration.</p> : null}
{required ? <p>Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration.</p> : null}
{required === false ? <Link className="button-dark inline-block" to="/admin">Open administration</Link> : null}
{message ? <p className="surface p-1 text-sm text-slate-200">{message}</p> : null}
</CardFrame>
@@ -85,11 +85,11 @@ export default function SetupApp() {
<button className="button-dark md:col-span-2" type="submit" disabled={busy || !setupCode || !username || !password}>Create lockdown administrator</button>
</form>
</CardFrame>
<CardFrame title="Import legacy configuration" bodyClassName="space-y-0.5 p-1 text-sm">
<p className="text-xs text-slate-400">The selected YAML is uploaded directly for one-time validation and import. Its secrets are never displayed back in the browser.</p>
<form className="flex flex-col gap-0.5 md:flex-row" onSubmit={importLegacy}>
<input className="field-input flex-1" type="file" accept=".yaml,.yml,text/yaml" onChange={(event) => setLegacyFile(event.target.files?.[0] || null)} />
<button className="button-dark" type="submit" disabled={busy || !setupCode || !legacyFile}>Import selected YAML</button>
<CardFrame title="Import configuration file" bodyClassName="space-y-0.5 p-1 text-sm">
<p className="text-xs text-slate-400">Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser.</p>
<form className="flex flex-col gap-0.5 md:flex-row" onSubmit={importSelectedConfiguration}>
<input className="field-input flex-1" type="file" accept=".yaml,.yml,text/yaml" onChange={(event) => setConfigurationFile(event.target.files?.[0] || null)} />
<button className="button-dark" type="submit" disabled={busy || !setupCode || !configurationFile}>Import selected YAML</button>
</form>
</CardFrame>
</>
+1 -1
View File
@@ -27,4 +27,4 @@ export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adm
export const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');
export const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);
export const importLegacyConfiguration = (socket, payload) => emitAdminRequest(socket, 'setup:importLegacy', payload);
export const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);