Skip to content
Open
1 change: 1 addition & 0 deletions packages/agent-bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
},
"dependencies": {
"@forestadmin/agent-client": "1.10.1",
"@forestadmin/datasource-toolkit": "1.53.1",
"@forestadmin/forestadmin-client": "1.40.4",
"@koa/bodyparser": "^6.1.0",
"jsonwebtoken": "^9.0.3",
Expand Down
84 changes: 84 additions & 0 deletions packages/agent-bff/src/action/action-execute-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
export interface ActionExecuteSuccessBody {
type: 'success';
message: string | null;
invalidated: string[];
html: string | null;
}

export interface ActionExecuteWebhookBody {
type: 'webhook';
url: unknown;
method: unknown;
headers: unknown;
body: unknown;
}

export interface ActionExecuteRedirectBody {
type: 'redirect';
path: unknown;
}

export interface ActionExecuteUnsupportedBody {
error: { type: 'unsupported_action_result'; status: 501 };
}

export type ActionExecuteMappedBody =
| ActionExecuteSuccessBody
| ActionExecuteWebhookBody
| ActionExecuteRedirectBody
| ActionExecuteUnsupportedBody;

export interface ActionExecuteMapped {
status: number;
body: ActionExecuteMappedBody;
}

// Normalizes the agent's 200 execute payload into the flat BFF wrapper. The native ActionResult
// union never reaches the BFF (agent-client `execute()` is typed `{ success, html? }`), so we
// discriminate on the agent HTTP payload shape. A File result streams a binary with no JSON marker,
// so any unrecognized 200 body falls through to a structured 501 rather than being mislabelled.
export function mapActionExecuteResult(raw: unknown): ActionExecuteMapped {
const body = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;

// Each branch validates the value shape, not just key presence: a malformed payload
// (`{ webhook: null }`, `{ redirectTo: {} }`, `{ success: {} }`) must fall through to the 501
// path rather than be surfaced as a 200 with null fields the client cannot tell from a real one.
if (typeof body.webhook === 'object' && body.webhook !== null) {
const hook = body.webhook as Record<string, unknown>;

return {
status: 200,
body: {
type: 'webhook',
url: hook.url,
method: hook.method,
headers: hook.headers,
body: hook.body,
},
};
}

if (typeof body.redirectTo === 'string') {
return { status: 200, body: { type: 'redirect', path: body.redirectTo } };
}

const refresh = typeof body.refresh === 'object' && body.refresh !== null ? body.refresh : null;

// A Success carries a string message and/or a refresh object; the agent always sends the refresh,
// so a bare refresh (message absent) still normalizes to a success with a null message.
if (typeof body.success === 'string' || refresh !== null) {
const relationships = (refresh as { relationships?: unknown } | null)?.relationships;

return {
status: 200,
body: {
type: 'success',
message: typeof body.success === 'string' ? body.success : null,
invalidated: Array.isArray(relationships) ? (relationships as string[]) : [],
html: typeof body.html === 'string' ? body.html : null,
},
};
}

return { status: 501, body: { error: { type: 'unsupported_action_result', status: 501 } } };
}
57 changes: 57 additions & 0 deletions packages/agent-bff/src/action/action-form-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { ActionForm } from './agent-action-client';
import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client';

const ENUM_TYPE = 'Enum';

export interface ActionFormFieldResponse {
name: string;
type: string;
value: unknown;
isRequired: boolean;
enumValues?: string[] | null;
}

export interface ActionFormResponse {
fields: ActionFormFieldResponse[];
canExecute: boolean;
requiredFields: string[];
skippedFields: string[];
layout: ForestServerActionFormLayoutElement[];
}

// 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(
action: ActionForm,
skippedFields: string[],
layout: ForestServerActionFormLayoutElement[],
): ActionFormResponse {
const fields = action.getFields();

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

return {
fields: fields.map(field => {
const base: ActionFormFieldResponse = {
name: field.getName(),
type: field.getType(),
value: field.getValue(),
isRequired: field.isRequired() ?? false,
};

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

return base;
}),
canExecute: requiredFields.length === 0,
requiredFields,
skippedFields,
layout,
};
}
178 changes: 178 additions & 0 deletions packages/agent-bff/src/action/action-routes-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { AgentActionClient, AgentActionClientOptions } from './agent-action-client';
import type { Logger } from '../ports/logger-port';
import type ReadModelStore from '../read-model/read-model-store';
import type { Context, Middleware } from 'koa';

