Merge pull request #856 from Cooldude2606/feature/config-validation

Add extended config validation
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-03-21 00:15:16 +00:00
commit 81e419dad4
25 files changed
+1912 -318

No files matched your search

+2 -1
View File
@@ -182,7 +182,8 @@ async function handleBootstrapCommand(
// eslint-disable-next-line no-console
console.log(jwt.sign(
{ aud: "host", host: args.id },
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
// Auth secret is never null after startup
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
));
} else if (subCommand === "create-ctl-config") {
+52 -4
View File
@@ -73,6 +73,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this));
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
@@ -84,6 +85,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
@@ -195,6 +197,26 @@ export default class ControlConnection extends BaseConnection {
return this._controller.config.toRemote("control");
}
async handleControllerConfigSetRequest(request: lib.ControllerConfigSetRequest) {
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
this._controller.config.stageProp(
fieldName as keyof lib.ControllerConfigFields, propName, propValue, "control"
);
}
} else {
this._controller.config.stage(fieldName as keyof lib.ControllerConfigFields, fieldValue, "control");
}
}
this._controller.config.commitStaging();
} finally {
this._controller.config.revertStaging();
}
}
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
}
@@ -280,17 +302,42 @@ export default class ControlConnection extends BaseConnection {
return instance.config.toRemote("control");
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
if (request.field === "instance.assigned_host") {
private static validateInstanceConfigSetField(fieldName: string) {
if (fieldName === "instance.assigned_host") {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
if (request.field === "instance.id") {
if (fieldName === "instance.id") {
// XXX is this worth implementing? It's race condition galore.
throw new lib.RequestError("Setting instance.id is not supported");
}
}
async handleInstanceConfigSetRequest(request: lib.InstanceConfigSetRequest) {
const instance = this._controller.instances.getForRequest(request.instanceId);
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
ControlConnection.validateInstanceConfigSetField(fieldName);
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
instance.config.stageProp(
fieldName as keyof lib.InstanceConfigFields, propName, propValue, "control"
);
}
} else {
instance.config.stage(fieldName as keyof lib.InstanceConfigFields, fieldValue, "control");
}
}
instance.config.commitStaging();
} finally {
instance.config.revertStaging();
}
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
const instance = this._controller.instances.getForRequest(request.instanceId);
ControlConnection.validateInstanceConfigSetField(request.field);
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
await this._controller.instanceConfigUpdated(instance);
}
@@ -298,6 +345,7 @@ export default class ControlConnection extends BaseConnection {
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
let { field, prop, value } = request;
ControlConnection.validateInstanceConfigSetField(field);
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
await this._controller.instanceConfigUpdated(instance);
}
+6 -4
View File
@@ -268,6 +268,11 @@ export default class Controller {
this.users.records.on("update", this.usersUpdated.bind(this));
}
get authSecret() {
// Auth secret is never null after startup
return Buffer.from(this.config.get("controller.auth_secret")!, "base64");
}
async start(args: ControllerArgs) {
if (this._state !== "new") {
throw new Error(`Cannot start in state ${this._state}`);
@@ -883,10 +888,7 @@ export default class Controller {
* @param hostId - ID of host to generate a token for.
*/
generateHostToken(hostId: number): string {
return jwt.sign(
{ aud: "host", host: hostId },
Buffer.from(this.config.get("controller.auth_secret"), "base64")
);
return jwt.sign({ aud: "host", host: hostId }, this.authSecret);
}
hostsUpdated(hosts: HostRecord[]) {
+2 -1
View File
@@ -151,7 +151,8 @@ export default class UserManager {
signUserToken(user: UserRecord): string {
return jwt.sign(
{ aud: "user", user: user.id },
Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64")
// auth secret is never null after startup
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
);
}
+5 -23
View File
@@ -223,10 +223,7 @@ ${err.stack}`
createSession(dst: lib.Address): [ WsServerConnector, string ] {
let sessionId = this.nextSessionId;
this.nextSessionId += 1;
let sessionToken = jwt.sign(
{ aud: this.sessionAud, sid: sessionId },
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
);
let sessionToken = jwt.sign({ aud: this.sessionAud, sid: sessionId }, this.controller.authSecret);
let sessionTimeout = this.controller.config.get("controller.session_timeout");
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
@@ -247,13 +244,8 @@ ${err.stack}`
}
try {
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
// migrate: allow pre-rename tokens issued to hosts before alpha-14
{ audience: ["host", "slave"] }
);
// migrate: allow pre-rename tokens issued to hosts before alpha-14
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -324,12 +316,7 @@ ${err.stack}`
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
let user;
try {
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -376,12 +363,7 @@ ${err.stack}`
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
let connector;
try {
let payload = jwt.verify(
data.sessionToken,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: this.sessionAud }
);
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { audience: this.sessionAud });
if (typeof payload === "string") {
throw new Error("unexpected JsonWebToken type");
}
+3 -13
View File
@@ -1,5 +1,5 @@
import type { Application, Request, Response } from "express";
import type Controller from "./Controller";
import Controller from "./Controller";
import busboy from "busboy";
import crypto from "crypto";
@@ -148,12 +148,7 @@ function validateHostToken(req: Request, res: Response, next: any) {
}
try {
const tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "host" }
);
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -180,12 +175,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
}
try {
let tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
+26 -2
View File
@@ -387,6 +387,7 @@ export default class Host extends lib.Link {
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
this.handle(lib.HostConfigSetRequest, this.handleHostConfigSetRequest.bind(this));
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
@@ -503,18 +504,41 @@ export default class Host extends lib.Link {
return this.config.toRemote("control");
}
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
if (request.field === "host.id") {
private static validateHostConfigSetField(fieldName: string) {
if (fieldName === "host.id") {
// Changing host.id at runtime is not worth the effort to
// support and will break a lot of things if it is allowed.
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
}
}
async handleHostConfigSetRequest(request: lib.HostConfigSetRequest) {
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
Host.validateHostConfigSetField(fieldName);
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
this.config.stageProp(fieldName as keyof lib.HostConfigFields, propName, propValue, "control");
}
} else {
this.config.stage(fieldName as keyof lib.HostConfigFields, fieldValue, "control");
}
}
this.config.commitStaging();
} finally {
this.config.revertStaging();
}
}
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
Host.validateHostConfigSetField(request.field);
this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control");
}
async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) {
let { field, prop, value } = request;
Host.validateHostConfigSetField(field);
this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control");
}
+333 -41
View File
@@ -8,6 +8,7 @@ import { basicType } from "../helpers";
import * as libSchema from "../schema";
import { StringEnum } from "../data/composites";
import { safeOutputFile } from "../file_ops";
import * as validators from "./validators";
const ConfigLocation = StringEnum(["controller", "host", "control"]);
export type ConfigLocation = Static<typeof ConfigLocation>;
@@ -63,14 +64,24 @@ export const ConfigSchema = Type.Record(Type.String(), Type.Union([
]));
export type ConfigSchema = Static<typeof ConfigSchema>;
type FieldType = "boolean" | "string" | "number" | "object";
type FieldValue = null | boolean | string | number | object;
export interface FieldDefinition {
type FieldType<T extends FieldValue> =
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<keyof Fields, FieldValue> = any,
> {
/**
* string declaring the type of the config value, supports boolean,
* string, number and object.
*/
type: FieldType;
type: FieldType<Type>;
/**
* Text used to identify the config field in user interfaces.
*/
@@ -79,6 +90,16 @@ export interface FieldDefinition {
* 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<Fields>) => 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.
@@ -93,7 +114,7 @@ export interface FieldDefinition {
/**
* True if this field can be set to null. Defaults to false.
*/
optional?: boolean;
optional?: Type extends null ? true : false;
/**
* Value this config field should take in a newly initialized config.
* This can also be an function returning the value to use.
@@ -137,8 +158,10 @@ export interface FieldDefinition {
hidden?: boolean;
}
export type ConfigDefs<Fields> = {
[Prop in keyof Fields]: FieldDefinition;
export type ConfigDefs<
Fields extends Record<keyof Fields, FieldValue>
> = {
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
}
function defaultValue(def: FieldDefinition) {
@@ -177,14 +200,17 @@ export enum ConfigAccess {
* @extends events.EventEmitter
*/
export class Config<
Fields extends { [Field in keyof Fields]: FieldValue },
Fields extends Record<keyof Fields, FieldValue>
> extends events.EventEmitter<ConfigEvents> {
/**
* Mapping of config name to field meta data
* Note that mutating this object will lead to unexpected behaviour.
*/
declare static fieldDefinitions: ConfigDefs<any>;
declare ["constructor"]: typeof Config;
declare ["constructor"]: typeof Config<Fields>;
/** A set of common validators and validator factories */
static validators = validators;
/**
* Handle migration between clusterio versions
@@ -204,6 +230,8 @@ export class Config<
private fields: Fields;
private _unknownFields: Record<string, FieldValue> = {};
/** 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. */
@@ -244,6 +272,110 @@ export class Config<
}
}
/** A staging version of the config with changes tracked, invalided by revertStaging */
private _staging?: { config: Config<Fields>, changes: Set<string>};
/** Get the latest version of staging */
// TODO replace with "using config.startStating()"
get staging(): Config<Fields> {
if (this._staging) { return this._staging.config; }
const config = new this.constructor(this.location, this.fields);
const changes = new Set<string>();
config.on("fieldChanged", name => changes.add(name));
this._staging = { config, changes };
return config;
}
private static _fieldDependents: Map<string, {
name: string,
def: FieldDefinition & { validator: NonNullable<FieldDefinition["validator"]> }
}[]>;
/** 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<string, { name: string, def: FieldDefinition }[]>();
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 {InvalidValue} if the value cannot be coerced to the correct type
* @returns The validated and coerced value.
* @private
*/
static _coerceValue<T extends FieldDefinition>(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 InvalidValue(`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 InvalidValue(`Error parsing value for ${name}: ${err.message}`);
}
}
}
// Check if it is allowed to be null
if (value === null) {
if (!def.optional) {
throw new InvalidValue(`Field ${name} cannot be null`);
}
// Check if is the correct type
} else if (basicType(value) !== def.type) {
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
return value as FieldType<T["type"]>;
}
/**
* Check access for a field
*
@@ -288,8 +420,14 @@ export class Config<
* @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) {
@@ -508,55 +646,50 @@ export class Config<
* @throws {InvalidValue} if value is not allowed for the field.
*/
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
let value: FieldValue = newValue;
let def = this.constructor.fieldDefinitions[name];
const def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidField(`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);
}
// Empty strings are treates as null
if (value === "") {
value = null;
}
const value = Config._coerceValue(name, def, newValue);
if (def.enum && !def.enum.includes(value)) {
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
}
if (typeof value === "string") {
if (def.type === "boolean") {
if (value === "true") {
value = true;
} else if (value === "false") {
value = false;
}
} else if (def.type === "number") {
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
if (numberRegExp.test(value.trim())) {
value = Number.parseFloat(value);
}
} else if (def.type === "object") {
const _readonly = this._readonly;
this.staging._readonly = true;
this._readonly = true;
try {
if (def.validator) {
try {
value = JSON.parse(value);
def.validator(value, this);
} catch (err: any) {
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
}
}
} finally {
this._readonly = _readonly;
this.staging._readonly = _readonly;
}
if (value === null) {
if (!def.optional) {
throw new InvalidValue(`Field ${name} cannot be null`);
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 InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
}
}
} finally {
this.revertStaging();
}
} else if (basicType(value) !== def.type) {
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
this._set(name, value);
@@ -585,6 +718,7 @@ export class Config<
if (!def) {
throw new InvalidField(`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);
@@ -603,6 +737,164 @@ export class Config<
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 InvalidValue(`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 InvalidValue(`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 {InvalidField} if field is not defined.
* @throws {InvalidValue} if value is not allowed for the field.
*/
stage<Field extends keyof Fields & string>(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 InvalidField(`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 {InvalidField} if field is not defined.
* @throws {InvalidValue} if field is not an object.
*/
stageProp<Field extends keyof Fields & string>(
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 InvalidField(`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 InvalidValue(`Cannot set property on non-object field '${name}'`);
}
const prev = this.staging.get(name) as Record<string, unknown>;
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 {InvalidValue} 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 InvalidValue(`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;
}
}
+57 -4
View File
@@ -1,7 +1,9 @@
// Core definitions for the configuration system
import * as classes from "./classes";
import * as validators from "./validators";
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
import { Static } from "@sinclair/typebox";
import { isTargetVersion } from "../data/version";
type configFromJSON<T> = (...args: Parameters<typeof classes.Config.fromJSON>) => T;
type configFromFile<T> = (...args: Parameters<typeof classes.Config.fromFile>) => Promise<T>;
@@ -18,7 +20,7 @@ export interface ControllerConfigFields {
"controller.public_url": string | null;
"controller.tls_certificate": string | null;
"controller.tls_private_key": string | null;
"controller.auth_secret": string;
"controller.auth_secret": string | null;
"controller.heartbeat_interval": number;
"controller.session_timeout": number;
"controller.metrics_timeout": number;
@@ -73,18 +75,21 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
initialValue: "mods",
validator: validators.filePath,
},
"controller.database_directory": {
title: "Database directory",
description: "Directory where item and configuration data is stored.",
type: "string",
initialValue: "database",
validator: validators.filePath,
},
"controller.autosave_interval": {
title: "Autosave Interval",
description: "Interval in seconds to autosave data in memory to disk.",
type: "number",
initialValue: 60,
validator: validators.greaterThanZero,
},
"controller.http_port": {
title: "HTTP Port",
@@ -93,6 +98,10 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
type: "number",
optional: true,
initialValue: 8080,
validator: validators.optional(validators.all(
validators.integer,
validators.greaterThanZero
)),
},
"controller.https_port": {
title: "HTTPS Port",
@@ -100,6 +109,10 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.all(
validators.integer,
validators.greaterThanZero
)),
},
"controller.bind_address": {
title: "Bind Address",
@@ -107,6 +120,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
optional: true,
// TODO extendedValidation, valid addresses including IPV4, IPV6, and localhost
},
"controller.trusted_proxies": {
title: "Trusted Proxies",
@@ -114,12 +128,14 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
"Comma separated list of IP addresses and/or CIDR blocks to trust the X-Forwarded-For header on",
type: "string",
optional: true,
// TODO extendedValidation, see parseTrustedProxies
},
"controller.public_url": {
title: "Public URL",
description: "Public facing URL the controller is hosted on, including the protocol.",
type: "string",
optional: true,
// TODO extendedValidation, valid url including the protocol
},
"controller.tls_certificate": {
title: "TLS Certificate",
@@ -127,6 +143,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"controller.tls_private_key": {
title: "TLS Private Key",
@@ -134,6 +151,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"controller.auth_secret": {
access: ["controller"],
@@ -151,30 +169,35 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
description: "Interval heartbeats are sent out on WebSocket connections.",
type: "number",
initialValue: 15,
validator: validators.greaterThanZero,
},
"controller.session_timeout": {
title: "Session Timeout",
description: "Time in seconds before giving up resuming a dropped WebSocket session.",
type: "number",
initialValue: 60,
validator: validators.greaterThanZero,
},
"controller.metrics_timeout": {
title: "Metrics Timeout",
description: "Timeout in seconds for metrics gathering from hosts.",
type: "number",
initialValue: 8,
validator: validators.greaterThanZero,
},
"controller.system_metrics_interval": {
title: "System Metrics Interval",
description: "Interval in seconds to collect and update system metrics for the Web UI",
type: "number",
initialValue: 10,
validator: validators.greaterThanEqualZero,
},
"controller.proxy_stream_timeout": {
title: "Proxy Stream Timeout",
description: "Timeout in seconds for proxy streams to start flowing.",
type: "number",
initialValue: 15,
validator: validators.greaterThanZero,
},
"controller.factorio_username": {
title: "Factorio Username",
@@ -219,12 +242,14 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
description: "Duration in minutes to cache mod portal API responses.",
type: "number",
initialValue: 30,
validator: validators.greaterThanEqualZero, // Zero is allowed here to disable cache
},
"controller.mod_portal_page_size": {
title: "Mod Portal Page Size",
description: "Maximum number of results per page when querying the Factorio mod portal API.",
type: "number",
initialValue: 1000,
validator: validators.greaterThanZero,
},
"controller.allow_remote_updates": {
description: "When true, allows a remote event to trigger a clusterio update via npm",
@@ -299,6 +324,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
description: "Path to directory to look for factorio installs",
type: "string",
initialValue: "factorio",
validator: validators.filePath,
},
"host.mods_directory": {
title: "Mods Directory",
@@ -306,18 +332,21 @@ export class HostConfig extends classes.Config<HostConfigFields> {
restartRequired: true,
type: "string",
initialValue: "mods",
validator: validators.filePath,
},
"host.instances_directory": {
description: "Path to directory to store instances in.",
restartRequired: true,
type: "string",
initialValue: "instances",
validator: validators.filePath,
},
"host.controller_url": {
description: "URL to connect to the controller at",
restartRequired: true,
type: "string",
initialValue: "http://localhost:8080/",
// TODO extendedValidation, valid url including the protocol
},
"host.controller_token": {
access: ["host"],
@@ -325,6 +354,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
restartRequired: true,
type: "string",
initialValue: "enter token here",
// TODO extendedValidation, valid JWT with matching host id
},
"host.public_address": {
description: "Public facing address players should connect to in order to join instances on this host",
@@ -338,6 +368,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
"ranges separated with a dash.",
type: "string",
initialValue: "34100-34199",
// TODO extendedValidation, port range
},
"host.factorio_username": {
title: "Factorio Username",
@@ -363,6 +394,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initialValue: 60,
validator: validators.greaterThanZero,
},
"host.allow_remote_updates": {
description: "When true, allows a remote event to trigger a clusterio update via npm",
@@ -456,6 +488,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "number",
optional: true,
hidden: true,
// Internal value, extendedValidation not used
},
"instance.auto_start": {
description: "Automatically start this instance when the host hosting it is started up",
@@ -473,6 +506,11 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "string",
initialValue: "latest",
inputComponent: "target_version",
validator: function(value) {
if (!isTargetVersion(value)) {
throw new Error("Value must be be 'latest', or match X.Y, or match X.Y.Z");
}
},
},
"factorio.executable_path": {
description:
@@ -481,6 +519,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
restartRequired: true,
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"factorio.shutdown_timeout": {
description:
@@ -488,24 +527,27 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
"the process. Set to 0 to disable.",
type: "number",
initialValue: 300,
optional: true,
validator: validators.greaterThanEqualZero,
},
"factorio.game_port": {
description: "UDP port to run game on, uses a port in host.factorio_port_range if null",
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.greaterThanEqualZero),
},
"factorio.host_assigned_game_port": {
access: ["host"],
type: "number",
optional: true,
// Internal value, extendedValidation not used
},
"factorio.rcon_port": {
description: "TCP port to run RCON on, uses a random port if null",
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.greaterThanEqualZero),
},
"factorio.rcon_password": {
credential: ["host", "controller"],
@@ -520,6 +562,14 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
"autosave pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.",
type: "number",
initialValue: 5,
dependsOn: ["factorio.settings", "factorio.settings.autosave_slots"],
validator: function(value, config) {
const factorioSettings = config.get("factorio.settings");
const autosaveSlots = factorioSettings.autosave_slots;
if (typeof autosaveSlots === "number" && value < autosaveSlots) {
throw new Error("Value cannot be less than the number of autosave slots");
}
},
},
"factorio.mod_pack_id": {
title: "Mod Pack",
@@ -568,6 +618,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
],
type: "object",
initialValue: {}, // See create instance handler in controller.
// TODO extendedValidation, we could check keys and types here if we wanted
},
"factorio.verbose_logging": {
description: "Enable verbose logging on the Factorio server",
@@ -610,6 +661,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
restartRequired: true,
type: "number",
initialValue: 5,
validator: validators.greaterThanZero,
},
};
}
@@ -644,12 +696,13 @@ export class ControlConfig extends classes.Config<ControlConfigFields> {
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initialValue: 60,
validator: validators.greaterThanEqualZero,
},
};
}
function validateFields(
function validateFieldNames(
pluginName: string,
fields: Record<string, classes.FieldDefinition>,
) {
@@ -682,7 +735,7 @@ export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginW
const fields = pluginInfo[kind];
if (fields) {
validateFields(pluginInfo.name, fields);
validateFieldNames(pluginInfo.name, fields);
Object.assign(Config.fieldDefinitions, fields);
}
}
+66
View File
@@ -0,0 +1,66 @@
import path from "path";
/** Combine multiple validators into a single one. */
export function all<V, C> (
...validators: ((value: V, config: C) => void)[]
) {
return function (value: V, config: C) {
for (const validator of validators) {
validator(value, config);
}
};
}
/** Skip validation of optional fields when null */
export function optional<V, C> (
validator: (value: V, config: C) => void
) {
return function (value: V | null, config: C) {
if (value === null) { return; }
validator(value, config);
};
}
/** Value must be greater than the minimum value */
export function greaterThan(min: number) {
return function (value: number) {
if (value <= min) {
throw new Error(`Value must be greater than ${min}`);
}
};
}
/** Value must be greater than 0 */
export const greaterThanZero = greaterThan(0);
/** Value must be greater than or equal to the minimum value */
export function greaterThanEqual(min: number) {
return function (value: number) {
if (value < min) {
throw new Error(`Value must be greater than ${min}`);
}
};
}
/** Value must be greater than or equal to 0 */
export const greaterThanEqualZero = greaterThanEqual(0);
/** Value must be an integer value */
export function integer(value: number) {
if (!Number.isInteger(value)) {
throw new Error("Value must be an integer");
}
}
/**
* Value must be a valid file path, it does not need to exist
* On posix this will only error for non string values, which isn't very useful
* However, this could be expanded in the future to check system OS
*/
export function filePath(value: string) {
try {
path.resolve(value);
} catch (err: any) {
throw new Error(`Value must be a valid path: ${err.message}`);
}
}
@@ -37,6 +37,28 @@ export class ControllerConfigGetRequest {
static Response = plainJson(ControllerConfig.jsonSchema);
}
export class ControllerConfigSetRequest {
declare ["constructor"]: typeof ControllerConfigSetRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.controller.update_config" as const;
constructor(
public fields: Record<string, string | Record<string, unknown>>,
) { }
static jsonSchema = Type.Object({
"fields": Type.Record(Type.String(), Type.Union([
Type.String(), Type.Record(Type.String(), Type.Unknown()),
])),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.fields);
}
}
export class ControllerConfigSetFieldRequest {
declare ["constructor"]: typeof ControllerConfigSetFieldRequest;
static type = "request" as const;
+22
View File
@@ -37,6 +37,28 @@ export class HostConfigGetRequest {
static Response = plainJson(HostConfig.jsonSchema);
}
export class HostConfigSetRequest {
declare ["constructor"]: typeof HostConfigSetRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.update_config" as const;
constructor(
public fields: Record<string, string | Record<string, unknown>>,
) { }
static jsonSchema = Type.Object({
"fields": Type.Record(Type.String(), Type.Union([
Type.String(), Type.Record(Type.String(), Type.Unknown()),
])),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.fields);
}
}
export class HostConfigSetFieldRequest {
declare ["constructor"]: typeof HostConfigSetFieldRequest;
static type = "request" as const;
@@ -102,6 +102,30 @@ export class InstanceConfigGetRequest {
}
}
export class InstanceConfigSetRequest {
declare ["constructor"]: typeof InstanceConfigSetRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.instance.update_config" as const;
constructor(
public instanceId: number,
public fields: Record<string, string | Record<string, unknown>>,
) { }
static jsonSchema = Type.Object({
"instanceId": Type.Integer(),
"fields": Type.Record(Type.String(), Type.Union([
Type.String(), Type.Record(Type.String(), Type.Unknown()),
])),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.instanceId, json.fields);
}
}
export class InstanceConfigSetFieldRequest {
declare ["constructor"]: typeof InstanceConfigSetFieldRequest;
static type = "request" as const;
+3
View File
@@ -23,6 +23,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.ControllerRestartRequest,
controller.ControllerUpdateRequest,
controller.ControllerConfigGetRequest,
controller.ControllerConfigSetRequest,
controller.ControllerConfigSetFieldRequest,
controller.ControllerConfigSetPropRequest,
controller.HostGenerateTokenRequest,
@@ -43,6 +44,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
host.HostRestartRequest,
host.HostUpdateRequest,
host.HostConfigGetRequest,
host.HostConfigSetRequest,
host.HostConfigSetFieldRequest,
host.HostConfigSetPropRequest,
host.HostListRequest,
@@ -59,6 +61,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
instance.InstanceDetailsUpdatesEvent,
instance.InstanceCreateRequest,
instance.InstanceConfigGetRequest,
instance.InstanceConfigSetRequest,
instance.InstanceConfigSetFieldRequest,
instance.InstanceConfigSetPropRequest,
instance.InstanceAssignRequest,
@@ -9,6 +9,7 @@ import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { InputComponent, InputComponentProps } from "../BaseWebPlugin";
import type { Control } from "../util/websocket";
import { notifyErrorHandler } from "../util/notify";
const { Title } = Typography;
@@ -67,8 +68,7 @@ function renderInput(inputComponents: Record<string, InputComponent>, def: lib.F
type BaseConfigTreeProps = {
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
retrieveConfig: () => Promise<lib.ConfigSchema>;
setField: (field:string, value:any) => Promise<void>;
setProp: (field:string, prop:string, value:any) => Promise<void>;
setConfig: (fields: Record<string, string | Record<string, string>>) => Promise<void>;
id?: number;
onApply?: () => void;
available?: boolean;
@@ -157,32 +157,33 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
loading={applying}
onClick={async () => {
setApplying(true);
for (let field of changedFields) {
let value = form.getFieldValue(field);
let request;
const fields: Record<string, string | Record<string, string>> = {};
for (const field of changedFields) {
const value = form.getFieldValue(field);
if (propsMap.has(field)) {
let [fieldName, prop] = propsMap.get(field);
request = props.setProp(fieldName, prop, value);
const [fieldName, prop] = propsMap.get(field)!;
const fieldValue = (fields[fieldName] ?? {}) as Record<string, string>;
fields[fieldName] = fieldValue;
try {
fieldValue[prop] = value ? JSON.parse(value) : undefined;
} catch (err: any) {
setErrorFields(errors => {
const newErrors = new Map(errors);
newErrors.set(fieldName, err.message);
return newErrors;
});
}
} else {
request = props.setField(field, value === null ? "" : String(value));
fields[field] = value === null ? "" : String(value);
}
}
try {
await request;
setChangedFields(changed => {
let newChanged = new Set(changed);
newChanged.delete(field);
return newChanged;
});
} catch (err: any) {
setErrorFields(errors => {
let newErrors = new Map(errors);
newErrors.set(field, err.message);
return newErrors;
});
}
try {
await props.setConfig(fields);
setChangedFields(new Set());
} catch (err: any) {
notifyErrorHandler(err.message);
}
await updateConfig();
@@ -254,7 +255,7 @@ function computeTreeData(
};
let treeData = [];
let propsMap = new Map();
const propsMap = new Map<string, [fieldName: string, prop: string]>();
const groups = new Map<string, { [name: string]: lib.FieldDefinition }>();
for (let [fieldName, def] of Object.entries(ConfigClass.fieldDefinitions)) {
const [groupName, field] = lib.splitOn(".", fieldName);
@@ -312,17 +313,37 @@ function computeTreeData(
{`"${prop}"`}
{restart && restartTip}
</>}
rules={[{ validator: (_, fieldValue) => {
dependencies={def.dependsOn}
rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => {
if (!fieldValue.length) {
return Promise.reject(new Error("Will be removed"));
throw new Error("Will be removed");
}
try {
JSON.parse(fieldValue);
fieldValue = JSON.parse(fieldValue);
} catch (err) {
return Promise.reject(new Error("Must be valid json"));
throw new Error("Must be valid json");
}
return Promise.resolve();
}}]}
if (!def.validator) { return; }
config.stageProp(fieldName, prop, fieldValue);
for (const dependsOn of def.dependsOn ?? []) {
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
try {
if (propsMap.has(dependsOn)) {
const [name, otherProp] = propsMap.get(dependsOn)!;
config.stageProp(name, otherProp, JSON.parse(getFieldValue(dependsOn)));
} else if (dependsOnDef.type !== "object") {
config.stage(dependsOn, getFieldValue(dependsOn));
}
} catch (err) {
throw new Error(`Failed to fetch field: ${dependsOn}`);
}
}
def.validator(fieldValue, config.staging);
}})]}
>
<Input className="json-input" disabled={!canWrite} />
</Form.Item>,
@@ -390,7 +411,30 @@ function computeTreeData(
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
help={errorFields.get(fieldName)}
tooltip={def.description}
dependencies={def.dependsOn}
valuePropName={def.type === "boolean" ? "checked" : "value"}
rules={[({getFieldValue}) => ({
validator: async (_, fieldValue) => {
if (!def.validator) { return; }
config.stage(fieldName, fieldValue);
for (const dependsOn of def.dependsOn ?? []) {
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
try {
if (propsMap.has(dependsOn)) {
const [name, prop] = propsMap.get(dependsOn)!;
config.stageProp(name, prop, JSON.parse(getFieldValue(dependsOn)));
} else if (dependsOnDef.type !== "object") {
config.stage(dependsOn, getFieldValue(dependsOn));
}
} catch (err) {
throw new Error(`Failed to fetch field: ${dependsOn}`);
}
}
def.validator(fieldValue, config.staging);
},
})]}
>
{renderInput(control.inputComponents, def, !canWrite)}
</Form.Item>;
@@ -13,30 +13,13 @@ export default function ControllerConfigTree() {
return await control.send(new lib.ControllerConfigGetRequest());
}
async function setField(field: string, value: any) {
await control.send(new lib.ControllerConfigSetFieldRequest(
field,
String(value),
));
}
async function setProp(field: string, prop: string, value: any) {
if (value) {
try {
value = JSON.parse(value);
} catch (err) {
return;
}
} else {
value = undefined;
}
await control.send(new lib.ControllerConfigSetPropRequest(field, prop, value));
async function setConfig(fields: Record<string, string | Record<string, string>>) {
await control.send(new lib.ControllerConfigSetRequest(fields));
}
return <BaseConfigTree
ConfigClass={lib.ControllerConfig}
retrieveConfig={retrieveConfig}
setField={setField}
setProp={setProp}
setConfig={setConfig}
/>;
}
@@ -13,28 +13,14 @@ export default function HostConfigTree(props: { id: number, available: boolean }
return await control.sendTo({ hostId: props.id }, new lib.HostConfigGetRequest());
}
async function setField(field: string, value: any) {
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetFieldRequest(field, value));
}
async function setProp(field: string, prop: string, value: any) {
if (value) {
try {
value = JSON.parse(value);
} catch (err) {
return;
}
} else {
value = undefined;
}
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetPropRequest(field, prop, value));
async function setConfig(fields: Record<string, string | Record<string, string>>) {
await control.send(new lib.HostConfigSetRequest(fields));
}
return <BaseConfigTree
ConfigClass={lib.HostConfig}
retrieveConfig={retrieveConfig}
setField={setField}
setProp={setProp}
setConfig={setConfig}
id={props.id}
available={props.available}
/>;
@@ -17,28 +17,14 @@ export default function InstanceConfigTree(props: InstanceConfigTreeProps) {
return await control.send(new lib.InstanceConfigGetRequest(props.id));
}
async function setField(field: string, value: any) {
await control.send(new lib.InstanceConfigSetFieldRequest(props.id, field, value));
}
async function setProp(field: string, prop: string, value: any) {
if (value) {
try {
value = JSON.parse(value);
} catch (err) {
return;
}
} else {
value = undefined;
}
await control.send(new lib.InstanceConfigSetPropRequest(props.id, field, prop, value));
async function setConfig(fields: Record<string, string | Record<string, string>>) {
await control.send(new lib.InstanceConfigSetRequest(props.id, fields));
}
return <BaseConfigTree
ConfigClass={lib.InstanceConfig}
retrieveConfig={retrieveConfig}
setField={setField}
setProp={setProp}
setConfig={setConfig}
id={props.id}
/>;
}
+5 -4
View File
@@ -90,7 +90,6 @@ export class ControllerPlugin extends BaseControllerPlugin {
for (let entry of this.players.values()) {
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
let verifyCode = await generateCode(this.controller.config.get("player_auth.code_length"));
let secret = Buffer.from(this.controller.config.get("controller.auth_secret"), "base64");
let verifyToken = jwt.sign(
{
aud: "player_auth.verify_code",
@@ -98,7 +97,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
verify_code: verifyCode,
player_code: playerCode,
},
secret
this.controller.authSecret
);
res.send({ verify_code: verifyCode, verify_token: verifyToken });
@@ -133,9 +132,11 @@ export class ControllerPlugin extends BaseControllerPlugin {
return;
}
let secret = Buffer.from(this.controller.config.get("controller.auth_secret"), "base64");
try {
let payload = jwt.verify(verifyToken, secret, { audience: "player_auth.verify_code" }) as jwt.JwtPayload;
let payload = jwt.verify(
verifyToken, this.controller.authSecret, { audience: "player_auth.verify_code" }
) as jwt.JwtPayload;
if (payload.verify_code !== verifyCode) {
throw new Error("invalid verify_code");
}
+471 -136
View File
@@ -1,5 +1,6 @@
"use strict";
const lib = require("@clusterio/lib");
const { optional } = require("@clusterio/lib/dist/node/src/config/validators");
const fs = require("fs-extra");
const path = require("path");
const assert = require("assert").strict;
@@ -10,6 +11,7 @@ describe("lib/config/classes", function() {
describe("Config", function() {
class TestConfig extends lib.Config {
static defaultAccess = ["local", "remote"];
static validatorCalls = [];
static migrations(config) {
if (config.hasOwnProperty("test.migration")) {
@@ -41,9 +43,90 @@ describe("lib/config/classes", function() {
"test.priv": { access: ["local"], type: "string", optional: true },
"test.cred": { credential: ["local"] },
"test.rdo": { readonly: ["local"] },
"test.base": { type: "number", initialValue: 1 },
"test.dependent": {
type: "number",
initialValue: 1,
dependsOn: ["test.base", "test.objDependent"],
validator(value, config) {
TestConfig.validatorCalls.push(["test.dependent", value]);
if (value < config.get("test.base")) {
throw new Error("dependent must be >= base");
}
const dep = config.get("test.objDependent")?.base;
if (dep === 0) {
throw new Error("objDependent cannot be 0");
}
},
},
"test.objDependent": {
type: "object",
initialValue: {},
dependsOn: ["test.base"],
validator(value, config) {
TestConfig.validatorCalls.push(["test.objDependent", value]);
if (value.base && value.base !== config.get("test.base")) {
throw new Error("base prop must match test.base");
}
},
},
"test.validated": {
type: "number",
optional: true,
validator(value) {
TestConfig.validatorCalls.push(["test.validated", value]);
if (value !== null && value < 0) {
throw new Error("must be positive");
}
},
},
"test.objValidated": {
type: "object",
initialValue: {},
validator(value) {
TestConfig.validatorCalls.push(["test.objValidated", value]);
if (value && value.bad !== undefined) {
throw new Error("bad key");
}
},
},
"test.badValidator": {
type: "string",
optional: true,
dependsOn: ["test.base"],
validator(value, config) {
if (config.get("test.base") < 1) {
return;
}
if (value === "set") {
config.set("alpha.foo", "BAD");
} else if (value === "setProp") {
config.setProp("beta.bar", "baz", "BAD");
} else if (value === "stage") {
config.stage("alpha.foo", "BAD");
} else if (value === "stageProp") {
config.stageProp("beta.bar", "baz", "BAD");
} else if (value === "commitStaging") {
config.commitStaging();
} else if (value === "revertStaging") {
config.revertStaging();
}
},
},
};
}
const testConfigDefault = Object.fromEntries(
Object.entries(TestConfig.fieldDefinitions).map(
([fieldName, fieldDef]) => ([
fieldName,
typeof fieldDef.initialValue === "function"
? fieldDef.initialValue()
: (fieldDef.initialValue ?? null),
])
)
);
describe("constructor", function() {
it("should construct an instance", function() {
let config = new TestConfig("local");
@@ -80,41 +163,37 @@ describe("lib/config/classes", function() {
const testInstance = new TestConfig("local", undefined, "filepath");
assert.equal(testInstance.filepath, "filepath");
});
it("should throw if a required field has no initialValue", function() {
this.skip(); // TODO known bug that was discovered but of of scope for #856
class BrokenConfig extends lib.Config {
static fieldDefinitions = {
"test.required": { type: "string" },
};
}
assert.throws(
() => new BrokenConfig("local"),
);
});
});
describe(".toJSON()", function() {
it("should serialize a basic config", function() {
let testInstance = new TestConfig("local");
assert.deepEqual(testInstance.toJSON(), {
"alpha.foo": null,
"beta.bar": {},
"test.enum": "b",
"test.test": null,
"test.func": 42,
"test.bool": false,
"test.json": {},
"test.rest": {},
"test.priv": null,
"test.cred": null,
"test.rdo": null,
});
const testInstance = new TestConfig("local");
assert.deepEqual(testInstance.toJSON(), testConfigDefault);
});
});
describe(".toRemote()", function() {
it("should leave out inaccessible fields", function() {
let testInstance = new TestConfig("local");
assert.deepEqual(testInstance.toRemote("remote"), {
"alpha.foo": null,
"beta.bar": {},
"test.enum": "b",
"test.test": null,
"test.func": 42,
"test.bool": false,
"test.json": {},
"test.rest": {},
"test.rdo": null,
});
const testInstance = new TestConfig("local");
const { "test.cred": _cred, "test.priv": _priv, ...expected } = testConfigDefault;
assert.deepEqual(testInstance.toRemote("remote"), expected);
});
it("should filter fields when filter argument is provided", function() {
const testInstance = new TestConfig("local");
const filtered = testInstance.toRemote("local", ["alpha.foo"]);
assert.deepEqual(filtered, { "alpha.foo": null });
});
});
@@ -165,6 +244,7 @@ describe("lib/config/classes", function() {
it("should preserve unknown fields when serialized back", function() {
let testFields = {
...testConfigDefault,
"extra.blah": true,
"extra.spam": "foobar",
"alpha.foo": "true",
@@ -175,10 +255,6 @@ describe("lib/config/classes", function() {
"test.bool": false,
"test.json": {},
"test.rest": {},
"test.priv": null,
"test.cred": null,
"test.rdo": null,
"test.alpha": null,
"test.beta": "decay",
"test.gamma": 99,
};
@@ -245,6 +321,7 @@ describe("lib/config/classes", function() {
});
it("should preserve unknown fields", async function() {
const testFields = {
...testConfigDefault,
"extra.blah": true,
"extra.spam": "foobar",
"alpha.foo": "true",
@@ -255,10 +332,6 @@ describe("lib/config/classes", function() {
"test.bool": false,
"test.json": {},
"test.rest": {},
"test.priv": null,
"test.cred": null,
"test.rdo": null,
"test.alpha": null,
"test.beta": "decay",
"test.gamma": 99,
};
@@ -347,19 +420,10 @@ describe("lib/config/classes", function() {
"beta.bar": { value: 30 },
}, false);
assert.deepEqual(testInstance.toJSON(), {
...testConfigDefault,
"extra.blah": true,
"extra.spam": "baz",
"alpha.foo": null,
"beta.bar": { value: 30 },
"test.bool": false,
"test.enum": "b",
"test.func": 42,
"test.json": {},
"test.rest": {},
"test.priv": null,
"test.cred": null,
"test.rdo": null,
"test.test": null,
});
});
@@ -571,90 +635,76 @@ describe("lib/config/classes", function() {
});
});
describe(".set()", function() {
let testInstance;
beforeEach(function() {
testInstance = new TestConfig("local", {
"test.enum": "a",
"test.test": "blah",
"test.func": 27,
});
});
/**
* @param {typeof lib.Config.prototype.set} set
* @param {typeof lib.Config.prototype.get} get
* @param {(config: lib.Config) => void} replaceConfig
*/
function testAccessAndCoerce(set, get, replaceConfig) {
it("should set the value", function() {
testInstance.set("alpha.foo", "new value");
assert.equal(testInstance.get("alpha.foo"), "new value");
set("alpha.foo", "new value");
assert.equal(get("alpha.foo"), "new value");
});
it("should throw if field does not exist", function() {
assert.throws(
() => testInstance.set("test.bar", 1),
() => set("test.bar", 1),
new lib.InvalidField("No field named 'test.bar'")
);
});
it("should throw if field is remotely inaccessible", function() {
assert.throws(
() => testInstance.set("test.priv", "bad", "remote"),
() => set("test.priv", "bad", "remote"),
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
);
});
it("should throw if field is locally inaccessible", function() {
testInstance = new TestConfig("remote");
replaceConfig(new TestConfig("remote"));
assert.throws(
() => testInstance.set("test.priv", "bad", "local"),
() => set("test.priv", "bad", "local"),
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
);
});
it("should throw if field is not in enum", function() {
assert.throws(
() => testInstance.set("test.enum", "bar"),
() => set("test.enum", "bar"),
new Error("Expected one of [a, b, c], not bar")
);
});
it("should work if field is in enum", function() {
testInstance.set("test.enum", "c");
assert.equal(testInstance.get("test.enum"), "c");
set("test.enum", "c");
assert.equal(get("test.enum"), "c");
});
it("should throw if field is not optional", function() {
assert.throws(
() => testInstance.set("test.func", null),
() => set("test.func", null),
new Error("Field test.func cannot be null")
);
});
it("should work if field is optional", function() {
testInstance.set("test.test", "spam");
assert.equal(testInstance.get("test.test"), "spam");
set("test.test", "spam");
assert.equal(get("test.test"), "spam");
});
it("should throw if field is of wrong type", function() {
assert.throws(
() => testInstance.set("test.test", 1),
() => set("test.test", 1),
new Error("Expected type of test.test to be string, not number")
);
});
it("should treat empty string as null", function() {
testInstance.set("test.test", "");
assert.equal(testInstance.get("test.test"), null);
set("test.test", "");
assert.equal(get("test.test"), null);
});
it("should auto convert string to boolean if possible", function() {
testInstance.set("test.bool", "true");
assert.equal(testInstance.get("test.bool"), true);
testInstance.set("test.bool", "false");
assert.equal(testInstance.get("test.bool"), false);
set("test.bool", "true");
assert.equal(get("test.bool"), true);
set("test.bool", "false");
assert.equal(get("test.bool"), false);
assert.throws(
() => testInstance.set("test.bool", "blah"),
() => set("test.bool", "blah"),
new lib.InvalidValue("Expected type of test.bool to be boolean, not string")
);
});
it("should auto convert string to number if possible", function() {
for (let s of [
"1",
@@ -664,19 +714,18 @@ describe("lib/config/classes", function() {
"100.0001",
"Infinity",
]) {
testInstance.set("test.func", s);
assert.equal(testInstance.get("test.func"), Number.parseFloat(s));
set("test.func", s);
assert.equal(get("test.func"), Number.parseFloat(s));
}
assert.throws(
() => testInstance.set("test.func", "blah"),
() => set("test.func", "blah"),
new lib.InvalidValue("Expected type of test.func to be number, not string")
);
});
it("should auto convert string to object", function() {
testInstance.set("test.json", '{"json": true}');
assert.deepEqual(testInstance.get("test.json"), { json: true });
set("test.json", '{"json": true}');
assert.deepEqual(get("test.json"), { json: true });
let errMsg;
try {
@@ -685,10 +734,28 @@ describe("lib/config/classes", function() {
errMsg = err.message;
}
assert.throws(
() => testInstance.set("test.json", "blah"),
() => set("test.json", "blah"),
new lib.InvalidValue(`Error parsing value for test.json: ${errMsg}`)
);
});
}
describe(".set()", function() {
let testInstance;
beforeEach(function() {
TestConfig.validatorCalls = [];
testInstance = new TestConfig("local", {
"test.enum": "a",
"test.test": "blah",
"test.func": 27,
});
});
testAccessAndCoerce(
(...args) => testInstance.set(...args),
(...args) => testInstance.get(...args),
config => { testInstance = config; },
);
it("should notify fieldChanged listeners", function() {
const changes = [];
testInstance.on("fieldChanged", (name, value, prev) => changes.push([name, value, prev]));
@@ -709,11 +776,97 @@ describe("lib/config/classes", function() {
testInstance.set("test.enum", "b");
assert.deepEqual(testInstance.restartRequired, true, "restart required not set");
});
it("should run field validator successfully", function() {
testInstance.set("test.validated", 5);
assert.equal(testInstance.get("test.validated"), 5);
assert.deepEqual(TestConfig.validatorCalls, [
["test.validated", 5], // set test.validated
]);
});
it("should throw if validator fails", function() {
assert.throws(
() => testInstance.set("test.validated", -1),
new lib.InvalidValue("Failed validation of test.validated: must be positive")
);
assert.deepEqual(TestConfig.validatorCalls, [
["test.validated", -1], // set test.validated
]);
});
it("should validate dependent fields", function() {
testInstance.set("test.dependent", 10);
testInstance.set("test.base", 5);
assert.equal(testInstance.get("test.dependent"), 10);
assert.equal(testInstance.get("test.base"), 5);
assert.deepEqual(TestConfig.validatorCalls, [
["test.dependent", 10], // set test.dependent
["test.dependent", 10], // set test.base
["test.objDependent", {}], // set test.base
]);
});
it("should throw if dependent validation fails", function() {
assert.throws(
() => testInstance.set("test.base", 5),
new lib.InvalidValue("Failed validation of dependent test.dependent: dependent must be >= base")
);
assert.deepEqual(TestConfig.validatorCalls, [
["test.dependent", 1], // set test.base
]);
});
});
/**
* @param {typeof lib.Config.prototype.set} set
* @param {typeof lib.Config.prototype.setProp} setProp
* @param {typeof lib.Config.prototype.get} get
* @param {(config: lib.Config) => void} replaceConfig
*/
function testAccessAndCoerceProp(set, setProp, get, replaceConfig) {
it("should throw if field does not exist", function() {
assert.throws(
() => setProp("test.bar", 1),
new lib.InvalidField("No field named 'test.bar'")
);
});
it("should throw if field is remotely inaccessible", function() {
assert.throws(
() => setProp("test.priv", "prop", "bad", "remote"),
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
);
});
it("should throw if field is locally inaccessible", function() {
replaceConfig(new TestConfig("remote"));
assert.throws(
() => setProp("test.priv", "prop", "bad", "local"),
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
);
});
it("should throw if field is not an object", function() {
assert.throws(
() => setProp("test.enum", "prop", "a"),
new lib.InvalidField("Cannot set property on non-object field 'test.enum'")
);
});
it("should work if field is an object", function() {
set("test.json", { prev: 32, test: false });
setProp("test.json", "test", true);
assert.deepEqual(get("test.json"), { prev: 32, test: true });
});
it("should handle field being null", function() {
set("test.json", null);
setProp("test.json", "test", true);
assert.deepEqual(get("test.json"), { test: true });
});
it("should unset field if passed undefined", function() {
set("test.json", { test: true, extra: "yes" });
setProp("test.json", "extra", undefined);
assert.deepEqual(get("test.json"), { test: true });
});
}
describe(".setProp()", function() {
let testInstance;
beforeEach(function() {
TestConfig.validatorCalls = [];
testInstance = new TestConfig("local", {
"test.enum": "a",
"test.test": "blah",
@@ -721,51 +874,12 @@ describe("lib/config/classes", function() {
});
});
it("should throw if field does not exist", function() {
assert.throws(
() => testInstance.setProp("test.bar", 1),
new lib.InvalidField("No field named 'test.bar'")
);
});
it("should throw if field is remotely inaccessible", function() {
assert.throws(
() => testInstance.setProp("test.priv", "prop", "bad", "remote"),
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
);
});
it("should throw if field is locally inaccessible", function() {
testInstance = new TestConfig("remote");
assert.throws(
() => testInstance.setProp("test.priv", "prop", "bad", "local"),
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
);
});
it("should throw if field is not an object", function() {
assert.throws(
() => testInstance.setProp("test.enum", "prop", "a"),
new lib.InvalidField("Cannot set property on non-object field 'test.enum'")
);
});
it("should work if field is an object", function() {
testInstance.set("test.json", { prev: 32, test: false });
testInstance.setProp("test.json", "test", true);
assert.deepEqual(testInstance.get("test.json"), { prev: 32, test: true });
});
it("should handle field being null", function() {
testInstance.set("test.json", null);
testInstance.setProp("test.json", "test", true);
assert.deepEqual(testInstance.get("test.json"), { test: true });
});
it("should unset field if passed undefined", function() {
testInstance.set("test.json", { test: true, extra: "yes" });
testInstance.setProp("test.json", "extra", undefined);
assert.deepEqual(testInstance.get("test.json"), { test: true });
});
testAccessAndCoerceProp(
(...args) => testInstance.set(...args),
(...args) => testInstance.setProp(...args),
(...args) => testInstance.get(...args),
config => { testInstance = config; },
);
it("should notify fieldChanged listeners", function() {
const changes = [];
testInstance.on("fieldChanged", (name, value, prev) => changes.push([name, value, prev]));
@@ -786,6 +900,227 @@ describe("lib/config/classes", function() {
testInstance.setProp("test.rest", "test", true);
assert.deepEqual(testInstance.restartRequired, true, "restart required not set");
});
it("should run validator when setting object property", function() {
testInstance.setProp("test.objValidated", "good", true);
assert.deepEqual(testInstance.get("test.objValidated"), { good: true });
assert.deepEqual(TestConfig.validatorCalls, [
["test.objValidated", { good: true }], // setProp test.objValidated good
]);
});
it("should throw if object validator fails", function() {
assert.throws(
() => testInstance.setProp("test.objValidated", "bad", true),
new lib.InvalidValue("Failed validation of test.objValidated: bad key")
);
assert.deepEqual(TestConfig.validatorCalls, [
["test.objValidated", { bad: true }], // setProp test.objValidated bad
]);
});
it("should validate dependent fields", function() {
testInstance.setProp("test.objDependent", "base", 1);
assert.deepEqual(testInstance.get("test.objDependent"), { base: 1 });
assert.deepEqual(TestConfig.validatorCalls, [
["test.objDependent", { base: 1 }], // setProp test.objDependent base
["test.dependent", 1], // setProp test.objDependent base
]);
});
it("should throw if dependent validation fails", function() {
testInstance.set("test.base", 0);
assert.throws(
() => testInstance.setProp("test.objDependent", "base", 0),
new lib.InvalidValue("Failed validation of dependent test.dependent: objDependent cannot be 0")
);
assert.deepEqual(TestConfig.validatorCalls, [
["test.dependent", 1], // set test.base
["test.objDependent", {}], // set test.base
["test.objDependent", { base: 0 }], // setProp test.objDependent base
["test.dependent", 1], // setProp test.objDependent base
]);
});
});
describe("get .staging", function() {
it("should create staging config and reuse it", function() {
const testInstance = new TestConfig("local");
const first = testInstance.staging;
const second = testInstance.staging;
assert.equal(first, second);
});
it("should return new instance if invalided", function() {
const testInstance = new TestConfig("local");
const first = testInstance.staging;
testInstance.set("alpha.foo", "value");
const second = testInstance.staging;
assert.notEqual(first, second);
});
});
describe(".stage()", function() {
let testInstance;
beforeEach(function() {
testInstance = new TestConfig("local");
});
it("should stage a value without committing", function() {
testInstance.stage("alpha.foo", "value");
assert.equal(testInstance.get("alpha.foo"), null);
assert.equal(testInstance.staging.get("alpha.foo"), "value");
});
testAccessAndCoerce(
(...args) => testInstance.stage(...args),
(...args) => testInstance.staging.get(...args),
config => { testInstance = config; },
);
});
describe(".stageProp()", function() {
let testInstance;
beforeEach(function() {
testInstance = new TestConfig("local");
});
testAccessAndCoerceProp(
(...args) => testInstance.stage(...args),
(...args) => testInstance.stageProp(...args),
(...args) => testInstance.staging.get(...args),
config => { testInstance = config; },
);
it("should stage object property change", function() {
testInstance.stageProp("test.json", "prop", true);
assert.deepEqual(testInstance.get("test.json"), {});
assert.deepEqual(testInstance.staging.get("test.json"), { prop: true });
});
it("should delete property when value undefined", function() {
testInstance.set("test.json", { a: 1 });
testInstance.stageProp("test.json", "a", undefined);
assert.deepEqual(testInstance.staging.get("test.json"), {});
assert.deepEqual(testInstance.get("test.json"), { a: 1 });
});
});
describe(".commitStaging()", function() {
let testInstance;
beforeEach(function() {
TestConfig.validatorCalls = [];
testInstance = new TestConfig("local");
});
it("should do nothing if staging does not exist", function() {
testInstance.commitStaging();
assert.equal(testInstance.get("alpha.foo"), null);
});
it("should commit staged values", function() {
testInstance.stage("alpha.foo", "value");
assert.equal(testInstance.get("alpha.foo"), null);
testInstance.commitStaging();
assert.equal(testInstance.get("alpha.foo"), "value");
assert.deepEqual(TestConfig.validatorCalls, [
// commitStaging, all values are checked once
["test.dependent", 1],
["test.objDependent", {}],
["test.validated", null],
["test.objValidated", {}],
]);
});
it("should throw if staged validation fails", function() {
testInstance.stage("test.validated", -1);
assert.throws(
() => testInstance.commitStaging(),
new lib.InvalidValue("Failed validation of test.validated: must be positive")
);
assert.deepEqual(TestConfig.validatorCalls, [
// commitStaging, all values are checked once
["test.dependent", 1],
["test.objDependent", {}],
["test.validated", -1], // throws, so no fourth item
]);
});
it("should commit stages values with dependent validation", function() {
assert.throws(() => testInstance.set("test.base", 10));
testInstance.stage("test.base", 10);
testInstance.stage("test.dependent", 15);
// The above ordering would error if set was used
testInstance.commitStaging();
assert.equal(testInstance.get("test.base"), 10);
assert.equal(testInstance.get("test.dependent"), 15);
assert.deepEqual(TestConfig.validatorCalls, [
["test.dependent", 1], // set test.base
// commitStaging, all values are checked once
["test.dependent", 15],
["test.objDependent", {}],
["test.validated", null],
["test.objValidated", {}],
]);
});
it("should notify fieldChanged listeners", function() {
const changes = [];
testInstance.on("fieldChanged", (name, value, prev) => changes.push([name, value, prev]));
testInstance.stage("alpha.foo", "foo");
testInstance.stage("test.test", "bar");
testInstance.commitStaging();
assert.deepEqual(changes, [
["alpha.foo", "foo", null],
["test.test", "bar", null],
], "fieldChanged emitted incorrectly");
});
it("should set the dirty flag", function() {
testInstance.stage("test.test", "foo");
testInstance.commitStaging();
assert.deepEqual(testInstance.dirty, true, "dirty flag not set");
});
it("should set the requires restart flag", function() {
testInstance.stage("test.test", "foo");
testInstance.commitStaging();
assert.deepEqual(testInstance.restartRequired, false, "restart required when no restart field changed");
testInstance.stage("test.enum", "a");
testInstance.commitStaging();
assert.deepEqual(testInstance.restartRequired, true, "restart required not set");
});
});
describe(".revertStaging()", function() {
it("should discard staged changes", function() {
const testInstance = new TestConfig("local");
testInstance.stage("alpha.foo", "value");
testInstance.revertStaging();
assert.equal(testInstance.staging.get("alpha.foo"), null);
assert.equal(testInstance.get("alpha.foo"), null);
});
});
describe("is readonly in validators", function() {
for (const method of [
"set", "setProp", "stage", "stageProp", "commitStaging", "revertStaging",
]) {
it(`${method} should throw if called inside a validator`, function() {
const testInstance = new TestConfig("local");
assert.throws(
() => testInstance.set("test.badValidator", method),
/config is readonly and cannot have values changed/
);
});
it(`${method} should throw if called inside a dependent validator`, function() {
const testInstance = new TestConfig("local");
testInstance.set("test.base", 0);
testInstance.set("test.badValidator", method);
assert.throws(
() => testInstance.set("test.base", 1),
/config is readonly and cannot have values changed/
);
});
}
});
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ const assert = require("assert").strict;
// Each migration test should indicate which version introduced the migration
describe("lib/config/migrations", function() {
describe("lib/config/definitions/migrations", function() {
describe("Controller Config", function() {
it("should migrate 'external_address' to 'public_url'", function() { // Alpha 19
const config = lib.ControllerConfig.fromJSON({
+245
View File
@@ -0,0 +1,245 @@
"use strict";
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const validators = lib.Config.validators;
const { ControllerConfig, HostConfig, InstanceConfig, ControlConfig } = lib;
describe("lib/config/validators", function() {
describe("all", function() {
it("should call all validators in order", function() {
let calls = [];
function v1(value, config) {
calls.push("v1");
}
function v2(value, config) {
calls.push("v2");
}
const combined = validators.all(v1, v2);
combined("value", {});
assert.deepEqual(calls, ["v1", "v2"], "validators were not called in order");
});
it("should propagate errors from validators", function() {
function v1() {}
function v2() {
throw new Error("failure");
}
const combined = validators.all(v1, v2);
assert.throws(
() => combined("value", {}),
/failure/,
"error from validator was not propagated"
);
});
});
describe("optional", function() {
it("should skip validation when value is null", function() {
let called = false;
function validator() {
called = true;
}
const wrapped = validators.optional(validator);
wrapped(null, {});
assert.equal(called, false, "validator should not have been called");
});
it("should run validator when value is not null", function() {
let called = false;
function validator() {
called = true;
}
const wrapped = validators.optional(validator);
wrapped("value", {});
assert.equal(called, true, "validator was not called");
});
});
describe("greaterThan", function() {
it("should allow values greater than min", function() {
const validator = validators.greaterThan(5);
assert.doesNotThrow(() => validator(6));
});
it("should throw if value equals min", function() {
const validator = validators.greaterThan(5);
assert.throws(
() => validator(5),
/Value must be greater than 5/,
"did not throw on equal value"
);
});
it("should throw if value is less than min", function() {
const validator = validators.greaterThan(5);
assert.throws(
() => validator(4),
/Value must be greater than 5/,
"did not throw on smaller value"
);
});
});
describe("greaterThanZero", function() {
it("should allow values greater than zero", function() {
assert.doesNotThrow(() => validators.greaterThanZero(1));
});
it("should throw on zero", function() {
assert.throws(
() => validators.greaterThanZero(0),
/Value must be greater than 0/
);
});
it("should throw on negative values", function() {
assert.throws(
() => validators.greaterThanZero(-1),
/Value must be greater than 0/
);
});
});
describe("greaterThanEqual", function() {
it("should allow values greater than min", function() {
const validator = validators.greaterThanEqual(5);
assert.doesNotThrow(() => validator(6));
});
it("should allow values equal to min", function() {
const validator = validators.greaterThanEqual(5);
assert.doesNotThrow(() => validator(5));
});
it("should throw if value is less than min", function() {
const validator = validators.greaterThanEqual(5);
assert.throws(
() => validator(4),
/Value must be greater than 5/,
"did not throw on smaller value"
);
});
});
describe("greaterThanEqualZero", function() {
it("should allow zero", function() {
assert.doesNotThrow(() => validators.greaterThanEqualZero(0));
});
it("should allow positive values", function() {
assert.doesNotThrow(() => validators.greaterThanEqualZero(1));
});
it("should throw on negative values", function() {
assert.throws(
() => validators.greaterThanEqualZero(-1),
/Value must be greater than 0/
);
});
});
describe("integer", function() {
it("should allow integer values", function() {
assert.doesNotThrow(() => validators.integer(10));
});
it("should throw on non-integer values", function() {
assert.throws(
() => validators.integer(1.5),
/Value must be an integer/,
"did not throw on float"
);
});
});
describe("filePath", function() {
it("should accept valid paths", function() {
assert.doesNotThrow(() => validators.filePath("test/file.txt"));
assert.doesNotThrow(() => validators.filePath("/absolute/path"));
});
it("should throw if value is not a string", function() {
// This is the only case that will cause path.resolve to throw on posix
assert.throws(
() => validators.filePath(null),
/Value must be a valid path/
);
});
});
});
describe("lib/config/definitions/validators", function() {
describe("Controller Config", function() {
});
describe("Host Config", function() {
});
describe("Instance Config", function() {
it("should validate factorio.version", function() {
const config = new InstanceConfig("controller");
// valid cases
assert.doesNotThrow(() => config.set("factorio.version", "latest"));
assert.doesNotThrow(() => config.set("factorio.version", "1.1"));
assert.doesNotThrow(() => config.set("factorio.version", "1.1.87"));
// invalid case
assert.throws(
() => config.set("factorio.version", "invalidValue"),
/Value must be be 'latest', or match X.Y, or match X.Y.Z/
);
});
it("should validate factorio.player_online_autosave_slots against autosave slots", function() {
const config = new InstanceConfig("controller", {
"factorio.settings": {
autosave_slots: 5,
},
});
// valid: equal
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 5));
// valid: greater
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 6));
// invalid: less than autosave_slots
assert.throws(
() => config.set("factorio.player_online_autosave_slots", 4),
/Value cannot be less than the number of autosave slots/
);
});
it("should allow player_online_autosave_slots when autosave_slots is not a number", function() {
const config = new InstanceConfig("controller", {
"factorio.settings": {
autosave_slots: null,
},
});
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 0));
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 1));
});
});
describe("Control Config", function() {
});
});
+343
View File
@@ -3,12 +3,15 @@ const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const { Controller, ControlConnection } = require("@clusterio/controller");
const { testMatrix, testRoundTripJsonSerialisable } = require("../common");
describe("messages/controller", function() {
/** @type {Controller} */
let controller;
/** @type {ControlConnection} */
let controlConnection;
/** @type {lib.InstanceConfig} */
let instanceConfig;
beforeEach(function() {
const controllerConfig = new lib.ControllerConfig("controller");
@@ -17,6 +20,8 @@ describe("messages/controller", function() {
lib.Address.fromShorthand({ controlId: 1 }),
);
controller = new Controller(lib.logger, [], controllerConfig);
instanceConfig = new lib.InstanceConfig("controller");
controller.instances.createInstance(instanceConfig);
const user = controller.users.getOrCreateUser("test");
controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1);
});
@@ -35,6 +40,344 @@ describe("messages/controller", function() {
});
});
describe("ControllerConfigGetRequest", function() {
it("runs", async function() {
const config = await controlConnection.handleControllerConfigGetRequest(
new lib.ControllerConfigGetRequest()
);
assert.deepEqual(config, controller.config.toRemote("control"));
});
});
describe("ControllerConfigSetFieldRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.ControllerConfigSetFieldRequest, testMatrix(
["controller.name", "invalid"], // field
["Foo Bar", "true", "5", "{}"], // value
));
});
it("runs", async function() {
await controlConnection.handleControllerConfigSetFieldRequest(
new lib.ControllerConfigSetFieldRequest("controller.name", "Foo Bar")
);
assert.equal(controller.config.get("controller.name"), "Foo Bar");
});
it("rejects if the field does not exist", async function() {
await assert.rejects(
controlConnection.handleControllerConfigSetFieldRequest(
new lib.ControllerConfigSetFieldRequest("invalid", "Foo Bar")
),
/No field named 'invalid'/
);
});
it("rejects if the field is inaccessible", async function() {
await assert.rejects(
controlConnection.handleControllerConfigSetFieldRequest(
new lib.ControllerConfigSetFieldRequest("controller.version", "2.0.0")
),
/Field 'controller.version' is not accessible from control/
);
});
});
describe("ControllerConfigSetPropRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.ControllerConfigSetPropRequest, testMatrix(
["controller.name", "invalid"], // field
["foo", "bar"], // prop
["Foo Bar", true, 5, {}], // value
));
});
it("runs", async function() {
this.skip(); // No object fields on controller
});
it("rejects if the field does not exist", async function() {
await assert.rejects(
controlConnection.handleControllerConfigSetPropRequest(
new lib.ControllerConfigSetPropRequest("invalid", "foo", "bar")
),
/No field named 'invalid'/
);
});
it("rejects if the field is not an object", async function() {
await assert.rejects(
controlConnection.handleControllerConfigSetPropRequest(
new lib.ControllerConfigSetPropRequest("controller.name", "foo", "bar")
),
/Cannot set property on non-object field 'controller\.name'/
);
});
it("rejects if the field is inaccessible", async function() {
await assert.rejects(
controlConnection.handleControllerConfigSetPropRequest(
new lib.ControllerConfigSetPropRequest("controller.version", "foo", "bar")
),
/Field 'controller\.version' is not accessible from control/
);
});
});
describe("ControllerConfigSetRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.ControllerConfigSetRequest, testMatrix(
[ // fields
{ "foo": "bar", "baz": { "cat": "dog" } },
{ "foo": "5", "baz": { "cat": "5" } },
{ "foo": "true", "baz": { "cat": "true" } },
{ "foo": "{}", "baz": { "cat": "{}" } },
],
));
});
it("runs", async function() {
await controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
"controller.name": "Foo Bar",
"controller.mods_directory": "Bar Baz",
}));
assert.equal(controller.config.get("controller.name"), "Foo Bar");
assert.equal(controller.config.get("controller.mods_directory"), "Bar Baz");
});
it("rejects if the field does not exist", async function() {
await assert.rejects(
controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
"controller.name": "Foo Bar",
"invalid": "Bar Baz",
})),
/No field named 'invalid'/
);
assert.equal(controller.config.get("controller.name"), "Your Cluster");
});
it("rejects if the field is not an object", async function() {
const prevDir = controller.config.get("controller.mods_directory");
await assert.rejects(
controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
"controller.name": "Foo Bar",
"controller.mods_directory": {
"foo": "bar",
},
})),
/Cannot set property on non-object field 'controller\.mods_directory'/
);
assert.equal(controller.config.get("controller.name"), "Your Cluster");
assert.equal(controller.config.get("controller.mods_directory"), prevDir);
});
it("rejects if the field is inaccessible", async function() {
const prevVersion = controller.config.get("controller.version");
await assert.rejects(
controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
"controller.name": "Foo Bar",
"controller.version": "2.0.0",
})),
/Field 'controller\.version' is not accessible from control/
);
assert.equal(controller.config.get("controller.name"), "Your Cluster");
assert.equal(controller.config.get("controller.version"), prevVersion);
});
});
describe("InstanceConfigGetRequest", function() {
it("runs", async function() {
const config = await controlConnection.handleInstanceConfigGetRequest(
new lib.InstanceConfigGetRequest(instanceConfig.get("instance.id"))
);
assert.deepEqual(config, instanceConfig.toRemote("control"));
});
});
describe("InstanceConfigSetFieldRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.InstanceConfigSetFieldRequest, testMatrix(
[1], // instance id
["controller.name", "invalid"], // field
["Foo Bar", "true", "5", "{}"], // value
));
});
it("runs", async function() {
await controlConnection.handleInstanceConfigSetFieldRequest(
new lib.InstanceConfigSetFieldRequest(instanceConfig.get("instance.id"), "instance.name", "Foo Bar")
);
assert.equal(instanceConfig.get("instance.name"), "Foo Bar");
});
it("rejects if the field does not exist", async function() {
await assert.rejects(
controlConnection.handleInstanceConfigSetFieldRequest(
new lib.InstanceConfigSetFieldRequest(instanceConfig.get("instance.id"), "invalid", "Foo Bar")
),
/No field named 'invalid'/
);
});
it("rejects if the field is inaccessible", async function() {
await assert.rejects(
controlConnection.handleInstanceConfigSetFieldRequest(
new lib.InstanceConfigSetFieldRequest(
instanceConfig.get("instance.id"), "factorio.host_assigned_game_port", "3000"
)
),
/Field 'factorio\.host_assigned_game_port' is not accessible from control/
);
});
it("rejects setting 'instance.assigned_host'", async function() {
await assert.rejects(
controlConnection.handleInstanceConfigSetFieldRequest(
new lib.InstanceConfigSetFieldRequest(
instanceConfig.get("instance.id"), "instance.assigned_host", 5
)
),
/instance\.assigned_host must be set through the assign-host interface/
);
});
it("rejects setting 'instance.id'", async function() {
await assert.rejects(
controlConnection.handleInstanceConfigSetFieldRequest(
new lib.InstanceConfigSetFieldRequest(
instanceConfig.get("instance.id"), "instance.id", 5
)
),
/Setting instance\.id is not supported/
);
});
});
describe("InstanceConfigSetPropRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.InstanceConfigSetPropRequest, testMatrix(
[1], // instance id
["controller.name", "invalid"], // field
["foo", "bar"], // prop
["Foo Bar", true, 5, {}], // value
));
});
it("runs", async function() {
controlConnection.handleInstanceConfigSetPropRequest(
new lib.InstanceConfigSetPropRequest(
instanceConfig.get("instance.id"), "factorio.settings", "name", "Foo Bar"
)
);
const { name, ...rest } = instanceConfig.get("factorio.settings");
assert.equal(name, "Foo Bar");
});
it("rejects if the field does not exist", async function() {
await assert.rejects(
controlConnection.handleInstanceConfigSetPropRequest(
new lib.InstanceConfigSetPropRequest(instanceConfig.get("instance.id"), "invalid", "foo", "bar")
),
/No field named 'invalid'/
);
});
it("rejects if the field is not an object", async function() {
await assert.rejects(
controlConnection.handleInstanceConfigSetPropRequest(
new lib.InstanceConfigSetPropRequest(
instanceConfig.get("instance.id"), "instance.name", "foo", "bar"
)
),
/Cannot set property on non-object field 'instance\.name'/
);
});
it("rejects if the field is inaccessible", async function() {
await assert.rejects(
controlConnection.handleInstanceConfigSetPropRequest(
new lib.InstanceConfigSetPropRequest(
instanceConfig.get("instance.id"), "factorio.host_assigned_game_port", "foo", "bar"
)
),
/Field 'factorio\.host_assigned_game_port' is not accessible from control/
);
});
});
describe("InstanceConfigSetRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.InstanceConfigSetRequest, testMatrix(
[1], // instance id
[ // fields
{ "foo": "bar", "baz": { "cat": "dog" } },
{ "foo": "5", "baz": { "cat": "5" } },
{ "foo": "true", "baz": { "cat": "true" } },
{ "foo": "{}", "baz": { "cat": "{}" } },
],
));
});
it("runs", async function() {
await controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
instanceConfig.get("instance.id"), {
"instance.name": "Foo Bar",
"factorio.executable_path": "Bar Baz",
})
);
assert.equal(instanceConfig.get("instance.name"), "Foo Bar");
assert.equal(instanceConfig.get("factorio.executable_path"), "Bar Baz");
});
it("rejects if the field does not exist", async function() {
await assert.rejects(
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
instanceConfig.get("instance.id"), {
"instance.name": "Foo Bar",
"invalid": "Bar Baz",
})
),
/No field named 'invalid'/
);
assert.equal(instanceConfig.get("instance.name"), "New Instance");
});
it("rejects if the field is not an object", async function() {
const prevPath = instanceConfig.get("factorio.executable_path");
await assert.rejects(
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
instanceConfig.get("instance.id"), {
"instance.name": "Foo Bar",
"factorio.executable_path": {
"foo": "bar",
},
})
),
/Cannot set property on non-object field 'factorio\.executable_path'/
);
assert.equal(instanceConfig.get("instance.name"), "New Instance");
assert.equal(instanceConfig.get("factorio.executable_path"), prevPath);
});
it("rejects if the field is inaccessible", async function() {
await assert.rejects(
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
instanceConfig.get("instance.id"), {
"instance.name": "Foo Bar",
"factorio.host_assigned_game_port": "Bar Baz",
})
),
/Field 'factorio\.host_assigned_game_port' is not accessible from control/
);
assert.equal(instanceConfig.get("instance.name"), "New Instance");
// Can't check value of host_assigned_game_port as it is not accessible on controller
});
it("rejects setting 'instance.assigned_host'", async function() {
const prevHost = instanceConfig.get("instance.assigned_host");
await assert.rejects(
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
instanceConfig.get("instance.id"), {
"instance.name": "Foo Bar",
"instance.assigned_host": 5,
})
),
/instance\.assigned_host must be set through the assign-host interface/
);
assert.equal(instanceConfig.get("instance.name"), "New Instance");
assert.equal(instanceConfig.get("instance.assigned_host"), prevHost);
});
it("rejects setting 'instance.id'", async function() {
const prevId = instanceConfig.get("instance.id");
await assert.rejects(
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
instanceConfig.get("instance.id"), {
"instance.name": "Foo Bar",
"instance.id": 5,
})
),
/Setting instance\.id is not supported/
);
assert.equal(instanceConfig.get("instance.name"), "New Instance");
assert.equal(instanceConfig.get("instance.id"), prevId);
});
});
describe("External", function() {
it("runs", async function() {
let callCount = 0;
+137
View File
@@ -3,6 +3,7 @@ const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const { Host } = require("@clusterio/host");
const { testMatrix, testRoundTripJsonSerialisable } = require("../common");
describe("messages/host", function() {
/** @type {Host} */
@@ -30,4 +31,140 @@ describe("messages/host", function() {
);
});
});
describe("HostConfigGetRequest", function() {
it("runs", async function() {
const config = await host.handleHostConfigGetRequest(new lib.HostConfigGetRequest());
assert.deepEqual(config, host.config.toRemote("control"));
});
});
describe("HostConfigSetFieldRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.HostConfigSetFieldRequest, testMatrix(
["controller.name", "invalid"], // field
["Foo Bar", "true", "5", "{}"], // value
));
});
it("runs", async function() {
await host.handleHostConfigSetFieldRequest(new lib.HostConfigSetFieldRequest("host.name", "Foo Bar"));
assert.equal(host.config.get("host.name"), "Foo Bar");
});
it("rejects if the field does not exist", async function() {
await assert.rejects(
host.handleHostConfigSetFieldRequest(new lib.HostConfigSetFieldRequest("invalid", "Foo Bar")),
/No field named 'invalid'/
);
});
it("rejects if the field is inaccessible", async function() {
await assert.rejects(
host.handleHostConfigSetFieldRequest(new lib.HostConfigSetFieldRequest("host.version", "2.0.0")),
/Field 'host.version' is not accessible from control/
);
});
it("rejects setting 'host.id'", async function() {
await assert.rejects(
host.handleHostConfigSetFieldRequest(new lib.HostConfigSetFieldRequest("host.id", 5)),
/Setting 'host.id' while host is running is not supported/
);
});
});
describe("HostConfigSetPropRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.HostConfigSetPropRequest, testMatrix(
["controller.name", "invalid"], // field
["foo", "bar"], // prop
["Foo Bar", true, 5, {}], // value
));
});
it("runs", async function() {
this.skip(); // No object fields on host
});
it("rejects if the field does not exist", async function() {
await assert.rejects(
host.handleHostConfigSetPropRequest(new lib.HostConfigSetPropRequest("invalid", "foo", "bar")),
/No field named 'invalid'/
);
});
it("rejects if the field is not an object", async function() {
await assert.rejects(
host.handleHostConfigSetPropRequest(new lib.HostConfigSetPropRequest("host.name", "foo", "bar")),
/Cannot set property on non-object field 'host\.name'/
);
});
it("rejects if the field is inaccessible", async function() {
await assert.rejects(
host.handleHostConfigSetPropRequest(new lib.HostConfigSetPropRequest("host.version", "foo", "bar")),
/Field 'host\.version' is not accessible from control/
);
});
});
describe("HostConfigSetRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.HostConfigSetRequest, testMatrix(
[ // fields
{ "foo": "bar", "baz": { "cat": "dog" } },
{ "foo": "5", "baz": { "cat": "5" } },
{ "foo": "true", "baz": { "cat": "true" } },
{ "foo": "{}", "baz": { "cat": "{}" } },
],
));
});
it("runs", async function() {
await host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
"host.name": "Foo Bar",
"host.factorio_directory": "Bar Baz",
}));
assert.equal(host.config.get("host.name"), "Foo Bar");
assert.equal(host.config.get("host.factorio_directory"), "Bar Baz");
});
it("rejects if the field does not exist", async function() {
await assert.rejects(
host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
"host.name": "Foo Bar",
"invalid": "Bar Baz",
})),
/No field named 'invalid'/
);
assert.equal(host.config.get("host.name"), "New Host");
});
it("rejects if the field is not an object", async function() {
const prevDir = host.config.get("host.factorio_directory");
await assert.rejects(
host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
"host.name": "Foo Bar",
"host.factorio_directory": {
"foo": "bar",
},
})),
/Cannot set property on non-object field 'host\.factorio_directory'/
);
assert.equal(host.config.get("host.name"), "New Host");
assert.equal(host.config.get("host.factorio_directory"), prevDir);
});
it("rejects if the field is inaccessible", async function() {
const prevVersion = host.config.get("host.version");
await assert.rejects(
host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
"host.name": "Foo Bar",
"host.version": "2.0.0",
})),
/Field 'host\.version' is not accessible from control/
);
assert.equal(host.config.get("host.name"), "New Host");
assert.equal(host.config.get("host.version"), prevVersion);
});
it("rejects setting 'host.id'", async function() {
await assert.rejects(
host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
"host.name": "Foo Bar",
"host.id": 5,
})),
/Setting 'host.id' while host is running is not supported/
);
assert.equal(host.config.get("host.name"), "New Host");
});
});
});
+4
View File
@@ -195,6 +195,10 @@ class MockController {
this.handles = new Map();
}
get authSecret() {
return Buffer.from(this.config.get("controller.auth_secret"), "base64");
}
handle(eventClass, handler) {
this.handles.set(eventClass, handler);
}