npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@forestadmin/agent-bff

v1.42.0

Published

> [!IMPORTANT] > Internal building block of the Forest Gateway. For new deployments, use > [`@forestadmin/gateway`](https://docs.forestadmin.com/product/embed/gateway-standalone). The > `forest-bff` command keeps working, deprecated: moving to `forest-gat

Readme

@forestadmin/agent-bff

[!IMPORTANT] Internal building block of the Forest Gateway. For new deployments, use @forestadmin/gateway. The forest-bff command keeps working, deprecated: moving to forest-gateway serves the API under /api.

Standalone REST BFF (Backend-For-Frontend) that lets a trusted third-party UI call a Forest agent from a browser without learning MCP or JSON:API.

It is a bootable Koa 3 server with a /health endpoint, a version header, env-driven config validation, OAuth (Mode 1) + API-key (Mode 2) auth, and a hardened request edge (timezone, CORS, auth-mode precedence, structured error contract), and it serves and exports its own OpenAPI document.

The routes it serves

Everything the agent proxy exposes lives under /agent/v1. The data and action routes are POST — the filter, projection, sort, search and paging all travel in the body, never in the query string:

| Route | Method | What it does | | ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------- | | /agent/v1/{collection}/list · /count | POST | list or count records of a collection | | /agent/v1/{collection}/relations/{relation}/list · /count | POST | same, through a to-many relation — to-one relations are not listable and 404 by design | | /agent/v1/{collection}/actions/{action}/form · /execute | POST | load a Smart Action's form, then run it | | /agent/v1/permissions | GET | what the caller may see and do, as display hints for graying out UI — never an authorization decision | | /agent/openapi.json | GET/HEAD | the unfolded document, auth-gated, when BFF_OPENAPI_ENABLED is on |

/health, the /oauth/* login routes and the /docs viewer (page and redoc.standalone.js bundle) sit outside the prefix and outside the auth edge; /health and /docs answer HEAD as well as GET.

The agent enforces permissions and scopes, not the BFF. Each proxied call carries a short-lived agent token the BFF mints for the caller, so a request reaches the agent as that person and comes back filtered exactly as it would for them in Forest. A collection the current schema no longer exposes is re-checked on every call, so a schema refresh can never leave a dropped collection reachable.

Usage

Two ways to run it: embedded in a Forest agent (agent.addBff(), see Embedded in an agent) or standalone, described here.

Docker (recommended)

cp .env.example .env   # then fill in the secrets
docker compose up

The template targets a local, non-containerised run, so one value has to change for Docker: set AGENT_URL=http://host.docker.internal:3351. Left at localhost, it resolves to the BFF container itself and every agent call fails (see the note below).

The docker-compose.yml at the root of this package starts a single BFF instance. See .env.example for the full list of environment variables and their descriptions.

Or run the image directly:

docker run -d \
  -p 3450:3450 \
  --stop-timeout 15 \
  --add-host host.docker.internal:host-gateway \
  -e FOREST_AUTH_SECRET="..." \
  -e FOREST_ENV_SECRET="..." \
  -e AGENT_URL="http://host.docker.internal:3351" \
  -e BFF_TOKEN_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
  ghcr.io/forestadmin/agent-bff:latest

Note: When the BFF runs in Docker and your agent runs on the host machine, use host.docker.internal instead of localhost in AGENT_URL. Docker Desktop resolves that name natively; on Docker Engine for Linux it does not exist unless you map it, hence the --add-host above (the Compose setup does the same through extra_hosts).

The image's entry point is the CLI, so the subcommands below work the same way:

docker run --rm ghcr.io/forestadmin/agent-bff:latest openapi > openapi.json

Tags follow the npm package: :latest, :1, :1.20 and the immutable :1.20.2.

The package is public, so none of the commands above need a login. That visibility is set once, by hand, on the GHCR package: a package GHCR creates on its first push is private, and the workflow's GITHUB_TOKEN can push to it but not change what it is. Until someone flips it (the same step workflow-executor went through), pulls need docker login ghcr.io -u <user> -p <token-with-read:packages>.

On SIGTERM or SIGINT the BFF stops accepting connections and gives the requests already in flight 10 seconds to finish before cutting their sockets, then exits 0. A second signal gives up on the wait and exits 1.

Allow for that in your orchestrator's grace period. The whole budget is up to 11 seconds — the 10 second deadline plus a 1 second fallback for the exit itself — and docker stop defaults to 10, so under load it would SIGKILL exactly when the shutdown is doing its job. Hence --stop-timeout 15 above and stop_grace_period: 15s in the Compose file; on Kubernetes the default terminationGracePeriodSeconds of 30 already covers it.

Observability (OpenTelemetry)

The Docker image ships with OpenTelemetry APM built in, and works with any OTLP-compatible backend (Datadog, Grafana Tempo, Jaeger, Honeycomb, etc.). It is off by default and turns on as soon as you point it at an OTLP receiver — no code changes or extra installs required. A setup that cannot start logs a warning and runs untraced rather than taking the process down with it. Tracing is set up before the app starts (auto-instrumentation for HTTP and the outbound calls to the agent and the Forest SaaS). The graceful shutdown described above waits for the buffered spans to be exported before it exits, but gives that its own 2 second deadline rather than the 10 seconds in-flight requests get: an unreachable collector costs you the last spans, never the ability to stop. Worst case it adds ~3 seconds to a shutdown.

Configure it entirely through the standard OTel environment variables:

| Variable | Description | | --- | --- | | OTEL_EXPORTER_OTLP_ENDPOINT | OTLP receiver URL (e.g. http://collector:4318). Tracing stays off until this or OTEL_TRACES_EXPORTER is set. | | OTEL_SERVICE_NAME | Service name reported in traces. Falls back to service.name in OTEL_RESOURCE_ATTRIBUTES, then to forestadmin-agent-bff. | | OTEL_RESOURCE_ATTRIBUTES | Extra resource attributes, e.g. deployment.environment=production. A service.name here is honoured when OTEL_SERVICE_NAME is unset. | | OTEL_SDK_DISABLED | Set to true (case-insensitive) to force-disable tracing whatever else is configured. | | OTEL_TRACES_EXPORTER | Which exporter the SDK builds: otlp (the default), console, zipkin, none, or a list. Setting it alone turns tracing on without an OTLP endpoint, which is what makes console usable for debugging. none keeps instrumentation running with nothing exported, so trace context still propagates to the agent and the Forest SaaS. | | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | Per-signal endpoint, taking precedence over the generic one above. Setting either turns tracing on. | | OTEL_EXPORTER_OTLP_PROTOCOL | http/protobuf (the default), http/json or grpc, with OTEL_EXPORTER_OTLP_TRACES_PROTOCOL for traces alone. | | OTEL_METRICS_EXPORTER / OTEL_LOGS_EXPORTER | Default to none here, against the SDK's own otlp: this image arms tracing, and leaving them unset would otherwise export metrics and logs to http://localhost:4318 on the side. Set either one (otlp, console, …) to opt that signal back in. |

docker run -d \
  -p 3450:3450 \
  --stop-timeout 15 \
  --add-host host.docker.internal:host-gateway \
  -e FOREST_AUTH_SECRET="..." \
  -e FOREST_ENV_SECRET="..." \
  -e AGENT_URL="http://host.docker.internal:3351" \
  -e BFF_TOKEN_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
  -e OTEL_EXPORTER_OTLP_ENDPOINT="http://collector:4318" \
  ghcr.io/forestadmin/agent-bff:latest

Note: These variables only do anything in the Docker image. The image's entry point loads the tracing preload before the CLI; the npm forest-bff bin runs the CLI on its own, and the OpenTelemetry packages are not npm dependencies — so outside Docker an OTEL_* variable is read by nothing and the process starts untraced, silently.

Without Docker

Packaged / production — run the bin:

forest-bff

Export the OpenAPI document without booting the server. Needs no configuration, so it works in CI to commit the document, diff it, or generate a client:

forest-bff openapi > openapi.json          # stdout, redirected
forest-bff openapi --output                # writes ./openapi.json
forest-bff openapi --output docs/api.json  # writes that path

Set BFF_PUBLIC_URL for this command in particular. A document fetched over HTTP resolves its relative servers[0].url against the URL it came from; an exported file has no such origin, so without it a client generated from the export has no base URL at all.

The document comes in two forms, and the command picks one from the environment:

  • Unfolded when FOREST_ENV_SECRET, FOREST_AUTH_SECRET and AGENT_URL are all set: one path per exposed collection, per to-many relation and per action, each carrying the collection's real field set. This is the form to generate a client from. The command reads the Forest schema and asks the agent for each collection's capabilities, signing its own short-lived agent token with FOREST_AUTH_SECRET. A deployment configured this way but whose schema cannot be read exits 1 rather than emitting the generic document, which would look like a complete answer.
  • Generic when that configuration is absent: the six runtime routes with {collection}, {relation} and {action} as path parameters and no field enumerated. info.description says which form the document is.

Either way the runtime routes stay generic — only the document unfolds.

--output takes the next argument as the destination unless it is empty or starts with -; the default openapi.json is written only when --output is the last argument, and a leftover token is rejected like any other extra. A missing parent directory is created, an existing file is overwritten, and a destination that cannot be written exits 1 with the path on stderr. The path is confirmed on stderr, so stdout stays empty and pipeable.

forest-bff --help and forest-bff --version print to stdout and exit 0, ignoring anything that follows. An unknown command, or an argument other than --output after openapi, exits 1 with the reason on stderr.

Local development — copy the env template, fill it in, and start with it loaded:

cp .env.example .env   # then fill in the secrets
yarn build
yarn start:dev         # node --env-file=.env dist/cli.js

yarn start runs the built server without loading a .env (env comes from the shell).

Configuration

| Env var | Required | Description | | ----------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | FOREST_AUTH_SECRET | yes | Agent JWT signing secret (never logged or echoed). | | FOREST_ENV_SECRET | yes | Forest SaaS environment secret (forest-secret-key), server-to-server. | | FOREST_SERVER_URL | no | Forest SaaS API base URL. Defaults to https://api.forestadmin.com. | | FOREST_APP_URL | no | Forest front base URL, used to build the OAuth front-channel redirect (src/oauth/oauth-routes.ts). Defaults to https://app.forestadmin.com. | | AGENT_URL | yes | The customer agent base URL the BFF calls via agent-client. | | BFF_TOKEN_ENCRYPTION_KEY | for OAuth | Base64-encoded 32-byte AES-256 key encrypting stored refresh tokens. Until it is set, the /oauth/* token-issuance routes are disabled and /health reports configured.oauth: false — but it stays ok, since the key gates OAuth and not boot; already-issued bff_access tokens still authenticate on /agent/* whenever FOREST_AUTH_SECRET is present. | | HTTP_PORT | no | Server port, integer 0–65535. Defaults to 3450. 0 binds an OS-assigned ephemeral port — useful for a local run, unusable in the Docker image, where nothing outside the process learns which port it got: it can be neither published nor probed, and the image's healthcheck would report the container unhealthy forever. | | BFF_ALLOWED_ORIGINS | no | Comma-separated CORS allow-list of origins (scheme + host + port). An entry may carry a single * as the leading host label — https://*.apps.zdusercontent.com — which matches exactly one DNS label there, and nothing else: not two labels, not the apex, and never the scheme or the port. The host left after *. must be at least two non-empty labels, so https://*.com is refused; a two-label public suffix such as https://*.co.uk is not, and would allow every site under it. Any other * in the host is refused and warned about at boot; a * outside the host — in userinfo, a path or a query — is stripped along with the rest of the URL, so https://*@example.com is simply the exact origin https://example.com. Empty ⇒ no cross-origin browser access. | | BFF_DEFAULT_TIMEZONE | no | Fallback IANA timezone used when a request carries neither an X-Forest-Timezone header nor a body timezone. | | BFF_PUBLIC_URL | no | The BFF's own external base URL, published as servers[0].url in the OpenAPI document so a generated client resolves endpoints without being configured by hand. Absent, servers[0].url stays /, which a consumer that fetched the document over HTTP resolves against that URL — but which leaves a client generated from an offline forest-bff openapi export with no base URL at all. Trailing slashes are stripped. A malformed value fails the boot, and so does one carrying credentials, a query string or a fragment: credentials would be published to every reader of the document, and anything behind a ? or # swallows the path a generated client appends. | | BFF_AGENT_TIMEOUT_MS | no | How long the BFF waits for the customer agent on any data or action call, in milliseconds. Defaults to 10000. Past it the route answers 504 agent_timeout; any outright transport failure — a refused connection, an unresolvable host, a connection reset, a TLS failure — answers 502 network_error instead. Surrounding whitespace is trimmed, then a malformed value (non-integer, 0, or above 2147483647) fails the boot; an unset, empty, or whitespace-only value counts as absent and takes the default. | | BFF_AI_TIMEOUT_MS | no | How long POST /agent/v1/ai/query waits for the Forest server on the relay itself, in milliseconds. Defaults to 120000 — an AI generation is slow, and neither the app nor the Forest server bounds it. Past it the route answers 504. It is not the route's end-to-end cap: an expired session is refreshed first, under a separate hard-coded 60 s ceiling that answers 502, so a request can exceed this value. Surrounding whitespace is trimmed, then a malformed value (non-integer, 0, or above 2147483647) fails the boot; an unset, empty, or whitespace-only value counts as absent and takes the default. | | BFF_RATE_LIMIT_MAX_REQUESTS | no | Requests allowed per identity per window on /agent/*. Defaults to 300. Counted per resolved identity, prefixed by auth mode, so an API key and an OAuth session never share a bucket. Anonymous and rejected-key traffic answers 401 before the limiter and allocates nothing. An integer between 1 and 10 000; a malformed or out-of-range value fails the boot. | | BFF_RATE_LIMIT_WINDOW_MS | no | Length of that fixed window, in milliseconds. Defaults to 60000. Over the quota the edge answers 429 too_many_requests with an integer Retry-After. The same status also covers saturation: past 10 000 live buckets a new identity is refused rather than evicting someone's live window, and Retry-After is then a lower bound — the earliest reset among the identities currently holding one. The details.cause field of the 429 error body distinguishes the two: limit_exceeded or limiter_saturated. An integer between 1 000 and 2 147 483 647; a malformed or out-of-range value fails the boot. | | BFF_OPENAPI_ENABLED | no | Serve GET/HEAD /agent/openapi.json (auth-gated) when true. Defaults to true. Set to false for customers who do not want the HTTP surface exposed: an authenticated GET/HEAD then gets 404 openapi_disabled, other methods fall through to the agent routes exactly as they do when enabled, and forest-bff openapi keeps working either way. Accepted values: true/false. The served document is unfolded and is not filtered per caller: any authenticated caller, whatever their role, reads the name of every exposed collection, relation and field. Set this to false if that surface must not be reachable over HTTP. |

Config validation

  • A malformed value (a non-http(s) *_URL, a HTTP_PORT that is not a decimal integer in 0–65535, a non-IANA BFF_DEFAULT_TIMEZONE, a non-boolean BFF_OPENAPI_ENABLED, a BFF_RATE_LIMIT_MAX_REQUESTS that is not an integer in 1–10 000, or a BFF_RATE_LIMIT_WINDOW_MS that is not an integer in 1 000–2 147 483 647) fails fast at boot: the process exits with a clear error and never echoes the offending value.
  • A required var that is absent (or empty / whitespace-only) does not crash the server. It boots and reports the gap through /health (503 degraded).
  • Malformed BFF_ALLOWED_ORIGINS entries are dropped and logged once at boot (Warn); they never enter the allow-list. That covers a literal *, a * outside the leading host label (https://a.*.example.com), several *, a host left with fewer than two non-empty labels (https://*.com, https://*.com., https://*..com, https://*.localhost), and a pattern on an IP (https://*.127.0.0.1). Only the single leading-label form is served as a wildcard.

Request edge (/agent/*)

Every agent call flows through a request edge that enforces three cross-cutting concerns before the call is proxied to the agent. Errors use a structured, type-first contract — { error: { type, status, message, details? } } — so consumers branch on error.type, never on message text.

Two of those types split what used to be one failure. An agent that accepts the connection and then does not answer within BFF_AGENT_TIMEOUT_MS answers 504 agent_timeout; an agent that refuses the connection or whose host does not resolve answers 502 network_error, and so does any other outright transport failure — a connection reset mid-flight, a socket hang up, a TLS failure — because 502 is the catch-all for everything that is not a timeout. The deadline is armed when the request starts, so at the default a host that accepts nothing and never resets is a 504 too: 502 means the transport failed outright, not that it ran out of time. Raise BFF_AGENT_TIMEOUT_MS past the OS connect timeout (~75 s) and that last case reverts to 502, because the kernel gives up first and reports ETIMEDOUT rather than superagent's deadline.

Two paths sit before the timezone layer and so skip it: /agent/v1/context, and the AI relay described below. Neither reaches an agent, so neither has a timezone to resolve. This holds only where they are actually mounted — /agent/v1/context needs the read-model bundle and the AI relay needs an OAuth session. In a deployment missing either, the request falls through to the timezone layer instead, so a caller sending no timezone against a BFF with no BFF_DEFAULT_TIMEZONE gets 400 missing_timezone rather than the 404 it would expect from an unmounted route.

Auth-mode precedence

| Presented credentials | Result | | ------------------------------------ | --------------------------- | | Authorization: Bearer <bff_access> | Mode 1 (OAuth session) | | X-Forest-Gateway-Key | Mode 2 (API key) | | X-Forest-Bff-Key (legacy) | Mode 2 (API key) | | both key headers, same key | Mode 2 (API key) | | both key headers, different keys | 400 ambiguous_credentials | | Bearer and a key header | 400 ambiguous_credentials | | neither | 401 unauthorized |

A Mode 1 bff_access that is malformed or wrong-typed → 401 unauthorized; one that is validly signed but expired → 401 session_expired (the client should refresh). Refresh-token reuse remains a POST /oauth/token concern (session_invalidated, OAuth/RFC shape).

CORS

Two layers, both driven by the same matcher (case-insensitive scheme/host, default ports normalized away, no trailing slash). An entry with no * matches by strict equality. An entry whose leading host label is * — https://*.apps.zdusercontent.com — matches exactly one DNS label in its place: https://1231469.apps.zdusercontent.com passes, https://a.b.apps.zdusercontent.com and the apex https://apps.zdusercontent.com do not. Scheme and port are never wildcarded and must match exactly. The host left after *. must be at least two non-empty labels; every other * shape is refused at parse time and warned about at boot.

The wildcard exists for hosts that serve an application under an opaque generated subdomain — an installed Zendesk app is served from https://<app-id>.apps.zdusercontent.com — where naming the exact origin means copying an id the operator does not choose. Understand what it grants: every app of every publisher on that domain is inside the allow-list. That is acceptable here because CORS is not the authentication boundary — a bff_access session or an API key is still required, and another origin cannot read this app's tokens — but it is a real widening, so keep the suffix as specific as the host allows.

The two layers:

  • Layer 1 (transport) — the only layer that sets Access-Control-Allow-Origin. An origin in 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, a Node backend or CI, an empty header counting as none — passes: there the API key is the boundary, not the origin. An opaque Origin: null (sandboxed iframe, cross-origin redirect) is a present origin and is rejected. An empty per-key list is a no-op.

A wildcard in a key's allowedOrigins (layer 2) is matched the same way. Those entries come from the Forest SaaS and never pass through the boot-time parser, so an illegal pattern there gets no warning of its own; it counts as an origin that can never pass, and the key-level Warn names the key once when every one of its origins is outside BFF_ALLOWED_ORIGINS or illegal.

Local development: browsers still enforce CORS against localhost, so add your dev origin(s) to BFF_ALLOWED_ORIGINS (e.g. BFF_ALLOWED_ORIGINS=http://localhost:4200) — there is no dev bypass.

Timezone

On every route that reaches an agent, the BFF forwards an explicit timezone, resolved in order: (1) X-Forest-Timezone header, (2) body timezone field, (3) BFF_DEFAULT_TIMEZONE. None → 400 missing_timezone; a non-IANA value → 400 invalid_timezone. The two paths listed above are mounted ahead of this layer and answer without it.

AI query relay

POST /agent/v1/ai/query relays its body to the Forest AI proxy under the OAuth session the BFF holds, and relays the answer back:

  • Session only. An API key is refused with 403 oauth_required: only the OAuth flow yields the Forest access token this route forwards. The environment and rendering are derived server-side, so sending forest-* headers has no effect.
  • Body. 1mb instead of the 16kb of every other route, and application/json only — any other content type arrives as an empty body, is relayed upstream as {}, and the Forest server rejects the query. Over the limit → 413. The parser is selected on the request path alone and runs ahead of authentication, so an unauthenticated caller can make the BFF buffer and JSON-parse 1 MB on this path — 64x the pre-auth surface of every other route. Bounding that is a deployment concern (rate limiting or a body cap at the reverse proxy), not a BFF one.
  • Timeout. BFF_AI_TIMEOUT_MS (default 120000) caps the relay itself; past it → 504. It is not the only cap on the request: when the session's access token has expired the route first refreshes it against the Forest server, and that hop has its own hard-coded 60 s ceiling which answers 502 network_error, not 504.
  • Error shape. Below 500 the upstream JSON body is relayed unchanged, so its shape is the Forest AI proxy's contract and not the BFF's; when the upstream answer carried no JSON body the BFF substitutes its own envelope, keeping the status only when it was 4xx and reporting 502 for a 2xx or 3xx that carried nothing to relay. At 500 and above the body is always the BFF envelope, so no upstream infrastructure detail reaches the caller. This is the one route where a sub-500 body is not guaranteed to be { error: { type, … } }, so a consumer cannot branch on error.type here without checking that the field exists.
  • Mounted only with OAuth. Without a complete OAuth configuration the route is absent (404) and forest-bff openapi omits it from the document.

Sessions & token rotation

OAuth sessions, opaque refresh tokens, and rotated-out-token reuse detection are held in an in-memory, single-process store. Reuse detection has a one-step grace window: re-presenting the most recently rotated-out token (a benign retry of a refresh whose response was lost) replays the last issued pair instead of killing the session — the stored refresh token is returned with a freshly minted access JWT. Re-presenting any older rotated-out token still invalidates the whole session, so a stolen earlier token stays detectable. The grace window is bounded by the rotation chain, not a timer: once the client advances one more rotation, the previous token is no longer the latest and falls back to reuse. These guarantees are only correct within one process. Two layers are process-local: the session/rotation maps in the store, and the in-flight refresh dedup maps (inFlightRefreshByPresentedHash in oauth-routes, inFlightRefreshesBySid in session-lifecycle) that collapse concurrent refreshes of the same token into a single rotation. Running the BFF behind a load balancer without sticky sessions breaks reuse detection and the single-rotation guarantee — and a shared session store alone does not fix it: two concurrent refreshes landing on different nodes would each try to rotate, one committing and the other failing with session_expired. Horizontal scaling requires both a shared session store and a shared (or node-pinned) refresh-coordination mechanism.

/health

// 200 — all required config present
{
  "status": "ok",
  "version": "<package version>",
  "configured": { "oauth": true, "ai": true, "cors": true, "openapi": true }
}
// 503 — one or more required keys missing
{ "status": "degraded", "version": "<package 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. The two urls default to production, so standalone only the two secrets and the key are yours to set. 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:

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:

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 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.