Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
56df8b1
refactor(agent-bff): expose the schema cache in the read-model bundle
Tonours Aug 18, 2026
2486239
feat(agent-bff): serialize the allow-listed agent schema as a context…
Tonours Aug 18, 2026
0b9973e
feat(agent-bff): serve GET /agent/v1/context and declare it in the Op…
Tonours Aug 18, 2026
22b0408
refactor(agent-bff): read the schema and its read-model as one snapshot
Tonours Aug 18, 2026
d675257
test(agent-bff): constrain the schema snapshot generation pairing
Tonours Aug 18, 2026
b877af9
fix(agent-bff): tolerate a null enums list from the agent schema
Tonours Aug 18, 2026
8d4f1c6
fix(agent-bff): declare the 400 the context route can return
Tonours Aug 18, 2026
99fdc26
feat(agent-bff): carry the resolved environment id in the context meta
Tonours Aug 18, 2026
b555b2f
feat(agent-bff): carry field validations in the context contract
Tonours Aug 19, 2026
cca0447
feat(agent-bff): carry enum values and the primary-key flag in the co…
Tonours Aug 19, 2026
fe98f68
refactor(agent-bff): tighten the context serializer and drop a redund…
Tonours Aug 19, 2026
6b93f1b
fix(agent-bff): skip a null action field instead of failing the conte…
Tonours Aug 19, 2026
dda9996
feat(agent-bff): carry relation metadata in the context contract
Tonours Aug 19, 2026
f86f185
feat(agent-bff): accept a BFF api key on the context route
Tonours Aug 19, 2026
3e2ec4b
fix(agent-bff): correct the context route status codes, security doc …
Tonours Aug 20, 2026
b1d4c50
refactor(agent-bff): drop the dead collection filter and pin the cont…
Tonours Aug 20, 2026
c917ac3
test(agent-bff): round-trip the context contract and pin unserved rel…
Tonours Aug 20, 2026
dd4321c
fix(agent-bff): type the context field type and cover the environment id
Tonours Aug 20, 2026
1720cf4
test(agent-bff): pin the dotted reference and drop the test comments
Tonours Aug 21, 2026
796473d
fix(agent-bff): stop claiming the context document filters nothing
Tonours Aug 21, 2026
6dc75ec
fix(agent-bff): type the context field type instead of accepting anyt…
Tonours Aug 21, 2026
410be8f
test(agent-bff): make the schema snapshot pairing test able to fail
Tonours Aug 21, 2026
b2250eb
test(agent-bff): pin the degraded statuses and the schema ttl refetch
Tonours Aug 21, 2026
90942eb
refactor(agent-bff): guard every schema list against a non-object entry
Tonours Aug 21, 2026
b70614f
refactor(agent-bff): inline the context middleware builder and drop a…
Tonours Aug 21, 2026
bba8d23
fix(agent-bff): carry the enums of a composite sub-field in the contract
Tonours Aug 21, 2026
82f7e09
refactor(agent-bff): drop the guards on lists the read-model already …
Tonours Aug 21, 2026
e01fa1f
refactor(agent-bff): type the validations input instead of casting it
Tonours Aug 21, 2026
72f4c41
test(agent-bff): pin the revision read and the nested array type
Tonours Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions packages/agent-bff/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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';
Expand Down Expand Up @@ -90,13 +91,18 @@ function resolveOAuthConfig(config: BFFConfig): ResolvedOAuthConfig | undefined
return undefined;
}

async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise<Middleware[]> {
interface OAuthEdge {
middlewares: Middleware[];
environmentId?: number;
}

async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise<OAuthEdge> {
const oauthConfig = resolveOAuthConfig(config);

if (!oauthConfig) {
logger('Warn', 'OAuth routes disabled: required configuration is missing');

return [];
return { middlewares: [] };
}

const { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey } =
Expand All @@ -120,7 +126,7 @@ async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise
logger,
});

return [oauthRoutes];
return { middlewares: [oauthRoutes], environmentId };
}

interface ResolvedApiKeyConfig {
Expand Down Expand Up @@ -265,7 +271,11 @@ function buildAgentRouteMiddlewares(
];
}

