From 843b80cb97f35deb4b46df0a80c432b4d0867839 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 3 Jul 2026 17:00:21 +0200 Subject: [PATCH 01/12] feat(agent-bff): reject nested relation field paths in top-level list and count Add a pure syntactic guard that rejects any top-level field path carrying the relation separator ":" with 422 relation_field_not_supported. Closes an agent authority gap where a relation-target field could be projected without the target collection browse/scope check. Guard + tests only; wiring into live list/count handlers belongs to the data-endpoints slice. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GqCtMftj4gwCo2AwicMNL --- packages/agent-bff/src/http/bff-http-error.ts | 9 ++ .../src/validation/relation-field-guard.ts | 15 +++ .../validation/relation-field-guard.test.ts | 101 ++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 packages/agent-bff/src/validation/relation-field-guard.ts create mode 100644 packages/agent-bff/test/validation/relation-field-guard.test.ts diff --git a/packages/agent-bff/src/http/bff-http-error.ts b/packages/agent-bff/src/http/bff-http-error.ts index dbba05fc4a..789db7c8bb 100644 --- a/packages/agent-bff/src/http/bff-http-error.ts +++ b/packages/agent-bff/src/http/bff-http-error.ts @@ -79,3 +79,12 @@ export function missingTimezone( export function invalidTimezone(value: string): BffHttpError { return new BffHttpError(400, 'invalid_timezone', `Invalid timezone: "${value}"`); } + +export function relationFieldNotSupported(fields: string[]): BffHttpError { + return new BffHttpError( + 422, + 'relation_field_not_supported', + 'Nested relation field paths are not supported on top-level list and count', + { fields }, + ); +} diff --git a/packages/agent-bff/src/validation/relation-field-guard.ts b/packages/agent-bff/src/validation/relation-field-guard.ts new file mode 100644 index 0000000000..8f6887f419 --- /dev/null +++ b/packages/agent-bff/src/validation/relation-field-guard.ts @@ -0,0 +1,15 @@ +import { relationFieldNotSupported } from '../http/bff-http-error'; + +const RELATION_SEPARATOR = ':'; + +export default function assertNoRelationFieldPaths(paths: string[]): void { + const offending: string[] = []; + + for (const path of paths) { + if (path.includes(RELATION_SEPARATOR) && !offending.includes(path)) { + offending.push(path); + } + } + + if (offending.length > 0) throw relationFieldNotSupported(offending); +} diff --git a/packages/agent-bff/test/validation/relation-field-guard.test.ts b/packages/agent-bff/test/validation/relation-field-guard.test.ts new file mode 100644 index 0000000000..a88c79a331 --- /dev/null +++ b/packages/agent-bff/test/validation/relation-field-guard.test.ts @@ -0,0 +1,101 @@ +import { type BffHttpError, toErrorBody } from '../../src/http/bff-http-error'; +import assertNoRelationFieldPaths from '../../src/validation/relation-field-guard'; + +function captureError(fn: () => void): unknown { + try { + fn(); + } catch (error) { + return error; + } + + throw new Error('expected to throw'); +} + +describe('assertNoRelationFieldPaths', () => { + it('rejects a nested relation path from a list projection', () => { + expect(() => assertNoRelationFieldPaths(['id', 'company:name'])).toThrow( + expect.objectContaining({ + type: 'relation_field_not_supported', + status: 422, + details: { fields: ['company:name'] }, + }), + ); + }); + + it('rejects a nested relation path from a list filter field', () => { + expect(() => assertNoRelationFieldPaths(['owner:email'])).toThrow( + expect.objectContaining({ + type: 'relation_field_not_supported', + status: 422, + details: { fields: ['owner:email'] }, + }), + ); + }); + + it('rejects a nested relation path from a list sort field', () => { + expect(() => assertNoRelationFieldPaths(['company:createdAt'])).toThrow( + expect.objectContaining({ + type: 'relation_field_not_supported', + status: 422, + details: { fields: ['company:createdAt'] }, + }), + ); + }); + + it('rejects a nested relation path from a count filter field', () => { + expect(() => assertNoRelationFieldPaths(['status', 'company:tier'])).toThrow( + expect.objectContaining({ + type: 'relation_field_not_supported', + status: 422, + details: { fields: ['company:tier'] }, + }), + ); + }); + + it('lists every offending path when several are present', () => { + expect(() => + assertNoRelationFieldPaths(['id', 'company:name', 'title', 'owner:email']), + ).toThrow( + expect.objectContaining({ + details: { fields: ['company:name', 'owner:email'] }, + }), + ); + }); + + it('reports a single offending path as an array of length 1', () => { + const error = captureError(() => assertNoRelationFieldPaths(['company:name'])); + + expect((error as BffHttpError).details).toEqual({ fields: ['company:name'] }); + }); + + it('dedupes an offending path seen on two surfaces, keeping first-seen order', () => { + expect(() => + assertNoRelationFieldPaths(['company:name', 'id', 'company:name', 'owner:email']), + ).toThrow( + expect.objectContaining({ + details: { fields: ['company:name', 'owner:email'] }, + }), + ); + }); + + it('passes through direct field paths unchanged', () => { + expect(() => assertNoRelationFieldPaths(['id', 'title', 'createdAt'])).not.toThrow(); + }); + + it('passes through an empty path list', () => { + expect(() => assertNoRelationFieldPaths([])).not.toThrow(); + }); + + it('serializes to the wire error envelope with details.fields as an array', () => { + const error = captureError(() => assertNoRelationFieldPaths(['company:name', 'owner:email'])); + + expect(toErrorBody(error as BffHttpError)).toEqual({ + error: { + type: 'relation_field_not_supported', + status: 422, + message: 'Nested relation field paths are not supported on top-level list and count', + details: { fields: ['company:name', 'owner:email'] }, + }, + }); + }); +}); From 790611e760ab5c54ec9360de379db1f97b9f52b1 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 2 Jul 2026 17:11:19 +0200 Subject: [PATCH 02/12] feat(agent-bff): fetch and cache agent schema, capabilities and allow-list Add a read-model layer that caches the agent schema (24h), derives collection/relation/action allow-list metadata with relation targets and an action-endpoint map, and caches per-collection capabilities coupled to the schema refresh. Adds a Metrics port emitting schema-cache and action-endpoint counters. Co-Authored-By: Claude Opus 4.8 --- packages/agent-bff/package.json | 1 + .../agent-bff/src/adapters/console-metrics.ts | 15 ++ packages/agent-bff/src/ports/metrics-port.ts | 6 + .../read-model/action-endpoint-resolver.ts | 55 ++++++ .../read-model/agent-capabilities-fetcher.ts | 21 +++ .../src/read-model/capabilities-cache.ts | 68 +++++++ .../src/read-model/create-read-model.ts | 42 +++++ packages/agent-bff/src/read-model/errors.ts | 9 + .../src/read-model/forest-schema-client.ts | 24 +++ .../src/read-model/read-model-store.ts | 50 +++++ .../agent-bff/src/read-model/read-model.ts | 98 ++++++++++ .../agent-bff/src/read-model/schema-cache.ts | 99 ++++++++++ .../test/adapters/console-metrics.test.ts | 49 +++++ .../action-endpoint-resolver.test.ts | 67 +++++++ .../agent-capabilities-fetcher.test.ts | 21 +++ .../read-model/capabilities-cache.test.ts | 112 +++++++++++ .../agent-bff/test/read-model/fixtures.ts | 58 ++++++ .../test/read-model/read-model-store.test.ts | 89 +++++++++ .../test/read-model/read-model.test.ts | 109 +++++++++++ .../test/read-model/schema-cache.test.ts | 178 ++++++++++++++++++ 20 files changed, 1171 insertions(+) create mode 100644 packages/agent-bff/src/adapters/console-metrics.ts create mode 100644 packages/agent-bff/src/ports/metrics-port.ts create mode 100644 packages/agent-bff/src/read-model/action-endpoint-resolver.ts create mode 100644 packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts create mode 100644 packages/agent-bff/src/read-model/capabilities-cache.ts create mode 100644 packages/agent-bff/src/read-model/create-read-model.ts create mode 100644 packages/agent-bff/src/read-model/errors.ts create mode 100644 packages/agent-bff/src/read-model/forest-schema-client.ts create mode 100644 packages/agent-bff/src/read-model/read-model-store.ts create mode 100644 packages/agent-bff/src/read-model/read-model.ts create mode 100644 packages/agent-bff/src/read-model/schema-cache.ts create mode 100644 packages/agent-bff/test/adapters/console-metrics.test.ts create mode 100644 packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts create mode 100644 packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts create mode 100644 packages/agent-bff/test/read-model/capabilities-cache.test.ts create mode 100644 packages/agent-bff/test/read-model/fixtures.ts create mode 100644 packages/agent-bff/test/read-model/read-model-store.test.ts create mode 100644 packages/agent-bff/test/read-model/read-model.test.ts create mode 100644 packages/agent-bff/test/read-model/schema-cache.test.ts diff --git a/packages/agent-bff/package.json b/packages/agent-bff/package.json index c57cc47ef2..3d4fc13592 100644 --- a/packages/agent-bff/package.json +++ b/packages/agent-bff/package.json @@ -31,6 +31,7 @@ "test": "jest" }, "dependencies": { + "@forestadmin/agent-client": "1.10.1", "@forestadmin/forestadmin-client": "1.40.4", "@koa/bodyparser": "^6.1.0", "jsonwebtoken": "^9.0.3", diff --git a/packages/agent-bff/src/adapters/console-metrics.ts b/packages/agent-bff/src/adapters/console-metrics.ts new file mode 100644 index 0000000000..da4e29906e --- /dev/null +++ b/packages/agent-bff/src/adapters/console-metrics.ts @@ -0,0 +1,15 @@ +import type { Logger } from '../ports/logger-port'; +import type { MetricTags, Metrics } from '../ports/metrics-port'; + +import createConsoleLogger from './console-logger'; + +export default function createConsoleMetrics(logger: Logger = createConsoleLogger()): Metrics { + return { + increment(name: string, tags?: MetricTags): void { + logger('Info', 'metric.increment', { metric: name, ...(tags ?? {}) }); + }, + gauge(name: string, value: number, tags?: MetricTags): void { + logger('Info', 'metric.gauge', { metric: name, value, ...(tags ?? {}) }); + }, + }; +} diff --git a/packages/agent-bff/src/ports/metrics-port.ts b/packages/agent-bff/src/ports/metrics-port.ts new file mode 100644 index 0000000000..5f67ce10f0 --- /dev/null +++ b/packages/agent-bff/src/ports/metrics-port.ts @@ -0,0 +1,6 @@ +export type MetricTags = Record; + +export interface Metrics { + increment(name: string, tags?: MetricTags): void; + gauge(name: string, value: number, tags?: MetricTags): void; +} diff --git a/packages/agent-bff/src/read-model/action-endpoint-resolver.ts b/packages/agent-bff/src/read-model/action-endpoint-resolver.ts new file mode 100644 index 0000000000..cc91e1afeb --- /dev/null +++ b/packages/agent-bff/src/read-model/action-endpoint-resolver.ts @@ -0,0 +1,55 @@ +import type ReadModel from './read-model'; +import type { Metrics } from '../ports/metrics-port'; +import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; + +export const ACTION_ENDPOINT_MISS = 'action_endpoint_miss'; +export const ACTION_ENDPOINT_ERROR = 'action_endpoint_error'; + +export type ActionEndpointInfo = ActionEndpointsByCollection[string][string]; + +export type ReadModelProvider = () => Promise; + +export interface ResolveActionContext { + rendering: string | number; +} + +/** + * Resolves an action to its endpoint against the current read-model. Never throws: an absent + * mapping emits the miss counter, a failure to obtain the read-model emits the error counter. + * Both counters carry `rendering`, `collection`, and `action` tags. In normal flow the action + * allow-list already excludes endpoint-less actions, so the miss path is a defensive guard. + */ +export default class ActionEndpointResolver { + constructor( + private readonly getReadModel: ReadModelProvider, + private readonly metrics: Metrics, + ) {} + + async resolve( + collection: string, + action: string, + { rendering }: ResolveActionContext, + ): Promise { + const tags = { rendering, collection, action }; + + let readModel: ReadModel; + + try { + readModel = await this.getReadModel(); + } catch { + this.metrics.increment(ACTION_ENDPOINT_ERROR, tags); + + return undefined; + } + + const info = readModel.getActionEndpoints()[collection]?.[action]; + + if (!info) { + this.metrics.increment(ACTION_ENDPOINT_MISS, tags); + + return undefined; + } + + return info; + } +} diff --git a/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts b/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts new file mode 100644 index 0000000000..032150b080 --- /dev/null +++ b/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts @@ -0,0 +1,21 @@ +import type { CapabilitiesFetcher } from './capabilities-cache'; + +import { createRemoteAgentClient } from '@forestadmin/agent-client'; + +export interface AgentCapabilitiesFetcherOptions { + agentUrl: string; + token: string; +} + +/** + * Builds a capabilities fetcher bound to a request's agent token. The cache calls it only on a + * miss, so the token of whichever request first populates a collection is the one used. + */ +export default function createAgentCapabilitiesFetcher({ + agentUrl, + token, +}: AgentCapabilitiesFetcherOptions): CapabilitiesFetcher { + const client = createRemoteAgentClient({ url: agentUrl, token }); + + return collection => client.collection(collection).capabilities(); +} diff --git a/packages/agent-bff/src/read-model/capabilities-cache.ts b/packages/agent-bff/src/read-model/capabilities-cache.ts new file mode 100644 index 0000000000..59842ccef3 --- /dev/null +++ b/packages/agent-bff/src/read-model/capabilities-cache.ts @@ -0,0 +1,68 @@ +import { ONE_DAY_MS } from './schema-cache'; + +export interface CapabilitiesResult { + fields: { name: string; type: string; operators: string[] }[]; +} + +export type CapabilitiesFetcher = (collection: string) => Promise; + +export interface CapabilitiesCacheOptions { + now?: () => number; + ttlMs?: number; +} + +interface CacheEntry { + result: CapabilitiesResult; + fetchedAt: number; +} + +/** + * Per-collection capabilities cache (24h). The fetcher is passed per call so it can be bound to + * the caller's request token. Invalidated together with the schema via `clear()`. + */ +export default class CapabilitiesCache { + private readonly now: () => number; + private readonly ttlMs: number; + + private readonly entries = new Map(); + private readonly inFlight = new Map>(); + private generation = 0; + + constructor({ now = Date.now, ttlMs = ONE_DAY_MS }: CapabilitiesCacheOptions = {}) { + this.now = now; + this.ttlMs = ttlMs; + } + + async get(collection: string, fetcher: CapabilitiesFetcher): Promise { + const entry = this.entries.get(collection); + if (entry && this.now() - entry.fetchedAt < this.ttlMs) return entry.result; + + const pending = this.inFlight.get(collection); + if (pending) return pending; + + const { generation } = this; + const fetch = fetcher(collection) + .then(result => { + // Skip the write if a clear() (schema refresh) happened while this fetch was in flight — + // its result belongs to the old schema generation and must not repopulate the cache. + if (this.generation === generation) { + this.entries.set(collection, { result, fetchedAt: this.now() }); + } + + return result; + }) + .finally(() => { + if (this.inFlight.get(collection) === fetch) this.inFlight.delete(collection); + }); + + this.inFlight.set(collection, fetch); + + return fetch; + } + + clear(): void { + this.generation += 1; + this.entries.clear(); + this.inFlight.clear(); + } +} diff --git a/packages/agent-bff/src/read-model/create-read-model.ts b/packages/agent-bff/src/read-model/create-read-model.ts new file mode 100644 index 0000000000..1aa1b64548 --- /dev/null +++ b/packages/agent-bff/src/read-model/create-read-model.ts @@ -0,0 +1,42 @@ +import type { Logger } from '../ports/logger-port'; +import type { Metrics } from '../ports/metrics-port'; + +import ActionEndpointResolver from './action-endpoint-resolver'; +import CapabilitiesCache from './capabilities-cache'; +import ForestSchemaClient from './forest-schema-client'; +import ReadModelStore from './read-model-store'; +import SchemaCache from './schema-cache'; +import createConsoleMetrics from '../adapters/console-metrics'; + +export interface CreateReadModelOptions { + forestServerUrl: string; + envSecret: string; + metrics?: Metrics; + logger?: Logger; + now?: () => number; +} + +export interface ReadModelBundle { + store: ReadModelStore; + actionEndpointResolver: ActionEndpointResolver; +} + +export default function createReadModel({ + forestServerUrl, + envSecret, + metrics, + logger, + now, +}: CreateReadModelOptions): ReadModelBundle { + const resolvedMetrics = metrics ?? createConsoleMetrics(logger); + const fetcher = new ForestSchemaClient({ forestServerUrl, envSecret }); + const schemaCache = new SchemaCache({ fetcher, metrics: resolvedMetrics, now }); + const capabilitiesCache = new CapabilitiesCache({ now }); + const store = new ReadModelStore(schemaCache, capabilitiesCache); + const actionEndpointResolver = new ActionEndpointResolver( + () => store.getReadModel(), + resolvedMetrics, + ); + + return { store, actionEndpointResolver }; +} diff --git a/packages/agent-bff/src/read-model/errors.ts b/packages/agent-bff/src/read-model/errors.ts new file mode 100644 index 0000000000..402556ee10 --- /dev/null +++ b/packages/agent-bff/src/read-model/errors.ts @@ -0,0 +1,9 @@ +export default class SchemaUnavailableError extends Error { + readonly cause?: unknown; + + constructor(cause?: unknown) { + super('The agent schema is unavailable'); + this.name = 'SchemaUnavailableError'; + this.cause = cause; + } +} diff --git a/packages/agent-bff/src/read-model/forest-schema-client.ts b/packages/agent-bff/src/read-model/forest-schema-client.ts new file mode 100644 index 0000000000..68e95e2071 --- /dev/null +++ b/packages/agent-bff/src/read-model/forest-schema-client.ts @@ -0,0 +1,24 @@ +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import { ForestHttpApi, SchemaService } from '@forestadmin/forestadmin-client'; + +export interface ForestSchemaClientOptions { + forestServerUrl: string; + envSecret: string; +} + +export interface SchemaFetcher { + fetchSchema(): Promise; +} + +export default class ForestSchemaClient implements SchemaFetcher { + private readonly schemaService: SchemaService; + + constructor({ forestServerUrl, envSecret }: ForestSchemaClientOptions) { + this.schemaService = new SchemaService(new ForestHttpApi(), { forestServerUrl, envSecret }); + } + + async fetchSchema(): Promise { + return this.schemaService.getSchema(); + } +} diff --git a/packages/agent-bff/src/read-model/read-model-store.ts b/packages/agent-bff/src/read-model/read-model-store.ts new file mode 100644 index 0000000000..4687254348 --- /dev/null +++ b/packages/agent-bff/src/read-model/read-model-store.ts @@ -0,0 +1,50 @@ +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'; + +/** + * Single owner of the coupled schema + capabilities lifecycle. A successful schema refresh (a new + * collections reference from the cache) rebuilds the read-model and clears capabilities atomically, + * so the allow-list and capabilities never split-brain across schema generations. + */ +export default class ReadModelStore { + private readonly schemaCache: SchemaCache; + private readonly capabilitiesCache: CapabilitiesCache; + + private lastCollections: ForestSchemaCollection[] | null = null; + private readModel: ReadModel | null = null; + + constructor(schemaCache: SchemaCache, capabilitiesCache: CapabilitiesCache) { + this.schemaCache = schemaCache; + this.capabilitiesCache = capabilitiesCache; + } + + async getReadModel(): Promise { + const collections = await this.schemaCache.get(); + + if (collections !== this.lastCollections || !this.readModel) { + this.readModel = new ReadModel(collections); + this.lastCollections = collections; + this.capabilitiesCache.clear(); + } + + return this.readModel; + } + + async getCapabilities( + collection: string, + fetcher: CapabilitiesFetcher, + ): Promise { + // Ensure any pending schema refresh (and its capabilities invalidation) runs first. + await this.getReadModel(); + + return this.capabilitiesCache.get(collection, fetcher); + } + + ageSeconds(): number | undefined { + return this.schemaCache.ageSeconds(); + } +} diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts new file mode 100644 index 0000000000..475ae7b1ae --- /dev/null +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -0,0 +1,98 @@ +import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; +import type { ForestSchemaCollection, ForestSchemaField } from '@forestadmin/forestadmin-client'; + +export type RelationshipType = 'BelongsTo' | 'HasOne' | 'HasMany' | 'BelongsToMany'; + +export type RelationTarget = + | { type: RelationshipType; polymorphic: false; target: string } + | { type: RelationshipType; polymorphic: true; targets: string[] }; + +function toRelationTarget(field: ForestSchemaField): RelationTarget | null { + if (!field.relationship) return null; + + const type = field.relationship; + + if (field.polymorphicReferencedModels && field.polymorphicReferencedModels.length > 0) { + return { type, polymorphic: true, targets: [...field.polymorphicReferencedModels] }; + } + + if (field.reference) { + return { type, polymorphic: false, target: field.reference.split('.')[0] }; + } + + return null; +} + +/** + * The agent read-model derived from a schema. Names are collection-scoped: relations and actions + * are keyed by `(collection, name)` so the same relation/action name on two collections stays + * distinct. The action allow-list is exactly the endpoint-map keys — endpoint-less actions are + * not exposed. + */ +export default class ReadModel { + private readonly collections: Set; + private readonly relations: Map>; + private readonly actionEndpoints: ActionEndpointsByCollection; + + constructor(collections: ForestSchemaCollection[]) { + this.collections = new Set(); + this.relations = new Map(); + this.actionEndpoints = {}; + + for (const collection of collections) { + this.collections.add(collection.name); + this.buildRelations(collection); + this.buildActionEndpoints(collection); + } + } + + private buildRelations(collection: ForestSchemaCollection): void { + const relations = new Map(); + + for (const field of collection.fields) { + const target = toRelationTarget(field); + if (target) relations.set(field.field, target); + } + + this.relations.set(collection.name, relations); + } + + private buildActionEndpoints(collection: ForestSchemaCollection): void { + const actions = collection.actions ?? []; + const withEndpoint = actions.filter(action => Boolean(action.endpoint)); + + if (withEndpoint.length === 0) return; + + this.actionEndpoints[collection.name] = {}; + + for (const action of withEndpoint) { + this.actionEndpoints[collection.name][action.name] = { + id: action.id, + name: action.name, + endpoint: action.endpoint, + hooks: action.hooks, + fields: action.fields, + }; + } + } + + isCollectionAllowed(collection: string): boolean { + return this.collections.has(collection); + } + + isRelationAllowed(collection: string, relation: string): boolean { + return this.relations.get(collection)?.has(relation) ?? false; + } + + getRelationTarget(collection: string, relation: string): RelationTarget | undefined { + return this.relations.get(collection)?.get(relation); + } + + isActionAllowed(collection: string, action: string): boolean { + return this.actionEndpoints[collection]?.[action] !== undefined; + } + + getActionEndpoints(): ActionEndpointsByCollection { + return this.actionEndpoints; + } +} diff --git a/packages/agent-bff/src/read-model/schema-cache.ts b/packages/agent-bff/src/read-model/schema-cache.ts new file mode 100644 index 0000000000..3f8cbd71a7 --- /dev/null +++ b/packages/agent-bff/src/read-model/schema-cache.ts @@ -0,0 +1,99 @@ +import type { SchemaFetcher } from './forest-schema-client'; +import type { Metrics } from '../ports/metrics-port'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import SchemaUnavailableError from './errors'; + +export const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +export const SCHEMA_CACHE_REFRESH_ERROR = 'schema_cache_refresh_error'; +export const SCHEMA_CACHE_AGE_SECONDS = 'schema_cache_age_seconds'; + +export interface SchemaCacheOptions { + fetcher: SchemaFetcher; + metrics: Metrics; + now?: () => number; + ttlMs?: number; +} + +interface CacheEntry { + collections: ForestSchemaCollection[]; + fetchedAt: number; +} + +/** + * Caches the agent schema for 24h. The TTL only *triggers* a refresh attempt: the last good + * schema keeps being served until a refresh succeeds. A cold cache (no last good) surfaces a + * typed `SchemaUnavailableError`. Cache hits return the same array reference so a consumer can + * detect a refresh by reference identity. + */ +export default class SchemaCache { + private readonly fetcher: SchemaFetcher; + private readonly metrics: Metrics; + private readonly now: () => number; + private readonly ttlMs: number; + + private entry: CacheEntry | null = null; + private inFlight: Promise | null = null; + + constructor({ fetcher, metrics, now = Date.now, ttlMs = ONE_DAY_MS }: SchemaCacheOptions) { + this.fetcher = fetcher; + this.metrics = metrics; + this.now = now; + this.ttlMs = ttlMs; + } + + async get(): Promise { + if (this.entry && this.now() - this.entry.fetchedAt < this.ttlMs) { + this.emitAge(); + + return this.entry.collections; + } + + return this.refresh(); + } + + ageSeconds(): number | undefined { + if (!this.entry) return undefined; + + return Math.floor((this.now() - this.entry.fetchedAt) / 1000); + } + + private async refresh(): Promise { + if (!this.inFlight) { + this.inFlight = this.doRefresh().finally(() => { + this.inFlight = null; + }); + } + + return this.inFlight; + } + + private async doRefresh(): Promise { + try { + const collections = await this.fetcher.fetchSchema(); + this.entry = { collections, fetchedAt: this.now() }; + this.emitAge(); + + return collections; + } catch (error) { + this.metrics.increment(SCHEMA_CACHE_REFRESH_ERROR); + + // Warm cache: keep serving the last good schema (stale), do not poison — the next read + // re-attempts because `fetchedAt` is unchanged and the entry stays expired. + if (this.entry) { + this.emitAge(); + + return this.entry.collections; + } + + // Cold cache: nothing to serve. + throw new SchemaUnavailableError(error); + } + } + + private emitAge(): void { + const age = this.ageSeconds(); + if (age !== undefined) this.metrics.gauge(SCHEMA_CACHE_AGE_SECONDS, age); + } +} diff --git a/packages/agent-bff/test/adapters/console-metrics.test.ts b/packages/agent-bff/test/adapters/console-metrics.test.ts new file mode 100644 index 0000000000..e0a0720a27 --- /dev/null +++ b/packages/agent-bff/test/adapters/console-metrics.test.ts @@ -0,0 +1,49 @@ +import type { Logger } from '../../src/ports/logger-port'; + +import createConsoleMetrics from '../../src/adapters/console-metrics'; + +describe('createConsoleMetrics', () => { + let logger: jest.MockedFunction; + + beforeEach(() => { + logger = jest.fn(); + }); + + describe('increment', () => { + it('should log the metric name and tags', () => { + const metrics = createConsoleMetrics(logger); + + metrics.increment('schema_cache_refresh_error', { collection: 'users', action: 'ban' }); + + expect(logger).toHaveBeenCalledWith('Info', 'metric.increment', { + metric: 'schema_cache_refresh_error', + collection: 'users', + action: 'ban', + }); + }); + + it('should log without tags when none are provided', () => { + const metrics = createConsoleMetrics(logger); + + metrics.increment('schema_cache_refresh_error'); + + expect(logger).toHaveBeenCalledWith('Info', 'metric.increment', { + metric: 'schema_cache_refresh_error', + }); + }); + }); + + describe('gauge', () => { + it('should log the metric name, value and tags', () => { + const metrics = createConsoleMetrics(logger); + + metrics.gauge('schema_cache_age_seconds', 42, { rendering: 7 }); + + expect(logger).toHaveBeenCalledWith('Info', 'metric.gauge', { + metric: 'schema_cache_age_seconds', + value: 42, + rendering: 7, + }); + }); + }); +}); diff --git a/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts b/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts new file mode 100644 index 0000000000..5e574bacce --- /dev/null +++ b/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts @@ -0,0 +1,67 @@ +import type { Metrics } from '../../src/ports/metrics-port'; +import type { ForestSchemaAction } from '@forestadmin/forestadmin-client'; + +import { action, collection, makeMetrics } from './fixtures'; +import ActionEndpointResolver, { + ACTION_ENDPOINT_ERROR, + ACTION_ENDPOINT_MISS, +} from '../../src/read-model/action-endpoint-resolver'; +import SchemaUnavailableError from '../../src/read-model/errors'; +import ReadModel from '../../src/read-model/read-model'; + +function modelWith(actions: ForestSchemaAction[]): ReadModel { + return new ReadModel([collection('users', [], actions)]); +} + +describe('ActionEndpointResolver', () => { + let metrics: jest.Mocked; + + beforeEach(() => { + metrics = makeMetrics(); + }); + + describe('hit', () => { + it('should return the endpoint info for a mapped action', async () => { + const model = modelWith([action('ban', '/forest/users/actions/ban')]); + const resolver = new ActionEndpointResolver(async () => model, metrics); + + const info = await resolver.resolve('users', 'ban', { rendering: 7 }); + + expect(info?.endpoint).toBe('/forest/users/actions/ban'); + expect(metrics.increment).not.toHaveBeenCalled(); + }); + }); + + describe('miss', () => { + it('should emit the miss counter with rendering/collection/action tags and not throw', async () => { + const model = modelWith([action('ban', '/forest/users/actions/ban')]); + const resolver = new ActionEndpointResolver(async () => model, metrics); + + const info = await resolver.resolve('users', 'unknown', { rendering: 7 }); + + expect(info).toBeUndefined(); + expect(metrics.increment).toHaveBeenCalledWith(ACTION_ENDPOINT_MISS, { + rendering: 7, + collection: 'users', + action: 'unknown', + }); + }); + }); + + describe('error', () => { + it('should emit the error counter when the read-model provider throws and not throw', async () => { + const resolver = new ActionEndpointResolver(async () => { + throw new SchemaUnavailableError(); + }, metrics); + + const info = await resolver.resolve('users', 'ban', { rendering: 7 }); + + expect(info).toBeUndefined(); + expect(metrics.increment).toHaveBeenCalledWith(ACTION_ENDPOINT_ERROR, { + rendering: 7, + collection: 'users', + action: 'ban', + }); + }); + }); +}); diff --git a/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts b/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts new file mode 100644 index 0000000000..5f42f8acd7 --- /dev/null +++ b/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts @@ -0,0 +1,21 @@ +import { createRemoteAgentClient } from '@forestadmin/agent-client'; + +import createAgentCapabilitiesFetcher from '../../src/read-model/agent-capabilities-fetcher'; + +jest.mock('@forestadmin/agent-client'); + +describe('createAgentCapabilitiesFetcher', () => { + it('should build a client for the agent url/token and fetch capabilities per collection', async () => { + const capabilities = jest.fn().mockResolvedValue({ fields: [{ name: 'email' }] }); + const collection = jest.fn().mockReturnValue({ capabilities }); + (createRemoteAgentClient as jest.Mock).mockReturnValue({ collection }); + + const fetcher = createAgentCapabilitiesFetcher({ agentUrl: 'https://agent', token: 'tok' }); + const result = await fetcher('users'); + + expect(createRemoteAgentClient).toHaveBeenCalledWith({ url: 'https://agent', token: 'tok' }); + expect(collection).toHaveBeenCalledWith('users'); + expect(capabilities).toHaveBeenCalledTimes(1); + expect(result).toEqual({ fields: [{ name: 'email' }] }); + }); +}); diff --git a/packages/agent-bff/test/read-model/capabilities-cache.test.ts b/packages/agent-bff/test/read-model/capabilities-cache.test.ts new file mode 100644 index 0000000000..87d48e23df --- /dev/null +++ b/packages/agent-bff/test/read-model/capabilities-cache.test.ts @@ -0,0 +1,112 @@ +import type { CapabilitiesResult } from '../../src/read-model/capabilities-cache'; + +import CapabilitiesCache from '../../src/read-model/capabilities-cache'; +import { ONE_DAY_MS } from '../../src/read-model/schema-cache'; + +function caps(name: string): CapabilitiesResult { + return { fields: [{ name, type: 'String', operators: ['equal'] }] }; +} + +describe('CapabilitiesCache', () => { + let clock: number; + const now = () => clock; + + beforeEach(() => { + clock = 1_000_000; + }); + + it('should fetch on first read and cache the raw result', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest.fn().mockResolvedValue(caps('email')); + + const result = await cache.get('users', fetcher); + + expect(fetcher).toHaveBeenCalledWith('users'); + expect(result).toEqual(caps('email')); + }); + + it('should serve from cache without re-fetching within 24h', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest.fn().mockResolvedValue(caps('email')); + + await cache.get('users', fetcher); + clock += ONE_DAY_MS - 1; + await cache.get('users', fetcher); + + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('should re-fetch after 24h', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest.fn().mockResolvedValue(caps('email')); + + await cache.get('users', fetcher); + clock += ONE_DAY_MS; + await cache.get('users', fetcher); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('should cache per collection independently', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest + .fn() + .mockImplementation((collection: string) => Promise.resolve(caps(collection))); + + const users = await cache.get('users', fetcher); + const orders = await cache.get('orders', fetcher); + + expect(users.fields[0].name).toBe('users'); + expect(orders.fields[0].name).toBe('orders'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('should re-fetch a collection after clear() (schema refresh invalidation)', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest.fn().mockResolvedValue(caps('email')); + + await cache.get('users', fetcher); + cache.clear(); + await cache.get('users', fetcher); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('should dedupe concurrent reads of the same collection into one fetch', async () => { + const cache = new CapabilitiesCache({ now }); + let resolveFetch!: (value: CapabilitiesResult) => void; + const fetcher = jest.fn().mockReturnValue( + new Promise(resolve => { + resolveFetch = resolve; + }), + ); + + const a = cache.get('users', fetcher); + const b = cache.get('users', fetcher); + resolveFetch(caps('email')); + + await Promise.all([a, b]); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('should not repopulate the cache when a fetch that started before clear() resolves', async () => { + const cache = new CapabilitiesCache({ now }); + let resolveStale!: (value: CapabilitiesResult) => void; + const staleFetcher = jest.fn().mockReturnValue( + new Promise(resolve => { + resolveStale = resolve; + }), + ); + + const inFlight = cache.get('users', staleFetcher); + cache.clear(); + resolveStale(caps('stale')); + await inFlight; + + const freshFetcher = jest.fn().mockResolvedValue(caps('fresh')); + const result = await cache.get('users', freshFetcher); + + expect(freshFetcher).toHaveBeenCalledTimes(1); + expect(result.fields[0].name).toBe('fresh'); + }); +}); diff --git a/packages/agent-bff/test/read-model/fixtures.ts b/packages/agent-bff/test/read-model/fixtures.ts new file mode 100644 index 0000000000..f51b1db7eb --- /dev/null +++ b/packages/agent-bff/test/read-model/fixtures.ts @@ -0,0 +1,58 @@ +import type { Metrics } from '../../src/ports/metrics-port'; +import type { + ForestSchemaAction, + ForestSchemaCollection, + ForestSchemaField, +} from '@forestadmin/forestadmin-client'; + +export function makeMetrics(): jest.Mocked { + return { increment: jest.fn(), gauge: jest.fn() }; +} + +export function column(field: string): ForestSchemaField { + return { + field, + type: 'String', + enum: null, + reference: null, + isReadOnly: false, + isRequired: false, + isPrimaryKey: field === 'id', + }; +} + +export function relation( + field: string, + relationship: 'BelongsTo' | 'HasOne' | 'HasMany' | 'BelongsToMany', + reference: string, +): ForestSchemaField { + return { ...column(field), reference, relationship }; +} + +export function polymorphic(field: string, models: string[]): ForestSchemaField { + return { ...column(field), relationship: 'BelongsTo', polymorphicReferencedModels: models }; +} + +export function action(name: string, endpoint: string): ForestSchemaAction { + return { + id: `${name}-id`, + name, + type: 'single', + endpoint, + download: false, + fields: [], + hooks: { load: false, change: [] }, + }; +} + +export function collection( + name: string, + fields: ForestSchemaField[], + actions: ForestSchemaAction[] = [], +): ForestSchemaCollection { + return { name, fields, actions }; +} + +export function makeSchema(name: string): ForestSchemaCollection[] { + return [collection(name, [])]; +} 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 new file mode 100644 index 0000000000..133f1f7f4e --- /dev/null +++ b/packages/agent-bff/test/read-model/read-model-store.test.ts @@ -0,0 +1,89 @@ +import type { SchemaFetcher } from '../../src/read-model/forest-schema-client'; + +import { makeMetrics, makeSchema } from './fixtures'; +import CapabilitiesCache from '../../src/read-model/capabilities-cache'; +import ReadModelStore from '../../src/read-model/read-model-store'; +import SchemaCache, { ONE_DAY_MS } from '../../src/read-model/schema-cache'; + +describe('ReadModelStore', () => { + let clock: number; + const now = () => clock; + + function build(fetchSchema: jest.Mock): ReadModelStore { + const metrics = makeMetrics(); + const schemaCache = new SchemaCache({ + fetcher: { fetchSchema } as SchemaFetcher, + metrics, + now, + }); + const capabilitiesCache = new CapabilitiesCache({ now }); + + return new ReadModelStore(schemaCache, capabilitiesCache); + } + + beforeEach(() => { + clock = 1_000_000; + }); + + describe('getReadModel', () => { + it('should build the read-model from the fetched schema', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + + const model = await store.getReadModel(); + + expect(model.isCollectionAllowed('users')).toBe(true); + }); + + it('should reuse the same read-model instance on a cache hit', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + + const a = await store.getReadModel(); + const b = await store.getReadModel(); + + expect(a).toBe(b); + }); + + it('should rebuild the read-model after a schema refresh', async () => { + const fetchSchema = jest + .fn() + .mockResolvedValueOnce(makeSchema('users')) + .mockResolvedValueOnce(makeSchema('orders')); + const store = build(fetchSchema); + + const before = await store.getReadModel(); + clock += ONE_DAY_MS; + const after = await store.getReadModel(); + + expect(before).not.toBe(after); + expect(after.isCollectionAllowed('orders')).toBe(true); + expect(after.isCollectionAllowed('users')).toBe(false); + }); + }); + + describe('capabilities coupling', () => { + it('should fetch capabilities and cache them', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + const capsFetcher = jest.fn().mockResolvedValue({ fields: [] }); + + await store.getCapabilities('users', capsFetcher); + await store.getCapabilities('users', capsFetcher); + + expect(capsFetcher).toHaveBeenCalledTimes(1); + }); + + it('should invalidate capabilities when the schema refreshes', async () => { + const fetchSchema = jest + .fn() + .mockResolvedValueOnce(makeSchema('users')) + .mockResolvedValueOnce(makeSchema('users')); + const store = build(fetchSchema); + const capsFetcher = jest.fn().mockResolvedValue({ fields: [] }); + + await store.getCapabilities('users', capsFetcher); + clock += ONE_DAY_MS; + await store.getCapabilities('users', capsFetcher); + + expect(capsFetcher).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/agent-bff/test/read-model/read-model.test.ts b/packages/agent-bff/test/read-model/read-model.test.ts new file mode 100644 index 0000000000..54835ccff1 --- /dev/null +++ b/packages/agent-bff/test/read-model/read-model.test.ts @@ -0,0 +1,109 @@ +import { action, collection, column, polymorphic, relation } from './fixtures'; +import ReadModel from '../../src/read-model/read-model'; + +describe('ReadModel', () => { + describe('collection allow-list', () => { + it('should report a schema collection as allowed and an absent one as not allowed', () => { + const model = new ReadModel([collection('users', [column('id')])]); + + expect(model.isCollectionAllowed('users')).toBe(true); + expect(model.isCollectionAllowed('secrets')).toBe(false); + }); + }); + + describe('relation allow-list and targets', () => { + it('should allow-list a relation and resolve its foreign collection from the reference', () => { + const model = new ReadModel([ + collection('users', [column('id'), relation('company', 'BelongsTo', 'companies.id')]), + ]); + + expect(model.isRelationAllowed('users', 'company')).toBe(true); + expect(model.getRelationTarget('users', 'company')).toEqual({ + type: 'BelongsTo', + polymorphic: false, + target: 'companies', + }); + }); + + it('should not allow-list a relation absent from the collection', () => { + const model = new ReadModel([collection('users', [column('id')])]); + + expect(model.isRelationAllowed('users', 'company')).toBe(false); + expect(model.getRelationTarget('users', 'company')).toBeUndefined(); + }); + + it('should allow-list all four relationship types', () => { + const model = new ReadModel([ + collection('users', [ + relation('company', 'BelongsTo', 'companies.id'), + relation('profile', 'HasOne', 'profiles.userId'), + relation('orders', 'HasMany', 'orders.userId'), + relation('roles', 'BelongsToMany', 'roles.id'), + ]), + ]); + + expect(model.getRelationTarget('users', 'company')?.type).toBe('BelongsTo'); + expect(model.getRelationTarget('users', 'profile')?.type).toBe('HasOne'); + expect(model.getRelationTarget('users', 'orders')?.type).toBe('HasMany'); + expect(model.getRelationTarget('users', 'roles')?.type).toBe('BelongsToMany'); + }); + + it('should flag a polymorphic relation and store its target list', () => { + const model = new ReadModel([ + collection('comments', [polymorphic('commentable', ['posts', 'videos'])]), + ]); + + expect(model.getRelationTarget('comments', 'commentable')).toEqual({ + type: 'BelongsTo', + polymorphic: true, + targets: ['posts', 'videos'], + }); + }); + }); + + describe('collection-scoped keying', () => { + it('should keep same-named relations on different collections distinct', () => { + const model = new ReadModel([ + collection('a', [relation('owner', 'BelongsTo', 'people.id')]), + collection('b', [relation('owner', 'BelongsTo', 'orgs.id')]), + ]); + + expect(model.getRelationTarget('a', 'owner')).toMatchObject({ target: 'people' }); + expect(model.getRelationTarget('b', 'owner')).toMatchObject({ target: 'orgs' }); + }); + + it('should keep same-named actions on different collections distinct', () => { + const model = new ReadModel([ + collection('a', [column('id')], [action('ban', '/forest/a/actions/ban')]), + collection('b', [column('id')], [action('ban', '/forest/b/actions/ban')]), + ]); + + expect(model.getActionEndpoints().a.ban.endpoint).toBe('/forest/a/actions/ban'); + expect(model.getActionEndpoints().b.ban.endpoint).toBe('/forest/b/actions/ban'); + }); + }); + + describe('action endpoint map and allow-list', () => { + it('should map an action with an endpoint and allow-list it', () => { + const model = new ReadModel([ + collection('users', [column('id')], [action('ban', '/forest/users/actions/ban')]), + ]); + + expect(model.isActionAllowed('users', 'ban')).toBe(true); + expect(model.getActionEndpoints().users.ban).toEqual({ + id: 'ban-id', + name: 'ban', + endpoint: '/forest/users/actions/ban', + hooks: { load: false, change: [] }, + fields: [], + }); + }); + + it('should not expose an action that has no endpoint', () => { + const model = new ReadModel([collection('users', [column('id')], [action('ban', '')])]); + + expect(model.isActionAllowed('users', 'ban')).toBe(false); + expect(model.getActionEndpoints().users).toBeUndefined(); + }); + }); +}); diff --git a/packages/agent-bff/test/read-model/schema-cache.test.ts b/packages/agent-bff/test/read-model/schema-cache.test.ts new file mode 100644 index 0000000000..ca118466a8 --- /dev/null +++ b/packages/agent-bff/test/read-model/schema-cache.test.ts @@ -0,0 +1,178 @@ +import type { Metrics } from '../../src/ports/metrics-port'; +import type { SchemaFetcher } from '../../src/read-model/forest-schema-client'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import { makeMetrics, makeSchema } from './fixtures'; +import SchemaUnavailableError from '../../src/read-model/errors'; +import SchemaCache, { + ONE_DAY_MS, + SCHEMA_CACHE_AGE_SECONDS, + SCHEMA_CACHE_REFRESH_ERROR, +} from '../../src/read-model/schema-cache'; + +describe('SchemaCache', () => { + let fetcher: { fetchSchema: jest.Mock, []> }; + let metrics: jest.Mocked; + let clock: number; + const now = () => clock; + + beforeEach(() => { + clock = 1_000_000; + metrics = makeMetrics(); + fetcher = { fetchSchema: jest.fn() }; + }); + + function build(): SchemaCache { + return new SchemaCache({ fetcher: fetcher as SchemaFetcher, metrics, now }); + } + + describe('cold cache', () => { + it('should fetch on first read and return the collections', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValue(schema); + + const result = await build().get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(1); + expect(result).toBe(schema); + }); + + it('should throw SchemaUnavailableError and emit the error counter when the first fetch fails', async () => { + fetcher.fetchSchema.mockRejectedValue(new Error('boom')); + const cache = build(); + + await expect(cache.get()).rejects.toBeInstanceOf(SchemaUnavailableError); + expect(metrics.increment).toHaveBeenCalledWith(SCHEMA_CACHE_REFRESH_ERROR); + }); + + it('should re-attempt the fetch on the next read after a cold failure (no poisoning)', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(schema); + const cache = build(); + + await expect(cache.get()).rejects.toBeInstanceOf(SchemaUnavailableError); + const result = await cache.get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(2); + expect(result).toBe(schema); + }); + + it('should report ageSeconds undefined until a first good schema exists', async () => { + fetcher.fetchSchema.mockRejectedValue(new Error('boom')); + const cache = build(); + + await expect(cache.get()).rejects.toBeInstanceOf(SchemaUnavailableError); + + expect(cache.ageSeconds()).toBeUndefined(); + }); + }); + + describe('warm cache within TTL', () => { + it('should serve from cache without re-fetching before 24h', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValue(schema); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS - 1; + const result = await cache.get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(1); + expect(result).toBe(schema); + }); + + it('should re-fetch after 24h', async () => { + const first = makeSchema('users'); + const second = makeSchema('users-v2'); + fetcher.fetchSchema.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + const result = await cache.get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(2); + expect(result).toBe(second); + }); + + it('should return the same array reference on a cache hit', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValue(schema); + const cache = build(); + + const a = await cache.get(); + const b = await cache.get(); + + expect(a).toBe(b); + }); + }); + + describe('warm cache refresh failure', () => { + it('should keep serving the last good schema and emit the error counter', async () => { + const good = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValueOnce(good).mockRejectedValueOnce(new Error('boom')); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + const result = await cache.get(); + + expect(result).toBe(good); + expect(metrics.increment).toHaveBeenCalledWith(SCHEMA_CACHE_REFRESH_ERROR); + }); + + it('should re-attempt on the next read and serve the fresh schema once it succeeds', async () => { + const good = makeSchema('users'); + const fresh = makeSchema('users-v2'); + fetcher.fetchSchema + .mockResolvedValueOnce(good) + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(fresh); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + await cache.get(); + const result = await cache.get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(3); + expect(result).toBe(fresh); + }); + }); + + describe('concurrent reads', () => { + it('should dedupe an in-flight fetch so concurrent cold reads fetch once', async () => { + const schema = makeSchema('users'); + let resolveFetch!: (value: ForestSchemaCollection[]) => void; + fetcher.fetchSchema.mockReturnValue( + new Promise(resolve => { + resolveFetch = resolve; + }), + ); + const cache = build(); + + const a = cache.get(); + const b = cache.get(); + resolveFetch(schema); + + expect(await a).toBe(schema); + expect(await b).toBe(schema); + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(1); + }); + }); + + describe('age gauge', () => { + it('should emit schema_cache_age_seconds reflecting the last good age on read', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValue(schema); + const cache = build(); + + await cache.get(); + clock += 5_000; + metrics.gauge.mockClear(); + await cache.get(); + + expect(metrics.gauge).toHaveBeenCalledWith(SCHEMA_CACHE_AGE_SECONDS, 5); + }); + }); +}); From 70d7b929c73f6ae2e2cb8fbaa88e7b39b8fa2a94 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 2 Jul 2026 17:46:48 +0200 Subject: [PATCH 03/12] fix(agent-bff): resolve dotted foreign collection names in relation targets A reference is `${foreignCollection}.${key}`; the collection name may itself contain dots (mongoose nested collections like `User.address`). Split on the last dot only instead of taking the first segment. Co-Authored-By: Claude Opus 4.8 --- packages/agent-bff/src/read-model/read-model.ts | 4 +++- .../agent-bff/test/read-model/read-model.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts index 475ae7b1ae..7e55210be2 100644 --- a/packages/agent-bff/src/read-model/read-model.ts +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -17,7 +17,9 @@ function toRelationTarget(field: ForestSchemaField): RelationTarget | null { } if (field.reference) { - return { type, polymorphic: false, target: field.reference.split('.')[0] }; + // reference is `${foreignCollection}.${key}`; the collection name may itself contain dots + // (e.g. mongoose nested collections like `User.address`), so drop only the trailing key. + return { type, polymorphic: false, target: field.reference.split('.').slice(0, -1).join('.') }; } return null; diff --git a/packages/agent-bff/test/read-model/read-model.test.ts b/packages/agent-bff/test/read-model/read-model.test.ts index 54835ccff1..140c18137a 100644 --- a/packages/agent-bff/test/read-model/read-model.test.ts +++ b/packages/agent-bff/test/read-model/read-model.test.ts @@ -48,6 +48,18 @@ describe('ReadModel', () => { expect(model.getRelationTarget('users', 'roles')?.type).toBe('BelongsToMany'); }); + it('should resolve a dotted foreign collection name (e.g. mongoose nested) as the target', () => { + const model = new ReadModel([ + collection('users', [relation('address', 'HasOne', 'users.address.id')]), + ]); + + expect(model.getRelationTarget('users', 'address')).toEqual({ + type: 'HasOne', + polymorphic: false, + target: 'users.address', + }); + }); + it('should flag a polymorphic relation and store its target list', () => { const model = new ReadModel([ collection('comments', [polymorphic('commentable', ['posts', 'videos'])]), From ab2f5921c2242781e2da45969d35955520f40009 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 2 Jul 2026 17:55:12 +0200 Subject: [PATCH 04/12] test(agent-bff): cover read-model composition root, schema client and store Add tests for createReadModel wiring, ForestSchemaClient (mocked SchemaService), ReadModelStore.ageSeconds delegation, and the read-model defensive branches (no-reference relation, actions-less collection, unknown collection). read-model files at 100% coverage. Co-Authored-By: Claude Opus 4.8 --- .../test/read-model/create-read-model.test.ts | 68 +++++++++++++++++++ .../read-model/forest-schema-client.test.ts | 41 +++++++++++ .../test/read-model/read-model-store.test.ts | 11 +++ .../test/read-model/read-model.test.ts | 25 +++++++ 4 files changed, 145 insertions(+) create mode 100644 packages/agent-bff/test/read-model/create-read-model.test.ts create mode 100644 packages/agent-bff/test/read-model/forest-schema-client.test.ts 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 new file mode 100644 index 0000000000..f7494f34cd --- /dev/null +++ b/packages/agent-bff/test/read-model/create-read-model.test.ts @@ -0,0 +1,68 @@ +import { SchemaService } from '@forestadmin/forestadmin-client'; + +import { action, collection, column, makeMetrics } from './fixtures'; +import { ACTION_ENDPOINT_MISS } from '../../src/read-model/action-endpoint-resolver'; +import createReadModel from '../../src/read-model/create-read-model'; + +jest.mock('@forestadmin/forestadmin-client'); + +describe('createReadModel', () => { + const getSchema = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (SchemaService as unknown as jest.Mock).mockImplementation(() => ({ getSchema })); + getSchema.mockResolvedValue([ + collection('users', [column('id')], [action('ban', '/forest/users/actions/ban')]), + ]); + }); + + it('should wire a store whose read-model reflects the fetched schema', async () => { + const { store } = createReadModel({ + forestServerUrl: 'x', + envSecret: 'y', + metrics: makeMetrics(), + }); + + const model = await store.getReadModel(); + + expect(model.isCollectionAllowed('users')).toBe(true); + expect(model.isActionAllowed('users', 'ban')).toBe(true); + }); + + it('should wire an action-endpoint resolver that resolves mapped actions', async () => { + const { actionEndpointResolver } = createReadModel({ + forestServerUrl: 'x', + envSecret: 'y', + metrics: makeMetrics(), + }); + + const info = await actionEndpointResolver.resolve('users', 'ban', { rendering: 1 }); + + expect(info?.endpoint).toBe('/forest/users/actions/ban'); + }); + + it('should wire the provided metrics into the resolver', async () => { + const metrics = makeMetrics(); + const { actionEndpointResolver } = createReadModel({ + forestServerUrl: 'x', + envSecret: 'y', + metrics, + }); + + await actionEndpointResolver.resolve('users', 'missing', { rendering: 3 }); + + expect(metrics.increment).toHaveBeenCalledWith(ACTION_ENDPOINT_MISS, { + rendering: 3, + collection: 'users', + action: 'missing', + }); + }); + + it('should default to console metrics when none is provided', () => { + const bundle = createReadModel({ forestServerUrl: 'x', envSecret: 'y' }); + + expect(bundle.store).toBeDefined(); + expect(bundle.actionEndpointResolver).toBeDefined(); + }); +}); diff --git a/packages/agent-bff/test/read-model/forest-schema-client.test.ts b/packages/agent-bff/test/read-model/forest-schema-client.test.ts new file mode 100644 index 0000000000..00fa10701d --- /dev/null +++ b/packages/agent-bff/test/read-model/forest-schema-client.test.ts @@ -0,0 +1,41 @@ +import { ForestHttpApi, SchemaService } from '@forestadmin/forestadmin-client'; + +import ForestSchemaClient from '../../src/read-model/forest-schema-client'; + +jest.mock('@forestadmin/forestadmin-client'); + +describe('ForestSchemaClient', () => { + const getSchema = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (SchemaService as unknown as jest.Mock).mockImplementation(() => ({ getSchema })); + }); + + it('should construct a SchemaService with a ForestHttpApi and the server options', () => { + const client = new ForestSchemaClient({ + forestServerUrl: 'https://api.test', + envSecret: 'secret', + }); + + expect(client).toBeInstanceOf(ForestSchemaClient); + expect(SchemaService).toHaveBeenCalledWith(expect.any(ForestHttpApi), { + forestServerUrl: 'https://api.test', + envSecret: 'secret', + }); + }); + + it('should delegate fetchSchema to SchemaService.getSchema', async () => { + const collections = [{ name: 'users', fields: [], actions: [] }]; + getSchema.mockResolvedValue(collections); + const client = new ForestSchemaClient({ + forestServerUrl: 'https://api.test', + envSecret: 'secret', + }); + + const result = await client.fetchSchema(); + + expect(getSchema).toHaveBeenCalledTimes(1); + expect(result).toBe(collections); + }); +}); 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 133f1f7f4e..7d69600f51 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 @@ -86,4 +86,15 @@ describe('ReadModelStore', () => { expect(capsFetcher).toHaveBeenCalledTimes(2); }); }); + + describe('ageSeconds', () => { + it('should reflect the schema cache age of the last good schema', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + + await store.getReadModel(); + clock += 7_000; + + expect(store.ageSeconds()).toBe(7); + }); + }); }); diff --git a/packages/agent-bff/test/read-model/read-model.test.ts b/packages/agent-bff/test/read-model/read-model.test.ts index 140c18137a..ad761d364e 100644 --- a/packages/agent-bff/test/read-model/read-model.test.ts +++ b/packages/agent-bff/test/read-model/read-model.test.ts @@ -60,6 +60,15 @@ describe('ReadModel', () => { }); }); + it('should ignore a relation field that has neither a reference nor polymorphic targets', () => { + const model = new ReadModel([ + collection('users', [{ ...column('mystery'), relationship: 'HasMany' }]), + ]); + + expect(model.isRelationAllowed('users', 'mystery')).toBe(false); + expect(model.getRelationTarget('users', 'mystery')).toBeUndefined(); + }); + it('should flag a polymorphic relation and store its target list', () => { const model = new ReadModel([ collection('comments', [polymorphic('commentable', ['posts', 'videos'])]), @@ -117,5 +126,21 @@ describe('ReadModel', () => { expect(model.isActionAllowed('users', 'ban')).toBe(false); expect(model.getActionEndpoints().users).toBeUndefined(); }); + + it('should handle a collection with no actions key', () => { + const model = new ReadModel([{ name: 'users', fields: [] }]); + + expect(model.isActionAllowed('users', 'ban')).toBe(false); + }); + }); + + describe('queries on an unknown collection', () => { + it('should report everything as not allowed', () => { + const model = new ReadModel([collection('users', [])]); + + expect(model.isRelationAllowed('ghost', 'x')).toBe(false); + expect(model.getRelationTarget('ghost', 'x')).toBeUndefined(); + expect(model.isActionAllowed('ghost', 'x')).toBe(false); + }); }); }); From d7320f54dd9d364e240cc8edd2c81e7ae1f5e354 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 6 Jul 2026 15:00:58 +0200 Subject: [PATCH 05/12] fix(agent-bff): harden read-model against malformed schemas and cache mutation Address review feedback: - guard `collection.fields ?? []` so a malformed collection cannot wedge the store for the 24h TTL - treat an empty schema as a failed fetch (cold -> SchemaUnavailableError, warm -> serve last good) instead of caching an all-denying schema - detect a schema refresh via an explicit revision counter instead of array reference identity - defensively copy action fields/hooks so a consumer cannot corrupt the shared cached schema - strengthen the console-metrics fallback test to assert routing, not presence Co-Authored-By: Claude Opus 4.8 --- .../src/read-model/read-model-store.ts | 13 ++-- .../agent-bff/src/read-model/read-model.ts | 7 +- .../agent-bff/src/read-model/schema-cache.ts | 16 ++++- .../test/read-model/create-read-model.test.ts | 23 +++++-- .../test/read-model/read-model-store.test.ts | 27 ++++++++ .../test/read-model/read-model.test.ts | 23 +++++++ .../test/read-model/schema-cache.test.ts | 67 +++++++++++++++++++ 7 files changed, 160 insertions(+), 16 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 4687254348..33a23a5b17 100644 --- a/packages/agent-bff/src/read-model/read-model-store.ts +++ b/packages/agent-bff/src/read-model/read-model-store.ts @@ -1,20 +1,19 @@ 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'; /** - * Single owner of the coupled schema + capabilities lifecycle. A successful schema refresh (a new - * collections reference from the cache) rebuilds the read-model and clears capabilities atomically, - * so the allow-list and capabilities never split-brain across schema generations. + * 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 + * allow-list and capabilities never split-brain across schema generations. */ export default class ReadModelStore { private readonly schemaCache: SchemaCache; private readonly capabilitiesCache: CapabilitiesCache; - private lastCollections: ForestSchemaCollection[] | null = null; + private builtRevision = -1; private readModel: ReadModel | null = null; constructor(schemaCache: SchemaCache, capabilitiesCache: CapabilitiesCache) { @@ -25,9 +24,9 @@ export default class ReadModelStore { async getReadModel(): Promise { const collections = await this.schemaCache.get(); - if (collections !== this.lastCollections || !this.readModel) { + if (this.schemaCache.revision !== this.builtRevision || !this.readModel) { this.readModel = new ReadModel(collections); - this.lastCollections = collections; + this.builtRevision = this.schemaCache.revision; this.capabilitiesCache.clear(); } diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts index 7e55210be2..5f551dd7de 100644 --- a/packages/agent-bff/src/read-model/read-model.ts +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -51,7 +51,7 @@ export default class ReadModel { private buildRelations(collection: ForestSchemaCollection): void { const relations = new Map(); - for (const field of collection.fields) { + for (const field of collection.fields ?? []) { const target = toRelationTarget(field); if (target) relations.set(field.field, target); } @@ -68,12 +68,13 @@ export default class ReadModel { this.actionEndpoints[collection.name] = {}; for (const action of withEndpoint) { + // Copy fields/hooks so a mutating consumer can't corrupt the shared cached schema. this.actionEndpoints[collection.name][action.name] = { id: action.id, name: action.name, endpoint: action.endpoint, - hooks: action.hooks, - fields: action.fields, + hooks: { ...action.hooks, change: [...action.hooks.change] }, + fields: action.fields.map(field => ({ ...field })), }; } } diff --git a/packages/agent-bff/src/read-model/schema-cache.ts b/packages/agent-bff/src/read-model/schema-cache.ts index 3f8cbd71a7..3b8351e297 100644 --- a/packages/agent-bff/src/read-model/schema-cache.ts +++ b/packages/agent-bff/src/read-model/schema-cache.ts @@ -24,8 +24,8 @@ interface CacheEntry { /** * Caches the agent schema for 24h. The TTL only *triggers* a refresh attempt: the last good * schema keeps being served until a refresh succeeds. A cold cache (no last good) surfaces a - * typed `SchemaUnavailableError`. Cache hits return the same array reference so a consumer can - * detect a refresh by reference identity. + * typed `SchemaUnavailableError`. A monotonic `revision` increments on each successful refresh so + * a consumer can detect a new schema generation explicitly. */ export default class SchemaCache { private readonly fetcher: SchemaFetcher; @@ -35,6 +35,7 @@ export default class SchemaCache { private entry: CacheEntry | null = null; private inFlight: Promise | null = null; + private revisionValue = 0; constructor({ fetcher, metrics, now = Date.now, ttlMs = ONE_DAY_MS }: SchemaCacheOptions) { this.fetcher = fetcher; @@ -59,6 +60,10 @@ export default class SchemaCache { return Math.floor((this.now() - this.entry.fetchedAt) / 1000); } + get revision(): number { + return this.revisionValue; + } + private async refresh(): Promise { if (!this.inFlight) { this.inFlight = this.doRefresh().finally(() => { @@ -72,7 +77,14 @@ export default class SchemaCache { private async doRefresh(): Promise { try { const collections = await this.fetcher.fetchSchema(); + + // An agent always exposes collections, so an empty result is far likelier a broken response + // than a valid state. Caching it would silently deny everything for 24h — treat it as a + // failed fetch and fall through to the failure path below. + if (collections.length === 0) throw new Error('Forest returned an empty schema'); + this.entry = { collections, fetchedAt: this.now() }; + this.revisionValue += 1; this.emitAge(); return collections; 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 f7494f34cd..6827492995 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 @@ -59,10 +59,25 @@ describe('createReadModel', () => { }); }); - it('should default to console metrics when none is provided', () => { - const bundle = createReadModel({ forestServerUrl: 'x', envSecret: 'y' }); + it('should route metrics through the console logger when none is provided', async () => { + const info = jest.spyOn(console, 'info').mockImplementation(() => undefined); - expect(bundle.store).toBeDefined(); - expect(bundle.actionEndpointResolver).toBeDefined(); + try { + const { actionEndpointResolver } = createReadModel({ forestServerUrl: 'x', envSecret: 'y' }); + await actionEndpointResolver.resolve('users', 'missing', { rendering: 9 }); + + const logged = info.mock.calls.map(call => JSON.parse(call[0] as string)); + expect(logged).toContainEqual( + expect.objectContaining({ + message: 'metric.increment', + metric: ACTION_ENDPOINT_MISS, + rendering: 9, + collection: 'users', + action: 'missing', + }), + ); + } finally { + info.mockRestore(); + } }); }); 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 7d69600f51..15b0d269ac 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 @@ -85,6 +85,33 @@ describe('ReadModelStore', () => { expect(capsFetcher).toHaveBeenCalledTimes(2); }); + + it('should not rebuild the read-model or clear capabilities when a refresh fails', async () => { + const fetchSchema = jest + .fn() + .mockResolvedValueOnce(makeSchema('users')) + .mockRejectedValueOnce(new Error('boom')); + // Capabilities TTL kept longer than the schema TTL so this asserts the *clear* (not a + // capabilities TTL expiry) does not happen on a warm schema-refresh failure. + const schemaCache = new SchemaCache({ + fetcher: { fetchSchema } as SchemaFetcher, + metrics: makeMetrics(), + now, + }); + const capabilitiesCache = new CapabilitiesCache({ now, ttlMs: ONE_DAY_MS * 10 }); + const store = new ReadModelStore(schemaCache, capabilitiesCache); + const capsFetcher = jest.fn().mockResolvedValue({ fields: [] }); + + const before = await store.getReadModel(); + await store.getCapabilities('users', capsFetcher); + + clock += ONE_DAY_MS; + const after = await store.getReadModel(); + await store.getCapabilities('users', capsFetcher); + + expect(after).toBe(before); + expect(capsFetcher).toHaveBeenCalledTimes(1); + }); }); describe('ageSeconds', () => { diff --git a/packages/agent-bff/test/read-model/read-model.test.ts b/packages/agent-bff/test/read-model/read-model.test.ts index ad761d364e..b082323ef4 100644 --- a/packages/agent-bff/test/read-model/read-model.test.ts +++ b/packages/agent-bff/test/read-model/read-model.test.ts @@ -1,3 +1,5 @@ +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + import { action, collection, column, polymorphic, relation } from './fixtures'; import ReadModel from '../../src/read-model/read-model'; @@ -127,11 +129,32 @@ describe('ReadModel', () => { expect(model.getActionEndpoints().users).toBeUndefined(); }); + it('should defensively copy action fields and hooks so a consumer cannot mutate the cache', () => { + const original = action('ban', '/forest/users/actions/ban'); + original.fields = [{ field: 'reason', type: 'String' }]; + original.hooks = { load: true, change: ['dep'] }; + const model = new ReadModel([collection('users', [], [original])]); + + const stored = model.getActionEndpoints().users.ban; + + expect(stored.fields).toEqual([{ field: 'reason', type: 'String' }]); + expect(stored.fields).not.toBe(original.fields); + expect(stored.fields[0]).not.toBe(original.fields[0]); + expect(stored.hooks.change).not.toBe(original.hooks.change); + }); + it('should handle a collection with no actions key', () => { const model = new ReadModel([{ name: 'users', fields: [] }]); expect(model.isActionAllowed('users', 'ban')).toBe(false); }); + + it('should not throw on a malformed collection with no fields key', () => { + const model = new ReadModel([{ name: 'weird' } as unknown as ForestSchemaCollection]); + + expect(model.isCollectionAllowed('weird')).toBe(true); + expect(model.isRelationAllowed('weird', 'x')).toBe(false); + }); }); describe('queries on an unknown collection', () => { diff --git a/packages/agent-bff/test/read-model/schema-cache.test.ts b/packages/agent-bff/test/read-model/schema-cache.test.ts index ca118466a8..b0cfc1fe7a 100644 --- a/packages/agent-bff/test/read-model/schema-cache.test.ts +++ b/packages/agent-bff/test/read-model/schema-cache.test.ts @@ -175,4 +175,71 @@ describe('SchemaCache', () => { expect(metrics.gauge).toHaveBeenCalledWith(SCHEMA_CACHE_AGE_SECONDS, 5); }); }); + + describe('empty schema', () => { + it('should treat an empty schema as a failed fetch on a cold cache', async () => { + fetcher.fetchSchema.mockResolvedValue([]); + const cache = build(); + + await expect(cache.get()).rejects.toBeInstanceOf(SchemaUnavailableError); + expect(metrics.increment).toHaveBeenCalledWith(SCHEMA_CACHE_REFRESH_ERROR); + expect(cache.ageSeconds()).toBeUndefined(); + }); + + it('should keep serving the last good schema when a refresh returns empty', async () => { + const good = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValueOnce(good).mockResolvedValueOnce([]); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + const result = await cache.get(); + + expect(result).toBe(good); + expect(metrics.increment).toHaveBeenCalledWith(SCHEMA_CACHE_REFRESH_ERROR); + }); + }); + + describe('revision', () => { + it('should start at 0 before any successful fetch', () => { + expect(build().revision).toBe(0); + }); + + it('should increment on each successful refresh', async () => { + fetcher.fetchSchema + .mockResolvedValueOnce(makeSchema('users')) + .mockResolvedValueOnce(makeSchema('users-v2')); + const cache = build(); + + await cache.get(); + expect(cache.revision).toBe(1); + + clock += ONE_DAY_MS; + await cache.get(); + expect(cache.revision).toBe(2); + }); + + it('should not increment on a cache hit', async () => { + fetcher.fetchSchema.mockResolvedValue(makeSchema('users')); + const cache = build(); + + await cache.get(); + await cache.get(); + + expect(cache.revision).toBe(1); + }); + + it('should not increment on a warm refresh failure', async () => { + fetcher.fetchSchema + .mockResolvedValueOnce(makeSchema('users')) + .mockRejectedValueOnce(new Error('boom')); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + await cache.get(); + + expect(cache.revision).toBe(1); + }); + }); }); From 0d226e2618671c3510b7bd1f24d8fb53a00460ac Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 6 Jul 2026 15:28:47 +0200 Subject: [PATCH 06/12] fix(agent-bff): freeze the read-model so consumers cannot mutate the cache Deep-freeze the action-endpoint map and relation targets at construction (after the field/hook clone that detaches them from the source schema), so the getters can return live references safely. Add a note on the getCapabilities TOCTOU to resolve when the data endpoints wire it in. Co-Authored-By: Claude Opus 4.8 --- .../src/read-model/read-model-store.ts | 5 ++++ .../agent-bff/src/read-model/read-model.ts | 13 ++++++++++ .../test/read-model/read-model.test.ts | 24 +++++++++++++++++++ 3 files changed, 42 insertions(+) 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 33a23a5b17..d2e1dd346e 100644 --- a/packages/agent-bff/src/read-model/read-model-store.ts +++ b/packages/agent-bff/src/read-model/read-model-store.ts @@ -40,6 +40,11 @@ export default class ReadModelStore { // Ensure any pending schema refresh (and its capabilities invalidation) runs first. await this.getReadModel(); + // TODO(wiring): possible TOCTOU once this is called from request handling. A concurrent schema + // refresh can clear capabilities while this fetch is in flight, so the caller could receive + // capabilities from the previous schema generation alongside the new allow-list. When wiring + // the data endpoints, re-check `schemaCache.revision` after the fetch resolves and retry on a + // mismatch so capabilities and schema stay atomically coupled. return this.capabilitiesCache.get(collection, fetcher); } diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts index 5f551dd7de..b4b466aa01 100644 --- a/packages/agent-bff/src/read-model/read-model.ts +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -25,6 +25,15 @@ function toRelationTarget(field: ForestSchemaField): RelationTarget | null { return null; } +function deepFreeze(value: T): T { + if (value && typeof value === 'object') { + Object.values(value as Record).forEach(deepFreeze); + Object.freeze(value); + } + + return value; +} + /** * The agent read-model derived from a schema. Names are collection-scoped: relations and actions * are keyed by `(collection, name)` so the same relation/action name on two collections stays @@ -46,6 +55,10 @@ export default class ReadModel { this.buildRelations(collection); this.buildActionEndpoints(collection); } + + // Freeze the exposed structures so a consumer cannot mutate the shared cached read-model. + deepFreeze(this.actionEndpoints); + this.relations.forEach(byRelation => byRelation.forEach(deepFreeze)); } private buildRelations(collection: ForestSchemaCollection): void { diff --git a/packages/agent-bff/test/read-model/read-model.test.ts b/packages/agent-bff/test/read-model/read-model.test.ts index b082323ef4..242bee7c58 100644 --- a/packages/agent-bff/test/read-model/read-model.test.ts +++ b/packages/agent-bff/test/read-model/read-model.test.ts @@ -82,6 +82,16 @@ describe('ReadModel', () => { targets: ['posts', 'videos'], }); }); + + it('should return frozen relation targets that a consumer cannot mutate', () => { + const model = new ReadModel([ + collection('comments', [polymorphic('commentable', ['posts'])]), + ]); + + const target = model.getRelationTarget('comments', 'commentable'); + + expect(Object.isFrozen(target)).toBe(true); + }); }); describe('collection-scoped keying', () => { @@ -149,6 +159,20 @@ describe('ReadModel', () => { expect(model.isActionAllowed('users', 'ban')).toBe(false); }); + it('should return a frozen action-endpoint map that a consumer cannot mutate', () => { + const model = new ReadModel([ + collection('users', [], [action('ban', '/forest/users/actions/ban')]), + ]); + + const endpoints = model.getActionEndpoints(); + + expect(Object.isFrozen(endpoints)).toBe(true); + expect(Object.isFrozen(endpoints.users.ban)).toBe(true); + expect(() => { + endpoints.users.ban.endpoint = 'mutated'; + }).toThrow(); + }); + it('should not throw on a malformed collection with no fields key', () => { const model = new ReadModel([{ name: 'weird' } as unknown as ForestSchemaCollection]); From 2d14997522a541ae71e283955e6590b65085deaa Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 6 Jul 2026 17:20:12 +0200 Subject: [PATCH 07/12] feat(agent-bff): map agent errors to the BFF error contract Add mapAgentError() translating agent-client AgentHttpError failures into the BFF error envelope, plus the complete registry of BFF-local error factories the Slice-3 endpoints emit. Fixes PRD-670 Co-Authored-By: Claude Opus 4.8 --- packages/agent-bff/package.json | 1 + .../agent-bff/src/http/agent-error-mapper.ts | 127 ++++++++++++++++++ .../agent-bff/src/http/bff-local-errors.ts | 43 ++++++ .../test/http/agent-error-mapper.test.ts | 90 +++++++++++++ .../test/http/bff-local-errors.test.ts | 37 +++++ 5 files changed, 298 insertions(+) create mode 100644 packages/agent-bff/src/http/agent-error-mapper.ts create mode 100644 packages/agent-bff/src/http/bff-local-errors.ts create mode 100644 packages/agent-bff/test/http/agent-error-mapper.test.ts create mode 100644 packages/agent-bff/test/http/bff-local-errors.test.ts diff --git a/packages/agent-bff/package.json b/packages/agent-bff/package.json index c57cc47ef2..3d4fc13592 100644 --- a/packages/agent-bff/package.json +++ b/packages/agent-bff/package.json @@ -31,6 +31,7 @@ "test": "jest" }, "dependencies": { + "@forestadmin/agent-client": "1.10.1", "@forestadmin/forestadmin-client": "1.40.4", "@koa/bodyparser": "^6.1.0", "jsonwebtoken": "^9.0.3", diff --git a/packages/agent-bff/src/http/agent-error-mapper.ts b/packages/agent-bff/src/http/agent-error-mapper.ts new file mode 100644 index 0000000000..7d71b8d10c --- /dev/null +++ b/packages/agent-bff/src/http/agent-error-mapper.ts @@ -0,0 +1,127 @@ +import type { Logger } from '../ports/logger-port'; + +import { AgentHttpError } from '@forestadmin/agent-client'; + +import { BffHttpError } from './bff-http-error'; + +const STATUS_BAD_REQUEST = 400; +const STATUS_UNAUTHORIZED = 401; +const STATUS_FORBIDDEN = 403; +const STATUS_NOT_FOUND = 404; +const STATUS_UNPROCESSABLE = 422; +const STATUS_TOO_MANY_REQUESTS = 429; +const STATUS_SERVER_ERROR = 500; +const STATUS_BAD_GATEWAY = 502; +const STATUS_SERVICE_UNAVAILABLE = 503; + +const TYPE_INVALID_REQUEST = 'invalid_request'; +const TYPE_UNAUTHORIZED = 'unauthorized'; +const TYPE_FORBIDDEN = 'forbidden'; +const TYPE_NOT_FOUND = 'not_found'; +const TYPE_UNPROCESSABLE = 'unprocessable_entity'; +const TYPE_TOO_MANY_REQUESTS = 'too_many_requests'; +const TYPE_NETWORK_ERROR = 'network_error'; +const TYPE_AGENT_UNAVAILABLE = 'agent_unavailable'; + +const DEFAULT_NETWORK_MESSAGE = 'The agent could not be reached'; +const DEFAULT_UNAVAILABLE_MESSAGE = 'The agent is unavailable'; +const DEFAULT_ERROR_MESSAGE = 'Unexpected error'; + +export const AGENT_ERROR_TYPE_MAP: Record = { + ValidationError: 'validation_error', + BadRequestError: TYPE_INVALID_REQUEST, + UnauthorizedError: TYPE_UNAUTHORIZED, + ForbiddenError: TYPE_FORBIDDEN, + NotFoundError: TYPE_NOT_FOUND, + UnprocessableError: TYPE_UNPROCESSABLE, + TooManyRequestsError: TYPE_TOO_MANY_REQUESTS, +}; + +const FALLBACK_TYPE_BY_STATUS: Record = { + [STATUS_BAD_REQUEST]: TYPE_INVALID_REQUEST, + [STATUS_UNAUTHORIZED]: TYPE_UNAUTHORIZED, + [STATUS_FORBIDDEN]: TYPE_FORBIDDEN, + [STATUS_NOT_FOUND]: TYPE_NOT_FOUND, + [STATUS_UNPROCESSABLE]: TYPE_UNPROCESSABLE, + [STATUS_TOO_MANY_REQUESTS]: TYPE_TOO_MANY_REQUESTS, +}; + +interface AgentJsonApiError { + name?: string; + detail?: string; + status?: number; + data?: unknown; +} + +function fallbackTypeByStatus(status: number, name?: string, logger?: Logger): string { + if (name !== undefined && logger !== undefined) { + logger('Warn', 'Unmapped agent error name, falling back to status', { name, status }); + } + + return FALLBACK_TYPE_BY_STATUS[status] ?? TYPE_INVALID_REQUEST; +} + +function firstJsonApiError(body: unknown): AgentJsonApiError | undefined { + if (typeof body !== 'object' || body === null) return undefined; + + const { errors } = body as { errors?: unknown }; + + return Array.isArray(errors) ? (errors[0] as AgentJsonApiError) : undefined; +} + +function mapJsonApiError(agentError: AgentJsonApiError, logger: Logger): BffHttpError { + const status = agentError.status ?? STATUS_BAD_REQUEST; + const message = agentError.detail ?? DEFAULT_ERROR_MESSAGE; + const type = + (agentError.name !== undefined ? AGENT_ERROR_TYPE_MAP[agentError.name] : undefined) ?? + fallbackTypeByStatus(status, agentError.name, logger); + + return new BffHttpError(status, type, message, agentError.data); +} + +function parseJsonApiFromMessage(error: unknown): AgentJsonApiError | undefined { + if (!(error instanceof Error)) return undefined; + + try { + return firstJsonApiError(JSON.parse(error.message)); + } catch { + return undefined; + } +} + +function mapFlatBody(status: number, body: unknown): BffHttpError { + const flat = (typeof body === 'object' && body !== null ? body : {}) as { + error?: unknown; + message?: unknown; + }; + const message = + (typeof flat.error === 'string' ? flat.error : undefined) ?? + (typeof flat.message === 'string' ? flat.message : undefined) ?? + DEFAULT_ERROR_MESSAGE; + + return new BffHttpError(status, fallbackTypeByStatus(status), message); +} + +export function mapAgentError(error: unknown, { logger }: { logger: Logger }): BffHttpError { + if (!(error instanceof AgentHttpError)) { + const agentError = parseJsonApiFromMessage(error); + if (agentError) return mapJsonApiError(agentError, logger); + + const message = error instanceof Error ? error.message : DEFAULT_NETWORK_MESSAGE; + + return new BffHttpError(STATUS_BAD_GATEWAY, TYPE_NETWORK_ERROR, message); + } + + if (error.status >= STATUS_SERVER_ERROR) { + return new BffHttpError( + STATUS_SERVICE_UNAVAILABLE, + TYPE_AGENT_UNAVAILABLE, + DEFAULT_UNAVAILABLE_MESSAGE, + ); + } + + const agentError = firstJsonApiError(error.body); + if (agentError) return mapJsonApiError(agentError, logger); + + return mapFlatBody(error.status, error.body); +} diff --git a/packages/agent-bff/src/http/bff-local-errors.ts b/packages/agent-bff/src/http/bff-local-errors.ts new file mode 100644 index 0000000000..b8852e9530 --- /dev/null +++ b/packages/agent-bff/src/http/bff-local-errors.ts @@ -0,0 +1,43 @@ +import { BffHttpError } from './bff-http-error'; + +export function unknownCollection(message = 'Unknown collection'): BffHttpError { + return new BffHttpError(404, 'unknown_collection', message); +} + +export function unknownRelation(message = 'Unknown relation'): BffHttpError { + return new BffHttpError(404, 'unknown_relation', message); +} + +export function unknownAction(message = 'Unknown action'): BffHttpError { + return new BffHttpError(404, 'unknown_action', message); +} + +export function collectionNotAllowed(message = 'Collection is not allowed'): BffHttpError { + return new BffHttpError(403, 'collection_not_allowed', message); +} + +export function relationNotAllowed(message = 'Relation is not allowed'): BffHttpError { + return new BffHttpError(403, 'relation_not_allowed', message); +} + +export function actionNotAllowed(message = 'Action is not allowed'): BffHttpError { + return new BffHttpError(403, 'action_not_allowed', message); +} + +export function invalidRequest(message = 'Invalid request', details?: unknown): BffHttpError { + return new BffHttpError(400, 'invalid_request', message, details); +} + +export function relationFieldNotSupported( + message = 'Relation field is not supported', +): BffHttpError { + return new BffHttpError(422, 'relation_field_not_supported', message); +} + +export function mappingError(message = 'Failed to map the agent response'): BffHttpError { + return new BffHttpError(500, 'mapping_error', message); +} + +export function unsupportedActionResult(message = 'Unsupported action result'): BffHttpError { + return new BffHttpError(501, 'unsupported_action_result', message); +} diff --git a/packages/agent-bff/test/http/agent-error-mapper.test.ts b/packages/agent-bff/test/http/agent-error-mapper.test.ts new file mode 100644 index 0000000000..8b471c6096 --- /dev/null +++ b/packages/agent-bff/test/http/agent-error-mapper.test.ts @@ -0,0 +1,90 @@ +import { AgentHttpError } from '@forestadmin/agent-client'; + +import { AGENT_ERROR_TYPE_MAP, mapAgentError } from '../../src/http/agent-error-mapper'; + +function jsonApiBody(error: { name?: string; detail?: string; status?: number; data?: unknown }) { + return { errors: [error] }; +} + +describe('mapAgentError', () => { + let logger: jest.Mock; + + beforeEach(() => { + logger = jest.fn(); + }); + + it('maps a JSON:API NotFoundError to not_found with its detail and data', () => { + const error = new AgentHttpError( + 404, + jsonApiBody({ name: 'NotFoundError', status: 404, detail: 'x', data: { id: 1 } }), + ); + + const result = mapAgentError(error, { logger }); + + expect(result).toMatchObject({ + type: 'not_found', + status: 404, + message: 'x', + details: { id: 1 }, + }); + }); + + it.each(Object.entries(AGENT_ERROR_TYPE_MAP))('maps agent name %s to type %s', (name, type) => { + const status = 422; + const error = new AgentHttpError(status, jsonApiBody({ name, status, detail: 'd' })); + + const result = mapAgentError(error, { logger }); + + expect(result).toMatchObject({ type, status }); + }); + + it('parses a JSON:API body carried in a plain Error message', () => { + const error = new Error( + JSON.stringify(jsonApiBody({ name: 'ForbiddenError', status: 403, detail: 'nope' })), + ); + + const result = mapAgentError(error, { logger }); + + expect(result).toMatchObject({ type: 'forbidden', status: 403, message: 'nope' }); + }); + + it('maps a transport failure to network_error (502)', () => { + const result = mapAgentError(new Error('ECONNREFUSED'), { logger }); + + expect(result).toMatchObject({ type: 'network_error', status: 502 }); + }); + + it('normalizes an agent 500 to agent_unavailable (503)', () => { + const result = mapAgentError(new AgentHttpError(500, {}), { logger }); + + expect(result).toMatchObject({ type: 'agent_unavailable', status: 503 }); + }); + + it('normalizes an agent 503 to agent_unavailable (503)', () => { + const result = mapAgentError(new AgentHttpError(503, {}), { logger }); + + expect(result).toMatchObject({ type: 'agent_unavailable', status: 503 }); + }); + + it('maps a flat native-action body to invalid_request (400) using body.error', () => { + const result = mapAgentError(new AgentHttpError(400, { error: 'boom' }), { logger }); + + expect(result).toMatchObject({ type: 'invalid_request', status: 400, message: 'boom' }); + }); + + it('falls back to invalid_request and warns for an unmapped name', () => { + const error = new AgentHttpError( + 400, + jsonApiBody({ name: 'MissingCollectionError', status: 400, detail: 'gone' }), + ); + + const result = mapAgentError(error, { logger }); + + expect(result).toMatchObject({ type: 'invalid_request', status: 400 }); + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.any(String), + expect.objectContaining({ name: 'MissingCollectionError' }), + ); + }); +}); diff --git a/packages/agent-bff/test/http/bff-local-errors.test.ts b/packages/agent-bff/test/http/bff-local-errors.test.ts new file mode 100644 index 0000000000..1a1930c41a --- /dev/null +++ b/packages/agent-bff/test/http/bff-local-errors.test.ts @@ -0,0 +1,37 @@ +import { + actionNotAllowed, + collectionNotAllowed, + invalidRequest, + mappingError, + relationFieldNotSupported, + relationNotAllowed, + unknownAction, + unknownCollection, + unknownRelation, + unsupportedActionResult, +} from '../../src/http/bff-local-errors'; + +describe('bff local errors', () => { + it.each([ + [unknownCollection, 'unknown_collection', 404], + [unknownRelation, 'unknown_relation', 404], + [unknownAction, 'unknown_action', 404], + [collectionNotAllowed, 'collection_not_allowed', 403], + [relationNotAllowed, 'relation_not_allowed', 403], + [actionNotAllowed, 'action_not_allowed', 403], + [invalidRequest, 'invalid_request', 400], + [relationFieldNotSupported, 'relation_field_not_supported', 422], + [mappingError, 'mapping_error', 500], + [unsupportedActionResult, 'unsupported_action_result', 501], + ])('%p builds a %s error with status %d', (factory, type, status) => { + expect(factory()).toMatchObject({ type, status }); + }); + + it('carries details on invalidRequest', () => { + expect(invalidRequest('bad', { field: 'x' })).toMatchObject({ + type: 'invalid_request', + status: 400, + details: { field: 'x' }, + }); + }); +}); From d9c89e734f123599b449741c67503f4aaa7f2cd8 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 6 Jul 2026 17:41:24 +0200 Subject: [PATCH 08/12] refactor(agent-bff): harden agent error mapping per review - unmapped agent error name now returns 500 mapping_error instead of a silent status fallback - extract the unmapped-name warn out of fallbackTypeByStatus (single responsibility) - use a named constant for the validation_error type - make the name-to-type test use an explicit table instead of deriving expectations from the map under test Co-Authored-By: Claude Opus 4.8 --- .../agent-bff/src/http/agent-error-mapper.ts | 27 ++++++++++++------- .../test/http/agent-error-mapper.test.ts | 18 +++++++++---- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/agent-bff/src/http/agent-error-mapper.ts b/packages/agent-bff/src/http/agent-error-mapper.ts index 7d71b8d10c..5315a37141 100644 --- a/packages/agent-bff/src/http/agent-error-mapper.ts +++ b/packages/agent-bff/src/http/agent-error-mapper.ts @@ -3,6 +3,7 @@ import type { Logger } from '../ports/logger-port'; import { AgentHttpError } from '@forestadmin/agent-client'; import { BffHttpError } from './bff-http-error'; +import { mappingError } from './bff-local-errors'; const STATUS_BAD_REQUEST = 400; const STATUS_UNAUTHORIZED = 401; @@ -15,6 +16,7 @@ const STATUS_BAD_GATEWAY = 502; const STATUS_SERVICE_UNAVAILABLE = 503; const TYPE_INVALID_REQUEST = 'invalid_request'; +const TYPE_VALIDATION_ERROR = 'validation_error'; const TYPE_UNAUTHORIZED = 'unauthorized'; const TYPE_FORBIDDEN = 'forbidden'; const TYPE_NOT_FOUND = 'not_found'; @@ -28,7 +30,7 @@ const DEFAULT_UNAVAILABLE_MESSAGE = 'The agent is unavailable'; const DEFAULT_ERROR_MESSAGE = 'Unexpected error'; export const AGENT_ERROR_TYPE_MAP: Record = { - ValidationError: 'validation_error', + ValidationError: TYPE_VALIDATION_ERROR, BadRequestError: TYPE_INVALID_REQUEST, UnauthorizedError: TYPE_UNAUTHORIZED, ForbiddenError: TYPE_FORBIDDEN, @@ -53,11 +55,7 @@ interface AgentJsonApiError { data?: unknown; } -function fallbackTypeByStatus(status: number, name?: string, logger?: Logger): string { - if (name !== undefined && logger !== undefined) { - logger('Warn', 'Unmapped agent error name, falling back to status', { name, status }); - } - +function fallbackTypeByStatus(status: number): string { return FALLBACK_TYPE_BY_STATUS[status] ?? TYPE_INVALID_REQUEST; } @@ -72,11 +70,20 @@ function firstJsonApiError(body: unknown): AgentJsonApiError | undefined { function mapJsonApiError(agentError: AgentJsonApiError, logger: Logger): BffHttpError { const status = agentError.status ?? STATUS_BAD_REQUEST; const message = agentError.detail ?? DEFAULT_ERROR_MESSAGE; - const type = - (agentError.name !== undefined ? AGENT_ERROR_TYPE_MAP[agentError.name] : undefined) ?? - fallbackTypeByStatus(status, agentError.name, logger); - return new BffHttpError(status, type, message, agentError.data); + if (agentError.name !== undefined) { + const type = AGENT_ERROR_TYPE_MAP[agentError.name]; + + if (type === undefined) { + logger('Error', 'Unmapped agent error name', { name: agentError.name, status }); + + return mappingError(`Unmapped agent error name: ${agentError.name}`); + } + + return new BffHttpError(status, type, message, agentError.data); + } + + return new BffHttpError(status, fallbackTypeByStatus(status), message, agentError.data); } function parseJsonApiFromMessage(error: unknown): AgentJsonApiError | undefined { diff --git a/packages/agent-bff/test/http/agent-error-mapper.test.ts b/packages/agent-bff/test/http/agent-error-mapper.test.ts index 8b471c6096..b8f55b9fde 100644 --- a/packages/agent-bff/test/http/agent-error-mapper.test.ts +++ b/packages/agent-bff/test/http/agent-error-mapper.test.ts @@ -1,6 +1,6 @@ import { AgentHttpError } from '@forestadmin/agent-client'; -import { AGENT_ERROR_TYPE_MAP, mapAgentError } from '../../src/http/agent-error-mapper'; +import { mapAgentError } from '../../src/http/agent-error-mapper'; function jsonApiBody(error: { name?: string; detail?: string; status?: number; data?: unknown }) { return { errors: [error] }; @@ -29,7 +29,15 @@ describe('mapAgentError', () => { }); }); - it.each(Object.entries(AGENT_ERROR_TYPE_MAP))('maps agent name %s to type %s', (name, type) => { + it.each([ + ['ValidationError', 'validation_error'], + ['BadRequestError', 'invalid_request'], + ['UnauthorizedError', 'unauthorized'], + ['ForbiddenError', 'forbidden'], + ['NotFoundError', 'not_found'], + ['UnprocessableError', 'unprocessable_entity'], + ['TooManyRequestsError', 'too_many_requests'], + ])('maps agent name %s to type %s', (name, type) => { const status = 422; const error = new AgentHttpError(status, jsonApiBody({ name, status, detail: 'd' })); @@ -72,7 +80,7 @@ describe('mapAgentError', () => { expect(result).toMatchObject({ type: 'invalid_request', status: 400, message: 'boom' }); }); - it('falls back to invalid_request and warns for an unmapped name', () => { + it('maps an unmapped agent error name to mapping_error (500) and logs it', () => { const error = new AgentHttpError( 400, jsonApiBody({ name: 'MissingCollectionError', status: 400, detail: 'gone' }), @@ -80,9 +88,9 @@ describe('mapAgentError', () => { const result = mapAgentError(error, { logger }); - expect(result).toMatchObject({ type: 'invalid_request', status: 400 }); + expect(result).toMatchObject({ type: 'mapping_error', status: 500 }); expect(logger).toHaveBeenCalledWith( - 'Warn', + 'Error', expect.any(String), expect.objectContaining({ name: 'MissingCollectionError' }), ); From 0661fa31567512119a185423d263310aed669d45 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 7 Jul 2026 10:41:14 +0200 Subject: [PATCH 09/12] fix(agent-bff): use outer agent status and message fallback in error mapping - fall back to the outer AgentHttpError status when the JSON:API error object omits its own status, instead of hardcoding 400 - fall back to errors[0].message when detail is absent, before the generic default message Co-Authored-By: Claude Opus 4.8 --- .../agent-bff/src/http/agent-error-mapper.ts | 15 +++++++---- .../test/http/agent-error-mapper.test.ts | 27 ++++++++++++++++++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/agent-bff/src/http/agent-error-mapper.ts b/packages/agent-bff/src/http/agent-error-mapper.ts index 5315a37141..ea3c253f2e 100644 --- a/packages/agent-bff/src/http/agent-error-mapper.ts +++ b/packages/agent-bff/src/http/agent-error-mapper.ts @@ -51,6 +51,7 @@ const FALLBACK_TYPE_BY_STATUS: Record = { interface AgentJsonApiError { name?: string; detail?: string; + message?: string; status?: number; data?: unknown; } @@ -67,9 +68,13 @@ function firstJsonApiError(body: unknown): AgentJsonApiError | undefined { return Array.isArray(errors) ? (errors[0] as AgentJsonApiError) : undefined; } -function mapJsonApiError(agentError: AgentJsonApiError, logger: Logger): BffHttpError { - const status = agentError.status ?? STATUS_BAD_REQUEST; - const message = agentError.detail ?? DEFAULT_ERROR_MESSAGE; +function mapJsonApiError( + agentError: AgentJsonApiError, + fallbackStatus: number, + logger: Logger, +): BffHttpError { + const status = agentError.status ?? fallbackStatus; + const message = agentError.detail ?? agentError.message ?? DEFAULT_ERROR_MESSAGE; if (agentError.name !== undefined) { const type = AGENT_ERROR_TYPE_MAP[agentError.name]; @@ -112,7 +117,7 @@ function mapFlatBody(status: number, body: unknown): BffHttpError { export function mapAgentError(error: unknown, { logger }: { logger: Logger }): BffHttpError { if (!(error instanceof AgentHttpError)) { const agentError = parseJsonApiFromMessage(error); - if (agentError) return mapJsonApiError(agentError, logger); + if (agentError) return mapJsonApiError(agentError, STATUS_BAD_REQUEST, logger); const message = error instanceof Error ? error.message : DEFAULT_NETWORK_MESSAGE; @@ -128,7 +133,7 @@ export function mapAgentError(error: unknown, { logger }: { logger: Logger }): B } const agentError = firstJsonApiError(error.body); - if (agentError) return mapJsonApiError(agentError, logger); + if (agentError) return mapJsonApiError(agentError, error.status, logger); return mapFlatBody(error.status, error.body); } diff --git a/packages/agent-bff/test/http/agent-error-mapper.test.ts b/packages/agent-bff/test/http/agent-error-mapper.test.ts index b8f55b9fde..820615fdc1 100644 --- a/packages/agent-bff/test/http/agent-error-mapper.test.ts +++ b/packages/agent-bff/test/http/agent-error-mapper.test.ts @@ -2,7 +2,13 @@ import { AgentHttpError } from '@forestadmin/agent-client'; import { mapAgentError } from '../../src/http/agent-error-mapper'; -function jsonApiBody(error: { name?: string; detail?: string; status?: number; data?: unknown }) { +function jsonApiBody(error: { + name?: string; + detail?: string; + message?: string; + status?: number; + data?: unknown; +}) { return { errors: [error] }; } @@ -46,6 +52,25 @@ describe('mapAgentError', () => { expect(result).toMatchObject({ type, status }); }); + it('uses the outer AgentHttpError status when the JSON:API error omits it', () => { + const error = new AgentHttpError(403, jsonApiBody({ name: 'ForbiddenError', detail: 'no' })); + + const result = mapAgentError(error, { logger }); + + expect(result).toMatchObject({ type: 'forbidden', status: 403 }); + }); + + it('falls back to the JSON:API message when detail is absent', () => { + const error = new AgentHttpError( + 404, + jsonApiBody({ name: 'NotFoundError', status: 404, message: 'gone' }), + ); + + const result = mapAgentError(error, { logger }); + + expect(result).toMatchObject({ type: 'not_found', status: 404, message: 'gone' }); + }); + it('parses a JSON:API body carried in a plain Error message', () => { const error = new Error( JSON.stringify(jsonApiBody({ name: 'ForbiddenError', status: 403, detail: 'nope' })), From 9ba53228847977446c40a5c441d37315c058c170 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 7 Jul 2026 11:07:52 +0200 Subject: [PATCH 10/12] fix(agent-bff): fall back to responseText for non-JSON agent error bodies When the agent body is not a JSON error object, use AgentHttpError.responseText as the message source before the generic default, so plain-text or empty bodies still surface the agent-supplied text. Co-Authored-By: Claude Opus 4.8 --- packages/agent-bff/src/http/agent-error-mapper.ts | 5 +++-- .../agent-bff/test/http/agent-error-mapper.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/agent-bff/src/http/agent-error-mapper.ts b/packages/agent-bff/src/http/agent-error-mapper.ts index ea3c253f2e..dcc4f20f03 100644 --- a/packages/agent-bff/src/http/agent-error-mapper.ts +++ b/packages/agent-bff/src/http/agent-error-mapper.ts @@ -101,7 +101,7 @@ function parseJsonApiFromMessage(error: unknown): AgentJsonApiError | undefined } } -function mapFlatBody(status: number, body: unknown): BffHttpError { +function mapFlatBody(status: number, body: unknown, responseText?: string): BffHttpError { const flat = (typeof body === 'object' && body !== null ? body : {}) as { error?: unknown; message?: unknown; @@ -109,6 +109,7 @@ function mapFlatBody(status: number, body: unknown): BffHttpError { const message = (typeof flat.error === 'string' ? flat.error : undefined) ?? (typeof flat.message === 'string' ? flat.message : undefined) ?? + (typeof responseText === 'string' && responseText !== '' ? responseText : undefined) ?? DEFAULT_ERROR_MESSAGE; return new BffHttpError(status, fallbackTypeByStatus(status), message); @@ -135,5 +136,5 @@ export function mapAgentError(error: unknown, { logger }: { logger: Logger }): B const agentError = firstJsonApiError(error.body); if (agentError) return mapJsonApiError(agentError, error.status, logger); - return mapFlatBody(error.status, error.body); + return mapFlatBody(error.status, error.body, error.responseText); } diff --git a/packages/agent-bff/test/http/agent-error-mapper.test.ts b/packages/agent-bff/test/http/agent-error-mapper.test.ts index 820615fdc1..3e7f4fefa0 100644 --- a/packages/agent-bff/test/http/agent-error-mapper.test.ts +++ b/packages/agent-bff/test/http/agent-error-mapper.test.ts @@ -105,6 +105,18 @@ describe('mapAgentError', () => { expect(result).toMatchObject({ type: 'invalid_request', status: 400, message: 'boom' }); }); + it('falls back to responseText when the agent body is not a JSON error object', () => { + const result = mapAgentError(new AgentHttpError(400, 'oops', 'Bad things happened'), { + logger, + }); + + expect(result).toMatchObject({ + type: 'invalid_request', + status: 400, + message: 'Bad things happened', + }); + }); + it('maps an unmapped agent error name to mapping_error (500) and logs it', () => { const error = new AgentHttpError( 400, From d22a4c2565c621671adaaf3aee611d41d8d16824 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 7 Jul 2026 11:47:10 +0200 Subject: [PATCH 11/12] fix(agent-bff): coerce JSON:API string status to a number JSON:API expresses errors[0].status as a string; a string status reached BffHttpError and was rejected by isSerializableError, downgrading a mapped 4xx to a generic 500. Coerce it to a number before constructing the error. Co-Authored-By: Claude Opus 4.8 --- packages/agent-bff/src/http/agent-error-mapper.ts | 10 ++++++++-- .../agent-bff/test/http/agent-error-mapper.test.ts | 14 +++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/agent-bff/src/http/agent-error-mapper.ts b/packages/agent-bff/src/http/agent-error-mapper.ts index dcc4f20f03..37409755c0 100644 --- a/packages/agent-bff/src/http/agent-error-mapper.ts +++ b/packages/agent-bff/src/http/agent-error-mapper.ts @@ -52,10 +52,16 @@ interface AgentJsonApiError { name?: string; detail?: string; message?: string; - status?: number; + status?: number | string; data?: unknown; } +function coerceStatus(status: number | string | undefined, fallback: number): number { + const parsed = typeof status === 'string' ? Number(status) : status; + + return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : fallback; +} + function fallbackTypeByStatus(status: number): string { return FALLBACK_TYPE_BY_STATUS[status] ?? TYPE_INVALID_REQUEST; } @@ -73,7 +79,7 @@ function mapJsonApiError( fallbackStatus: number, logger: Logger, ): BffHttpError { - const status = agentError.status ?? fallbackStatus; + const status = coerceStatus(agentError.status, fallbackStatus); const message = agentError.detail ?? agentError.message ?? DEFAULT_ERROR_MESSAGE; if (agentError.name !== undefined) { diff --git a/packages/agent-bff/test/http/agent-error-mapper.test.ts b/packages/agent-bff/test/http/agent-error-mapper.test.ts index 3e7f4fefa0..85dd7e128c 100644 --- a/packages/agent-bff/test/http/agent-error-mapper.test.ts +++ b/packages/agent-bff/test/http/agent-error-mapper.test.ts @@ -6,7 +6,7 @@ function jsonApiBody(error: { name?: string; detail?: string; message?: string; - status?: number; + status?: number | string; data?: unknown; }) { return { errors: [error] }; @@ -52,6 +52,18 @@ describe('mapAgentError', () => { expect(result).toMatchObject({ type, status }); }); + it('coerces a spec-compliant string JSON:API status to a number', () => { + const error = new AgentHttpError( + 404, + jsonApiBody({ name: 'NotFoundError', status: '404', detail: 'x' }), + ); + + const result = mapAgentError(error, { logger }); + + expect(result).toMatchObject({ type: 'not_found', status: 404 }); + expect(typeof result.status).toBe('number'); + }); + it('uses the outer AgentHttpError status when the JSON:API error omits it', () => { const error = new AgentHttpError(403, jsonApiBody({ name: 'ForbiddenError', detail: 'no' })); From aefa0cdd2713a21e69feba11302c03cad4338b61 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 7 Jul 2026 17:10:01 +0200 Subject: [PATCH 12/12] feat(agent-bff): expose collection list and count with flat mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /v1/:collection/list and /count as flat REST over the agent: - list returns flat records with __forest { collection, primaryKey } and meta.countStatus "not_requested"; primaryKey is the opaque id unpacked to a typed { field: value } map via read-model key metadata. - count returns { count, countStatus }, deriving "deactivated" from the raw agent payload (meta.count) instead of the lossy Number() helper; an active empty collection is 0/available, a missing signal is a mapping_error. - Wire the nested-relation guard on list projection/filter/sort and count filter (422 relation_field_not_supported). - Resolve the collection via the read-model allow-list. Absent name maps to 404 unknown_collection; 403 collection_not_allowed has no local trigger yet (single allow-list) — see TODO and the ticket escalation. - All agent calls go through the exported HttpRequester so the BFF controls the query params (notably the resolved timezone) for both list and count. - Add schema_unavailable (503) local error and expose ReadModel.getPrimaryKeys. Fixes PRD-671 Co-Authored-By: Claude Opus 4.8 --- packages/agent-bff/src/cli-core.ts | 22 ++- .../agent-bff/src/data/agent-data-client.ts | 35 ++++ packages/agent-bff/src/data/agent-query.ts | 119 ++++++++++++ .../src/data/data-routes-middleware.ts | 103 ++++++++++ packages/agent-bff/src/data/pack-id.ts | 37 ++++ .../agent-bff/src/data/response-mappers.ts | 63 ++++++ .../agent-bff/src/http/bff-local-errors.ts | 10 +- .../agent-bff/src/read-model/read-model.ts | 20 ++ .../agent-bff/test/data/agent-query.test.ts | 91 +++++++++ .../test/data/data-routes-middleware.test.ts | 183 ++++++++++++++++++ packages/agent-bff/test/data/pack-id.test.ts | 35 ++++ .../test/data/response-mappers.test.ts | 75 +++++++ .../test/http/bff-local-errors.test.ts | 4 +- .../test/read-model/read-model.test.ts | 31 +++ 14 files changed, 819 insertions(+), 9 deletions(-) create mode 100644 packages/agent-bff/src/data/agent-data-client.ts create mode 100644 packages/agent-bff/src/data/agent-query.ts create mode 100644 packages/agent-bff/src/data/data-routes-middleware.ts create mode 100644 packages/agent-bff/src/data/pack-id.ts create mode 100644 packages/agent-bff/src/data/response-mappers.ts create mode 100644 packages/agent-bff/test/data/agent-query.test.ts create mode 100644 packages/agent-bff/test/data/data-routes-middleware.test.ts create mode 100644 packages/agent-bff/test/data/pack-id.test.ts create mode 100644 packages/agent-bff/test/data/response-mappers.test.ts diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index c391cd1738..f52258c616 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -14,6 +14,7 @@ import createAuthModeMiddleware from './auth/auth-mode-middleware'; import { parseConfig } from './config/env-config'; import createCorsMiddleware from './cors/cors-middleware'; import createPerKeyOriginMiddleware from './cors/per-key-origin'; +import createDataRoutesMiddleware from './data/data-routes-middleware'; import { extractErrorMessage } from './errors'; import { unauthorized } from './http/bff-http-error'; import BFFHttpServer from './http/bff-http-server'; @@ -22,6 +23,7 @@ import ForestServerClient from './oauth/forest-server-client'; import createOAuthRoutes from './oauth/oauth-routes'; import createInMemorySessionStore from './oauth/session-store'; import createTokenCipher from './oauth/token-cipher'; +import createReadModel from './read-model/create-read-model'; import createTimezoneMiddleware from './timezone/timezone-middleware'; import version from './version'; @@ -152,6 +154,24 @@ function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware | return createApiKeyMiddleware({ authenticator, logger }); } +function buildDataRoutesMiddleware(config: BFFConfig, logger: Logger): Middleware { + const apiKeyConfig = resolveApiKeyConfig(config); + + if (!apiKeyConfig || !config.agentUrl) { + logger('Warn', 'Data endpoints disabled: AGENT_URL or read-model configuration is missing'); + + return createAgentStubMiddleware(); + } + + const { store } = createReadModel({ + forestServerUrl: apiKeyConfig.forestServerUrl, + envSecret: apiKeyConfig.forestEnvSecret, + logger, + }); + + return createDataRoutesMiddleware({ store, agentUrl: config.agentUrl, logger }); +} + function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] { const { forestAuthSecret, defaultTimezone } = config; @@ -168,7 +188,7 @@ function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] apiKeyStep, createPerKeyOriginMiddleware(), createTimezoneMiddleware({ defaultTimezone }), - createAgentStubMiddleware(), + buildDataRoutesMiddleware(config, logger), ]; return chain.map(agentScoped); diff --git a/packages/agent-bff/src/data/agent-data-client.ts b/packages/agent-bff/src/data/agent-data-client.ts new file mode 100644 index 0000000000..db10b8050f --- /dev/null +++ b/packages/agent-bff/src/data/agent-data-client.ts @@ -0,0 +1,35 @@ +import { HttpRequester } from '@forestadmin/agent-client'; + +export interface AgentDataClientOptions { + agentUrl: string; + token: string; +} + +export interface AgentDataClient { + list(collection: string, query: Record): Promise[]>; + countRaw(collection: string, query: Record): Promise; +} + +/** + * Thin data client bound to a request's agent token. Uses the raw `HttpRequester` (not `Collection`) + * so the BFF controls the query params — notably the resolved `timezone` — and can read the count + * endpoint's raw payload, which `collection.count()` coerces through `Number()` and loses. + */ +export default function createAgentDataClient({ + agentUrl, + token, +}: AgentDataClientOptions): AgentDataClient { + const requester = new HttpRequester(token, { url: agentUrl }); + + return { + list: (collection, query) => + requester.query({ method: 'get', path: `/forest/${collection}`, query }), + countRaw: (collection, query) => + requester.query({ + method: 'get', + path: `/forest/${collection}/count`, + query, + skipDeserialization: true, + }), + }; +} diff --git a/packages/agent-bff/src/data/agent-query.ts b/packages/agent-bff/src/data/agent-query.ts new file mode 100644 index 0000000000..c261728d60 --- /dev/null +++ b/packages/agent-bff/src/data/agent-query.ts @@ -0,0 +1,119 @@ +import { invalidRequest } from '../http/bff-local-errors'; + +export interface BffSortClause { + field: string; + direction?: 'asc' | 'desc'; +} + +export interface BffPage { + limit: number; + offset: number; +} + +export interface ListRequestBody { + filter?: unknown; + projection?: string[]; + sort?: BffSortClause[]; + page?: BffPage; +} + +export interface CountRequestBody { + filter?: unknown; +} + +export type AgentQuery = Record & { timezone: string }; + +interface ConditionTreeBranch { + aggregator: string; + conditions: unknown[]; +} + +interface ConditionTreeLeaf { + field: string; +} + +function isBranch(node: unknown): node is ConditionTreeBranch { + return ( + typeof node === 'object' && + node !== null && + Array.isArray((node as { conditions?: unknown }).conditions) + ); +} + +function isLeaf(node: unknown): node is ConditionTreeLeaf { + return ( + typeof node === 'object' && + node !== null && + typeof (node as { field?: unknown }).field === 'string' + ); +} + +function collectFilterFields(filter: unknown, acc: string[]): void { + if (isBranch(filter)) { + filter.conditions.forEach(condition => collectFilterFields(condition, acc)); + } else if (isLeaf(filter)) { + acc.push(filter.field); + } +} + +function serializeSort(sort: BffSortClause[]): string { + return sort.map(({ field, direction }) => (direction === 'desc' ? `-${field}` : field)).join(','); +} + +function serializePage(page: BffPage): Record { + const { limit, offset } = page; + + if (!Number.isInteger(limit) || limit <= 0) { + throw invalidRequest(`Invalid page.limit: ${limit}`); + } + + if (!Number.isInteger(offset) || offset < 0) { + throw invalidRequest(`Invalid page.offset: ${offset}`); + } + + // The agent paginates by page number/size, so an arbitrary offset that is not a whole multiple + // of the limit cannot be expressed. Reject it rather than silently return a shifted window. + if (offset % limit !== 0) { + throw invalidRequest(`page.offset (${offset}) must be a multiple of page.limit (${limit})`); + } + + return { 'page[size]': limit, 'page[number]': offset / limit + 1 }; +} + +export function buildListAgentQuery( + collection: string, + timezone: string, + body: ListRequestBody, +): AgentQuery { + const query: AgentQuery = { timezone }; + + if (body.filter !== undefined) query.filters = JSON.stringify(body.filter); + if (body.projection?.length) query[`fields[${collection}]`] = body.projection.join(','); + if (body.sort?.length) query.sort = serializeSort(body.sort); + if (body.page) Object.assign(query, serializePage(body.page)); + + return query; +} + +export function buildCountAgentQuery(timezone: string, body: CountRequestBody): AgentQuery { + const query: AgentQuery = { timezone }; + + if (body.filter !== undefined) query.filters = JSON.stringify(body.filter); + + return query; +} + +export function collectListFieldPaths(body: ListRequestBody): string[] { + const paths: string[] = [...(body.projection ?? [])]; + collectFilterFields(body.filter, paths); + (body.sort ?? []).forEach(({ field }) => paths.push(field)); + + return paths; +} + +export function collectCountFieldPaths(body: CountRequestBody): string[] { + const paths: string[] = []; + collectFilterFields(body.filter, paths); + + return paths; +} diff --git a/packages/agent-bff/src/data/data-routes-middleware.ts b/packages/agent-bff/src/data/data-routes-middleware.ts new file mode 100644 index 0000000000..eb425bbe89 --- /dev/null +++ b/packages/agent-bff/src/data/data-routes-middleware.ts @@ -0,0 +1,103 @@ +import type { AgentDataClient, AgentDataClientOptions } from './agent-data-client'; +import type { CountRequestBody, ListRequestBody } from './agent-query'; +import type { Logger } from '../ports/logger-port'; +import type ReadModelStore from '../read-model/read-model-store'; +import type { Middleware } from 'koa'; + +import defaultCreateAgentDataClient from './agent-data-client'; +import { + buildCountAgentQuery, + buildListAgentQuery, + collectCountFieldPaths, + collectListFieldPaths, +} from './agent-query'; +import { mapCountResponse, mapListResponse } from './response-mappers'; +import { mapAgentError } from '../http/agent-error-mapper'; +import { schemaUnavailable, unknownCollection } from '../http/bff-local-errors'; +import SchemaUnavailableError from '../read-model/errors'; +import assertNoRelationFieldPaths from '../validation/relation-field-guard'; + +const DATA_ROUTE = /^\/agent\/v1\/([^/]+)\/(list|count)$/; + +export interface DataRoutesMiddlewareOptions { + store: ReadModelStore; + agentUrl: string; + logger: Logger; + createClient?: (options: AgentDataClientOptions) => AgentDataClient; +} + +async function resolveReadModel(store: ReadModelStore) { + try { + return await store.getReadModel(); + } catch (error) { + if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); + throw error; + } +} + +export default function createDataRoutesMiddleware({ + store, + agentUrl, + logger, + createClient = defaultCreateAgentDataClient, +}: DataRoutesMiddlewareOptions): Middleware { + return async function dataRoutesMiddleware(ctx, next) { + const match = DATA_ROUTE.exec(ctx.path); + + if (!match || ctx.method !== 'POST') { + await next(); + + return; + } + + const collection = decodeURIComponent(match[1]); + const operation = match[2] as 'list' | 'count'; + + const readModel = await resolveReadModel(store); + + if (!readModel.isCollectionAllowed(collection)) { + // TODO(PRD-671): the read-model exposes a single allow-list (the exposed collections), so it + // cannot tell an unknown collection from a known-but-disallowed one. Every absent name maps + // to 404 here; `collection_not_allowed` (403) has no local trigger until a distinct exposure + // source exists. Escalated on the ticket — revisit when Anthony answers. + throw unknownCollection(`Unknown collection: ${collection}`); + } + + const timezone = ctx.state.timezone as string; + const token = ctx.state.agentToken as string; + const client = createClient({ agentUrl, token }); + const body = (ctx.request.body ?? {}) as ListRequestBody & CountRequestBody; + + if (operation === 'list') { + assertNoRelationFieldPaths(collectListFieldPaths(body)); + + const query = buildListAgentQuery(collection, timezone, body); + let records: Record[]; + + try { + records = await client.list(collection, query); + } catch (error) { + throw mapAgentError(error, { logger }); + } + + ctx.status = 200; + ctx.body = mapListResponse(collection, records, readModel.getPrimaryKeys(collection)); + + return; + } + + assertNoRelationFieldPaths(collectCountFieldPaths(body)); + + const query = buildCountAgentQuery(timezone, body); + let raw: unknown; + + try { + raw = await client.countRaw(collection, query); + } catch (error) { + throw mapAgentError(error, { logger }); + } + + ctx.status = 200; + ctx.body = mapCountResponse(raw); + }; +} diff --git a/packages/agent-bff/src/data/pack-id.ts b/packages/agent-bff/src/data/pack-id.ts new file mode 100644 index 0000000000..eab7bd831c --- /dev/null +++ b/packages/agent-bff/src/data/pack-id.ts @@ -0,0 +1,37 @@ +import type { PrimaryKeyField } from '../read-model/read-model'; + +import { mappingError } from '../http/bff-local-errors'; + +const PACKED_ID_SEPARATOR = '|'; + +/** + * Rebuild the structured primary key of a record from its opaque packed id, mirroring the agent's + * `IdUtils.packId`/`unpackId` (`|`-joined values, `Number` columns cast back to numbers). Returns a + * `{ pkField: value }` map for `__forest.primaryKey`. Throws a mapping error rather than emitting a + * malformed key when the schema lacks key metadata or the packed id shape does not match it. + */ +export default function unpackPrimaryKey( + packedId: string, + primaryKeys: PrimaryKeyField[], +): Record { + if (primaryKeys.length === 0) { + throw mappingError('Cannot build primary key: the collection exposes no key metadata'); + } + + const values = packedId.split(PACKED_ID_SEPARATOR); + + if (values.length !== primaryKeys.length) { + throw mappingError( + `Cannot build primary key: expected ${primaryKeys.length} values, found ${values.length}`, + ); + } + + const result: Record = {}; + + primaryKeys.forEach(({ name, type }, index) => { + const value = values[index]; + result[name] = type === 'Number' ? Number(value) : value; + }); + + return result; +} diff --git a/packages/agent-bff/src/data/response-mappers.ts b/packages/agent-bff/src/data/response-mappers.ts new file mode 100644 index 0000000000..044d6086ea --- /dev/null +++ b/packages/agent-bff/src/data/response-mappers.ts @@ -0,0 +1,63 @@ +import type { PrimaryKeyField } from '../read-model/read-model'; + +import unpackPrimaryKey from './pack-id'; +import { mappingError } from '../http/bff-local-errors'; + +export type CountStatus = 'available' | 'deactivated' | 'not_requested'; + +export interface ForestRecordMetadata { + collection: string; + primaryKey: Record; +} + +export interface ListResponse { + data: Array & { __forest: ForestRecordMetadata }>; + meta: { countStatus: 'not_requested' }; +} + +export interface CountResponse { + count: number | null; + countStatus: 'available' | 'deactivated'; +} + +const DEACTIVATED = 'deactivated'; + +export function mapListResponse( + collection: string, + records: Record[], + primaryKeys: PrimaryKeyField[], +): ListResponse { + const data = records.map(record => { + if (record.id === undefined || record.id === null) { + throw mappingError('Agent record is missing its id'); + } + + return { + ...record, + __forest: { + collection, + primaryKey: unpackPrimaryKey(String(record.id), primaryKeys), + }, + }; + }); + + return { data, meta: { countStatus: 'not_requested' } }; +} + +export function mapCountResponse(raw: unknown): CountResponse { + const body = (typeof raw === 'object' && raw !== null ? raw : {}) as { + count?: unknown; + meta?: { count?: unknown }; + }; + + if (body.meta?.count === DEACTIVATED) { + return { count: null, countStatus: 'deactivated' }; + } + + if (typeof body.count === 'number' && Number.isFinite(body.count)) { + return { count: body.count, countStatus: 'available' }; + } + + // Neither schema metadata nor a usable raw payload: never silently report 0. + throw mappingError('Agent count payload has no numeric count and no deactivated marker'); +} diff --git a/packages/agent-bff/src/http/bff-local-errors.ts b/packages/agent-bff/src/http/bff-local-errors.ts index b8852e9530..e4d37d95df 100644 --- a/packages/agent-bff/src/http/bff-local-errors.ts +++ b/packages/agent-bff/src/http/bff-local-errors.ts @@ -28,16 +28,14 @@ export function invalidRequest(message = 'Invalid request', details?: unknown): return new BffHttpError(400, 'invalid_request', message, details); } -export function relationFieldNotSupported( - message = 'Relation field is not supported', -): BffHttpError { - return new BffHttpError(422, 'relation_field_not_supported', message); -} - export function mappingError(message = 'Failed to map the agent response'): BffHttpError { return new BffHttpError(500, 'mapping_error', message); } +export function schemaUnavailable(message = 'The agent schema is unavailable'): BffHttpError { + return new BffHttpError(503, 'schema_unavailable', message); +} + export function unsupportedActionResult(message = 'Unsupported action result'): BffHttpError { return new BffHttpError(501, 'unsupported_action_result', message); } diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts index b4b466aa01..c8ba3aa83d 100644 --- a/packages/agent-bff/src/read-model/read-model.ts +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -7,6 +7,8 @@ export type RelationTarget = | { type: RelationshipType; polymorphic: false; target: string } | { type: RelationshipType; polymorphic: true; targets: string[] }; +export type PrimaryKeyField = { name: string; type: string }; + function toRelationTarget(field: ForestSchemaField): RelationTarget | null { if (!field.relationship) return null; @@ -44,21 +46,25 @@ export default class ReadModel { private readonly collections: Set; private readonly relations: Map>; private readonly actionEndpoints: ActionEndpointsByCollection; + private readonly primaryKeys: Map; constructor(collections: ForestSchemaCollection[]) { this.collections = new Set(); this.relations = new Map(); this.actionEndpoints = {}; + this.primaryKeys = new Map(); for (const collection of collections) { this.collections.add(collection.name); this.buildRelations(collection); this.buildActionEndpoints(collection); + this.buildPrimaryKeys(collection); } // Freeze the exposed structures so a consumer cannot mutate the shared cached read-model. deepFreeze(this.actionEndpoints); this.relations.forEach(byRelation => byRelation.forEach(deepFreeze)); + this.primaryKeys.forEach(deepFreeze); } private buildRelations(collection: ForestSchemaCollection): void { @@ -92,6 +98,16 @@ export default class ReadModel { } } + private buildPrimaryKeys(collection: ForestSchemaCollection): void { + const keys: PrimaryKeyField[] = []; + + for (const field of collection.fields ?? []) { + if (field.isPrimaryKey) keys.push({ name: field.field, type: field.type }); + } + + this.primaryKeys.set(collection.name, keys); + } + isCollectionAllowed(collection: string): boolean { return this.collections.has(collection); } @@ -111,4 +127,8 @@ export default class ReadModel { getActionEndpoints(): ActionEndpointsByCollection { return this.actionEndpoints; } + + getPrimaryKeys(collection: string): PrimaryKeyField[] { + return this.primaryKeys.get(collection) ?? []; + } } diff --git a/packages/agent-bff/test/data/agent-query.test.ts b/packages/agent-bff/test/data/agent-query.test.ts new file mode 100644 index 0000000000..95c39a12dd --- /dev/null +++ b/packages/agent-bff/test/data/agent-query.test.ts @@ -0,0 +1,91 @@ +import { + buildCountAgentQuery, + buildListAgentQuery, + collectCountFieldPaths, + collectListFieldPaths, +} from '../../src/data/agent-query'; + +describe('buildListAgentQuery', () => { + it('should always pass the resolved timezone', () => { + expect(buildListAgentQuery('users', 'America/New_York', {})).toEqual({ + timezone: 'America/New_York', + }); + }); + + it('should serialize filter, projection, sort and page to agent query params', () => { + const query = buildListAgentQuery('users', 'Europe/Paris', { + filter: { field: 'email', operator: 'present' }, + projection: ['id', 'email'], + sort: [{ field: 'createdAt', direction: 'desc' }], + page: { limit: 20, offset: 40 }, + }); + + expect(query).toEqual({ + timezone: 'Europe/Paris', + filters: JSON.stringify({ field: 'email', operator: 'present' }), + 'fields[users]': 'id,email', + sort: '-createdAt', + 'page[size]': 20, + 'page[number]': 3, + }); + }); + + it('should default an unspecified sort direction to ascending', () => { + const query = buildListAgentQuery('users', 'Europe/Paris', { + sort: [{ field: 'name' }, { field: 'age', direction: 'desc' }], + }); + + expect(query.sort).toBe('name,-age'); + }); + + it('should reject an offset that is not a multiple of the limit', () => { + expect(() => + buildListAgentQuery('users', 'Europe/Paris', { page: { limit: 20, offset: 15 } }), + ).toThrow(expect.objectContaining({ type: 'invalid_request', status: 400 })); + }); + + it('should reject a non-positive limit', () => { + expect(() => + buildListAgentQuery('users', 'Europe/Paris', { page: { limit: 0, offset: 0 } }), + ).toThrow(expect.objectContaining({ type: 'invalid_request', status: 400 })); + }); +}); + +describe('buildCountAgentQuery', () => { + it('should serialize only the filter and timezone', () => { + expect( + buildCountAgentQuery('Europe/Paris', { filter: { field: 'active', operator: 'equal' } }), + ).toEqual({ + timezone: 'Europe/Paris', + filters: JSON.stringify({ field: 'active', operator: 'equal' }), + }); + }); +}); + +describe('collectListFieldPaths', () => { + it('should collect field paths from projection, filter and sort', () => { + const paths = collectListFieldPaths({ + projection: ['id', 'company:name'], + filter: { + aggregator: 'and', + conditions: [ + { field: 'email', operator: 'present' }, + { field: 'owner:email', operator: 'present' }, + ], + }, + sort: [{ field: 'author:name', direction: 'asc' }], + }); + + expect(paths).toEqual(['id', 'company:name', 'email', 'owner:email', 'author:name']); + }); +}); + +describe('collectCountFieldPaths', () => { + it('should collect field paths from the filter only', () => { + const paths = collectCountFieldPaths({ + filter: { field: 'company:name', operator: 'present' }, + }); + + expect(paths).toEqual(['company:name']); + }); +}); diff --git a/packages/agent-bff/test/data/data-routes-middleware.test.ts b/packages/agent-bff/test/data/data-routes-middleware.test.ts new file mode 100644 index 0000000000..831fb0b210 --- /dev/null +++ b/packages/agent-bff/test/data/data-routes-middleware.test.ts @@ -0,0 +1,183 @@ +import type { AgentDataClient } from '../../src/data/agent-data-client'; +import type { Logger } from '../../src/ports/logger-port'; +import type ReadModelStore from '../../src/read-model/read-model-store'; + +import { AgentHttpError } from '@forestadmin/agent-client'; +import { bodyParser } from '@koa/bodyparser'; +import Koa from 'koa'; +import request from 'supertest'; + +import createDataRoutesMiddleware from '../../src/data/data-routes-middleware'; +import createErrorMiddleware from '../../src/http/error-middleware'; +import SchemaUnavailableError from '../../src/read-model/errors'; +import ReadModel from '../../src/read-model/read-model'; +import { collection, column } from '../read-model/fixtures'; + +const TIMEZONE = 'Europe/Paris'; + +const noopLogger: Logger = () => {}; + +function storeOf(readModel: ReadModel | Error): ReadModelStore { + return { + getReadModel: async () => { + if (readModel instanceof Error) throw readModel; + + return readModel; + }, + } as unknown as ReadModelStore; +} + +function buildApp(store: ReadModelStore, client: Partial) { + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger: noopLogger })); + app.use(bodyParser()); + app.use(async (ctx, next) => { + ctx.state.timezone = TIMEZONE; + ctx.state.agentToken = 'agent-jwt'; + await next(); + }); + app.use( + createDataRoutesMiddleware({ + store, + agentUrl: 'https://agent.example.com', + logger: noopLogger, + createClient: () => client as AgentDataClient, + }), + ); + + return app; +} + +const usersReadModel = new ReadModel([collection('users', [column('id'), column('email')])]); + +describe('data routes middleware', () => { + describe('list', () => { + it('should return flat records with __forest and meta.countStatus not_requested', async () => { + const list = jest.fn(async () => [{ id: '42', email: 'user@example.com' }]); + const app = buildApp(storeOf(usersReadModel), { list }); + + const response = await request(app.callback()) + .post('/agent/v1/users/list') + .send({ projection: ['id', 'email'] }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + data: [ + { + id: '42', + email: 'user@example.com', + __forest: { collection: 'users', primaryKey: { id: '42' } }, + }, + ], + meta: { countStatus: 'not_requested' }, + }); + }); + + it('should pass the resolved timezone to the agent query', async () => { + const list = jest.fn(async () => []); + const app = buildApp(storeOf(usersReadModel), { list }); + + await request(app.callback()).post('/agent/v1/users/list').send({}); + + expect(list).toHaveBeenCalledWith('users', expect.objectContaining({ timezone: TIMEZONE })); + }); + + it.each([['projection'], ['filter'], ['sort']])( + 'should reject a nested relation path in %s with 422', + async surface => { + const bodies: Record = { + projection: { projection: ['company:name'] }, + filter: { filter: { field: 'company:name', operator: 'present' } }, + sort: { sort: [{ field: 'company:name', direction: 'asc' }] }, + }; + const list = jest.fn(async () => []); + const app = buildApp(storeOf(usersReadModel), { list }); + + const response = await request(app.callback()) + .post('/agent/v1/users/list') + .send(bodies[surface]); + + expect(response.status).toBe(422); + expect(response.body.error).toMatchObject({ + type: 'relation_field_not_supported', + status: 422, + details: { fields: ['company:name'] }, + }); + expect(list).not.toHaveBeenCalled(); + }, + ); + + it('should return 404 for an unknown collection', async () => { + const app = buildApp(storeOf(usersReadModel), { list: jest.fn() }); + + const response = await request(app.callback()).post('/agent/v1/ghosts/list').send({}); + + expect(response.status).toBe(404); + expect(response.body.error).toMatchObject({ type: 'unknown_collection', status: 404 }); + }); + + it('should map an agent failure through the error contract', async () => { + const list = jest.fn(async () => { + throw new AgentHttpError( + 404, + { errors: [{ status: 404, name: 'NotFoundError' }] }, + undefined, + ); + }); + const app = buildApp(storeOf(usersReadModel), { list }); + + const response = await request(app.callback()).post('/agent/v1/users/list').send({}); + + expect(response.status).toBe(404); + expect(response.body.error).toMatchObject({ type: 'not_found', status: 404 }); + }); + + it('should return 503 schema_unavailable when the schema cannot be loaded', async () => { + const app = buildApp(storeOf(new SchemaUnavailableError()), { list: jest.fn() }); + + const response = await request(app.callback()).post('/agent/v1/users/list').send({}); + + expect(response.status).toBe(503); + expect(response.body.error).toMatchObject({ type: 'schema_unavailable', status: 503 }); + }); + }); + + describe('count', () => { + it('should return available with the numeric count', async () => { + const countRaw = jest.fn(async () => ({ count: 7 })); + const app = buildApp(storeOf(usersReadModel), { countRaw }); + + const response = await request(app.callback()).post('/agent/v1/users/count').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ count: 7, countStatus: 'available' }); + }); + + it('should return deactivated from the raw agent payload', async () => { + const countRaw = jest.fn(async () => ({ meta: { count: 'deactivated' } })); + const app = buildApp(storeOf(usersReadModel), { countRaw }); + + const response = await request(app.callback()).post('/agent/v1/users/count').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ count: null, countStatus: 'deactivated' }); + }); + + it('should reject a nested relation path in the count filter with 422', async () => { + const countRaw = jest.fn(); + const app = buildApp(storeOf(usersReadModel), { countRaw }); + + const response = await request(app.callback()) + .post('/agent/v1/users/count') + .send({ filter: { field: 'company:name', operator: 'present' } }); + + expect(response.status).toBe(422); + expect(response.body.error).toMatchObject({ + type: 'relation_field_not_supported', + status: 422, + }); + expect(countRaw).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/agent-bff/test/data/pack-id.test.ts b/packages/agent-bff/test/data/pack-id.test.ts new file mode 100644 index 0000000000..ed1e8784bf --- /dev/null +++ b/packages/agent-bff/test/data/pack-id.test.ts @@ -0,0 +1,35 @@ +import unpackPrimaryKey from '../../src/data/pack-id'; + +describe('unpackPrimaryKey', () => { + it('should build a single numeric key from the packed id', () => { + expect(unpackPrimaryKey('42', [{ name: 'id', type: 'Number' }])).toEqual({ id: 42 }); + }); + + it('should keep a non-Number key as a string', () => { + expect(unpackPrimaryKey('a1b2', [{ name: 'uuid', type: 'Uuid' }])).toEqual({ uuid: 'a1b2' }); + }); + + it('should build a composite key casting each segment by its type', () => { + expect( + unpackPrimaryKey('7|ab', [ + { name: 'orderId', type: 'Number' }, + { name: 'sku', type: 'String' }, + ]), + ).toEqual({ orderId: 7, sku: 'ab' }); + }); + + it('should throw a 500 mapping error when the collection exposes no key metadata', () => { + expect(() => unpackPrimaryKey('42', [])).toThrow( + expect.objectContaining({ type: 'mapping_error', status: 500 }), + ); + }); + + it('should throw a 500 mapping error when segment count does not match the keys', () => { + expect(() => + unpackPrimaryKey('7', [ + { name: 'orderId', type: 'Number' }, + { name: 'sku', type: 'String' }, + ]), + ).toThrow(expect.objectContaining({ type: 'mapping_error', status: 500 })); + }); +}); diff --git a/packages/agent-bff/test/data/response-mappers.test.ts b/packages/agent-bff/test/data/response-mappers.test.ts new file mode 100644 index 0000000000..8c437b926e --- /dev/null +++ b/packages/agent-bff/test/data/response-mappers.test.ts @@ -0,0 +1,75 @@ +import { mapCountResponse, mapListResponse } from '../../src/data/response-mappers'; + +describe('mapListResponse', () => { + it('should return flat records with __forest metadata and countStatus not_requested', () => { + const result = mapListResponse( + 'users', + [{ id: '42', email: 'user@example.com' }], + [{ name: 'id', type: 'Number' }], + ); + + expect(result).toEqual({ + data: [ + { + id: '42', + email: 'user@example.com', + __forest: { collection: 'users', primaryKey: { id: 42 } }, + }, + ], + meta: { countStatus: 'not_requested' }, + }); + }); + + it('should return the packed composite id unchanged and expose the structured primaryKey', () => { + const result = mapListResponse( + 'orderLines', + [{ id: '7|ab', label: 'Widget' }], + [ + { name: 'orderId', type: 'Number' }, + { name: 'sku', type: 'String' }, + ], + ); + + expect(result.data[0]).toEqual( + expect.objectContaining({ + id: '7|ab', + __forest: { collection: 'orderLines', primaryKey: { orderId: 7, sku: 'ab' } }, + }), + ); + }); + + it('should throw a mapping error when a record has no id', () => { + expect(() => + mapListResponse('users', [{ email: 'x' }], [{ name: 'id', type: 'Number' }]), + ).toThrow(expect.objectContaining({ type: 'mapping_error', status: 500 })); + }); +}); + +describe('mapCountResponse', () => { + it('should report available with the numeric count', () => { + expect(mapCountResponse({ count: 42 })).toEqual({ count: 42, countStatus: 'available' }); + }); + + it('should report available with zero for an active empty collection', () => { + expect(mapCountResponse({ count: 0 })).toEqual({ count: 0, countStatus: 'available' }); + }); + + it('should report deactivated from the raw meta marker', () => { + expect(mapCountResponse({ meta: { count: 'deactivated' } })).toEqual({ + count: null, + countStatus: 'deactivated', + }); + }); + + it('should throw a mapping error when neither a numeric count nor the marker is present', () => { + expect(() => mapCountResponse({})).toThrow( + expect.objectContaining({ type: 'mapping_error', status: 500 }), + ); + }); + + it('should not trust a NaN count from the lossy helper', () => { + expect(() => mapCountResponse({ count: Number.NaN })).toThrow( + expect.objectContaining({ type: 'mapping_error', status: 500 }), + ); + }); +}); diff --git a/packages/agent-bff/test/http/bff-local-errors.test.ts b/packages/agent-bff/test/http/bff-local-errors.test.ts index 1a1930c41a..48f41f3cf1 100644 --- a/packages/agent-bff/test/http/bff-local-errors.test.ts +++ b/packages/agent-bff/test/http/bff-local-errors.test.ts @@ -3,8 +3,8 @@ import { collectionNotAllowed, invalidRequest, mappingError, - relationFieldNotSupported, relationNotAllowed, + schemaUnavailable, unknownAction, unknownCollection, unknownRelation, @@ -20,8 +20,8 @@ describe('bff local errors', () => { [relationNotAllowed, 'relation_not_allowed', 403], [actionNotAllowed, 'action_not_allowed', 403], [invalidRequest, 'invalid_request', 400], - [relationFieldNotSupported, 'relation_field_not_supported', 422], [mappingError, 'mapping_error', 500], + [schemaUnavailable, 'schema_unavailable', 503], [unsupportedActionResult, 'unsupported_action_result', 501], ])('%p builds a %s error with status %d', (factory, type, status) => { expect(factory()).toMatchObject({ type, status }); diff --git a/packages/agent-bff/test/read-model/read-model.test.ts b/packages/agent-bff/test/read-model/read-model.test.ts index 242bee7c58..c8e7afcc22 100644 --- a/packages/agent-bff/test/read-model/read-model.test.ts +++ b/packages/agent-bff/test/read-model/read-model.test.ts @@ -190,4 +190,35 @@ describe('ReadModel', () => { expect(model.isActionAllowed('ghost', 'x')).toBe(false); }); }); + + describe('primary keys', () => { + it('should expose the primary key field name and type', () => { + const model = new ReadModel([ + collection('users', [{ ...column('id'), type: 'Number', isPrimaryKey: true }]), + ]); + + expect(model.getPrimaryKeys('users')).toEqual([{ name: 'id', type: 'Number' }]); + }); + + it('should expose composite keys in schema field order', () => { + const model = new ReadModel([ + collection('orderLines', [ + { ...column('orderId'), type: 'Number', isPrimaryKey: true }, + { ...column('sku'), type: 'String', isPrimaryKey: true }, + { ...column('label'), isPrimaryKey: false }, + ]), + ]); + + expect(model.getPrimaryKeys('orderLines')).toEqual([ + { name: 'orderId', type: 'Number' }, + { name: 'sku', type: 'String' }, + ]); + }); + + it('should return an empty array for an unknown collection', () => { + const model = new ReadModel([collection('users', [])]); + + expect(model.getPrimaryKeys('ghost')).toEqual([]); + }); + }); });