From 843b80cb97f35deb4b46df0a80c432b4d0867839 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 3 Jul 2026 17:00:21 +0200 Subject: [PATCH] feat(agent-bff): reject nested relation field paths in top-level list and count Add a pure syntactic guard that rejects any top-level field path carrying the relation separator ":" with 422 relation_field_not_supported. Closes an agent authority gap where a relation-target field could be projected without the target collection browse/scope check. Guard + tests only; wiring into live list/count handlers belongs to the data-endpoints slice. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GqCtMftj4gwCo2AwicMNL --- packages/agent-bff/src/http/bff-http-error.ts | 9 ++ .../src/validation/relation-field-guard.ts | 15 +++ .../validation/relation-field-guard.test.ts | 101 ++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 packages/agent-bff/src/validation/relation-field-guard.ts create mode 100644 packages/agent-bff/test/validation/relation-field-guard.test.ts diff --git a/packages/agent-bff/src/http/bff-http-error.ts b/packages/agent-bff/src/http/bff-http-error.ts index dbba05fc4a..789db7c8bb 100644 --- a/packages/agent-bff/src/http/bff-http-error.ts +++ b/packages/agent-bff/src/http/bff-http-error.ts @@ -79,3 +79,12 @@ export function missingTimezone( export function invalidTimezone(value: string): BffHttpError { return new BffHttpError(400, 'invalid_timezone', `Invalid timezone: "${value}"`); } + +export function relationFieldNotSupported(fields: string[]): BffHttpError { + return new BffHttpError( + 422, + 'relation_field_not_supported', + 'Nested relation field paths are not supported on top-level list and count', + { fields }, + ); +} diff --git a/packages/agent-bff/src/validation/relation-field-guard.ts b/packages/agent-bff/src/validation/relation-field-guard.ts new file mode 100644 index 0000000000..8f6887f419 --- /dev/null +++ b/packages/agent-bff/src/validation/relation-field-guard.ts @@ -0,0 +1,15 @@ +import { relationFieldNotSupported } from '../http/bff-http-error'; + +const RELATION_SEPARATOR = ':'; + +export default function assertNoRelationFieldPaths(paths: string[]): void { + const offending: string[] = []; + + for (const path of paths) { + if (path.includes(RELATION_SEPARATOR) && !offending.includes(path)) { + offending.push(path); + } + } + + if (offending.length > 0) throw relationFieldNotSupported(offending); +} diff --git a/packages/agent-bff/test/validation/relation-field-guard.test.ts b/packages/agent-bff/test/validation/relation-field-guard.test.ts new file mode 100644 index 0000000000..a88c79a331 --- /dev/null +++ b/packages/agent-bff/test/validation/relation-field-guard.test.ts @@ -0,0 +1,101 @@ +import { type BffHttpError, toErrorBody } from '../../src/http/bff-http-error'; +import assertNoRelationFieldPaths from '../../src/validation/relation-field-guard'; + +function captureError(fn: () => void): unknown { + try { + fn(); + } catch (error) { + return error; + } + + throw new Error('expected to throw'); +} + +describe('assertNoRelationFieldPaths', () => { + it('rejects a nested relation path from a list projection', () => { + expect(() => assertNoRelationFieldPaths(['id', 'company:name'])).toThrow( + expect.objectContaining({ + type: 'relation_field_not_supported', + status: 422, + details: { fields: ['company:name'] }, + }), + ); + }); + + it('rejects a nested relation path from a list filter field', () => { + expect(() => assertNoRelationFieldPaths(['owner:email'])).toThrow( + expect.objectContaining({ + type: 'relation_field_not_supported', + status: 422, + details: { fields: ['owner:email'] }, + }), + ); + }); + + it('rejects a nested relation path from a list sort field', () => { + expect(() => assertNoRelationFieldPaths(['company:createdAt'])).toThrow( + expect.objectContaining({ + type: 'relation_field_not_supported', + status: 422, + details: { fields: ['company:createdAt'] }, + }), + ); + }); + + it('rejects a nested relation path from a count filter field', () => { + expect(() => assertNoRelationFieldPaths(['status', 'company:tier'])).toThrow( + expect.objectContaining({ + type: 'relation_field_not_supported', + status: 422, + details: { fields: ['company:tier'] }, + }), + ); + }); + + it('lists every offending path when several are present', () => { + expect(() => + assertNoRelationFieldPaths(['id', 'company:name', 'title', 'owner:email']), + ).toThrow( + expect.objectContaining({ + details: { fields: ['company:name', 'owner:email'] }, + }), + ); + }); + + it('reports a single offending path as an array of length 1', () => { + const error = captureError(() => assertNoRelationFieldPaths(['company:name'])); + + expect((error as BffHttpError).details).toEqual({ fields: ['company:name'] }); + }); + + it('dedupes an offending path seen on two surfaces, keeping first-seen order', () => { + expect(() => + assertNoRelationFieldPaths(['company:name', 'id', 'company:name', 'owner:email']), + ).toThrow( + expect.objectContaining({ + details: { fields: ['company:name', 'owner:email'] }, + }), + ); + }); + + it('passes through direct field paths unchanged', () => { + expect(() => assertNoRelationFieldPaths(['id', 'title', 'createdAt'])).not.toThrow(); + }); + + it('passes through an empty path list', () => { + expect(() => assertNoRelationFieldPaths([])).not.toThrow(); + }); + + it('serializes to the wire error envelope with details.fields as an array', () => { + const error = captureError(() => assertNoRelationFieldPaths(['company:name', 'owner:email'])); + + expect(toErrorBody(error as BffHttpError)).toEqual({ + error: { + type: 'relation_field_not_supported', + status: 422, + message: 'Nested relation field paths are not supported on top-level list and count', + details: { fields: ['company:name', 'owner:email'] }, + }, + }); + }); +});