yaml importer slopping it up

This commit is contained in:
legop3
2026-09-14 13:48:58 -04:00
parent 8ff3c39765
commit 881583ee0a
4 changed files with 95 additions and 16 deletions
+4 -3
View File
@@ -213,7 +213,7 @@ The setup upload must:
- Preserve lockdown roles and Discord IDs.
- Preserve secrets without printing them.
- Apply current defaults for absent fields.
- Report unknown or invalid fields instead of discarding them.
- 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.
- Refuse to replace an already-configured database.
- Write the configuration, administrators, and audit event atomically.
@@ -389,7 +389,7 @@ 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 reports unknown or invalid values instead of discarding them.
- 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.
@@ -439,6 +439,7 @@ Implemented on 2026-09-14:
- 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.
- 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.
@@ -456,7 +457,7 @@ Implemented on 2026-09-14:
Local verification completed:
- All 106 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, explicit setup-file import, and the earlier filesystem coverage.
- All 107 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, explicit setup-file import with recursive removal of nonexistent fields, 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.
+53 -1
View File
@@ -279,7 +279,7 @@ test('administrator storage never exposes hashes or removes the final lockdown a
database.close();
});
test('an explicitly uploaded YAML file imports configuration and bcrypt hashes exactly once', () => {
test('an explicitly uploaded YAML imports current fields, ignores obsolete keys, and preserves bcrypt hashes exactly once', () => {
const yamlText = `
admins:
- username: owner
@@ -289,10 +289,45 @@ admins:
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.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 });
@@ -301,3 +336,20 @@ media:
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;
});
});
@@ -2,7 +2,32 @@
// 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');
const { rootSchema, normalizeConfig, assertValidConfig } = require('./validation');
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)) {
@@ -33,13 +58,11 @@ function parseConfigurationFile(text) {
const configInput = Object.fromEntries(
Object.entries(parsed).filter(([key]) => key !== 'admins'),
);
const config = normalizeConfig(configInput);
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
/*
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.
*/
// 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);
if (!administrators.some((admin) => admin.role === 'lockdown')) {
throw new Error('The configuration file must contain at least one lockdown administrator.');
@@ -35,12 +35,15 @@ module.exports = {
items: strictObject({
id: string({ description: 'Stable camera identifier used in socket requests, selections, and replay source names.', examples: ['lobby'], minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
name: string({ description: 'Human-readable camera name shown in the UI.', examples: ['Lobby Camera'], minLength: 1, maxLength: 120 }),
description: string({ description: 'Short explanation of the camera location or view shown in the UI.', examples: ['Wide shot of the staging area.'], maxLength: 500 }),
url: string({ title: 'Snapshot URL', description: 'HTTP URL fetched when the server needs a still image from this camera.', examples: ['http://192.168.0.50/snapshot.jpg'], format: 'uri', maxLength: 2048 }),
streamUrl: string({ title: 'Stream URL', description: 'Live stream URL consumed by the server snapshot engine and replay capture path.', examples: ['http://192.168.0.50/stream.mjpg'], maxLength: 2048 }),
description: string({ description: 'Optional explanation of the camera location or view shown in the UI.', examples: ['Wide shot of the staging area.'], maxLength: 500 }),
url: string({ title: 'Snapshot URL', description: 'Optional HTTP URL fetched when the server needs a still image from this camera.', examples: ['http://192.168.0.50/snapshot.jpg'], format: 'uri', maxLength: 2048 }),
streamUrl: string({ title: 'Stream URL', description: 'Optional live stream URL consumed by the server snapshot engine and replay capture path.', examples: ['http://192.168.0.50/stream.mjpg'], maxLength: 2048 }),
}, {
description: 'One named room camera with its still-image and live-stream sources.',
required: ['id', 'name', 'description', 'url', 'streamUrl'],
// The runtime has always accepted snapshot-only and stream-only camera
// entries, and treats descriptions as presentation metadata. Requiring
// all three optional values made working YAML impossible to import.
description: 'One named room camera with an optional description and any snapshot or live-stream sources it provides.',
required: ['id', 'name'],
}),
},
}, {