From 65580d3f13a550c1c8879c48a06fe57d3ce2316a Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 28 Aug 2026 18:21:26 +0200 Subject: [PATCH 01/15] fix(agent-bff): reject unknown keys on list and count bodies --- packages/agent-bff/src/data/agent-query.ts | 34 +++++---- .../agent-bff/src/data/request-schemas.ts | 40 ++++++++-- packages/agent-bff/src/openapi/schemas.ts | 76 +++++++++++-------- .../agent-bff/src/openapi/unfolded-paths.ts | 16 +++- .../agent-bff/test/data/agent-query.test.ts | 74 ++++++++++++++++++ .../test/data/data-routes-middleware.test.ts | 17 +++++ .../test/openapi/openapi-document.test.ts | 47 ++++++++++++ .../test/openapi/openapi-unfolded.test.ts | 17 ++++- 8 files changed, 263 insertions(+), 58 deletions(-) diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts index 8be98697b0..ee43dd9e06 100644 --- a/packages/agent-bff/src/data/agent-query.ts +++ b/packages/agent-bff/src/data/agent-query.ts @@ -1,6 +1,11 @@ import type { ZodType } from 'zod'; -import { CountFlatInputs, ListFlatInputs } from './request-schemas'; +import { + CountFlatInputs, + ListFlatInputs, + RelationCountFlatInputs, + RelationListFlatInputs, +} from './request-schemas'; import { invalidRequest } from '../http/bff-local-errors'; import { MAX_FILTER_DEPTH, isBranch, isLeaf } from '../validation/capabilities-validator'; import { filterTooDeep } from '../validation/validation-errors'; @@ -64,8 +69,10 @@ function assertNoNodeReadableAsBothLeafAndBranch(node: unknown, depth = 0): void * 400 invalid_request, so a malformed shape (`projection` as a string, a fractional `page.limit`) * surfaces as a client error rather than a 500 from an array method blowing up downstream. * - * Unknown keys are left alone: the object schemas ignore them, so `filter`, `timezone` and - * `parentId` travel through untouched and the body is returned by reference, not rebuilt. + * The schemas are closed, so an undeclared key is reported the same way rather than stripped: a + * misspelled `filter` must not run the query unfiltered and answer 200. The declared keys — + * `filter`, `timezone`, and `parentId` on a relation — travel through untouched, and the body is + * returned by reference, not rebuilt. */ function assertFlatInputs(schema: ZodType, body: Record): void { const result = schema.safeParse(body); @@ -84,22 +91,21 @@ function assertFilter(filter: unknown): void { assertNoNodeReadableAsBothLeafAndBranch(filter); } -export function parseListRequest(body: unknown): ListRequestBody { +function parseRequest(schema: ZodType, body: unknown): T { if (!isPlainObject(body)) throw invalidRequest('Request body must be an object'); - assertFlatInputs(ListFlatInputs, body); + assertFlatInputs(schema, body); assertFilter(body.filter); - return body as ListRequestBody; + return body as T; } -export function parseCountRequest(body: unknown): CountRequestBody { - if (!isPlainObject(body)) throw invalidRequest('Request body must be an object'); - - assertFlatInputs(CountFlatInputs, body); - assertFilter(body.filter); +export function parseListRequest(body: unknown): ListRequestBody { + return parseRequest(ListFlatInputs, body); +} - return body as CountRequestBody; +export function parseCountRequest(body: unknown): CountRequestBody { + return parseRequest(CountFlatInputs, body); } function collectFilterFields(filter: unknown, acc: string[]): void { @@ -205,13 +211,13 @@ export function parseParentId(parentId: unknown): string { export function parseRelationListRequest(body: unknown): RelationListRequestBody { const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId); - return { ...parseListRequest(body), parentId }; + return { ...parseRequest(RelationListFlatInputs, body), parentId }; } export function parseRelationCountRequest(body: unknown): RelationCountRequestBody { const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId); - return { ...parseCountRequest(body), parentId }; + return { ...parseRequest(RelationCountFlatInputs, body), parentId }; } export function collectCountFieldPaths(body: CountRequestBody): string[] { diff --git a/packages/agent-bff/src/data/request-schemas.ts b/packages/agent-bff/src/data/request-schemas.ts index e474ebd45f..091a7d0507 100644 --- a/packages/agent-bff/src/data/request-schemas.ts +++ b/packages/agent-bff/src/data/request-schemas.ts @@ -5,16 +5,23 @@ import { z } from '../zod'; * with names and descriptions to publish them, and the parsers below check bodies against them, so * the document and the runtime cannot drift apart. * - * `filter` is deliberately absent: its operators are checked against the collection capabilities - * (`capabilities-validator`), and a node readable as both leaf and branch has no schema equivalent, - * so it keeps its own path in `agent-query.ts`. + * Every object here is closed: an undeclared key is a 400, never a silently stripped one. A + * misspelled `filter` used to run the query unfiltered and answer 200, and a misspelled `direction` + * used to sort ascending — a typo must not quietly change the returned rows. + * + * `filter` is declared but not described: its operators are checked against the collection + * capabilities (`capabilities-validator`), and a node readable as both leaf and branch has no + * schema equivalent, so its own path in `agent-query.ts` stays its only validation. The tree is + * therefore NOT closed — a leaf carrying `valu` instead of `value` still reaches the agent. Closing + * it is a separate change: the wire format of a condition node is the agent's, not this one's, and + * rejecting a node it would have accepted breaks a filter that works today. */ -export const SortClauseInput = z.object({ +export const SortClauseInput = z.strictObject({ field: z.string(), direction: z.enum(['asc', 'desc']).optional(), }); -export const PageInput = z.object({ +export const PageInput = z.strictObject({ limit: z.number().int().positive(), offset: z.number().int().nonnegative(), }); @@ -29,17 +36,34 @@ export const TimezoneInput = z.string(); export const ParentIdInput = z.union([z.string().regex(/\S/), z.number()]); -/** Everything a list body carries except `filter`, which is validated separately. */ -export const ListFlatInputs = z.object({ +/** + * `timezone` is only PERMITTED here. The timezone middleware reads it off the body before the route + * runs and falls back to the header or the deployment default when it is not a string + * (`timezone/timezone-middleware.ts`), so type-checking it a second time would turn a tolerated + * `null` into a 400 — a value this change has no reason to reject. + */ +export const ListFlatInputs = z.strictObject({ + filter: z.unknown().optional(), projection: ProjectionInput.optional(), sort: z.array(SortClauseInput).optional(), page: PageInput.optional(), search: SearchInput.optional(), searchExtended: SearchExtendedInput.optional(), + timezone: z.unknown().optional(), }); /** A count body carries no projection, sort or page. */ -export const CountFlatInputs = z.object({ +export const CountFlatInputs = z.strictObject({ + filter: z.unknown().optional(), search: SearchInput.optional(), searchExtended: SearchExtendedInput.optional(), + timezone: z.unknown().optional(), }); + +/** + * Same for `parentId`: `parseParentId` stays its single validator, so its message is the one a + * caller sees rather than a second, subtly different rule's. + */ +export const RelationListFlatInputs = ListFlatInputs.extend({ parentId: z.unknown().optional() }); + +export const RelationCountFlatInputs = CountFlatInputs.extend({ parentId: z.unknown().optional() }); diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 23dacfc4c6..1e0c6e1a80 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -85,48 +85,58 @@ export const SearchExtendedSchema = SearchExtendedInput.openapi('SearchExtended' '`relation.column:value` syntax, which does so without this flag.', }); -export const ListRequestSchema = z - .object({ - filter: ConditionTreeSchema.optional(), - projection: ProjectionInput.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', { - description: - 'Accepts the same search inputs as list, so a client can count exactly the rows its search ' + - 'returns.', - }); - const ParentIdSchema = ParentIdInput.openapi('ParentId', { description: 'The parent record id, opaque: a composite or packed id must be passed unchanged. A ' + 'blank string is rejected.', }); -export const RelationListRequestSchema = ListRequestSchema.extend({ - parentId: ParentIdSchema, -}).openapi('RelationListRequest', { +const countRequestShape = { + filter: ConditionTreeSchema.optional(), + search: SearchSchema.optional(), + searchExtended: SearchExtendedSchema.optional(), + timezone: TimezoneSchema.optional(), +}; + +const listRequestShape = { + ...countRequestShape, + projection: ProjectionInput.optional(), + sort: z.array(SortClauseSchema).optional(), + page: PageSchema.optional(), +}; + +const CLOSED_BODY_NOTE = + 'The body is closed at the TOP LEVEL: an undeclared top-level key is rejected with 400 ' + + 'invalid_request, so `filters` instead of `filter` cannot silently return unfiltered rows. ' + + 'Inside the filter tree the check stops there: an unknown key on a condition node is still ' + + 'forwarded to the agent, so a leaf carrying `valu` runs with no value rather than being rejected.'; + +export const ListRequestSchema = z + .strictObject(listRequestShape) + .openapi('ListRequest', { description: CLOSED_BODY_NOTE }); + +export const CountRequestSchema = z.strictObject(countRequestShape).openapi('CountRequest', { description: - 'Filter, sort, projection and search apply to the FOREIGN collection; the parent only resolves ' + - 'which records are related.', + 'Accepts the same search inputs as list, so a client can count exactly the rows its search ' + + `returns. ${CLOSED_BODY_NOTE}`, }); -export const RelationCountRequestSchema = CountRequestSchema.extend({ - parentId: ParentIdSchema, -}).openapi('RelationCountRequest'); +/** + * Spread from the same shapes rather than extended from the schemas above: extending a REGISTERED + * object publishes an `allOf` of the two, and a closed base then forbids `parentId` in the very + * component that adds it — an unsatisfiable body. + */ +export const RelationListRequestSchema = z + .strictObject({ ...listRequestShape, parentId: ParentIdSchema }) + .openapi('RelationListRequest', { + description: + 'Filter, sort, projection and search apply to the FOREIGN collection; the parent only ' + + `resolves which records are related. ${CLOSED_BODY_NOTE}`, + }); + +export const RelationCountRequestSchema = z + .strictObject({ ...countRequestShape, parentId: ParentIdSchema }) + .openapi('RelationCountRequest', { description: CLOSED_BODY_NOTE }); export const ActionRequestSchema = z .object({ diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index 5e1a1a7f0d..c311413e00 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -254,10 +254,22 @@ function fieldRefs(deps: Deps, plan: Pick) }; } +/** + * Stated in prose because the shape cannot say it here: these bodies compose with `allOf` for the + * relation paths, and `additionalProperties: false` on a composed branch forbids the very key the + * other branch adds. `oas31` has no `unevaluatedProperties` to express it either, so closing this + * document means flattening every relation request -- its own change. + */ +const CLOSED_BODY_NOTE = + 'The runtime rejects an undeclared TOP-LEVEL key with 400 invalid_request, so `filters` instead ' + + 'of `filter` is an error rather than a silently unfiltered result. This schema does not say so ' + + 'structurally yet: treat it as closed. Inside the filter tree the check stops there, and an ' + + 'unknown key on a condition node still reaches the agent.'; + function requestDescription(collection: UnfoldedCollection, subject: string): string { const note = collection.fields.degraded ? ` ${DEGRADED_NOTE[collection.fields.degraded]}` : ''; - return `${subject}${note}`; + return `${subject}${note} ${CLOSED_BODY_NOTE}`; } function registerRequests(deps: Deps, plan: Omit): RequestRefs { @@ -349,7 +361,7 @@ function registerRelationRequests( const description = `Filter, sort and projection apply to ${quoted(foreign.collection.name)}, the foreign ` + `collection of ${quoted(plan.collection.name)}.${quoted(relation.name)}; the parent only ` + - 'resolves which records are related.'; + `resolves which records are related. ${CLOSED_BODY_NOTE}`; return { list: pool.add(`RelationListRequest_${relationKey}`, { diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts index 0e48abc991..8f2c3190d0 100644 --- a/packages/agent-bff/test/data/agent-query.test.ts +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -395,6 +395,80 @@ describe('parseRelationCountRequest', () => { }); }); +describe('unknown keys', () => { + const REJECTED = expect.objectContaining({ type: 'invalid_request', status: 400 }); + + it.each([ + ['parseListRequest', parseListRequest], + ['parseCountRequest', parseCountRequest], + ])('should reject a misspelled filter on %s rather than run unfiltered', (_label, parse) => { + expect(() => parse({ filters: { field: 'plan', operator: 'Equal', value: 'free' } })).toThrow( + REJECTED, + ); + }); + + it.each([ + ['parseListRequest', parseListRequest], + ['parseCountRequest', parseCountRequest], + ])('should name the unknown key in the %s rejection', (_label, parse) => { + expect(() => parse({ totallyUnknownField: 123 })).toThrow( + expect.objectContaining({ message: expect.stringContaining('totallyUnknownField') }), + ); + }); + + it('should reject a misspelled projection rather than return every field', () => { + expect(() => parseListRequest({ projections: ['id'] })).toThrow(REJECTED); + }); + + it('should reject parentId on a plain list, where a parent id means nothing', () => { + expect(() => parseListRequest({ parentId: '7' })).toThrow(REJECTED); + }); + + it.each([ + ['parseRelationListRequest', parseRelationListRequest], + ['parseRelationCountRequest', parseRelationCountRequest], + ])('should reject a misspelled filter on %s', (_label, parse) => { + expect(() => parse({ parentId: '7', filters: { field: 'a', operator: 'present' } })).toThrow( + REJECTED, + ); + }); + + it.each([ + ['parseRelationListRequest', parseRelationListRequest], + ['parseRelationCountRequest', parseRelationCountRequest], + ])('should still accept parentId on %s', (_label, parse) => { + expect(parse({ parentId: '7' })).toMatchObject({ parentId: '7' }); + }); + + it.each([ + ['parseListRequest', parseListRequest], + ['parseCountRequest', parseCountRequest], + ])('should still accept the timezone the middleware reads on %s', (_label, parse) => { + expect(parse({ timezone: 'Europe/Paris' })).toMatchObject({ timezone: 'Europe/Paris' }); + }); + + // The middleware falls back to the header or the deployment default for a non-string timezone, + // so rejecting one here would break a client that serializes an unset field as null. + it.each([ + ['parseListRequest', parseListRequest], + ['parseCountRequest', parseCountRequest], + ])('should leave a null timezone to the middleware on %s', (_label, parse) => { + expect(parse({ timezone: null })).toMatchObject({ timezone: null }); + }); + + it('should reject a misspelled sort direction rather than sort ascending', () => { + expect(() => parseListRequest({ sort: [{ field: 'createdAt', direciton: 'desc' }] })).toThrow( + REJECTED, + ); + }); + + it('should reject an unknown key inside page', () => { + expect(() => parseListRequest({ page: { limit: 10, offset: 0, cursor: 'x' } })).toThrow( + REJECTED, + ); + }); +}); + describe('collectCountFieldPaths', () => { it('should collect field paths from the filter only', () => { const paths = collectCountFieldPaths({ diff --git a/packages/agent-bff/test/data/data-routes-middleware.test.ts b/packages/agent-bff/test/data/data-routes-middleware.test.ts index f52e5bb466..a13974706b 100644 --- a/packages/agent-bff/test/data/data-routes-middleware.test.ts +++ b/packages/agent-bff/test/data/data-routes-middleware.test.ts @@ -384,6 +384,23 @@ describe('data routes middleware', () => { expect(list).not.toHaveBeenCalled(); }); + it('should return 400 for a misspelled filter instead of listing every record', async () => { + const list = jest.fn(); + const app = buildApp(storeOf(usersReadModel), { list }); + + const response = await request(app.callback()) + .post('/agent/v1/users/list') + .send({ filters: { field: 'email', operator: 'present' } }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ + type: 'invalid_request', + status: 400, + message: expect.stringContaining('filters'), + }); + expect(list).not.toHaveBeenCalled(); + }); + it('should expose a Number primary key as a number in __forest.primaryKey', async () => { const numericPkReadModel = new ReadModel([ collection('metrics', [{ ...column('id'), type: 'Number' }]), diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index eb62e23b72..655a937e8e 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -1,5 +1,11 @@ import { allOperators } from '@forestadmin/datasource-toolkit'; +import { + CountFlatInputs, + ListFlatInputs, + RelationCountFlatInputs, + RelationListFlatInputs, +} from '../../src/data/request-schemas'; import { OPENAPI_VERSION, ROUTE_PREFIX, @@ -461,6 +467,47 @@ describe('generateOpenApiDocument', () => { }); }); +describe('the closed request bodies', () => { + // The runtime rejects an undeclared key, so the document is only honest while it publishes the + // SAME key set. The two live in different files by design, and nothing but this check couples + // them: a key added to one and forgotten in the other makes the document lie about a 400. + it.each([ + ['ListRequest', ListFlatInputs], + ['CountRequest', CountFlatInputs], + ['RelationListRequest', RelationListFlatInputs], + ['RelationCountRequest', RelationCountFlatInputs], + ])('should publish exactly the keys %s accepts', (name, schema) => { + const published = Object.keys( + (schemas[name] as { properties: Record }).properties, + ).sort(); + + expect(published).toEqual(Object.keys(schema.shape).sort()); + }); + + it.each([ + ['ListRequest'], + ['CountRequest'], + ['RelationListRequest'], + ['RelationCountRequest'], + ['SortClause'], + ['Page'], + ])('should forbid an undeclared key on %s', name => { + expect(schemas[name].additionalProperties).toBe(false); + }); + + // Flat, not `allOf`: a closed base composed with the component that adds `parentId` forbids that + // very key, and the published body becomes unsatisfiable. + it.each([['RelationListRequest'], ['RelationCountRequest']])( + 'should publish %s flat rather than as an allOf of a closed base', + name => { + expect(schemas[name].allOf).toBeUndefined(); + expect( + (schemas[name] as { properties: Record }).properties.parentId, + ).toEqual({ $ref: '#/components/schemas/ParentId' }); + }, + ); +}); + describe('the documented search inputs', () => { function propertiesOf(name: string): Record { return (schemas[name] as { properties: Record }).properties; diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index 1c9de502d1..e179d17243 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -217,12 +217,27 @@ describe('the unfolded document', () => { expect(branch.not.properties.field.type).toBe('string'); }); - it('should not forbid an unknown extra key on a filter node, which the runtime strips', () => { + it('should not forbid an unknown extra key on a filter node, which the runtime forwards', () => { const leaf = schemas['FilterLeaf_My_Coll-1'] as { additionalProperties?: unknown }; expect(leaf.additionalProperties).toBeUndefined(); }); + // This document composes relation bodies with `allOf`, where `additionalProperties: false` on one + // branch forbids the key the other adds, so the closed shape is stated in prose until the relation + // requests are flattened. A reader must not conclude from the missing keyword that a stray + // top-level key is accepted. + it.each([ + ['My%20Coll/list'], + ['My%20Coll/count'], + ['My%20Coll/relations/orders/list'], + ['My%20Coll/relations/orders/count'], + ])('should say on %s that an undeclared top-level key is rejected', path => { + const request = requestSchema(path) as unknown as { description: string }; + + expect(request.description).toContain('undeclared TOP-LEVEL key with 400 invalid_request'); + }); + it('should describe a relation request with the FOREIGN collection fields, not the parent ones', () => { const request = requestSchema('My%20Coll/relations/orders/list') as unknown as { allOf: [{ $ref: string }, Record]; From 376f125fe7b8f342e954161c30a70d67f8c74c44 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 28 Aug 2026 19:37:57 +0200 Subject: [PATCH 02/15] refactor(agent-bff): make the request shape single-source --- packages/agent-bff/src/data/agent-query.ts | 48 +++++------- .../agent-bff/src/data/request-schemas.ts | 39 +++++----- packages/agent-bff/src/openapi/schemas.ts | 74 +++++++++++-------- .../agent-bff/src/openapi/unfolded-paths.ts | 19 ++--- .../agent-bff/test/data/agent-query.test.ts | 65 ++++++++-------- .../test/openapi/openapi-document.test.ts | 22 ------ 6 files changed, 116 insertions(+), 151 deletions(-) diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts index ee43dd9e06..eddfb0dd2d 100644 --- a/packages/agent-bff/src/data/agent-query.ts +++ b/packages/agent-bff/src/data/agent-query.ts @@ -1,4 +1,5 @@ -import type { ZodType } from 'zod'; +import type { PageInput, SortClauseInput } from './request-schemas'; +import type { ZodType, z } from 'zod'; import { CountFlatInputs, @@ -12,34 +13,21 @@ import { filterTooDeep } from '../validation/validation-errors'; export { MAX_FILTER_DEPTH as MAX_PARSED_FILTER_DEPTH }; -export interface BffSortClause { - field: string; - direction?: 'asc' | 'desc'; -} +// Inferred, never transcribed: a hand-written interface drifts from the schema that actually +// accepts the body, and the parsers below return that body by reference. +export type BffSortClause = z.infer; -export interface BffPage { - limit: number; - offset: number; -} +export type BffPage = z.infer; -export interface ListRequestBody { - filter?: unknown; - projection?: string[]; - sort?: BffSortClause[]; - page?: BffPage; - search?: string; - searchExtended?: boolean; -} +export type ListRequestBody = z.infer; -export interface CountRequestBody { - filter?: unknown; - search?: string; - searchExtended?: boolean; -} +export type CountRequestBody = z.infer; -export type RelationListRequestBody = ListRequestBody & { parentId: string }; +export type RelationListRequestBody = z.infer & { parentId: string }; -export type RelationCountRequestBody = CountRequestBody & { parentId: string }; +export type RelationCountRequestBody = z.infer & { + parentId: string; +}; export type AgentQuery = Record & { timezone: string }; @@ -91,21 +79,21 @@ function assertFilter(filter: unknown): void { assertNoNodeReadableAsBothLeafAndBranch(filter); } -function parseRequest(schema: ZodType, body: unknown): T { +function parseRequest(schema: S, body: unknown): z.output { if (!isPlainObject(body)) throw invalidRequest('Request body must be an object'); assertFlatInputs(schema, body); assertFilter(body.filter); - return body as T; + return body as z.output; } export function parseListRequest(body: unknown): ListRequestBody { - return parseRequest(ListFlatInputs, body); + return parseRequest(ListFlatInputs, body); } export function parseCountRequest(body: unknown): CountRequestBody { - return parseRequest(CountFlatInputs, body); + return parseRequest(CountFlatInputs, body); } function collectFilterFields(filter: unknown, acc: string[]): void { @@ -211,13 +199,13 @@ export function parseParentId(parentId: unknown): string { export function parseRelationListRequest(body: unknown): RelationListRequestBody { const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId); - return { ...parseRequest(RelationListFlatInputs, body), parentId }; + return { ...parseRequest(RelationListFlatInputs, body), parentId }; } export function parseRelationCountRequest(body: unknown): RelationCountRequestBody { const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId); - return { ...parseRequest(RelationCountFlatInputs, body), parentId }; + return { ...parseRequest(RelationCountFlatInputs, body), parentId }; } export function collectCountFieldPaths(body: CountRequestBody): string[] { diff --git a/packages/agent-bff/src/data/request-schemas.ts b/packages/agent-bff/src/data/request-schemas.ts index 091a7d0507..215136cb8c 100644 --- a/packages/agent-bff/src/data/request-schemas.ts +++ b/packages/agent-bff/src/data/request-schemas.ts @@ -2,19 +2,12 @@ import { z } from '../zod'; /** * The validation rules of a request body, declared once. `src/openapi/schemas.ts` decorates these - * with names and descriptions to publish them, and the parsers below check bodies against them, so + * with names and descriptions to publish them, and derives the published components from them, so * the document and the runtime cannot drift apart. * * Every object here is closed: an undeclared key is a 400, never a silently stripped one. A * misspelled `filter` used to run the query unfiltered and answer 200, and a misspelled `direction` * used to sort ascending — a typo must not quietly change the returned rows. - * - * `filter` is declared but not described: its operators are checked against the collection - * capabilities (`capabilities-validator`), and a node readable as both leaf and branch has no - * schema equivalent, so its own path in `agent-query.ts` stays its only validation. The tree is - * therefore NOT closed — a leaf carrying `valu` instead of `value` still reaches the agent. Closing - * it is a separate change: the wire format of a condition node is the agent's, not this one's, and - * rejecting a node it would have accepted breaks a filter that works today. */ export const SortClauseInput = z.strictObject({ field: z.string(), @@ -37,33 +30,35 @@ export const TimezoneInput = z.string(); export const ParentIdInput = z.union([z.string().regex(/\S/), z.number()]); /** - * `timezone` is only PERMITTED here. The timezone middleware reads it off the body before the route - * runs and falls back to the header or the deployment default when it is not a string - * (`timezone/timezone-middleware.ts`), so type-checking it a second time would turn a tolerated - * `null` into a 400 — a value this change has no reason to reject. + * Permitted here, checked by its own owner: `filter` by `assertFilter` and the capabilities + * validator, `timezone` by the timezone middleware, `parentId` by `parseParentId`. A second rule + * here would only disagree with the first at the margins — the middleware falls back to the header + * for a non-string `timezone`, so re-typing it would turn a tolerated `null` into a 400. + * + * The filter TREE is not closed either: a leaf carrying `valu` instead of `value` still reaches the + * agent. Closing it is a separate change — a condition node's wire format is the agent's, not this + * one's, and rejecting a node it would have accepted breaks a filter that works today. */ +const validatedElsewhere = z.unknown().optional(); + export const ListFlatInputs = z.strictObject({ - filter: z.unknown().optional(), + filter: validatedElsewhere, projection: ProjectionInput.optional(), sort: z.array(SortClauseInput).optional(), page: PageInput.optional(), search: SearchInput.optional(), searchExtended: SearchExtendedInput.optional(), - timezone: z.unknown().optional(), + timezone: validatedElsewhere, }); /** A count body carries no projection, sort or page. */ export const CountFlatInputs = z.strictObject({ - filter: z.unknown().optional(), + filter: validatedElsewhere, search: SearchInput.optional(), searchExtended: SearchExtendedInput.optional(), - timezone: z.unknown().optional(), + timezone: validatedElsewhere, }); -/** - * Same for `parentId`: `parseParentId` stays its single validator, so its message is the one a - * caller sees rather than a second, subtly different rule's. - */ -export const RelationListFlatInputs = ListFlatInputs.extend({ parentId: z.unknown().optional() }); +export const RelationListFlatInputs = ListFlatInputs.extend({ parentId: validatedElsewhere }); -export const RelationCountFlatInputs = CountFlatInputs.extend({ parentId: z.unknown().optional() }); +export const RelationCountFlatInputs = CountFlatInputs.extend({ parentId: validatedElsewhere }); diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 1e0c6e1a80..93326447fc 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -2,9 +2,12 @@ import { allOperators } from '@forestadmin/datasource-toolkit'; import { z } from './zod-openapi'; import { + CountFlatInputs, + ListFlatInputs, PageInput, ParentIdInput, - ProjectionInput, + RelationCountFlatInputs, + RelationListFlatInputs, SearchExtendedInput, SearchInput, SortClauseInput, @@ -91,52 +94,59 @@ const ParentIdSchema = ParentIdInput.openapi('ParentId', { 'blank string is rejected.', }); -const countRequestShape = { +/** Stated once, published on every request component and reused by the unfolded document. */ +export const CLOSED_BODY_NOTE = + 'The runtime rejects an undeclared TOP-LEVEL key with 400 invalid_request, so `filters` instead ' + + 'of `filter` is an error rather than a silently unfiltered result. Inside the filter tree the ' + + 'check stops there: an unknown key on a condition node still reaches the agent, so a leaf ' + + 'carrying `valu` runs with no value rather than being rejected.'; + +/** + * Overrides on top of the runtime schemas, not a second declaration of the same key set: a key + * added to `ListFlatInputs` publishes here on its own, and `satisfies` refuses a decoration for a + * key the runtime does not accept. The keys absent from this list are published as the runtime + * declares them. + * + * Extending is safe here only because the runtime schemas are NOT registered. Extending a + * REGISTERED object publishes an `allOf` of the two, and a closed base then forbids `parentId` in + * the very component that adds it — an unsatisfiable body. + */ +const countOverrides = { filter: ConditionTreeSchema.optional(), search: SearchSchema.optional(), searchExtended: SearchExtendedSchema.optional(), timezone: TimezoneSchema.optional(), -}; +} satisfies Partial>; -const listRequestShape = { - ...countRequestShape, - projection: ProjectionInput.optional(), +const listOverrides = { + ...countOverrides, sort: z.array(SortClauseSchema).optional(), page: PageSchema.optional(), -}; +} satisfies Partial>; -const CLOSED_BODY_NOTE = - 'The body is closed at the TOP LEVEL: an undeclared top-level key is rejected with 400 ' + - 'invalid_request, so `filters` instead of `filter` cannot silently return unfiltered rows. ' + - 'Inside the filter tree the check stops there: an unknown key on a condition node is still ' + - 'forwarded to the agent, so a leaf carrying `valu` runs with no value rather than being rejected.'; - -export const ListRequestSchema = z - .strictObject(listRequestShape) - .openapi('ListRequest', { description: CLOSED_BODY_NOTE }); +export const ListRequestSchema = ListFlatInputs.extend(listOverrides).openapi('ListRequest', { + description: CLOSED_BODY_NOTE, +}); -export const CountRequestSchema = z.strictObject(countRequestShape).openapi('CountRequest', { +export const CountRequestSchema = CountFlatInputs.extend(countOverrides).openapi('CountRequest', { description: 'Accepts the same search inputs as list, so a client can count exactly the rows its search ' + `returns. ${CLOSED_BODY_NOTE}`, }); -/** - * Spread from the same shapes rather than extended from the schemas above: extending a REGISTERED - * object publishes an `allOf` of the two, and a closed base then forbids `parentId` in the very - * component that adds it — an unsatisfiable body. - */ -export const RelationListRequestSchema = z - .strictObject({ ...listRequestShape, parentId: ParentIdSchema }) - .openapi('RelationListRequest', { - description: - 'Filter, sort, projection and search apply to the FOREIGN collection; the parent only ' + - `resolves which records are related. ${CLOSED_BODY_NOTE}`, - }); +export const RelationListRequestSchema = RelationListFlatInputs.extend({ + ...listOverrides, + parentId: ParentIdSchema, +}).openapi('RelationListRequest', { + description: + 'Filter, sort, projection and search apply to the FOREIGN collection; the parent only ' + + `resolves which records are related. ${CLOSED_BODY_NOTE}`, +}); -export const RelationCountRequestSchema = z - .strictObject({ ...countRequestShape, parentId: ParentIdSchema }) - .openapi('RelationCountRequest', { description: CLOSED_BODY_NOTE }); +export const RelationCountRequestSchema = RelationCountFlatInputs.extend({ + ...countOverrides, + parentId: ParentIdSchema, +}).openapi('RelationCountRequest', { description: CLOSED_BODY_NOTE }); export const ActionRequestSchema = z .object({ diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index c311413e00..5d458ca977 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -16,6 +16,7 @@ import type { ReferenceObject, SchemaObject } from 'openapi3-ts/oas31'; import toFieldSchema from './field-schemas'; import createNamer from './names'; import { + CLOSED_BODY_NOTE, CountResponseSchema, ListResponseSchema, OPERATORS, @@ -255,21 +256,17 @@ function fieldRefs(deps: Deps, plan: Pick) } /** - * Stated in prose because the shape cannot say it here: these bodies compose with `allOf` for the - * relation paths, and `additionalProperties: false` on a composed branch forbids the very key the - * other branch adds. `oas31` has no `unevaluatedProperties` to express it either, so closing this - * document means flattening every relation request -- its own change. + * The same rule as the folded document, minus the keyword that states it: these bodies compose with + * `allOf` for the relation paths, and `additionalProperties: false` on a composed branch forbids + * the very key the other branch adds. `oas31` has no `unevaluatedProperties` to express it either, + * so closing this document means flattening every relation request -- its own change. */ -const CLOSED_BODY_NOTE = - 'The runtime rejects an undeclared TOP-LEVEL key with 400 invalid_request, so `filters` instead ' + - 'of `filter` is an error rather than a silently unfiltered result. This schema does not say so ' + - 'structurally yet: treat it as closed. Inside the filter tree the check stops there, and an ' + - 'unknown key on a condition node still reaches the agent.'; +const CLOSED_BODY_PROSE_ONLY = `${CLOSED_BODY_NOTE} This schema does not say so structurally yet: treat it as closed.`; function requestDescription(collection: UnfoldedCollection, subject: string): string { const note = collection.fields.degraded ? ` ${DEGRADED_NOTE[collection.fields.degraded]}` : ''; - return `${subject}${note} ${CLOSED_BODY_NOTE}`; + return `${subject}${note} ${CLOSED_BODY_PROSE_ONLY}`; } function registerRequests(deps: Deps, plan: Omit): RequestRefs { @@ -361,7 +358,7 @@ function registerRelationRequests( const description = `Filter, sort and projection apply to ${quoted(foreign.collection.name)}, the foreign ` + `collection of ${quoted(plan.collection.name)}.${quoted(relation.name)}; the parent only ` + - `resolves which records are related. ${CLOSED_BODY_NOTE}`; + `resolves which records are related. ${CLOSED_BODY_PROSE_ONLY}`; return { list: pool.add(`RelationListRequest_${relationKey}`, { diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts index 8f2c3190d0..652c8a16bf 100644 --- a/packages/agent-bff/test/data/agent-query.test.ts +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -11,6 +11,18 @@ import { parseRelationListRequest, } from '../../src/data/agent-query'; +type Parser = [string, (body: unknown) => unknown]; + +const FLAT_PARSERS: Parser[] = [ + ['parseListRequest', parseListRequest], + ['parseCountRequest', parseCountRequest], +]; + +const RELATION_PARSERS: Parser[] = [ + ['parseRelationListRequest', parseRelationListRequest], + ['parseRelationCountRequest', parseRelationCountRequest], +]; + describe('buildListAgentQuery', () => { it('should always pass the resolved timezone', () => { expect(buildListAgentQuery('users', 'America/New_York', {})).toEqual({ @@ -286,10 +298,7 @@ describe('a filter node readable as both a leaf and a branch', () => { conditions: [], }; - it.each([ - ['parseListRequest', parseListRequest], - ['parseCountRequest', parseCountRequest], - ])('should reject it in %s with 400 invalid_request', (_label, parse) => { + it.each(FLAT_PARSERS)('should reject it in %s with 400 invalid_request', (_label, parse) => { expect(() => parse({ filter: READABLE_AS_BOTH })).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); @@ -398,19 +407,16 @@ describe('parseRelationCountRequest', () => { describe('unknown keys', () => { const REJECTED = expect.objectContaining({ type: 'invalid_request', status: 400 }); - it.each([ - ['parseListRequest', parseListRequest], - ['parseCountRequest', parseCountRequest], - ])('should reject a misspelled filter on %s rather than run unfiltered', (_label, parse) => { - expect(() => parse({ filters: { field: 'plan', operator: 'Equal', value: 'free' } })).toThrow( - REJECTED, - ); - }); + it.each(FLAT_PARSERS)( + 'should reject a misspelled filter on %s rather than run unfiltered', + (_label, parse) => { + expect(() => parse({ filters: { field: 'plan', operator: 'Equal', value: 'free' } })).toThrow( + REJECTED, + ); + }, + ); - it.each([ - ['parseListRequest', parseListRequest], - ['parseCountRequest', parseCountRequest], - ])('should name the unknown key in the %s rejection', (_label, parse) => { + it.each(FLAT_PARSERS)('should name the unknown key in the %s rejection', (_label, parse) => { expect(() => parse({ totallyUnknownField: 123 })).toThrow( expect.objectContaining({ message: expect.stringContaining('totallyUnknownField') }), ); @@ -424,35 +430,26 @@ describe('unknown keys', () => { expect(() => parseListRequest({ parentId: '7' })).toThrow(REJECTED); }); - it.each([ - ['parseRelationListRequest', parseRelationListRequest], - ['parseRelationCountRequest', parseRelationCountRequest], - ])('should reject a misspelled filter on %s', (_label, parse) => { + it.each(RELATION_PARSERS)('should reject a misspelled filter on %s', (_label, parse) => { expect(() => parse({ parentId: '7', filters: { field: 'a', operator: 'present' } })).toThrow( REJECTED, ); }); - it.each([ - ['parseRelationListRequest', parseRelationListRequest], - ['parseRelationCountRequest', parseRelationCountRequest], - ])('should still accept parentId on %s', (_label, parse) => { + it.each(RELATION_PARSERS)('should still accept parentId on %s', (_label, parse) => { expect(parse({ parentId: '7' })).toMatchObject({ parentId: '7' }); }); - it.each([ - ['parseListRequest', parseListRequest], - ['parseCountRequest', parseCountRequest], - ])('should still accept the timezone the middleware reads on %s', (_label, parse) => { - expect(parse({ timezone: 'Europe/Paris' })).toMatchObject({ timezone: 'Europe/Paris' }); - }); + it.each(FLAT_PARSERS)( + 'should still accept the timezone the middleware reads on %s', + (_label, parse) => { + expect(parse({ timezone: 'Europe/Paris' })).toMatchObject({ timezone: 'Europe/Paris' }); + }, + ); // The middleware falls back to the header or the deployment default for a non-string timezone, // so rejecting one here would break a client that serializes an unset field as null. - it.each([ - ['parseListRequest', parseListRequest], - ['parseCountRequest', parseCountRequest], - ])('should leave a null timezone to the middleware on %s', (_label, parse) => { + it.each(FLAT_PARSERS)('should leave a null timezone to the middleware on %s', (_label, parse) => { expect(parse({ timezone: null })).toMatchObject({ timezone: null }); }); diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index 655a937e8e..e0a3daa734 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -1,11 +1,5 @@ import { allOperators } from '@forestadmin/datasource-toolkit'; -import { - CountFlatInputs, - ListFlatInputs, - RelationCountFlatInputs, - RelationListFlatInputs, -} from '../../src/data/request-schemas'; import { OPENAPI_VERSION, ROUTE_PREFIX, @@ -468,22 +462,6 @@ describe('generateOpenApiDocument', () => { }); describe('the closed request bodies', () => { - // The runtime rejects an undeclared key, so the document is only honest while it publishes the - // SAME key set. The two live in different files by design, and nothing but this check couples - // them: a key added to one and forgotten in the other makes the document lie about a 400. - it.each([ - ['ListRequest', ListFlatInputs], - ['CountRequest', CountFlatInputs], - ['RelationListRequest', RelationListFlatInputs], - ['RelationCountRequest', RelationCountFlatInputs], - ])('should publish exactly the keys %s accepts', (name, schema) => { - const published = Object.keys( - (schemas[name] as { properties: Record }).properties, - ).sort(); - - expect(published).toEqual(Object.keys(schema.shape).sort()); - }); - it.each([ ['ListRequest'], ['CountRequest'], From 4f86fcb385719c1b4bca89d2c45e521749f97303 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 28 Aug 2026 19:58:24 +0200 Subject: [PATCH 03/15] refactor(agent-bff): guard relation doc overrides against stray keys --- packages/agent-bff/src/data/agent-query.ts | 12 ------- .../agent-bff/src/data/request-schemas.ts | 19 ------------ packages/agent-bff/src/openapi/schemas.ts | 31 +++++++++---------- .../agent-bff/src/openapi/unfolded-paths.ts | 6 ---- .../agent-bff/test/data/agent-query.test.ts | 2 -- .../test/openapi/openapi-document.test.ts | 2 -- .../test/openapi/openapi-unfolded.test.ts | 4 --- 7 files changed, 14 insertions(+), 62 deletions(-) diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts index eddfb0dd2d..954485229c 100644 --- a/packages/agent-bff/src/data/agent-query.ts +++ b/packages/agent-bff/src/data/agent-query.ts @@ -13,8 +13,6 @@ import { filterTooDeep } from '../validation/validation-errors'; export { MAX_FILTER_DEPTH as MAX_PARSED_FILTER_DEPTH }; -// Inferred, never transcribed: a hand-written interface drifts from the schema that actually -// accepts the body, and the parsers below return that body by reference. export type BffSortClause = z.infer; export type BffPage = z.infer; @@ -52,16 +50,6 @@ function assertNoNodeReadableAsBothLeafAndBranch(node: unknown, depth = 0): void } } -/** - * Checks the flat inputs against the shared schema and reports the first failure as - * 400 invalid_request, so a malformed shape (`projection` as a string, a fractional `page.limit`) - * surfaces as a client error rather than a 500 from an array method blowing up downstream. - * - * The schemas are closed, so an undeclared key is reported the same way rather than stripped: a - * misspelled `filter` must not run the query unfiltered and answer 200. The declared keys — - * `filter`, `timezone`, and `parentId` on a relation — travel through untouched, and the body is - * returned by reference, not rebuilt. - */ function assertFlatInputs(schema: ZodType, body: Record): void { const result = schema.safeParse(body); if (result.success) return; diff --git a/packages/agent-bff/src/data/request-schemas.ts b/packages/agent-bff/src/data/request-schemas.ts index 215136cb8c..c4559aa751 100644 --- a/packages/agent-bff/src/data/request-schemas.ts +++ b/packages/agent-bff/src/data/request-schemas.ts @@ -1,14 +1,5 @@ import { z } from '../zod'; -/** - * The validation rules of a request body, declared once. `src/openapi/schemas.ts` decorates these - * with names and descriptions to publish them, and derives the published components from them, so - * the document and the runtime cannot drift apart. - * - * Every object here is closed: an undeclared key is a 400, never a silently stripped one. A - * misspelled `filter` used to run the query unfiltered and answer 200, and a misspelled `direction` - * used to sort ascending — a typo must not quietly change the returned rows. - */ export const SortClauseInput = z.strictObject({ field: z.string(), direction: z.enum(['asc', 'desc']).optional(), @@ -29,16 +20,6 @@ export const TimezoneInput = z.string(); export const ParentIdInput = z.union([z.string().regex(/\S/), z.number()]); -/** - * Permitted here, checked by its own owner: `filter` by `assertFilter` and the capabilities - * validator, `timezone` by the timezone middleware, `parentId` by `parseParentId`. A second rule - * here would only disagree with the first at the margins — the middleware falls back to the header - * for a non-string `timezone`, so re-typing it would turn a tolerated `null` into a 400. - * - * The filter TREE is not closed either: a leaf carrying `valu` instead of `value` still reaches the - * agent. Closing it is a separate change — a condition node's wire format is the agent's, not this - * one's, and rejecting a node it would have accepted breaks a filter that works today. - */ const validatedElsewhere = z.unknown().optional(); export const ListFlatInputs = z.strictObject({ diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 93326447fc..8385a1ad37 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -94,23 +94,12 @@ const ParentIdSchema = ParentIdInput.openapi('ParentId', { 'blank string is rejected.', }); -/** Stated once, published on every request component and reused by the unfolded document. */ export const CLOSED_BODY_NOTE = 'The runtime rejects an undeclared TOP-LEVEL key with 400 invalid_request, so `filters` instead ' + 'of `filter` is an error rather than a silently unfiltered result. Inside the filter tree the ' + 'check stops there: an unknown key on a condition node still reaches the agent, so a leaf ' + 'carrying `valu` runs with no value rather than being rejected.'; -/** - * Overrides on top of the runtime schemas, not a second declaration of the same key set: a key - * added to `ListFlatInputs` publishes here on its own, and `satisfies` refuses a decoration for a - * key the runtime does not accept. The keys absent from this list are published as the runtime - * declares them. - * - * Extending is safe here only because the runtime schemas are NOT registered. Extending a - * REGISTERED object publishes an `allOf` of the two, and a closed base then forbids `parentId` in - * the very component that adds it — an unsatisfiable body. - */ const countOverrides = { filter: ConditionTreeSchema.optional(), search: SearchSchema.optional(), @@ -134,19 +123,27 @@ export const CountRequestSchema = CountFlatInputs.extend(countOverrides).openapi `returns. ${CLOSED_BODY_NOTE}`, }); -export const RelationListRequestSchema = RelationListFlatInputs.extend({ +const relationListOverrides = { ...listOverrides, parentId: ParentIdSchema, -}).openapi('RelationListRequest', { +} satisfies Partial>; + +const relationCountOverrides = { + ...countOverrides, + parentId: ParentIdSchema, +} satisfies Partial>; + +export const RelationListRequestSchema = RelationListFlatInputs.extend( + relationListOverrides, +).openapi('RelationListRequest', { description: 'Filter, sort, projection and search apply to the FOREIGN collection; the parent only ' + `resolves which records are related. ${CLOSED_BODY_NOTE}`, }); -export const RelationCountRequestSchema = RelationCountFlatInputs.extend({ - ...countOverrides, - parentId: ParentIdSchema, -}).openapi('RelationCountRequest', { description: CLOSED_BODY_NOTE }); +export const RelationCountRequestSchema = RelationCountFlatInputs.extend( + relationCountOverrides, +).openapi('RelationCountRequest', { description: CLOSED_BODY_NOTE }); export const ActionRequestSchema = z .object({ diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index 5d458ca977..90b01a9f15 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -255,12 +255,6 @@ function fieldRefs(deps: Deps, plan: Pick) }; } -/** - * The same rule as the folded document, minus the keyword that states it: these bodies compose with - * `allOf` for the relation paths, and `additionalProperties: false` on a composed branch forbids - * the very key the other branch adds. `oas31` has no `unevaluatedProperties` to express it either, - * so closing this document means flattening every relation request -- its own change. - */ const CLOSED_BODY_PROSE_ONLY = `${CLOSED_BODY_NOTE} This schema does not say so structurally yet: treat it as closed.`; function requestDescription(collection: UnfoldedCollection, subject: string): string { diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts index 652c8a16bf..284c01b963 100644 --- a/packages/agent-bff/test/data/agent-query.test.ts +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -447,8 +447,6 @@ describe('unknown keys', () => { }, ); - // The middleware falls back to the header or the deployment default for a non-string timezone, - // so rejecting one here would break a client that serializes an unset field as null. it.each(FLAT_PARSERS)('should leave a null timezone to the middleware on %s', (_label, parse) => { expect(parse({ timezone: null })).toMatchObject({ timezone: null }); }); diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index e0a3daa734..2a1d24d86b 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -473,8 +473,6 @@ describe('the closed request bodies', () => { expect(schemas[name].additionalProperties).toBe(false); }); - // Flat, not `allOf`: a closed base composed with the component that adds `parentId` forbids that - // very key, and the published body becomes unsatisfiable. it.each([['RelationListRequest'], ['RelationCountRequest']])( 'should publish %s flat rather than as an allOf of a closed base', name => { diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index e179d17243..b5b9ab29f5 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -223,10 +223,6 @@ describe('the unfolded document', () => { expect(leaf.additionalProperties).toBeUndefined(); }); - // This document composes relation bodies with `allOf`, where `additionalProperties: false` on one - // branch forbids the key the other adds, so the closed shape is stated in prose until the relation - // requests are flattened. A reader must not conclude from the missing keyword that a stray - // top-level key is accepted. it.each([ ['My%20Coll/list'], ['My%20Coll/count'], From f7241bd52940d1f69a576258178b219ce09eb317 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Mon, 31 Aug 2026 11:38:25 +0200 Subject: [PATCH 04/15] fix(agent-bff): publish search on the unfolded list and count bodies --- packages/agent-bff/src/openapi/unfolded-paths.ts | 8 +++++++- .../test/openapi/openapi-unfolded.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index 90b01a9f15..d138884f36 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -22,6 +22,8 @@ import { OPERATORS, PageSchema, ParentIdSchema, + SearchExtendedSchema, + SearchSchema, SortClauseSchema, TimezoneSchema, } from './schemas'; @@ -268,6 +270,8 @@ function registerRequests(deps: Deps, plan: Omit): R const { collection, key } = plan; const refs = fieldRefs(deps, plan); const timezone = pool.reuse('Timezone', TimezoneSchema); + const search = pool.reuse('Search', SearchSchema); + const searchExtended = pool.reuse('SearchExtended', SearchExtendedSchema); return { list: pool.add(`ListRequest_${key}`, { @@ -281,6 +285,8 @@ function registerRequests(deps: Deps, plan: Omit): R projection: { type: 'array', items: refs.projectable }, sort: { type: 'array', items: refs.sort }, page: pool.reuse('Page', PageSchema), + search, + searchExtended, timezone, }, }), @@ -290,7 +296,7 @@ function registerRequests(deps: Deps, plan: Omit): R collection, `Count records of ${quoted(collection.name)} matching a filter.`, ), - properties: { filter: refs.filter, timezone }, + properties: { filter: refs.filter, search, searchExtended, timezone }, }), }; } diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index b5b9ab29f5..12fdbd91df 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -192,6 +192,20 @@ describe('the unfolded document', () => { expect(request.properties.sort.items?.$ref).toBe('#/components/schemas/SortClause_My_Coll'); }); + it('should publish search and searchExtended on list and count, which the runtime accepts', () => { + const list = requestSchema('My%20Coll/list') as unknown as { + properties: Record; + }; + const count = requestSchema('My%20Coll/count') as unknown as { + properties: Record; + }; + + expect(list.properties.search.$ref).toBe('#/components/schemas/Search'); + expect(list.properties.searchExtended.$ref).toBe('#/components/schemas/SearchExtended'); + expect(count.properties.search.$ref).toBe('#/components/schemas/Search'); + expect(count.properties.searchExtended.$ref).toBe('#/components/schemas/SearchExtended'); + }); + it('should make every leaf and the branch mutually exclusive, which the runtime enforces', () => { const branch = branchOf('Filter_My_Coll') as unknown as { not: { required: string[] } }; From dc26e96c5b6078750f6dc61aa7a17aa51c0cd8a0 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 1 Sep 2026 22:53:32 +0200 Subject: [PATCH 05/15] fix(agent-bff): close the unfolded request bodies structurally --- packages/agent-bff/src/openapi/schemas.ts | 10 +- .../agent-bff/src/openapi/unfolded-paths.ts | 91 ++++++++++++------- .../agent-bff/test/data/agent-query.test.ts | 6 ++ .../test/openapi/openapi-unfolded.test.ts | 63 +++++++++---- 4 files changed, 117 insertions(+), 53 deletions(-) diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 8385a1ad37..38cdbd9c5b 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -100,18 +100,20 @@ export const CLOSED_BODY_NOTE = 'check stops there: an unknown key on a condition node still reaches the agent, so a leaf ' + 'carrying `valu` runs with no value rather than being rejected.'; +type OverridesOf = Partial>; + const countOverrides = { filter: ConditionTreeSchema.optional(), search: SearchSchema.optional(), searchExtended: SearchExtendedSchema.optional(), timezone: TimezoneSchema.optional(), -} satisfies Partial>; +} satisfies OverridesOf; const listOverrides = { ...countOverrides, sort: z.array(SortClauseSchema).optional(), page: PageSchema.optional(), -} satisfies Partial>; +} satisfies OverridesOf; export const ListRequestSchema = ListFlatInputs.extend(listOverrides).openapi('ListRequest', { description: CLOSED_BODY_NOTE, @@ -126,12 +128,12 @@ export const CountRequestSchema = CountFlatInputs.extend(countOverrides).openapi const relationListOverrides = { ...listOverrides, parentId: ParentIdSchema, -} satisfies Partial>; +} satisfies OverridesOf; const relationCountOverrides = { ...countOverrides, parentId: ParentIdSchema, -} satisfies Partial>; +} satisfies OverridesOf; export const RelationListRequestSchema = RelationListFlatInputs.extend( relationListOverrides, diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index d138884f36..53057914a7 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -47,10 +47,18 @@ interface RequestRefs { count: ReferenceObject; } +type BodyProperties = Record; + +interface RequestProperties { + list: BodyProperties; + count: BodyProperties; +} + interface CollectionPlan { collection: UnfoldedCollection; key: string; requests: RequestRefs; + properties: RequestProperties; } interface Deps { @@ -253,25 +261,43 @@ function fieldRefs(deps: Deps, plan: Pick) direction: { type: 'string', enum: ['asc', 'desc'] }, }, required: ['field'], + additionalProperties: false, }), }; } -const CLOSED_BODY_PROSE_ONLY = `${CLOSED_BODY_NOTE} This schema does not say so structurally yet: treat it as closed.`; - function requestDescription(collection: UnfoldedCollection, subject: string): string { const note = collection.fields.degraded ? ` ${DEGRADED_NOTE[collection.fields.degraded]}` : ''; - return `${subject}${note} ${CLOSED_BODY_PROSE_ONLY}`; + return `${subject}${note} ${CLOSED_BODY_NOTE}`; } -function registerRequests(deps: Deps, plan: Omit): RequestRefs { +function requestProperties(deps: Deps, plan: Pick) { const { pool } = deps; - const { collection, key } = plan; const refs = fieldRefs(deps, plan); const timezone = pool.reuse('Timezone', TimezoneSchema); const search = pool.reuse('Search', SearchSchema); const searchExtended = pool.reuse('SearchExtended', SearchExtendedSchema); + const count: BodyProperties = { filter: refs.filter, search, searchExtended, timezone }; + + return { + count, + list: { + ...count, + projection: { type: 'array', items: refs.projectable }, + sort: { type: 'array', items: refs.sort }, + page: pool.reuse('Page', PageSchema), + } satisfies BodyProperties, + }; +} + +function registerRequests( + deps: Deps, + plan: Pick, + properties: RequestProperties, +): RequestRefs { + const { pool } = deps; + const { collection, key } = plan; return { list: pool.add(`ListRequest_${key}`, { @@ -280,15 +306,8 @@ function registerRequests(deps: Deps, plan: Omit): R collection, `Filter, sort and project records of ${quoted(collection.name)}.`, ), - properties: { - filter: refs.filter, - projection: { type: 'array', items: refs.projectable }, - sort: { type: 'array', items: refs.sort }, - page: pool.reuse('Page', PageSchema), - search, - searchExtended, - timezone, - }, + properties: properties.list, + additionalProperties: false, }), count: pool.add(`CountRequest_${key}`, { type: 'object', @@ -296,7 +315,8 @@ function registerRequests(deps: Deps, plan: Omit): R collection, `Count records of ${quoted(collection.name)} matching a filter.`, ), - properties: { filter: refs.filter, search, searchExtended, timezone }, + properties: properties.count, + additionalProperties: false, }), }; } @@ -350,25 +370,25 @@ function registerRelationRequests( ): RequestRefs { const { pool } = deps; const parentId = parentIdSchema(pool, plan.collection.name, plan.collection.primaryKeys); - const parentProperties = { - type: 'object' as const, - properties: { parentId }, - required: ['parentId'], - }; const description = - `Filter, sort and projection apply to ${quoted(foreign.collection.name)}, the foreign ` + - `collection of ${quoted(plan.collection.name)}.${quoted(relation.name)}; the parent only ` + - `resolves which records are related. ${CLOSED_BODY_PROSE_ONLY}`; + `Filter, sort, projection and search apply to ${quoted(foreign.collection.name)}, the ` + + `foreign collection of ${quoted(plan.collection.name)}.${quoted(relation.name)}; the parent ` + + `only resolves which records are related. ${CLOSED_BODY_NOTE}`; + + // The foreign properties are spread rather than composed with `allOf`: `additionalProperties: + // false` on one branch forbids the key the other adds, and oas31 carries no + // `unevaluatedProperties` to lift that. Flattening is what lets the relation body close too. + const body = (properties: BodyProperties): SchemaObject => ({ + type: 'object', + description, + properties: { ...properties, parentId }, + required: ['parentId'], + additionalProperties: false, + }); return { - list: pool.add(`RelationListRequest_${relationKey}`, { - description, - allOf: [foreign.requests.list, parentProperties], - }), - count: pool.add(`RelationCountRequest_${relationKey}`, { - description, - allOf: [foreign.requests.count, parentProperties], - }), + list: pool.add(`RelationListRequest_${relationKey}`, body(foreign.properties.list)), + count: pool.add(`RelationCountRequest_${relationKey}`, body(foreign.properties.count)), }; } @@ -595,7 +615,14 @@ export default function registerUnfoldedPaths(deps: Deps, unfolding: Unfolding): const plans = unfolding.collections.map(collection => { const key = collections(collection.name); - return { collection, key, requests: registerRequests(deps, { collection, key }) }; + const properties = requestProperties(deps, { collection, key }); + + return { + collection, + key, + properties, + requests: registerRequests(deps, { collection, key }, properties), + }; }); const plansByName = new Map(plans.map(plan => [plan.collection.name, plan])); diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts index 284c01b963..f2c815749e 100644 --- a/packages/agent-bff/test/data/agent-query.test.ts +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -426,6 +426,12 @@ describe('unknown keys', () => { expect(() => parseListRequest({ projections: ['id'] })).toThrow(REJECTED); }); + it('should reject a list-only key on a count body, where paging and projection mean nothing', () => { + expect(() => parseCountRequest({ projection: ['id'] })).toThrow(REJECTED); + expect(() => parseCountRequest({ sort: [{ field: 'id' }] })).toThrow(REJECTED); + expect(() => parseCountRequest({ page: { limit: 10, offset: 0 } })).toThrow(REJECTED); + }); + it('should reject parentId on a plain list, where a parent id means nothing', () => { expect(() => parseListRequest({ parentId: '7' })).toThrow(REJECTED); }); diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index 12fdbd91df..17691a44de 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -81,10 +81,9 @@ function collectionOf( }; } -// A relation request composes the foreign collection request, so its filter sits one hop further. +// A relation request spreads the foreign collection properties, so its filter sits on the body. function relationFilterOf(path: string): string { - const { allOf } = requestSchema(path) as unknown as { allOf: [{ $ref: string }, unknown] }; - const { properties } = dereference(allOf[0].$ref) as unknown as { + const { properties } = requestSchema(path) as unknown as { properties: { filter: { $ref: string } }; }; @@ -248,29 +247,61 @@ describe('the unfolded document', () => { expect(request.description).toContain('undeclared TOP-LEVEL key with 400 invalid_request'); }); + it('should close a per-collection sort clause, so a misspelled direction is not silently kept', () => { + const sort = schemas.SortClause_My_Coll as unknown as { additionalProperties: boolean }; + + expect(sort.additionalProperties).toBe(false); + }); + + it('should close the per-collection list and count bodies structurally', () => { + const list = requestSchema('My%20Coll/list') as unknown as { additionalProperties: boolean }; + const count = requestSchema('My%20Coll/count') as unknown as { additionalProperties: boolean }; + + expect(list.additionalProperties).toBe(false); + expect(count.additionalProperties).toBe(false); + }); + it('should describe a relation request with the FOREIGN collection fields, not the parent ones', () => { + const foreign = dereference('#/components/schemas/ListRequest_orders') as unknown as { + properties: Record; + }; const request = requestSchema('My%20Coll/relations/orders/list') as unknown as { - allOf: [{ $ref: string }, Record]; + properties: Record; }; - expect(request.allOf[0].$ref).toBe('#/components/schemas/ListRequest_orders'); + expect(Object.keys(request.properties).sort()).toEqual( + [...Object.keys(foreign.properties), 'parentId'].sort(), + ); + expect(request.properties.filter).toEqual(foreign.properties.filter); + }); + + it('should close a relation request structurally, not only in prose', () => { + const request = requestSchema('My%20Coll/relations/orders/list') as unknown as { + additionalProperties: boolean; + description: string; + }; + + expect(request.additionalProperties).toBe(false); + expect(request.description).toContain('undeclared TOP-LEVEL key with 400 invalid_request'); + expect(request.description).not.toContain('does not say so structurally'); }); it('should require parentId on a relation request and name the parent key it belongs to', () => { const request = requestSchema('My%20Coll/relations/orders/list') as unknown as { - allOf: [unknown, { properties: { parentId: { description: string } }; required: string[] }]; + properties: { parentId: { description: string } }; + required: string[]; }; - expect(request.allOf[1].required).toEqual(['parentId']); - expect(request.allOf[1].properties.parentId.description).toContain('(id, Number)'); + expect(request.required).toEqual(['parentId']); + expect(request.properties.parentId.description).toContain('(id, Number)'); }); it('should accept a numeric parent id as a number or its string form, like the runtime', () => { const request = requestSchema('My%20Coll/relations/orders/list') as unknown as { - allOf: [unknown, { properties: { parentId: { anyOf: unknown[] } } }]; + properties: { parentId: { anyOf: unknown[] } }; }; - expect(request.allOf[1].properties.parentId.anyOf).toEqual([ + expect(request.properties.parentId.anyOf).toEqual([ { type: 'string', pattern: '\\S' }, { type: 'number' }, ]); @@ -278,21 +309,19 @@ describe('the unfolded document', () => { it('should document a composite parent key as its packed string form', () => { const request = requestSchema('orders/relations/buyers/list') as unknown as { - allOf: [unknown, { properties: { parentId: { type: string; description: string } } }]; + properties: { parentId: { type: string; description: string } }; }; - expect(request.allOf[1].properties.parentId.type).toBe('string'); - expect(request.allOf[1].properties.parentId.description).toContain( - 'shop, number joined by "|"', - ); + expect(request.properties.parentId.type).toBe('string'); + expect(request.properties.parentId.description).toContain('shop, number joined by "|"'); }); it('should fall back to the opaque parent id when the parent exposes no key metadata', () => { const request = requestSchema('users.address/relations/orders/list') as unknown as { - allOf: [unknown, { properties: { parentId: { $ref?: string } } }]; + properties: { parentId: { $ref?: string } }; }; - expect(request.allOf[1].properties.parentId.$ref).toBe('#/components/schemas/ParentId'); + expect(request.properties.parentId.$ref).toBe('#/components/schemas/ParentId'); }); it('should keep the paths of a collection whose capabilities failed, with free-form fields', () => { From 1314219b523a9e797cec4d8ede0a60ee87ade366 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 1 Sep 2026 22:55:24 +0200 Subject: [PATCH 06/15] style(agent-bff): drop the flattening comment --- packages/agent-bff/src/openapi/unfolded-paths.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index 53057914a7..d0878b4920 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -375,9 +375,6 @@ function registerRelationRequests( `foreign collection of ${quoted(plan.collection.name)}.${quoted(relation.name)}; the parent ` + `only resolves which records are related. ${CLOSED_BODY_NOTE}`; - // The foreign properties are spread rather than composed with `allOf`: `additionalProperties: - // false` on one branch forbids the key the other adds, and oas31 carries no - // `unevaluatedProperties` to lift that. Flattening is what lets the relation body close too. const body = (properties: BodyProperties): SchemaObject => ({ type: 'object', description, From 0a203bf3641b1e4c1e68e289d5f00fa4a1734eac Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 1 Sep 2026 22:58:21 +0200 Subject: [PATCH 07/15] fix(agent-bff): keep the degraded note on relation request bodies --- packages/agent-bff/src/openapi/unfolded-paths.ts | 4 +++- .../agent-bff/test/openapi/openapi-unfolded.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index d0878b4920..32e7bf2a64 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -370,10 +370,12 @@ function registerRelationRequests( ): RequestRefs { const { pool } = deps; const parentId = parentIdSchema(pool, plan.collection.name, plan.collection.primaryKeys); + const { degraded } = foreign.collection.fields; + const foreignNote = degraded ? ` ${DEGRADED_NOTE[degraded]}` : ''; const description = `Filter, sort, projection and search apply to ${quoted(foreign.collection.name)}, the ` + `foreign collection of ${quoted(plan.collection.name)}.${quoted(relation.name)}; the parent ` + - `only resolves which records are related. ${CLOSED_BODY_NOTE}`; + `only resolves which records are related.${foreignNote} ${CLOSED_BODY_NOTE}`; const body = (properties: BodyProperties): SchemaObject => ({ type: 'object', diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index 17691a44de..08630fd312 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -275,6 +275,17 @@ describe('the unfolded document', () => { expect(request.properties.filter).toEqual(foreign.properties.filter); }); + it('should carry the foreign degraded note on a relation request, like the collection body', () => { + const relation = requestSchema('My%20Coll/relations/orders/list') as unknown as { + description: string; + }; + const foreign = requestSchema('orders/list') as unknown as { description: string }; + const note = 'The field names are NOT enumerated here'; + + expect(foreign.description).toContain(note); + expect(relation.description).toContain(note); + }); + it('should close a relation request structurally, not only in prose', () => { const request = requestSchema('My%20Coll/relations/orders/list') as unknown as { additionalProperties: boolean; From b36b370787ac651eb202c0088c79a5316eb37d35 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 1 Sep 2026 23:09:19 +0200 Subject: [PATCH 08/15] fix(agent-bff): type the body timezone and log rejected bodies --- packages/agent-bff/src/data/agent-query.ts | 31 +++--- .../src/data/data-routes-middleware.ts | 8 +- .../agent-bff/src/data/request-schemas.ts | 4 +- packages/agent-bff/src/openapi/schemas.ts | 3 +- .../agent-bff/test/data/agent-query.test.ts | 95 ++++++++++++------- 5 files changed, 86 insertions(+), 55 deletions(-) diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts index 954485229c..2c8fb59369 100644 --- a/packages/agent-bff/src/data/agent-query.ts +++ b/packages/agent-bff/src/data/agent-query.ts @@ -1,4 +1,5 @@ import type { PageInput, SortClauseInput } from './request-schemas'; +import type { Logger } from '../ports/logger-port'; import type { ZodType, z } from 'zod'; import { @@ -50,14 +51,18 @@ function assertNoNodeReadableAsBothLeafAndBranch(node: unknown, depth = 0): void } } -function assertFlatInputs(schema: ZodType, body: Record): void { +function assertFlatInputs(schema: ZodType, body: Record, logger: Logger): void { const result = schema.safeParse(body); if (result.success) return; - const [issue] = result.error.issues; + const { issues } = result.error; + const issue = issues.find(candidate => candidate.code === 'unrecognized_keys') ?? issues[0]; const path = issue.path.join('.'); + const reason = path ? `${path}: ${issue.message}` : issue.message; - throw invalidRequest(path ? `${path}: ${issue.message}` : issue.message); + logger('Warn', 'Request body rejected', { reason }); + + throw invalidRequest(reason); } function assertFilter(filter: unknown): void { @@ -67,21 +72,21 @@ function assertFilter(filter: unknown): void { assertNoNodeReadableAsBothLeafAndBranch(filter); } -function parseRequest(schema: S, body: unknown): z.output { +function parseRequest(schema: S, body: unknown, logger: Logger): z.output { if (!isPlainObject(body)) throw invalidRequest('Request body must be an object'); - assertFlatInputs(schema, body); + assertFlatInputs(schema, body, logger); assertFilter(body.filter); return body as z.output; } -export function parseListRequest(body: unknown): ListRequestBody { - return parseRequest(ListFlatInputs, body); +export function parseListRequest(body: unknown, logger: Logger): ListRequestBody { + return parseRequest(ListFlatInputs, body, logger); } -export function parseCountRequest(body: unknown): CountRequestBody { - return parseRequest(CountFlatInputs, body); +export function parseCountRequest(body: unknown, logger: Logger): CountRequestBody { + return parseRequest(CountFlatInputs, body, logger); } function collectFilterFields(filter: unknown, acc: string[]): void { @@ -184,16 +189,16 @@ export function parseParentId(parentId: unknown): string { throw invalidRequest('parentId is required and must be a non-empty string or a number'); } -export function parseRelationListRequest(body: unknown): RelationListRequestBody { +export function parseRelationListRequest(body: unknown, logger: Logger): RelationListRequestBody { const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId); - return { ...parseRequest(RelationListFlatInputs, body), parentId }; + return { ...parseRequest(RelationListFlatInputs, body, logger), parentId }; } -export function parseRelationCountRequest(body: unknown): RelationCountRequestBody { +export function parseRelationCountRequest(body: unknown, logger: Logger): RelationCountRequestBody { const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId); - return { ...parseRequest(RelationCountFlatInputs, body), parentId }; + return { ...parseRequest(RelationCountFlatInputs, body, logger), parentId }; } export function collectCountFieldPaths(body: CountRequestBody): string[] { diff --git a/packages/agent-bff/src/data/data-routes-middleware.ts b/packages/agent-bff/src/data/data-routes-middleware.ts index 1c9e633af6..be4915cca9 100644 --- a/packages/agent-bff/src/data/data-routes-middleware.ts +++ b/packages/agent-bff/src/data/data-routes-middleware.ts @@ -295,12 +295,12 @@ async function handleRelation( const relationDeps: RelationHandlerDeps = { ...deps, relation, foreignCollection }; if (operation === 'list') { - await handleRelationList(ctx, parseRelationListRequest(rawBody), { + await handleRelationList(ctx, parseRelationListRequest(rawBody, deps.logger), { ...relationDeps, primaryKeys: readModel.getPrimaryKeys(foreignCollection), }); } else { - await handleRelationCount(ctx, parseRelationCountRequest(rawBody), relationDeps); + await handleRelationCount(ctx, parseRelationCountRequest(rawBody, deps.logger), relationDeps); } } @@ -355,10 +355,10 @@ export default function createDataRoutesMiddleware({ const operation = match[2] as 'list' | 'count'; if (operation === 'list') { - const body = parseListRequest(rawBody); + const body = parseListRequest(rawBody, logger); await handleList(ctx, body, { ...deps, primaryKeys: readModel.getPrimaryKeys(collection) }); } else { - const body = parseCountRequest(rawBody); + const body = parseCountRequest(rawBody, logger); await handleCount(ctx, body, deps); } }; diff --git a/packages/agent-bff/src/data/request-schemas.ts b/packages/agent-bff/src/data/request-schemas.ts index c4559aa751..7b94af02cf 100644 --- a/packages/agent-bff/src/data/request-schemas.ts +++ b/packages/agent-bff/src/data/request-schemas.ts @@ -29,7 +29,7 @@ export const ListFlatInputs = z.strictObject({ page: PageInput.optional(), search: SearchInput.optional(), searchExtended: SearchExtendedInput.optional(), - timezone: validatedElsewhere, + timezone: TimezoneInput.optional(), }); /** A count body carries no projection, sort or page. */ @@ -37,7 +37,7 @@ export const CountFlatInputs = z.strictObject({ filter: validatedElsewhere, search: SearchInput.optional(), searchExtended: SearchExtendedInput.optional(), - timezone: validatedElsewhere, + timezone: TimezoneInput.optional(), }); export const RelationListFlatInputs = ListFlatInputs.extend({ parentId: validatedElsewhere }); diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 38cdbd9c5b..7cf864d249 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -98,7 +98,8 @@ export const CLOSED_BODY_NOTE = 'The runtime rejects an undeclared TOP-LEVEL key with 400 invalid_request, so `filters` instead ' + 'of `filter` is an error rather than a silently unfiltered result. Inside the filter tree the ' + 'check stops there: an unknown key on a condition node still reaches the agent, so a leaf ' + - 'carrying `valu` runs with no value rather than being rejected.'; + 'carrying `valu` instead of `value` is not rejected here and what the agent then does with it ' + + 'is not defined by this contract.'; type OverridesOf = Partial>; diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts index f2c815749e..da343dfd65 100644 --- a/packages/agent-bff/test/data/agent-query.test.ts +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -13,14 +13,16 @@ import { type Parser = [string, (body: unknown) => unknown]; +const logger = jest.fn(); + const FLAT_PARSERS: Parser[] = [ - ['parseListRequest', parseListRequest], - ['parseCountRequest', parseCountRequest], + ['parseListRequest', body => parseListRequest(body, logger)], + ['parseCountRequest', body => parseCountRequest(body, logger)], ]; const RELATION_PARSERS: Parser[] = [ - ['parseRelationListRequest', parseRelationListRequest], - ['parseRelationCountRequest', parseRelationCountRequest], + ['parseRelationListRequest', body => parseRelationListRequest(body, logger)], + ['parseRelationCountRequest', body => parseRelationCountRequest(body, logger)], ]; describe('buildListAgentQuery', () => { @@ -187,7 +189,7 @@ describe('parseListRequest', () => { page: { limit: 10, offset: 0 }, }; - expect(parseListRequest(body)).toBe(body); + expect(parseListRequest(body, logger)).toBe(body); }); it.each([ @@ -203,13 +205,13 @@ describe('parseListRequest', () => { ['a non-integer page.limit', { page: { limit: 2.5, offset: 0 } }], ['a negative page.offset', { page: { limit: 10, offset: -10 } }], ])('should reject %s with 400 invalid_request', (_label, body) => { - expect(() => parseListRequest(body)).toThrow( + expect(() => parseListRequest(body, logger)).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); it('should pass search and searchExtended through rather than strip them', () => { - expect(parseListRequest({ search: 'ada', searchExtended: true })).toMatchObject({ + expect(parseListRequest({ search: 'ada', searchExtended: true }, logger)).toMatchObject({ search: 'ada', searchExtended: true, }); @@ -221,13 +223,13 @@ describe('parseListRequest', () => { ['projection.0', { projection: [1] }], ['searchExtended', { searchExtended: 'true' }], ])('should name %s in the rejection message', (path, body) => { - expect(() => parseListRequest(body)).toThrow( + expect(() => parseListRequest(body, logger)).toThrow( expect.objectContaining({ message: expect.stringContaining(`${path}: `) }), ); }); it('should accept a blank search rather than rejecting a cleared search box', () => { - expect(parseListRequest({ search: ' ' })).toMatchObject({ search: ' ' }); + expect(parseListRequest({ search: ' ' }, logger)).toMatchObject({ search: ' ' }); }); it.each([ @@ -235,7 +237,7 @@ describe('parseListRequest', () => { ['a null search', { search: null }], ['an array search', { search: ['ada'] }], ])('should reject %s with 400 invalid_request', (_label, body) => { - expect(() => parseListRequest(body)).toThrow( + expect(() => parseListRequest(body, logger)).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); @@ -249,7 +251,7 @@ describe('parseListRequest', () => { ])( 'should reject searchExtended sent as %s rather than coercing it like the agent does', (_label, body) => { - expect(() => parseListRequest(body)).toThrow( + expect(() => parseListRequest(body, logger)).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }, @@ -258,7 +260,7 @@ describe('parseListRequest', () => { describe('parseCountRequest', () => { it('should reject a string filter with 400 invalid_request', () => { - expect(() => parseCountRequest({ filter: 'id' })).toThrow( + expect(() => parseCountRequest({ filter: 'id' }, logger)).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); @@ -268,13 +270,13 @@ describe('parseCountRequest', () => { ['a string', 'foo'], ['an array', []], ])('should reject a non-object body (%s) with 400 invalid_request', (_label, body) => { - expect(() => parseCountRequest(body)).toThrow( + expect(() => parseCountRequest(body, logger)).toThrow( 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({ + expect(parseCountRequest({ search: 'ada', searchExtended: false }, logger)).toMatchObject({ search: 'ada', searchExtended: false, }); @@ -284,7 +286,7 @@ describe('parseCountRequest', () => { ['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(() => parseCountRequest(body, logger)).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); @@ -306,7 +308,7 @@ describe('a filter node readable as both a leaf and a branch', () => { it('should reject it nested inside a legitimate branch', () => { expect(() => - parseListRequest({ filter: { aggregator: 'And', conditions: [READABLE_AS_BOTH] } }), + parseListRequest({ filter: { aggregator: 'And', conditions: [READABLE_AS_BOTH] } }, logger), ).toThrow(expect.objectContaining({ type: 'invalid_request', status: 400 })); }); @@ -317,9 +319,9 @@ describe('a filter node readable as both a leaf and a branch', () => { it('should still accept a plain leaf and a plain branch', () => { const leaf = { field: 'title', operator: 'Present' }; - expect(() => parseCountRequest({ filter: leaf })).not.toThrow(); + expect(() => parseCountRequest({ filter: leaf }, logger)).not.toThrow(); expect(() => - parseListRequest({ filter: { aggregator: 'And', conditions: [leaf] } }), + parseListRequest({ filter: { aggregator: 'And', conditions: [leaf] } }, logger), ).not.toThrow(); }); @@ -330,7 +332,7 @@ describe('a filter node readable as both a leaf and a branch', () => { filter = { aggregator: 'And', conditions: [filter] }; } - expect(() => parseCountRequest({ filter })).toThrow( + expect(() => parseCountRequest({ filter }, logger)).toThrow( expect.objectContaining({ type: 'filter_too_deep', status: 400, @@ -371,20 +373,20 @@ describe('parseParentId', () => { describe('parseRelationListRequest', () => { it('should return the validated list body with the parsed parentId', () => { - expect(parseRelationListRequest({ parentId: 'a|b', projection: ['id'] })).toEqual({ + expect(parseRelationListRequest({ parentId: 'a|b', projection: ['id'] }, logger)).toEqual({ parentId: 'a|b', projection: ['id'], }); }); it('should reject a missing parentId with 400 invalid_request', () => { - expect(() => parseRelationListRequest({ projection: ['id'] })).toThrow( + expect(() => parseRelationListRequest({ projection: ['id'] }, logger)).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); it('should reject an invalid list body with 400 invalid_request', () => { - expect(() => parseRelationListRequest({ parentId: '7', projection: 'id' })).toThrow( + expect(() => parseRelationListRequest({ parentId: '7', projection: 'id' }, logger)).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); @@ -393,12 +395,15 @@ describe('parseRelationListRequest', () => { describe('parseRelationCountRequest', () => { it('should return the validated count body with the parsed parentId', () => { expect( - parseRelationCountRequest({ parentId: '7', filter: { field: 'a', operator: 'present' } }), + parseRelationCountRequest( + { parentId: '7', filter: { field: 'a', operator: 'present' } }, + logger, + ), ).toEqual({ parentId: '7', filter: { field: 'a', operator: 'present' } }); }); it('should reject a missing parentId with 400 invalid_request', () => { - expect(() => parseRelationCountRequest({})).toThrow( + expect(() => parseRelationCountRequest({}, logger)).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); @@ -423,17 +428,17 @@ describe('unknown keys', () => { }); it('should reject a misspelled projection rather than return every field', () => { - expect(() => parseListRequest({ projections: ['id'] })).toThrow(REJECTED); + expect(() => parseListRequest({ projections: ['id'] }, logger)).toThrow(REJECTED); }); it('should reject a list-only key on a count body, where paging and projection mean nothing', () => { - expect(() => parseCountRequest({ projection: ['id'] })).toThrow(REJECTED); - expect(() => parseCountRequest({ sort: [{ field: 'id' }] })).toThrow(REJECTED); - expect(() => parseCountRequest({ page: { limit: 10, offset: 0 } })).toThrow(REJECTED); + expect(() => parseCountRequest({ projection: ['id'] }, logger)).toThrow(REJECTED); + expect(() => parseCountRequest({ sort: [{ field: 'id' }] }, logger)).toThrow(REJECTED); + expect(() => parseCountRequest({ page: { limit: 10, offset: 0 } }, logger)).toThrow(REJECTED); }); it('should reject parentId on a plain list, where a parent id means nothing', () => { - expect(() => parseListRequest({ parentId: '7' })).toThrow(REJECTED); + expect(() => parseListRequest({ parentId: '7' }, logger)).toThrow(REJECTED); }); it.each(RELATION_PARSERS)('should reject a misspelled filter on %s', (_label, parse) => { @@ -453,18 +458,38 @@ describe('unknown keys', () => { }, ); - it.each(FLAT_PARSERS)('should leave a null timezone to the middleware on %s', (_label, parse) => { - expect(parse({ timezone: null })).toMatchObject({ timezone: null }); + it.each(FLAT_PARSERS)( + 'should reject a non-string timezone on %s rather than resolve one the caller did not send', + (_label, parse) => { + expect(() => parse({ timezone: null })).toThrow(REJECTED); + expect(() => parse({ timezone: 42 })).toThrow(REJECTED); + }, + ); + + it.each(FLAT_PARSERS)('should log the rejected key on %s, never the value', (_label, parse) => { + logger.mockClear(); + expect(() => parse({ filters: { value: 'secret' } })).toThrow(REJECTED); + expect(logger).toHaveBeenCalledWith('Warn', 'Request body rejected', { + reason: 'Unrecognized key: "filters"', + }); + expect(JSON.stringify(logger.mock.calls)).not.toContain('secret'); }); - it('should reject a misspelled sort direction rather than sort ascending', () => { - expect(() => parseListRequest({ sort: [{ field: 'createdAt', direciton: 'desc' }] })).toThrow( - REJECTED, + it('should name the unrecognized key even when another issue comes first', () => { + logger.mockClear(); + expect(() => parseListRequest({ filters: {}, page: { limit: 0, offset: 0 } }, logger)).toThrow( + expect.objectContaining({ message: expect.stringContaining('filters') }), ); }); + it('should reject a misspelled sort direction rather than sort ascending', () => { + expect(() => + parseListRequest({ sort: [{ field: 'createdAt', direciton: 'desc' }] }, logger), + ).toThrow(REJECTED); + }); + it('should reject an unknown key inside page', () => { - expect(() => parseListRequest({ page: { limit: 10, offset: 0, cursor: 'x' } })).toThrow( + expect(() => parseListRequest({ page: { limit: 10, offset: 0, cursor: 'x' } }, logger)).toThrow( REJECTED, ); }); From 259dc95bd9e168a4a494efc19e8b24ed2b1e029a Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 2 Sep 2026 09:48:37 +0200 Subject: [PATCH 09/15] fix(agent-bff): reject unknown keys inside the filter tree --- packages/agent-bff/src/data/agent-query.ts | 35 ++++++++++-- .../agent-bff/test/data/agent-query.test.ts | 54 +++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts index 2c8fb59369..cb345a74fd 100644 --- a/packages/agent-bff/src/data/agent-query.ts +++ b/packages/agent-bff/src/data/agent-query.ts @@ -34,7 +34,21 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function assertNoNodeReadableAsBothLeafAndBranch(node: unknown, depth = 0): void { +const LEAF_KEYS = ['field', 'operator', 'value']; +const BRANCH_KEYS = ['aggregator', 'conditions']; + +// The tree is closed like the flat body is: a leaf carrying `valu` instead of `value` builds a +// condition with `value: undefined`, which the agent reads as null and runs — a typo must not +// silently change the returned rows. +function assertNoStrayKey(node: object, allowed: string[]): void { + const stray = Object.keys(node).find(key => !allowed.includes(key)); + + if (stray !== undefined) { + throw invalidRequest(`A filter node cannot carry "${stray}"`); + } +} + +function assertFilterNode(node: unknown, depth = 0): void { if (depth > MAX_FILTER_DEPTH) throw filterTooDeep(MAX_FILTER_DEPTH); if (typeof node !== 'object' || node === null) return; @@ -45,10 +59,21 @@ function assertNoNodeReadableAsBothLeafAndBranch(node: unknown, depth = 0): void } if (readableAsBranch) { - node.conditions.forEach(condition => - assertNoNodeReadableAsBothLeafAndBranch(condition, depth + 1), - ); + assertNoStrayKey(node, BRANCH_KEYS); + node.conditions.forEach(condition => assertFilterNode(condition, depth + 1)); + + return; } + + if (isLeaf(node)) { + assertNoStrayKey(node, LEAF_KEYS); + + return; + } + + // Neither readable as a leaf nor as a branch: `feild` instead of `field` reaches the agent as a + // node it cannot act on. An empty object stays allowed — it is how an absent filter is spelled. + assertNoStrayKey(node, []); } function assertFlatInputs(schema: ZodType, body: Record, logger: Logger): void { @@ -69,7 +94,7 @@ function assertFilter(filter: unknown): void { if (filter === undefined) return; if (!isPlainObject(filter)) throw invalidRequest('filter must be an object'); - assertNoNodeReadableAsBothLeafAndBranch(filter); + assertFilterNode(filter); } function parseRequest(schema: S, body: unknown, logger: Logger): z.output { diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts index da343dfd65..efd887ff1e 100644 --- a/packages/agent-bff/test/data/agent-query.test.ts +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -342,6 +342,60 @@ describe('a filter node readable as both a leaf and a branch', () => { }); }); +describe('a filter node carrying an unknown key', () => { + it.each(FLAT_PARSERS)('should reject a misspelled leaf value in %s', (_label, parse) => { + expect(() => parse({ filter: { field: 'title', operator: 'Equal', valu: 'x' } })).toThrow( + expect.objectContaining({ + type: 'invalid_request', + status: 400, + message: 'A filter node cannot carry "valu"', + }), + ); + }); + + it('should reject a misspelled leaf value nested inside a branch', () => { + expect(() => + parseListRequest( + { + filter: { + aggregator: 'And', + conditions: [{ field: 'title', operator: 'Equal', valu: 'x' }], + }, + }, + logger, + ), + ).toThrow(expect.objectContaining({ type: 'invalid_request', status: 400 })); + }); + + it('should reject a misspelled aggregator on a branch', () => { + expect(() => + parseCountRequest({ filter: { agregator: 'And', conditions: [] } }, logger), + ).toThrow( + expect.objectContaining({ + type: 'invalid_request', + status: 400, + message: 'A filter node cannot carry "agregator"', + }), + ); + }); + + it('should reject a node readable as neither a leaf nor a branch', () => { + expect(() => + parseCountRequest({ filter: { feild: 'title', operator: 'Equal', value: 'x' } }, logger), + ).toThrow(expect.objectContaining({ type: 'invalid_request', status: 400 })); + }); + + it('should accept an empty filter object, which is how an absent filter is spelled', () => { + expect(() => parseCountRequest({ filter: {} }, logger)).not.toThrow(); + }); + + it('should accept a leaf carrying field, operator and value', () => { + expect(() => + parseCountRequest({ filter: { field: 'title', operator: 'Equal', value: 'x' } }, logger), + ).not.toThrow(); + }); +}); + describe('parseParentId', () => { it('should return a non-empty string unchanged, including a composite packed id', () => { expect(parseParentId('a|b')).toBe('a|b'); From bec27762819623116df47af3b2361d09fbf4b496 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 2 Sep 2026 12:12:23 +0200 Subject: [PATCH 10/15] fix(agent-bff): log every site that rejects a request body --- packages/agent-bff/src/data/agent-query.ts | 61 ++++++++++++------- .../agent-bff/test/data/agent-query.test.ts | 58 ++++++++++++++++-- 2 files changed, 92 insertions(+), 27 deletions(-) diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts index cb345a74fd..028829d8f8 100644 --- a/packages/agent-bff/src/data/agent-query.ts +++ b/packages/agent-bff/src/data/agent-query.ts @@ -1,3 +1,4 @@ +import type { BffHttpError } from '../http/bff-http-error'; import type { PageInput, SortClauseInput } from './request-schemas'; import type { Logger } from '../ports/logger-port'; import type { ZodType, z } from 'zod'; @@ -37,43 +38,52 @@ function isPlainObject(value: unknown): value is Record { const LEAF_KEYS = ['field', 'operator', 'value']; const BRANCH_KEYS = ['aggregator', 'conditions']; +function rejectBody(logger: Logger, error: BffHttpError): never { + logger('Warn', 'Request body rejected', { reason: error.message }); + + throw error; +} + // The tree is closed like the flat body is: a leaf carrying `valu` instead of `value` builds a // condition with `value: undefined`, which the agent reads as null and runs — a typo must not // silently change the returned rows. -function assertNoStrayKey(node: object, allowed: string[]): void { +function assertNoStrayKey(node: Record, allowed: string[], logger: Logger): void { const stray = Object.keys(node).find(key => !allowed.includes(key)); if (stray !== undefined) { - throw invalidRequest(`A filter node cannot carry "${stray}"`); + rejectBody(logger, invalidRequest(`A filter node cannot carry "${stray}"`)); } } -function assertFilterNode(node: unknown, depth = 0): void { - if (depth > MAX_FILTER_DEPTH) throw filterTooDeep(MAX_FILTER_DEPTH); - if (typeof node !== 'object' || node === null) return; +function assertFilterNode(node: unknown, logger: Logger, depth = 0): void { + if (depth > MAX_FILTER_DEPTH) rejectBody(logger, filterTooDeep(MAX_FILTER_DEPTH)); + if (!isPlainObject(node)) return; const readableAsBranch = isBranch(node); if (isLeaf(node) && readableAsBranch) { - throw invalidRequest('A filter node cannot carry both "field" and "conditions"'); + rejectBody( + logger, + invalidRequest('A filter node cannot carry both "field" and "conditions"'), + ); } if (readableAsBranch) { - assertNoStrayKey(node, BRANCH_KEYS); - node.conditions.forEach(condition => assertFilterNode(condition, depth + 1)); + assertNoStrayKey(node, BRANCH_KEYS, logger); + node.conditions.forEach(condition => assertFilterNode(condition, logger, depth + 1)); return; } if (isLeaf(node)) { - assertNoStrayKey(node, LEAF_KEYS); + assertNoStrayKey(node, LEAF_KEYS, logger); return; } // Neither readable as a leaf nor as a branch: `feild` instead of `field` reaches the agent as a // node it cannot act on. An empty object stays allowed — it is how an absent filter is spelled. - assertNoStrayKey(node, []); + assertNoStrayKey(node, [], logger); } function assertFlatInputs(schema: ZodType, body: Record, logger: Logger): void { @@ -85,23 +95,23 @@ function assertFlatInputs(schema: ZodType, body: Record, logger const path = issue.path.join('.'); const reason = path ? `${path}: ${issue.message}` : issue.message; - logger('Warn', 'Request body rejected', { reason }); - - throw invalidRequest(reason); + rejectBody(logger, invalidRequest(reason)); } -function assertFilter(filter: unknown): void { +function assertFilter(filter: unknown, logger: Logger): void { if (filter === undefined) return; - if (!isPlainObject(filter)) throw invalidRequest('filter must be an object'); + if (!isPlainObject(filter)) rejectBody(logger, invalidRequest('filter must be an object')); - assertFilterNode(filter); + assertFilterNode(filter, logger); } function parseRequest(schema: S, body: unknown, logger: Logger): z.output { - if (!isPlainObject(body)) throw invalidRequest('Request body must be an object'); + if (!isPlainObject(body)) { + rejectBody(logger, invalidRequest('Request body must be an object')); + } assertFlatInputs(schema, body, logger); - assertFilter(body.filter); + assertFilter(body.filter, logger); return body as z.output; } @@ -200,9 +210,11 @@ export function collectListFieldPaths(body: ListRequestBody): string[] { // The parent record id is opaque: a packed/composite id must survive unchanged, so its content is // never inspected — only presence and primitive type. A finite number (single numeric pk) is // coerced to string; anything else is a BFF-local 400 with no agent call. -export function parseParentId(parentId: unknown): string { +export function parseParentId(parentId: unknown, logger: Logger): string { if (typeof parentId === 'string') { - if (parentId.trim() === '') throw invalidRequest('parentId must not be empty'); + if (parentId.trim() === '') { + rejectBody(logger, invalidRequest('parentId must not be empty')); + } return parentId; } @@ -211,17 +223,20 @@ export function parseParentId(parentId: unknown): string { return String(parentId); } - throw invalidRequest('parentId is required and must be a non-empty string or a number'); + rejectBody( + logger, + invalidRequest('parentId is required and must be a non-empty string or a number'), + ); } export function parseRelationListRequest(body: unknown, logger: Logger): RelationListRequestBody { - const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId); + const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId, logger); return { ...parseRequest(RelationListFlatInputs, body, logger), parentId }; } export function parseRelationCountRequest(body: unknown, logger: Logger): RelationCountRequestBody { - const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId); + const parentId = parseParentId((body as { parentId?: unknown } | null)?.parentId, logger); return { ...parseRequest(RelationCountFlatInputs, body, logger), parentId }; } diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts index efd887ff1e..ea90cacfa9 100644 --- a/packages/agent-bff/test/data/agent-query.test.ts +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -398,14 +398,14 @@ describe('a filter node carrying an unknown key', () => { describe('parseParentId', () => { it('should return a non-empty string unchanged, including a composite packed id', () => { - expect(parseParentId('a|b')).toBe('a|b'); - expect(parseParentId('550e8400-e29b-41d4-a716-446655440000')).toBe( + expect(parseParentId('a|b', logger)).toBe('a|b'); + expect(parseParentId('550e8400-e29b-41d4-a716-446655440000', logger)).toBe( '550e8400-e29b-41d4-a716-446655440000', ); }); it('should coerce a finite number to a string', () => { - expect(parseParentId(42)).toBe('42'); + expect(parseParentId(42, logger)).toBe('42'); }); it.each([ @@ -419,7 +419,7 @@ describe('parseParentId', () => { ['NaN', NaN], ['Infinity', Infinity], ])('should reject %s with invalid_request', (_label, value) => { - expect(() => parseParentId(value)).toThrow( + expect(() => parseParentId(value, logger)).toThrow( expect.objectContaining({ type: 'invalid_request', status: 400 }), ); }); @@ -529,6 +529,56 @@ describe('unknown keys', () => { expect(JSON.stringify(logger.mock.calls)).not.toContain('secret'); }); + it.each(FLAT_PARSERS)( + 'should log a stray key inside the filter tree on %s, never the value', + (_label, parse) => { + logger.mockClear(); + expect(() => + parse({ filter: { field: 'title', operator: 'Equal', valu: 'secret' } }), + ).toThrow(REJECTED); + expect(logger).toHaveBeenCalledWith('Warn', 'Request body rejected', { + reason: 'A filter node cannot carry "valu"', + }); + expect(JSON.stringify(logger.mock.calls)).not.toContain('secret'); + }, + ); + + it.each([ + ['a non-object body', 'nope', 'Request body must be an object'], + ['a non-object filter', { filter: 'id' }, 'filter must be an object'], + ])('should log %s too, not only zod rejections', (_label, body, reason) => { + logger.mockClear(); + expect(() => parseListRequest(body, logger)).toThrow(REJECTED); + expect(logger).toHaveBeenCalledWith('Warn', 'Request body rejected', { reason }); + }); + + it.each(RELATION_PARSERS)( + 'should log a parentId failure on %s, which the schema never sees', + (_label, parse) => { + logger.mockClear(); + expect(() => parse({ projection: ['id'] })).toThrow(REJECTED); + expect(logger).toHaveBeenCalledWith('Warn', 'Request body rejected', { + reason: 'parentId is required and must be a non-empty string or a number', + }); + }, + ); + + it('should log a filter nested past the depth cap', () => { + let filter: unknown = { field: 'title', operator: 'Present' }; + + for (let i = 0; i <= MAX_PARSED_FILTER_DEPTH; i += 1) { + filter = { aggregator: 'And', conditions: [filter] }; + } + + logger.mockClear(); + expect(() => parseCountRequest({ filter }, logger)).toThrow( + expect.objectContaining({ type: 'filter_too_deep' }), + ); + expect(logger).toHaveBeenCalledWith('Warn', 'Request body rejected', { + reason: `Filter nesting exceeds the maximum depth of ${MAX_PARSED_FILTER_DEPTH}`, + }); + }); + it('should name the unrecognized key even when another issue comes first', () => { logger.mockClear(); expect(() => parseListRequest({ filters: {}, page: { limit: 0, offset: 0 } }, logger)).toThrow( From e3e6820a0b0dffea8d2b89bf02e1826a04b0ee49 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 2 Sep 2026 12:13:15 +0200 Subject: [PATCH 11/15] style(agent-bff): format the rejectbody call --- packages/agent-bff/src/data/agent-query.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts index 028829d8f8..f2ca58fca3 100644 --- a/packages/agent-bff/src/data/agent-query.ts +++ b/packages/agent-bff/src/data/agent-query.ts @@ -62,10 +62,7 @@ function assertFilterNode(node: unknown, logger: Logger, depth = 0): void { const readableAsBranch = isBranch(node); if (isLeaf(node) && readableAsBranch) { - rejectBody( - logger, - invalidRequest('A filter node cannot carry both "field" and "conditions"'), - ); + rejectBody(logger, invalidRequest('A filter node cannot carry both "field" and "conditions"')); } if (readableAsBranch) { From c3281770ae31438d5f348fbc31570e87587c07f6 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 2 Sep 2026 12:14:23 +0200 Subject: [PATCH 12/15] fix(agent-bff): reject a blank timezone instead of resolving another one --- packages/agent-bff/src/data/request-schemas.ts | 2 +- packages/agent-bff/src/openapi/schemas.ts | 5 +++-- packages/agent-bff/test/data/agent-query.test.ts | 8 ++++++++ packages/agent-bff/test/openapi/openapi-document.test.ts | 4 ++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/agent-bff/src/data/request-schemas.ts b/packages/agent-bff/src/data/request-schemas.ts index 7b94af02cf..a64597d8ce 100644 --- a/packages/agent-bff/src/data/request-schemas.ts +++ b/packages/agent-bff/src/data/request-schemas.ts @@ -16,7 +16,7 @@ export const SearchInput = z.string(); export const SearchExtendedInput = z.boolean(); -export const TimezoneInput = z.string(); +export const TimezoneInput = z.string().regex(/\S/); export const ParentIdInput = z.union([z.string().regex(/\S/), z.number()]); diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 7cf864d249..90e7a5044e 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -62,8 +62,9 @@ export const PageSchema = PageInput.openapi('Page', { export const TimezoneSchema = TimezoneInput.openapi('Timezone', { description: 'Used when the X-Forest-Timezone header is absent. The header wins when both are sent. A ' + - 'deployment with no configured default rejects a request carrying neither with 400 ' + - 'missing_timezone.', + 'blank or whitespace-only string is rejected with 400 rather than silently resolving from ' + + 'the header or the default. A deployment with no configured default rejects a request ' + + 'carrying neither with 400 missing_timezone.', }); export const SearchSchema = SearchInput.openapi('Search', { diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts index ea90cacfa9..26accaad78 100644 --- a/packages/agent-bff/test/data/agent-query.test.ts +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -520,6 +520,14 @@ describe('unknown keys', () => { }, ); + it.each(FLAT_PARSERS)( + 'should reject a blank timezone on %s rather than silently resolve another one', + (_label, parse) => { + expect(() => parse({ timezone: '' })).toThrow(REJECTED); + expect(() => parse({ timezone: ' ' })).toThrow(REJECTED); + }, + ); + it.each(FLAT_PARSERS)('should log the rejected key on %s, never the value', (_label, parse) => { logger.mockClear(); expect(() => parse({ filters: { value: 'secret' } })).toThrow(REJECTED); diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index 2a1d24d86b..ff5b3919ab 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -418,6 +418,10 @@ describe('generateOpenApiDocument', () => { expect(Object.keys(schemas.ActionRequest.properties as object)).toContain('timezone'); }); + it('should publish the blank-timezone rejection in the schema, not only at runtime', () => { + expect(schemas.Timezone).toEqual(expect.objectContaining({ type: 'string', pattern: '\\S' })); + }); + it('should declare Retry-After on 503, the only status that sets it', () => { const list = listResponses(); From 05064b9cfa3977c84b652abb772b50399a1ac2ee Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 2 Sep 2026 12:18:48 +0200 Subject: [PATCH 13/15] fix(agent-bff): close the filter tree in the published document --- packages/agent-bff/src/openapi/schemas.ts | 13 +++--- .../agent-bff/src/openapi/unfolded-paths.ts | 10 +---- .../test/openapi/openapi-document.test.ts | 9 ++++ .../test/openapi/openapi-unfolded.test.ts | 43 ++++++------------- 4 files changed, 29 insertions(+), 46 deletions(-) diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 90e7a5044e..4f4e613297 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -19,7 +19,7 @@ import { MAX_FILTER_DEPTH } from '../validation/capabilities-validator'; const OPERATORS = [...allOperators] as [string, ...string[]]; const ConditionTreeLeafSchema = z - .object({ + .strictObject({ field: z.string(), operator: z.enum(OPERATORS), value: z.unknown().optional(), @@ -34,7 +34,7 @@ const ConditionTreeSchema: z.ZodType = z .lazy(() => z.union([ ConditionTreeLeafSchema, - z.object({ + z.strictObject({ aggregator: z.enum(['And', 'Or']), conditions: z.array(ConditionTreeSchema), }), @@ -96,11 +96,10 @@ const ParentIdSchema = ParentIdInput.openapi('ParentId', { }); export const CLOSED_BODY_NOTE = - 'The runtime rejects an undeclared TOP-LEVEL key with 400 invalid_request, so `filters` instead ' + - 'of `filter` is an error rather than a silently unfiltered result. Inside the filter tree the ' + - 'check stops there: an unknown key on a condition node still reaches the agent, so a leaf ' + - 'carrying `valu` instead of `value` is not rejected here and what the agent then does with it ' + - 'is not defined by this contract.'; + 'The runtime rejects an undeclared key with 400 invalid_request, at the top level and inside ' + + 'the filter tree alike: `filters` instead of `filter` is an error rather than a silently ' + + 'unfiltered result, and a leaf carrying `valu` instead of `value` is rejected rather than run ' + + 'with its value dropped.'; type OverridesOf = Partial>; diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index 32e7bf2a64..68a5fd960c 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -127,12 +127,6 @@ function groupByOperators(filterable: FilterableField[]): OperatorGroup[] { return [...groups.values()]; } -// A node readable as BOTH a leaf and a branch is rejected with 400 (`agent-query.ts` -// `assertNoNodeReadableAsBothLeafAndBranch`), so each alternative excludes the other's discriminator. -// The exclusion carries the TYPE the runtime looks for — `isBranch` needs an ARRAY `conditions` and -// `isLeaf` a STRING `field` — because `{field, operator, conditions: "x"}` is a plain leaf the runtime -// accepts. Expressed as `not: { required }` rather than `additionalProperties: false`, which would -// also forbid an unknown extra key the runtime strips. function leafShape( field: ReferenceObject | SchemaObject, operators: string[], @@ -147,7 +141,7 @@ function leafShape( value: {}, }, required: ['field', 'operator'], - not: { properties: { conditions: { type: 'array' } }, required: ['conditions'] }, + additionalProperties: false, }; } @@ -220,7 +214,7 @@ function filterSchema( conditions: { type: 'array', items: treeRef }, }, required: ['aggregator', 'conditions'], - not: { properties: { field: { type: 'string' } }, required: ['field'] }, + additionalProperties: false, }, ], }); diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index ff5b3919ab..f0832a2635 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -477,6 +477,15 @@ describe('the closed request bodies', () => { expect(schemas[name].additionalProperties).toBe(false); }); + it('should forbid an undeclared key on both shapes of a condition tree node', () => { + const { anyOf } = schemas.ConditionTree as unknown as { anyOf: unknown[] }; + const [leaf, branch] = anyOf; + + expect(leaf).toEqual({ $ref: '#/components/schemas/ConditionTreeLeaf' }); + expect(branch).toEqual(expect.objectContaining({ additionalProperties: false })); + expect(schemas.ConditionTreeLeaf.additionalProperties).toBe(false); + }); + it.each([['RelationListRequest'], ['RelationCountRequest']])( 'should publish %s flat rather than as an allOf of a closed base', name => { diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index 08630fd312..ed38aa6e63 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -205,35 +205,15 @@ describe('the unfolded document', () => { expect(count.properties.searchExtended.$ref).toBe('#/components/schemas/SearchExtended'); }); - it('should make every leaf and the branch mutually exclusive, which the runtime enforces', () => { - const branch = branchOf('Filter_My_Coll') as unknown as { not: { required: string[] } }; - - ['FilterLeaf_My_Coll-1', 'FilterLeaf_My_Coll-2'].forEach(name => { - const leaf = schemas[name] as unknown as { not: { required: string[] } }; - - expect(leaf.not.required).toEqual(['conditions']); + it('should close every leaf and the branch of a filter tree, so a stray key is refused before it leaves', () => { + ['FilterLeaf_My_Coll-1', 'FilterLeaf_My_Coll-2', 'FilterLeaf_orders'].forEach(name => { + expect((schemas[name] as { additionalProperties?: boolean }).additionalProperties).toBe( + false, + ); }); - expect(branch.not.required).toEqual(['field']); - }); - - it('should exclude only the type the runtime reads as the other shape', () => { - // `isBranch` needs an ARRAY `conditions` and `isLeaf` a STRING `field`, so a leaf carrying - // `conditions: "x"` is a plain leaf the runtime accepts and the document must not refuse. - const leaf = schemas['FilterLeaf_My_Coll-1'] as unknown as { - not: { properties: { conditions: { type: string } } }; - }; - const branch = branchOf('Filter_My_Coll') as unknown as { - not: { properties: { field: { type: string } } }; - }; - - expect(leaf.not.properties.conditions.type).toBe('array'); - expect(branch.not.properties.field.type).toBe('string'); - }); - - it('should not forbid an unknown extra key on a filter node, which the runtime forwards', () => { - const leaf = schemas['FilterLeaf_My_Coll-1'] as { additionalProperties?: unknown }; - - expect(leaf.additionalProperties).toBeUndefined(); + expect( + (branchOf('Filter_My_Coll') as { additionalProperties?: boolean }).additionalProperties, + ).toBe(false); }); it.each([ @@ -241,10 +221,11 @@ describe('the unfolded document', () => { ['My%20Coll/count'], ['My%20Coll/relations/orders/list'], ['My%20Coll/relations/orders/count'], - ])('should say on %s that an undeclared top-level key is rejected', path => { + ])('should say on %s that an undeclared key is rejected, in the body and the tree alike', path => { const request = requestSchema(path) as unknown as { description: string }; - expect(request.description).toContain('undeclared TOP-LEVEL key with 400 invalid_request'); + expect(request.description).toContain('undeclared key with 400 invalid_request'); + expect(request.description).toContain('`valu` instead of `value` is rejected'); }); it('should close a per-collection sort clause, so a misspelled direction is not silently kept', () => { @@ -293,7 +274,7 @@ describe('the unfolded document', () => { }; expect(request.additionalProperties).toBe(false); - expect(request.description).toContain('undeclared TOP-LEVEL key with 400 invalid_request'); + expect(request.description).toContain('undeclared key with 400 invalid_request'); expect(request.description).not.toContain('does not say so structurally'); }); From 40e7c4a16459f176512046135f8f36e7df95cb99 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 2 Sep 2026 12:20:07 +0200 Subject: [PATCH 14/15] fix(agent-bff): stop advertising list-only keys on relation count bodies --- .../agent-bff/src/openapi/unfolded-paths.ts | 21 +++++++++++-------- .../test/openapi/openapi-unfolded.test.ts | 20 ++++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index 68a5fd960c..6605c0d649 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -366,22 +366,25 @@ function registerRelationRequests( const parentId = parentIdSchema(pool, plan.collection.name, plan.collection.primaryKeys); const { degraded } = foreign.collection.fields; const foreignNote = degraded ? ` ${DEGRADED_NOTE[degraded]}` : ''; - const description = - `Filter, sort, projection and search apply to ${quoted(foreign.collection.name)}, the ` + - `foreign collection of ${quoted(plan.collection.name)}.${quoted(relation.name)}; the parent ` + - `only resolves which records are related.${foreignNote} ${CLOSED_BODY_NOTE}`; + const appliesTo = + `apply to ${quoted(foreign.collection.name)}, the foreign collection of ` + + `${quoted(plan.collection.name)}.${quoted(relation.name)}; the parent only resolves which ` + + 'records are related.'; - const body = (properties: BodyProperties): SchemaObject => ({ + const body = (inputs: 'list' | 'count', subject: string): SchemaObject => ({ type: 'object', - description, - properties: { ...properties, parentId }, + description: `${subject} ${appliesTo}${foreignNote} ${CLOSED_BODY_NOTE}`, + properties: { ...foreign.properties[inputs], parentId }, required: ['parentId'], additionalProperties: false, }); return { - list: pool.add(`RelationListRequest_${relationKey}`, body(foreign.properties.list)), - count: pool.add(`RelationCountRequest_${relationKey}`, body(foreign.properties.count)), + list: pool.add( + `RelationListRequest_${relationKey}`, + body('list', 'Filter, sort, projection and search'), + ), + count: pool.add(`RelationCountRequest_${relationKey}`, body('count', 'Filter and search')), }; } diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index ed38aa6e63..617bec2cb0 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -267,6 +267,26 @@ describe('the unfolded document', () => { expect(relation.description).toContain(note); }); + it('should not advertise list-only keys on the relation count body or its description', () => { + const list = requestSchema('My%20Coll/relations/orders/list') as unknown as { + description: string; + }; + const count = requestSchema('My%20Coll/relations/orders/count') as unknown as { + description: string; + properties: Record; + }; + + expect(list.description).toContain('Filter, sort, projection and search'); + expect(count.description).toContain('Filter and search'); + expect(Object.keys(count.properties).sort()).toEqual([ + 'filter', + 'parentId', + 'search', + 'searchExtended', + 'timezone', + ]); + }); + it('should close a relation request structurally, not only in prose', () => { const request = requestSchema('My%20Coll/relations/orders/list') as unknown as { additionalProperties: boolean; From bcf806aae493e096ba7618ca25b6409467ca7cd1 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 2 Sep 2026 12:21:58 +0200 Subject: [PATCH 15/15] style(agent-bff): apply lint fixes to the review round --- packages/agent-bff/src/data/agent-query.ts | 2 +- .../agent-bff/test/openapi/openapi-unfolded.test.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts index f2ca58fca3..f89166a9da 100644 --- a/packages/agent-bff/src/data/agent-query.ts +++ b/packages/agent-bff/src/data/agent-query.ts @@ -1,5 +1,5 @@ -import type { BffHttpError } from '../http/bff-http-error'; import type { PageInput, SortClauseInput } from './request-schemas'; +import type { BffHttpError } from '../http/bff-http-error'; import type { Logger } from '../ports/logger-port'; import type { ZodType, z } from 'zod'; diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index 617bec2cb0..b545214420 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -221,12 +221,15 @@ describe('the unfolded document', () => { ['My%20Coll/count'], ['My%20Coll/relations/orders/list'], ['My%20Coll/relations/orders/count'], - ])('should say on %s that an undeclared key is rejected, in the body and the tree alike', path => { - const request = requestSchema(path) as unknown as { description: string }; + ])( + 'should say on %s that an undeclared key is rejected, in the body and the tree alike', + path => { + const request = requestSchema(path) as unknown as { description: string }; - expect(request.description).toContain('undeclared key with 400 invalid_request'); - expect(request.description).toContain('`valu` instead of `value` is rejected'); - }); + expect(request.description).toContain('undeclared key with 400 invalid_request'); + expect(request.description).toContain('`valu` instead of `value` is rejected'); + }, + ); it('should close a per-collection sort clause, so a misspelled direction is not silently kept', () => { const sort = schemas.SortClause_My_Coll as unknown as { additionalProperties: boolean };