From 790611e760ab5c54ec9360de379db1f97b9f52b1 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 2 Jul 2026 17:11:19 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6486f7ec79d6870ebe5f8c09e01bbb8e80cba919 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 7 Jul 2026 16:21:53 +0200 Subject: [PATCH 6/8] fix(agent-bff): default action hooks/fields when a malformed schema omits them The schema is cast from Record, so an action can lack hooks or fields at runtime despite the declared type. Building the endpoint map then threw in ReadModel, outside the schema-cache try/catch, wedging every collection's allow-list for the 24h TTL. Default them defensively. Co-Authored-By: Claude Opus 4.8 --- .../agent-bff/src/read-model/read-model.ts | 8 +++++--- .../test/read-model/read-model.test.ts | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts index b4b466aa01..10c3a00e4c 100644 --- a/packages/agent-bff/src/read-model/read-model.ts +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -81,13 +81,15 @@ 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. + // Copy fields/hooks so a mutating consumer can't corrupt the shared cached schema, and + // default them defensively: the schema is cast from `Record`, so a + // malformed action could omit them at runtime despite the declared type. this.actionEndpoints[collection.name][action.name] = { id: action.id, name: action.name, endpoint: action.endpoint, - hooks: { ...action.hooks, change: [...action.hooks.change] }, - fields: action.fields.map(field => ({ ...field })), + hooks: { load: action.hooks?.load ?? false, change: [...(action.hooks?.change ?? [])] }, + fields: (action.fields ?? []).map(field => ({ ...field })), }; } } 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..da1e46fdd7 100644 --- a/packages/agent-bff/test/read-model/read-model.test.ts +++ b/packages/agent-bff/test/read-model/read-model.test.ts @@ -1,4 +1,4 @@ -import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; +import type { ForestSchemaAction, ForestSchemaCollection } from '@forestadmin/forestadmin-client'; import { action, collection, column, polymorphic, relation } from './fixtures'; import ReadModel from '../../src/read-model/read-model'; @@ -173,6 +173,22 @@ describe('ReadModel', () => { }).toThrow(); }); + it('should default hooks and fields when a malformed action omits them', () => { + const malformed = { + id: 'ban-id', + name: 'ban', + type: 'single', + endpoint: '/forest/users/actions/ban', + download: false, + } as unknown as ForestSchemaAction; + const model = new ReadModel([collection('users', [], [malformed])]); + + const stored = model.getActionEndpoints().users.ban; + + expect(stored.hooks).toEqual({ load: false, change: [] }); + expect(stored.fields).toEqual([]); + }); + it('should not throw on a malformed collection with no fields key', () => { const model = new ReadModel([{ name: 'weird' } as unknown as ForestSchemaCollection]); From 2acfc837cbd4150120b335be4322526d89029783 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 7 Jul 2026 16:36:33 +0200 Subject: [PATCH 7/8] fix(agent-bff): null-proto action map and crash-safe resolver metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build the action-endpoint map with `Object.create(null)` so an action named like an Object.prototype member (`toString`, `constructor`, `__proto__`, …) can't falsely pass the allow-list. - Wrap the resolver's metric emissions so a throwing Metrics backend can't break its documented "never throws" contract. Co-Authored-By: Claude Opus 4.8 --- .../read-model/action-endpoint-resolver.ts | 13 ++++++++-- .../agent-bff/src/read-model/read-model.ts | 6 +++-- .../action-endpoint-resolver.test.ts | 24 +++++++++++++++++++ .../test/read-model/read-model.test.ts | 10 ++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/agent-bff/src/read-model/action-endpoint-resolver.ts b/packages/agent-bff/src/read-model/action-endpoint-resolver.ts index cc91e1afeb..c2051b7815 100644 --- a/packages/agent-bff/src/read-model/action-endpoint-resolver.ts +++ b/packages/agent-bff/src/read-model/action-endpoint-resolver.ts @@ -37,7 +37,7 @@ export default class ActionEndpointResolver { try { readModel = await this.getReadModel(); } catch { - this.metrics.increment(ACTION_ENDPOINT_ERROR, tags); + this.safeIncrement(ACTION_ENDPOINT_ERROR, tags); return undefined; } @@ -45,11 +45,20 @@ export default class ActionEndpointResolver { const info = readModel.getActionEndpoints()[collection]?.[action]; if (!info) { - this.metrics.increment(ACTION_ENDPOINT_MISS, tags); + this.safeIncrement(ACTION_ENDPOINT_MISS, tags); return undefined; } return info; } + + // Metrics is a user-supplied port; a throwing backend must not break the "never throws" contract. + private safeIncrement(name: string, tags: Record): void { + try { + this.metrics.increment(name, tags); + } catch { + // Swallow: emitting a metric must never turn a missing action into a request failure. + } + } } diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts index 10c3a00e4c..98f87bec32 100644 --- a/packages/agent-bff/src/read-model/read-model.ts +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -48,7 +48,9 @@ export default class ReadModel { constructor(collections: ForestSchemaCollection[]) { this.collections = new Set(); this.relations = new Map(); - this.actionEndpoints = {}; + // Null-prototype so an action/collection named like an Object.prototype member + // (`toString`, `constructor`, `__proto__`, …) can't falsely pass the allow-list. + this.actionEndpoints = Object.create(null) as ActionEndpointsByCollection; for (const collection of collections) { this.collections.add(collection.name); @@ -78,7 +80,7 @@ export default class ReadModel { if (withEndpoint.length === 0) return; - this.actionEndpoints[collection.name] = {}; + this.actionEndpoints[collection.name] = Object.create(null); for (const action of withEndpoint) { // Copy fields/hooks so a mutating consumer can't corrupt the shared cached schema, and 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 index 5e574bacce..e9bfafe7d0 100644 --- a/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts +++ b/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts @@ -64,4 +64,28 @@ describe('ActionEndpointResolver', () => { }); }); }); + + describe('when the metrics backend throws', () => { + const throwingMetrics: Metrics = { + increment: () => { + throw new Error('metrics backend down'); + }, + gauge: jest.fn(), + }; + + it('should still return undefined on a miss (never throws)', async () => { + const model = modelWith([action('ban', '/forest/users/actions/ban')]); + const resolver = new ActionEndpointResolver(async () => model, throwingMetrics); + + await expect(resolver.resolve('users', 'unknown', { rendering: 7 })).resolves.toBeUndefined(); + }); + + it('should still return undefined on the error path (never throws)', async () => { + const resolver = new ActionEndpointResolver(async () => { + throw new SchemaUnavailableError(); + }, throwingMetrics); + + await expect(resolver.resolve('users', 'ban', { rendering: 7 })).resolves.toBeUndefined(); + }); + }); }); 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 da1e46fdd7..42390a9960 100644 --- a/packages/agent-bff/test/read-model/read-model.test.ts +++ b/packages/agent-bff/test/read-model/read-model.test.ts @@ -189,6 +189,16 @@ describe('ReadModel', () => { expect(stored.fields).toEqual([]); }); + it('should not allow an action named like an Object.prototype member', () => { + const model = new ReadModel([ + collection('users', [], [action('ban', '/forest/users/actions/ban')]), + ]); + + expect(model.isActionAllowed('users', 'constructor')).toBe(false); + expect(model.isActionAllowed('users', 'toString')).toBe(false); + expect(model.isActionAllowed('users', 'hasOwnProperty')).toBe(false); + }); + it('should not throw on a malformed collection with no fields key', () => { const model = new ReadModel([{ name: 'weird' } as unknown as ForestSchemaCollection]); From 82f1155cd80e15706901f3ce233b5c638c586ee2 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 7 Jul 2026 17:32:12 +0200 Subject: [PATCH 8/8] refactor(agent-bff): guard all metrics emissions with a safeMetrics wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the metrics port once in createReadModel so a throwing backend can never break business logic anywhere in the bundle — including SchemaCache.emitAge on the cache-hit and refresh-success paths, the worse exposure the resolver-local guard missed. Drop the resolver's safeIncrement in favour of the single wrapper. Co-Authored-By: Claude Opus 4.8 --- .../agent-bff/src/adapters/safe-metrics.ts | 25 ++++++++++++ .../read-model/action-endpoint-resolver.ts | 13 +------ .../src/read-model/create-read-model.ts | 4 +- .../test/adapters/safe-metrics.test.ts | 38 +++++++++++++++++++ .../action-endpoint-resolver.test.ts | 24 ------------ .../test/read-model/create-read-model.test.ts | 22 +++++++++++ 6 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 packages/agent-bff/src/adapters/safe-metrics.ts create mode 100644 packages/agent-bff/test/adapters/safe-metrics.test.ts diff --git a/packages/agent-bff/src/adapters/safe-metrics.ts b/packages/agent-bff/src/adapters/safe-metrics.ts new file mode 100644 index 0000000000..762b479e5e --- /dev/null +++ b/packages/agent-bff/src/adapters/safe-metrics.ts @@ -0,0 +1,25 @@ +import type { MetricTags, Metrics } from '../ports/metrics-port'; + +/** + * Wraps a Metrics port so emitting a metric can never throw into business logic. Metrics is a + * user-supplied implementation; a backend that throws must not break a request (e.g. a throwing + * gauge on the schema cache-hit path would otherwise degrade every read). + */ +export default function safeMetrics(metrics: Metrics): Metrics { + return { + increment(name: string, tags?: MetricTags): void { + try { + metrics.increment(name, tags); + } catch { + // Swallow: a metrics-backend failure must never break business logic. + } + }, + gauge(name: string, value: number, tags?: MetricTags): void { + try { + metrics.gauge(name, value, tags); + } catch { + // Swallow: a metrics-backend failure must never break business logic. + } + }, + }; +} diff --git a/packages/agent-bff/src/read-model/action-endpoint-resolver.ts b/packages/agent-bff/src/read-model/action-endpoint-resolver.ts index c2051b7815..cc91e1afeb 100644 --- a/packages/agent-bff/src/read-model/action-endpoint-resolver.ts +++ b/packages/agent-bff/src/read-model/action-endpoint-resolver.ts @@ -37,7 +37,7 @@ export default class ActionEndpointResolver { try { readModel = await this.getReadModel(); } catch { - this.safeIncrement(ACTION_ENDPOINT_ERROR, tags); + this.metrics.increment(ACTION_ENDPOINT_ERROR, tags); return undefined; } @@ -45,20 +45,11 @@ export default class ActionEndpointResolver { const info = readModel.getActionEndpoints()[collection]?.[action]; if (!info) { - this.safeIncrement(ACTION_ENDPOINT_MISS, tags); + this.metrics.increment(ACTION_ENDPOINT_MISS, tags); return undefined; } return info; } - - // Metrics is a user-supplied port; a throwing backend must not break the "never throws" contract. - private safeIncrement(name: string, tags: Record): void { - try { - this.metrics.increment(name, tags); - } catch { - // Swallow: emitting a metric must never turn a missing action into a request failure. - } - } } diff --git a/packages/agent-bff/src/read-model/create-read-model.ts b/packages/agent-bff/src/read-model/create-read-model.ts index 1aa1b64548..45c3aec4c4 100644 --- a/packages/agent-bff/src/read-model/create-read-model.ts +++ b/packages/agent-bff/src/read-model/create-read-model.ts @@ -7,6 +7,7 @@ import ForestSchemaClient from './forest-schema-client'; import ReadModelStore from './read-model-store'; import SchemaCache from './schema-cache'; import createConsoleMetrics from '../adapters/console-metrics'; +import safeMetrics from '../adapters/safe-metrics'; export interface CreateReadModelOptions { forestServerUrl: string; @@ -28,7 +29,8 @@ export default function createReadModel({ logger, now, }: CreateReadModelOptions): ReadModelBundle { - const resolvedMetrics = metrics ?? createConsoleMetrics(logger); + // Wrap once so a throwing metrics backend can never break business logic anywhere in the bundle. + const resolvedMetrics = safeMetrics(metrics ?? createConsoleMetrics(logger)); const fetcher = new ForestSchemaClient({ forestServerUrl, envSecret }); const schemaCache = new SchemaCache({ fetcher, metrics: resolvedMetrics, now }); const capabilitiesCache = new CapabilitiesCache({ now }); diff --git a/packages/agent-bff/test/adapters/safe-metrics.test.ts b/packages/agent-bff/test/adapters/safe-metrics.test.ts new file mode 100644 index 0000000000..e122a6688c --- /dev/null +++ b/packages/agent-bff/test/adapters/safe-metrics.test.ts @@ -0,0 +1,38 @@ +import type { Metrics } from '../../src/ports/metrics-port'; + +import safeMetrics from '../../src/adapters/safe-metrics'; + +describe('safeMetrics', () => { + it('should delegate increment and gauge to the wrapped metrics', () => { + const inner: jest.Mocked = { increment: jest.fn(), gauge: jest.fn() }; + const metrics = safeMetrics(inner); + + metrics.increment('a', { collection: 'users' }); + metrics.gauge('b', 42, { rendering: 7 }); + + expect(inner.increment).toHaveBeenCalledWith('a', { collection: 'users' }); + expect(inner.gauge).toHaveBeenCalledWith('b', 42, { rendering: 7 }); + }); + + it('should swallow errors thrown by the wrapped increment', () => { + const metrics = safeMetrics({ + increment: () => { + throw new Error('backend down'); + }, + gauge: jest.fn(), + }); + + expect(() => metrics.increment('a')).not.toThrow(); + }); + + it('should swallow errors thrown by the wrapped gauge', () => { + const metrics = safeMetrics({ + increment: jest.fn(), + gauge: () => { + throw new Error('backend down'); + }, + }); + + expect(() => metrics.gauge('b', 1)).not.toThrow(); + }); +}); 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 index e9bfafe7d0..5e574bacce 100644 --- a/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts +++ b/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts @@ -64,28 +64,4 @@ describe('ActionEndpointResolver', () => { }); }); }); - - describe('when the metrics backend throws', () => { - const throwingMetrics: Metrics = { - increment: () => { - throw new Error('metrics backend down'); - }, - gauge: jest.fn(), - }; - - it('should still return undefined on a miss (never throws)', async () => { - const model = modelWith([action('ban', '/forest/users/actions/ban')]); - const resolver = new ActionEndpointResolver(async () => model, throwingMetrics); - - await expect(resolver.resolve('users', 'unknown', { rendering: 7 })).resolves.toBeUndefined(); - }); - - it('should still return undefined on the error path (never throws)', async () => { - const resolver = new ActionEndpointResolver(async () => { - throw new SchemaUnavailableError(); - }, throwingMetrics); - - await expect(resolver.resolve('users', 'ban', { rendering: 7 })).resolves.toBeUndefined(); - }); - }); }); 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 6827492995..0687b070c2 100644 --- a/packages/agent-bff/test/read-model/create-read-model.test.ts +++ b/packages/agent-bff/test/read-model/create-read-model.test.ts @@ -80,4 +80,26 @@ describe('createReadModel', () => { info.mockRestore(); } }); + + it('should not let a throwing metrics backend break the read-model', async () => { + const throwing = { + increment: () => { + throw new Error('backend down'); + }, + gauge: () => { + throw new Error('backend down'); + }, + }; + const { store, actionEndpointResolver } = createReadModel({ + forestServerUrl: 'x', + envSecret: 'y', + metrics: throwing, + }); + + await expect(store.getReadModel()).resolves.toBeDefined(); // cold fetch emits the age gauge + await expect(store.getReadModel()).resolves.toBeDefined(); // cache hit re-emits the age gauge + await expect( + actionEndpointResolver.resolve('users', 'missing', { rendering: 1 }), + ).resolves.toBeUndefined(); // miss emits the counter + }); });