Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
318862f
refactor(agent-bff): expose the schema cache in the read-model bundle
Tonours Aug 18, 2026
f6be717
feat(agent-bff): serialize the allow-listed agent schema as a context…
Tonours Aug 18, 2026
c4f19ec
feat(agent-bff): serve GET /agent/v1/context and declare it in the Op…
Tonours Aug 18, 2026
114b3c1
refactor(agent-bff): read the schema and its read-model as one snapshot
Tonours Aug 18, 2026
adbccbb
test(agent-bff): constrain the schema snapshot generation pairing
Tonours Aug 18, 2026
10516a5
fix(agent-bff): tolerate a null enums list from the agent schema
Tonours Aug 18, 2026
e68a428
fix(agent-bff): declare the 400 the context route can return
Tonours Aug 18, 2026
654c086
feat(agent-bff): carry the resolved environment id in the context meta
Tonours Aug 18, 2026
817de19
feat(agent-bff): carry field validations in the context contract
Tonours Aug 19, 2026
09f927d
feat(agent-bff): carry enum values and the primary-key flag in the co…
Tonours Aug 19, 2026
2100d99
refactor(agent-bff): tighten the context serializer and drop a redund…
Tonours Aug 19, 2026
1768f10
fix(agent-bff): skip a null action field instead of failing the conte…
Tonours Aug 19, 2026
baaa53b
feat(agent-bff): carry relation metadata in the context contract
Tonours Aug 19, 2026
1518a9a
feat(agent-bff): accept a BFF api key on the context route
Tonours Aug 19, 2026
56b0049
fix(agent-bff): correct the context route status codes, security doc …
Tonours Aug 20, 2026
863fe7a
refactor(agent-bff): drop the dead collection filter and pin the cont…
Tonours Aug 20, 2026
f0e4a31
test(agent-bff): round-trip the context contract and pin unserved rel…
Tonours Aug 20, 2026
f270080
fix(agent-bff): type the context field type and cover the environment id
Tonours Aug 20, 2026
5662fbb
test(agent-bff): pin the dotted reference and drop the test comments
Tonours Aug 21, 2026
afa2c9b
fix(agent-bff): stop claiming the context document filters nothing
Tonours Aug 21, 2026
c0409d0
fix(agent-bff): type the context field type instead of accepting anyt…
Tonours Aug 21, 2026
985ee75
test(agent-bff): make the schema snapshot pairing test able to fail
Tonours Aug 21, 2026
6240578
test(agent-bff): pin the degraded statuses and the schema ttl refetch
Tonours Aug 21, 2026
876c6f4
refactor(agent-bff): guard every schema list against a non-object entry
Tonours Aug 21, 2026
462a500
refactor(agent-bff): inline the context middleware builder and drop a…
Tonours Aug 21, 2026
55bada1
fix(agent-bff): carry the enums of a composite sub-field in the contract
Tonours Aug 21, 2026
a924e22
refactor(agent-bff): drop the guards on lists the read-model already …
Tonours Aug 21, 2026
74dac7d
refactor(agent-bff): type the validations input instead of casting it
Tonours Aug 21, 2026
7aa5f7d
test(agent-bff): pin the revision read and the nested array type
Tonours Aug 21, 2026
fc57061
fix(agent-bff): skip a non-object schema field and keep an empty enum…
Tonours Aug 24, 2026
a0407f7
test(agent-bff): assert the warm-cache response instead of only the f…
Tonours Aug 24, 2026
4369061
test(agent-bff): scope the tag invariants to the collection operations
Tonours Aug 24, 2026
7b7015d
fix(agent-bff): serve only the action the read-model resolved for a name
Tonours Aug 24, 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 @@ -91,13 +92,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 @@ -121,7 +127,7 @@ async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise
logger,
});

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

interface ResolvedApiKeyConfig {
Expand Down Expand Up @@ -266,7 +272,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 @@ -286,6 +296,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 @@ -305,15 +316,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,
// Outside the agent-scoped chain on purpose: the viewer is a public page, the document it fetches
// is not. Gated on the edge being mounted too, like the error middleware above: with no agent
// chain there is no document to fetch, and the page would only ever reach a bare Koa 404.
Expand Down
160 changes: 160 additions & 0 deletions packages/agent-bff/src/context/build-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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[] };
Comment thread
Tonours marked this conversation as resolved.

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 thread
Tonours marked this conversation as resolved.

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

if (Array.isArray(field.enums)) serialized.enums = [...field.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 {
const allowedActions = readModel.getActionEndpoints()[collection.name] ?? {};

return {
name: collection.name,
fields: toArray(collection.fields)
.filter(field => typeof field === 'object' && field !== null)
.map(toContextField),
actions: toArray(collection.actions)
.filter(action => {
const allowed = allowedActions[action?.name];

return allowed !== undefined && allowed.id === action.id;
})
.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