Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8137a46
fix(agent-bff): enforce the action input contract on execute
Tonours Aug 28, 2026
48d7461
refactor(agent-bff): guard enum membership without a widening cast
Tonours Aug 28, 2026
bb95de0
fix(agent-bff): distinguish file value errors from agent outages
Tonours Aug 28, 2026
d5c6e4d
style(agent-bff): wrap the file value error fixture line
Tonours Aug 28, 2026
4c56138
fix(agent-bff): enforce list item types and align the unfolded enum s…
Tonours Aug 28, 2026
f06835d
fix(agent-bff): validate record picker fields as packed id strings
Tonours Aug 28, 2026
314a4a2
fix(agent-bff): validate legacy list types and gate doc enums on enum…
Tonours Aug 28, 2026
0259962
refactor(agent-bff): extract execute validation tests and shared fixt…
Tonours Aug 28, 2026
6287f3f
test(agent-bff): cover the unconstrained item list expectation
Tonours Aug 28, 2026
8a76ac8
refactor(agent-bff): share the field shape, enum and required rules
Tonours Sep 1, 2026
79175da
refactor(agent-bff): split the column type algebra out of the cache
Tonours Sep 1, 2026
0ffb932
test(agent-bff): assert the list validator blocks execute
Tonours Sep 1, 2026
087a40e
fix(agent-bff): log execute rejections and name the failing list index
Tonours Sep 1, 2026
e4abbbe
fix(agent-client): reject a file inside an array on a non-file field
Tonours Sep 2, 2026
8b95b2a
fix(agent-bff): reject unknown keys on the action request body
Tonours Sep 2, 2026
7aed983
test(agent-client): group the array file cases with the non-file field
Tonours Sep 2, 2026
5a18bbf
fix(agent-bff): publish a malformed action-field reference as null
Tonours Sep 2, 2026
2cd3169
fix(agent-bff): pass logger in enum list mapper test
Tonours Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions packages/agent-bff/src/action/action-form-mapper.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { ActionForm } from './agent-action-client';
import type { ActionForm, ActionFormField } from './agent-action-client';
import type { Logger } from '../ports/logger-port';
import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client';

import { sanitizeActionLayout } from './sanitize-action-html';

const ENUM_TYPE = 'Enum';
import { isEnumFieldType } from '../read-model/field-type';

