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
2 changes: 2 additions & 0 deletions packages/agent-bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"zod": "4.3.6"
},
"devDependencies": {
"@forestadmin/agent": "1.96.0",
"@forestadmin/agent-testing": "1.1.79",
"@hey-api/openapi-ts": "0.99.0",
"@redocly/cli": "2.35.1",
"@types/jsonwebtoken": "^9.0.1",
Expand Down
47 changes: 47 additions & 0 deletions packages/agent-bff/src/data/agent-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ export interface ListRequestBody {
projection?: string[];
sort?: BffSortClause[];
page?: BffPage;
search?: string;
searchExtended?: boolean;
}

export interface CountRequestBody {
filter?: unknown;
search?: string;
searchExtended?: boolean;
}

export type RelationListRequestBody = ListRequestBody & { parentId: string };
Expand Down Expand Up @@ -52,6 +56,22 @@ function assertNoNodeReadableAsBothLeafAndBranch(node: unknown, depth = 0): void
}
}

/**

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assertValidSearch slots between the parseListRequest comment and the function it documents, so that comment now reads as this helper's. Move the new function above the three // Validate the untyped request body... lines.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and I had not noticed. The // Validate the untyped request body... lines documented parseListRequest and my helper landed between the two, so they now read as its docblock. assertValidSearch moved above them.

* The agent reads `search`/`searchExtended` from query params, so it coerces both from strings.
* The BFF is a JSON contract: a real boolean is required here, like `page.limit` requires a real
* integer. Only the type is checked β€” whether a blank search is worth sending is the builder's
* call, so a cleared search box parses the same way as an absent one.
*/
function assertValidSearch(search: unknown, searchExtended: unknown): void {
if (search !== undefined && typeof search !== 'string') {
throw invalidRequest('search must be a string');
}

if (searchExtended !== undefined && typeof searchExtended !== 'boolean') {
throw invalidRequest('searchExtended must be a boolean');
}
}

