fix: address composite input contract review

This commit is contained in:
lda
2026-08-13 21:44:29 +07:00 Verified
parent 1bb684bfcd
commit cca1be891f
18 changed files with 455 additions and 172 deletions
@@ -1,4 +1,5 @@
import { Schema } from "effect";
import { hasBoundedInputExpressionNodeBudget } from "./input-expression-limits.js";
type JsonValue = string | number | boolean | null | JsonValue[] | { readonly [key: string]: JsonValue };
@@ -353,6 +354,11 @@ const InputExpressionSchema: Schema.Schema<InputExpression, unknown, never> =
ObjectExpressionSchema,
),
);
const BoundedInputExpressionSchema = InputExpressionSchema.pipe(
Schema.filter((value) => hasBoundedInputExpressionNodeBudget(value), {
message: () => "input expression exceeds the 1024-node budget",
}),
);
const InputExpressionBindingSchema = Schema.Struct({
target: Schema.Union(
Schema.String,
@@ -361,7 +367,7 @@ const InputExpressionBindingSchema = Schema.Struct({
root: Schema.Literal("local"),
}),
),
expression: InputExpressionSchema,
expression: BoundedInputExpressionSchema,
});
const InputBindingSchema = Schema.Union(
@@ -0,0 +1,88 @@
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const MAX_INPUT_EXPRESSION_NODES = 1024;
const expressionKinds = new Set(["literal", "path", "array", "object"]);
/** Count expression nodes and containers nested inside literal values. */
export const hasBoundedInputExpressionNodeBudget = (
input: unknown,
maxNodes: number = MAX_INPUT_EXPRESSION_NODES,
): boolean => {
let nodes = 0;
const active = new WeakSet<object>();
const visitNode = (value: object): boolean => {
if (active.has(value)) return false;
active.add(value);
nodes += 1;
if (nodes > maxNodes) {
active.delete(value);
return false;
}
return true;
};
const visitJson = (value: unknown): boolean => {
if (typeof value !== "object" || value === null) return true;
if (!visitNode(value)) return false;
const valid = Array.isArray(value)
? value.every(visitJson)
: Object.values(value).every(visitJson);
active.delete(value);
return valid;
};
const visitExpression = (value: unknown): boolean => {
if (!isRecord(value) || typeof value.kind !== "string") return false;
if (!expressionKinds.has(value.kind) || !visitNode(value)) return false;
let valid = true;
switch (value.kind) {
case "literal":
valid = visitJson(value.value);
break;
case "path":
break;
case "array":
valid = Array.isArray(value.items) && value.items.every(visitExpression);
break;
case "object":
valid = isRecord(value.fields) && Object.values(value.fields).every(visitExpression);
break;
}
active.delete(value);
return valid;
};
return visitExpression(input);
};
/** Find and bound generated expression-shaped values anywhere in an RPC value. */
export const hasBoundedInputExpressionPayload = (
input: unknown,
maxNodes: number = MAX_INPUT_EXPRESSION_NODES,
): boolean => {
const active = new WeakSet<object>();
const visit = (value: unknown): boolean => {
if (typeof value !== "object" || value === null) return true;
if (active.has(value)) return false;
active.add(value);
const valid = Array.isArray(value)
? value.every(visit)
: isRecord(value) &&
(typeof value.kind === "string" &&
expressionKinds.has(value.kind) &&
((value.kind === "literal" && "value" in value) ||
(value.kind === "path" && "path" in value) ||
(value.kind === "array" && Array.isArray(value.items)) ||
(value.kind === "object" && isRecord(value.fields)))
? hasBoundedInputExpressionNodeBudget(value, maxNodes)
: Object.values(value).every(visit));
active.delete(value);
return valid;
};
return visit(input);
};
@@ -1148,6 +1148,25 @@ describe("authored RPC and manifest schema parity", () => {
).toBe(false);
});
it("rejects authored expressions over 1024 nodes including literal containers", () => {
const basePayload = {
workspace_id: "console.demo",
revision: 3,
step_id: "concat",
};
const expression = {
kind: "literal",
value: Array.from({ length: 1023 }, () => ({})),
};
expect(
accepts(authoredRpcSchemas["workflow.draft_workspaces.set_step_input_bindings"].payload, {
...basePayload,
bindings: [{ target: "request", expression }],
}),
).toBe(false);
});
it("catalogs every authored RPC exactly once", () => {
const expectedMethods = [
"workflow.health",
@@ -88,6 +88,47 @@ describe("runtimeSchemasFor", () => {
}
});
it("rejects an input expression over the canonical 1024-node budget", () => {
const schemas = runtimeSchemasFor(
"workflow.draft_workspaces.set_step_input_bindings",
);
const expression = {
kind: "array",
items: Array.from({ length: 1025 }, () => ({
kind: "literal",
value: "child",
})),
};
expect(
accepts(schemas.payload, {
workspace_id: "console.demo",
revision: 3,
step_id: "render",
bindings: [{ target: "request", expression }],
}),
).toBe(false);
});
it("counts nested literal array and object containers in the input budget", () => {
const schemas = runtimeSchemasFor(
"workflow.draft_workspaces.set_step_input_bindings",
);
const expression = {
kind: "literal",
value: Array.from({ length: 1023 }, () => ({})),
};
expect(
accepts(schemas.payload, {
workspace_id: "console.demo",
revision: 3,
step_id: "render",
bindings: [{ target: "request", expression }],
}),
).toBe(false);
});
it("returns typed payload and result schemas for a generated operation", () => {
const schemas = runtimeSchemasFor("workflow.health");
const payload: WorkflowOperationParams<"workflow.health"> =
@@ -6,6 +6,7 @@ import {
type WorkflowOperationResult,
} from "../generated/workflow-contract.js";
import { translateJsonSchema } from "./translator.js";
import { hasBoundedInputExpressionPayload } from "./input-expression-limits.js";
type RuntimeOperationName = keyof typeof workflowRuntimeContract.operations;
const MAX_RUNTIME_VALUE_DEPTH = 64;
@@ -47,6 +48,10 @@ const BoundedRuntimeValueSchema = Schema.Unknown.pipe(
message: () =>
`runtime value exceeds ${MAX_RUNTIME_VALUE_DEPTH} nested containers`,
}),
Schema.filter((value) => hasBoundedInputExpressionPayload(value), {
message: () =>
"runtime value contains an input expression over the 1024-node budget",
}),
);
const translatedAst = (schema: unknown): AST.AST => {
@@ -246,6 +246,54 @@ describe("translateJsonSchema", () => {
);
});
it("rejects a decorative discriminator on inline overlapping oneOf branches", () => {
const error = rejected({
discriminator: {
mapping: { text: "#/components/schemas/Text", short: "#/components/schemas/Short" },
propertyName: "kind",
},
oneOf: [{ type: "string" }, { minLength: 1, type: "string" }],
}, {
Short: { minLength: 1, type: "string" },
Text: { type: "string" },
});
expect(error.keyword).toBe("oneOf");
expect(error.message).toMatch(/generated tagged object union/i);
});
it("rejects discriminated branches without distinct discriminator constants", () => {
const error = rejected({
discriminator: {
mapping: {
first: "#/components/schemas/First",
second: "#/components/schemas/Second",
},
propertyName: "kind",
},
oneOf: [
{ $ref: "#/components/schemas/First" },
{ $ref: "#/components/schemas/Second" },
],
}, {
First: {
additionalProperties: false,
properties: { kind: { type: "string" }, value: { type: "string" } },
required: ["kind", "value"],
type: "object",
},
Second: {
additionalProperties: false,
properties: { kind: { type: "string" }, value: { type: "string" } },
required: ["kind", "value"],
type: "object",
},
});
expect(error.keyword).toBe("oneOf");
expect(error.message).toMatch(/generated tagged object union/i);
});
it("rejects unproductive component reference cycles", () => {
const components = {
Loop: { $ref: "#/components/schemas/Loop" },
@@ -441,6 +441,14 @@ class Translator {
);
}
if (!this.#isGeneratedDiscriminatedUnion(value.oneOf, value.discriminator.propertyName, mapping)) {
return failure(
path,
"discriminated oneOf is supported only for generated tagged object unions",
"oneOf",
);
}
const members: Schema.Schema.AnyNoContext[] = [];
for (const [index, member] of value.oneOf.entries()) {
const translated = this.translate(
@@ -457,6 +465,55 @@ class Translator {
return Either.right(Schema.Union(...members));
}
#isGeneratedDiscriminatedUnion(
branches: readonly unknown[],
propertyName: string,
mapping: Record<string, unknown>,
): boolean {
const mappedReferences = Object.entries(mapping);
if (mappedReferences.length !== branches.length) return false;
const references = new Set<string>();
for (const branch of branches) {
if (!isRecord(branch) || typeof branch.$ref !== "string") return false;
if (references.has(branch.$ref)) return false;
references.add(branch.$ref);
}
const mappedReferenceSet = new Set<string>();
for (const [tag, reference] of mappedReferences) {
if (typeof reference !== "string") return false;
if (mappedReferenceSet.has(reference) || !references.has(reference)) return false;
mappedReferenceSet.add(reference);
const prefix = "#/components/schemas/";
if (!reference.startsWith(prefix)) return false;
const component = this.#components[reference.slice(prefix.length)];
if (!isRecord(component)) return false;
if (component.type !== "object" || component.additionalProperties !== false) {
return false;
}
const required = component.required;
const properties = component.properties;
if (
!Array.isArray(required) ||
!required.includes(propertyName) ||
!isRecord(properties) ||
!isRecord(properties[propertyName])
) {
return false;
}
const discriminator = properties[propertyName];
if (
discriminator.type !== "string" ||
discriminator.const !== tag
) {
return false;
}
}
return mappedReferenceSet.size === references.size;
}
#translateConst(
value: Readonly<Record<string, unknown>>,
path: string,