From b49d46e5c379b60e919c9c59183c7370cb1e6f21 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Wed, 2 Sep 2026 00:17:52 +0200 Subject: [PATCH 1/6] docs(agent): document the embedded BFF and the mount order it needs The BFF README described a standalone deployment only, and the agent had no README at all. Both now cover `addBff()`: what it inherits from the agent, what the `/health` features mean, and the four ways the embedded mode differs from the standalone one. The registration order is documented because it is not obvious and it fails hard: an Express body parser registered before the mount consumes the request stream, and every BFF POST then answers 500 stream.not.readable. The e2e suite pins both directions, so the contract stops being folklore. Co-Authored-By: Claude Opus 5 (1M context) --- packages/_example/package.json | 1 + packages/_example/src/forest/agent.ts | 13 ++++ packages/agent-bff/README.md | 71 +++++++++++++++++-- packages/agent/README.md | 48 +++++++++++++ .../agent/test/bff/embedded-bff.e2e.test.ts | 71 +++++++++++++++++++ 5 files changed, 199 insertions(+), 5 deletions(-) diff --git a/packages/_example/package.json b/packages/_example/package.json index 7ef3299e8a..4fba96cccc 100644 --- a/packages/_example/package.json +++ b/packages/_example/package.json @@ -6,6 +6,7 @@ "dependencies": { "@faker-js/faker": "^7.6.0", "@forestadmin/agent": "*", + "@forestadmin/agent-bff": "1.23.4", "@forestadmin/datasource-dummy": "*", "@forestadmin/datasource-mongo": "*", "@forestadmin/datasource-mongoose": "*", diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index 9214c69a50..0282e67306 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -46,6 +46,11 @@ export default function makeAgent() { .filter(Boolean) : undefined; + const bffAllowedOrigins = (process.env.BFF_ALLOWED_ORIGINS ?? '') + .split(',') + .map(origin => origin.trim()) + .filter(Boolean); + return createAgent(envOptions) .addDataSource(createSqlDataSource({ dialect: 'sqlite', storage: './assets/db.sqlite' })) @@ -97,6 +102,14 @@ export default function makeAgent() { ...(allowedOAuthClients && { allowedOAuthClients }), }) + // Serves the BFF at /bff on every port this agent is mounted on, in-process. Without + // `allowedOrigins` no browser can call it, which for a backend-for-frontend is a mistake the + // BFF warns about at startup. + .addBff({ + allowedOrigins: bffAllowedOrigins, + tokenEncryptionKey: process.env.BFF_TOKEN_ENCRYPTION_KEY, + }) + .customizeCollection('card', customizeCard) .customizeCollection('account', customizeAccount) .customizeCollection('owner', customizeOwner) diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index dbce76f258..93bd71f227 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -33,6 +33,9 @@ dropped collection reachable. ## Usage +Two ways to run it: embedded in a Forest agent (`agent.addBff()`, see +[Embedded in an agent](#embedded-in-an-agent)) or standalone, described here. + Packaged / production — run the bin: ```bash @@ -230,11 +233,69 @@ with `session_expired`. Horizontal scaling requires both a shared session store ```jsonc // 200 — all required config present -{ "status": "ok", "version": "" } +{ + "status": "ok", + "version": "", + "features": { "oauth": true, "ai": true, "cors": true, "openapi": true } +} // 503 — one or more required keys missing -{ "status": "degraded", "version": "" } +{ "status": "degraded", "version": "", "features": { /* … */ } } +``` + +`features` says what this deployment actually serves. They form a chain, not four independent +switches: + +| Feature | Switched on by | +| --- | --- | +| `oauth` | `BFF_TOKEN_ENCRYPTION_KEY` (`tokenEncryptionKey` when embedded) | +| `ai` | `oauth` — the relay needs a session, and only the OAuth flow creates one | +| `cors` | a non-empty `BFF_ALLOWED_ORIGINS` (`allowedOrigins`) | +| `openapi` | `BFF_OPENAPI_ENABLED` (`openapiEnabled`), and a mounted agent edge | + +The body still never discloses which config *keys* are present or missing — that would leak the +internal config surface to an unauthenticated probe. It reports what is served, not how it was +configured. Missing keys are logged once at startup (`Warn`) for operators. Every response carries +the `X-Forest-Bff-Version` header, read from `package.json`. + +## Embedded in an agent + +The same BFF runs inside a Forest agent, with no second deployment and no second port: + +```ts +createAgent(options) + .addDataSource(/* … */) + .addBff({ allowedOrigins: ['https://my-app.com'] }) + .start(); ``` -The body never discloses which config keys are present or missing — that would leak the internal -config surface to an unauthenticated probe. Missing keys are logged once at startup (`Warn`) for -operators. Every response carries the `X-Forest-Bff-Version` header, read from `package.json`. +It answers under `/bff` on the agent's own port — `/bff/agent/v1/{collection}/list`, +`/bff/health`, `/bff/oauth/*` — so the REST contract is the one documented above and only the base +url changes. A client generated from the OpenAPI document stays portable: the document's `servers` +entry carries the prefix. + +What differs from the standalone deployment: + +| | Standalone | Embedded | +| --- | --- | --- | +| Configuration | environment variables | `addBff()` options; the secrets, the Forest urls and the logger are inherited from the agent and cannot be overridden | +| `AGENT_URL` | required, an http(s) url | gone — the BFF reaches the agent in the same process, without a socket | +| `HTTP_PORT` | its own listener | gone — the agent's port serves it | +| `openapiEnabled` | `true` | `false`. The document is not filtered per caller, so adding a BFF must not silently publish every collection and field name on an already-open port | +| `/health` | 503 until every required key is set | always 200: everything required is inherited, so there is no gap to report. Read `features` | + +**Registration order matters on Express and Connect-style hosts.** Mount the agent *before* any +body parser of your own: + +```ts +const app = express(); +agent.mountOnExpress(app); // first +app.use(express.json()); // then yours +``` + +A body parser that runs first has already consumed the request stream, and nothing downstream can +put it back: every BFF `POST` then answers `500 stream.not.readable`. The same applies to a +permissive `cors()` registered ahead of the mount — it answers the preflight itself, and the BFF's +strict allow-list never gets a say. + +When to prefer which: embedded for a single deployment, which is most of them. Standalone when +several agents share one BFF, or when the BFF and the agent have to scale separately. diff --git a/packages/agent/README.md b/packages/agent/README.md index e69de29bb2..dd422d3dee 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -0,0 +1,48 @@ +# @forestadmin/agent + +The main entry point of the Forest Admin Node.js SDK: `createAgent(options)` returns an agent to +which you attach datasources, customizations, charts and plugins, then mount on your HTTP server. + +See the [developer guide](https://docs.forestadmin.com/developer-guide-agents-nodejs) for the full +documentation. + +## Optional in-process components + +Two components normally deployed on their own can run inside the agent instead. Both are optional +packages, loaded dynamically: an agent that does not use them never loads their code. + +### A BFF — `agent.addBff()` + +```bash +npm install @forestadmin/agent-bff +``` + +```ts +createAgent(options) + .addDataSource(/* … */) + .addBff({ allowedOrigins: ['https://my-app.com'] }) + .start(); +``` + +Serves the REST BFF at `/bff` on the agent's own port, on every mount target. It reaches the agent +in the same process, so there is no second port, no agent url to configure, and no secrets to keep +in sync — `authSecret`, `envSecret`, the Forest urls and the logger are inherited. + +Everything `addBff()` takes is a feature it switches on: `tokenEncryptionKey` enables OAuth (and +with it the AI relay), `allowedOrigins` enables browser access, `openapiEnabled` serves the docs +(off by default when embedded). `GET /bff/health` reports which of them are on. + +**Mount the agent before any body parser of your own** — a parser that runs first consumes the +request stream, and every BFF `POST` then answers `500 stream.not.readable`. + +See [`@forestadmin/agent-bff`](../agent-bff/README.md) for the routes, the auth modes and the +differences with the standalone deployment. + +### A workflow executor — `agent.addWorkflowExecutor()` + +```bash +npm install @forestadmin/workflow-executor +``` + +Runs a workflow executor alongside the agent, which proxies `/_internal/executor/*` to it. See +[`@forestadmin/workflow-executor`](../workflow-executor/README.md). diff --git a/packages/agent/test/bff/embedded-bff.e2e.test.ts b/packages/agent/test/bff/embedded-bff.e2e.test.ts index c3168f6e03..f62bf81c71 100644 --- a/packages/agent/test/bff/embedded-bff.e2e.test.ts +++ b/packages/agent/test/bff/embedded-bff.e2e.test.ts @@ -555,4 +555,75 @@ describe('embedded BFF', () => { BOOT_TIMEOUT_MS, ); }); + + describe('the host registration order', () => { + async function startAgentOn(hostApp: express.Express, mountFirst: boolean) { + const orderAgent = new Agent({ + authSecret: AUTH_SECRET, + envSecret: ENV_SECRET, + forestServerUrl: 'https://api.forestadmin.com', + forestAppUrl: 'https://hostApp.forestadmin.com', + isProduction: false, + schemaPath: path.join(tmpdir(), `.forestadmin-schema-bff-order-${Date.now()}.json`), + logger: () => undefined, + }) + .addDataSource(async () => new SearchDataSource()) + .addBff({}); + + if (mountFirst) orderAgent.mountOnExpress(hostApp); + hostApp.use(express.json()); + if (!mountFirst) orderAgent.mountOnExpress(hostApp); + + await orderAgent.start(); + + return orderAgent; + } + + function listBooks(hostApp: express.Express) { + return supertest(hostApp) + .post('/bff/agent/v1/books/list') + .set('Authorization', `Bearer ${sessionToken()}`) + .set('X-Forest-Timezone', 'Europe/Paris') + .send({ projection: ['id', 'title'], search: 'foundation' }); + } + + it( + 'should serve normally when the agent is mounted before the host body parser', + async () => { + const hostApp = express(); + const orderAgent = await startAgentOn(hostApp, true); + + try { + const response = await listBooks(hostApp); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + } finally { + await orderAgent.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); + + // Pinned rather than fixed: a body parser that ran first has already consumed the stream, and + // nothing downstream can put it back. Failing loudly beats serving a request whose filters, + // projection and search silently went missing. + it( + 'should fail loudly when a host body parser consumed the stream first', + async () => { + const hostApp = express(); + const orderAgent = await startAgentOn(hostApp, false); + + try { + const response = await listBooks(hostApp); + + expect(response.status).toBe(500); + expect(response.body.error).toMatchObject({ type: 'stream.not.readable' }); + } finally { + await orderAgent.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); + }); }); From ce4c17dffc03801b5b82444c3c93b1e92c415c3a Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Wed, 2 Sep 2026 17:01:38 +0200 Subject: [PATCH 2/6] docs(agent): fix the mountless quick-starts and pin the cors hazard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every quick-start ended on start(), which only builds the agent's router: no mountOn* call means no socket, so a reader copying the snippet got nothing on /bff while the prose promised a port. Fixed in both READMEs and in addBff()'s JSDoc, which is where the READMEs got it. Three claims were wrong and are now what the code does: `oauth` needs the four Forest keys as well as the encryption key; the embedded /health answers 200 because the in-process dispatcher short-circuits the readiness check, not because everything required is inherited (tokenEncryptionKey is not); and only the served OpenAPI document carries the /bff prefix — `forest-bff openapi` cannot be told about one and always emits a root server. Three hazards a host can walk into were undocumented: a timeout is not a cancellation, so an action cut at agentTimeoutMs still applies its mutation and a retry doubles it; /bff/* answers 503 bff_not_started around start/stop; and addBff() with mountAiMcpServer({ basePath: '/bff' }) throws at boot. The /bff prefix being fixed also makes a sub-router mount silently wrong, so that is now stated rather than left to be discovered. The cors() half of the registration contract was documented but never tested, and it is the half that quietly disables a security control instead of failing loud. Two cases now pin it, in the same shape as the body-parser pair. The example unpins agent-bff, which no release rewrites for a private package, and its env template gains the two BFF variables it reads. Co-Authored-By: Claude Opus 5 (1M context) --- packages/_example/.env.example | 7 ++ packages/_example/package.json | 2 +- packages/agent-bff/README.md | 43 +++++++-- packages/agent/README.md | 31 +++++-- packages/agent/src/agent.ts | 6 +- .../agent/test/bff/embedded-bff.e2e.test.ts | 87 ++++++++++++++++++- 6 files changed, 157 insertions(+), 19 deletions(-) diff --git a/packages/_example/.env.example b/packages/_example/.env.example index 38b7a2b7fb..369f045554 100644 --- a/packages/_example/.env.example +++ b/packages/_example/.env.example @@ -16,6 +16,13 @@ FOREST_AUTH_SECRET= # MCP OAuth client allowlist (comma-separated domains, e.g. dust.tt); unset allows any registered client # FOREST_MCP_ALLOWED_OAUTH_CLIENTS= +# Embedded BFF, served at /bff on every port above. +# Browser origins allowed to call it: comma-separated, exact origins, no wildcard. Empty means no +# browser can reach it — put your frontend's origin here, e.g. http://localhost:4200 +BFF_ALLOWED_ORIGINS= +# Enables the BFF's OAuth flow, and with it the AI relay (openssl rand -hex 32) +BFF_TOKEN_ENCRYPTION_KEY= + # Production # FOREST_ENV_SECRET= # FOREST_AUTH_SECRET= diff --git a/packages/_example/package.json b/packages/_example/package.json index 4fba96cccc..63641e8d4d 100644 --- a/packages/_example/package.json +++ b/packages/_example/package.json @@ -6,7 +6,7 @@ "dependencies": { "@faker-js/faker": "^7.6.0", "@forestadmin/agent": "*", - "@forestadmin/agent-bff": "1.23.4", + "@forestadmin/agent-bff": "*", "@forestadmin/datasource-dummy": "*", "@forestadmin/datasource-mongo": "*", "@forestadmin/datasource-mongoose": "*", diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index 93bd71f227..8610c5cca1 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -247,31 +247,45 @@ switches: | Feature | Switched on by | | --- | --- | -| `oauth` | `BFF_TOKEN_ENCRYPTION_KEY` (`tokenEncryptionKey` when embedded) | +| `oauth` | `BFF_TOKEN_ENCRYPTION_KEY` **and** `FOREST_SERVER_URL`, `FOREST_ENV_SECRET`, `FOREST_APP_URL`, `FOREST_AUTH_SECRET` — the routes need a Forest server to talk to as much as a key. Embedded, only `tokenEncryptionKey` is yours to set: the other four are inherited | | `ai` | `oauth` — the relay needs a session, and only the OAuth flow creates one | | `cors` | a non-empty `BFF_ALLOWED_ORIGINS` (`allowedOrigins`) | | `openapi` | `BFF_OPENAPI_ENABLED` (`openapiEnabled`), and a mounted agent edge | The body still never discloses which config *keys* are present or missing — that would leak the internal config surface to an unauthenticated probe. It reports what is served, not how it was -configured. Missing keys are logged once at startup (`Warn`) for operators. Every response carries -the `X-Forest-Bff-Version` header, read from `package.json`. +configured. Missing keys are logged once at startup (`Warn`) for operators. Every response the BFF +itself produces carries the `X-Forest-Bff-Version` header, read from `package.json`. The one +exception is the embedded `503 bff_not_started` below: the agent writes it before there is a BFF to +read a version from. ## Embedded in an agent The same BFF runs inside a Forest agent, with no second deployment and no second port: ```ts -createAgent(options) +await createAgent(options) .addDataSource(/* … */) .addBff({ allowedOrigins: ['https://my-app.com'] }) + .mountOnStandaloneServer(3351) .start(); ``` -It answers under `/bff` on the agent's own port — `/bff/agent/v1/{collection}/list`, +A mount is what opens a socket — `start()` only builds the agent's router, so without a +`mountOn*` call nothing is served, BFF included. + +It answers under `/bff` on whatever port the agent is mounted on — `/bff/agent/v1/{collection}/list`, `/bff/health`, `/bff/oauth/*` — so the REST contract is the one documented above and only the base -url changes. A client generated from the OpenAPI document stays portable: the document's `servers` -entry carries the prefix. +url changes. + +The prefix is fixed at `/bff` and is resolved against the url the host hands the agent, so **mount +the agent on the root application, not on a sub-router**: an agent mounted on an express router at +`/api` answers at `/api/bff`, while the OpenAPI document and the docs page still say `/bff`. + +The *served* OpenAPI document carries the prefix in its `servers` entry, so a client generated from +`GET /bff/agent/openapi.json` is correct as-is. The *exported* one does not: `forest-bff openapi` +has no way to be told about a prefix and always emits `servers: [{ "url": "/" }]`. Generating a +client from the export against an embedded BFF means pointing its base url at `/bff` yourself. What differs from the standalone deployment: @@ -281,7 +295,7 @@ What differs from the standalone deployment: | `AGENT_URL` | required, an http(s) url | gone — the BFF reaches the agent in the same process, without a socket | | `HTTP_PORT` | its own listener | gone — the agent's port serves it | | `openapiEnabled` | `true` | `false`. The document is not filtered per caller, so adding a BFF must not silently publish every collection and field name on an already-open port | -| `/health` | 503 until every required key is set | always 200: everything required is inherited, so there is no gap to report. Read `features` | +| `/health` | 503 until every required key is set | always 200: an in-process dispatcher short-circuits the readiness check, since a 503 here would let a load balancer restart a process that serves api-key traffic fine. It stays 200 with no `tokenEncryptionKey`, which is *not* inherited — read `features` to know what is on | **Registration order matters on Express and Connect-style hosts.** Mount the agent *before* any body parser of your own: @@ -290,6 +304,7 @@ body parser of your own: const app = express(); agent.mountOnExpress(app); // first app.use(express.json()); // then yours +await agent.start(); ``` A body parser that runs first has already consumed the request stream, and nothing downstream can @@ -297,5 +312,17 @@ put it back: every BFF `POST` then answers `500 stream.not.readable`. The same a permissive `cors()` registered ahead of the mount — it answers the preflight itself, and the BFF's strict allow-list never gets a say. +NestJS needs no special handling as long as you mount before `listen()`: `NestFactory.create()` +does not install its body parser, `init()` does, and `listen()` is what triggers `init()` — so the +middleware `mountOnNestJs()` registers is already ahead of it. + +**`/bff/*` answers `503 bff_not_started`** between `addBff()` and the end of `start()`, and again +after `stop()` — the agent claims the prefix as soon as it is mounted, and a 404 there would read as +"wrong url" rather than "not ready yet". A host restarting an agent under traffic will see it. + +**`addBff()` and `mountAiMcpServer({ basePath: '/bff' })` are mutually exclusive** and throw at +startup rather than letting the MCP server quietly claim `/bff/oauth` and `/bff/mcp`. Mount the MCP +server elsewhere. + When to prefer which: embedded for a single deployment, which is most of them. Standalone when several agents share one BFF, or when the BFF and the agent have to scale separately. diff --git a/packages/agent/README.md b/packages/agent/README.md index dd422d3dee..797409b542 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -18,22 +18,43 @@ npm install @forestadmin/agent-bff ``` ```ts -createAgent(options) +await createAgent(options) .addDataSource(/* … */) .addBff({ allowedOrigins: ['https://my-app.com'] }) + .mountOnStandaloneServer(3351) .start(); ``` -Serves the REST BFF at `/bff` on the agent's own port, on every mount target. It reaches the agent -in the same process, so there is no second port, no agent url to configure, and no secrets to keep -in sync — `authSecret`, `envSecret`, the Forest urls and the logger are inherited. +Serves the REST BFF under `/bff` on whatever port the agent is mounted on, on every mount target — +`mountOnStandaloneServer` above, or the host's own listener with `mountOnExpress`, `mountOnKoa`, +`mountOnFastify`, `mountOnNestJs`. A `mountOn*` call is what opens a socket: `start()` only builds +the agent's router, so a chain without one serves nothing, BFF included. + +The BFF reaches the agent in the same process, so there is no second port, no agent url to +configure, and no secrets to keep in sync — `authSecret`, `envSecret`, the Forest urls and the +logger are inherited. Everything `addBff()` takes is a feature it switches on: `tokenEncryptionKey` enables OAuth (and with it the AI relay), `allowedOrigins` enables browser access, `openapiEnabled` serves the docs (off by default when embedded). `GET /bff/health` reports which of them are on. **Mount the agent before any body parser of your own** — a parser that runs first consumes the -request stream, and every BFF `POST` then answers `500 stream.not.readable`. +request stream, and every BFF `POST` then answers `500 stream.not.readable`. A permissive `cors()` +registered ahead of the mount is the quieter version of the same mistake: it answers the preflight +with its own policy, and the BFF's exact-origin allow-list never gets a say. + +**Mount the agent on the root application, not on a sub-router.** The `/bff` prefix is fixed and +resolved against the url the host hands the agent, so an agent mounted on an express router at +`/api` answers at `/api/bff` while the OpenAPI document still advertises `/bff`. + +**`agentTimeoutMs` bounds the wait, not the work.** When a call to the agent exceeds it the BFF +answers with an error, but nothing is cancelled: the in-process request runs to completion. An +action cut at the timeout still applies its mutation, so a client that retries applies it twice. +Size the timeout above your slowest action, and make actions idempotent if you intend to retry them. + +**`addBff()` cannot be combined with `mountAiMcpServer({ basePath: '/bff' })`** — the MCP server +would claim `/bff/oauth` and `/bff/mcp`. The agent throws at startup rather than letting the two +overlap. See [`@forestadmin/agent-bff`](../agent-bff/README.md) for the routes, the auth modes and the differences with the standalone deployment. diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 86c9ddafea..37558cab62 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -423,10 +423,14 @@ export default class Agent extends FrameworkMounter * @returns the agent instance for chaining * @throws Error if called more than once, or if the MCP server already claims `/bff` * + * A `mountOn*` call is what opens a socket — start() only builds the router — so the BFF is only + * reachable on an agent that is mounted somewhere. + * * @example - * createAgent(options) + * await createAgent(options) * .addDataSource(...) * .addBff({ allowedOrigins: ['https://my-app.com'] }) + * .mountOnStandaloneServer(3351) * .start(); */ addBff(options: BffEmbedOptions = {}): this { diff --git a/packages/agent/test/bff/embedded-bff.e2e.test.ts b/packages/agent/test/bff/embedded-bff.e2e.test.ts index f62bf81c71..e99fabde6b 100644 --- a/packages/agent/test/bff/embedded-bff.e2e.test.ts +++ b/packages/agent/test/bff/embedded-bff.e2e.test.ts @@ -1,3 +1,4 @@ +import type { BffEmbedOptions } from '../../src/types'; import type { Server } from 'http'; import type supertest from 'supertest'; @@ -16,6 +17,22 @@ import MockForestServer from '../__helper__/mock-forest-server'; const AUTH_SECRET = 'test-auth-secret-32-chars-min!!!'; const ENV_SECRET = '0'.repeat(64); const BOOT_TIMEOUT_MS = 30_000; +const ALLOWED_ORIGIN = 'https://my-app.com'; +const FOREIGN_ORIGIN = 'https://not-my-app.com'; + +/** What `cors()` with no options does: answer any preflight, allow any origin. */ +const permissiveCors: express.RequestHandler = (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + + return; + } + + next(); +}; const COLLECTION_PERMISSIONS = { collection: { @@ -297,7 +314,7 @@ describe('embedded BFF', () => { agent = new Agent(agentOptions('embedded')) .addDataSource(async () => new SearchDataSource()) .addDataSource(async () => new RecordContractDataSource()) - .addBff({ allowedOrigins: ['https://my-app.com'] }); + .addBff({ allowedOrigins: [ALLOWED_ORIGIN] }); app = express(); agent.mountOnExpress(app); @@ -557,8 +574,8 @@ describe('embedded BFF', () => { }); describe('the host registration order', () => { - async function startAgentOn(hostApp: express.Express, mountFirst: boolean) { - const orderAgent = new Agent({ + function createOrderAgent(bffOptions: BffEmbedOptions) { + return new Agent({ authSecret: AUTH_SECRET, envSecret: ENV_SECRET, forestServerUrl: 'https://api.forestadmin.com', @@ -568,7 +585,11 @@ describe('embedded BFF', () => { logger: () => undefined, }) .addDataSource(async () => new SearchDataSource()) - .addBff({}); + .addBff(bffOptions); + } + + async function startAgentOn(hostApp: express.Express, mountFirst: boolean) { + const orderAgent = createOrderAgent({}); if (mountFirst) orderAgent.mountOnExpress(hostApp); hostApp.use(express.json()); @@ -579,6 +600,25 @@ describe('embedded BFF', () => { return orderAgent; } + async function startAgentBehindHostCors(hostApp: express.Express, mountFirst: boolean) { + const orderAgent = createOrderAgent({ allowedOrigins: [ALLOWED_ORIGIN] }); + + if (mountFirst) orderAgent.mountOnExpress(hostApp); + hostApp.use(permissiveCors); + if (!mountFirst) orderAgent.mountOnExpress(hostApp); + + await orderAgent.start(); + + return orderAgent; + } + + function preflightListBooks(hostApp: express.Express) { + return supertest(hostApp) + .options('/bff/agent/v1/books/list') + .set('Origin', FOREIGN_ORIGIN) + .set('Access-Control-Request-Method', 'POST'); + } + function listBooks(hostApp: express.Express) { return supertest(hostApp) .post('/bff/agent/v1/books/list') @@ -625,5 +665,44 @@ describe('embedded BFF', () => { }, BOOT_TIMEOUT_MS, ); + + it( + 'should let the BFF allow-list refuse a foreign origin when the agent is mounted first', + async () => { + const hostApp = express(); + const orderAgent = await startAgentBehindHostCors(hostApp, true); + + try { + const response = await preflightListBooks(hostApp); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + } finally { + await orderAgent.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); + + // Pinned for the same reason as the body-parser pair: a host that answers the preflight itself + // silently replaces the BFF's exact-origin allow-list with its own policy, and nothing about the + // response says so. + it( + 'should let a permissive host cors registered first answer for a foreign origin', + async () => { + const hostApp = express(); + const orderAgent = await startAgentBehindHostCors(hostApp, false); + + try { + const response = await preflightListBooks(hostApp); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-origin']).toBe('*'); + } finally { + await orderAgent.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); }); }); From ed5054ac984f998c17a8c9d7611cefe8c60c2efc Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 3 Sep 2026 11:32:17 +0200 Subject: [PATCH 3/6] fix(agent): call supertest by the name the e2e suite imports it under The two host-registration helpers were written against the value import before it was renamed to `request`, so the suite stopped compiling once both changes sat on the same branch. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent/test/bff/embedded-bff.e2e.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent/test/bff/embedded-bff.e2e.test.ts b/packages/agent/test/bff/embedded-bff.e2e.test.ts index e99fabde6b..59eb81cd99 100644 --- a/packages/agent/test/bff/embedded-bff.e2e.test.ts +++ b/packages/agent/test/bff/embedded-bff.e2e.test.ts @@ -613,14 +613,14 @@ describe('embedded BFF', () => { } function preflightListBooks(hostApp: express.Express) { - return supertest(hostApp) + return request(hostApp) .options('/bff/agent/v1/books/list') .set('Origin', FOREIGN_ORIGIN) .set('Access-Control-Request-Method', 'POST'); } function listBooks(hostApp: express.Express) { - return supertest(hostApp) + return request(hostApp) .post('/bff/agent/v1/books/list') .set('Authorization', `Bearer ${sessionToken()}`) .set('X-Forest-Timezone', 'Europe/Paris') From 6501ebb3aafd979a1fc459653505373dd7d9cb35 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 3 Sep 2026 15:21:30 +0200 Subject: [PATCH 4/6] docs(agent): correct three statements the rebased code no longer matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openssl rand -hex 32` is 64 hex characters, which base64-decodes to 48 bytes, and parseEncryptionKey demands exactly 32 — so anyone following the example env file could not boot the agent at all. The command is `-base64 32`, and the line now says what the parser wants rather than only how to generate it. The executor key just below stays `-hex 32`: that one is not validated. The lifecycle now has two error types, not one. `bff_stopped` after stop() is distinct from `bff_not_started` before start() finishes, precisely so a probe can wait on the first and drain on the second — documenting only the second would have clients treat a draining agent as a booting one. The MCP collision throws from the builder call, not from start(): a host that only guards its start() would take it uncaught. Recorded with the widened matcher too, since any basePath landing inside /bff collides, not just '/bff'. Co-Authored-By: Claude Opus 5 (1M context) --- packages/_example/.env.example | 3 ++- packages/agent-bff/README.md | 18 ++++++++++-------- packages/agent/README.md | 4 ++-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/_example/.env.example b/packages/_example/.env.example index 369f045554..b6cf93c20e 100644 --- a/packages/_example/.env.example +++ b/packages/_example/.env.example @@ -20,7 +20,8 @@ FOREST_AUTH_SECRET= # Browser origins allowed to call it: comma-separated, exact origins, no wildcard. Empty means no # browser can reach it — put your frontend's origin here, e.g. http://localhost:4200 BFF_ALLOWED_ORIGINS= -# Enables the BFF's OAuth flow, and with it the AI relay (openssl rand -hex 32) +# Enables the BFF's OAuth flow, and with it the AI relay. Base64, decoding to exactly 32 bytes +# (openssl rand -base64 32) — a hex key is rejected at startup BFF_TOKEN_ENCRYPTION_KEY= # Production diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index 8610c5cca1..0bc4a4f5e0 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -256,8 +256,8 @@ The body still never discloses which config *keys* are present or missing — th internal config surface to an unauthenticated probe. It reports what is served, not how it was configured. Missing keys are logged once at startup (`Warn`) for operators. Every response the BFF itself produces carries the `X-Forest-Bff-Version` header, read from `package.json`. The one -exception is the embedded `503 bff_not_started` below: the agent writes it before there is a BFF to -read a version from. +exception is the embedded `503 bff_not_started` / `bff_stopped` below: the agent writes those +itself, with no BFF to read a version from. ## Embedded in an agent @@ -316,13 +316,15 @@ NestJS needs no special handling as long as you mount before `listen()`: `NestFa does not install its body parser, `init()` does, and `listen()` is what triggers `init()` — so the middleware `mountOnNestJs()` registers is already ahead of it. -**`/bff/*` answers `503 bff_not_started`** between `addBff()` and the end of `start()`, and again -after `stop()` — the agent claims the prefix as soon as it is mounted, and a 404 there would read as -"wrong url" rather than "not ready yet". A host restarting an agent under traffic will see it. +**`/bff/*` answers `503` while the BFF is not serving**, and the two ends of the lifecycle are told +apart: `bff_not_started` between `addBff()` and the end of `start()`, `bff_stopped` after `stop()`. +The agent claims the prefix as soon as it is mounted, and a 404 there would read as "wrong url" +rather than "not available". A probe should wait on the first and drain on the second. -**`addBff()` and `mountAiMcpServer({ basePath: '/bff' })` are mutually exclusive** and throw at -startup rather than letting the MCP server quietly claim `/bff/oauth` and `/bff/mcp`. Mount the MCP -server elsewhere. +**`addBff()` and `mountAiMcpServer({ basePath: '/bff' })` are mutually exclusive** — whichever is +called second throws on the spot, at the builder call and not from `start()`, rather than letting +the MCP server quietly claim `/bff/oauth` and `/bff/mcp`. Any `basePath` landing inside `/bff` +counts (`bff`, `/bff/`, `/bff/ai`), so mount the MCP server elsewhere. When to prefer which: embedded for a single deployment, which is most of them. Standalone when several agents share one BFF, or when the BFF and the agent have to scale separately. diff --git a/packages/agent/README.md b/packages/agent/README.md index 797409b542..b7793b8c06 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -53,8 +53,8 @@ action cut at the timeout still applies its mutation, so a client that retries a Size the timeout above your slowest action, and make actions idempotent if you intend to retry them. **`addBff()` cannot be combined with `mountAiMcpServer({ basePath: '/bff' })`** — the MCP server -would claim `/bff/oauth` and `/bff/mcp`. The agent throws at startup rather than letting the two -overlap. +would claim `/bff/oauth` and `/bff/mcp`. Whichever you call second throws on the spot, at the +builder call and not from `start()`. See [`@forestadmin/agent-bff`](../agent-bff/README.md) for the routes, the auth modes and the differences with the standalone deployment. From 9dbfab60248188cbfdaec4a6e86d3e692a72a5c2 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 3 Sep 2026 16:30:53 +0200 Subject: [PATCH 5/6] docs(agent-bff): name the one control the embedded mode does not apply The IP whitelist exemption is a decision, and the agent now warns about it at startup, but the README described the embedded mode as differing from the standalone one only in configuration and defaults. A reader comparing the two had no way to see that a security control they may have enabled does not cover this surface. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent-bff/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index 0bc4a4f5e0..b1ebc36dc8 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -326,5 +326,12 @@ called second throws on the spot, at the builder call and not from `start()`, ra the MCP server quietly claim `/bff/oauth` and `/bff/mcp`. Any `basePath` landing inside `/bff` counts (`bff`, `/bff/`, `/bff/ai`), so mount the MCP server elsewhere. +**One security control does not apply to the embedded mode.** If the environment has Forest's IP +whitelist enabled, requests served under `/bff` are not subject to it: they reach the agent +in-process, over what the whitelist treats as a trusted loopback caller. This is deliberate — +applying it would filter the browsers a third-party UI is made of, and the standalone deployment +never filtered the end user either, only the BFF's own host. A resolved API key or a valid OAuth +session is still required, and the agent still logs a warning at startup naming the exemption. + When to prefer which: embedded for a single deployment, which is most of them. Standalone when several agents share one BFF, or when the BFF and the agent have to scale separately. From f753b638448178f877fb67d1d929b1bef60e6dae Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 4 Sep 2026 11:24:08 +0200 Subject: [PATCH 6/6] docs(agent): resync the three paragraphs the stack moved under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/health` serializes `configured`, not `features`, and the rename carried a meaning with it: the block says which surfaces were set up, deliberately not that they work. The README advertised a key that no longer exists and closed on "what is served, not how it was configured", which is now exactly backwards. The sub-router warning described a limitation that has been lifted. The emitted prefix is derived per request from `originalUrl`, so a host mounting at `/api` gets `/api/bff` in the `servers` entry and on the docs page — the paragraph now says the shape is supported instead of telling readers to avoid it. The cors paragraph claimed the allow-list never gets a say behind a permissive host `cors()`. It does: the preflight is lost, but the request that follows is refused with 403 origin_not_allowed. Understating the mitigation is as misleading as overstating it, and the Layer 1 description two sections up said the same stale thing — a request with no Origin still passes, which is what keeps server-to-server api-key calls working. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent-bff/README.md | 44 ++++++++++++++++++++++-------------- packages/agent/README.md | 18 +++++++++------ 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index b1ebc36dc8..94afb32656 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -162,9 +162,12 @@ Two layers, both driven by exact-origin matching (case-insensitive scheme/host, normalized away, no trailing slash, no wildcard, no subdomain matching): - **Layer 1 (transport)** — the only layer that sets `Access-Control-Allow-Origin`. An origin in - `BFF_ALLOWED_ORIGINS` is echoed back exactly; anything else gets no CORS headers (the browser - blocks). Applies to `POST /oauth/token` too. Preflight `OPTIONS` from an allow-listed origin gets - the allowed methods + headers; credentials are never enabled. + `BFF_ALLOWED_ORIGINS` is echoed back exactly; any other `Origin` is refused outright with + `403 origin_not_allowed`, rather than served without the header and left for the browser to + discard — omitting the header only stops the caller from *reading* the answer, and the request + would already have run. A request with no `Origin` at all is untouched, which is every + server-to-server api-key call. Applies to `POST /oauth/token` too. Preflight `OPTIONS` from an + allow-listed origin gets the allowed methods + headers; credentials are never enabled. - **Layer 2 (per-key authorization, Mode 2 only)** — when the resolved key has a non-empty `allowedOrigins`, an `Origin` sent by the client must be in that list, else `403 origin_not_allowed`. A request with no `Origin` at all — a server-side client such as curl, @@ -236,16 +239,17 @@ with `session_expired`. Horizontal scaling requires both a shared session store { "status": "ok", "version": "", - "features": { "oauth": true, "ai": true, "cors": true, "openapi": true } + "configured": { "oauth": true, "ai": true, "cors": true, "openapi": true } } // 503 — one or more required keys missing -{ "status": "degraded", "version": "", "features": { /* … */ } } +{ "status": "degraded", "version": "", "configured": { /* … */ } } ``` -`features` says what this deployment actually serves. They form a chain, not four independent -switches: +`configured` says which optional surfaces this deployment was set up to serve — deliberately not +that they work: `oauth` is `true` as soon as a key is set, whether or not the Forest server ever +answers. They form a chain, not four independent switches: -| Feature | Switched on by | +| Surface | Switched on by | | --- | --- | | `oauth` | `BFF_TOKEN_ENCRYPTION_KEY` **and** `FOREST_SERVER_URL`, `FOREST_ENV_SECRET`, `FOREST_APP_URL`, `FOREST_AUTH_SECRET` — the routes need a Forest server to talk to as much as a key. Embedded, only `tokenEncryptionKey` is yours to set: the other four are inherited | | `ai` | `oauth` — the relay needs a session, and only the OAuth flow creates one | @@ -253,8 +257,8 @@ switches: | `openapi` | `BFF_OPENAPI_ENABLED` (`openapiEnabled`), and a mounted agent edge | The body still never discloses which config *keys* are present or missing — that would leak the -internal config surface to an unauthenticated probe. It reports what is served, not how it was -configured. Missing keys are logged once at startup (`Warn`) for operators. Every response the BFF +internal config surface to an unauthenticated probe. It names the surfaces, never the values behind +them. Missing keys are logged once at startup (`Warn`) for operators. Every response the BFF itself produces carries the `X-Forest-Bff-Version` header, read from `package.json`. The one exception is the embedded `503 bff_not_started` / `bff_stopped` below: the agent writes those itself, with no BFF to read a version from. @@ -278,9 +282,10 @@ It answers under `/bff` on whatever port the agent is mounted on — `/bff/agent `/bff/health`, `/bff/oauth/*` — so the REST contract is the one documented above and only the base url changes. -The prefix is fixed at `/bff` and is resolved against the url the host hands the agent, so **mount -the agent on the root application, not on a sub-router**: an agent mounted on an express router at -`/api` answers at `/api/bff`, while the OpenAPI document and the docs page still say `/bff`. +A host serving the agent under a sub-path of its own is supported: `app.use('/api', mounted)` puts +the BFF at `/api/bff`, and the emitted prefix is derived per request from `originalUrl` +(`base-path.ts`), so the `servers` entry and the docs page carry `/api/bff` rather than the +configured `/bff`. The *served* OpenAPI document carries the prefix in its `servers` entry, so a client generated from `GET /bff/agent/openapi.json` is correct as-is. The *exported* one does not: `forest-bff openapi` @@ -295,7 +300,7 @@ What differs from the standalone deployment: | `AGENT_URL` | required, an http(s) url | gone — the BFF reaches the agent in the same process, without a socket | | `HTTP_PORT` | its own listener | gone — the agent's port serves it | | `openapiEnabled` | `true` | `false`. The document is not filtered per caller, so adding a BFF must not silently publish every collection and field name on an already-open port | -| `/health` | 503 until every required key is set | always 200: an in-process dispatcher short-circuits the readiness check, since a 503 here would let a load balancer restart a process that serves api-key traffic fine. It stays 200 with no `tokenEncryptionKey`, which is *not* inherited — read `features` to know what is on | +| `/health` | 503 until every required key is set | always 200: an in-process dispatcher short-circuits the readiness check, since a 503 here would let a load balancer restart a process that serves api-key traffic fine. It stays 200 with no `tokenEncryptionKey`, which is *not* inherited — read `configured` to know what is on | **Registration order matters on Express and Connect-style hosts.** Mount the agent *before* any body parser of your own: @@ -308,9 +313,14 @@ await agent.start(); ``` A body parser that runs first has already consumed the request stream, and nothing downstream can -put it back: every BFF `POST` then answers `500 stream.not.readable`. The same applies to a -permissive `cors()` registered ahead of the mount — it answers the preflight itself, and the BFF's -strict allow-list never gets a say. +put it back: every BFF `POST` then answers `500 stream.not.readable`. + +A permissive `cors()` registered ahead of the mount costs the preflight, not the allow-list. The +host answers `OPTIONS` with its own policy and that is unrecoverable, but the request that follows +still reaches this app, where an `Origin` outside `BFF_ALLOWED_ORIGINS` is refused with +`403 origin_not_allowed` — the browser reads the wrong preflight, and the collection is still never +read for that origin. A caller sending no `Origin` at all is untouched, which is every +server-to-server api-key call. NestJS needs no special handling as long as you mount before `listen()`: `NestFactory.create()` does not install its body parser, `init()` does, and `listen()` is what triggers `init()` — so the diff --git a/packages/agent/README.md b/packages/agent/README.md index b7793b8c06..1840b7cf37 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -36,16 +36,20 @@ logger are inherited. Everything `addBff()` takes is a feature it switches on: `tokenEncryptionKey` enables OAuth (and with it the AI relay), `allowedOrigins` enables browser access, `openapiEnabled` serves the docs -(off by default when embedded). `GET /bff/health` reports which of them are on. +(off by default when embedded). `GET /bff/health` reports which of them are on, under `configured`. **Mount the agent before any body parser of your own** — a parser that runs first consumes the -request stream, and every BFF `POST` then answers `500 stream.not.readable`. A permissive `cors()` -registered ahead of the mount is the quieter version of the same mistake: it answers the preflight -with its own policy, and the BFF's exact-origin allow-list never gets a say. +request stream, and every BFF `POST` then answers `500 stream.not.readable`. -**Mount the agent on the root application, not on a sub-router.** The `/bff` prefix is fixed and -resolved against the url the host hands the agent, so an agent mounted on an express router at -`/api` answers at `/api/bff` while the OpenAPI document still advertises `/bff`. +A permissive `cors()` registered ahead of the mount costs you the preflight but not the allow-list: +the host answers `OPTIONS` with its own policy, and nothing downstream can take that back, so the +request that follows arrives here anyway — and is refused with `403 origin_not_allowed` unless its +`Origin` is one you listed. The browser sees the wrong preflight; the collection is still never read +for that origin. + +Mounting under a sub-path of your own works: `app.use('/api', mounted)` serves the BFF at +`/api/bff`, and the prefix is derived per request, so the OpenAPI `servers` entry and the docs page +carry `/api/bff` too. **`agentTimeoutMs` bounds the wait, not the work.** When a call to the agent exceeds it the BFF answers with an error, but nothing is cancelled: the in-process request runs to completion. An