Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 execute result is
// untyped at the BFF boundary (`Action.execute(): Promise<unknown>`), 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 } } };
}
123 changes: 106 additions & 17 deletions packages/agent-bff/src/action/action-routes-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
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 { Middleware } from 'koa';
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 { invalidRequest, unknownAction } from '../http/bff-local-errors';
import { actionRequiresApproval, invalidRequest, unknownAction } from '../http/bff-local-errors';

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

interface ActionFormRequestBody {
interface ActionRequestBody {
recordIds?: unknown;
values?: unknown;
}
Expand Down Expand Up @@ -50,14 +58,100 @@ export interface ActionRoutesMiddlewareOptions {
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,
);
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,
);

// 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);

// An unrecognized payload (a File stream, or a new agent result type) maps to a generic 501 with
// no trace of what it was; log the payload keys so the case can be diagnosed without the body.
if (status === 501) {
logger('Warn', 'Unrecognized action execute result mapped to 501', {
keys: typeof raw === 'object' && raw !== null ? Object.keys(raw) : typeof 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_FORM_ROUTE.exec(ctx.path);
const match = ACTION_ROUTE.exec(ctx.path);

if (!match || ctx.method !== 'POST') {
await next();
Expand All @@ -67,6 +161,7 @@ export default function createActionRoutesMiddleware({

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);

Expand All @@ -79,7 +174,7 @@ export default function createActionRoutesMiddleware({
throw unknownAction(`Unknown action: ${collection}.${actionName}`);
}

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

Expand All @@ -89,16 +184,10 @@ export default function createActionRoutesMiddleware({
actionEndpoints: readModel.getActionEndpoints(),
});

// 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.loadActionForm(collection, actionName, recordIds),
logger,
);
const skippedFields = await callAgent(() => action.tryToSetFields(values), logger);

ctx.status = 200;
ctx.body = mapActionForm(action, skippedFields, extractRawLayout(action));
if (verb === 'execute') {
await handleExecute(ctx, client, collection, actionName, recordIds, values, logger);
} else {
await handleForm(ctx, client, collection, actionName, recordIds, values, logger);
}
};
}
11 changes: 9 additions & 2 deletions packages/agent-bff/src/action/agent-action-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@ export interface ActionForm {
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 {
loadActionForm(collection: string, action: string, recordIds: string[]): Promise<ActionForm>;
loadAction(collection: string, action: string, recordIds: string[]): Promise<Action>;
}

export interface AgentActionClientOptions {
Expand Down Expand Up @@ -53,7 +60,7 @@ export default function createAgentActionClient({
const client = createRemoteAgentClient({ url: agentUrl, token, actionEndpoints });

return {
loadActionForm: (collection, action, recordIds) =>
loadAction: (collection, action, recordIds) =>
client.collection(collection).action(action, { recordIds }),
};
}
7 changes: 7 additions & 0 deletions packages/agent-bff/src/http/bff-local-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,10 @@ export function schemaUnavailable(message = 'The agent schema is unavailable'):
export function unsupportedActionResult(message = 'Unsupported action result'): BffHttpError {
return new BffHttpError(501, 'unsupported_action_result', message);
}

export function actionRequiresApproval(
message = 'This action requires an approval before it can run',
details?: unknown,
): BffHttpError {
return new BffHttpError(403, 'action_requires_approval', message, details);
}
94 changes: 94 additions & 0 deletions packages/agent-bff/test/action/action-execute-mapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { mapActionExecuteResult } from '../../src/action/action-execute-mapper';

describe('mapActionExecuteResult', () => {
it('maps a Success payload, serializing refresh.relationships into invalidated', () => {
expect(
mapActionExecuteResult({
success: 'Done',
html: '<b>ok</b>',
refresh: { relationships: ['orders', 'items'] },
}),
).toEqual({
status: 200,
body: {
type: 'success',
message: 'Done',
invalidated: ['orders', 'items'],
html: '<b>ok</b>',
},
});
});

it('defaults message and html to null and invalidated to [] when absent', () => {
expect(mapActionExecuteResult({ success: undefined, refresh: { relationships: [] } })).toEqual({
status: 200,
body: { type: 'success', message: null, invalidated: [], html: null },
});
});

it('treats a bare refresh payload as a Success', () => {
expect(mapActionExecuteResult({ refresh: { relationships: ['orders'] } })).toEqual({
status: 200,
body: { type: 'success', message: null, invalidated: ['orders'], html: null },
});
});

it.each([
['relationships absent', { success: 'ok', refresh: {} }],
['relationships not an array', { success: 'ok', refresh: { relationships: 'x' } }],
])('falls back invalidated to [] when %s', (_label, payload) => {
expect(mapActionExecuteResult(payload)).toEqual({
status: 200,
body: { type: 'success', message: 'ok', invalidated: [], html: null },
});
});

it('maps a Webhook payload verbatim', () => {
expect(
mapActionExecuteResult({
webhook: { url: 'https://x.test', method: 'POST', headers: { a: '1' }, body: { b: 2 } },
}),
).toEqual({
status: 200,
body: {
type: 'webhook',
url: 'https://x.test',
method: 'POST',
headers: { a: '1' },
body: { b: 2 },
},
});
});

it('maps a Redirect payload to the path', () => {
expect(mapActionExecuteResult({ redirectTo: '/orders/1' })).toEqual({
status: 200,
body: { type: 'redirect', path: '/orders/1' },
});
});

it('falls through to 501 for an unrecognized (File) payload', () => {
expect(mapActionExecuteResult({})).toEqual({
status: 501,
body: { error: { type: 'unsupported_action_result', status: 501 } },
});
});

it('falls through to 501 for a non-object payload', () => {
expect(mapActionExecuteResult(null)).toEqual({
status: 501,
body: { error: { type: 'unsupported_action_result', status: 501 } },
});
});

it.each([
['a null webhook', { webhook: null }],
['a non-string redirectTo', { redirectTo: {} }],
['a non-string success with no refresh', { success: {} }],
])('falls through to 501 for a malformed payload: %s', (_label, payload) => {
expect(mapActionExecuteResult(payload)).toEqual({
status: 501,
body: { error: { type: 'unsupported_action_result', status: 501 } },
});
});
});
Loading
Loading