import {
ActionFormValidationError,
ActionRequiresApprovalError,
UnknownActionFieldError,
} from '@forestadmin/agent-client';

import { mapActionExecuteResult } from './action-execute-mapper';
import { mapActionForm } from './action-form-mapper';
import defaultCreateAgentActionClient, { extractRawLayout } from './agent-action-client';
import { mapAgentError } from '../http/agent-error-mapper';
import {
callAgent,
decodeSegment,
requireAgentToken,
resolveReadModel,
} from '../http/agent-route-helpers';
import { actionRequiresApproval, invalidRequest, unknownAction } from '../http/bff-local-errors';

const ACTION_ROUTE = /^\/agent\/v1\/([^/]+)\/actions\/([^/]+)\/(form|execute)$/;

interface ActionRequestBody {
recordIds?: unknown;
values?: unknown;
}

// recordIds are opaque BFF ids; only presence and array-ness are checked. An empty array is allowed
// on purpose (global/bulk actions have no selected records); the spec AC only covers a missing
// recordIds, so `[]` is an explicit BFF choice, not a spec requirement.
// Ids are coerced to strings so a valid `0` (numeric primary key) survives agent-client's downstream
// `.filter(Boolean)`, which would otherwise drop it and load the form as if no record were selected.
function parseRecordIds(raw: unknown): string[] {
if (!Array.isArray(raw)) {
throw invalidRequest('recordIds is required and must be an array');
}

return raw.map(id => String(id));
}

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

if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw invalidRequest('values must be an object');
}

return raw as Record<string, unknown>;
}

export interface ActionRoutesMiddlewareOptions {
store: ReadModelStore;
agentUrl: string;
logger: Logger;
createClient?: (options: AgentActionClientOptions) => AgentActionClient;
}

async function handleForm(
ctx: Context,
client: AgentActionClient,
collection: string,
actionName: string,
recordIds: string[],
values: Record<string, unknown>,
logger: Logger,
): Promise<void> {
// Each agent-hitting call is wrapped on its own; getFields/extractRawLayout/mapping stay outside
// callAgent so a local BFF bug surfaces as a 500, not a mislabelled agent error. Fields and
// layout are read AFTER tryToSetFields because a change hook rebuilds them in place.
const action = await callAgent(() => client.loadAction(collection, actionName, recordIds), logger);

Check failure on line 73 in packages/agent-bff/src/action/action-routes-middleware.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-bff)

Replace `()·=>·client.loadAction(collection,·actionName,·recordIds),·logger` with `⏎····()·=>·client.loadAction(collection,·actionName,·recordIds),⏎····logger,⏎··`
const skippedFields = await callAgent(() => action.tryToSetFields(values), logger);

ctx.status = 200;
ctx.body = mapActionForm(action, skippedFields, extractRawLayout(action));
}

async function handleExecute(
ctx: Context,
client: AgentActionClient,
collection: string,
actionName: string,
recordIds: string[],
values: Record<string, unknown>,
logger: Logger,
): Promise<void> {
const action = await callAgent(() => client.loadAction(collection, actionName, recordIds), logger);

Check failure on line 89 in packages/agent-bff/src/action/action-routes-middleware.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-bff)

Replace `()·=>·client.loadAction(collection,·actionName,·recordIds),·logger` with `⏎····()·=>·client.loadAction(collection,·actionName,·recordIds),⏎····logger,⏎··`

