From d76902cebe7d061398993b72928a048ade54be94 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:01:55 +0000 Subject: [PATCH] Add imperfect dependency validation --- packages/lib/src/config/classes.ts | 79 +++++++++++++++++++ packages/lib/src/config/definitions.ts | 1 + .../web_ui/src/components/BaseConfigTree.tsx | 66 +++++++++++++--- plugins/player_auth/controller.ts | 9 ++- 4 files changed, 139 insertions(+), 16 deletions(-) diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index 6c572979..48fde889 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -95,6 +95,11 @@ export interface FieldDefinition< * Should throw an error if the value in invalid / inconsistent */ validator?: (value: Type, config: Config) => void; + /** + * Names the config fields that the validator depends on. + * This will ensure it is checked when these fields are changed. + */ + dependsOn?: string[]; /** * Value to use for the autocomplete attribute in the Web UI's input element. * Useful for making browsers behave properly with credential inputs. @@ -265,6 +270,46 @@ export class Config< } } + // TODO add stage, stageProp, revertStaging, and commitStaging + /** Staging is invalided when _set is called, so a new one is made with the new state */ + private _staging?: this; + get staging(): typeof this { + if (this._staging) { return this._staging; } + this._staging = new this.constructor(this.location, this.fields) as this; + return this._staging; + } + + private static _fieldDependents: Map } + }[]>; + + /** Map of all fields whose validators depend on the given field */ + static get fieldDependents(): typeof this._fieldDependents { + if (this._fieldDependents) { + return this._fieldDependents; + } + + const dependents = new Map(); + + for (const [name, def] of Object.entries(this.fieldDefinitions)) { + if (!def.validator || !def.dependsOn) { continue; } + + for (const otherName of def.dependsOn) { + const fieldDependents = dependents.get(otherName); + if (fieldDependents) { + fieldDependents.push({name, def}); + } else { + dependents.set(otherName, [{name, def}]); + } + } + } + + // The type is correct because of "!def.validator", not clear why this doesn't infer + this._fieldDependents = dependents as any; + return this._fieldDependents; + } + /** * Check access for a field * @@ -309,6 +354,7 @@ export class Config< * @param notify - False to skip setting dirty flags and emitting events */ _set(name: keyof Fields & string, value: FieldValue, notify: boolean = true) { + this._staging = undefined; const prev = this.fields[name]; this.fields[name] = value as any; if (notify && !isDeepStrictEqual(value, prev)) { @@ -589,6 +635,18 @@ export class Config< } } + const dependents = this.constructor.fieldDependents.get(name); + if (dependents) { + // this.staging.stage(name, value, remote); // TODO need stage to avoid self reference + for (const dependent of dependents) { + try { + dependent.def.validator(this.get(dependent.name as keyof Fields & string, remote), this.staging); + } catch (err: any) { + throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`); + } + } + } + this._set(name, value); } @@ -615,6 +673,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); @@ -633,6 +692,26 @@ export class Config< delete updated[prop]; } + if (def.validator) { + try { + def.validator(updated, this); + } catch (err: any) { + throw new InvalidValue(`Failed validation of ${name}: ${err.message}`); + } + } + + const dependents = this.constructor.fieldDependents.get(name); + if (dependents) { + // this.staging.setProp(name, prop, value, remote); // TODO need stageProp to avoid self reference + for (const dependent of dependents) { + try { + dependent.def.validator(this.get(dependent.name as keyof Fields & string, remote), this.staging); + } catch (err: any) { + throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`); + } + } + } + this._set(name, updated); } } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 2a560353..498c018a 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -572,6 +572,7 @@ export class InstanceConfig extends classes.Config { "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; diff --git a/packages/web_ui/src/components/BaseConfigTree.tsx b/packages/web_ui/src/components/BaseConfigTree.tsx index 73c2379b..a17a11dd 100644 --- a/packages/web_ui/src/components/BaseConfigTree.tsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -161,7 +161,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { let value = form.getFieldValue(field); let request; if (propsMap.has(field)) { - let [fieldName, prop] = propsMap.get(field); + let [fieldName, prop] = propsMap.get(field)!; request = props.setProp(fieldName, prop, value); } else { @@ -254,7 +254,7 @@ function computeTreeData( }; let treeData = []; - let propsMap = new Map(); + const propsMap = new Map(); const groups = new Map(); for (let [fieldName, def] of Object.entries(ConfigClass.fieldDefinitions)) { const [groupName, field] = lib.splitOn(".", fieldName); @@ -312,17 +312,40 @@ 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; } + + // TODO this needs to use stage and stageProp to avoid deadlocks + const staging = config.staging; + staging.setProp(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)!; + staging.setProp(name, otherProp, JSON.parse(getFieldValue(dependsOn))); + } else if (dependsOnDef.type !== "object") { + staging.set(dependsOn, getFieldValue(dependsOn)); + } + } catch (err) { + throw new Error(`Failed to fetch field: ${dependsOn}`); + } + } + + def.validator(fieldValue, staging); + }})]} > , @@ -390,14 +413,33 @@ 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={[{ + rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => { - if (def.extendedValidation) { - def.extendedValidation(fieldValue, config); + if (!def.validator) { return; } + + // TODO this needs to use stage and stageProp to avoid deadlocks + const staging = config.staging; + staging.set(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)!; + staging.setProp(name, prop, JSON.parse(getFieldValue(dependsOn))); + } else if (dependsOnDef.type !== "object") { + staging.set(dependsOn, getFieldValue(dependsOn)); + } + } catch (err) { + throw new Error(`Failed to fetch field: ${dependsOn}`); + } } + + def.validator(fieldValue, staging); }, - }]} + })]} > {renderInput(control.inputComponents, def, !canWrite)} ; diff --git a/plugins/player_auth/controller.ts b/plugins/player_auth/controller.ts index 0fc43226..a1b51e53 100644 --- a/plugins/player_auth/controller.ts +++ b/plugins/player_auth/controller.ts @@ -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"); }