diff --git a/packages/_example/.env.example b/packages/_example/.env.example index 38b7a2b7fb..b6cf93c20e 100644 --- a/packages/_example/.env.example +++ b/packages/_example/.env.example @@ -16,6 +16,14 @@ 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. Base64, decoding to exactly 32 bytes +# (openssl rand -base64 32) — a hex key is rejected at startup +BFF_TOKEN_ENCRYPTION_KEY= + # Production # FOREST_ENV_SECRET= # FOREST_AUTH_SECRET= diff --git a/packages/_example/package.json b/packages/_example/package.json index 7ef3299e8a..63641e8d4d 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": "*", "@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..94afb32656 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 @@ -159,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, @@ -230,11 +236,112 @@ with `session_expired`. Horizontal scaling requires both a shared session store ```jsonc // 200 — all required config present -{ "status": "ok", "version": "" } +{ + "status": "ok", + "version": "", + "configured": { "oauth": true, "ai": true, "cors": true, "openapi": true } +} // 503 — one or more required keys missing -{ "status": "degraded", "version": "" } +{ "status": "degraded", "version": "", "configured": { /* … */ } } +``` + +`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: + +| 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 | +| `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 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. + +## Embedded in an agent + +The same BFF runs inside a Forest agent, with no second deployment and no second port: + +```ts +await createAgent(options) + .addDataSource(/* … */) + .addBff({ allowedOrigins: ['https://my-app.com'] }) + .mountOnStandaloneServer(3351) + .start(); +``` + +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 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` +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: + +| | 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: 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: + +```ts +const app = express(); +agent.mountOnExpress(app); // first +app.use(express.json()); // then yours +await agent.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`. +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`. + +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 +middleware `mountOnNestJs()` registers is already ahead of 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** — 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. + +**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. diff --git a/packages/agent/README.md b/packages/agent/README.md index e69de29bb2..1840b7cf37 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -0,0 +1,73 @@ +# @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 +await createAgent(options) + .addDataSource(/* … */) + .addBff({ allowedOrigins: ['https://my-app.com'] }) + .mountOnStandaloneServer(3351) + .start(); +``` + +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, 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 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 +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`. 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. + +### 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/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 c3168f6e03..59eb81cd99 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); @@ -555,4 +572,137 @@ describe('embedded BFF', () => { BOOT_TIMEOUT_MS, ); }); + + describe('the host registration order', () => { + function createOrderAgent(bffOptions: BffEmbedOptions) { + return 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(bffOptions); + } + + async function startAgentOn(hostApp: express.Express, mountFirst: boolean) { + const orderAgent = createOrderAgent({}); + + if (mountFirst) orderAgent.mountOnExpress(hostApp); + hostApp.use(express.json()); + if (!mountFirst) orderAgent.mountOnExpress(hostApp); + + await orderAgent.start(); + + 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 request(hostApp) + .options('/bff/agent/v1/books/list') + .set('Origin', FOREIGN_ORIGIN) + .set('Access-Control-Request-Method', 'POST'); + } + + function listBooks(hostApp: express.Express) { + return request(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, + ); + + 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, + ); + }); });