// 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);
throw mapAgentError(error, { 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.
let raw: unknown;

try {
raw = await action.execute();
} catch (error) {
if (error instanceof ActionRequiresApprovalError) {
throw actionRequiresApproval(
error.message,
error.roleIdsAllowedToApprove !== undefined
? { roleIdsAllowedToApprove: error.roleIdsAllowedToApprove }
: undefined,
);
}

if (error instanceof ActionFormValidationError) {
ctx.status = 400;
ctx.body = { type: 'error', status: 400, message: error.message, html: error.html ?? null };

return;
}

throw mapAgentError(error, { logger });
}

const { status, body } = mapActionExecuteResult(raw);
ctx.status = status;
ctx.body = body;
}

export default function createActionRoutesMiddleware({
store,
agentUrl,
logger,
createClient = defaultCreateAgentActionClient,
}: ActionRoutesMiddlewareOptions): Middleware {
return async function actionRoutesMiddleware(ctx, next) {
const match = ACTION_ROUTE.exec(ctx.path);

if (!match || ctx.method !== 'POST') {
await next();

return;
}

const collection = decodeSegment(match[1], 'collection name');
const actionName = decodeSegment(match[2], 'action name');
const verb = match[3];
const token = requireAgentToken(ctx);
const readModel = await resolveReadModel(store);

// The read-model's action map IS the allow-list, so an absent action cannot be told from a
// known-but-disallowed one — every non-exposed action maps to 404 here; `action_not_allowed`
// (403) has no local trigger, mirroring `collection_not_allowed`/`relation_not_allowed`. The
// URL identity is resolved before the body, so a bad action 404s before its payload is read.
// TODO(PRD-673): distinguish disallowed from unknown when a separate exposure source exists.
if (!readModel.isActionAllowed(collection, actionName)) {
throw unknownAction(`Unknown action: ${collection}.${actionName}`);
}

const body = (ctx.request.body ?? {}) as ActionRequestBody;
const recordIds = parseRecordIds(body.recordIds);
const values = parseValues(body.values);

const client = createClient({
agentUrl,
token,
actionEndpoints: readModel.getActionEndpoints(),
});

if (verb === 'execute') {
await handleExecute(ctx, client, collection, actionName, recordIds, values, logger);
} else {
await handleForm(ctx, client, collection, actionName, recordIds, values, logger);
}
};
}
65 changes: 65 additions & 0 deletions packages/agent-bff/src/action/agent-action-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { ActionEndpointsByCollection } from '@forestadmin/agent-client';
import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client';

import { createRemoteAgentClient } from '@forestadmin/agent-client';

export interface ActionFormField {
getName(): string;
getType(): string;
getValue(): unknown;
isRequired(): boolean | undefined;
}

// Structural subset of agent-client's `Action` the form endpoint consumes. Kept local so the BFF
// does not depend on agent-client exporting the concrete `Action` type (see extractRawLayout).
export interface ActionForm {
tryToSetFields(values: Record<string, unknown>): Promise<string[]>;
getFields(): ActionFormField[];
getEnumField(name: string): { getOptions(): string[] | undefined };
getLayout(): unknown;
}

// The form and execute endpoints load the same agent-client `Action` object; execute adds the
// strict setFields + execute members on top of the form ones.
export interface Action extends ActionForm {
setFields(values: Record<string, unknown>): Promise<void>;
execute(): Promise<unknown>;
}

export interface AgentActionClient {
loadAction(collection: string, action: string, recordIds: string[]): Promise<Action>;
}

export interface AgentActionClientOptions {
agentUrl: string;
token: string;
actionEndpoints: ActionEndpointsByCollection;
}

// The raw layout must be read AFTER tryToSetFields: a change hook rebuilds fields+layout in place.
// agent-client's `Action.getLayout()` only returns an `ActionLayoutRoot` wrapper whose element array
// lives in a protected field. The rollback contract forbids agent-client changes, so we read it
// through a cast rather than adding a public accessor. A layout-shape test guards against drift.
export function extractRawLayout(action: ActionForm): ForestServerActionFormLayoutElement[] {
const root = action.getLayout() as { layout?: ForestServerActionFormLayoutElement[] };

return root.layout ?? [];
}

/**
* Thin action-form client bound to a request's agent token. Reuses agent-client's stateful form
* loading (`/hooks/load` + `/hooks/change`, Ruby 404 fallback, dependent-field re-evaluation) rather
* than reimplementing it. The endpoint map from the read-model is the action allow-list.
*/
export default function createAgentActionClient({
agentUrl,
token,
actionEndpoints,
}: AgentActionClientOptions): AgentActionClient {
const client = createRemoteAgentClient({ url: agentUrl, token, actionEndpoints });

return {
loadAction: (collection, action, recordIds) =>
client.collection(collection).action(action, { recordIds }),
};
}
Loading
Loading