// Validate the untyped request body before it reaches the query builders, so malformed shapes
// (e.g. `projection` or `sort` as a string) surface as 400 invalid_request rather than a 500 from
// an array method blowing up downstream.
Expand All @@ -60,6 +80,8 @@ export function parseListRequest(body: unknown): ListRequestBody {

const { filter, projection, sort, page } = body;

assertValidSearch(body.search, body.searchExtended);

if (projection !== undefined) {
if (!Array.isArray(projection) || projection.some(field => typeof field !== 'string')) {
throw invalidRequest('projection must be an array of field names');
Expand Down Expand Up @@ -105,6 +127,8 @@ export function parseListRequest(body: unknown): ListRequestBody {
export function parseCountRequest(body: unknown): CountRequestBody {
if (!isPlainObject(body)) throw invalidRequest('Request body must be an object');

assertValidSearch(body.search, body.searchExtended);

if (body.filter !== undefined) {
if (!isPlainObject(body.filter)) throw invalidRequest('filter must be an object');
assertNoNodeReadableAsBothLeafAndBranch(body.filter);
Expand Down Expand Up @@ -138,6 +162,27 @@ function serializePage(page: BffPage): Record<string, number> {
return { 'page[size]': limit, 'page[number]': offset / limit + 1 };
}

/**
* `search` and `searchExtended` are the wire names the agent reads; no other spelling is parsed.
*
* A blank search is dropped rather than forwarded. The agent's search decorator already treats it
* as absent, but `parseSearch` guards on a truthy value, so a whitespace-only search would raise
* "Collection is not searchable" on a non-searchable collection while an empty one would not β€” a
* cleared search box must not depend on how many spaces it holds.
*
* `searchExtended` only ships alongside a real search: on its own it changes nothing agent-side,
* and emitting it would alter the outgoing query of every search-less request.
*/
function applySearch(
query: AgentQuery,
body: Pick<CountRequestBody, 'search' | 'searchExtended'>,
): void {
if (!body.search?.trim()) return;

query.search = body.search;
if (body.searchExtended !== undefined) query.searchExtended = body.searchExtended;
}

export function buildListAgentQuery(
collection: string,
timezone: string,
Expand All @@ -149,6 +194,7 @@ export function buildListAgentQuery(
if (body.projection?.length) query[`fields[${collection}]`] = body.projection.join(',');
if (body.sort?.length) query.sort = serializeSort(body.sort);
if (body.page) Object.assign(query, serializePage(body.page));
applySearch(query, body);

return query;
}
Expand All @@ -157,6 +203,7 @@ export function buildCountAgentQuery(timezone: string, body: CountRequestBody):
const query: AgentQuery = { timezone };

if (body.filter !== undefined) query.filters = JSON.stringify(body.filter);
applySearch(query, body);

return query;
}
Expand Down
34 changes: 32 additions & 2 deletions packages/agent-bff/src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,22 +61,52 @@ export const TimezoneSchema = z.string().openapi('Timezone', {
'missing_timezone.',
});

export const SearchSchema = z.string().openapi('Search', {
description:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The relation-field warning is on the wrong schema: search: "author.name:asimov" reaches an authors column with no searchExtended at all, while filter: { field: "author:name" } on the same path is refused with 422 relation_field_not_supported. Move that paragraph into Search, or say here that plain search already crosses relations through its field.subfield:value syntax.

Mechanism: datasource-customizer/src/decorators/search/collection.ts:84 calls extractSpecifiedFields outside the extended branch, and lenientGetSchema (:139-164) resolves prefix:suffix through ManyToOne/OneToOne/OneToMany. assertNoRelationFieldPaths never sees it: collectListFieldPaths/collectCountFieldPaths only read projection, filter and sort.

Worth deciding beyond the doc: the BFF exposure allow-list is separate from agent permissions (data-routes-middleware.ts:289-293 says a browsable collection may still be hidden here), so a relation-scoped search can read columns of a collection that list hides, without going through /relations/. The hole predates this PR agent-side, but this is the first time it is reachable through the BFF.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified, and it is worse than the doc placement. I reproduced the mechanism you describe: FieldsQueryWalker.enterPropertyMatching does .replace(/\./g, ':') (fields-query-walker.ts:9), so author.name:asimov becomes the path author:name, which lenientGetSchema resolves through the ManyToOne β€” and extractSpecifiedFields is indeed called outside the extended branch, so no searchExtended is needed.

Against a real agent with authors removed from the BFF read-model, books exposed:

Path to the hidden collection's columns Result
POST /agent/v1/authors/list 404 unknown_collection
POST /agent/v1/books/relations/author/list 404 unknown_relation
filter: { field: 'author:name', operator: 'IContains' } 422 relation_field_not_supported
search: 'author.name:asimov' 200, ["Foundation","I, Robot"]

Search is the only one of the four doors open. The hidden data is not returned (projection stays on books), but it is a filtering oracle: with IContains you can extract a hidden string column character by character.

Doc fixed as you asked, on Search rather than SearchExtended, and stated as the mechanism rather than as a side effect of the flag: the value is a query, column:value narrows to one column, relation.column:value narrows to a related collection's column with no searchExtended, it escapes the 422 the same path draws in filter/sort/projection, and the agent resolves the relation against its own schema so a query can filter on a collection this BFF does not expose. SearchExtended now points at Search for that instead of claiming it.

Two integration tests pin the contrast so it cannot drift silently: the search crossing the relation, and the same path in a filter drawing 422.

On "worth deciding beyond the doc" β€” agreed, and I did not want to decide it inside this PR. Blocking it needs the relation paths extracted from the query string, which means running the agent's own parseQuery + extractSpecifiedFields BFF-side: a runtime dependency on datasource-customizer and ANTLR, plus keeping the BFF's reading of that grammar in step with the agent's. A regex over the query string is the tempting shortcut and the likely source of both false rejections and missed paths.

It also rests on a question I do not think this PR should answer alone: is the BFF exposure allow-list a security boundary, or a convenience that trims what a client sees? data-routes-middleware.ts:289-290 reads like the former, and every other route honours it, which is what makes this inconsistent rather than merely undocumented.

Filed as PRD-1037 with the evidence, both options (authorize the relation target against the read-model, vs reject any relation path) and that open question. If you read the allow-list as a real boundary, say so and I will treat PRD-1037 as a prerequisite to this merge rather than a follow-up.

"The agent's native full-text search, applied on top of `filter` rather than instead of it. " +
'An empty or whitespace-only value is treated as absent, so clearing a search box is not an ' +
'error. Searching a collection whose search is disabled is not rejected here: the agent ' +
'answers 400 validation_error with "Collection is not searchable". The response does not say ' +
'which field matched. The value is a query, not a plain term: `column:value` narrows the ' +
'search to one column, and `relation.column:value` narrows it to a column of a related ' +
'collection β€” so a search reaches relation fields on its own, with no `searchExtended`, and ' +
'escapes the 422 relation_field_not_supported that the same path draws in `filter`, `sort` or ' +
'`projection`. The agent resolves a relation named in a query against its own schema, so a ' +
'query can filter on a column of a collection this BFF does not expose.',
});

export const SearchExtendedSchema = z.boolean().openapi('SearchExtended', {
description:
'Widens `search` to every related collection reachable from this one, instead of only this ' +
"collection's own columns. Meaningless on its own: sent without `search` it is ignored and " +
'changes nothing. It is not the only way a search reaches a relation β€” see `Search` for the ' +
'`relation.column:value` syntax, which does so without this flag.',
});

export const ListRequestSchema = z
.object({
filter: ConditionTreeSchema.optional(),
projection: z.array(z.string()).optional(),
sort: z.array(SortClauseSchema).optional(),
page: PageSchema.optional(),
search: SearchSchema.optional(),
searchExtended: SearchExtendedSchema.optional(),
timezone: TimezoneSchema.optional(),
})
.openapi('ListRequest');

export const CountRequestSchema = z
.object({
filter: ConditionTreeSchema.optional(),
search: SearchSchema.optional(),
searchExtended: SearchExtendedSchema.optional(),
timezone: TimezoneSchema.optional(),
})
.openapi('CountRequest');
.openapi('CountRequest', {
description:
'Accepts the same search inputs as list, so a client can count exactly the rows its search ' +
'returns.',
});

const ParentIdSchema = z.union([z.string().regex(/\S/), z.number()]).openapi('ParentId', {
description:
Expand All @@ -88,7 +118,7 @@ export const RelationListRequestSchema = ListRequestSchema.extend({
parentId: ParentIdSchema,
}).openapi('RelationListRequest', {
description:
'Filter, sort and projection apply to the FOREIGN collection; the parent only resolves ' +
'Filter, sort, projection and search apply to the FOREIGN collection; the parent only resolves ' +
'which records are related.',
});

Expand Down
130 changes: 130 additions & 0 deletions packages/agent-bff/test/data/agent-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,84 @@ describe('buildCountAgentQuery', () => {
});
});

describe('search in the outgoing agent query', () => {
it('should send the search term under the wire name the agent reads', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { search: 'ada' })).toEqual({
timezone: 'Europe/Paris',
search: 'ada',
});
});

it('should send searchExtended under the wire name the agent reads', () => {
expect(
buildListAgentQuery('users', 'Europe/Paris', { search: 'ada', searchExtended: true }),
).toEqual({ timezone: 'Europe/Paris', search: 'ada', searchExtended: true });
});

it('should send searchExtended false when explicitly disabled alongside a search', () => {
expect(
buildListAgentQuery('users', 'Europe/Paris', { search: 'ada', searchExtended: false }),
).toEqual({ timezone: 'Europe/Paris', search: 'ada', searchExtended: false });
});

it('should send both the filter and the search so the agent intersects them', () => {
expect(
buildListAgentQuery('users', 'Europe/Paris', {
filter: { field: 'active', operator: 'equal', value: true },
search: 'ada',
}),
).toEqual({
timezone: 'Europe/Paris',
filters: JSON.stringify({ field: 'active', operator: 'equal', value: true }),
search: 'ada',
});
});

it('should treat an empty search as absent', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { search: '' })).toEqual({
timezone: 'Europe/Paris',
});
});

it('should treat a whitespace-only search as absent', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { search: ' ' })).toEqual({
timezone: 'Europe/Paris',
});
});

