Skip to content
Merged
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
37 changes: 24 additions & 13 deletions packages/agent-bff/src/action/action-execute-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ export interface ActionExecuteMapped {
body: ActionExecuteMappedBody;
}

interface AgentWebhookPayload {
url: string;
method: string;
headers: unknown;
body: unknown;
}

// The agent always serializes the four webhook fields together
// (`agent/src/routes/modification/action/action.ts`), so a payload missing url/method is malformed
// and must reach the 501 path rather than surface as a 200 the client cannot act on.
function isWebhook(value: unknown): value is AgentWebhookPayload {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;

const { url, method } = value as Record<string, unknown>;

return typeof url === 'string' && typeof method === 'string';
}

// 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
Expand All @@ -43,19 +61,10 @@ export function mapActionExecuteResult(raw: unknown): ActionExecuteMapped {
// 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>;
if (isWebhook(body.webhook)) {
const { url, method, headers, body: hookBody } = body.webhook;

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

if (typeof body.redirectTo === 'string') {
Expand All @@ -74,7 +83,9 @@ export function mapActionExecuteResult(raw: unknown): ActionExecuteMapped {
body: {
type: 'success',
message: typeof body.success === 'string' ? body.success : null,
invalidated: Array.isArray(relationships) ? (relationships as string[]) : [],
invalidated: Array.isArray(relationships)
? relationships.filter((name): name is string => typeof name === 'string')
: [],
html: typeof body.html === 'string' ? body.html : null,
},
};
Expand Down
38 changes: 38 additions & 0 deletions packages/agent-bff/src/data/data-routes-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
RelationListRequestBody,
} from './agent-query';
import type { Logger } from '../ports/logger-port';
import type { CapabilitiesResult } from '../read-model/capabilities-cache';
import type ReadModel from '../read-model/read-model';
import type { PrimaryKeyField, RelationTarget } from '../read-model/read-model';
import type ReadModelStore from '../read-model/read-model-store';
Expand All @@ -30,6 +31,11 @@ import {
resolveReadModel,
} from '../http/agent-route-helpers';
import { unknownCollection, unknownRelation } from '../http/bff-local-errors';
import createAgentCapabilitiesFetcher from '../read-model/agent-capabilities-fetcher';
import {
assertValidAgainstCapabilities,
hasCapabilityConstrainedInput,
} from '../validation/capabilities-validator';
import assertNoRelationFieldPaths from '../validation/relation-field-guard';

const DATA_ROUTE = /^\/agent\/v1\/([^/]+)\/(list|count)$/;
Expand All @@ -54,15 +60,39 @@ export interface DataRoutesMiddlewareOptions {
interface RequestHandlerDeps {
collection: string;
client: AgentDataClient;
store: ReadModelStore;
agentUrl: string;
token: string;
timezone: string;
logger: Logger;
}

type ListHandlerDeps = RequestHandlerDeps & { primaryKeys: PrimaryKeyField[] };

function resolveCapabilities(deps: RequestHandlerDeps): Promise<CapabilitiesResult> {
return callAgent(
() =>
deps.store.getCapabilities(
deps.collection,
createAgentCapabilitiesFetcher({ agentUrl: deps.agentUrl, token: deps.token }),
),
deps.logger,
);
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

async function handleList(ctx: Context, body: ListRequestBody, deps: ListHandlerDeps) {
assertNoRelationFieldPaths(collectListFieldPaths(body));

const validationInput = {
filter: body.filter,
sortFields: body.sort?.map(clause => clause.field),
projectionFields: body.projection,
};

if (hasCapabilityConstrainedInput(validationInput)) {
assertValidAgainstCapabilities(validationInput, await resolveCapabilities(deps));
}

const query = buildListAgentQuery(deps.collection, deps.timezone, body);
const records = await callAgent(() => deps.client.list(deps.collection, query), deps.logger);

Expand All @@ -73,6 +103,11 @@ async function handleList(ctx: Context, body: ListRequestBody, deps: ListHandler
async function handleCount(ctx: Context, body: CountRequestBody, deps: RequestHandlerDeps) {
assertNoRelationFieldPaths(collectCountFieldPaths(body));

// Count carries only a filter (no sort/projection), so that is all there is to validate.
if (body.filter !== undefined) {
assertValidAgainstCapabilities({ filter: body.filter }, await resolveCapabilities(deps));
}

const query = buildCountAgentQuery(deps.timezone, body);
const raw = await callAgent(() => deps.client.countRaw(deps.collection, query), deps.logger);

Expand Down Expand Up @@ -198,6 +233,9 @@ export default function createDataRoutesMiddleware({
const deps: RequestHandlerDeps = {
collection,
client: createClient({ agentUrl, token }),
store,
agentUrl,
token,
timezone: ctx.state.timezone as string,
logger,
};
Expand Down
13 changes: 8 additions & 5 deletions packages/agent-bff/src/read-model/read-model-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ export default class ReadModelStore {
// Ensure any pending schema refresh (and its capabilities invalidation) runs first.
await this.getReadModel();

// TODO(wiring): possible TOCTOU once this is called from request handling. A concurrent schema
// refresh can clear capabilities while this fetch is in flight, so the caller could receive
// capabilities from the previous schema generation alongside the new allow-list. When wiring
// the data endpoints, re-check `schemaCache.revision` after the fetch resolves and retry on a
// mismatch so capabilities and schema stay atomically coupled.
// TODO(wiring): known TOCTOU, deferred. Two snapshots can straddle a schema generation:
// 1. the data middleware captures the read-model (allow-list) once, then calls this later;
// 2. this capabilities fetch can be in flight when a concurrent schema refresh clear()s it.
// Either gap lets the caller validate against capabilities from one generation while the
// allow-list came from another. A full fix couples both reads to a single generation (return
// read-model + capabilities together, or re-check `schemaCache.revision` across both and retry);
// a retry here alone only closes gap 2. Low risk: the trigger is a 24h-TTL refresh landing exactly
// during a request, and the agent stays the final validator.
return this.capabilitiesCache.get(collection, fetcher);
}

Expand Down
27 changes: 24 additions & 3 deletions packages/agent-bff/src/validation/capabilities-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { BffHttpError } from '../http/bff-http-error';
import type { CapabilitiesResult } from '../read-model/capabilities-cache';

import { normalizeOperator } from './operator-normalizer';
import { fieldNotFilterable, invalidFilterOperator, unknownField } from './validation-errors';
import {
fieldNotFilterable,
filterTooDeep,
invalidFilterOperator,
unknownField,
} from './validation-errors';
import { mappingError } from '../http/bff-local-errors';

export interface ValidateParams {
Expand Down Expand Up @@ -32,9 +37,13 @@ function isLeaf(node: unknown): node is FilterLeaf {
);
}

function collectLeaves(node: unknown, acc: FilterLeaf[]): void {
export const MAX_FILTER_DEPTH = 100;

function collectLeaves(node: unknown, acc: FilterLeaf[], depth = 0): void {
if (depth > MAX_FILTER_DEPTH) throw filterTooDeep(MAX_FILTER_DEPTH);

if (isBranch(node)) {
node.conditions.forEach(condition => collectLeaves(condition, acc));
node.conditions.forEach(condition => collectLeaves(condition, acc, depth + 1));
} else if (isLeaf(node)) {
const { operator } = node as { operator?: unknown };
acc.push({ field: node.field, operator: typeof operator === 'string' ? operator : undefined });
Expand Down Expand Up @@ -107,6 +116,18 @@ function dedupe(errors: BffHttpError[]): BffHttpError[] {
return result;
}

/**
* True when the request carries something capabilities can invalidate. Callers use it to skip the
* capabilities fetch entirely, so a plain list/count still succeeds while that fetch is unavailable.
*/
export function hasCapabilityConstrainedInput(params: ValidateParams): boolean {
return (
params.filter !== undefined ||
(params.sortFields?.length ?? 0) > 0 ||
(params.projectionFields?.length ?? 0) > 0
);
}

/**
* Validates a request's filter, sort, and projection fields against the target collection's
* capabilities. Returns every offending field as a structured error (empty = valid); the caller
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bff/src/validation/operator-normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function toSnakeCaseOperator(operator: string): string {
.toLowerCase();
}

const SNAKE_TO_PASCAL: Record<string, Operator> = Object.fromEntries(
const SNAKE_TO_PASCAL = new Map<string, Operator>(
allOperators.map(operator => [toSnakeCaseOperator(operator), operator]),
);

Expand All @@ -24,5 +24,5 @@ const SNAKE_TO_PASCAL: Record<string, Operator> = Object.fromEntries(
* only happens when the agent runs a newer operator set than this package (a version skew).
*/
export function normalizeOperator(snakeCaseOperator: string): Operator | undefined {
return SNAKE_TO_PASCAL[snakeCaseOperator];
return SNAKE_TO_PASCAL.get(snakeCaseOperator);
}
9 changes: 9 additions & 0 deletions packages/agent-bff/src/validation/validation-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ export function fieldNotFilterable(field: string): BffHttpError {
});
}

export function filterTooDeep(maxDepth: number): BffHttpError {
return new BffHttpError(
400,
'filter_too_deep',
`Filter nesting exceeds the maximum depth of ${maxDepth}`,
{ maxDepth },
);
}

export function invalidFilterOperator(field: string, validOperators: string[]): BffHttpError {
return new BffHttpError(
400,
Expand Down
22 changes: 22 additions & 0 deletions packages/agent-bff/test/action/action-execute-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ describe('mapActionExecuteResult', () => {
});
});

it.each([
['an empty object', { webhook: {} }],
['an array', { webhook: [] }],
['url missing', { webhook: { method: 'POST' } }],
['method missing', { webhook: { url: 'https://x.test' } }],
['url not a string', { webhook: { url: 42, method: 'POST' } }],
])('falls through to 501 when the webhook payload is %s', (_label, payload) => {
expect(mapActionExecuteResult(payload)).toEqual({
status: 501,
body: { error: { type: 'unsupported_action_result', status: 501 } },
});
});

it('drops non-string entries from invalidated', () => {
expect(
mapActionExecuteResult({ success: 'ok', refresh: { relationships: ['orders', 42, null] } }),
).toEqual({
status: 200,
body: { type: 'success', message: 'ok', invalidated: ['orders'], html: null },
});
});

it('maps a Redirect payload to the path', () => {
expect(mapActionExecuteResult({ redirectTo: '/orders/1' })).toEqual({
status: 200,
Expand Down
Loading
Loading