Add imperfect dependency validation

This commit is contained in:
Cooldude2606 committed 2026-03-05 00:01:55 +00:00
1 parent 0fa96521c5
commit d76902cebe
4 files changed
+139 -16

No files matched your search

+79
View File
@@ -95,6 +95,11 @@ export interface FieldDefinition<
* 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.
@@ -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<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;
}
/**
* 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);
}
}
+1
View File
@@ -572,6 +572,7 @@ 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;
@@ -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<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 +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);
}})]}
>
<Input className="json-input" disabled={!canWrite} />
</Form.Item>,
@@ -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)}
</Form.Item>;
+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");
}