it('should not send searchExtended when it arrives without a search', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { searchExtended: true })).toEqual({
timezone: 'Europe/Paris',
});
});

it('should not send searchExtended when the search it accompanies is blank', () => {
expect(
buildListAgentQuery('users', 'Europe/Paris', { search: ' ', searchExtended: true }),
).toEqual({ timezone: 'Europe/Paris' });
});

it('should send the search term unchanged, including its inner spacing', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { search: 'ada lovelace' }).search).toBe(
'ada lovelace',
);
});

it('should accept the same search inputs on count as on list', () => {
expect(buildCountAgentQuery('Europe/Paris', { search: 'ada', searchExtended: true })).toEqual({
timezone: 'Europe/Paris',
search: 'ada',
searchExtended: true,
});
});

it('should leave the count query untouched when the search is blank', () => {
expect(buildCountAgentQuery('UTC', { search: ' ', searchExtended: true })).toEqual({
timezone: 'UTC',
});
});
});

describe('collectListFieldPaths', () => {
it('should collect field paths from projection, filter and sort', () => {
const paths = collectListFieldPaths({
Expand Down Expand Up @@ -113,6 +191,42 @@ describe('parseListRequest', () => {
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toBe(body) cannot fail: parseListRequest returns the same reference whatever it validates, so this only proves nothing threw. Assert on the built query instead (buildListAgentQuery(...).search), or drop the identity check for expect(() => parseListRequest(body)).not.toThrow(), which at least says what is being tested.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, parseListRequest returns body as ListRequestBody β€” the same reference whatever it validates, so the assertion was unfalsifiable. I took neither of your two options: toMatchObject({ search, searchExtended }) can actually fail, since it also proves the parser passes the fields through instead of stripping them. The values reaching the wire stay asserted in the builder describe.

Left the pre-existing toBe(body) on line 174 alone β€” same weakness, but not mine to change in this PR.

it('should pass search and searchExtended through rather than strip them', () => {
expect(parseListRequest({ search: 'ada', searchExtended: true })).toMatchObject({
search: 'ada',
searchExtended: true,
});
});

it('should accept a blank search rather than rejecting a cleared search box', () => {
expect(parseListRequest({ search: ' ' })).toMatchObject({ search: ' ' });
});

it.each([
['a non-string search', { search: 42 }],
['a null search', { search: null }],
['an array search', { search: ['ada'] }],
])('should reject %s with 400 invalid_request', (_label, body) => {
expect(() => parseListRequest(body)).toThrow(
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
});

it.each([
['the string "true"', { search: 'ada', searchExtended: 'true' }],
['the string "false"', { search: 'ada', searchExtended: 'false' }],
['the number 1', { search: 'ada', searchExtended: 1 }],
['the string "0"', { search: 'ada', searchExtended: '0' }],
['a null value', { search: 'ada', searchExtended: null }],
])(
'should reject searchExtended sent as %s rather than coercing it like the agent does',
(_label, body) => {
expect(() => parseListRequest(body)).toThrow(
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
},
);
});

describe('parseCountRequest', () => {
Expand All @@ -131,6 +245,22 @@ describe('parseCountRequest', () => {
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
});

it('should pass search and searchExtended through rather than strip them', () => {
expect(parseCountRequest({ search: 'ada', searchExtended: false })).toMatchObject({
search: 'ada',
searchExtended: false,
});
});

it.each([
['a non-string search', { search: 42 }],
['a non-boolean searchExtended', { search: 'ada', searchExtended: 'true' }],
])('should reject %s with 400 invalid_request', (_label, body) => {
expect(() => parseCountRequest(body)).toThrow(
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
});
});

describe('a filter node readable as both a leaf and a branch', () => {
Expand Down
Loading
Loading