// Configuration classes import { Type, Static } from "@sinclair/typebox"; import * as fs from "node:fs/promises"; import events from "events"; import isDeepStrictEqual from "#is_deep_strict_equal"; import { basicType } from "../helpers.js"; import * as libSchema from "../schema.js"; import { StringEnum } from "../data/composites.js"; import { safeOutputFile } from "../file_ops.js"; import * as validators from "./validators.js"; import { InvalidAccessError, InvalidFieldError, InvalidValueError } from "../errors.js"; import type { SyncHook } from "../SyncHook.js"; const ConfigLocation = StringEnum(["controller", "host", "control"]); export type ConfigLocation = Static; const OldConfigGroupSchema = Type.Object({ "name": Type.String(), "fields": Type.Record(Type.String(), Type.Unknown()), }); type OldConfigGroupSchema = Static; const OldConfigSchema = Type.Object({ "groups": Type.Array(OldConfigGroupSchema), }); export type OldConfigSchema = Static; export const ConfigSchema = Type.Record(Type.String(), Type.Union([ Type.Null(), Type.Boolean(), Type.Number(), Type.String(), Type.Object({}), ])); export type ConfigSchema = Static; type FieldValue = null | boolean | string | number | object; type FieldType = T extends boolean ? "boolean" : T extends string ? "string" : T extends number ? "number" : T extends object ? "object" : never; export interface FieldDefinition< Type extends FieldValue = any, Fields extends Record = any, > { /** * string declaring the type of the config value, supports boolean, * string, number and object. */ type: FieldType; /** * Text used to identify the config field in user interfaces. */ title?: string; /** * Text used to describe the config field in user interfaces. */ description?: string; /** * Validation function ran to confirm the value is consistent with other config values. * Should throw an error if the value in invalid / inconsistent */ validator?: (value: Type, config: Config) => void; /** * Names the config fields that the validator depends on. * This will ensure it is checked when these fields are changed. */ dependsOn?: string[]; /** * Value to use for the autocomplete attribute in the Web UI's input element. * Useful for making browsers behave properly with credential inputs. * Only used for string and number fields. */ autoComplete?: string; /** * Web UI component to use for rendering this input. Plugins register components * through the inputComponents hook on control.hooks in their web entrypoint. */ inputComponent?: string; /** * True if this field can be set to null. Defaults to false. */ optional?: Type extends null ? true : false; /** * Value this config field should take in a newly initialized config. * This can also be a synchronous function returning the value to use. * Promise results are not awaited. */ initialValue?: unknown | (() => unknown); /** * Array of all values that are acceptible. Useful for making * enumerated values. */ enum?: unknown[]; /** * True if a restart of the entity this config is attached to is * required for changes to take effect. Informative only, defaults to * false. */ restartRequired?: boolean; /** * Properties to invert the value of restartRequired for if present. */ restartRequiredProps?: string[]; /** * Array of strings declaring where this config entry can be read and * modified from. */ access?: ConfigLocation[]; /** * If present treat the config value as a credential. * Only the locations specified will be able to read the current value. * Access can be used to specify which locations are able to overwrite the value. */ credential?: ConfigLocation[]; /** * If present treat the config value as readonly. * Only the locations specified will be able to update the config value. * Access can be used to specify which locations are able to read the value. */ readonly?: ConfigLocation[]; /** * True if graphical interfaces should hide the config value */ hidden?: boolean; } export type ConfigDefs< Fields extends Record > = { [Prop in keyof Fields]: FieldDefinition; }; function defaultValue(def: FieldDefinition) { if (def.initialValue === undefined) { return null; } if (typeof def.initialValue === "function") { return def.initialValue(); } return def.initialValue; } export type ConfigEvents = { fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ], }; export enum ConfigAccess { read = 0x1, write = 0x2, readWrite = 0x3, } /** * Collection of config entries * * Type checks and stores a collection of related config entries. Attempt * to set invalid values for fields will result in an error thrown while * loading a config with invalid values will result in those being replaced * by the innitial value for those fields. * * This is an EventEmitter with the following events: * - fieldChanged - * invoked after a field has been updated. Takes three parameters: name, * curr, prev. * * @extends events.EventEmitter */ export class Config< Fields extends Record > extends events.EventEmitter { /** * Mapping of config name to field meta data * Note that mutating this object will lead to unexpected behaviour. */ declare static fieldDefinitions: ConfigDefs; declare ["constructor"]: typeof Config; /** A set of common validators and validator factories */ static validators = validators; /** * Handle migration between clusterio versions * * @param config - Input config read and validated from file * @returns Output config to be loaded into the class * @protected */ static migrations(config: Static) { return config; } /** * Migrations attached by plugins, run after {@link Config.migrations}. * Declared by each subclass that plugins can add fields to. */ declare static pluginMigrations?: SyncHook; /** * Versions of the plugins whose migrations are attached, keyed by * plugin name. Written to `.version` after the migrations run. */ static pluginVersions = new Map(); /** Defines the default access locations for the config class */ static defaultAccess: ConfigLocation[] = ["controller", "host", "control"]; /** Contains the fields and their values */ private fields: Fields; private _unknownFields: Record = {}; /** Set to true during validators to prevent edits */ private _readonly = false; // TODO replace with "using config.asReadonly()" /** Set to true when a field in the config is changed. */ private _dirty = false; /** Set to true when a 'restart required' field in the config is changed. */ restartRequired = false; /** True if the config has changes that have not been saved. */ get dirty() { return this._dirty; } /** * Create a new instance of the given config * * @param location - * Location to evaluate access for this instance from. * @param fields - * Serialized representation of fields to load. * @param filepath - * Filepath for the config to save to. */ constructor( public location: ConfigLocation, fields?: Static, private _filepath?: string, ) { if (typeof location !== "string") { throw new Error("location must be a string"); } super(); this.fields = Object.fromEntries( Object.entries( this.constructor.fieldDefinitions ).filter( ([name, def]) => this._checkAccess(name, def, location, ConfigAccess.read, false) ).map( ([name, def]) => [name, defaultValue(def)] ) ) as Fields; if (fields) { let migrated = this.constructor.migrations(fields); if (this.constructor.pluginMigrations) { migrated = this.constructor.pluginMigrations.reduce(migrated); } this.update(migrated, false, location); } for (const [pluginName, version] of this.constructor.pluginVersions) { const name = `${pluginName}.version` as keyof Fields & string; if (name in this.fields && this.fields[name] !== version) { this.fields[name] = version as Fields[typeof name]; if (fields) { this._dirty = true; } } } } get filepath() { return this._filepath; } set filepath(filepath: string | undefined) { this._dirty = true; this._filepath = filepath; } /** A staging version of the config with changes tracked, invalided by revertStaging */ private _staging?: { config: Config, changes: Set}; /** Get the latest version of staging */ // TODO replace with "using config.startStating()" get staging(): Config { if (this._staging) { return this._staging.config; } // Copied with update so migrations don't run again on the live fields const config = new this.constructor(this.location); config.update(this.fields, false); const changes = new Set(); config.on("fieldChanged", name => changes.add(name)); this._staging = { config, changes }; return config; } private static _fieldDependents: Map } }[]>; /** Map of all fields whose validators depend on the given field */ static get fieldDependents(): typeof this._fieldDependents { if (this._fieldDependents) { return this._fieldDependents; } const dependents = new Map(); for (const [name, def] of Object.entries(this.fieldDefinitions)) { if (!def.validator || !def.dependsOn) { continue; } for (const otherName of def.dependsOn) { const fieldDependents = dependents.get(otherName); if (fieldDependents) { fieldDependents.push({name, def}); } else { dependents.set(otherName, [{name, def}]); } } } // The type is correct because of "!def.validator", not clear why this doesn't infer this._fieldDependents = dependents as any; return this._fieldDependents; } /** * Coerce and validate a value according to a field definition. * * @param name - Name of the field being validated. * @param def - Definition describing the field type and constraints. * @param value - Value to validate and coerce. * @throws {InvalidValueError} if the value cannot be coerced to the correct type * @returns The validated and coerced value. * @private */ static _coerceValue(name: string, def: T, value: unknown) { // Empty strings are treated as null if (value === "") { value = null; } // If def is an enum then it must be a value within it if (def.enum && !def.enum.includes(value)) { throw new InvalidValueError(`Expected one of [${def.enum.join(", ")}], not ${value}`); } // If it is a string, then attempt to coerce it if (typeof value === "string") { if (def.type === "boolean") { if (value === "true") { value = true; } else if (value === "false") { value = false; } } else if (def.type === "number") { const numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/; if (numberRegExp.test(value.trim())) { value = Number.parseFloat(value); } } else if (def.type === "object") { try { value = JSON.parse(value); } catch (err: any) { throw new InvalidValueError(`Error parsing value for ${name}: ${err.message}`); } } } // Check if it is allowed to be null if (value === null) { if (!def.optional) { throw new InvalidValueError(`Field ${name} cannot be null`); } // Check if is the correct type } else if (basicType(value) !== def.type) { throw new InvalidValueError(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`); } return value as FieldType; } /** * Check access for a field * * @param name - Name of the field to check. * @param def - definition for for the field to check. * @param location - location to check access for. * @param mode - type of access to check for. * @param error - throw on failure to pass check. * @throws {InvalidAccessError} * if access is not granted and error is true. * @returns true if access is granted * @private */ _checkAccess(name: string, def: FieldDefinition, location: ConfigLocation, mode: ConfigAccess, error: boolean) { // If credential array is present it acts as read permission and the access array as write permission, // If readonly array is present it acts as write permission and the access array as the read permission. // If neither is present access array acts as a combined read/write permission check. if ( def.credential && mode & ConfigAccess.read && !(def.credential).includes(location) || def.readonly && mode & ConfigAccess.write && !(def.readonly).includes(location) || ( (!def.credential || mode & ConfigAccess.write) && (!def.readonly || mode & ConfigAccess.read) && !(def.access ?? this.constructor.defaultAccess).includes(location) ) ) { if (error) { throw new InvalidAccessError(`Field '${name}' is not accessible from ${location}`); } else { return false; } } return true; } /** * Set the value of a field, while also settings the required flags and events. * This method does not perform any validation on the value being set * * @param name - Name of the field to set * @param value - The new value of the field * @param notify - False to skip setting dirty flags and emitting events */ _set(name: keyof Fields & string, value: FieldValue, notify: boolean = true) { if (this._readonly) { throw new Error("config is readonly and cannot have values changed"); } const prev = this.fields[name]; this.fields[name] = value as any; this.revertStaging(); if (notify && !isDeepStrictEqual(value, prev)) { const def = this.constructor.fieldDefinitions[name]; if (def.restartRequired) { this.restartRequired = true; } this._dirty = true; this.emit("fieldChanged", name, value, prev); } } /** * Create config from file * * @param location - Location used for access control. * @param filepath - Filepath for the config to save to. * @returns Instance of this config */ static async fromFile(location: ConfigLocation, filepath: string) { const fields = JSON.parse(await fs.readFile(filepath, { encoding: "utf8" })); return this.fromJSON(fields, location, filepath); } /** * Saves this config to file if there are unsaved changes * @param force - Set to true to save the file even if no changes have been made. */ async save(force = false) { if (!force && !this._dirty) { return; } if (!this.filepath) { throw new Error("Cannot save config which has no filepath"); } await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 }); this._dirty = false; } static jsonSchema = ConfigSchema; static validate = libSchema.compile(this.jsonSchema as any); /** * Create config from a serialized object * * @param json - Serialized config to load. * @param location - Location used for access control. * @param filepath - Filepath for the config to save to. * @returns Instance of this config */ static fromJSON( json: Static, location: ConfigLocation, filepath?: string, ) { // migrate: Pre alpha 14 config format if (typeof json === "object" && json !== null && json.groups instanceof Array) { json = Object.fromEntries( (json.groups as unknown as OldConfigGroupSchema[]).flatMap( group => Object.entries(group.fields).map( ([name, value]) => [`${group.name}.${name}`, value as FieldValue] ), ), ); } const valid = Config.validate(json); if (!valid) { throw new Error("Invalid config"); } return new this(location, json, filepath); } toJSON() { return this.toRemote(this.location); } /** * Serialize the config to a plain JavaScript object * * @param remote - Location this serialised representation is for * @param filter - When provided, only the given fields are serialized * @returns JSON serializable representation of the config. */ toRemote(remote: ConfigLocation, filter?: (keyof Fields)[]): Static { let fields: Record = {}; for (let [name, value] of Object.entries(this.fields)) { if (filter && !filter.includes(name as keyof Fields)) { continue; } let def = this.constructor.fieldDefinitions[name]; if ( this.location !== "control" && !this._checkAccess(name, def, this.location, ConfigAccess.write, false) || this.location !== remote && !this._checkAccess(name, def, remote, ConfigAccess.read, false) ) { continue; } fields[name] = value as FieldValue; } for (let [name, value] of Object.entries(this._unknownFields)) { fields[name] = value; } return fields; } /** * Update a config from a serialized config * * Overwrites the values of all the fields in this config that has a * valid value in the passed serialized config with that value. * * @param json - * Result from a previous call to .toRemote(). * @param notify - Invoke fieldChanged events if true. * @param remote - Location this serialised representation was received from. */ update( json: Static, notify: boolean, remote = this.location ) { const valid = Config.validate(json); if (!valid) { throw new Error("Invalid config"); } if (basicType(json) !== "object") { throw new Error(`Expected object, not ${basicType(json)} for Config`); } for (let [name, value] of Object.entries(json) as [keyof Fields & string, FieldValue][]) { let def = this.constructor.fieldDefinitions[name]; if (!def) { this._unknownFields[name as string] = value; continue; } if ( this.location !== "control" && !this._checkAccess(name, def, remote, ConfigAccess.write, false) ) { continue; } if (def.optional) { if (value !== null && basicType(value) !== def.type) { continue; } } else if (basicType(value) !== def.type) { continue; } if (def.enum && !def.enum.includes(value)) { continue; } this._set(name, value, notify); } } /** * Check if field can be accessed from the given location * * @param name - Name of field to check. * @param mode - Mode to access the field as. * @param location - Location used for access control. * @returns true if field is accessible */ canAccess(name: string, mode: ConfigAccess, location = this.location) { if (typeof mode !== "number") { throw new TypeError("mode argument is required to canAccess"); } let def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidFieldError(`No field named '${name}'`); } return this._checkAccess(name, def, location, mode, false); } /** * Get value for field * * @param name - Name of field to get. * @param remote - Location this value is being read from. * @returns Value stored for the field. */ get(name: Field, remote = this.location) { let def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidFieldError(`No field named '${name}'`); } // If remote access is requested require it to be able to read the field if (remote !== this.location) { this._checkAccess(name, def, remote, ConfigAccess.read, true); } // Require that the field is either read or writable locally so that you can get the value // back of write only fields recently written to. if ( !this._checkAccess(name, def, this.location, ConfigAccess.write, false) && !this._checkAccess(name, def, this.location, ConfigAccess.read, false) ) { throw new InvalidAccessError(`Field '${name}' is not accessible from ${this.location}`); } return this.fields[name]; } /** * Set value of field * * The field must be defined for the config and the value is * type checked against the field definition. * * @param name - Name of field to set. * @param newValue - Value to set for field. * @param remote - Location this field is being set from. * @throws {InvalidFieldError} if field is not defined. * @throws {InvalidValueError} if value is not allowed for the field. */ set(name: Field, newValue: Fields[Field], remote = this.location) { const def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidFieldError(`No field named '${name}'`); } this._checkAccess(name, def, remote, ConfigAccess.write, true); if (remote !== this.location) { this._checkAccess(name, def, this.location, ConfigAccess.write, true); } const value = Config._coerceValue(name, def, newValue); const _readonly = this._readonly; this.staging._readonly = true; this._readonly = true; try { if (def.validator) { try { def.validator(value, this); } catch (err: any) { throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`); } } } finally { this._readonly = _readonly; this.staging._readonly = _readonly; } const dependents = this.constructor.fieldDependents.get(name); if (dependents) { // This mimics stage + commit but optimised for a single value this.staging._set(name, value); this.staging._readonly = true; try { for (const dependent of dependents) { try { dependent.def.validator(this.get(dependent.name as any, remote), this.staging); } catch (err: any) { throw new InvalidValueError(`Failed validation of dependent ${dependent.name}: ${err.message}`); } } } finally { this.revertStaging(); } } this._set(name, value); } /** * Set property of object field * * Update value of stored object field by setting the specified property * on it. The field must be defined as an object for the config. * * @param name - Name of field to set property on. * @param prop - Name of property to set on field. * @param value - the value to set the property to. * @param remote - Location this property is being set from * @throws {InvalidFieldError} if field is not defined. * @throws {InvalidValueError} if field is not an object. */ setProp( name: Field, prop: string, value?: unknown, remote = this.location, ) { let def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidFieldError(`No field named '${name}'`); } this._checkAccess(name, def, remote, ConfigAccess.write, true); if (remote !== this.location) { this._checkAccess(name, def, this.location, ConfigAccess.write, true); } if (def.type !== "object") { throw new InvalidValueError(`Cannot set property on non-object field '${name}'`); } let prev = this.fields[name] as Record; let updated = {...prev || {}}; if (value !== undefined) { updated[prop] = value; } else { delete updated[prop]; } const _readonly = this._readonly; this.staging._readonly = true; this._readonly = true; try { if (def.validator) { try { def.validator(updated, this); } catch (err: any) { throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`); } } } finally { this._readonly = _readonly; this.staging._readonly = _readonly; } const dependents = this.constructor.fieldDependents.get(name); if (dependents) { // This mimics stage + commit but optimised for a single value this.staging._set(name, updated); this.staging._readonly = true; try { for (const dependent of dependents) { try { dependent.def.validator(this.get(dependent.name as any, remote), this.staging); } catch (err: any) { throw new InvalidValueError(`Failed validation of dependent ${dependent.name}: ${err.message}`); } } } finally { this.revertStaging(); } } this._set(name, updated); } /** * Similar to set but only validates the field type and not other constraints. * The value is stored in staging and must be committed for the change to take effect. * * @param name - Name of field to set. * @param newValue - Value to set for field. * @param remote - Location this field is being set from. * @throws {InvalidFieldError} if field is not defined. * @throws {InvalidValueError} if value is not allowed for the field. */ stage(name: Field, newValue: Fields[Field], remote = this.location) { if (this._readonly) { throw new Error("config is readonly and cannot have values changed"); } const def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidFieldError(`No field named '${name}'`); } this._checkAccess(name, def, remote, ConfigAccess.write, true); if (remote !== this.location) { this._checkAccess(name, def, this.location, ConfigAccess.write, true); } const value = Config._coerceValue(name, def, newValue); this.staging._set(name, value); } /** * Similar to setProp but only validates the field type and not other constraints. * The value is stored in staging and must be committed for the change to take effect. * * @param name - Name of field to set property on. * @param prop - Name of property to set on field. * @param value - the value to set the property to. * @param remote - Location this property is being set from * @throws {InvalidFieldError} if field is not defined. * @throws {InvalidValueError} if field is not an object. */ stageProp( name: Field, prop: string, value?: unknown, remote = this.location, ) { if (this._readonly) { throw new Error("config is readonly and cannot have values changed"); } const def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidFieldError(`No field named '${name}'`); } this._checkAccess(name, def, remote, ConfigAccess.write, true); if (remote !== this.location) { this._checkAccess(name, def, this.location, ConfigAccess.write, true); } if (def.type !== "object") { throw new InvalidValueError(`Cannot set property on non-object field '${name}'`); } const prev = this.staging.get(name) as Record; const updated = {...prev || {}}; if (value !== undefined) { updated[prop] = value; } else { delete updated[prop]; } this.staging._set(name, updated); } /** * Commit all changes in stating after they have been validated. * * @throws {InvalidValueError} if a value is not allowed for its field. */ commitStaging() { if (this._readonly) { throw new Error("config is readonly and cannot have values changed"); } const staging = this._staging; if (!staging) { return; // Nothing to commit } // Validate all fields using the current state of staging const config = staging.config; const _readonly = this._readonly; config._readonly = true; try { for (const [fieldName, fieldDef] of Object.entries(this.constructor.fieldDefinitions)) { if (fieldDef.validator) { try { fieldDef.validator(config.fields[fieldName as keyof Fields], config); } catch (err: any) { throw new InvalidValueError(`Failed validation of ${fieldName}: ${err.message}`); } } } } finally { config._readonly = _readonly; } // Validation success, set all values which have changed for (const fieldName of staging.changes) { const field = fieldName as keyof Fields & string; this._set(field, config.fields[field]); } } /** Revert all changes in staging */ revertStaging() { if (this._readonly) { throw new Error("config is readonly and cannot have values changed"); } this._staging = undefined; } }