export interface ActionFormFieldResponse {
name: string;
Expand All @@ -23,6 +22,13 @@ export interface ActionFormResponse {
layout: ForestServerActionFormLayoutElement[];
}

export function missingRequiredFieldNames(fields: ActionFormField[]): string[] {
return fields
.filter(field => field.isRequired())
.filter(field => field.getValue() === undefined || field.getValue() === null)
.map(field => field.getName());
}

// Mirrors the MCP getActionForm tool, adding `layout`. A required field is "missing a value" only
// when its resolved value is null/undefined; an explicit empty string or 0 counts as present.
export function mapActionForm(
Expand All @@ -33,10 +39,7 @@ export function mapActionForm(
): ActionFormResponse {
const fields = action.getFields();

const requiredFields = fields
.filter(field => field.isRequired())
.filter(field => field.getValue() === undefined || field.getValue() === null)
.map(field => field.getName());
const requiredFields = missingRequiredFieldNames(fields);

return {
fields: fields.map(field => {
Expand All @@ -47,8 +50,8 @@ export function mapActionForm(
isRequired: field.isRequired() ?? false,
};

// enumValues is emitted only for Enum fields, matching the MCP tool.
if (field.getType() === ENUM_TYPE) {
// enumValues is emitted for every enum field, matching what the execute validator checks.
if (isEnumFieldType(field.getType())) {
return { ...base, enumValues: action.getEnumField(field.getName()).getOptions() ?? null };
}

Expand Down
24 changes: 21 additions & 3 deletions packages/agent-bff/src/action/action-routes-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import type { Context, Middleware } from 'koa';
import {
ActionFormValidationError,
ActionRequiresApprovalError,
InvalidActionFileValueError,
UnknownActionFieldError,
} from '@forestadmin/agent-client';

import { mapActionExecuteResult } from './action-execute-mapper';
import { mapActionForm } from './action-form-mapper';
import assertActionValuesExecutable from './action-values-validator';
import defaultCreateAgentActionClient, { extractRawLayout } from './agent-action-client';
import sanitizeActionHtml from './sanitize-action-html';
import { mapAgentError } from '../http/agent-error-mapper';
Expand Down Expand Up @@ -52,6 +54,18 @@ function parseRecordIds(raw: unknown): string[] {
return raw.map(id => String(id));
}

const ACTION_BODY_KEYS = ['recordIds', 'values', 'timezone'];

// The body is closed like the data bodies are: a misspelled `values` used to execute the action
// with no value at all, and answer 200 as if every field had been filled.
function assertKnownBodyKeys(body: Record<string, unknown>): void {
const stray = Object.keys(body).find(key => !ACTION_BODY_KEYS.includes(key));

if (stray !== undefined) {
throw invalidRequest(`Request body cannot carry "${stray}"`);
}
}

function parseValues(raw: unknown): Record<string, unknown> {
if (raw === undefined) return {};

Expand Down Expand Up @@ -111,15 +125,18 @@ async function handleExecute({
values,
logger,
}: ActionHandlerArgs<Action>): Promise<void> {
// setFields is strict: an unknown submitted field is a client error (400), not a 500. A transport
// failure from the change-hook it triggers is a genuine agent error, so it goes to the mapper.
try {
await action.setFields(values);
} catch (error) {
if (error instanceof UnknownActionFieldError) throw invalidRequest(error.message);
if (error instanceof UnknownActionFieldError || error instanceof InvalidActionFileValueError) {
throw invalidRequest(error.message);
}

throw mapAgentError(error, { logger });
}

assertActionValuesExecutable(action, logger);

// execute() cannot go through the generic callAgent: agent-client turns the native action Error
// (HTTP 400) into ActionFormValidationError, a non-AgentHttpError the mapper would mislabel as a
// transport 502. So the semantic outcomes are caught here, everything else falls to the mapper.
Expand Down Expand Up @@ -192,6 +209,7 @@ export default function createActionRoutesMiddleware({
}

const body = (ctx.request.body ?? {}) as ActionRequestBody;
assertKnownBodyKeys(body as Record<string, unknown>);
const recordIds = parseRecordIds(body.recordIds);
const values = parseValues(body.values);

Expand Down
115 changes: 115 additions & 0 deletions packages/agent-bff/src/action/action-values-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { ActionForm } from './agent-action-client';
import type { Logger } from '../ports/logger-port';
import type { FieldType, PrimitiveShape } from '../read-model/field-type';

import { missingRequiredFieldNames } from './action-form-mapper';
import {
enumOptionsOf,
isEnumFieldType,
normalizeFieldType,
primitiveShapeOf,
} from '../read-model/field-type';
import { invalidActionValue, missingRequiredActionFields } from '../validation/validation-errors';

const PACKED_ID_EXPECTATION = 'a string holding a packed record id';

function shapeOf(type: FieldType): PrimitiveShape {
return typeof type === 'string' ? primitiveShapeOf(type) : 'any';
}

interface ValueCheck {
expected: string;
matches(value: unknown): boolean;
}

const SHAPE_CHECKS: Record<PrimitiveShape, ValueCheck> = {
any: { expected: 'any value', matches: () => true },
boolean: { expected: 'a boolean', matches: value => typeof value === 'boolean' },
number: {
expected: 'a number',
matches: value => typeof value === 'number' && Number.isFinite(value),
},
string: { expected: 'a string', matches: value => typeof value === 'string' },
};

function checkFor(type: FieldType, options: string[] | undefined): ValueCheck {
if (options === undefined) return SHAPE_CHECKS[shapeOf(type)];

return {
expected: `one of: ${options.join(', ')}`,
matches: value => typeof value === 'string' && options.includes(value),
};
}

function expectedWhenViolated(
type: FieldType,
value: unknown,
options: string[] | undefined,
): string | undefined {
if (Array.isArray(type)) {
const [itemType] = type;

if (!Array.isArray(value)) return `an array of ${checkFor(itemType, options).expected}`;

const bad = value
.map((item, index) => ({ index, expected: expectedWhenViolated(itemType, item, options) }))
.filter((item): item is { index: number; expected: string } => item.expected !== undefined);

Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (bad.length === 0) return undefined;

const label = bad.length === 1 ? 'index' : 'indexes';

return `${label} ${bad.map(item => item.index).join(', ')} to be ${bad[0].expected}`;
}

const check = checkFor(type, options);

return check.matches(value) ? undefined : check.expected;
}

export default function assertActionValuesExecutable(action: ActionForm, logger: Logger): void {
const fields = action.getFields();
const missing = missingRequiredFieldNames(fields);

if (missing.length > 0) {
logger('Warn', 'Action execute rejected: required fields left empty', { fields: missing });

throw missingRequiredActionFields(missing);
}

const violations: { field: string; expected: string }[] = [];

const provided = fields.filter(
field => field.getValue() !== undefined && field.getValue() !== null,
);

for (const field of provided) {
const name = field.getName();
const value = field.getValue();

if (field.getReference()) {
if (typeof value !== 'string') {
violations.push({ field: name, expected: PACKED_ID_EXPECTATION });
}
} else {
const type = normalizeFieldType(field.getType());
const options = enumOptionsOf(type, action.getEnumField(name).getOptions());

if (options === undefined && isEnumFieldType(type)) {
logger('Warn', 'Action enum field accepted without its options', { field: name });
}

const expected = expectedWhenViolated(type, value, options);

if (expected) violations.push({ field: name, expected });
}
}

if (violations.length > 0) {
logger('Warn', 'Action execute rejected: invalid values', {
fields: violations.map(violation => violation.field),
});

throw invalidActionValue(violations);
}
}
1 change: 1 addition & 0 deletions packages/agent-bff/src/action/agent-action-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ActionFormField {
getName(): string;
/** A list type is the array the agent sent, `['String']`, not `'StringList'`. */
getType(): string | [string];
getReference(): string | null | undefined;
getValue(): unknown;
isRequired(): boolean | undefined;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bff/src/context/build-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FieldType } from '../read-model/capabilities-cache';
import type { FieldType } from '../read-model/field-type';
import type ReadModel from '../read-model/read-model';
import type { RelationshipType } from '../read-model/read-model';
import type {
Expand Down
18 changes: 12 additions & 6 deletions packages/agent-bff/src/openapi/collect-unfolding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type ReadModelStore from '../read-model/read-model-store';
import type { Operator } from '@forestadmin/datasource-toolkit';

import { extractErrorMessage } from '../errors';
import { normalizeFieldType } from '../read-model/field-type';
import { normalizeOperator, toCanonicalOperatorSet } from '../validation/operator-normalizer';

// A cold document costs one capabilities call per collection. They are capped rather than fired all
Expand Down Expand Up @@ -176,12 +177,17 @@ function collectActions(readModel: ReadModel, collection: string): UnfoldedActio
// literally called "undefined".
fields: (byName[name].fields ?? [])
.filter(field => typeof field?.field === 'string')
.map(field => ({
name: field.field,
type: field.type,
isRequired: field.isRequired === true,
enums: field.enums ?? null,
})),
.map(field => {
const { reference } = field as { reference?: unknown };

return {
name: field.field,
type: normalizeFieldType(field.type),
isRequired: field.isRequired === true,
enums: field.enums ?? null,
reference: typeof reference === 'string' ? reference : null,
};
}),
}));
}

Expand Down
50 changes: 28 additions & 22 deletions packages/agent-bff/src/openapi/field-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
import type { FieldType } from '../read-model/capabilities-cache';
import type { FieldType } from '../read-model/field-type';
import type { SchemaObject } from 'openapi3-ts/oas31';

// Forest primitives, mapped to the closest JSON Schema shape. `Json` maps to no constraint at all,
// which is honest: any JSON value is accepted there.
const PRIMITIVE_SCHEMAS: Record<string, SchemaObject> = {
Binary: { type: 'string' },
Boolean: { type: 'boolean' },
Date: { type: 'string', format: 'date-time' },
Dateonly: { type: 'string', format: 'date' },
Enum: { type: 'string' },
File: { type: 'string', description: 'A data URI.' },
Json: {},
Number: { type: 'number' },
Point: { type: 'string' },
String: { type: 'string' },
Time: { type: 'string' },
Timeonly: { type: 'string' },
Uuid: { type: 'string', format: 'uuid' },
};
import { primitiveShapeOf } from '../read-model/field-type';

const FORMATS = new Map([
['Date', 'date-time'],
['Dateonly', 'date'],
['Uuid', 'uuid'],
]);

const DESCRIPTIONS = new Map([['File', 'A data URI.']]);

/**
* Turns a Forest column type into a JSON Schema. Anything unrecognized — a relation marker such as
* `ManyToOne`, or a type a newer agent introduced — maps to an unconstrained schema rather than a
* guess, so the document never rejects a value the runtime accepts.
* Turns a Forest column type into a JSON Schema, off the shared primitive table in
* `read-model/field-type`. Anything unrecognized — a relation marker such as `ManyToOne`, or a type
* a newer agent introduced — maps to an unconstrained schema rather than a guess, so the document
* never rejects a value the runtime accepts.
*/
export default function toFieldSchema(type: FieldType): SchemaObject {
if (Array.isArray(type)) {
Expand All @@ -45,5 +38,18 @@ export default function toFieldSchema(type: FieldType): SchemaObject {
};
}

return typeof type === 'string' ? PRIMITIVE_SCHEMAS[type] ?? {} : {};
if (typeof type !== 'string') return {};

const shape = primitiveShapeOf(type);

if (shape === 'any') return {};

const format = FORMATS.get(type);
const description = DESCRIPTIONS.get(type);

return {
type: shape,
...(format !== undefined && { format }),
...(description !== undefined && { description }),
};
}
4 changes: 2 additions & 2 deletions packages/agent-bff/src/openapi/openapi-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ const API_KEY_SCHEME = 'bffApiKey';
const SECURITY = [{ [SESSION_SCHEME]: [] }, { [API_KEY_SCHEME]: [] }];

const ERROR_STATUSES: Record<string, string> = {
400: 'Malformed body, a malformed URL-encoded path segment, an invalid filter operator, a filter nested too deep, ambiguous credentials, an unsupported page, a missing or invalid timezone, an unknown submitted action field, or a rejected action form (type action_error)',
400: 'Malformed body, a malformed URL-encoded path segment, an invalid filter operator, a filter nested too deep, ambiguous credentials, an unsupported page, a missing or invalid timezone, an unknown submitted action field, a required action field left empty or a malformed file value at execute, or a rejected action form (type action_error)',
401: 'Missing, invalid, or expired credentials',
403: 'The action needs approval before it runs (the body carries the approving roles), the Forest identity behind the API key is not allowed, the origin is not allowed for this key, or the agent refused the collection, relation, or action',
404: 'Unknown collection, relation, or action',
413: `The request body exceeds the BFF limit of ${BODY_LIMIT}`,
415: 'The request Content-Type is neither application/json nor an application/*+json type, including form-urlencoded, and is rejected with 415 instead of being silently dropped; a request carrying a body with no Content-Type at all is rejected the same way; or the declared character set cannot be decoded',
422: 'A field is unknown, not filterable, or is a nested relation path',
422: 'A field is unknown, not filterable, is a nested relation path, or an action value at execute is outside its enum or of the wrong type',
429: 'The agent rate-limited the request',
500: 'The agent payload could not be mapped to the BFF contract, or the BFF hit an unexpected error',
501: 'The BFF is running without an agent configured, so the proxy is not implemented',
Expand Down
8 changes: 6 additions & 2 deletions packages/agent-bff/src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,19 @@ export const RelationCountRequestSchema = CountRequestSchema.extend({
}).openapi('RelationCountRequest');

export const ActionRequestSchema = z
.object({
.strictObject({
recordIds: z.array(z.union([z.string(), z.number()])),
values: z.record(z.string(), z.unknown()).optional(),
timezone: TimezoneSchema.optional(),
})
.openapi('ActionRequest', {
description:
'`recordIds` is required, even on a global action: send an empty array when the action ' +
'targets no record. Every id is coerced to a string before reaching the agent.',
'targets no record. Every id is coerced to a string before reaching the agent. On execute ' +
'the submitted values are validated against the live form: a required field left empty ' +
'answers 400, an out-of-enum or wrongly typed value 422. Only the JSON type and an Enum ' +
"field's options are checked there: a declared string format (date-time, uuid) and a " +
"widget's own option list are not.",
});

const ForestRecordMetaSchema = z
Expand Down
Loading
Loading