From bceed7c8f4e006ab6a14578c8546f6d5e8704a74 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Fri, 7 Aug 2026 15:41:26 +0200 Subject: [PATCH 1/5] feat(agent): support get-one projection via the Forest-Projection header The get-one route now reads the projection from the Forest-Projection header (format: `field1,field2,relation:subfield`), taking precedence over the `fields[...]` query params. This lets the frontend send large projections without hitting query string size limits enforced by common WAF rules (e.g. AWS WAF SizeRestrictions_QUERYSTRING, 2KB). The agent announces a new `canUseProjectionViaHeader` capability so the frontend only sends the header to agents that understand it. The existing query-string parsing is kept as fallback. Co-Authored-By: Claude Fable 5 --- packages/agent/src/routes/access/get.ts | 6 +- packages/agent/src/routes/capabilities.ts | 1 + packages/agent/src/utils/query-string.ts | 23 +++ packages/agent/test/agent-integration.test.ts | 37 +++++ packages/agent/test/routes/access/get.test.ts | 91 +++++++++++ .../agent/test/routes/capabilities.test.ts | 4 + .../agent/test/utils/query-string.test.ts | 142 ++++++++++++++++++ 7 files changed, 303 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/routes/access/get.ts b/packages/agent/src/routes/access/get.ts index 4e7af200bf..718a50348c 100644 --- a/packages/agent/src/routes/access/get.ts +++ b/packages/agent/src/routes/access/get.ts @@ -24,10 +24,14 @@ export default class GetRoute extends CollectionRoute { ), }); + const projection = + QueryStringParser.parseProjectionFromHeader(this.collection, context) ?? + QueryStringParser.parseProjection(this.collection, context); + const records = await this.collection.list( QueryStringParser.parseCaller(context), filter, - QueryStringParser.parseProjectionWithPks(this.collection, context), + projection.withPks(this.collection), ); if (!records.length) { diff --git a/packages/agent/src/routes/capabilities.ts b/packages/agent/src/routes/capabilities.ts index 02fe46ab8b..744bccc702 100644 --- a/packages/agent/src/routes/capabilities.ts +++ b/packages/agent/src/routes/capabilities.ts @@ -39,6 +39,7 @@ export default class Capabilities extends BaseRoute { ), agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: diff --git a/packages/agent/src/utils/query-string.ts b/packages/agent/src/utils/query-string.ts index 8b726af409..6b0d978c36 100644 --- a/packages/agent/src/utils/query-string.ts +++ b/packages/agent/src/utils/query-string.ts @@ -71,6 +71,29 @@ export default class QueryStringParser { } } + static parseProjectionFromHeader(collection: Collection, context: Context): Projection | null { + const header = context.request.headers['forest-projection']?.toString().trim(); + if (!header) return null; + + try { + const fields = header.split(',').map(field => field.trim()); + + // Keep parity with the `fields[...]` query params, which cannot express + // projections deeper than one relation level. + const nestedField = fields.find(field => field.split(':').length > 2); + + if (nestedField) { + throw new ValidationError(`nested projections are not supported ('${nestedField}')`); + } + + ProjectionValidator.validate(collection, fields); + + return new Projection(...fields); + } catch (e) { + throw new ValidationError(`Invalid projection: ${e.message}`); + } + } + static parseProjectionWithPks(collection: Collection, context: Context): Projection { const projection = QueryStringParser.parseProjection(collection, context); diff --git a/packages/agent/test/agent-integration.test.ts b/packages/agent/test/agent-integration.test.ts index 1fec67e3fa..3e09425b00 100644 --- a/packages/agent/test/agent-integration.test.ts +++ b/packages/agent/test/agent-integration.test.ts @@ -302,6 +302,43 @@ describe('Agent Integration Tests', () => { await expect(superagent.get(`${testContext.baseUrl}/forest/users`)).rejects.toThrow(); }); + it('should allow the Forest-Projection header on CORS preflight requests', async () => { + const response = await superagent + .options(`${testContext.baseUrl}/forest/users/1`) + .set('Origin', 'https://app.forestadmin.com') + .set('Access-Control-Request-Method', 'GET') + .set('Access-Control-Request-Headers', 'authorization,forest-projection'); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-headers']).toContain('forest-projection'); + }); + + it('should honor the Forest-Projection header on get-one requests', async () => { + const token = createTestToken(); + + const response = await superagent + .get(`${testContext.baseUrl}/forest/users/1`) + .query({ timezone: 'Europe/Paris' }) + .set('Authorization', `Bearer ${token}`) + .set('Forest-Projection', 'firstName'); + + expect(response.status).toBe(200); + expect(response.body.data.attributes).toEqual({ id: 1, firstName: 'John' }); + }); + + it('should return 400 when the Forest-Projection header is invalid', async () => { + const token = createTestToken(); + + const error: { status?: number } = await superagent + .get(`${testContext.baseUrl}/forest/users/1`) + .query({ timezone: 'Europe/Paris' }) + .set('Authorization', `Bearer ${token}`) + .set('Forest-Projection', 'field-that-do-not-exist') + .catch(err => err); + + expect(error.status).toBe(400); + }); + it('should accept authenticated requests with valid JWT', async () => { const token = createTestToken(); diff --git a/packages/agent/test/routes/access/get.test.ts b/packages/agent/test/routes/access/get.test.ts index 7fe5d8e03d..edf391b5b4 100644 --- a/packages/agent/test/routes/access/get.test.ts +++ b/packages/agent/test/routes/access/get.test.ts @@ -207,6 +207,97 @@ describe('GetRoute', () => { ); }); + test('it should read the projection from the Forest-Projection header', async () => { + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ title: 'test ' }]); + services.serializer.serialize = jest.fn().mockReturnValue('test'); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': 'name,author:id' }, + customProperties: { + query: { timezone: 'Europe/Paris' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await get.handleGet(context); + + expect(dataSource.getCollection('books').list).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + ['name', 'author:id', 'id'], + ); + }); + + test('it should give precedence to the Forest-Projection header over query params', async () => { + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ title: 'test ' }]); + services.serializer.serialize = jest.fn().mockReturnValue('test'); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': 'author:id' }, + customProperties: { + query: { timezone: 'Europe/Paris', 'fields[books]': 'name' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await get.handleGet(context); + + expect(dataSource.getCollection('books').list).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + ['author:id', 'id'], + ); + }); + + test('it should not fall back to query params when the header is invalid', async () => { + const listSpy = jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ title: 'test ' }]); + listSpy.mockClear(); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': 'field-that-do-not-exist' }, + customProperties: { + query: { timezone: 'Europe/Paris', 'fields[books]': 'name' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await expect(get.handleGet(context)).rejects.toThrow('Invalid projection'); + expect(listSpy).not.toHaveBeenCalled(); + }); + + test('it should fall back to query params when the header is empty', async () => { + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ title: 'test ' }]); + services.serializer.serialize = jest.fn().mockReturnValue('test'); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': '' }, + customProperties: { + query: { timezone: 'Europe/Paris', 'fields[books]': 'name' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await get.handleGet(context); + + expect(dataSource.getCollection('books').list).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + ['name', 'id'], + ); + }); + test('it should handle multiple projected fields on a belongsTo relation', async () => { jest .spyOn(dataSource.getCollection('books'), 'list') diff --git a/packages/agent/test/routes/capabilities.test.ts b/packages/agent/test/routes/capabilities.test.ts index 1e9068ff6a..49d3b7b912 100644 --- a/packages/agent/test/routes/capabilities.test.ts +++ b/packages/agent/test/routes/capabilities.test.ts @@ -77,6 +77,7 @@ describe('Capabilities', () => { nativeQueryConnections: [{ name: 'main' }, { name: 'replica' }], agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: [], @@ -99,6 +100,7 @@ describe('Capabilities', () => { nativeQueryConnections: [], agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: [], @@ -119,6 +121,7 @@ describe('Capabilities', () => { nativeQueryConnections: [], agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: [], @@ -141,6 +144,7 @@ describe('Capabilities', () => { nativeQueryConnections: [], agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: [ diff --git a/packages/agent/test/utils/query-string.test.ts b/packages/agent/test/utils/query-string.test.ts index 3c7fad23c1..ec7e6d265c 100644 --- a/packages/agent/test/utils/query-string.test.ts +++ b/packages/agent/test/utils/query-string.test.ts @@ -220,6 +220,148 @@ describe('QueryStringParser', () => { }); }); + describe('parseProjectionFromHeader', () => { + test('should return null when the header is missing', () => { + const context = createMockContext({ + customProperties: { query: { 'fields[books]': 'id' } }, + }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toBeNull(); + }); + + test('should return null when the header is empty', () => { + const context = createMockContext({ headers: { 'forest-projection': '' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toBeNull(); + }); + + test('should return null when the header only contains whitespace', () => { + const context = createMockContext({ headers: { 'forest-projection': ' ' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toBeNull(); + }); + + test('should handle a repeated header sent as an array of values', () => { + const context = createMockContext({ + headers: { 'forest-projection': ['id', 'name'] as unknown as string }, + }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toEqual(new Projection('id', 'name')); + }); + + test('should keep duplicated fields as-is, like the query string parsing', () => { + const context = createMockContext({ headers: { 'forest-projection': 'id,id' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toEqual(new Projection('id', 'id')); + }); + + test('should throw a ValidationError on a trailing comma', () => { + const context = createMockContext({ headers: { 'forest-projection': 'id,' } }); + + const fn = () => QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(fn).toThrow(ValidationError); + }); + + test('should throw a ValidationError when a subfield targets a column', () => { + const context = createMockContext({ headers: { 'forest-projection': 'name:foo' } }); + + const fn = () => QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(fn).toThrow(ValidationError); + }); + + test('should convert the header to a valid projection', () => { + const context = createMockContext({ headers: { 'forest-projection': 'id,name' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toEqual(new Projection('id', 'name')); + }); + + test('should trim spaces around field names', () => { + const context = createMockContext({ headers: { 'forest-projection': 'id, name' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toEqual(new Projection('id', 'name')); + }); + + test('should support relation fields with the colon notation', () => { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cars', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + owner: factories.oneToOneSchema.build({ + foreignCollection: 'owner', + originKey: 'id', + originKeyTarget: 'id', + }), + }, + }), + }), + factories.collection.build({ + name: 'owner', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build(), + }, + }), + }), + ]); + + const context = createMockContext({ + headers: { 'forest-projection': 'id,owner:id,owner:name' }, + }); + + const projection = QueryStringParser.parseProjectionFromHeader( + dataSource.getCollection('cars'), + context, + ); + + expect(projection).toEqual(new Projection('id', 'owner:id', 'owner:name')); + }); + + test('should throw a ValidationError on nested projections', () => { + const context = createMockContext({ + headers: { 'forest-projection': 'id,owner:address:street' }, + }); + + const fn = () => QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(fn).toThrow(ValidationError); + expect(fn).toThrow( + "Invalid projection: nested projections are not supported ('owner:address:street')", + ); + }); + + test('should throw a ValidationError when the header contains an unknown field', () => { + const context = createMockContext({ + headers: { 'forest-projection': 'field-that-do-not-exist' }, + }); + + const fn = () => QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(fn).toThrow(ValidationError); + expect(fn).toThrow( + "Invalid projection: The 'books.field-that-do-not-exist' field was not found. Available fields are: [id,name]. Please check if the field name is correct.", + ); + }); + }); + describe('parseProjectionWithPks', () => { describe('when the request does not contain the primary keys', () => { test('should return the requested project with the primary keys', () => { From 1042ae810e9f731d0eb894f245254bc49cde6cbe Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Mon, 10 Aug 2026 15:05:26 +0200 Subject: [PATCH 2/5] fix(agent): address review feedback on the Forest-Projection header - Use a dedicated `Invalid Forest-Projection header:` error prefix so a header-caused 400 is distinguishable from a query-string one (debug logs print the query string but not the headers). - Fix the comment justifying the one-relation-level cap: the query string can technically express deeper paths, the real invariant is that the frontend never sends them on get-one. Co-Authored-By: Claude Fable 5 --- packages/agent/src/utils/query-string.ts | 6 +++--- packages/agent/test/routes/access/get.test.ts | 2 +- packages/agent/test/utils/query-string.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/utils/query-string.ts b/packages/agent/src/utils/query-string.ts index 6b0d978c36..ccbf47d72b 100644 --- a/packages/agent/src/utils/query-string.ts +++ b/packages/agent/src/utils/query-string.ts @@ -78,8 +78,8 @@ export default class QueryStringParser { try { const fields = header.split(',').map(field => field.trim()); - // Keep parity with the `fields[...]` query params, which cannot express - // projections deeper than one relation level. + // The frontend never projects deeper than one relation level on get-one; deeper + // projections stay rejected until a dedicated capability announces support for them. const nestedField = fields.find(field => field.split(':').length > 2); if (nestedField) { @@ -90,7 +90,7 @@ export default class QueryStringParser { return new Projection(...fields); } catch (e) { - throw new ValidationError(`Invalid projection: ${e.message}`); + throw new ValidationError(`Invalid Forest-Projection header: ${e.message}`); } } diff --git a/packages/agent/test/routes/access/get.test.ts b/packages/agent/test/routes/access/get.test.ts index edf391b5b4..6dbd995520 100644 --- a/packages/agent/test/routes/access/get.test.ts +++ b/packages/agent/test/routes/access/get.test.ts @@ -270,7 +270,7 @@ describe('GetRoute', () => { }, }); - await expect(get.handleGet(context)).rejects.toThrow('Invalid projection'); + await expect(get.handleGet(context)).rejects.toThrow('Invalid Forest-Projection header'); expect(listSpy).not.toHaveBeenCalled(); }); diff --git a/packages/agent/test/utils/query-string.test.ts b/packages/agent/test/utils/query-string.test.ts index ec7e6d265c..7afc620f7f 100644 --- a/packages/agent/test/utils/query-string.test.ts +++ b/packages/agent/test/utils/query-string.test.ts @@ -344,7 +344,7 @@ describe('QueryStringParser', () => { expect(fn).toThrow(ValidationError); expect(fn).toThrow( - "Invalid projection: nested projections are not supported ('owner:address:street')", + "Invalid Forest-Projection header: nested projections are not supported ('owner:address:street')", ); }); @@ -357,7 +357,7 @@ describe('QueryStringParser', () => { expect(fn).toThrow(ValidationError); expect(fn).toThrow( - "Invalid projection: The 'books.field-that-do-not-exist' field was not found. Available fields are: [id,name]. Please check if the field name is correct.", + "Invalid Forest-Projection header: The 'books.field-that-do-not-exist' field was not found. Available fields are: [id,name]. Please check if the field name is correct.", ); }); }); From eeca14e036fc9aed5c1798f8ce53a0732a77a7d2 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Mon, 10 Aug 2026 15:15:37 +0200 Subject: [PATCH 3/5] feat(agent): support nested projections in the Forest-Projection header The frontend will soon project relations of relations on get-one (e.g. `author:publisher:name`). The query string already accepts such paths, so the header now does too instead of rejecting them with a 400: shipping this before the first release keeps a single capability (canUseProjectionViaHeader implies arbitrary depth) instead of requiring a second capability handshake later. Covered end to end: parsing, intermediate pks added at every level by the route, and JSON:API serialization of nested included resources. Co-Authored-By: Claude Fable 5 --- packages/agent/src/utils/query-string.ts | 8 --- packages/agent/test/routes/access/get.test.ts | 70 +++++++++++++++++++ .../agent/test/services/serializer.test.ts | 69 ++++++++++++++++++ .../agent/test/utils/query-string.test.ts | 50 +++++++++++-- 4 files changed, 183 insertions(+), 14 deletions(-) diff --git a/packages/agent/src/utils/query-string.ts b/packages/agent/src/utils/query-string.ts index ccbf47d72b..fcd942a93d 100644 --- a/packages/agent/src/utils/query-string.ts +++ b/packages/agent/src/utils/query-string.ts @@ -78,14 +78,6 @@ export default class QueryStringParser { try { const fields = header.split(',').map(field => field.trim()); - // The frontend never projects deeper than one relation level on get-one; deeper - // projections stay rejected until a dedicated capability announces support for them. - const nestedField = fields.find(field => field.split(':').length > 2); - - if (nestedField) { - throw new ValidationError(`nested projections are not supported ('${nestedField}')`); - } - ProjectionValidator.validate(collection, fields); return new Projection(...fields); diff --git a/packages/agent/test/routes/access/get.test.ts b/packages/agent/test/routes/access/get.test.ts index 6dbd995520..ee2ecccfab 100644 --- a/packages/agent/test/routes/access/get.test.ts +++ b/packages/agent/test/routes/access/get.test.ts @@ -348,6 +348,76 @@ describe('GetRoute', () => { }); }); + describe('with a nested projection in the Forest-Projection header', () => { + test('it should request the nested projection with the intermediate pks', async () => { + const services = factories.forestAdminHttpDriverServices.build(); + const options = factories.forestAdminHttpDriverOptions.build(); + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'books', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build({ columnType: 'String' }), + author: factories.oneToOneSchema.build({ + foreignCollection: 'persons', + originKey: 'bookId', + originKeyTarget: 'id', + }), + }, + }), + }), + factories.collection.build({ + name: 'persons', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + bookId: factories.columnSchema.build({ columnType: 'Uuid' }), + addressId: factories.columnSchema.build({ columnType: 'Uuid' }), + address: factories.manyToOneSchema.build({ + foreignCollection: 'addresses', + foreignKey: 'addressId', + }), + }, + }), + }), + factories.collection.build({ + name: 'addresses', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + street: factories.columnSchema.build({ columnType: 'String' }), + }, + }), + }), + ]); + const listSpy = jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: '2d162303-78bf-599e-b197-93590ac3d315' }]); + services.serializer.serialize = jest.fn().mockReturnValue('serialized'); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': 'name,author:address:street' }, + customProperties: { + query: { timezone: 'Europe/Paris' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await get.handleGet(context); + + const projection = listSpy.mock.calls[0][2]; + expect([...projection].sort()).toEqual([ + 'author:address:id', + 'author:address:street', + 'author:id', + 'id', + 'name', + ]); + }); + }); + describe('with special characters in names', () => { it('should register routes with escaped characters', () => { const options = factories.forestAdminHttpDriverOptions.build(); diff --git a/packages/agent/test/services/serializer.test.ts b/packages/agent/test/services/serializer.test.ts index 421502670a..e936f07c68 100644 --- a/packages/agent/test/services/serializer.test.ts +++ b/packages/agent/test/services/serializer.test.ts @@ -388,6 +388,75 @@ describe('Serializer', () => { }); }); + test('serialize should serialize nested relations of relations', () => { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'book', + schema: factories.collectionSchema.build({ + fields: { + isbn: factories.columnSchema.uuidPrimaryKey().build(), + authorId: factories.columnSchema.build(), + author: factories.manyToOneSchema.build({ + foreignCollection: 'person', + foreignKey: 'authorId', + }), + }, + }), + }), + factories.collection.build({ + name: 'person', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build(), + addressId: factories.columnSchema.build(), + address: factories.manyToOneSchema.build({ + foreignCollection: 'address', + foreignKey: 'addressId', + }), + }, + }), + }), + factories.collection.build({ + name: 'address', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + street: factories.columnSchema.build(), + }, + }), + }), + ]); + + const result = setupSerializer().serialize(dataSource.getCollection('book'), { + isbn: '9780345317988', + author: { id: 'asim00', name: 'Asimov', address: { id: 'addr01', street: 'Main street' } }, + }); + + expect(result).toStrictEqual({ + data: { + type: 'book', + id: '9780345317988', + attributes: { isbn: '9780345317988' }, + relationships: { author: { data: { type: 'person', id: 'asim00' } } }, + }, + included: [ + { + type: 'address', + id: 'addr01', + attributes: { id: 'addr01', street: 'Main street' }, + }, + { + type: 'person', + id: 'asim00', + attributes: { id: 'asim00', name: 'Asimov' }, + relationships: { address: { data: { type: 'address', id: 'addr01' } } }, + }, + ], + jsonapi: { version: '1.0' }, + }); + }); + test('serialize should encode the primary key', () => { const result = setupSerializer().serialize(setupWithRelation().collections[0], { isbn: '9780345317988', diff --git a/packages/agent/test/utils/query-string.test.ts b/packages/agent/test/utils/query-string.test.ts index 7afc620f7f..3adbe79e17 100644 --- a/packages/agent/test/utils/query-string.test.ts +++ b/packages/agent/test/utils/query-string.test.ts @@ -335,17 +335,55 @@ describe('QueryStringParser', () => { expect(projection).toEqual(new Projection('id', 'owner:id', 'owner:name')); }); - test('should throw a ValidationError on nested projections', () => { + test('should support nested projections through to-one relation chains', () => { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cars', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + owner: factories.oneToOneSchema.build({ + foreignCollection: 'owner', + originKey: 'id', + originKeyTarget: 'id', + }), + }, + }), + }), + factories.collection.build({ + name: 'owner', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + addressId: factories.columnSchema.build({ columnType: 'Uuid' }), + address: factories.manyToOneSchema.build({ + foreignCollection: 'address', + foreignKey: 'addressId', + }), + }, + }), + }), + factories.collection.build({ + name: 'address', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + street: factories.columnSchema.build(), + }, + }), + }), + ]); + const context = createMockContext({ headers: { 'forest-projection': 'id,owner:address:street' }, }); - const fn = () => QueryStringParser.parseProjectionFromHeader(collectionSimple, context); - - expect(fn).toThrow(ValidationError); - expect(fn).toThrow( - "Invalid Forest-Projection header: nested projections are not supported ('owner:address:street')", + const projection = QueryStringParser.parseProjectionFromHeader( + dataSource.getCollection('cars'), + context, ); + + expect(projection).toEqual(new Projection('id', 'owner:address:street')); }); test('should throw a ValidationError when the header contains an unknown field', () => { From a9468b8f347a750a1be57c2f9be70af03f9c1f1f Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Mon, 10 Aug 2026 15:26:58 +0200 Subject: [PATCH 4/5] test(agent): cover 3-level nested projections in the Forest-Projection header Co-Authored-By: Claude Fable 5 --- .../agent/test/utils/query-string.test.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/agent/test/utils/query-string.test.ts b/packages/agent/test/utils/query-string.test.ts index 3adbe79e17..dd54ebdf9d 100644 --- a/packages/agent/test/utils/query-string.test.ts +++ b/packages/agent/test/utils/query-string.test.ts @@ -369,13 +369,29 @@ describe('QueryStringParser', () => { fields: { id: factories.columnSchema.uuidPrimaryKey().build(), street: factories.columnSchema.build(), + countryId: factories.columnSchema.build({ columnType: 'Uuid' }), + country: factories.manyToOneSchema.build({ + foreignCollection: 'country', + foreignKey: 'countryId', + }), + }, + }), + }), + factories.collection.build({ + name: 'country', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build(), }, }), }), ]); const context = createMockContext({ - headers: { 'forest-projection': 'id,owner:address:street' }, + headers: { + 'forest-projection': 'id,owner:address:street,owner:address:country:name', + }, }); const projection = QueryStringParser.parseProjectionFromHeader( @@ -383,7 +399,9 @@ describe('QueryStringParser', () => { context, ); - expect(projection).toEqual(new Projection('id', 'owner:address:street')); + expect(projection).toEqual( + new Projection('id', 'owner:address:street', 'owner:address:country:name'), + ); }); test('should throw a ValidationError when the header contains an unknown field', () => { From 2405b96d06fcd7dc3a38f97cc35fbfe81debaeb5 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Mon, 10 Aug 2026 16:04:21 +0200 Subject: [PATCH 5/5] fix(agent-bff): allow the Forest-Projection header in the CORS allow-list The BFF has no get-one route today, but its hardcoded CORS allow-list would reject the header's preflight the day it grows one; adding it now keeps every first-party CORS layer consistent with the agents. Co-Authored-By: Claude Fable 5 --- packages/agent-bff/src/cors/cors-middleware.ts | 2 +- packages/agent-bff/test/cors/cors-middleware.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/agent-bff/src/cors/cors-middleware.ts b/packages/agent-bff/src/cors/cors-middleware.ts index 0ca76a7dd4..b737af31d5 100644 --- a/packages/agent-bff/src/cors/cors-middleware.ts +++ b/packages/agent-bff/src/cors/cors-middleware.ts @@ -4,7 +4,7 @@ import { originAllowed } from './origin'; export const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; export const ALLOWED_HEADERS = - 'Authorization, Content-Type, X-Forest-Timezone, X-Forest-Bff-Key, X-Request-Id'; + 'Authorization, Content-Type, X-Forest-Timezone, X-Forest-Bff-Key, X-Request-Id, Forest-Projection'; export const PREFLIGHT_MAX_AGE_SECONDS = 600; export interface CorsMiddlewareOptions { diff --git a/packages/agent-bff/test/cors/cors-middleware.test.ts b/packages/agent-bff/test/cors/cors-middleware.test.ts index f90630e5a1..52a6194e63 100644 --- a/packages/agent-bff/test/cors/cors-middleware.test.ts +++ b/packages/agent-bff/test/cors/cors-middleware.test.ts @@ -74,13 +74,14 @@ describe('cors middleware (layer 1)', () => { expect(terminal).not.toHaveBeenCalled(); }); - it('lists all five allowed request headers', () => { + it('lists all six allowed request headers', () => { expect(ALLOWED_HEADERS.split(', ')).toEqual([ 'Authorization', 'Content-Type', 'X-Forest-Timezone', 'X-Forest-Bff-Key', 'X-Request-Id', + 'Forest-Projection', ]); });