From 56df8b1c93deff6b3af9c5656501ab8cb9531781 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 14:33:05 +0200 Subject: [PATCH 01/29] refactor(agent-bff): expose the schema cache in the read-model bundle --- packages/agent-bff/src/cli-core.ts | 14 ++++++++------ .../agent-bff/src/read-model/create-read-model.ts | 3 ++- .../test/read-model/create-read-model.test.ts | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 01732f8da2..82d742949f 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -3,6 +3,7 @@ import type { UnfoldSource } from './openapi/unfolded-document'; import type { Logger } from './ports/logger-port'; import type { Metrics } from './ports/metrics-port'; import type ReadModelStore from './read-model/read-model-store'; +import type SchemaCache from './read-model/schema-cache'; import type { Middleware } from 'koa'; import { bodyParser } from '@koa/bodyparser'; @@ -162,8 +163,9 @@ function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware | return createApiKeyMiddleware({ authenticator, logger }); } -interface ReadModelBundle { +interface AgentEdgeReadModel { store: ReadModelStore; + schemaCache: SchemaCache; apiKeyConfig: ResolvedApiKeyConfig; } @@ -176,19 +178,19 @@ function resolveReadModelBundle( config: BFFConfig, logger: Logger, metrics?: Metrics, -): ReadModelBundle | undefined { +): AgentEdgeReadModel | undefined { const apiKeyConfig = resolveApiKeyConfig(config); if (!apiKeyConfig) return undefined; - const { store } = createReadModel({ + const { store, schemaCache } = createReadModel({ forestServerUrl: apiKeyConfig.forestServerUrl, envSecret: apiKeyConfig.forestEnvSecret, logger, metrics, }); - return { store, apiKeyConfig }; + return { store, schemaCache, apiKeyConfig }; } /** @@ -198,7 +200,7 @@ function resolveReadModelBundle( * the two report a missing configuration differently. */ function toUnfoldSource( - bundle: ReadModelBundle | undefined, + bundle: AgentEdgeReadModel | undefined, config: BFFConfig, logger: Logger, ): UnfoldSource | undefined { @@ -226,7 +228,7 @@ export function resolveUnfoldSource(config: BFFConfig, logger: Logger): UnfoldSo // The data middleware falls through to the action middleware on a non-data path. function buildAgentRouteMiddlewares( - bundle: ReadModelBundle | undefined, + bundle: AgentEdgeReadModel | undefined, config: BFFConfig, logger: Logger, ): Middleware[] { diff --git a/packages/agent-bff/src/read-model/create-read-model.ts b/packages/agent-bff/src/read-model/create-read-model.ts index 45c3aec4c4..ab1a5d9dde 100644 --- a/packages/agent-bff/src/read-model/create-read-model.ts +++ b/packages/agent-bff/src/read-model/create-read-model.ts @@ -20,6 +20,7 @@ export interface CreateReadModelOptions { export interface ReadModelBundle { store: ReadModelStore; actionEndpointResolver: ActionEndpointResolver; + schemaCache: SchemaCache; } export default function createReadModel({ @@ -40,5 +41,5 @@ export default function createReadModel({ resolvedMetrics, ); - return { store, actionEndpointResolver }; + return { store, actionEndpointResolver, schemaCache }; } diff --git a/packages/agent-bff/test/read-model/create-read-model.test.ts b/packages/agent-bff/test/read-model/create-read-model.test.ts index 0687b070c2..a1fa46d558 100644 --- a/packages/agent-bff/test/read-model/create-read-model.test.ts +++ b/packages/agent-bff/test/read-model/create-read-model.test.ts @@ -30,6 +30,20 @@ describe('createReadModel', () => { expect(model.isActionAllowed('users', 'ban')).toBe(true); }); + it('should expose the same schema cache the store reads from', async () => { + const { store, schemaCache } = createReadModel({ + forestServerUrl: 'x', + envSecret: 'y', + metrics: makeMetrics(), + }); + + await store.getReadModel(); + const collections = await schemaCache.get(); + + expect(getSchema).toHaveBeenCalledTimes(1); + expect(collections.map(entry => entry.name)).toEqual(['users']); + }); + it('should wire an action-endpoint resolver that resolves mapped actions', async () => { const { actionEndpointResolver } = createReadModel({ forestServerUrl: 'x', From 248623913def9ada836f68a3d0f692b04396ec65 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 14:37:01 +0200 Subject: [PATCH 02/29] feat(agent-bff): serialize the allow-listed agent schema as a context contract --- .../agent-bff/src/context/build-context.ts | 97 ++++++++++ .../test/context/build-context.test.ts | 169 ++++++++++++++++++ packages/agent-bff/test/context/fixtures.ts | 80 +++++++++ 3 files changed, 346 insertions(+) create mode 100644 packages/agent-bff/src/context/build-context.ts create mode 100644 packages/agent-bff/test/context/build-context.test.ts create mode 100644 packages/agent-bff/test/context/fixtures.ts diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts new file mode 100644 index 0000000000..fe740a10df --- /dev/null +++ b/packages/agent-bff/src/context/build-context.ts @@ -0,0 +1,97 @@ +import type ReadModel from '../read-model/read-model'; +import type { + ForestSchemaAction, + ForestSchemaCollection, + ForestSchemaField, +} from '@forestadmin/forestadmin-client'; + +export interface ContextActionField { + field: string; + type: unknown; + isRequired?: boolean; + defaultValue?: unknown; + enums?: string[]; +} + +export interface ContextAction { + id: string; + name: string; + type: ForestSchemaAction['type']; + fields: ContextActionField[]; +} + +export interface ContextField { + field: string; + type: unknown; + reference?: string; + inverseOf?: string; + isRequired?: boolean; + isReadOnly?: boolean; +} + +export interface ContextCollection { + name: string; + fields: ContextField[]; + actions: ContextAction[]; +} + +export interface AgentContext { + collections: ContextCollection[]; + meta: { schemaRevision: number }; +} + +function toContextField(field: ForestSchemaField): ContextField { + const serialized: ContextField = { field: field.field, type: field.type }; + + if (field.reference) serialized.reference = field.reference; + if (field.inverseOf) serialized.inverseOf = field.inverseOf; + if (field.isRequired) serialized.isRequired = true; + if (field.isReadOnly) serialized.isReadOnly = true; + + return serialized; +} + +function toContextActionField(field: ForestSchemaAction['fields'][number]): ContextActionField { + const serialized: ContextActionField = { field: field.field, type: field.type }; + + if (field.isRequired !== undefined) serialized.isRequired = field.isRequired; + if (field.defaultValue !== undefined) serialized.defaultValue = field.defaultValue; + if (field.enums !== undefined) serialized.enums = [...field.enums]; + + return serialized; +} + +function toContextAction(action: ForestSchemaAction): ContextAction { + return { + id: action.id, + name: action.name, + type: action.type, + fields: (action.fields ?? []).map(toContextActionField), + }; +} + +function toContextCollection( + collection: ForestSchemaCollection, + readModel: ReadModel, +): ContextCollection { + return { + name: collection.name, + fields: (collection.fields ?? []).map(toContextField), + actions: (collection.actions ?? []) + .filter(action => readModel.isActionAllowed(collection.name, action.name)) + .map(toContextAction), + }; +} + +export default function buildContext( + collections: ForestSchemaCollection[], + readModel: ReadModel, + schemaRevision: number, +): AgentContext { + return { + collections: collections + .filter(collection => readModel.isCollectionAllowed(collection.name)) + .map(collection => toContextCollection(collection, readModel)), + meta: { schemaRevision }, + }; +} diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts new file mode 100644 index 0000000000..503f22b1c8 --- /dev/null +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -0,0 +1,169 @@ +import schemaCoveringEveryContractShape from './fixtures'; +import buildContext from '../../src/context/build-context'; +import ReadModel from '../../src/read-model/read-model'; + +describe('buildContext', () => { + const schema = schemaCoveringEveryContractShape(); + const readModel = new ReadModel(schema); + + function usersOf(context: ReturnType) { + return context.collections.find(collection => collection.name === 'users'); + } + + function fieldNamed(context: ReturnType, name: string) { + return usersOf(context)?.fields.find(entry => entry.field === name); + } + + describe('when the schema carries every field shape', () => { + it('should pass a primitive, an array and a composite type through untouched', () => { + const context = buildContext(schema, readModel, 1); + + expect(fieldNamed(context, 'id')?.type).toBe('Uuid'); + expect(fieldNamed(context, 'tagsWithArrayType')?.type).toEqual(['String']); + expect(fieldNamed(context, 'addressWithCompositeType')?.type).toEqual({ + fields: [{ field: 'city', type: 'String' }], + }); + }); + + it('should carry reference and inverseOf on a relation that has them', () => { + const context = buildContext(schema, readModel, 1); + + expect(fieldNamed(context, 'ordersHasManyWithInverseOf')).toEqual({ + field: 'ordersHasManyWithInverseOf', + type: 'String', + reference: 'orders.customerId', + inverseOf: 'customer', + }); + }); + + it('should omit inverseOf on a relation that lacks it', () => { + const context = buildContext(schema, readModel, 1); + const relation = fieldNamed(context, 'teamBelongsToWithoutInverseOf'); + + expect(relation).toEqual({ + field: 'teamBelongsToWithoutInverseOf', + type: 'String', + reference: 'teams.id', + }); + expect(relation).not.toHaveProperty('inverseOf'); + }); + + it('should keep a polymorphic relation, which carries no reference', () => { + const context = buildContext(schema, readModel, 1); + + expect(fieldNamed(context, 'ownerPolymorphic')).toEqual({ + field: 'ownerPolymorphic', + type: 'String', + }); + }); + + it('should carry isRequired and isReadOnly only when true', () => { + const context = buildContext(schema, readModel, 1); + + expect(fieldNamed(context, 'emailRequired')?.isRequired).toBe(true); + expect(fieldNamed(context, 'lockedReadOnly')?.isReadOnly).toBe(true); + expect(fieldNamed(context, 'id')).not.toHaveProperty('isRequired'); + expect(fieldNamed(context, 'id')).not.toHaveProperty('isReadOnly'); + }); + }); + + describe('when the schema carries actions', () => { + it('should expose single, bulk and global actions alike', () => { + const context = buildContext(schema, readModel, 1); + + expect(usersOf(context)?.actions.map(entry => [entry.name, entry.type])).toEqual([ + ['Ban user', 'single'], + ['Export all', 'global'], + ['Archive', 'bulk'], + ]); + }); + + it('should drop an action with no endpoint, matching the read-model allow-list', () => { + const context = buildContext(schema, readModel, 1); + + expect(readModel.isActionAllowed('users', 'Endpointless action')).toBe(false); + expect(usersOf(context)?.actions.map(entry => entry.name)).not.toContain( + 'Endpointless action', + ); + }); + + it('should serialize an action field with its enums and default value', () => { + const context = buildContext(schema, readModel, 1); + const banAction = usersOf(context)?.actions.find(entry => entry.name === 'Ban user'); + + expect(banAction?.fields).toEqual([ + { + field: 'reason', + type: 'Enum', + isRequired: true, + defaultValue: 'spam', + enums: ['spam', 'abuse'], + }, + ]); + }); + }); + + describe('when a collection is not in the read-model allow-list', () => { + it('should omit it', () => { + const restricted = new ReadModel(schema.filter(collection => collection.name === 'users')); + + const context = buildContext(schema, restricted, 1); + + expect(context.collections.map(collection => collection.name)).toEqual(['users']); + }); + }); + + describe('when a collection name is unusual', () => { + it('should keep a dotted name and a name carrying a space verbatim', () => { + const context = buildContext(schema, readModel, 1); + + expect(context.collections.map(collection => collection.name)).toEqual([ + 'users', + 'User.address', + 'My Coll', + 'collectionWithoutFieldsNorActions', + ]); + }); + }); + + describe('when a collection has neither fields nor actions', () => { + it('should serialize it with empty lists rather than throwing', () => { + const context = buildContext(schema, readModel, 1); + + expect( + context.collections.find( + collection => collection.name === 'collectionWithoutFieldsNorActions', + ), + ).toEqual({ name: 'collectionWithoutFieldsNorActions', fields: [], actions: [] }); + }); + }); + + describe('meta', () => { + it('should carry the schema revision it was built from', () => { + expect(buildContext(schema, readModel, 7).meta).toEqual({ schemaRevision: 7 }); + }); + }); + + describe('identity', () => { + it('should expose collections and meta only, so no identity can ride along', () => { + const context = buildContext(schema, readModel, 1); + + expect(Object.keys(context).sort()).toEqual(['collections', 'meta']); + expect(Object.keys(context.meta)).toEqual(['schemaRevision']); + }); + + it('should expose only the agreed keys on a collection, a field and an action', () => { + const context = buildContext(schema, readModel, 1); + const users = usersOf(context); + + expect(Object.keys(users ?? {}).sort()).toEqual(['actions', 'fields', 'name']); + expect(Object.keys(fieldNamed(context, 'ordersHasManyWithInverseOf') ?? {}).sort()).toEqual([ + 'field', + 'inverseOf', + 'reference', + 'type', + ]); + expect(Object.keys(users?.actions[0] ?? {}).sort()).toEqual(['fields', 'id', 'name', 'type']); + }); + }); +}); diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts new file mode 100644 index 0000000000..1f9b88f10b --- /dev/null +++ b/packages/agent-bff/test/context/fixtures.ts @@ -0,0 +1,80 @@ +import type { + ForestSchemaAction, + ForestSchemaCollection, + ForestSchemaField, +} from '@forestadmin/forestadmin-client'; + +function field(name: string, type: unknown, extra: Partial = {}) { + return { + field: name, + type, + enum: null, + reference: null, + isReadOnly: false, + isRequired: false, + isPrimaryKey: name === 'id', + ...extra, + } as ForestSchemaField; +} + +function action( + name: string, + type: ForestSchemaAction['type'], + endpoint: string | undefined, + fields: ForestSchemaAction['fields'] = [], +) { + return { + id: `${name}-id`, + name, + type, + endpoint, + download: false, + fields, + hooks: { load: false, change: [] }, + } as ForestSchemaAction; +} + +export default function schemaCoveringEveryContractShape(): ForestSchemaCollection[] { + return [ + { + name: 'users', + fields: [ + field('id', 'Uuid'), + field('tagsWithArrayType', ['String']), + field('addressWithCompositeType', { fields: [{ field: 'city', type: 'String' }] }), + field('lockedReadOnly', 'Boolean', { isReadOnly: true }), + field('emailRequired', 'String', { isRequired: true }), + field('ordersHasManyWithInverseOf', 'String', { + reference: 'orders.customerId', + relationship: 'HasMany', + inverseOf: 'customer', + }), + field('teamBelongsToWithoutInverseOf', 'String', { + reference: 'teams.id', + relationship: 'BelongsTo', + }), + field('ownerPolymorphic', 'String', { + relationship: 'BelongsTo', + polymorphicReferencedModels: ['users', 'teams'], + }), + ], + actions: [ + action('Ban user', 'single', '/forest/users/actions/ban', [ + { + field: 'reason', + type: 'Enum', + isRequired: true, + defaultValue: 'spam', + enums: ['spam', 'abuse'], + }, + ]), + action('Export all', 'global', '/forest/users/actions/export'), + action('Archive', 'bulk', '/forest/users/actions/archive'), + action('Endpointless action', 'single', undefined), + ], + }, + { name: 'User.address', fields: [field('city', 'String')], actions: [] }, + { name: 'My Coll', fields: [field('id', 'Number')], actions: [] }, + { name: 'collectionWithoutFieldsNorActions' } as ForestSchemaCollection, + ]; +} From 0b9973e90920b3ff23274595c2189d0d6ec0ef4d Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 14:51:02 +0200 Subject: [PATCH 03/29] feat(agent-bff): serve GET /agent/v1/context and declare it in the OpenAPI document --- packages/agent-bff/src/cli-core.ts | 8 ++ .../src/context/context-routes-middleware.ts | 63 ++++++++++ .../agent-bff/src/http/bff-local-errors.ts | 10 ++ .../agent-bff/src/openapi/openapi-document.ts | 28 ++++- packages/agent-bff/src/openapi/schemas.ts | 54 ++++++++ .../context/context-routes-middleware.test.ts | 118 ++++++++++++++++++ .../test/openapi/openapi-cli.test.ts | 9 +- .../test/openapi/openapi-document.test.ts | 76 ++++++----- .../test/openapi/openapi-routes.test.ts | 6 +- .../openapi/openapi-spec-validity.test.ts | 5 +- .../test/openapi/openapi-unfolded.test.ts | 11 +- 11 files changed, 340 insertions(+), 48 deletions(-) create mode 100644 packages/agent-bff/src/context/context-routes-middleware.ts create mode 100644 packages/agent-bff/test/context/context-routes-middleware.test.ts diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 82d742949f..842080f651 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -17,6 +17,7 @@ import createApiKeyMiddleware from './api-key/api-key-middleware'; import createResolveCache from './api-key/resolve-cache'; import createAuthModeMiddleware from './auth/auth-mode-middleware'; import { parseConfig } from './config/env-config'; +import createContextRoutesMiddleware from './context/context-routes-middleware'; import createCorsMiddleware from './cors/cors-middleware'; import createPerKeyOriginMiddleware from './cors/per-key-origin'; import createDataRoutesMiddleware from './data/data-routes-middleware'; @@ -267,6 +268,12 @@ function buildAgentRouteMiddlewares( ]; } +function buildContextMiddlewares(bundle: AgentEdgeReadModel | undefined): Middleware[] { + if (!bundle) return []; + + return [createContextRoutesMiddleware({ store: bundle.store, schemaCache: bundle.schemaCache })]; +} + function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] { const { forestAuthSecret, defaultTimezone } = config; @@ -287,6 +294,7 @@ function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] apiKeyStep, createPerKeyOriginMiddleware(), createOpenApiRoutes({ version, enabled: config.openapiEnabled, source }), + ...buildContextMiddlewares(bundle), createTimezoneMiddleware({ defaultTimezone }), ...buildAgentRouteMiddlewares(bundle, config, logger), ]; diff --git a/packages/agent-bff/src/context/context-routes-middleware.ts b/packages/agent-bff/src/context/context-routes-middleware.ts new file mode 100644 index 0000000000..10f4fcc5c0 --- /dev/null +++ b/packages/agent-bff/src/context/context-routes-middleware.ts @@ -0,0 +1,63 @@ +import type ReadModelStore from '../read-model/read-model-store'; +import type SchemaCache from '../read-model/schema-cache'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; +import type { Middleware } from 'koa'; + +import buildContext from './build-context'; +import { resolveReadModel } from '../http/agent-route-helpers'; +import { oauthRequired, schemaUnavailable } from '../http/bff-local-errors'; +import SchemaUnavailableError from '../read-model/errors'; + +export const CONTEXT_ROUTE = '/agent/v1/context'; + +const MAX_GENERATION_RETRIES = 3; + +export interface ContextRoutesMiddlewareOptions { + store: ReadModelStore; + schemaCache: SchemaCache; +} + +async function readSchema(schemaCache: SchemaCache): Promise { + try { + return await schemaCache.get(); + } catch (error) { + if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); + throw error; + } +} + +async function buildStableContext( + { store, schemaCache }: ContextRoutesMiddlewareOptions, + attemptsLeft = MAX_GENERATION_RETRIES, +): Promise> { + if (attemptsLeft <= 0) { + throw new Error('Schema generation kept changing while building the context contract'); + } + + const readModel = await resolveReadModel(store); + const { revision } = schemaCache; + const collections = await readSchema(schemaCache); + + if (schemaCache.revision !== revision) { + return buildStableContext({ store, schemaCache }, attemptsLeft - 1); + } + + return buildContext(collections, readModel, schemaCache.revision); +} + +export default function createContextRoutesMiddleware( + options: ContextRoutesMiddlewareOptions, +): Middleware { + return async function contextRoutesMiddleware(ctx, next) { + if (ctx.path !== CONTEXT_ROUTE || ctx.method !== 'GET') { + await next(); + + return; + } + + if (ctx.state.authMode !== 'oauth') throw oauthRequired(); + + ctx.status = 200; + ctx.body = await buildStableContext(options); + }; +} diff --git a/packages/agent-bff/src/http/bff-local-errors.ts b/packages/agent-bff/src/http/bff-local-errors.ts index 427a6b7778..78b931059d 100644 --- a/packages/agent-bff/src/http/bff-local-errors.ts +++ b/packages/agent-bff/src/http/bff-local-errors.ts @@ -52,6 +52,16 @@ export function forestIdentityNotAllowed(message = 'Forest identity not allowed' return new BffHttpError(403, 'forest_identity_not_allowed', message); } +export function oauthRequired( + message = 'This route requires an OAuth session; an API key is not accepted', +): BffHttpError { + return new BffHttpError(403, 'oauth_required', message); +} + +export function unknownRoute(message = 'Unknown route'): BffHttpError { + return new BffHttpError(404, 'unknown_route', message); +} + export function permissionsUnavailable( retryAfter: number, message = 'Permissions are unavailable', diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index ccb63074c6..a5229cf729 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -6,6 +6,7 @@ import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-ope import ComponentPool from './component-pool'; import { ActionRequestSchema, + ContextResponseSchema, CountRequestSchema, CountResponseSchema, ErrorResponseSchema, @@ -30,7 +31,7 @@ const SECURITY = [{ [API_KEY_SCHEME]: [] }]; const ERROR_STATUSES: Record = { 400: 'Malformed body, a malformed URL-encoded path segment, an invalid filter operator, a filter nested too deep, ambiguous credentials, an unsupported page, a missing or invalid timezone, an unknown submitted action field, or a rejected action form (type action_error)', 401: 'Missing, invalid, or expired credentials', - 403: 'The action needs approval before it runs (the body carries the approving roles), the Forest identity behind the API key is not allowed, the origin is not allowed for this key, or the agent refused the collection, relation, or action', + 403: 'The action needs approval before it runs (the body carries the approving roles), the Forest identity behind the API key is not allowed, the origin is not allowed for this key, the route requires an OAuth session and an API key was presented (type oauth_required), or the agent refused the collection, relation, or action', 404: 'Unknown collection, relation, or action', 413: `The request body exceeds the BFF limit of ${BODY_LIMIT}`, 415: 'The request declares a character set the server cannot decode. Other content types are NOT rejected: a form-urlencoded body is parsed and validated like JSON (its values arrive as strings, so typed fields such as page.limit fail with 400), while any other non-JSON content type is read as an absent body, silently dropping filters and pagination', @@ -277,9 +278,9 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): type: 'http', scheme: 'bearer', description: - 'Mode 1: the BFF session token issued after the OAuth login. It authenticates the caller ' + - 'but the data and action routes reject it until the BFF mints an agent token from the ' + - 'OAuth principal, so no operation lists it yet. Use the API key today.', + 'Mode 1: the BFF session token issued after the OAuth login. The context contract requires ' + + 'it and accepts nothing else, since it is the only credential carrying a session. The data ' + + 'and action routes list the API key instead.', }); registry.registerComponent('securitySchemes', API_KEY_SCHEME, { type: 'apiKey', @@ -288,6 +289,25 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): description: 'Mode 2: a BFF API key. Never send both this and an Authorization header.', }); + registry.registerPath({ + method: 'get', + path: `${ROUTE_PREFIX}/context`, + operationId: 'getContext', + summary: 'Read the exposed schema contract', + security: [{ [SESSION_SCHEME]: [] }], + request: {}, + responses: { + 200: { + description: 'The exposed schema: collections, typed fields, relations and actions', + content: { 'application/json': { schema: ContextResponseSchema } }, + }, + 401: errorRefs.byStatus['401'], + 403: errorRefs.byStatus['403'], + 500: errorRefs.byStatus['500'], + 503: errorRefs.byStatus['503'], + }, + }); + if (unfolding) { registerUnfoldedPaths( { diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index f2655f5c48..058ba3d5ad 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -143,6 +143,60 @@ export const CountResponseSchema = z 'collection disables count. The pair never disagrees.', }); +const ContextFieldTypeSchema = z.unknown().openapi('ContextFieldType', { + description: + 'The field type, passed through from the agent wire format without normalization: a string ' + + 'for a primitive, an array of types for an array field, or an object carrying `fields` for a ' + + 'composite. A consumer that only understands primitives should ignore the other two rather ' + + 'than fail.', +}); + +const ContextFieldSchema = z.object({ + field: z.string(), + type: ContextFieldTypeSchema, + reference: z.string().optional(), + inverseOf: z.string().optional(), + isRequired: z.boolean().optional(), + isReadOnly: z.boolean().optional(), +}); + +const ContextActionSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(['single', 'bulk', 'global']), + fields: z.array( + z.object({ + field: z.string(), + type: ContextFieldTypeSchema, + isRequired: z.boolean().optional(), + defaultValue: z.unknown().optional(), + enums: z.array(z.string()).optional(), + }), + ), +}); + +export const ContextResponseSchema = z + .object({ + collections: z.array( + z.object({ + name: z.string(), + fields: z.array(ContextFieldSchema), + actions: z.array(ContextActionSchema), + }), + ), + meta: z.object({ schemaRevision: z.number() }), + }) + .openapi('ContextResponse', { + description: + 'The schema the agent exposes, filtered by the allow-list: collection names, typed fields ' + + 'with their relation markers, and the custom actions that carry an endpoint. Field types ' + + 'are passed through from the agent wire format, so a type is a string, an array of types, ' + + 'or a composite object. The document carries no rendering, environment, project or team ' + + 'identity, and it is NOT filtered by the caller permissions: cross it with ' + + '`/agent/v1/permissions` for that. `meta.schemaRevision` increments whenever the BFF ' + + 'refreshes its schema, and resets when the BFF restarts.', + }); + export const ErrorResponseSchema = z .object({ error: z.object({ diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts new file mode 100644 index 0000000000..26ed526de0 --- /dev/null +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -0,0 +1,118 @@ +import type { SchemaFetcher } from '../../src/read-model/forest-schema-client'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import Koa from 'koa'; +import request from 'supertest'; + +import schemaCoveringEveryContractShape from './fixtures'; +import createContextRoutesMiddleware from '../../src/context/context-routes-middleware'; +import createErrorMiddleware from '../../src/http/error-middleware'; +import CapabilitiesCache from '../../src/read-model/capabilities-cache'; +import ReadModelStore from '../../src/read-model/read-model-store'; +import SchemaCache from '../../src/read-model/schema-cache'; +import { makeMetrics } from '../read-model/fixtures'; + +const ROUTE = '/agent/v1/context'; + +function makeApp(fetchSchema: jest.Mock, authMode: string | undefined = 'oauth') { + const fetcher: SchemaFetcher = { fetchSchema }; + const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics() }); + const store = new ReadModelStore(schemaCache, new CapabilitiesCache({})); + + const app = new Koa(); + app.use(createErrorMiddleware({ logger: () => {} })); + app.use(async (ctx, next) => { + ctx.state.authMode = authMode; + await next(); + }); + app.use(createContextRoutesMiddleware({ store, schemaCache })); + + return { app, schemaCache }; +} + +describe('contextRoutesMiddleware', () => { + let schema: ForestSchemaCollection[]; + + beforeEach(() => { + schema = schemaCoveringEveryContractShape(); + }); + + describe('when the caller holds an OAuth session', () => { + it('should serve the contract with its collections and schema revision', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + const { app } = makeApp(fetchSchema); + + const response = await request(app.callback()).get(ROUTE); + + expect(response.status).toBe(200); + expect(response.body.collections.map((entry: { name: string }) => entry.name)).toEqual([ + 'users', + 'User.address', + 'My Coll', + 'collectionWithoutFieldsNorActions', + ]); + expect(response.body.meta).toEqual({ schemaRevision: 1 }); + }); + + it('should fetch the schema once on a cold cache and never again while it stays warm', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + const { app } = makeApp(fetchSchema); + + await request(app.callback()).get(ROUTE); + await request(app.callback()).get(ROUTE); + + expect(fetchSchema).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the schema cannot be read', () => { + it('should answer 503 schema_unavailable rather than an empty contract', async () => { + const fetchSchema = jest.fn().mockRejectedValue(new Error('agent down')); + const { app } = makeApp(fetchSchema); + + const response = await request(app.callback()).get(ROUTE); + + expect(response.status).toBe(503); + expect(response.body.error).toMatchObject({ type: 'schema_unavailable', status: 503 }); + }); + }); + + describe('when the caller authenticated with an API key', () => { + it('should refuse with 403 oauth_required', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + const { app } = makeApp(fetchSchema, 'api-key'); + + const response = await request(app.callback()).get(ROUTE); + + expect(response.status).toBe(403); + expect(response.body.error).toMatchObject({ type: 'oauth_required', status: 403 }); + expect(fetchSchema).not.toHaveBeenCalled(); + }); + }); + + describe('when no timezone is supplied', () => { + it('should still answer 200, because the contract needs no timezone', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + const { app } = makeApp(fetchSchema); + + const response = await request(app.callback()).get(ROUTE).unset('X-Forest-Timezone'); + + expect(response.status).toBe(200); + }); + }); + + describe('when the path or method does not match', () => { + it('should pass through to the next middleware', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + const { app } = makeApp(fetchSchema); + app.use(async ctx => { + ctx.status = 418; + }); + + await expect(request(app.callback()).post(ROUTE)).resolves.toMatchObject({ status: 418 }); + await expect(request(app.callback()).get('/agent/v1/permissions')).resolves.toMatchObject({ + status: 418, + }); + }); + }); +}); diff --git a/packages/agent-bff/test/openapi/openapi-cli.test.ts b/packages/agent-bff/test/openapi/openapi-cli.test.ts index d12f4e8542..32b21ed473 100644 --- a/packages/agent-bff/test/openapi/openapi-cli.test.ts +++ b/packages/agent-bff/test/openapi/openapi-cli.test.ts @@ -93,7 +93,7 @@ describe('renderOpenApi', () => { it('should emit the generic document when nothing is configured to unfold against', async () => { const document = JSON.parse(await renderOpenApi({}, noopLogger)); - expect(Object.keys(document.paths)).toHaveLength(6); + expect(Object.keys(document.paths)).toHaveLength(7); expect(document.info.description).toContain('Paths are generic'); }); @@ -101,6 +101,7 @@ describe('renderOpenApi', () => { const document = JSON.parse(await renderOpenApi(VALID_ENV, noopLogger)); expect(Object.keys(document.paths).sort()).toEqual([ + '/agent/v1/context', '/agent/v1/orders/count', '/agent/v1/orders/list', '/agent/v1/users/actions/Mark%20as%20paid/execute', @@ -145,14 +146,14 @@ describe('renderOpenApi', () => { await renderOpenApi({ ...VALID_ENV, AGENT_URL: undefined }, noopLogger), ); - expect(Object.keys(document.paths)).toHaveLength(6); + expect(Object.keys(document.paths)).toHaveLength(7); expect(fetchSchema).not.toHaveBeenCalled(); }); it('should ignore a broken server-only setting, which the export does not use', async () => { const document = JSON.parse(await renderOpenApi({ HTTP_PORT: 'nope' }, noopLogger)); - expect(Object.keys(document.paths)).toHaveLength(6); + expect(Object.keys(document.paths)).toHaveLength(7); }); it('should still reject a broken setting once the deployment asks to be unfolded', async () => { @@ -230,7 +231,7 @@ describe('dispatchCli', () => { const document = JSON.parse(stdout.mock.calls[0][0] as string); - expect(Object.keys(document.paths)).toHaveLength(6); + expect(Object.keys(document.paths)).toHaveLength(7); } finally { stdout.mockRestore(); } diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index aa8993306c..9c81d7e12f 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -22,8 +22,11 @@ const responseComponents = (document.components?.responses ?? {}) as unknown as >; function responsesOf(path: string): Record { - const { responses } = (document.paths?.[path] as { post: { responses: Record } }) - .post; + const item = document.paths?.[path] as { + post?: { responses: Record }; + get?: { responses: Record }; + }; + const { responses } = (item.post ?? item.get) as { responses: Record }; return Object.fromEntries( Object.entries(responses).map(([status, response]) => { @@ -41,6 +44,14 @@ function listResponses(): Record { return responsesOf(`${ROUTE_PREFIX}/{collection}/list`); } +function dataOperations(): { security: unknown; responses: Record }[] { + return Object.values(document.paths ?? {}) + .map( + path => (path as { post?: { security: unknown; responses: Record } }).post, + ) + .filter(operation => operation !== undefined); +} + function leafOperators(): string[] { const leaf = schemas.ConditionTreeLeaf as { properties: { operator: { enum: string[] } }; @@ -58,12 +69,13 @@ describe('generateOpenApiDocument', () => { it('should serve every path under the /agent/v1 prefix', () => { const paths = Object.keys(document.paths ?? {}); - expect(paths).toHaveLength(6); + expect(paths).toHaveLength(7); expect(paths.every(path => path.startsWith(`${ROUTE_PREFIX}/`))).toBe(true); }); - it('should expose the six generic runtime routes', () => { + it('should expose the six generic runtime routes plus the context contract', () => { expect(Object.keys(document.paths ?? {}).sort()).toEqual([ + `${ROUTE_PREFIX}/context`, `${ROUTE_PREFIX}/{collection}/actions/{action}/execute`, `${ROUTE_PREFIX}/{collection}/actions/{action}/form`, `${ROUTE_PREFIX}/{collection}/count`, @@ -148,10 +160,8 @@ describe('generateOpenApiDocument', () => { }); }); - it('should secure every operation with the API key only, the one mode the routes accept', () => { - const operations = Object.values(document.paths ?? {}).map( - path => (path as { post: { security: unknown } }).post, - ); + it('should secure every data operation with the API key only, the one mode those routes accept', () => { + const operations = dataOperations(); expect(operations).toHaveLength(6); operations.forEach(operation => { @@ -159,21 +169,31 @@ describe('generateOpenApiDocument', () => { }); }); - it('should say in the session scheme why no operation accepts it yet', () => { + it('should secure the context contract with the session scheme, the one mode it accepts', () => { + const context = (document.paths ?? {})[`${ROUTE_PREFIX}/context`] as { + get: { security: unknown }; + }; + + expect(context.get.security).toEqual([{ bffSession: [] }]); + }); + + it('should say in the session scheme which routes accept it', () => { const session = ( document.components?.securitySchemes as Record ).bffSession; - expect(session.description).toContain('reject it until'); + expect(session.description).toContain('context contract requires it'); }); it('should require a body where parentId or recordIds is mandatory', () => { const requiredByPath = Object.fromEntries( - Object.entries(document.paths ?? {}).map(([path, item]) => [ - path, - (item as { post: { requestBody: { required?: boolean } } }).post.requestBody.required === - true, - ]), + Object.entries(document.paths ?? {}) + .filter(([, item]) => (item as { post?: unknown }).post !== undefined) + .map(([path, item]) => [ + path, + (item as { post: { requestBody: { required?: boolean } } }).post.requestBody.required === + true, + ]), ); expect(requiredByPath).toEqual({ @@ -209,26 +229,20 @@ describe('generateOpenApiDocument', () => { }); it('should document 502, which any agent transport failure returns', () => { - Object.values(document.paths ?? {}).forEach(path => { - expect( - Object.keys((path as { post: { responses: Record } }).post.responses), - ).toContain('502'); + dataOperations().forEach(operation => { + expect(Object.keys(operation.responses)).toContain('502'); }); }); it('should document 413, since the BFF caps the body at 16kb', () => { - Object.values(document.paths ?? {}).forEach(path => { - expect( - Object.keys((path as { post: { responses: Record } }).post.responses), - ).toContain('413'); + dataOperations().forEach(operation => { + expect(Object.keys(operation.responses)).toContain('413'); }); }); it('should declare 501 everywhere, since the agent stub returns it on every route', () => { - Object.values(document.paths ?? {}).forEach(path => { - expect( - Object.keys((path as { post: { responses: Record } }).post.responses), - ).toContain('501'); + dataOperations().forEach(operation => { + expect(Object.keys(operation.responses)).toContain('501'); }); }); @@ -254,10 +268,8 @@ describe('generateOpenApiDocument', () => { }); it('should share one response component per error status instead of inlining it per path', () => { - const errors = Object.values(document.paths ?? {}).flatMap(path => - Object.entries( - (path as { post: { responses: Record } }).post.responses, - ).filter(([status]) => status !== '200'), + const errors = dataOperations().flatMap(operation => + Object.entries(operation.responses).filter(([status]) => status !== '200'), ); expect(errors).toHaveLength(72); @@ -296,7 +308,7 @@ describe('generateOpenApiDocument', () => { it('should share the timezone header as one parameter component instead of per path', () => { const parameters = Object.values(document.paths ?? {}).flatMap( - path => (path as { post: { parameters: unknown[] } }).post.parameters, + path => (path as { post?: { parameters: unknown[] } }).post?.parameters ?? [], ); expect(parameters).toContainEqual({ $ref: '#/components/parameters/XForestTimezone' }); diff --git a/packages/agent-bff/test/openapi/openapi-routes.test.ts b/packages/agent-bff/test/openapi/openapi-routes.test.ts index e2b35cc02f..d7a6c36420 100644 --- a/packages/agent-bff/test/openapi/openapi-routes.test.ts +++ b/packages/agent-bff/test/openapi/openapi-routes.test.ts @@ -143,6 +143,7 @@ describe('GET /agent/openapi.json', () => { expect(response.status).toBe(200); expect(response.body.openapi).toBe('3.1.0'); expect(Object.keys(response.body.paths).sort()).toEqual([ + '/agent/v1/context', '/agent/v1/orders/count', '/agent/v1/orders/list', '/agent/v1/users/actions/Mark%20as%20paid/execute', @@ -206,7 +207,7 @@ describe('GET /agent/openapi.json', () => { expect(withKey.status).toBe(200); expect(withKey.body.openapi).toBe('3.1.0'); - expect(Object.keys(withKey.body.paths)).toHaveLength(8); + expect(Object.keys(withKey.body.paths)).toHaveLength(9); expect(Object.keys(withKey.body.paths).sort()).toEqual( Object.keys(withSession.body.paths).sort(), ); @@ -233,7 +234,7 @@ describe('GET /agent/openapi.json', () => { .set('Authorization', `Bearer ${sessionToken()}`); expect(response.status).toBe(200); - expect(Object.keys(response.body.paths)).toHaveLength(6); + expect(Object.keys(response.body.paths)).toHaveLength(7); expect(response.body.info.description).toContain('Paths are generic'); expect(fetchSchema).not.toHaveBeenCalled(); }); @@ -392,6 +393,7 @@ describe('GET /agent/openapi.json', () => { await routesFor(store)(ctx, async () => undefined); expect(Object.keys(JSON.parse(ctx.body as string).paths)).toEqual([ + '/agent/v1/context', '/agent/v1/orders/list', '/agent/v1/orders/count', ]); diff --git a/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts b/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts index 9673ec78b4..00606078d8 100644 --- a/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts +++ b/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts @@ -42,9 +42,8 @@ describe.each([ }); }); - it('should warn only that the session scheme is unused, which is deliberate', () => { - expect(output).toContain('You have 1 warning'); - expect(output).toContain('bffSession" is never used'); + it('should lint clean, now that the context contract uses the session scheme', () => { + expect(output).not.toMatch(/You have \d+ warning/); expect(output).not.toMatch(/You have \d+ error/); }); }); diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index 290e50560f..5a9f7fb3bb 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -10,7 +10,9 @@ import unfoldingFixture from './fixtures'; import { ROUTE_PREFIX, generateOpenApiDocument } from '../../src/openapi/openapi-document'; const document = generateOpenApiDocument('9.9.9', unfoldingFixture()); -const paths = document.paths as Record }>; +const paths = Object.fromEntries( + Object.entries(document.paths ?? {}).filter(([path]) => path !== `${ROUTE_PREFIX}/context`), +) as Record }>; const schemas = document.components?.schemas as Record>; function operation(path: string): Record { @@ -434,6 +436,7 @@ describe('an unfolding naming a collection it does not carry', () => { }); expect(Object.keys(orphaned.paths ?? {})).toEqual([ + '/agent/v1/context', '/agent/v1/users/list', '/agent/v1/users/count', ]); @@ -464,8 +467,10 @@ describe('names that collide once sanitized', () => { collections: [collection('A_B', 'C', 'R'), collection('A', 'B_C', 'B_R')], }); const operationIds = Object.values( - colliding.paths as Record, - ).map(item => item.post.operationId); + colliding.paths as Record, + ) + .filter(item => item.post !== undefined) + .map(item => (item.post as { operationId: string }).operationId); it('should keep every operationId unique, which codegen tools require', () => { expect(new Set(operationIds).size).toBe(operationIds.length); From 22b04086cfbf491a740e9cbda859a62268bea415 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 15:02:50 +0200 Subject: [PATCH 04/29] refactor(agent-bff): read the schema and its read-model as one snapshot --- packages/agent-bff/src/cli-core.ts | 8 ++-- .../src/context/context-routes-middleware.ts | 40 +++++-------------- .../agent-bff/src/http/bff-local-errors.ts | 4 -- .../agent-bff/src/openapi/openapi-document.ts | 1 + packages/agent-bff/src/openapi/schemas.ts | 18 +++++---- .../src/read-model/create-read-model.ts | 3 +- .../src/read-model/read-model-store.ts | 13 +++++- .../test/context/build-context.test.ts | 12 ++++++ .../context/context-routes-middleware.test.ts | 2 +- packages/agent-bff/test/context/fixtures.ts | 7 +++- .../test/read-model/create-read-model.test.ts | 14 ------- 11 files changed, 56 insertions(+), 66 deletions(-) diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 842080f651..a6e6844cb0 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -3,7 +3,6 @@ import type { UnfoldSource } from './openapi/unfolded-document'; import type { Logger } from './ports/logger-port'; import type { Metrics } from './ports/metrics-port'; import type ReadModelStore from './read-model/read-model-store'; -import type SchemaCache from './read-model/schema-cache'; import type { Middleware } from 'koa'; import { bodyParser } from '@koa/bodyparser'; @@ -166,7 +165,6 @@ function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware | interface AgentEdgeReadModel { store: ReadModelStore; - schemaCache: SchemaCache; apiKeyConfig: ResolvedApiKeyConfig; } @@ -184,14 +182,14 @@ function resolveReadModelBundle( if (!apiKeyConfig) return undefined; - const { store, schemaCache } = createReadModel({ + const { store } = createReadModel({ forestServerUrl: apiKeyConfig.forestServerUrl, envSecret: apiKeyConfig.forestEnvSecret, logger, metrics, }); - return { store, schemaCache, apiKeyConfig }; + return { store, apiKeyConfig }; } /** @@ -271,7 +269,7 @@ function buildAgentRouteMiddlewares( function buildContextMiddlewares(bundle: AgentEdgeReadModel | undefined): Middleware[] { if (!bundle) return []; - return [createContextRoutesMiddleware({ store: bundle.store, schemaCache: bundle.schemaCache })]; + return [createContextRoutesMiddleware({ store: bundle.store })]; } function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] { diff --git a/packages/agent-bff/src/context/context-routes-middleware.ts b/packages/agent-bff/src/context/context-routes-middleware.ts index 10f4fcc5c0..2502a6f422 100644 --- a/packages/agent-bff/src/context/context-routes-middleware.ts +++ b/packages/agent-bff/src/context/context-routes-middleware.ts @@ -1,53 +1,29 @@ import type ReadModelStore from '../read-model/read-model-store'; -import type SchemaCache from '../read-model/schema-cache'; -import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; +import type { SchemaSnapshot } from '../read-model/read-model-store'; import type { Middleware } from 'koa'; import buildContext from './build-context'; -import { resolveReadModel } from '../http/agent-route-helpers'; import { oauthRequired, schemaUnavailable } from '../http/bff-local-errors'; import SchemaUnavailableError from '../read-model/errors'; export const CONTEXT_ROUTE = '/agent/v1/context'; -const MAX_GENERATION_RETRIES = 3; - export interface ContextRoutesMiddlewareOptions { store: ReadModelStore; - schemaCache: SchemaCache; } -async function readSchema(schemaCache: SchemaCache): Promise { +async function readSnapshot(store: ReadModelStore): Promise { try { - return await schemaCache.get(); + return await store.getSchemaSnapshot(); } catch (error) { if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); throw error; } } -async function buildStableContext( - { store, schemaCache }: ContextRoutesMiddlewareOptions, - attemptsLeft = MAX_GENERATION_RETRIES, -): Promise> { - if (attemptsLeft <= 0) { - throw new Error('Schema generation kept changing while building the context contract'); - } - - const readModel = await resolveReadModel(store); - const { revision } = schemaCache; - const collections = await readSchema(schemaCache); - - if (schemaCache.revision !== revision) { - return buildStableContext({ store, schemaCache }, attemptsLeft - 1); - } - - return buildContext(collections, readModel, schemaCache.revision); -} - -export default function createContextRoutesMiddleware( - options: ContextRoutesMiddlewareOptions, -): Middleware { +export default function createContextRoutesMiddleware({ + store, +}: ContextRoutesMiddlewareOptions): Middleware { return async function contextRoutesMiddleware(ctx, next) { if (ctx.path !== CONTEXT_ROUTE || ctx.method !== 'GET') { await next(); @@ -57,7 +33,9 @@ export default function createContextRoutesMiddleware( if (ctx.state.authMode !== 'oauth') throw oauthRequired(); + const { collections, readModel, revision } = await readSnapshot(store); + ctx.status = 200; - ctx.body = await buildStableContext(options); + ctx.body = buildContext(collections, readModel, revision); }; } diff --git a/packages/agent-bff/src/http/bff-local-errors.ts b/packages/agent-bff/src/http/bff-local-errors.ts index 78b931059d..1da5e90d47 100644 --- a/packages/agent-bff/src/http/bff-local-errors.ts +++ b/packages/agent-bff/src/http/bff-local-errors.ts @@ -58,10 +58,6 @@ export function oauthRequired( return new BffHttpError(403, 'oauth_required', message); } -export function unknownRoute(message = 'Unknown route'): BffHttpError { - return new BffHttpError(404, 'unknown_route', message); -} - export function permissionsUnavailable( retryAfter: number, message = 'Permissions are unavailable', diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index a5229cf729..23cf679666 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -304,6 +304,7 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): 401: errorRefs.byStatus['401'], 403: errorRefs.byStatus['403'], 500: errorRefs.byStatus['500'], + 501: errorRefs.byStatus['501'], 503: errorRefs.byStatus['503'], }, }); diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 058ba3d5ad..343db49cf7 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -188,13 +188,17 @@ export const ContextResponseSchema = z }) .openapi('ContextResponse', { description: - 'The schema the agent exposes, filtered by the allow-list: collection names, typed fields ' + - 'with their relation markers, and the custom actions that carry an endpoint. Field types ' + - 'are passed through from the agent wire format, so a type is a string, an array of types, ' + - 'or a composite object. The document carries no rendering, environment, project or team ' + - 'identity, and it is NOT filtered by the caller permissions: cross it with ' + - '`/agent/v1/permissions` for that. `meta.schemaRevision` increments whenever the BFF ' + - 'refreshes its schema, and resets when the BFF restarts.', + 'Everything the agent schema exposes: collection names, typed fields with their relation ' + + 'markers, and the custom actions that carry an endpoint. Field types are passed through ' + + 'from the agent wire format, so a type is a string, an array of types, or a composite ' + + 'object. `reference` keeps the raw agent form, the foreign collection and the key joined ' + + 'by a dot — the collection name may itself contain dots, so drop only the trailing ' + + 'segment to recover it. ' + + 'The document carries no rendering, environment, project or team identity. It is NOT ' + + 'filtered by the caller permissions, nor by anything else: it describes the whole exposed ' + + 'schema, so cross it with `/agent/v1/permissions` to know what the caller may actually ' + + 'see. `meta.schemaRevision` increments whenever the BFF refreshes its schema, and resets ' + + 'when the BFF restarts.', }); export const ErrorResponseSchema = z diff --git a/packages/agent-bff/src/read-model/create-read-model.ts b/packages/agent-bff/src/read-model/create-read-model.ts index ab1a5d9dde..45c3aec4c4 100644 --- a/packages/agent-bff/src/read-model/create-read-model.ts +++ b/packages/agent-bff/src/read-model/create-read-model.ts @@ -20,7 +20,6 @@ export interface CreateReadModelOptions { export interface ReadModelBundle { store: ReadModelStore; actionEndpointResolver: ActionEndpointResolver; - schemaCache: SchemaCache; } export default function createReadModel({ @@ -41,5 +40,5 @@ export default function createReadModel({ resolvedMetrics, ); - return { store, actionEndpointResolver, schemaCache }; + return { store, actionEndpointResolver }; } diff --git a/packages/agent-bff/src/read-model/read-model-store.ts b/packages/agent-bff/src/read-model/read-model-store.ts index bf1c226a82..070839b330 100644 --- a/packages/agent-bff/src/read-model/read-model-store.ts +++ b/packages/agent-bff/src/read-model/read-model-store.ts @@ -1,11 +1,18 @@ import type CapabilitiesCache from './capabilities-cache'; import type { CapabilitiesFetcher, CapabilitiesResult } from './capabilities-cache'; import type SchemaCache from './schema-cache'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; import ReadModel from './read-model'; const MAX_GENERATION_RETRIES = 3; +export interface SchemaSnapshot { + collections: ForestSchemaCollection[]; + readModel: ReadModel; + revision: number; +} + /** * Single owner of the coupled schema + capabilities lifecycle. A successful schema refresh (a bump * of the cache `revision`) rebuilds the read-model and clears capabilities atomically, so the @@ -24,6 +31,10 @@ export default class ReadModelStore { } async getReadModel(): Promise { + return (await this.getSchemaSnapshot()).readModel; + } + + async getSchemaSnapshot(): Promise { const collections = await this.schemaCache.get(); if (this.schemaCache.revision !== this.builtRevision || !this.readModel) { @@ -32,7 +43,7 @@ export default class ReadModelStore { this.capabilitiesCache.clear(); } - return this.readModel; + return { collections, readModel: this.readModel, revision: this.builtRevision }; } /** diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index 503f22b1c8..1706235203 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -87,6 +87,18 @@ describe('buildContext', () => { ); }); + it('should keep a falsy default value and an empty enum list, which carry meaning', () => { + const context = buildContext(schema, readModel, 1); + const exportAction = usersOf(context)?.actions.find(entry => entry.name === 'Export all'); + + expect(exportAction?.fields).toEqual([ + { field: 'includeArchived', type: 'Boolean', isRequired: false, defaultValue: false }, + { field: 'limit', type: 'Number', defaultValue: 0 }, + { field: 'since', type: 'Date', defaultValue: null }, + { field: 'format', type: 'Enum', enums: [] }, + ]); + }); + it('should serialize an action field with its enums and default value', () => { const context = buildContext(schema, readModel, 1); const banAction = usersOf(context)?.actions.find(entry => entry.name === 'Ban user'); diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts index 26ed526de0..29561f1c2f 100644 --- a/packages/agent-bff/test/context/context-routes-middleware.test.ts +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -25,7 +25,7 @@ function makeApp(fetchSchema: jest.Mock, authMode: string | undefined = 'oauth') ctx.state.authMode = authMode; await next(); }); - app.use(createContextRoutesMiddleware({ store, schemaCache })); + app.use(createContextRoutesMiddleware({ store })); return { app, schemaCache }; } diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts index 1f9b88f10b..79548b415b 100644 --- a/packages/agent-bff/test/context/fixtures.ts +++ b/packages/agent-bff/test/context/fixtures.ts @@ -68,7 +68,12 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti enums: ['spam', 'abuse'], }, ]), - action('Export all', 'global', '/forest/users/actions/export'), + action('Export all', 'global', '/forest/users/actions/export', [ + { field: 'includeArchived', type: 'Boolean', isRequired: false, defaultValue: false }, + { field: 'limit', type: 'Number', defaultValue: 0 }, + { field: 'since', type: 'Date', defaultValue: null }, + { field: 'format', type: 'Enum', enums: [] }, + ]), action('Archive', 'bulk', '/forest/users/actions/archive'), action('Endpointless action', 'single', undefined), ], diff --git a/packages/agent-bff/test/read-model/create-read-model.test.ts b/packages/agent-bff/test/read-model/create-read-model.test.ts index a1fa46d558..0687b070c2 100644 --- a/packages/agent-bff/test/read-model/create-read-model.test.ts +++ b/packages/agent-bff/test/read-model/create-read-model.test.ts @@ -30,20 +30,6 @@ describe('createReadModel', () => { expect(model.isActionAllowed('users', 'ban')).toBe(true); }); - it('should expose the same schema cache the store reads from', async () => { - const { store, schemaCache } = createReadModel({ - forestServerUrl: 'x', - envSecret: 'y', - metrics: makeMetrics(), - }); - - await store.getReadModel(); - const collections = await schemaCache.get(); - - expect(getSchema).toHaveBeenCalledTimes(1); - expect(collections.map(entry => entry.name)).toEqual(['users']); - }); - it('should wire an action-endpoint resolver that resolves mapped actions', async () => { const { actionEndpointResolver } = createReadModel({ forestServerUrl: 'x', From d6752578ef0fdc3498d86f9ece16490445b9bafa Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 15:09:05 +0200 Subject: [PATCH 05/29] test(agent-bff): constrain the schema snapshot generation pairing --- .../src/read-model/read-model-store.ts | 7 +++-- .../test/read-model/read-model-store.test.ts | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/agent-bff/src/read-model/read-model-store.ts b/packages/agent-bff/src/read-model/read-model-store.ts index 070839b330..3f3b677d53 100644 --- a/packages/agent-bff/src/read-model/read-model-store.ts +++ b/packages/agent-bff/src/read-model/read-model-store.ts @@ -36,14 +36,15 @@ export default class ReadModelStore { async getSchemaSnapshot(): Promise { const collections = await this.schemaCache.get(); + const { revision } = this.schemaCache; - if (this.schemaCache.revision !== this.builtRevision || !this.readModel) { + if (revision !== this.builtRevision || !this.readModel) { this.readModel = new ReadModel(collections); - this.builtRevision = this.schemaCache.revision; + this.builtRevision = revision; this.capabilitiesCache.clear(); } - return { collections, readModel: this.readModel, revision: this.builtRevision }; + return { collections, readModel: this.readModel, revision }; } /** diff --git a/packages/agent-bff/test/read-model/read-model-store.test.ts b/packages/agent-bff/test/read-model/read-model-store.test.ts index 300e2ad9b5..6800dde325 100644 --- a/packages/agent-bff/test/read-model/read-model-store.test.ts +++ b/packages/agent-bff/test/read-model/read-model-store.test.ts @@ -25,6 +25,36 @@ describe('ReadModelStore', () => { clock = 1_000_000; }); + describe('getSchemaSnapshot', () => { + it('should return a read-model derived from the very collections it returns', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + + const { collections, readModel, revision } = await store.getSchemaSnapshot(); + + expect(collections.map(entry => entry.name)).toEqual(['users']); + expect(readModel.getAllowedCollections()).toEqual(collections.map(entry => entry.name)); + expect(revision).toBe(1); + }); + + it('should not label one generation of collections with another generation revision', async () => { + const fetchSchema = jest + .fn() + .mockResolvedValueOnce(makeSchema('first')) + .mockResolvedValueOnce(makeSchema('second')); + const store = build(fetchSchema); + + const first = await store.getSchemaSnapshot(); + clock += ONE_DAY_MS + 1; + const second = await store.getSchemaSnapshot(); + + expect(first.collections.map(entry => entry.name)).toEqual(['first']); + expect(first.readModel.getAllowedCollections()).toEqual(['first']); + expect(second.collections.map(entry => entry.name)).toEqual(['second']); + expect(second.readModel.getAllowedCollections()).toEqual(['second']); + expect(second.revision).toBeGreaterThan(first.revision); + }); + }); + describe('getReadModel', () => { it('should build the read-model from the fetched schema', async () => { const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); From b877af9ba7ec5ad3398b93c4e5d88d9ead4e7193 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 15:13:33 +0200 Subject: [PATCH 06/29] fix(agent-bff): tolerate a null enums list from the agent schema --- .../agent-bff/src/context/build-context.ts | 12 ++++++---- packages/agent-bff/test/cli-core.test.ts | 22 +++++++++++++++++++ .../test/context/build-context.test.ts | 10 +++++++++ packages/agent-bff/test/context/fixtures.ts | 5 +++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index fe740a10df..1f8e90f246 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -40,6 +40,10 @@ export interface AgentContext { meta: { schemaRevision: number }; } +function toArray(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} + function toContextField(field: ForestSchemaField): ContextField { const serialized: ContextField = { field: field.field, type: field.type }; @@ -56,7 +60,7 @@ function toContextActionField(field: ForestSchemaAction['fields'][number]): Cont if (field.isRequired !== undefined) serialized.isRequired = field.isRequired; if (field.defaultValue !== undefined) serialized.defaultValue = field.defaultValue; - if (field.enums !== undefined) serialized.enums = [...field.enums]; + if (Array.isArray(field.enums)) serialized.enums = [...field.enums]; return serialized; } @@ -66,7 +70,7 @@ function toContextAction(action: ForestSchemaAction): ContextAction { id: action.id, name: action.name, type: action.type, - fields: (action.fields ?? []).map(toContextActionField), + fields: toArray(action.fields).map(toContextActionField), }; } @@ -76,8 +80,8 @@ function toContextCollection( ): ContextCollection { return { name: collection.name, - fields: (collection.fields ?? []).map(toContextField), - actions: (collection.actions ?? []) + fields: toArray(collection.fields).map(toContextField), + actions: toArray(collection.actions) .filter(action => readModel.isActionAllowed(collection.name, action.name)) .map(toContextAction), }; diff --git a/packages/agent-bff/test/cli-core.test.ts b/packages/agent-bff/test/cli-core.test.ts index e147f420a5..fe65485486 100644 --- a/packages/agent-bff/test/cli-core.test.ts +++ b/packages/agent-bff/test/cli-core.test.ts @@ -164,6 +164,28 @@ describe('runCli', () => { } }); + it('should reach the context route without a timezone, since it is mounted before that middleware', async () => { + const token = jsonwebtoken.sign( + { type: 'bff_access', sid: 's1', id: 1, rendering_id: '1', tags: {} }, + VALID_ENV.FOREST_AUTH_SECRET, + { algorithm: 'HS256', expiresIn: '15m' }, + ); + const server = await runCli(VALID_ENV, noopLogger); + + try { + const response = await request(server.callback) + .get('/agent/v1/context') + .set('Authorization', `Bearer ${token}`); + + expect(response.body.error?.type).not.toBe('missing_timezone'); + expect(response.body.error).toEqual( + expect.objectContaining({ type: 'schema_unavailable', status: 503 }), + ); + } finally { + await server.stop(); + } + }); + it('should carry an oauth Bearer past requireAgentToken on a data route', async () => { const token = jsonwebtoken.sign( { diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index 1706235203..3f7fc34337 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -96,9 +96,19 @@ describe('buildContext', () => { { field: 'limit', type: 'Number', defaultValue: 0 }, { field: 'since', type: 'Date', defaultValue: null }, { field: 'format', type: 'Enum', enums: [] }, + { field: 'loading', type: 'String' }, ]); }); + it('should omit enums when the agent sends null, which it does on every dynamic form', () => { + const context = buildContext(schema, readModel, 1); + const loading = usersOf(context) + ?.actions.find(entry => entry.name === 'Export all') + ?.fields.find(entry => entry.field === 'loading'); + + expect(loading).not.toHaveProperty('enums'); + }); + it('should serialize an action field with its enums and default value', () => { const context = buildContext(schema, readModel, 1); const banAction = usersOf(context)?.actions.find(entry => entry.name === 'Ban user'); diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts index 79548b415b..4a4e9bd1c9 100644 --- a/packages/agent-bff/test/context/fixtures.ts +++ b/packages/agent-bff/test/context/fixtures.ts @@ -73,6 +73,11 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti { field: 'limit', type: 'Number', defaultValue: 0 }, { field: 'since', type: 'Date', defaultValue: null }, { field: 'format', type: 'Enum', enums: [] }, + { + field: 'loading', + type: 'String', + enums: null, + } as unknown as ForestSchemaAction['fields'][number], ]), action('Archive', 'bulk', '/forest/users/actions/archive'), action('Endpointless action', 'single', undefined), From 8d4f1c6e457fb6d394711af3f9d7152dbb47d9b6 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 15:32:57 +0200 Subject: [PATCH 07/29] fix(agent-bff): declare the 400 the context route can return --- packages/agent-bff/src/openapi/openapi-document.ts | 1 + .../test/context/context-routes-middleware.test.ts | 11 ----------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index 23cf679666..81776b683d 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -301,6 +301,7 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): description: 'The exposed schema: collections, typed fields, relations and actions', content: { 'application/json': { schema: ContextResponseSchema } }, }, + 400: errorRefs.byStatus['400'], 401: errorRefs.byStatus['401'], 403: errorRefs.byStatus['403'], 500: errorRefs.byStatus['500'], diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts index 29561f1c2f..766e810193 100644 --- a/packages/agent-bff/test/context/context-routes-middleware.test.ts +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -90,17 +90,6 @@ describe('contextRoutesMiddleware', () => { }); }); - describe('when no timezone is supplied', () => { - it('should still answer 200, because the contract needs no timezone', async () => { - const fetchSchema = jest.fn().mockResolvedValue(schema); - const { app } = makeApp(fetchSchema); - - const response = await request(app.callback()).get(ROUTE).unset('X-Forest-Timezone'); - - expect(response.status).toBe(200); - }); - }); - describe('when the path or method does not match', () => { it('should pass through to the next middleware', async () => { const fetchSchema = jest.fn().mockResolvedValue(schema); From 99fdc269227f1232ea92966e1305efff90927704 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 18 Aug 2026 16:05:17 +0200 Subject: [PATCH 08/29] feat(agent-bff): carry the resolved environment id in the context meta --- packages/agent-bff/src/cli-core.ts | 32 +++++++++---- .../agent-bff/src/context/build-context.ts | 11 +++-- .../src/context/context-routes-middleware.ts | 4 +- packages/agent-bff/src/openapi/schemas.ts | 6 ++- .../test/context/build-context.test.ts | 46 ++++++++++++------- 5 files changed, 67 insertions(+), 32 deletions(-) diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index a6e6844cb0..e085050002 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -91,13 +91,18 @@ function resolveOAuthConfig(config: BFFConfig): ResolvedOAuthConfig | undefined return undefined; } -async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise { +interface OAuthEdge { + middlewares: Middleware[]; + environmentId?: number; +} + +async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise { const oauthConfig = resolveOAuthConfig(config); if (!oauthConfig) { logger('Warn', 'OAuth routes disabled: required configuration is missing'); - return []; + return { middlewares: [] }; } const { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey } = @@ -121,7 +126,7 @@ async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise logger, }); - return [oauthRoutes]; + return { middlewares: [oauthRoutes], environmentId }; } interface ResolvedApiKeyConfig { @@ -266,13 +271,20 @@ function buildAgentRouteMiddlewares( ]; } -function buildContextMiddlewares(bundle: AgentEdgeReadModel | undefined): Middleware[] { +function buildContextMiddlewares( + bundle: AgentEdgeReadModel | undefined, + environmentId?: number, +): Middleware[] { if (!bundle) return []; - return [createContextRoutesMiddleware({ store: bundle.store })]; + return [createContextRoutesMiddleware({ store: bundle.store, environmentId })]; } -function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] { +function buildAgentMiddlewares( + config: BFFConfig, + logger: Logger, + environmentId?: number, +): Middleware[] { const { forestAuthSecret, defaultTimezone } = config; if (!forestAuthSecret) { @@ -292,7 +304,7 @@ function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] apiKeyStep, createPerKeyOriginMiddleware(), createOpenApiRoutes({ version, enabled: config.openapiEnabled, source }), - ...buildContextMiddlewares(bundle), + ...buildContextMiddlewares(bundle, environmentId), createTimezoneMiddleware({ defaultTimezone }), ...buildAgentRouteMiddlewares(bundle, config, logger), ]; @@ -312,15 +324,15 @@ export default async function runCli( }); } - const oauthMiddlewares = await buildOAuthMiddlewares(config, logger); - const agentMiddlewares = buildAgentMiddlewares(config, logger); + const oauth = await buildOAuthMiddlewares(config, logger); + const agentMiddlewares = buildAgentMiddlewares(config, logger, oauth.environmentId); const agentErrorMiddleware = agentMiddlewares.length > 0 ? [agentScoped(createErrorMiddleware({ logger }))] : []; const middlewares = [ createCorsMiddleware({ allowedOrigins: config.allowedOrigins }), ...agentErrorMiddleware, bodyParser({ jsonLimit: BODY_LIMIT }), - ...oauthMiddlewares, + ...oauth.middlewares, ...agentMiddlewares, ]; const server = new BFFHttpServer({ diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index 1f8e90f246..0201064e9b 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -35,9 +35,14 @@ export interface ContextCollection { actions: ContextAction[]; } +export interface ContextMeta { + schemaRevision: number; + environmentId?: number; +} + export interface AgentContext { collections: ContextCollection[]; - meta: { schemaRevision: number }; + meta: ContextMeta; } function toArray(value: T[] | null | undefined): T[] { @@ -90,12 +95,12 @@ function toContextCollection( export default function buildContext( collections: ForestSchemaCollection[], readModel: ReadModel, - schemaRevision: number, + meta: ContextMeta, ): AgentContext { return { collections: collections .filter(collection => readModel.isCollectionAllowed(collection.name)) .map(collection => toContextCollection(collection, readModel)), - meta: { schemaRevision }, + meta: meta.environmentId === undefined ? { schemaRevision: meta.schemaRevision } : { ...meta }, }; } diff --git a/packages/agent-bff/src/context/context-routes-middleware.ts b/packages/agent-bff/src/context/context-routes-middleware.ts index 2502a6f422..3101ca7f7c 100644 --- a/packages/agent-bff/src/context/context-routes-middleware.ts +++ b/packages/agent-bff/src/context/context-routes-middleware.ts @@ -10,6 +10,7 @@ export const CONTEXT_ROUTE = '/agent/v1/context'; export interface ContextRoutesMiddlewareOptions { store: ReadModelStore; + environmentId?: number; } async function readSnapshot(store: ReadModelStore): Promise { @@ -23,6 +24,7 @@ async function readSnapshot(store: ReadModelStore): Promise { export default function createContextRoutesMiddleware({ store, + environmentId, }: ContextRoutesMiddlewareOptions): Middleware { return async function contextRoutesMiddleware(ctx, next) { if (ctx.path !== CONTEXT_ROUTE || ctx.method !== 'GET') { @@ -36,6 +38,6 @@ export default function createContextRoutesMiddleware({ const { collections, readModel, revision } = await readSnapshot(store); ctx.status = 200; - ctx.body = buildContext(collections, readModel, revision); + ctx.body = buildContext(collections, readModel, { schemaRevision: revision, environmentId }); }; } diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 343db49cf7..4851898444 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -184,7 +184,7 @@ export const ContextResponseSchema = z actions: z.array(ContextActionSchema), }), ), - meta: z.object({ schemaRevision: z.number() }), + meta: z.object({ schemaRevision: z.number(), environmentId: z.number().optional() }), }) .openapi('ContextResponse', { description: @@ -198,7 +198,9 @@ export const ContextResponseSchema = z 'filtered by the caller permissions, nor by anything else: it describes the whole exposed ' + 'schema, so cross it with `/agent/v1/permissions` to know what the caller may actually ' + 'see. `meta.schemaRevision` increments whenever the BFF refreshes its schema, and resets ' + - 'when the BFF restarts.', + 'when the BFF restarts. `meta.environmentId` is the environment the BFF resolved at boot ' + + 'from its own secret — it is telemetry, not a routing input, and it is absent when the ' + + 'deployment runs without the OAuth configuration.', }); export const ErrorResponseSchema = z diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index 3f7fc34337..a0beb5340e 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -16,7 +16,7 @@ describe('buildContext', () => { describe('when the schema carries every field shape', () => { it('should pass a primitive, an array and a composite type through untouched', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(fieldNamed(context, 'id')?.type).toBe('Uuid'); expect(fieldNamed(context, 'tagsWithArrayType')?.type).toEqual(['String']); @@ -26,7 +26,7 @@ describe('buildContext', () => { }); it('should carry reference and inverseOf on a relation that has them', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(fieldNamed(context, 'ordersHasManyWithInverseOf')).toEqual({ field: 'ordersHasManyWithInverseOf', @@ -37,7 +37,7 @@ describe('buildContext', () => { }); it('should omit inverseOf on a relation that lacks it', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); const relation = fieldNamed(context, 'teamBelongsToWithoutInverseOf'); expect(relation).toEqual({ @@ -49,7 +49,7 @@ describe('buildContext', () => { }); it('should keep a polymorphic relation, which carries no reference', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(fieldNamed(context, 'ownerPolymorphic')).toEqual({ field: 'ownerPolymorphic', @@ -58,7 +58,7 @@ describe('buildContext', () => { }); it('should carry isRequired and isReadOnly only when true', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(fieldNamed(context, 'emailRequired')?.isRequired).toBe(true); expect(fieldNamed(context, 'lockedReadOnly')?.isReadOnly).toBe(true); @@ -69,7 +69,7 @@ describe('buildContext', () => { describe('when the schema carries actions', () => { it('should expose single, bulk and global actions alike', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(usersOf(context)?.actions.map(entry => [entry.name, entry.type])).toEqual([ ['Ban user', 'single'], @@ -79,7 +79,7 @@ describe('buildContext', () => { }); it('should drop an action with no endpoint, matching the read-model allow-list', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(readModel.isActionAllowed('users', 'Endpointless action')).toBe(false); expect(usersOf(context)?.actions.map(entry => entry.name)).not.toContain( @@ -88,7 +88,7 @@ describe('buildContext', () => { }); it('should keep a falsy default value and an empty enum list, which carry meaning', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); const exportAction = usersOf(context)?.actions.find(entry => entry.name === 'Export all'); expect(exportAction?.fields).toEqual([ @@ -101,7 +101,7 @@ describe('buildContext', () => { }); it('should omit enums when the agent sends null, which it does on every dynamic form', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); const loading = usersOf(context) ?.actions.find(entry => entry.name === 'Export all') ?.fields.find(entry => entry.field === 'loading'); @@ -110,7 +110,7 @@ describe('buildContext', () => { }); it('should serialize an action field with its enums and default value', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); const banAction = usersOf(context)?.actions.find(entry => entry.name === 'Ban user'); expect(banAction?.fields).toEqual([ @@ -129,7 +129,7 @@ describe('buildContext', () => { it('should omit it', () => { const restricted = new ReadModel(schema.filter(collection => collection.name === 'users')); - const context = buildContext(schema, restricted, 1); + const context = buildContext(schema, restricted, { schemaRevision: 1 }); expect(context.collections.map(collection => collection.name)).toEqual(['users']); }); @@ -137,7 +137,7 @@ describe('buildContext', () => { describe('when a collection name is unusual', () => { it('should keep a dotted name and a name carrying a space verbatim', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(context.collections.map(collection => collection.name)).toEqual([ 'users', @@ -150,7 +150,7 @@ describe('buildContext', () => { describe('when a collection has neither fields nor actions', () => { it('should serialize it with empty lists rather than throwing', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect( context.collections.find( @@ -162,20 +162,34 @@ describe('buildContext', () => { describe('meta', () => { it('should carry the schema revision it was built from', () => { - expect(buildContext(schema, readModel, 7).meta).toEqual({ schemaRevision: 7 }); + expect(buildContext(schema, readModel, { schemaRevision: 7 }).meta).toEqual({ + schemaRevision: 7, + }); + }); + + it('should carry the environment id when the deployment resolved one', () => { + expect( + buildContext(schema, readModel, { schemaRevision: 7, environmentId: 42 }).meta, + ).toEqual({ schemaRevision: 7, environmentId: 42 }); + }); + + it('should omit the environment id rather than send null when none was resolved', () => { + const { meta } = buildContext(schema, readModel, { schemaRevision: 7 }); + + expect(meta).not.toHaveProperty('environmentId'); }); }); describe('identity', () => { it('should expose collections and meta only, so no identity can ride along', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(Object.keys(context).sort()).toEqual(['collections', 'meta']); expect(Object.keys(context.meta)).toEqual(['schemaRevision']); }); it('should expose only the agreed keys on a collection, a field and an action', () => { - const context = buildContext(schema, readModel, 1); + const context = buildContext(schema, readModel, { schemaRevision: 1 }); const users = usersOf(context); expect(Object.keys(users ?? {}).sort()).toEqual(['actions', 'fields', 'name']); From b555b2f9a19df2c34c5ca85e5534cb43e0e0eb43 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 19 Aug 2026 10:34:32 +0200 Subject: [PATCH 09/29] feat(agent-bff): carry field validations in the context contract --- .../agent-bff/src/context/build-context.ts | 22 +++++++++ packages/agent-bff/src/openapi/schemas.ts | 21 +++++++-- .../test/context/build-context.test.ts | 45 +++++++++++++++++++ packages/agent-bff/test/context/fixtures.ts | 23 ++++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index 0201064e9b..e17f1d6214 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -20,6 +20,11 @@ export interface ContextAction { fields: ContextActionField[]; } +export interface ContextValidation { + type: string; + value?: unknown; +} + export interface ContextField { field: string; type: unknown; @@ -27,6 +32,7 @@ export interface ContextField { inverseOf?: string; isRequired?: boolean; isReadOnly?: boolean; + validations?: ContextValidation[]; } export interface ContextCollection { @@ -49,6 +55,19 @@ function toArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : []; } +function toContextValidations(validations: unknown): ContextValidation[] { + return toArray(validations as unknown[]) + .filter( + (entry): entry is { type: string; value?: unknown } => + typeof entry === 'object' && + entry !== null && + typeof (entry as { type?: unknown }).type === 'string', + ) + .map(entry => + 'value' in entry ? { type: entry.type, value: entry.value } : { type: entry.type }, + ); +} + function toContextField(field: ForestSchemaField): ContextField { const serialized: ContextField = { field: field.field, type: field.type }; @@ -57,6 +76,9 @@ function toContextField(field: ForestSchemaField): ContextField { if (field.isRequired) serialized.isRequired = true; if (field.isReadOnly) serialized.isReadOnly = true; + const validations = toContextValidations(field.validations); + if (validations.length > 0) serialized.validations = validations; + return serialized; } diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 4851898444..12cbb018f8 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -151,6 +151,18 @@ const ContextFieldTypeSchema = z.unknown().openapi('ContextFieldType', { 'than fail.', }); +const ContextValidationSchema = z + .object({ type: z.string(), value: z.unknown().optional() }) + .openapi('ContextValidation', { + description: + 'A validation rule as the agent states it. `type` is the Forest wording (`is like`, `is ' + + 'present`, `is longer than`, …) and `value` is passed through unchanged, so its shape ' + + 'follows the rule: a number for a length rule, a date for a comparison. On `is like` it is ' + + 'the JavaScript literal form of the regular expression, **delimiting slashes included** ' + + '(`/^data:.*;base64,.*/`) — strip them before building a RegExp, or the pattern will match ' + + 'nothing. A rule with no operand carries no `value`.', + }); + const ContextFieldSchema = z.object({ field: z.string(), type: ContextFieldTypeSchema, @@ -158,6 +170,7 @@ const ContextFieldSchema = z.object({ inverseOf: z.string().optional(), isRequired: z.boolean().optional(), isReadOnly: z.boolean().optional(), + validations: z.array(ContextValidationSchema).optional(), }); const ContextActionSchema = z.object({ @@ -191,9 +204,11 @@ export const ContextResponseSchema = z 'Everything the agent schema exposes: collection names, typed fields with their relation ' + 'markers, and the custom actions that carry an endpoint. Field types are passed through ' + 'from the agent wire format, so a type is a string, an array of types, or a composite ' + - 'object. `reference` keeps the raw agent form, the foreign collection and the key joined ' + - 'by a dot — the collection name may itself contain dots, so drop only the trailing ' + - 'segment to recover it. ' + + 'object. A `Binary` column is advertised as `String`, because that is what it is on the ' + + 'wire — the bytes travel as a data uri or as hex — so `type` alone does not tell a text ' + + 'field from an encoded one: read `validations` for that. `reference` keeps the raw agent ' + + 'form, the foreign collection and the key joined by a dot — the collection name may itself ' + + 'contain dots, so drop only the trailing segment to recover it. ' + 'The document carries no rendering, environment, project or team identity. It is NOT ' + 'filtered by the caller permissions, nor by anything else: it describes the whole exposed ' + 'schema, so cross it with `/agent/v1/permissions` to know what the caller may actually ' + diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index a0beb5340e..c9314829cb 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -67,6 +67,51 @@ describe('buildContext', () => { }); }); + describe('validations', () => { + it('should carry the pattern a binary field requires, which the type alone cannot express', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'thumbnailWithPattern')?.validations).toEqual([ + { type: 'is like', value: '/^data:.*;base64,.*/' }, + ]); + }); + + it('should drop the message, which is UI copy rather than contract', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const [rule] = fieldNamed(context, 'titleWithPresence')?.validations ?? []; + + expect(Object.keys(rule ?? {})).toEqual(['type']); + }); + + it('should keep a rule that carries no value, rather than inventing one', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'titleWithPresence')?.validations).toEqual([ + { type: 'is present' }, + ]); + }); + + it('should skip malformed entries instead of failing the whole contract', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'fieldWithMalformedValidations')?.validations).toEqual([ + { type: 'contains', value: 'ok' }, + ]); + }); + + it('should omit the key entirely when the agent sends null', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'fieldWithNullValidations')).not.toHaveProperty('validations'); + }); + + it('should omit the key on a field with no validation at all', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'id')).not.toHaveProperty('validations'); + }); + }); + describe('when the schema carries actions', () => { it('should expose single, bulk and global actions alike', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts index 4a4e9bd1c9..1fd9ceff9a 100644 --- a/packages/agent-bff/test/context/fixtures.ts +++ b/packages/agent-bff/test/context/fixtures.ts @@ -44,6 +44,29 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti field('addressWithCompositeType', { fields: [{ field: 'city', type: 'String' }] }), field('lockedReadOnly', 'Boolean', { isReadOnly: true }), field('emailRequired', 'String', { isRequired: true }), + field('thumbnailWithPattern', 'String', { + validations: [ + { + type: 'is like', + value: '/^data:.*;base64,.*/', + message: 'Value must match /^data:.*;base64,.*/', + }, + ], + }), + field('titleWithPresence', 'String', { + validations: [{ type: 'is present', message: 'Field is required' }], + }), + field('fieldWithMalformedValidations', 'String', { + validations: [ + null, + { message: 'no type at all' }, + 'garbage', + { type: 'contains', value: 'ok' }, + ], + } as unknown as Partial), + field('fieldWithNullValidations', 'String', { + validations: null, + } as unknown as Partial), field('ordersHasManyWithInverseOf', 'String', { reference: 'orders.customerId', relationship: 'HasMany', From cca0447b17a14d27117e5e3c3d5865ad753cc152 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 19 Aug 2026 10:44:25 +0200 Subject: [PATCH 10/29] feat(agent-bff): carry enum values and the primary-key flag in the context contract --- .../agent-bff/src/context/build-context.ts | 6 ++++ .../agent-bff/src/openapi/openapi-document.ts | 1 - packages/agent-bff/src/openapi/schemas.ts | 10 +++++-- .../test/context/build-context.test.ts | 29 +++++++++++++++++++ packages/agent-bff/test/context/fixtures.ts | 6 ++++ 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index e17f1d6214..c33ac3a92f 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -30,8 +30,10 @@ export interface ContextField { type: unknown; reference?: string; inverseOf?: string; + isPrimaryKey?: boolean; isRequired?: boolean; isReadOnly?: boolean; + enums?: string[]; validations?: ContextValidation[]; } @@ -73,9 +75,13 @@ function toContextField(field: ForestSchemaField): ContextField { if (field.reference) serialized.reference = field.reference; if (field.inverseOf) serialized.inverseOf = field.inverseOf; + if (field.isPrimaryKey) serialized.isPrimaryKey = true; if (field.isRequired) serialized.isRequired = true; if (field.isReadOnly) serialized.isReadOnly = true; + const enums = toArray((field as { enums?: string[] }).enums); + if (enums.length > 0) serialized.enums = [...enums]; + const validations = toContextValidations(field.validations); if (validations.length > 0) serialized.validations = validations; diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index 81776b683d..53c4e7c3b3 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -304,7 +304,6 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): 400: errorRefs.byStatus['400'], 401: errorRefs.byStatus['401'], 403: errorRefs.byStatus['403'], - 500: errorRefs.byStatus['500'], 501: errorRefs.byStatus['501'], 503: errorRefs.byStatus['503'], }, diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 12cbb018f8..108e55d38f 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -158,9 +158,11 @@ const ContextValidationSchema = z 'A validation rule as the agent states it. `type` is the Forest wording (`is like`, `is ' + 'present`, `is longer than`, …) and `value` is passed through unchanged, so its shape ' + 'follows the rule: a number for a length rule, a date for a comparison. On `is like` it is ' + - 'the JavaScript literal form of the regular expression, **delimiting slashes included** ' + - '(`/^data:.*;base64,.*/`) — strip them before building a RegExp, or the pattern will match ' + - 'nothing. A rule with no operand carries no `value`.', + 'the JavaScript **literal** form of the regular expression — slashes included, and flags ' + + 'after the closing one (`/^data:.*;base64,.*/`, but also `/^a|b|c$/g`). Parse it as a ' + + 'literal, splitting on the LAST slash to separate pattern from flags; stripping the outer ' + + 'characters instead leaves the flags inside the pattern, which then matches nothing. A ' + + 'rule with no operand carries no `value`.', }); const ContextFieldSchema = z.object({ @@ -168,8 +170,10 @@ const ContextFieldSchema = z.object({ type: ContextFieldTypeSchema, reference: z.string().optional(), inverseOf: z.string().optional(), + isPrimaryKey: z.boolean().optional(), isRequired: z.boolean().optional(), isReadOnly: z.boolean().optional(), + enums: z.array(z.string()).optional(), validations: z.array(ContextValidationSchema).optional(), }); diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index c9314829cb..cb993a9e43 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -67,7 +67,36 @@ describe('buildContext', () => { }); }); + describe('when a field is an enum or a primary key', () => { + it('should carry the allowed values, which the type alone does not give', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'statusWithEnums')?.enums).toEqual(['DRAFT', 'PUBLISHED']); + }); + + it('should flag the primary key, so a caller can build recordIds without a list call', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'id')?.isPrimaryKey).toBe(true); + expect(fieldNamed(context, 'emailRequired')).not.toHaveProperty('isPrimaryKey'); + }); + + it('should omit enums on a field that has none rather than send an empty list', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'id')).not.toHaveProperty('enums'); + }); + }); + describe('validations', () => { + it('should keep the flags of a regex rule, which the agent emits on rewritten In filters', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'statusWithFlaggedPattern')?.validations).toEqual([ + { type: 'is like', value: '/^a|b|c$/g' }, + ]); + }); + it('should carry the pattern a binary field requires, which the type alone cannot express', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts index 1fd9ceff9a..e403a9f90f 100644 --- a/packages/agent-bff/test/context/fixtures.ts +++ b/packages/agent-bff/test/context/fixtures.ts @@ -53,6 +53,12 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti }, ], }), + field('statusWithEnums', 'Enum', { + enums: ['DRAFT', 'PUBLISHED'], + } as unknown as Partial), + field('statusWithFlaggedPattern', 'Enum', { + validations: [{ type: 'is like', value: '/^a|b|c$/g', message: 'x' }], + }), field('titleWithPresence', 'String', { validations: [{ type: 'is present', message: 'Field is required' }], }), From fe98f68b9dd0248e3fdc244a5f1ddc428e4c14a3 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 19 Aug 2026 10:58:25 +0200 Subject: [PATCH 11/29] refactor(agent-bff): tighten the context serializer and drop a redundant test --- packages/agent-bff/src/context/build-context.ts | 16 +++++++++++++--- .../src/context/context-routes-middleware.ts | 2 +- .../agent-bff/test/context/build-context.test.ts | 9 +-------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index c33ac3a92f..777ef307d7 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -57,6 +57,8 @@ function toArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : []; } +type FieldWithWireEnums = ForestSchemaField & { enums?: string[] }; + function toContextValidations(validations: unknown): ContextValidation[] { return toArray(validations as unknown[]) .filter( @@ -70,7 +72,7 @@ function toContextValidations(validations: unknown): ContextValidation[] { ); } -function toContextField(field: ForestSchemaField): ContextField { +function toContextField(field: FieldWithWireEnums): ContextField { const serialized: ContextField = { field: field.field, type: field.type }; if (field.reference) serialized.reference = field.reference; @@ -79,7 +81,7 @@ function toContextField(field: ForestSchemaField): ContextField { if (field.isRequired) serialized.isRequired = true; if (field.isReadOnly) serialized.isReadOnly = true; - const enums = toArray((field as { enums?: string[] }).enums); + const enums = toArray(field.enums); if (enums.length > 0) serialized.enums = [...enums]; const validations = toContextValidations(field.validations); @@ -120,6 +122,14 @@ function toContextCollection( }; } +function toContextMeta({ schemaRevision, environmentId }: ContextMeta): ContextMeta { + const meta: ContextMeta = { schemaRevision }; + + if (environmentId !== undefined) meta.environmentId = environmentId; + + return meta; +} + export default function buildContext( collections: ForestSchemaCollection[], readModel: ReadModel, @@ -129,6 +139,6 @@ export default function buildContext( collections: collections .filter(collection => readModel.isCollectionAllowed(collection.name)) .map(collection => toContextCollection(collection, readModel)), - meta: meta.environmentId === undefined ? { schemaRevision: meta.schemaRevision } : { ...meta }, + meta: toContextMeta(meta), }; } diff --git a/packages/agent-bff/src/context/context-routes-middleware.ts b/packages/agent-bff/src/context/context-routes-middleware.ts index 3101ca7f7c..6ed75a04a8 100644 --- a/packages/agent-bff/src/context/context-routes-middleware.ts +++ b/packages/agent-bff/src/context/context-routes-middleware.ts @@ -6,7 +6,7 @@ import buildContext from './build-context'; import { oauthRequired, schemaUnavailable } from '../http/bff-local-errors'; import SchemaUnavailableError from '../read-model/errors'; -export const CONTEXT_ROUTE = '/agent/v1/context'; +const CONTEXT_ROUTE = '/agent/v1/context'; export interface ContextRoutesMiddlewareOptions { store: ReadModelStore; diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index cb993a9e43..fc8c55be2b 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -105,14 +105,7 @@ describe('buildContext', () => { ]); }); - it('should drop the message, which is UI copy rather than contract', () => { - const context = buildContext(schema, readModel, { schemaRevision: 1 }); - const [rule] = fieldNamed(context, 'titleWithPresence')?.validations ?? []; - - expect(Object.keys(rule ?? {})).toEqual(['type']); - }); - - it('should keep a rule that carries no value, rather than inventing one', () => { + it('should drop the message and invent no value on a rule that has neither', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(fieldNamed(context, 'titleWithPresence')?.validations).toEqual([ From 6b93f1b58912f68fa7efb9a58acc769ef806504d Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 19 Aug 2026 11:12:56 +0200 Subject: [PATCH 12/29] fix(agent-bff): skip a null action field instead of failing the context contract --- packages/agent-bff/src/context/build-context.ts | 4 +++- packages/agent-bff/src/openapi/openapi-document.ts | 3 ++- packages/agent-bff/test/context/build-context.test.ts | 7 +++++++ packages/agent-bff/test/context/fixtures.ts | 5 ++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index 777ef307d7..daf7be8928 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -105,7 +105,9 @@ function toContextAction(action: ForestSchemaAction): ContextAction { id: action.id, name: action.name, type: action.type, - fields: toArray(action.fields).map(toContextActionField), + fields: toArray(action.fields) + .filter(field => typeof field === 'object' && field !== null) + .map(toContextActionField), }; } diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index 53c4e7c3b3..149c78c8f5 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -37,7 +37,7 @@ const ERROR_STATUSES: Record = { 415: 'The request declares a character set the server cannot decode. Other content types are NOT rejected: a form-urlencoded body is parsed and validated like JSON (its values arrive as strings, so typed fields such as page.limit fail with 400), while any other non-JSON content type is read as an absent body, silently dropping filters and pagination', 422: 'A field is unknown, not filterable, or is a nested relation path', 429: 'The agent rate-limited the request', - 500: 'The agent payload could not be mapped to the BFF contract', + 500: 'The agent payload could not be mapped to the BFF contract, or the BFF hit an unexpected error', 501: 'The BFF is running without an agent configured, so the proxy is not implemented', 502: 'The agent could not be reached', 503: 'The agent schema is unavailable, the agent returned a 5xx, or the API key could not be resolved', @@ -304,6 +304,7 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): 400: errorRefs.byStatus['400'], 401: errorRefs.byStatus['401'], 403: errorRefs.byStatus['403'], + 500: errorRefs.byStatus['500'], 501: errorRefs.byStatus['501'], 503: errorRefs.byStatus['503'], }, diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index fc8c55be2b..08006d668d 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -176,6 +176,13 @@ describe('buildContext', () => { expect(loading).not.toHaveProperty('enums'); }); + it('should skip a null action field rather than fail the whole contract', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const archive = usersOf(context)?.actions.find(entry => entry.name === 'Archive'); + + expect(archive?.fields).toEqual([{ field: 'confirm', type: 'Boolean' }]); + }); + it('should serialize an action field with its enums and default value', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); const banAction = usersOf(context)?.actions.find(entry => entry.name === 'Ban user'); diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts index e403a9f90f..086d404391 100644 --- a/packages/agent-bff/test/context/fixtures.ts +++ b/packages/agent-bff/test/context/fixtures.ts @@ -108,7 +108,10 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti enums: null, } as unknown as ForestSchemaAction['fields'][number], ]), - action('Archive', 'bulk', '/forest/users/actions/archive'), + action('Archive', 'bulk', '/forest/users/actions/archive', [ + null, + { field: 'confirm', type: 'Boolean' }, + ] as unknown as ForestSchemaAction['fields']), action('Endpointless action', 'single', undefined), ], }, From dda999635b70c51731915b809797ba02fe197c75 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 19 Aug 2026 18:20:52 +0200 Subject: [PATCH 13/29] feat(agent-bff): carry relation metadata in the context contract --- packages/agent-bff/src/context/build-context.ts | 8 ++++++++ .../agent-bff/src/http/agent-route-helpers.ts | 10 ++++++++++ .../agent-bff/test/context/build-context.test.ts | 15 ++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index daf7be8928..81204b1038 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -1,4 +1,5 @@ import type ReadModel from '../read-model/read-model'; +import type { RelationshipType } from '../read-model/read-model'; import type { ForestSchemaAction, ForestSchemaCollection, @@ -28,8 +29,10 @@ export interface ContextValidation { export interface ContextField { field: string; type: unknown; + relationship?: RelationshipType; reference?: string; inverseOf?: string; + polymorphicTargets?: string[]; isPrimaryKey?: boolean; isRequired?: boolean; isReadOnly?: boolean; @@ -75,8 +78,13 @@ function toContextValidations(validations: unknown): ContextValidation[] { function toContextField(field: FieldWithWireEnums): ContextField { const serialized: ContextField = { field: field.field, type: field.type }; + if (field.relationship) serialized.relationship = field.relationship; if (field.reference) serialized.reference = field.reference; if (field.inverseOf) serialized.inverseOf = field.inverseOf; + + const polymorphicTargets = toArray(field.polymorphicReferencedModels); + if (polymorphicTargets.length > 0) serialized.polymorphicTargets = [...polymorphicTargets]; + if (field.isPrimaryKey) serialized.isPrimaryKey = true; if (field.isRequired) serialized.isRequired = true; if (field.isReadOnly) serialized.isReadOnly = true; diff --git a/packages/agent-bff/src/http/agent-route-helpers.ts b/packages/agent-bff/src/http/agent-route-helpers.ts index 996c9896aa..dc64d56b16 100644 --- a/packages/agent-bff/src/http/agent-route-helpers.ts +++ b/packages/agent-bff/src/http/agent-route-helpers.ts @@ -1,6 +1,7 @@ import type { Logger } from '../ports/logger-port'; import type ReadModel from '../read-model/read-model'; import type ReadModelStore from '../read-model/read-model-store'; +import type { SchemaSnapshot } from '../read-model/read-model-store'; import type { Context } from 'koa'; import { mapAgentError } from './agent-error-mapper'; @@ -25,6 +26,15 @@ export async function resolveReadModel(store: ReadModelStore): Promise { + try { + return await store.getSchemaSnapshot(); + } catch (error) { + if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); + throw error; + } +} + export function requireAgentToken(ctx: Context): string { const token = ctx.state.agentToken as string | undefined; if (!token) throw unauthorized('No agent credentials for this request'); diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index 08006d668d..90010d725b 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -31,6 +31,7 @@ describe('buildContext', () => { expect(fieldNamed(context, 'ordersHasManyWithInverseOf')).toEqual({ field: 'ordersHasManyWithInverseOf', type: 'String', + relationship: 'HasMany', reference: 'orders.customerId', inverseOf: 'customer', }); @@ -43,20 +44,31 @@ describe('buildContext', () => { expect(relation).toEqual({ field: 'teamBelongsToWithoutInverseOf', type: 'String', + relationship: 'BelongsTo', reference: 'teams.id', }); expect(relation).not.toHaveProperty('inverseOf'); }); - it('should keep a polymorphic relation, which carries no reference', () => { + it('should name the targets of a polymorphic relation, which carries no reference', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); expect(fieldNamed(context, 'ownerPolymorphic')).toEqual({ field: 'ownerPolymorphic', type: 'String', + relationship: 'BelongsTo', + polymorphicTargets: ['users', 'teams'], }); }); + it('should tell a to-many relation from a to-one, which share the reference shape', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'ordersHasManyWithInverseOf')?.relationship).toBe('HasMany'); + expect(fieldNamed(context, 'teamBelongsToWithoutInverseOf')?.relationship).toBe('BelongsTo'); + expect(fieldNamed(context, 'id')).not.toHaveProperty('relationship'); + }); + it('should carry isRequired and isReadOnly only when true', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); @@ -271,6 +283,7 @@ describe('buildContext', () => { 'field', 'inverseOf', 'reference', + 'relationship', 'type', ]); expect(Object.keys(users?.actions[0] ?? {}).sort()).toEqual(['fields', 'id', 'name', 'type']); From f86f1859c748dd29d89dd4723f11d5f77c3806e2 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 19 Aug 2026 18:21:05 +0200 Subject: [PATCH 14/29] feat(agent-bff): accept a BFF api key on the context route --- .../src/context/context-routes-middleware.ts | 17 ++--------------- packages/agent-bff/src/http/bff-local-errors.ts | 6 ------ .../agent-bff/src/openapi/openapi-document.ts | 12 ++++++------ packages/agent-bff/src/openapi/schemas.ts | 5 ++++- .../context/context-routes-middleware.test.ts | 13 +++++++------ .../test/openapi/openapi-document.test.ts | 6 +++--- 6 files changed, 22 insertions(+), 37 deletions(-) diff --git a/packages/agent-bff/src/context/context-routes-middleware.ts b/packages/agent-bff/src/context/context-routes-middleware.ts index 6ed75a04a8..e3c131d6b1 100644 --- a/packages/agent-bff/src/context/context-routes-middleware.ts +++ b/packages/agent-bff/src/context/context-routes-middleware.ts @@ -1,10 +1,8 @@ import type ReadModelStore from '../read-model/read-model-store'; -import type { SchemaSnapshot } from '../read-model/read-model-store'; import type { Middleware } from 'koa'; import buildContext from './build-context'; -import { oauthRequired, schemaUnavailable } from '../http/bff-local-errors'; -import SchemaUnavailableError from '../read-model/errors'; +import { resolveSchemaSnapshot } from '../http/agent-route-helpers'; const CONTEXT_ROUTE = '/agent/v1/context'; @@ -13,15 +11,6 @@ export interface ContextRoutesMiddlewareOptions { environmentId?: number; } -async function readSnapshot(store: ReadModelStore): Promise { - try { - return await store.getSchemaSnapshot(); - } catch (error) { - if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); - throw error; - } -} - export default function createContextRoutesMiddleware({ store, environmentId, @@ -33,9 +22,7 @@ export default function createContextRoutesMiddleware({ return; } - if (ctx.state.authMode !== 'oauth') throw oauthRequired(); - - const { collections, readModel, revision } = await readSnapshot(store); + const { collections, readModel, revision } = await resolveSchemaSnapshot(store); ctx.status = 200; ctx.body = buildContext(collections, readModel, { schemaRevision: revision, environmentId }); diff --git a/packages/agent-bff/src/http/bff-local-errors.ts b/packages/agent-bff/src/http/bff-local-errors.ts index 1da5e90d47..427a6b7778 100644 --- a/packages/agent-bff/src/http/bff-local-errors.ts +++ b/packages/agent-bff/src/http/bff-local-errors.ts @@ -52,12 +52,6 @@ export function forestIdentityNotAllowed(message = 'Forest identity not allowed' return new BffHttpError(403, 'forest_identity_not_allowed', message); } -export function oauthRequired( - message = 'This route requires an OAuth session; an API key is not accepted', -): BffHttpError { - return new BffHttpError(403, 'oauth_required', message); -} - export function permissionsUnavailable( retryAfter: number, message = 'Permissions are unavailable', diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index 149c78c8f5..b8169ea724 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -31,7 +31,7 @@ const SECURITY = [{ [API_KEY_SCHEME]: [] }]; const ERROR_STATUSES: Record = { 400: 'Malformed body, a malformed URL-encoded path segment, an invalid filter operator, a filter nested too deep, ambiguous credentials, an unsupported page, a missing or invalid timezone, an unknown submitted action field, or a rejected action form (type action_error)', 401: 'Missing, invalid, or expired credentials', - 403: 'The action needs approval before it runs (the body carries the approving roles), the Forest identity behind the API key is not allowed, the origin is not allowed for this key, the route requires an OAuth session and an API key was presented (type oauth_required), or the agent refused the collection, relation, or action', + 403: 'The action needs approval before it runs (the body carries the approving roles), the Forest identity behind the API key is not allowed, the origin is not allowed for this key, or the agent refused the collection, relation, or action', 404: 'Unknown collection, relation, or action', 413: `The request body exceeds the BFF limit of ${BODY_LIMIT}`, 415: 'The request declares a character set the server cannot decode. Other content types are NOT rejected: a form-urlencoded body is parsed and validated like JSON (its values arrive as strings, so typed fields such as page.limit fail with 400), while any other non-JSON content type is read as an absent body, silently dropping filters and pagination', @@ -278,9 +278,8 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): type: 'http', scheme: 'bearer', description: - 'Mode 1: the BFF session token issued after the OAuth login. The context contract requires ' + - 'it and accepts nothing else, since it is the only credential carrying a session. The data ' + - 'and action routes list the API key instead.', + 'Mode 1: the BFF session token issued after the OAuth login. Accepted on every agent route, ' + + 'including the context contract.', }); registry.registerComponent('securitySchemes', API_KEY_SCHEME, { type: 'apiKey', @@ -294,7 +293,7 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): path: `${ROUTE_PREFIX}/context`, operationId: 'getContext', summary: 'Read the exposed schema contract', - security: [{ [SESSION_SCHEME]: [] }], + security: [{ [SESSION_SCHEME]: [] }, { [API_KEY_SCHEME]: [] }], request: {}, responses: { 200: { @@ -303,7 +302,8 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): }, 400: errorRefs.byStatus['400'], 401: errorRefs.byStatus['401'], - 403: errorRefs.byStatus['403'], + 413: errorRefs.byStatus['413'], + 415: errorRefs.byStatus['415'], 500: errorRefs.byStatus['500'], 501: errorRefs.byStatus['501'], 503: errorRefs.byStatus['503'], diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 108e55d38f..6946927936 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -168,8 +168,10 @@ const ContextValidationSchema = z const ContextFieldSchema = z.object({ field: z.string(), type: ContextFieldTypeSchema, + relationship: z.enum(['BelongsTo', 'HasOne', 'HasMany', 'BelongsToMany']).optional(), reference: z.string().optional(), inverseOf: z.string().optional(), + polymorphicTargets: z.array(z.string()).optional(), isPrimaryKey: z.boolean().optional(), isRequired: z.boolean().optional(), isReadOnly: z.boolean().optional(), @@ -213,7 +215,8 @@ export const ContextResponseSchema = z 'field from an encoded one: read `validations` for that. `reference` keeps the raw agent ' + 'form, the foreign collection and the key joined by a dot — the collection name may itself ' + 'contain dots, so drop only the trailing segment to recover it. ' + - 'The document carries no rendering, environment, project or team identity. It is NOT ' + + 'The document carries no rendering, project or team identity, and the only environment ' + + 'datum is `meta.environmentId` below. It is served to both auth modes — an OAuth session and a BFF API key get the same document. It is NOT ' + 'filtered by the caller permissions, nor by anything else: it describes the whole exposed ' + 'schema, so cross it with `/agent/v1/permissions` to know what the caller may actually ' + 'see. `meta.schemaRevision` increments whenever the BFF refreshes its schema, and resets ' + diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts index 766e810193..9ca1fcd4ba 100644 --- a/packages/agent-bff/test/context/context-routes-middleware.test.ts +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -78,15 +78,16 @@ describe('contextRoutesMiddleware', () => { }); describe('when the caller authenticated with an API key', () => { - it('should refuse with 403 oauth_required', async () => { + it('should serve the same contract as an OAuth caller, since the schema is not caller-scoped', async () => { const fetchSchema = jest.fn().mockResolvedValue(schema); - const { app } = makeApp(fetchSchema, 'api-key'); + const withKey = makeApp(fetchSchema, 'api-key'); + const withSession = makeApp(jest.fn().mockResolvedValue(schema)); - const response = await request(app.callback()).get(ROUTE); + const keyResponse = await request(withKey.app.callback()).get(ROUTE); + const sessionResponse = await request(withSession.app.callback()).get(ROUTE); - expect(response.status).toBe(403); - expect(response.body.error).toMatchObject({ type: 'oauth_required', status: 403 }); - expect(fetchSchema).not.toHaveBeenCalled(); + expect(keyResponse.status).toBe(200); + expect(keyResponse.body).toEqual(sessionResponse.body); }); }); diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index 9c81d7e12f..365b17d08a 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -169,12 +169,12 @@ describe('generateOpenApiDocument', () => { }); }); - it('should secure the context contract with the session scheme, the one mode it accepts', () => { + it('should let both auth modes reach the context contract, which is not caller-scoped', () => { const context = (document.paths ?? {})[`${ROUTE_PREFIX}/context`] as { get: { security: unknown }; }; - expect(context.get.security).toEqual([{ bffSession: [] }]); + expect(context.get.security).toEqual([{ bffSession: [] }, { bffApiKey: [] }]); }); it('should say in the session scheme which routes accept it', () => { @@ -182,7 +182,7 @@ describe('generateOpenApiDocument', () => { document.components?.securitySchemes as Record ).bffSession; - expect(session.description).toContain('context contract requires it'); + expect(session.description).toContain('including the context contract'); }); it('should require a body where parentId or recordIds is mandatory', () => { From 3e2ec4b1789ae31176b2447fc53d8004b140cc2a Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Thu, 20 Aug 2026 11:55:55 +0200 Subject: [PATCH 15/29] fix(agent-bff): correct the context route status codes, security doc and relationship union --- .../agent-bff/src/http/agent-route-helpers.ts | 15 ++-- .../agent-bff/src/openapi/openapi-document.ts | 7 +- packages/agent-bff/src/openapi/schemas.ts | 6 +- .../agent-bff/src/read-model/read-model.ts | 9 +- .../context/context-routes-middleware.test.ts | 86 +++++++++++++++++-- .../test/openapi/openapi-document.test.ts | 3 +- 6 files changed, 102 insertions(+), 24 deletions(-) diff --git a/packages/agent-bff/src/http/agent-route-helpers.ts b/packages/agent-bff/src/http/agent-route-helpers.ts index dc64d56b16..3020715919 100644 --- a/packages/agent-bff/src/http/agent-route-helpers.ts +++ b/packages/agent-bff/src/http/agent-route-helpers.ts @@ -17,9 +17,9 @@ export function decodeSegment(raw: string, label: string): string { } } -export async function resolveReadModel(store: ReadModelStore): Promise { +async function mappingSchemaFailure(read: () => Promise): Promise { try { - return await store.getReadModel(); + return await read(); } catch (error) { if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); throw error; @@ -27,12 +27,11 @@ export async function resolveReadModel(store: ReadModelStore): Promise { - try { - return await store.getSchemaSnapshot(); - } catch (error) { - if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); - throw error; - } + return mappingSchemaFailure(() => store.getSchemaSnapshot()); +} + +export async function resolveReadModel(store: ReadModelStore): Promise { + return mappingSchemaFailure(() => store.getReadModel()); } export function requireAgentToken(ctx: Context): string { diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index b8169ea724..68d147b645 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -278,8 +278,8 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): type: 'http', scheme: 'bearer', description: - 'Mode 1: the BFF session token issued after the OAuth login. Accepted on every agent route, ' + - 'including the context contract.', + 'Mode 1: the BFF session token issued after the OAuth login. Accepted on the context ' + + 'contract; the data and action routes advertise the API key only.', }); registry.registerComponent('securitySchemes', API_KEY_SCHEME, { type: 'apiKey', @@ -302,8 +302,7 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): }, 400: errorRefs.byStatus['400'], 401: errorRefs.byStatus['401'], - 413: errorRefs.byStatus['413'], - 415: errorRefs.byStatus['415'], + 403: errorRefs.byStatus['403'], 500: errorRefs.byStatus['500'], 501: errorRefs.byStatus['501'], 503: errorRefs.byStatus['503'], diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 6946927936..4005598cd2 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -1,6 +1,7 @@ import { allOperators } from '@forestadmin/datasource-toolkit'; import { z } from './zod-openapi'; +import { RELATIONSHIP_TYPES } from '../read-model/read-model'; import { MAX_FILTER_DEPTH } from '../validation/capabilities-validator'; const OPERATORS = [...allOperators] as [string, ...string[]]; @@ -168,7 +169,7 @@ const ContextValidationSchema = z const ContextFieldSchema = z.object({ field: z.string(), type: ContextFieldTypeSchema, - relationship: z.enum(['BelongsTo', 'HasOne', 'HasMany', 'BelongsToMany']).optional(), + relationship: z.enum(RELATIONSHIP_TYPES).optional(), reference: z.string().optional(), inverseOf: z.string().optional(), polymorphicTargets: z.array(z.string()).optional(), @@ -216,7 +217,8 @@ export const ContextResponseSchema = z 'form, the foreign collection and the key joined by a dot — the collection name may itself ' + 'contain dots, so drop only the trailing segment to recover it. ' + 'The document carries no rendering, project or team identity, and the only environment ' + - 'datum is `meta.environmentId` below. It is served to both auth modes — an OAuth session and a BFF API key get the same document. It is NOT ' + + 'datum is `meta.environmentId` below. It is served to both auth modes — an OAuth session ' + + 'and a BFF API key get the same document. It is NOT ' + 'filtered by the caller permissions, nor by anything else: it describes the whole exposed ' + 'schema, so cross it with `/agent/v1/permissions` to know what the caller may actually ' + 'see. `meta.schemaRevision` increments whenever the BFF refreshes its schema, and resets ' + diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts index 20a620f290..6b6a275ad1 100644 --- a/packages/agent-bff/src/read-model/read-model.ts +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -1,7 +1,14 @@ import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; import type { ForestSchemaCollection, ForestSchemaField } from '@forestadmin/forestadmin-client'; -export type RelationshipType = 'BelongsTo' | 'HasOne' | 'HasMany' | 'BelongsToMany'; +export const RELATIONSHIP_TYPES = [ + 'BelongsTo', + 'HasOne', + 'HasMany', + 'BelongsToMany', +] as const satisfies readonly NonNullable[]; + +export type RelationshipType = (typeof RELATIONSHIP_TYPES)[number]; export type RelationTarget = | { type: RelationshipType; polymorphic: false; target: string } diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts index 9ca1fcd4ba..6fa1a8f9f1 100644 --- a/packages/agent-bff/test/context/context-routes-middleware.test.ts +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -1,11 +1,15 @@ import type { SchemaFetcher } from '../../src/read-model/forest-schema-client'; import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; +import jsonwebtoken from 'jsonwebtoken'; import Koa from 'koa'; import request from 'supertest'; import schemaCoveringEveryContractShape from './fixtures'; +import createApiKeyMiddleware, { BFF_KEY_HEADER } from '../../src/api-key/api-key-middleware'; +import createAuthModeMiddleware from '../../src/auth/auth-mode-middleware'; import createContextRoutesMiddleware from '../../src/context/context-routes-middleware'; +import createPerKeyOriginMiddleware from '../../src/cors/per-key-origin'; import createErrorMiddleware from '../../src/http/error-middleware'; import CapabilitiesCache from '../../src/read-model/capabilities-cache'; import ReadModelStore from '../../src/read-model/read-model-store'; @@ -13,6 +17,8 @@ import SchemaCache from '../../src/read-model/schema-cache'; import { makeMetrics } from '../read-model/fixtures'; const ROUTE = '/agent/v1/context'; +const AUTH_SECRET = 'context-secret'; +const RAW_KEY = `fbff_${'a'.repeat(16)}_${'b'.repeat(64)}`; function makeApp(fetchSchema: jest.Mock, authMode: string | undefined = 'oauth') { const fetcher: SchemaFetcher = { fetchSchema }; @@ -30,6 +36,51 @@ function makeApp(fetchSchema: jest.Mock, authMode: string | undefined = 'oauth') return { app, schemaCache }; } +function apiKeyIdentity(allowedOrigins: string[] = []) { + return { + user: { + id: 1, + email: 'a@b.com', + firstName: 'A', + lastName: 'B', + team: 'T', + tags: [], + permissionLevel: 'admin', + }, + renderingId: 1, + allowedOrigins, + }; +} + +// The real agent edge, in the order `cli-core` mounts it: an API key is resolved by the api-key +// middleware and screened by the per-key origin guard before the context route ever runs. +function makeEdge(fetchSchema: jest.Mock, allowedOrigins: string[] = []) { + const fetcher: SchemaFetcher = { fetchSchema }; + const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics() }); + const store = new ReadModelStore(schemaCache, new CapabilitiesCache({})); + const logger = () => undefined; + + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger })); + app.use(createAuthModeMiddleware({ authSecret: AUTH_SECRET })); + app.use( + createApiKeyMiddleware({ + authenticator: { + authenticate: async () => ({ + agentToken: 'agent-token', + identity: apiKeyIdentity(allowedOrigins), + }), + }, + logger, + }), + ); + app.use(createPerKeyOriginMiddleware()); + app.use(createContextRoutesMiddleware({ store })); + + return app.callback(); +} + describe('contextRoutesMiddleware', () => { let schema: ForestSchemaCollection[]; @@ -77,18 +128,37 @@ describe('contextRoutesMiddleware', () => { }); }); - describe('when the caller authenticated with an API key', () => { - it('should serve the same contract as an OAuth caller, since the schema is not caller-scoped', async () => { - const fetchSchema = jest.fn().mockResolvedValue(schema); - const withKey = makeApp(fetchSchema, 'api-key'); - const withSession = makeApp(jest.fn().mockResolvedValue(schema)); - - const keyResponse = await request(withKey.app.callback()).get(ROUTE); - const sessionResponse = await request(withSession.app.callback()).get(ROUTE); + describe('when the caller authenticated with an API key through the full edge', () => { + it('should serve the same contract an OAuth session gets, since the schema is not caller-scoped', async () => { + const sessionToken = jsonwebtoken.sign( + { type: 'bff_access', sid: 's1', rendering_id: '1', tags: {} }, + AUTH_SECRET, + { algorithm: 'HS256', expiresIn: '15m' } as jsonwebtoken.SignOptions, + ); + + const keyResponse = await request(makeEdge(jest.fn().mockResolvedValue(schema))) + .get(ROUTE) + .set(BFF_KEY_HEADER, RAW_KEY); + const sessionResponse = await request(makeEdge(jest.fn().mockResolvedValue(schema))) + .get(ROUTE) + .set('Authorization', `Bearer ${sessionToken}`); expect(keyResponse.status).toBe(200); + expect(keyResponse.body.collections).toHaveLength(4); expect(keyResponse.body).toEqual(sessionResponse.body); }); + + it('should refuse with 403 origin_not_allowed when the key does not allow the request origin', async () => { + const response = await request( + makeEdge(jest.fn().mockResolvedValue(schema), ['https://ok.com']), + ) + .get(ROUTE) + .set(BFF_KEY_HEADER, RAW_KEY) + .set('Origin', 'https://evil.com'); + + expect(response.status).toBe(403); + expect(response.body.error).toMatchObject({ type: 'origin_not_allowed', status: 403 }); + }); }); describe('when the path or method does not match', () => { diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index 365b17d08a..401c134705 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -182,7 +182,8 @@ describe('generateOpenApiDocument', () => { document.components?.securitySchemes as Record ).bffSession; - expect(session.description).toContain('including the context contract'); + expect(session.description).toContain('Accepted on the context contract'); + expect(session.description).toContain('the data and action routes advertise the API key only'); }); it('should require a body where parentId or recordIds is mandatory', () => { From b1d4c50dfedcfeaffe8a054d42e2f66d3dbb532f Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Thu, 20 Aug 2026 13:58:32 +0200 Subject: [PATCH 16/29] refactor(agent-bff): drop the dead collection filter and pin the context contract --- .../agent-bff/src/context/build-context.ts | 4 +-- .../test/context/build-context.test.ts | 26 ++++++++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index 81204b1038..d18a652fa6 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -146,9 +146,7 @@ export default function buildContext( meta: ContextMeta, ): AgentContext { return { - collections: collections - .filter(collection => readModel.isCollectionAllowed(collection.name)) - .map(collection => toContextCollection(collection, readModel)), + collections: collections.map(collection => toContextCollection(collection, readModel)), meta: toContextMeta(meta), }; } diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index 90010d725b..159882ae84 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -1,5 +1,6 @@ import schemaCoveringEveryContractShape from './fixtures'; import buildContext from '../../src/context/build-context'; +import { ContextResponseSchema } from '../../src/openapi/schemas'; import ReadModel from '../../src/read-model/read-model'; describe('buildContext', () => { @@ -211,16 +212,6 @@ describe('buildContext', () => { }); }); - describe('when a collection is not in the read-model allow-list', () => { - it('should omit it', () => { - const restricted = new ReadModel(schema.filter(collection => collection.name === 'users')); - - const context = buildContext(schema, restricted, { schemaRevision: 1 }); - - expect(context.collections.map(collection => collection.name)).toEqual(['users']); - }); - }); - describe('when a collection name is unusual', () => { it('should keep a dotted name and a name carrying a space verbatim', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); @@ -289,4 +280,19 @@ describe('buildContext', () => { expect(Object.keys(users?.actions[0] ?? {}).sort()).toEqual(['fields', 'id', 'name', 'type']); }); }); + + // The serializer and the OpenAPI schema declare the contract twice, on purpose: the documentation + // layer must not become the source of truth for the runtime shape. The fixture covers every wire + // edge, so validating what the serializer emits against what the document promises catches a drift + // between the two without either one importing the other's types. + describe('when the built context is validated against the published OpenAPI schema', () => { + it('should satisfy ContextResponseSchema for every shape the fixture covers', () => { + const context = buildContext(schema, readModel, { schemaRevision: 3, environmentId: 42 }); + + const result = ContextResponseSchema.safeParse(context); + + expect(result.error?.issues ?? []).toEqual([]); + expect(result.success).toBe(true); + }); + }); }); From c917ac38d853c630889c4edebb4e767ba0ff9e2c Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Thu, 20 Aug 2026 15:03:36 +0200 Subject: [PATCH 17/29] test(agent-bff): round-trip the context contract and pin unserved relation targets --- packages/agent-bff/src/openapi/schemas.ts | 7 ++++- .../test/context/build-context.test.ts | 30 +++++++++++++++---- .../context/context-routes-middleware.test.ts | 10 +++---- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 4005598cd2..b455cfacdb 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -215,7 +215,12 @@ export const ContextResponseSchema = z 'wire — the bytes travel as a data uri or as hex — so `type` alone does not tell a text ' + 'field from an encoded one: read `validations` for that. `reference` keeps the raw agent ' + 'form, the foreign collection and the key joined by a dot — the collection name may itself ' + - 'contain dots, so drop only the trailing segment to recover it. ' + + 'contain dots, so drop only the trailing segment to recover it. A target named by ' + + '`reference` or `polymorphicTargets` is NOT guaranteed to appear in `collections[]`: the ' + + 'schema describes a field as the agent declares it, and a relation can point at a ' + + 'collection this document does not expose. Cross a target against `collections[]` before ' + + 'following it — unlike the per-collection route documents, nothing here is dropped for ' + + 'pointing outside the served set. ' + 'The document carries no rendering, project or team identity, and the only environment ' + 'datum is `meta.environmentId` below. It is served to both auth modes — an OAuth session ' + 'and a BFF API key get the same document. It is NOT ' + diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index 159882ae84..b33a015c6c 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -212,6 +212,26 @@ describe('buildContext', () => { }); }); + // Deliberate, and the opposite of `collect-unfolding`, which drops a relation whose target is + // hidden because documenting a route would promise a 404. Here nothing is a route: dropping the + // target would make a relation indistinguishable from a plain text column, which is the very bug + // `relationship`/`polymorphicTargets` exist to prevent. + describe('when a relation points at a collection the document does not serve', () => { + it('should still name the target, leaving the cross-check to the consumer', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const served = context.collections.map(collection => collection.name); + + expect(served).not.toContain('orders'); + expect(fieldNamed(context, 'ordersHasManyWithInverseOf')?.reference).toBe( + 'orders.customerId', + ); + expect(fieldNamed(context, 'ownerPolymorphic')?.polymorphicTargets).toEqual([ + 'users', + 'teams', + ]); + }); + }); + describe('when a collection name is unusual', () => { it('should keep a dotted name and a name carrying a space verbatim', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); @@ -286,13 +306,13 @@ describe('buildContext', () => { // edge, so validating what the serializer emits against what the document promises catches a drift // between the two without either one importing the other's types. describe('when the built context is validated against the published OpenAPI schema', () => { - it('should satisfy ContextResponseSchema for every shape the fixture covers', () => { + // Compared to the input rather than asserted valid: `z.object()` strips unknown keys instead of + // rejecting them, so a key the serializer emits and the document does not declare would pass + // validation and be silently dropped. Round-tripping catches that direction too. + it('should round-trip through ContextResponseSchema for every shape the fixture covers', () => { const context = buildContext(schema, readModel, { schemaRevision: 3, environmentId: 42 }); - const result = ContextResponseSchema.safeParse(context); - - expect(result.error?.issues ?? []).toEqual([]); - expect(result.success).toBe(true); + expect(ContextResponseSchema.parse(context)).toEqual(context); }); }); }); diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts index 6fa1a8f9f1..b403b6980f 100644 --- a/packages/agent-bff/test/context/context-routes-middleware.test.ts +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -20,17 +20,15 @@ const ROUTE = '/agent/v1/context'; const AUTH_SECRET = 'context-secret'; const RAW_KEY = `fbff_${'a'.repeat(16)}_${'b'.repeat(64)}`; -function makeApp(fetchSchema: jest.Mock, authMode: string | undefined = 'oauth') { +// No auth middleware: the route reads no principal, so these cases exercise the serializer and the +// cache. The credentialed paths go through `makeEdge` below, which mounts the real chain. +function makeApp(fetchSchema: jest.Mock) { const fetcher: SchemaFetcher = { fetchSchema }; const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics() }); const store = new ReadModelStore(schemaCache, new CapabilitiesCache({})); const app = new Koa(); app.use(createErrorMiddleware({ logger: () => {} })); - app.use(async (ctx, next) => { - ctx.state.authMode = authMode; - await next(); - }); app.use(createContextRoutesMiddleware({ store })); return { app, schemaCache }; @@ -88,7 +86,7 @@ describe('contextRoutesMiddleware', () => { schema = schemaCoveringEveryContractShape(); }); - describe('when the caller holds an OAuth session', () => { + describe('when the route serves the contract', () => { it('should serve the contract with its collections and schema revision', async () => { const fetchSchema = jest.fn().mockResolvedValue(schema); const { app } = makeApp(fetchSchema); From dd4321c17a5bca458e8a6fb39fecf673227a8088 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 01:06:23 +0200 Subject: [PATCH 18/29] fix(agent-bff): type the context field type and cover the environment id --- .../agent-bff/src/context/build-context.ts | 5 +++-- .../context/context-routes-middleware.test.ts | 20 +++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index d18a652fa6..f59b8df767 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -1,3 +1,4 @@ +import type { FieldType } from '../read-model/capabilities-cache'; import type ReadModel from '../read-model/read-model'; import type { RelationshipType } from '../read-model/read-model'; import type { @@ -8,7 +9,7 @@ import type { export interface ContextActionField { field: string; - type: unknown; + type: FieldType; isRequired?: boolean; defaultValue?: unknown; enums?: string[]; @@ -28,7 +29,7 @@ export interface ContextValidation { export interface ContextField { field: string; - type: unknown; + type: FieldType; relationship?: RelationshipType; reference?: string; inverseOf?: string; diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts index b403b6980f..21243c6291 100644 --- a/packages/agent-bff/test/context/context-routes-middleware.test.ts +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -22,14 +22,14 @@ const RAW_KEY = `fbff_${'a'.repeat(16)}_${'b'.repeat(64)}`; // No auth middleware: the route reads no principal, so these cases exercise the serializer and the // cache. The credentialed paths go through `makeEdge` below, which mounts the real chain. -function makeApp(fetchSchema: jest.Mock) { +function makeApp(fetchSchema: jest.Mock, environmentId?: number) { const fetcher: SchemaFetcher = { fetchSchema }; const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics() }); const store = new ReadModelStore(schemaCache, new CapabilitiesCache({})); const app = new Koa(); app.use(createErrorMiddleware({ logger: () => {} })); - app.use(createContextRoutesMiddleware({ store })); + app.use(createContextRoutesMiddleware({ store, environmentId })); return { app, schemaCache }; } @@ -103,6 +103,22 @@ describe('contextRoutesMiddleware', () => { expect(response.body.meta).toEqual({ schemaRevision: 1 }); }); + it('should carry the environment id the deployment resolved at boot', async () => { + const { app } = makeApp(jest.fn().mockResolvedValue(schema), 42); + + const response = await request(app.callback()).get(ROUTE); + + expect(response.body.meta).toEqual({ schemaRevision: 1, environmentId: 42 }); + }); + + it('should omit the environment id when the deployment resolved none', async () => { + const { app } = makeApp(jest.fn().mockResolvedValue(schema)); + + const response = await request(app.callback()).get(ROUTE); + + expect(response.body.meta).toEqual({ schemaRevision: 1 }); + }); + it('should fetch the schema once on a cold cache and never again while it stays warm', async () => { const fetchSchema = jest.fn().mockResolvedValue(schema); const { app } = makeApp(fetchSchema); From 1720cf4fb4759449d77ab88358bc0d68105c4878 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 12:14:31 +0200 Subject: [PATCH 19/29] test(agent-bff): pin the dotted reference and drop the test comments --- .../test/context/build-context.test.ts | 24 ++++++++--------- .../context/context-routes-middleware.test.ts | 26 ++++++++----------- packages/agent-bff/test/context/fixtures.ts | 4 +++ 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index b33a015c6c..cefad7e859 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -212,16 +212,13 @@ describe('buildContext', () => { }); }); - // Deliberate, and the opposite of `collect-unfolding`, which drops a relation whose target is - // hidden because documenting a route would promise a 404. Here nothing is a route: dropping the - // target would make a relation indistinguishable from a plain text column, which is the very bug - // `relationship`/`polymorphicTargets` exist to prevent. describe('when a relation points at a collection the document does not serve', () => { it('should still name the target, leaving the cross-check to the consumer', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); const served = context.collections.map(collection => collection.name); expect(served).not.toContain('orders'); + expect(served).not.toContain('teams'); expect(fieldNamed(context, 'ordersHasManyWithInverseOf')?.reference).toBe( 'orders.customerId', ); @@ -232,6 +229,16 @@ describe('buildContext', () => { }); }); + describe('when a relation targets a collection whose name contains dots', () => { + it('should keep the reference whole so only the trailing segment is the key', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'addressBelongsToDottedCollection')?.reference).toBe( + 'User.address.city', + ); + }); + }); + describe('when a collection name is unusual', () => { it('should keep a dotted name and a name carrying a space verbatim', () => { const context = buildContext(schema, readModel, { schemaRevision: 1 }); @@ -301,18 +308,11 @@ describe('buildContext', () => { }); }); - // The serializer and the OpenAPI schema declare the contract twice, on purpose: the documentation - // layer must not become the source of truth for the runtime shape. The fixture covers every wire - // edge, so validating what the serializer emits against what the document promises catches a drift - // between the two without either one importing the other's types. describe('when the built context is validated against the published OpenAPI schema', () => { - // Compared to the input rather than asserted valid: `z.object()` strips unknown keys instead of - // rejecting them, so a key the serializer emits and the document does not declare would pass - // validation and be silently dropped. Round-tripping catches that direction too. it('should round-trip through ContextResponseSchema for every shape the fixture covers', () => { const context = buildContext(schema, readModel, { schemaRevision: 3, environmentId: 42 }); - expect(ContextResponseSchema.parse(context)).toEqual(context); + expect(ContextResponseSchema.parse(context)).toStrictEqual(context); }); }); }); diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts index 21243c6291..f73844cb98 100644 --- a/packages/agent-bff/test/context/context-routes-middleware.test.ts +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -20,9 +20,7 @@ const ROUTE = '/agent/v1/context'; const AUTH_SECRET = 'context-secret'; const RAW_KEY = `fbff_${'a'.repeat(16)}_${'b'.repeat(64)}`; -// No auth middleware: the route reads no principal, so these cases exercise the serializer and the -// cache. The credentialed paths go through `makeEdge` below, which mounts the real chain. -function makeApp(fetchSchema: jest.Mock, environmentId?: number) { +function makeRouteOnlyApp(fetchSchema: jest.Mock, environmentId?: number) { const fetcher: SchemaFetcher = { fetchSchema }; const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics() }); const store = new ReadModelStore(schemaCache, new CapabilitiesCache({})); @@ -50,9 +48,7 @@ function apiKeyIdentity(allowedOrigins: string[] = []) { }; } -// The real agent edge, in the order `cli-core` mounts it: an API key is resolved by the api-key -// middleware and screened by the per-key origin guard before the context route ever runs. -function makeEdge(fetchSchema: jest.Mock, allowedOrigins: string[] = []) { +function makeFullAgentEdge(fetchSchema: jest.Mock, allowedOrigins: string[] = []) { const fetcher: SchemaFetcher = { fetchSchema }; const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics() }); const store = new ReadModelStore(schemaCache, new CapabilitiesCache({})); @@ -89,7 +85,7 @@ describe('contextRoutesMiddleware', () => { describe('when the route serves the contract', () => { it('should serve the contract with its collections and schema revision', async () => { const fetchSchema = jest.fn().mockResolvedValue(schema); - const { app } = makeApp(fetchSchema); + const { app } = makeRouteOnlyApp(fetchSchema); const response = await request(app.callback()).get(ROUTE); @@ -104,7 +100,7 @@ describe('contextRoutesMiddleware', () => { }); it('should carry the environment id the deployment resolved at boot', async () => { - const { app } = makeApp(jest.fn().mockResolvedValue(schema), 42); + const { app } = makeRouteOnlyApp(jest.fn().mockResolvedValue(schema), 42); const response = await request(app.callback()).get(ROUTE); @@ -112,7 +108,7 @@ describe('contextRoutesMiddleware', () => { }); it('should omit the environment id when the deployment resolved none', async () => { - const { app } = makeApp(jest.fn().mockResolvedValue(schema)); + const { app } = makeRouteOnlyApp(jest.fn().mockResolvedValue(schema)); const response = await request(app.callback()).get(ROUTE); @@ -121,7 +117,7 @@ describe('contextRoutesMiddleware', () => { it('should fetch the schema once on a cold cache and never again while it stays warm', async () => { const fetchSchema = jest.fn().mockResolvedValue(schema); - const { app } = makeApp(fetchSchema); + const { app } = makeRouteOnlyApp(fetchSchema); await request(app.callback()).get(ROUTE); await request(app.callback()).get(ROUTE); @@ -133,7 +129,7 @@ describe('contextRoutesMiddleware', () => { describe('when the schema cannot be read', () => { it('should answer 503 schema_unavailable rather than an empty contract', async () => { const fetchSchema = jest.fn().mockRejectedValue(new Error('agent down')); - const { app } = makeApp(fetchSchema); + const { app } = makeRouteOnlyApp(fetchSchema); const response = await request(app.callback()).get(ROUTE); @@ -150,10 +146,10 @@ describe('contextRoutesMiddleware', () => { { algorithm: 'HS256', expiresIn: '15m' } as jsonwebtoken.SignOptions, ); - const keyResponse = await request(makeEdge(jest.fn().mockResolvedValue(schema))) + const keyResponse = await request(makeFullAgentEdge(jest.fn().mockResolvedValue(schema))) .get(ROUTE) .set(BFF_KEY_HEADER, RAW_KEY); - const sessionResponse = await request(makeEdge(jest.fn().mockResolvedValue(schema))) + const sessionResponse = await request(makeFullAgentEdge(jest.fn().mockResolvedValue(schema))) .get(ROUTE) .set('Authorization', `Bearer ${sessionToken}`); @@ -164,7 +160,7 @@ describe('contextRoutesMiddleware', () => { it('should refuse with 403 origin_not_allowed when the key does not allow the request origin', async () => { const response = await request( - makeEdge(jest.fn().mockResolvedValue(schema), ['https://ok.com']), + makeFullAgentEdge(jest.fn().mockResolvedValue(schema), ['https://ok.com']), ) .get(ROUTE) .set(BFF_KEY_HEADER, RAW_KEY) @@ -178,7 +174,7 @@ describe('contextRoutesMiddleware', () => { describe('when the path or method does not match', () => { it('should pass through to the next middleware', async () => { const fetchSchema = jest.fn().mockResolvedValue(schema); - const { app } = makeApp(fetchSchema); + const { app } = makeRouteOnlyApp(fetchSchema); app.use(async ctx => { ctx.status = 418; }); diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts index 086d404391..f740dba4e9 100644 --- a/packages/agent-bff/test/context/fixtures.ts +++ b/packages/agent-bff/test/context/fixtures.ts @@ -82,6 +82,10 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti reference: 'teams.id', relationship: 'BelongsTo', }), + field('addressBelongsToDottedCollection', 'String', { + reference: 'User.address.city', + relationship: 'BelongsTo', + }), field('ownerPolymorphic', 'String', { relationship: 'BelongsTo', polymorphicReferencedModels: ['users', 'teams'], From 796473d36dea723696a852db920f4b37fdb3894d Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 12:14:32 +0200 Subject: [PATCH 20/29] fix(agent-bff): stop claiming the context document filters nothing --- packages/agent-bff/src/openapi/schemas.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index b455cfacdb..13bd6eec46 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -223,9 +223,9 @@ export const ContextResponseSchema = z 'pointing outside the served set. ' + 'The document carries no rendering, project or team identity, and the only environment ' + 'datum is `meta.environmentId` below. It is served to both auth modes — an OAuth session ' + - 'and a BFF API key get the same document. It is NOT ' + - 'filtered by the caller permissions, nor by anything else: it describes the whole exposed ' + - 'schema, so cross it with `/agent/v1/permissions` to know what the caller may actually ' + + 'and a BFF API key get the same document. It is NOT filtered by the caller permissions: ' + + 'it describes the whole exposed schema minus the endpoint-less actions, so cross it with ' + + '`/agent/v1/permissions` to know what the caller may actually ' + 'see. `meta.schemaRevision` increments whenever the BFF refreshes its schema, and resets ' + 'when the BFF restarts. `meta.environmentId` is the environment the BFF resolved at boot ' + 'from its own secret — it is telemetry, not a routing input, and it is absent when the ' + From 6dc75ec349bb225ba7ebae6d52642338d3c37649 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 12:39:54 +0200 Subject: [PATCH 21/29] fix(agent-bff): type the context field type instead of accepting anything --- packages/agent-bff/src/openapi/schemas.ts | 24 ++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 13bd6eec46..0c8ceb0c6b 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -144,13 +144,23 @@ export const CountResponseSchema = z 'collection disables count. The pair never disagrees.', }); -const ContextFieldTypeSchema = z.unknown().openapi('ContextFieldType', { - description: - 'The field type, passed through from the agent wire format without normalization: a string ' + - 'for a primitive, an array of types for an array field, or an object carrying `fields` for a ' + - 'composite. A consumer that only understands primitives should ignore the other two rather ' + - 'than fail.', -}); +const ContextFieldTypeSchema: z.ZodType = z + .lazy(() => + z.union([ + z.string(), + z.array(ContextFieldTypeSchema), + z.object({ + fields: z.array(z.object({ field: z.string(), type: ContextFieldTypeSchema })), + }), + ]), + ) + .openapi('ContextFieldType', { + description: + 'The field type, passed through from the agent wire format without normalization: a string ' + + 'for a primitive, an array of types for an array field, or an object carrying `fields` for ' + + 'a composite. A consumer that only understands primitives should ignore the other two ' + + 'rather than fail.', + }); const ContextValidationSchema = z .object({ type: z.string(), value: z.unknown().optional() }) From 410be8fd2e0d14f3e70d5b4f29bd497295989d17 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 12:39:55 +0200 Subject: [PATCH 22/29] test(agent-bff): make the schema snapshot pairing test able to fail --- .../test/read-model/read-model-store.test.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/agent-bff/test/read-model/read-model-store.test.ts b/packages/agent-bff/test/read-model/read-model-store.test.ts index 6800dde325..b0efca7204 100644 --- a/packages/agent-bff/test/read-model/read-model-store.test.ts +++ b/packages/agent-bff/test/read-model/read-model-store.test.ts @@ -9,14 +9,14 @@ describe('ReadModelStore', () => { let clock: number; const now = () => clock; - function build(fetchSchema: jest.Mock): ReadModelStore { + function build(fetchSchema: jest.Mock, clockOverride: () => number = now): ReadModelStore { const metrics = makeMetrics(); const schemaCache = new SchemaCache({ fetcher: { fetchSchema } as SchemaFetcher, metrics, - now, + now: clockOverride, }); - const capabilitiesCache = new CapabilitiesCache({ now }); + const capabilitiesCache = new CapabilitiesCache({ now: clockOverride }); return new ReadModelStore(schemaCache, capabilitiesCache); } @@ -36,6 +36,31 @@ describe('ReadModelStore', () => { expect(revision).toBe(1); }); + it('should read the schema once, so the read-model cannot come from a later generation', async () => { + let generation = 0; + + const fetchSchema = jest.fn().mockImplementation(async () => { + generation += 1; + + return makeSchema(`generation-${generation}`); + }); + + const alwaysExpiredClock = () => { + clock += ONE_DAY_MS + 1; + + return clock; + }; + + const store = build(fetchSchema, alwaysExpiredClock); + + const { collections, readModel, revision } = await store.getSchemaSnapshot(); + + expect(fetchSchema).toHaveBeenCalledTimes(1); + expect(collections.map(entry => entry.name)).toEqual(['generation-1']); + expect(readModel.getAllowedCollections()).toEqual(['generation-1']); + expect(revision).toBe(1); + }); + it('should not label one generation of collections with another generation revision', async () => { const fetchSchema = jest .fn() From b2250eba583784a03525c591b35c825fc9e8bcb7 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 12:39:56 +0200 Subject: [PATCH 23/29] test(agent-bff): pin the degraded statuses and the schema ttl refetch --- packages/agent-bff/test/cli-core.test.ts | 30 +++++++++++++++++++ .../context/context-routes-middleware.test.ts | 30 ++++++++++++++----- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/agent-bff/test/cli-core.test.ts b/packages/agent-bff/test/cli-core.test.ts index fe65485486..550776b55a 100644 --- a/packages/agent-bff/test/cli-core.test.ts +++ b/packages/agent-bff/test/cli-core.test.ts @@ -186,6 +186,36 @@ describe('runCli', () => { } }); + it('should fall through to the agent stub when the deployment carries no read-model', async () => { + const token = jsonwebtoken.sign( + { type: 'bff_access', sid: 's1', id: 1, rendering_id: '1', tags: {} }, + VALID_ENV.FOREST_AUTH_SECRET, + { algorithm: 'HS256', expiresIn: '15m' }, + ); + const server = await runCli({ ...VALID_ENV, FOREST_ENV_SECRET: undefined }, noopLogger); + + try { + const withTimezone = await request(server.callback) + .get('/agent/v1/context') + .set('Authorization', `Bearer ${token}`) + .set('X-Forest-Timezone', 'Europe/Paris'); + const withoutTimezone = await request(server.callback) + .get('/agent/v1/context') + .set('Authorization', `Bearer ${token}`); + + expect(withTimezone.status).toBe(501); + expect(withTimezone.body.error).toEqual( + expect.objectContaining({ type: 'not_implemented', status: 501 }), + ); + expect(withoutTimezone.status).toBe(400); + expect(withoutTimezone.body.error).toEqual( + expect.objectContaining({ type: 'missing_timezone', status: 400 }), + ); + } finally { + await server.stop(); + } + }); + it('should carry an oauth Bearer past requireAgentToken on a data route', async () => { const token = jsonwebtoken.sign( { diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts index f73844cb98..41a3e046ea 100644 --- a/packages/agent-bff/test/context/context-routes-middleware.test.ts +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -13,17 +13,22 @@ import createPerKeyOriginMiddleware from '../../src/cors/per-key-origin'; import createErrorMiddleware from '../../src/http/error-middleware'; import CapabilitiesCache from '../../src/read-model/capabilities-cache'; import ReadModelStore from '../../src/read-model/read-model-store'; -import SchemaCache from '../../src/read-model/schema-cache'; +import SchemaCache, { ONE_DAY_MS } from '../../src/read-model/schema-cache'; import { makeMetrics } from '../read-model/fixtures'; const ROUTE = '/agent/v1/context'; const AUTH_SECRET = 'context-secret'; const RAW_KEY = `fbff_${'a'.repeat(16)}_${'b'.repeat(64)}`; -function makeRouteOnlyApp(fetchSchema: jest.Mock, environmentId?: number) { +function makeStore(fetchSchema: jest.Mock, now?: () => number) { const fetcher: SchemaFetcher = { fetchSchema }; - const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics() }); - const store = new ReadModelStore(schemaCache, new CapabilitiesCache({})); + const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics(), now }); + + return { schemaCache, store: new ReadModelStore(schemaCache, new CapabilitiesCache({})) }; +} + +function makeRouteOnlyApp(fetchSchema: jest.Mock, environmentId?: number, now?: () => number) { + const { schemaCache, store } = makeStore(fetchSchema, now); const app = new Koa(); app.use(createErrorMiddleware({ logger: () => {} })); @@ -49,9 +54,7 @@ function apiKeyIdentity(allowedOrigins: string[] = []) { } function makeFullAgentEdge(fetchSchema: jest.Mock, allowedOrigins: string[] = []) { - const fetcher: SchemaFetcher = { fetchSchema }; - const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics() }); - const store = new ReadModelStore(schemaCache, new CapabilitiesCache({})); + const { store } = makeStore(fetchSchema); const logger = () => undefined; const app = new Koa(); @@ -124,6 +127,19 @@ describe('contextRoutesMiddleware', () => { expect(fetchSchema).toHaveBeenCalledTimes(1); }); + + it('should fetch the schema again once the cached one has outlived its ttl', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + let clock = 1_000_000; + const { app } = makeRouteOnlyApp(fetchSchema, undefined, () => clock); + + await request(app.callback()).get(ROUTE); + clock += ONE_DAY_MS + 1; + const response = await request(app.callback()).get(ROUTE); + + expect(fetchSchema).toHaveBeenCalledTimes(2); + expect(response.body.meta.schemaRevision).toBe(2); + }); }); describe('when the schema cannot be read', () => { From 90942eb9d89d113dc5bbaccaa30919d245ca6226 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 12:39:56 +0200 Subject: [PATCH 24/29] refactor(agent-bff): guard every schema list against a non-object entry --- .../agent-bff/src/context/build-context.ts | 12 ++++++----- packages/agent-bff/test/context/fixtures.ts | 21 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index f59b8df767..8adf96d9dd 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -61,6 +61,10 @@ function toArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : []; } +function toObjectArray(value: T[] | null | undefined): T[] { + return toArray(value).filter(entry => typeof entry === 'object' && entry !== null); +} + type FieldWithWireEnums = ForestSchemaField & { enums?: string[] }; function toContextValidations(validations: unknown): ContextValidation[] { @@ -114,9 +118,7 @@ function toContextAction(action: ForestSchemaAction): ContextAction { id: action.id, name: action.name, type: action.type, - fields: toArray(action.fields) - .filter(field => typeof field === 'object' && field !== null) - .map(toContextActionField), + fields: toObjectArray(action.fields).map(toContextActionField), }; } @@ -126,8 +128,8 @@ function toContextCollection( ): ContextCollection { return { name: collection.name, - fields: toArray(collection.fields).map(toContextField), - actions: toArray(collection.actions) + fields: toObjectArray(collection.fields).map(toContextField), + actions: toObjectArray(collection.actions) .filter(action => readModel.isActionAllowed(collection.name, action.name)) .map(toContextAction), }; diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts index f740dba4e9..fe7e22065e 100644 --- a/packages/agent-bff/test/context/fixtures.ts +++ b/packages/agent-bff/test/context/fixtures.ts @@ -4,7 +4,14 @@ import type { ForestSchemaField, } from '@forestadmin/forestadmin-client'; -function field(name: string, type: unknown, extra: Partial = {}) { +type WireField = Partial> & { + enums?: string[] | null; + validations?: unknown; +}; + +type WireActionField = ForestSchemaAction['fields'][number] | null; + +function field(name: string, type: unknown, extra: WireField = {}) { return { field: name, type, @@ -21,7 +28,7 @@ function action( name: string, type: ForestSchemaAction['type'], endpoint: string | undefined, - fields: ForestSchemaAction['fields'] = [], + fields: WireActionField[] = [], ) { return { id: `${name}-id`, @@ -55,7 +62,7 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti }), field('statusWithEnums', 'Enum', { enums: ['DRAFT', 'PUBLISHED'], - } as unknown as Partial), + }), field('statusWithFlaggedPattern', 'Enum', { validations: [{ type: 'is like', value: '/^a|b|c$/g', message: 'x' }], }), @@ -69,10 +76,10 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti 'garbage', { type: 'contains', value: 'ok' }, ], - } as unknown as Partial), + }), field('fieldWithNullValidations', 'String', { validations: null, - } as unknown as Partial), + }), field('ordersHasManyWithInverseOf', 'String', { reference: 'orders.customerId', relationship: 'HasMany', @@ -110,12 +117,12 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti field: 'loading', type: 'String', enums: null, - } as unknown as ForestSchemaAction['fields'][number], + }, ]), action('Archive', 'bulk', '/forest/users/actions/archive', [ null, { field: 'confirm', type: 'Boolean' }, - ] as unknown as ForestSchemaAction['fields']), + ]), action('Endpointless action', 'single', undefined), ], }, From b70614f23f2fbbc9500b4f171fa35af75f8ee387 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 12:39:57 +0200 Subject: [PATCH 25/29] refactor(agent-bff): inline the context middleware builder and drop a rename --- packages/agent-bff/src/cli-core.ts | 19 +++++-------------- .../agent-bff/src/http/agent-route-helpers.ts | 6 +++--- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index e085050002..82e4afdccd 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -168,7 +168,7 @@ function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware | return createApiKeyMiddleware({ authenticator, logger }); } -interface AgentEdgeReadModel { +interface ReadModelBundle { store: ReadModelStore; apiKeyConfig: ResolvedApiKeyConfig; } @@ -182,7 +182,7 @@ function resolveReadModelBundle( config: BFFConfig, logger: Logger, metrics?: Metrics, -): AgentEdgeReadModel | undefined { +): ReadModelBundle | undefined { const apiKeyConfig = resolveApiKeyConfig(config); if (!apiKeyConfig) return undefined; @@ -204,7 +204,7 @@ function resolveReadModelBundle( * the two report a missing configuration differently. */ function toUnfoldSource( - bundle: AgentEdgeReadModel | undefined, + bundle: ReadModelBundle | undefined, config: BFFConfig, logger: Logger, ): UnfoldSource | undefined { @@ -232,7 +232,7 @@ export function resolveUnfoldSource(config: BFFConfig, logger: Logger): UnfoldSo // The data middleware falls through to the action middleware on a non-data path. function buildAgentRouteMiddlewares( - bundle: AgentEdgeReadModel | undefined, + bundle: ReadModelBundle | undefined, config: BFFConfig, logger: Logger, ): Middleware[] { @@ -271,15 +271,6 @@ function buildAgentRouteMiddlewares( ]; } -function buildContextMiddlewares( - bundle: AgentEdgeReadModel | undefined, - environmentId?: number, -): Middleware[] { - if (!bundle) return []; - - return [createContextRoutesMiddleware({ store: bundle.store, environmentId })]; -} - function buildAgentMiddlewares( config: BFFConfig, logger: Logger, @@ -304,7 +295,7 @@ function buildAgentMiddlewares( apiKeyStep, createPerKeyOriginMiddleware(), createOpenApiRoutes({ version, enabled: config.openapiEnabled, source }), - ...buildContextMiddlewares(bundle, environmentId), + ...(bundle ? [createContextRoutesMiddleware({ store: bundle.store, environmentId })] : []), createTimezoneMiddleware({ defaultTimezone }), ...buildAgentRouteMiddlewares(bundle, config, logger), ]; diff --git a/packages/agent-bff/src/http/agent-route-helpers.ts b/packages/agent-bff/src/http/agent-route-helpers.ts index 3020715919..d9fb3332c7 100644 --- a/packages/agent-bff/src/http/agent-route-helpers.ts +++ b/packages/agent-bff/src/http/agent-route-helpers.ts @@ -17,7 +17,7 @@ export function decodeSegment(raw: string, label: string): string { } } -async function mappingSchemaFailure(read: () => Promise): Promise { +async function mapSchemaFailure(read: () => Promise): Promise { try { return await read(); } catch (error) { @@ -27,11 +27,11 @@ async function mappingSchemaFailure(read: () => Promise): Promise { } export async function resolveSchemaSnapshot(store: ReadModelStore): Promise { - return mappingSchemaFailure(() => store.getSchemaSnapshot()); + return mapSchemaFailure(() => store.getSchemaSnapshot()); } export async function resolveReadModel(store: ReadModelStore): Promise { - return mappingSchemaFailure(() => store.getReadModel()); + return mapSchemaFailure(() => store.getReadModel()); } export function requireAgentToken(ctx: Context): string { From bba8d23a923a49ee4eb8bdb47450944e97dbe439 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 16:16:08 +0200 Subject: [PATCH 26/29] fix(agent-bff): carry the enums of a composite sub-field in the contract --- packages/agent-bff/src/openapi/schemas.ts | 13 ++++++++++--- .../agent-bff/src/read-model/capabilities-cache.ts | 5 ++++- .../agent-bff/test/context/build-context.test.ts | 6 +++++- packages/agent-bff/test/context/fixtures.ts | 8 +++++++- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index 0c8ceb0c6b..edd467dfae 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -150,7 +150,13 @@ const ContextFieldTypeSchema: z.ZodType = z z.string(), z.array(ContextFieldTypeSchema), z.object({ - fields: z.array(z.object({ field: z.string(), type: ContextFieldTypeSchema })), + fields: z.array( + z.object({ + field: z.string(), + type: ContextFieldTypeSchema, + enums: z.array(z.string()).optional(), + }), + ), }), ]), ) @@ -158,8 +164,9 @@ const ContextFieldTypeSchema: z.ZodType = z description: 'The field type, passed through from the agent wire format without normalization: a string ' + 'for a primitive, an array of types for an array field, or an object carrying `fields` for ' + - 'a composite. A consumer that only understands primitives should ignore the other two ' + - 'rather than fail.', + 'a composite. A composite sub-field carries its own `type`, recursively, and its own ' + + '`enums` when it is an enum. A consumer that only understands primitives should ignore the ' + + 'other two rather than fail.', }); const ContextValidationSchema = z diff --git a/packages/agent-bff/src/read-model/capabilities-cache.ts b/packages/agent-bff/src/read-model/capabilities-cache.ts index 46d2347119..0c2f3dd307 100644 --- a/packages/agent-bff/src/read-model/capabilities-cache.ts +++ b/packages/agent-bff/src/read-model/capabilities-cache.ts @@ -4,7 +4,10 @@ import { ONE_DAY_MS } from './schema-cache'; * A Forest column type: a primitive name, a one-element array wrapping another type, a composite * `{ fields }`, or a relation marker like `ManyToOne`. */ -export type FieldType = string | FieldType[] | { fields: { field: string; type: FieldType }[] }; +export type FieldType = + | string + | FieldType[] + | { fields: { field: string; type: FieldType; enums?: string[] }[] }; export interface CapabilitiesResult { // `type` is the agent's raw `columnType`, so it is not always a plain name: an array-of-primitive diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index cefad7e859..fb90d21626 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -22,7 +22,11 @@ describe('buildContext', () => { expect(fieldNamed(context, 'id')?.type).toBe('Uuid'); expect(fieldNamed(context, 'tagsWithArrayType')?.type).toEqual(['String']); expect(fieldNamed(context, 'addressWithCompositeType')?.type).toEqual({ - fields: [{ field: 'city', type: 'String' }], + fields: [ + { field: 'city', type: 'String' }, + { field: 'country', type: 'Enum', enums: ['FR', 'BE'] }, + { field: 'tags', type: ['String'] }, + ], }); }); diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts index fe7e22065e..608ccce0d0 100644 --- a/packages/agent-bff/test/context/fixtures.ts +++ b/packages/agent-bff/test/context/fixtures.ts @@ -48,7 +48,13 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti fields: [ field('id', 'Uuid'), field('tagsWithArrayType', ['String']), - field('addressWithCompositeType', { fields: [{ field: 'city', type: 'String' }] }), + field('addressWithCompositeType', { + fields: [ + { field: 'city', type: 'String' }, + { field: 'country', type: 'Enum', enums: ['FR', 'BE'] }, + { field: 'tags', type: ['String'] }, + ], + }), field('lockedReadOnly', 'Boolean', { isReadOnly: true }), field('emailRequired', 'String', { isRequired: true }), field('thumbnailWithPattern', 'String', { From 82f7e0960533ce4b1d93fb2759436e979671b3df Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 16:16:09 +0200 Subject: [PATCH 27/29] refactor(agent-bff): drop the guards on lists the read-model already rejects --- packages/agent-bff/src/context/build-context.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index 8adf96d9dd..f59b8df767 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -61,10 +61,6 @@ function toArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : []; } -function toObjectArray(value: T[] | null | undefined): T[] { - return toArray(value).filter(entry => typeof entry === 'object' && entry !== null); -} - type FieldWithWireEnums = ForestSchemaField & { enums?: string[] }; function toContextValidations(validations: unknown): ContextValidation[] { @@ -118,7 +114,9 @@ function toContextAction(action: ForestSchemaAction): ContextAction { id: action.id, name: action.name, type: action.type, - fields: toObjectArray(action.fields).map(toContextActionField), + fields: toArray(action.fields) + .filter(field => typeof field === 'object' && field !== null) + .map(toContextActionField), }; } @@ -128,8 +126,8 @@ function toContextCollection( ): ContextCollection { return { name: collection.name, - fields: toObjectArray(collection.fields).map(toContextField), - actions: toObjectArray(collection.actions) + fields: toArray(collection.fields).map(toContextField), + actions: toArray(collection.actions) .filter(action => readModel.isActionAllowed(collection.name, action.name)) .map(toContextAction), }; From e01fa1f14825aaed1507acb2cd6055628eeb0078 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 16:36:30 +0200 Subject: [PATCH 28/29] refactor(agent-bff): type the validations input instead of casting it --- packages/agent-bff/src/context/build-context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts index f59b8df767..9528abdf4f 100644 --- a/packages/agent-bff/src/context/build-context.ts +++ b/packages/agent-bff/src/context/build-context.ts @@ -63,8 +63,8 @@ function toArray(value: T[] | null | undefined): T[] { type FieldWithWireEnums = ForestSchemaField & { enums?: string[] }; -function toContextValidations(validations: unknown): ContextValidation[] { - return toArray(validations as unknown[]) +function toContextValidations(validations: unknown[] | null | undefined): ContextValidation[] { + return toArray(validations) .filter( (entry): entry is { type: string; value?: unknown } => typeof entry === 'object' && From 72f4c41c6a1c06252ddac5336ed5e8d3cfa73ccc Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Fri, 21 Aug 2026 16:46:17 +0200 Subject: [PATCH 29/29] test(agent-bff): pin the revision read and the nested array type --- .../test/context/build-context.test.ts | 4 ++++ packages/agent-bff/test/context/fixtures.ts | 2 ++ .../test/read-model/read-model-store.test.ts | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts index fb90d21626..587fdd6243 100644 --- a/packages/agent-bff/test/context/build-context.test.ts +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -21,6 +21,10 @@ describe('buildContext', () => { expect(fieldNamed(context, 'id')?.type).toBe('Uuid'); expect(fieldNamed(context, 'tagsWithArrayType')?.type).toEqual(['String']); + expect(fieldNamed(context, 'matrixWithNestedArrayType')?.type).toEqual([['String']]); + expect(fieldNamed(context, 'addressesWithArrayOfComposite')?.type).toEqual([ + { fields: [{ field: 'label', type: 'String' }] }, + ]); expect(fieldNamed(context, 'addressWithCompositeType')?.type).toEqual({ fields: [ { field: 'city', type: 'String' }, diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts index 608ccce0d0..a0f5c3f1ff 100644 --- a/packages/agent-bff/test/context/fixtures.ts +++ b/packages/agent-bff/test/context/fixtures.ts @@ -48,6 +48,8 @@ export default function schemaCoveringEveryContractShape(): ForestSchemaCollecti fields: [ field('id', 'Uuid'), field('tagsWithArrayType', ['String']), + field('matrixWithNestedArrayType', [['String']]), + field('addressesWithArrayOfComposite', [{ fields: [{ field: 'label', type: 'String' }] }]), field('addressWithCompositeType', { fields: [ { field: 'city', type: 'String' }, diff --git a/packages/agent-bff/test/read-model/read-model-store.test.ts b/packages/agent-bff/test/read-model/read-model-store.test.ts index b0efca7204..5fa5b90d5f 100644 --- a/packages/agent-bff/test/read-model/read-model-store.test.ts +++ b/packages/agent-bff/test/read-model/read-model-store.test.ts @@ -61,6 +61,25 @@ describe('ReadModelStore', () => { expect(revision).toBe(1); }); + it('should read the cache revision once, so the triple cannot straddle two generations', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + const cache = Reflect.get(store, 'schemaCache') as SchemaCache; + let reads = 0; + let bumped = 0; + + jest.spyOn(cache, 'revision', 'get').mockImplementation(() => { + reads += 1; + bumped += 1; + + return bumped; + }); + + const { revision } = await store.getSchemaSnapshot(); + + expect(reads).toBe(1); + expect(revision).toBe(1); + }); + it('should not label one generation of collections with another generation revision', async () => { const fetchSchema = jest .fn()