function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] {
function buildAgentMiddlewares(
config: BFFConfig,
logger: Logger,
environmentId?: number,
): Middleware[] {
const { forestAuthSecret, defaultTimezone } = config;

if (!forestAuthSecret) {
Expand All @@ -285,6 +295,7 @@ function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[]
apiKeyStep,
createPerKeyOriginMiddleware(),
createOpenApiRoutes({ version, enabled: config.openapiEnabled, source }),
...(bundle ? [createContextRoutesMiddleware({ store: bundle.store, environmentId })] : []),
createTimezoneMiddleware({ defaultTimezone }),
...buildAgentRouteMiddlewares(bundle, config, logger),
];
Expand All @@ -304,15 +315,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({
Expand Down
153 changes: 153 additions & 0 deletions packages/agent-bff/src/context/build-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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 {
ForestSchemaAction,
ForestSchemaCollection,
ForestSchemaField,
} from '@forestadmin/forestadmin-client';

export interface ContextActionField {
field: string;
type: FieldType;
isRequired?: boolean;
defaultValue?: unknown;
enums?: string[];
}

export interface ContextAction {
id: string;
name: string;
type: ForestSchemaAction['type'];
fields: ContextActionField[];
}

export interface ContextValidation {
type: string;
value?: unknown;
}

export interface ContextField {
field: string;
type: FieldType;
relationship?: RelationshipType;
reference?: string;
inverseOf?: string;
polymorphicTargets?: string[];
isPrimaryKey?: boolean;
isRequired?: boolean;
isReadOnly?: boolean;
enums?: string[];
validations?: ContextValidation[];
}

export interface ContextCollection {
name: string;
fields: ContextField[];
actions: ContextAction[];
}

export interface ContextMeta {
schemaRevision: number;
environmentId?: number;
}

export interface AgentContext {
collections: ContextCollection[];
meta: ContextMeta;
}

function toArray<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : [];
}

type FieldWithWireEnums = ForestSchemaField & { enums?: string[] };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ForestSchemaField declares enum, the agent emits enums — fixing the type upstream in forestadmin-client would drop this intersection and the as unknown as cast in the fixture.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed on the diagnosis: ForestSchemaField declares enum, the agent emits enums (generator-fields.ts:71, generator-actions.ts:45), and unfolding.ts:80 already works around the same gap.

Not doing it in this PR though — fixing the declared type in forestadmin-client changes a published type for every consumer of that package, which is a wider blast radius than a BFF route deserves to carry. Worth its own ticket; I'll open one. Until then the intersection type is named FieldWithWireEnums so the reason is visible at the use site rather than hidden in a cast.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Follow-up on the ticket I promised: PRD-993 — "forestadmin-client: ForestSchemaField declares enum but the wire sends enums", currently In Review. Once that lands, FieldWithWireEnums here and the as unknown as casts in the fixture both go away.

Leaving this PR as is unless you want the upstream fix folded in — say so and I will.


function toContextValidations(validations: unknown[] | null | undefined): ContextValidation[] {
return toArray(validations)
.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: 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;
Comment thread
Tonours marked this conversation as resolved.

const polymorphicTargets = toArray(field.polymorphicReferencedModels);
if (polymorphicTargets.length > 0) serialized.polymorphicTargets = [...polymorphicTargets];
Comment on lines +83 to +87

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

collect-unfolding.ts:157 drops relations whose foreign collection is outside the allow-list so the document doesn't "promise a dead path" — here reference and polymorphicTargets go out unfiltered, so the contract can name a target absent from collections[].

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Real gap — the fixture already exhibits it: it references orders and teams while serving only users, User.address, My Coll and collectionWithoutFieldsNorActions. Documented rather than filtered, in c917ac3.

The two cases are not symmetric. collect-unfolding emits routes, so documenting orders while it is hidden promises an endpoint that 404s — dropping it is right there. /context emits a schema: reference: "orders.customerId" claims the field points at orders, which stays true whether or not this document serves it.

Filtering would also undo your round-1 point. Strip reference and polymorphicTargets and ownerPolymorphic goes back to {"field":"ownerPolymorphic","type":"String","relationship":"BelongsTo"} — a relation indistinguishable from a text column, which is exactly the bug those two fields were added to fix. I would rather not trade a blocking finding for a smaller one.

So the contract now states it: the description says a target named by reference or polymorphicTargets is not guaranteed to appear in collections[], and the consumer must cross-check. A test pins the behaviour so it reads as a decision rather than an oversight. If you would rather the document never name an unserved target, that is a contract change worth its own ticket — say so and I will open it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No answer on the contract-change question, so I am leaving the behaviour as it stands and treating the point as settled by documentation plus a test. The document states the target may be absent from collections[], and 1720cf4 strengthened the test with expect(served).not.toContain('teams') so both unserved targets the fixture names are proven absent.

If you would rather the document never name an unserved target, that is a contract change — reopen this and I will open the ticket.


if (field.isPrimaryKey) serialized.isPrimaryKey = true;
if (field.isRequired) serialized.isRequired = true;
if (field.isReadOnly) serialized.isReadOnly = true;

const enums = toArray(field.enums);
if (enums.length > 0) serialized.enums = [...enums];

const validations = toContextValidations(field.validations);
if (validations.length > 0) serialized.validations = validations;

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 (Array.isArray(field.enums)) serialized.enums = [...field.enums];

return serialized;
}

function toContextAction(action: ForestSchemaAction): ContextAction {
return {
id: action.id,
name: action.name,
type: action.type,
fields: toArray(action.fields)
.filter(field => typeof field === 'object' && field !== null)
.map(toContextActionField),
};
}

function toContextCollection(
collection: ForestSchemaCollection,
readModel: ReadModel,
): ContextCollection {
return {
name: collection.name,
fields: toArray(collection.fields).map(toContextField),
actions: toArray(collection.actions)
.filter(action => readModel.isActionAllowed(collection.name, action.name))
.map(toContextAction),
};
}

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,
meta: ContextMeta,
): AgentContext {
return {
collections: collections.map(collection => toContextCollection(collection, readModel)),
Comment thread
Tonours marked this conversation as resolved.
meta: toContextMeta(meta),
};
}
30 changes: 30 additions & 0 deletions packages/agent-bff/src/context/context-routes-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type ReadModelStore from '../read-model/read-model-store';
import type { Middleware } from 'koa';

import buildContext from './build-context';
import { resolveSchemaSnapshot } from '../http/agent-route-helpers';

const CONTEXT_ROUTE = '/agent/v1/context';

export interface ContextRoutesMiddlewareOptions {
store: ReadModelStore;
environmentId?: number;
}

export default function createContextRoutesMiddleware({
store,
environmentId,
}: ContextRoutesMiddlewareOptions): Middleware {
return async function contextRoutesMiddleware(ctx, next) {
if (ctx.path !== CONTEXT_ROUTE || ctx.method !== 'GET') {
await next();

return;
}

const { collections, readModel, revision } = await resolveSchemaSnapshot(store);

ctx.status = 200;
ctx.body = buildContext(collections, readModel, { schemaRevision: revision, environmentId });
};
}
13 changes: 11 additions & 2 deletions packages/agent-bff/src/http/agent-route-helpers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,15 +17,23 @@ export function decodeSegment(raw: string, label: string): string {
}
}

export async function resolveReadModel(store: ReadModelStore): Promise<ReadModel> {
async function mapSchemaFailure<T>(read: () => Promise<T>): Promise<T> {
try {
return await store.getReadModel();
return await read();
} catch (error) {
if (error instanceof SchemaUnavailableError) throw schemaUnavailable();
throw error;
}
}

export async function resolveSchemaSnapshot(store: ReadModelStore): Promise<SchemaSnapshot> {
return mapSchemaFailure(() => store.getSchemaSnapshot());
}

export async function resolveReadModel(store: ReadModelStore): Promise<ReadModel> {
return mapSchemaFailure(() => store.getReadModel());
}
Comment thread
Tonours marked this conversation as resolved.

export function requireAgentToken(ctx: Context): string {
const token = ctx.state.agentToken as string | undefined;
if (!token) throw unauthorized('No agent credentials for this request');
Expand Down
29 changes: 25 additions & 4 deletions packages/agent-bff/src/openapi/openapi-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-ope
import ComponentPool from './component-pool';
import {
ActionRequestSchema,
ContextResponseSchema,
CountRequestSchema,
CountResponseSchema,
ErrorResponseSchema,
Expand Down Expand Up @@ -36,7 +37,7 @@ const ERROR_STATUSES: Record<string, string> = {
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',
Expand Down Expand Up @@ -277,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. 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. Accepted on the context ' +
'contract; the data and action routes advertise the API key only.',
});
registry.registerComponent('securitySchemes', API_KEY_SCHEME, {
type: 'apiKey',
Expand All @@ -288,6 +288,27 @@ 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]: [] }, { [API_KEY_SCHEME]: [] }],
request: {},
responses: {
200: {
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'],
501: errorRefs.byStatus['501'],
Comment thread
Tonours marked this conversation as resolved.
503: errorRefs.byStatus['503'],
Comment thread
Tonours marked this conversation as resolved.
},
});

if (unfolding) {
registerUnfoldedPaths(
{
Expand Down
Loading
Loading