Skip to content

feat(agent): serve a BFF in-process with addBff() - #1876

Open
nbouliol wants to merge 3 commits into
feature/prd-1076-6-root-middlewarefrom
feature/prd-1076-7-add-bff
Open

feat(agent): serve a BFF in-process with addBff()#1876
nbouliol wants to merge 3 commits into
feature/prd-1076-6-root-middlewarefrom
feature/prd-1076-7-add-bff

Conversation

@nbouliol

@nbouliol nbouliol commented Sep 1, 2026

Copy link
Copy Markdown
Member

Stacked on #1875. This is where the stack becomes a feature.

Why

Running a BFF meant a second deployment: another process, another port, another set of secrets to keep in sync with the agent's — for a component whose only job is to sit in front of that same agent.

What

createAgent(options)
  .addDataSource(/* … */)
  .addBff({ allowedOrigins: ['https://my-app.com'] })
  .start();

Served at /bff on the agent's own port, on every mount target. The BFF reaches the agent through the in-process dispatcher the embedded MCP server already uses: no socket, no agent url to guess per host framework, no second listener.

authSecret, envSecret, forestServerUrl, forestAppUrl and the logger are inherited — a divergent authSecret would make the agent reject the very tokens the BFF mints, as an opaque 401. What is left are features the BFF switches on: tokenEncryptionKey (the OAuth login/refresh flow, and with it the AI relay — it gates that flow, not the data surface, which answers to any bff_access bearer signed with authSecret), allowedOrigins (browser access), openapiEnabled (the docs, off by default when embedded because the document is not filtered per caller).

Three lifecycle details that are easy to get wrong, and are tested:

  • The dispatcher is registered in addBff(), not at start(). getInProcessDispatcher() pushes its hook the first time it is called, and mount() only runs the hooks registered before it — asked for later, every BFF call would throw not mounted yet until the first restart.
  • /bff answers 503 while the agent is starting, rather than falling through to the host's 404, which would read as "wrong url" instead of "not started".
  • stop() stops answering. The host application keeps whatever middleware it registered, so without it a stopped agent would keep serving BFF data through a dispatcher pointing at a dead stack.

restart() invalidates what the BFF read from the SaaS — a restart means the customizations changed.

addBff() refuses a second call, and refuses to coexist with an MCP server mounted under /bff in either order — including the spellings bff, /bff/ and /bff/ai, which the MCP server normalizes onto the same prefix. The BFF is registered at builder time and wins the root middleware's first-match, so the MCP surface would boot, log success and never be reachable.

The build cycle

agent gains an optional peer dependency (exact pin, like every internal dep — multi-semantic-release rewrites those ranges on release) plus a dev dependency on @forestadmin/agent-bff, which would close the cycle agent → agent-bff → agent-testing → agent that lerna run build sorts on. It is broken by moving the search integration suite out of agent-bff, which loses its dev dependency on the agent.

engines: { node: ">=22.12.0" } is declared on the agent too: addBff() pulls in a package that requires it, and the agent declared nothing.

Tests

agent-bff 82 suites / 1368 tests, agent 87 suites / 1587 tests.

The moved suite becomes test/bff/embedded-bff.e2e.test.ts: a real Agent over a real datasource, mounted on Express, with a real BFF in front — list, search, relation-extended search, count, and an agent-side refusal surfacing as the BFF error contract. It also pins what the unit tests cannot see: /forest still answers next to it, /bffalo is not claimed, and a stopped agent answers 503. Its data contract runs twice — over the in-process dispatcher, and over a real socket against a listening agent, so the HTTP transport the standalone deployment uses keeps a gate. The CI job that ran the old suite now runs this one, and the unit job ignores it.

Fixes PRD-1076

🤖 Generated with Claude Code

Note

Add addBff() to Agent to serve an embedded BFF in-process

  • Agent.addBff() mounts a BFF at /bff using an in-process dispatcher, managed alongside the agent lifecycle (start/stop/restart) in agent.ts and embedded-bff.ts
  • agent-bff gains resolveTransport to choose between in-process and HTTP transport; when neither a dispatcher nor AGENT_URL is set, data and action routes fall back to stubs in build-bff.ts
  • The /health endpoint now returns a features object (oauth, ai, cors, openapi) and derives status from an explicit healthy boolean in health-route.ts
  • CI now runs embedded BFF e2e tests in @forestadmin/agent; the old agent-bff integration test file is deleted
  • Risk: package.json adds engines.node: >=22.12.0 and declares @forestadmin/agent-bff as an optional dependency; if the package is missing at runtime, EmbeddedBff throws a guidance error. Agent.mountAiMcpServer() now throws on MCP basePath /bff collision

Changes since #1876 opened

  • Refactored EmbeddedBff class to implement a two-phase lifecycle with prepare() for early configuration validation and start() for building the BFF server, forwarding counter metrics to the host logger at Warn level while dropping gauges, preserving originalUrl and rewriting req.url when handling requests under /bff, returning 503 responses with distinct error types (bff_not_started or bff_stopped) when not serving, and wrapping import failures of @forestadmin/agent-bff with clearer error messages that retain the original cause [271c47b]
  • Modified Agent.start() to call this.embeddedBff?.prepare() before building routes or subscribing to events for early BFF configuration validation, and updated Agent.addBff() and Agent.mountAiMcpServer() to use a new collidesWithBff() utility that normalizes base paths and detects collisions with any path under /bff [271c47b]
  • Updated the /health endpoint in buildBff() to require a non-empty FOREST_AUTH_SECRET when using an in-process dispatcher, returning degraded status (503) if the secret is missing, and added a features block to health responses containing OAuth, AI, CORS, and OpenAPI configuration flags [271c47b]
  • Introduced collidesWithBff() utility function in bff-routes that normalizes a provided base path by trimming whitespace, ensuring a leading slash, collapsing multiple slashes, and dropping trailing slashes, then returns true if the normalized path with /mcp appended would land under /bff [271c47b]
  • Refactored E2E tests in embedded-bff.e2e.test.ts to extract a reusable itServesTheDataContract suite that runs over both in-process and HTTP transports, added assertions for search behavior including plain search, relation-extended search, query syntax crossing relations, filter vs search semantics, whitespace handling, and counts, and updated health checks and stopped-agent behavior assertions to match new 503 error types [271c47b]
  • Added test suites covering buildBff() behavior with an in-process AgentDispatcher, lifecycle behavior including restart invalidation, early failure on invalid options, 503 responses with distinct error types pre-start and post-stop, URL prefix handling with originalUrl preservation, metrics forwarding, error context serialization in logs, and missing dependency scenarios [271c47b]
  • Exported BffEmbedOptions type from packages/agent/src/index.ts and expanded documentation comments for its fields to clarify OAuth gating, CORS semantics, agent timeout implications, and OpenAPI exposure [271c47b]
  • Updated CI workflow test exclusion pattern from llm.integration|search-agent.integration to llm.integration|embedded-bff.e2e and revised comments and job names to reflect both embedded and HTTP transports for BFF integration tests [271c47b]

Macroscope summarized 339fda7.

Running a BFF meant a second deployment: another process, another port,
another set of secrets to keep in sync with the agent's. `addBff()` serves it
at /bff on the agent's own port instead, on every mount target.

The BFF reaches the agent through the in-process dispatcher the embedded MCP
server already uses, so there is no socket, no agent url to guess per host
framework, and no second listener. Everything it shares with the agent — the
secrets, the Forest urls, the logger — is inherited rather than repeated.

The dispatcher is registered in addBff() rather than at start(): its hook is
pushed on first use and mount() only runs the hooks registered before it, so
asking later would leave every BFF call throwing until the first restart.
`/bff` answers 503 while the agent is starting and stops answering entirely
once it stopped, since a host application keeps the middleware it registered.

The search integration suite moves here from agent-bff, which loses its dev
dependency on the agent and with it the build cycle that dependency would have
created. It now covers the embedded path end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 1, 2026

Copy link
Copy Markdown

PRD-1076

@qltysh

qltysh Bot commented Sep 1, 2026

Copy link
Copy Markdown

2 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 4): buildAgentRouteMiddlewares 2

Comment thread packages/agent/src/embedded-bff.ts Outdated
Comment thread packages/agent/src/agent.ts Outdated
@qltysh

qltysh Bot commented Sep 1, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (9)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/health-route.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/agent.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/agent/in-process-transport.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/build-bff.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/bff-http-server.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/index.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/framework-mounter.ts100.0%
New file Coverage rating: C
packages/agent/src/embedded-bff.ts79.4%14-17, 79, 97-99, 122
New file Coverage rating: A
packages/agent/src/bff-routes.ts100.0%
Total92.0%
🤖 Increase coverage with AI coding...
In the `feature/prd-1076-7-add-bff` branch, add test coverage for this new code:

- `packages/agent/src/embedded-bff.ts` -- Lines 14-17, 79, 97-99, and 122

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Without a sink `createReadModel` builds a console one, which reports its
gauges at Info. That is what the standalone deployment wants; embedded it puts
a schema-cache age line in the host's own logs on every read, for a number
nobody reads there. `buildBff` now takes the sink, and the agent passes a
no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/agent-bff/src/build-bff.ts Outdated

@Tonours Tonours left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec (PRD-1076): steps 8, 9 (its /bff half), 10, 11, 12, 15 and 16 are all delivered, and the two packaging decisions the ticket asked for by name — engines: { node: ">=22.12.0" } and the exact-pinned optional peer — match step 15 word for word, so neither is in question here. Two steps landed partially:

  • Step 13 asked that a buildBff failure not leave a half-started agent, "the host is already serving /forest when start() rejects, and a retry could duplicate hooks and subscriptions". The mounted flag and the audit-trail close are in, well reasoned. The retry half is not: subscribeToServerEvents() and onRefreshCustomizations() run before the failure point and there is no guard against a second start().
  • Step 17 asked to "document that the Promise.race cancels nothing: an action cut at 10s keeps running agent-side, and a retry can double the mutation". The propagation itself is complete and I traced it end to end — agentTimeoutMs → config → resolveTransportInProcessRequesterinjectWithTimeout. The documentation is absent: git grep -i 'cancels nothing|keeps running|double the mutation' over both this PR and the docs PR returns nothing. It is a safety caveat about a doubled mutation, and embedded mode is what makes it reachable.

What I checked and found sound, so it does not get re-litigated: the bff-routes.ts helpers are correct on every input I threw at them, including /bff and /bff/ both stripping to / and never to '', /bffalo falling through, and query/fragment handling; the agent's auth chain genuinely still runs on the in-process path (the dispatcher injects into the mounted /forest router whose first root route installs jwt({ secret: authSecret }), and types.ts states the shared-secret invariant explicitly); getInProcessDispatcher() at builder time really is necessary and the comment explaining why is right; stop() leaks nothing, since agent-bff/src holds no timer; and the dependency direction between the two packages is now cleanly one-way.

One packaging mechanic worth a decision rather than a fix: engines: ">=22.12.0" is blocking under yarn 1 — an install failure, not a warning — and it ships here as a feat, so a minor bump. Any consumer still on Node 20 fails to install on a minor. The ticket asked for the field; it did not weigh that. Your call whether it wants a release note.

Eleven findings inline, three of them Must fix.

Comment thread packages/agent/src/agent.ts Outdated
throw new Error('addBff can only be called once.');
}

if (this.mcpBasePath === BFF_PREFIX) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Must fix

The collision guard is defeated by any non-literal spelling of /bff, and when it is, the entire MCP surface goes silently dark — the server initialises, logs Server initialized successfully, and is never reachable.

Both guards compare the raw option string to '/bff', while mcp-server normalises its basePath (trim, add the leading slash, collapse and strip trailing slashes) before building its paths. Executed against the shipped helpers:

basePath="/bff"     guard trips=true
basePath="/bff/"    guard trips=false   <-- MCP shadowed silently
basePath="/bff/ai"  guard trips=false   <-- MCP shadowed silently
basePath="bff"      guard trips=false   <-- MCP shadowed silently

'bff' and '/bff/' both normalise to /bff inside the MCP server, so it claims exactly /bff/oauth/ and /bff/mcp — the collision this error exists to reject. '/bff/ai' is accepted by normalizeMountPath's allowlist (it permits multi-segment prefixes) and lands wholly under /bff. In every case isBffRoute claims the path, so POST /bff/mcp returns the BFF's 404.

And the comment above BFF_MCP_COLLISION has the direction backwards: "the MCP server … is consulted first, so it would shadow two of the BFF's own routes". It is the other way round. addBff() calls setBffCallback at builder time; setMcpCallback runs inside start(); handlerFor iterates a Map in insertion order and returns the first match, so 'bff' wins. Map.set on an existing key preserves position, so restart() does not reorder it. The throw is still right — it is the stated victim that is wrong, which matters because the next person reasoning about a new handler will reason from a false premise.

Normalise before comparing, and test containment rather than equality — either path being a prefix of the other is a collision, whichever is consulted first. isBffRoute(${normalized}/mcp) covers all four rows above.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and you are right on both counts. collidesWithBff() in bff-routes.ts normalizes the way normalizeMountPath does, then tests containment with isBffRoute(${normalized}/mcp) — so all four of your rows now throw, and the error names the basePath the caller actually passed instead of a hardcoded /bff. The normalization is mirrored rather than imported because mcp-server keeps it internal and exporting it would widen this PR into a third package. The comment is rewritten with the direction the right way round: the BFF registers at builder time, wins the Map first-match, and the MCP surface is the one that goes dark. Cases in bff-routes.test.ts and agent-bff.test.ts.

Comment thread packages/agent/src/types.ts Outdated
export type BffEmbedOptions = {
/**
* Base64-encoded 32-byte AES-256 key encrypting stored refresh tokens. Enables OAuth (mode 1),
* and with it the AI relay. Absent, the BFF serves API-key callers only.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Must fix

This sentence makes a security claim that the code does not honour, on public API surface.

Absent, the BFF serves API-key callers only.

It does not. createAuthModeMiddleware is mounted whenever forestAuthSecret exists, and it verifies a bff_access JWT with authSecret alone — no session store, no tokenEncryptionKey — then issues the agent token from that principal. So with the key omitted, every data route still answers to anyone who can sign a bff_access JWT with the agent's authSecret. This PR's own e2e demonstrates it: addBff({ allowedOrigins: [...] }) with no key reports features.oauth: false, and POST /bff/agent/v1/books/list with a bff_access token returns 200.

What the key actually gates is the login/refresh flow and the AI relay. The consequence of the current wording is the dangerous direction: an integrator who omits it believing they have closed session access has in fact left the data surface open.

Reword to say what it gates, and say plainly that the session-bearer path is governed by authSecret, not by this key.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The doc now says what the key gates — the OAuth login/refresh flow and the AI relay — and says plainly that it is not an access control: the data routes accept a bff_access bearer either way, because that token is verified against authSecret alone. Also corrected the same sentence in the PR description.

Comment thread .github/workflows/build.yml Outdated
@@ -153,7 +153,7 @@ jobs:
# Boots a real agent on a local HTTP port, so it stays out of the unit job. Unlike the LLM suite

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Must fix

Applies to: .github/workflows/build.yml:114 (the unit job's ignore pattern, not in this diff) — anchored on the comment this diff makes false.

This comment now says the opposite of what happens. The unit job runs yarn test --coverage --testPathIgnorePatterns='llm.integration|search-agent.integration'. The deleted file matched search-agent.integration; the new one is test/bff/embedded-bff.e2e.test.ts, which matches neither pattern, and packages/agent/jest.config.ts collects test/**/*.test.ts.

So the suite — two full agents booted, a mock Forest server, a 30s beforeAll budget — now runs inside the fail-fast matrix's agent cell at timeout-minutes: 10, in addition to its own dedicated job. A slow runner takes the whole matrix down with it, and the failure will look like an unrelated unit regression.

One word: --testPathIgnorePatterns='llm.integration|embedded-bff.e2e'.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: --testPathIgnorePatterns='llm.integration|embedded-bff.e2e'. Also updated the dedicated job comment and name, since it now gates both transports rather than only the embedded one.

* Dynamically load the optional @forestadmin/agent-bff package. Deferred so agents that embed no
* BFF never load its code at startup.
*/
private async importPackage() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Should fix

error is bound and never used, so every way of failing to load a package that is installed collapses into "install it". The developer installs, reinstalls, and the message does not move.

Realistic causes here, since the package loads from the customer's own node_modules: a missing or ESM-only transitive dependency of agent-bff (the message then names the wrong package), a SyntaxError from a partially written install or a bundler that rewrote the dist, a throw during agent-bff's own module evaluation, ESM/CJS interop under a type: module host, or a Node version below the newly declared engines — which is a resolution failure, not a helpful error.

This is the class PR #1834 already fixed in this repo for mcp-server, and the MCP path sits one file over doing it right: it logs Failed to initialize MCP server: ${message} and rethrows the original.

Keep the hint, keep the cause — engines now guarantees Node ≥22.12, so cause is available:

const { message } = error as Error;
throw new Error(
  `Cannot load '@forestadmin/agent-bff' (required by addBff()): ${message}. ` +
    'If it is not installed: `npm install @forestadmin/agent-bff`.',
  { cause: error },
);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, with the cause kept: the message now carries the load failure verbatim, and cause is set by assignment rather than through the constructor options bag — the repo targets ES2020, so new Error(msg, { cause }) does not typecheck even though the declared engines guarantee a runtime that reads it. Three cases in agent-bff-missing-dep.test.ts.

Comment thread packages/agent/src/embedded-bff.ts Outdated

const { buildBff, parseConfig, IN_PROCESS_AGENT_URL } = await this.importPackage();

const config = parseConfig({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Should fix

Everything this line validates is data the caller already handed to addBff(), but it is validated at start()after mount(). So a mistyped key does not degrade the BFF, it kills the agent, with the host left half-serving.

parseConfig throws synchronously on a tokenEncryptionKey that is not base64/32 bytes (the likeliest typo), a non-IANA defaultTimezone, a zero or out-of-range timeout, and a malformed Forest URL. Resulting state for the documented Express pattern, where app.listen() precedes agent.start(): /forest live and serving; /bff/* claimed since builder time and answering 503 bff_not_started for the rest of the process; start() rejected. There is no path back without a process restart — embedOptions is never cleared and setBffCallback(null) is never called.

Retrying start() is worse, and this is step 13's unaddressed half: subscribeToServerEvents() and onRefreshCustomizations() run before the failure point, with no guard against a second start(), so a retry opens a second SSE subscription and adds a second restart listener (the handler is a bare eventEmitter.on, no dedupe). The duplicate is then masked by the isRestarting guard, which only logs at Debug.

Call parseConfig in addBff() and leave only buildBff — which genuinely needs the mounted stack — in start(). That turns "app half-up, /bff bricked" into a throw on the line the developer wrote.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. parseConfig moved into a new EmbeddedBff.prepare(), which start() calls first — before buildRouterAndSendSchema(), before subscribeToServerEvents(), before mount(). So a mistyped key throws with nothing mounted and nothing subscribed, instead of leaving a live /forest next to a bricked /bff; and since the failure is now ahead of the subscription, a retry cannot duplicate it either. buildBff stays in start(dispatcher), which genuinely needs the mounted stack. Asserted in agent-bff-lifecycle.test.ts. The general "second start() on an agent with no BFF" guard is pre-existing Agent.start() behaviour on every failure path, so I left it out of this PR.

fail-on-cache-miss: true
- name: Run BFF integration tests
run: yarn workspace @forestadmin/agent-bff test --testPathPattern='search-agent.integration'
run: yarn workspace @forestadmin/agent test --testPathPattern='bff/embedded-bff.e2e'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Should fix

Applies to: packages/agent-bff/test/data/search-agent.integration.test.ts (deleted by this PR) — anchored on the job that used to gate it.

After this PR no end-to-end coverage of the HTTP transport survives anywhere in the repo, and that transport is the standalone deployment's only route to the agent — the configuration shipping today. The deleted suite was the only test booting a real agent on a real port and driving createHttpTransport through a socket; the replacement uses the in-process dispatcher exclusively. Every remaining createHttpTransport test mocks @forestadmin/agent-client away, and agent-bff can no longer boot an agent in its own suite at all, having dropped those devDeps.

The job is still named "BFF Integration Tests", so a reader assumes it gates the standalone path. It no longer does.

What that leaves unguarded on the HTTP path specifically: HttpRequester header and auth-token construction, query-string encoding, JSON:API deserialization over the wire, the socket timeout, HTTP status → BFF error mapping, and AGENT_URL/prefix joining.

Separately, thirteen assertions became five, and five behaviours now have no case in any package: relation crossing via the author.name: query syntax without searchExtended (whose deleted case commented that it was "pinned here so it cannot change unnoticed"), the 422 relation_field_not_supported contrast for the same path in a filter, search ∩ filter intersecting rather than replacing, whitespace-only search listing everything, and the count search refusal.

The ticket sanctions moving the suite (step 7), but it says the deleted file's unique value moves — an equivalent that swaps the transport is not an equivalent for the transport it left behind. packages/agent/test/bff/ is now the natural home for both: it already has the agent and supertest, so pointing a createHttpTransport BFF at mountOnStandaloneServer costs one describe block and restores the gate the job name still claims.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. embedded-bff.e2e.test.ts now runs its data contract twice through one shared itServesTheDataContract(): once over the in-process dispatcher, once over a buildBff({ AGENT_URL }) pointed at the express app listening on a real port — so createHttpTransport, header and token construction, query encoding, JSON:API over the wire and the status mapping are gated again. All five behaviours you listed as orphaned are back in that contract, so each is now asserted on both transports (13 cases x 2, plus the health//forest//bffalo/stopped cases — 30 in the suite). Job renamed to say it gates both.

* Stop answering. The host application keeps whatever middleware it registered, so without this
* a stopped agent would go on serving BFF data through a dispatcher pointing at a dead stack.
*/
stop(): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Should fix

stop() sets bff = null and nothing else, so embedOptions stays set and handle answers 503 bff_not_started for the rest of the process lifetime. Three consequences:

Boot and shutdown become indistinguishable. A k8s or ALB probe on /bff/health gets the same 503 bff_not_started while the agent is booting and after a deliberate stop(). During boot the right action is to wait; after stop it is to take the pod out of rotation. EmbeddedBff knows which state it is in — one flag separates bff_not_started from bff_stopped.

The type misleads. "not started" for a BFF that started, served traffic, and was stopped.

/bff is permanently occupied. RootMiddleware.set supports removal and setBffCallback already accepts null; neither is used on stop. So a host that stops the agent and mounts its own /bff handler, or a test harness cycling agents on one Express app, can never get the path back — its own routes stay shadowed by a 503. setBffCallback(null) in stop() is a one-liner and makes the middleware genuinely fall through.

Minor, same body: it omits message, unlike every other BFF error, so a client rendering error.message shows blank.

While here: the if (!this.embedOptions) next?.() branch just above is unreachable — configure() runs two lines before setBffCallback(bff.handle) — so its comment describes a fall-through that does not exist, while the state that should fall through (stopped) 503s instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half fixed. A stopped BFF now answers bff_stopped with a message, and boot answers bff_not_started — a probe can tell shutdown from boot, which was the substance of the finding. The unreachable !embedOptions branch is gone: embedOptions moved to the constructor, so the state cannot exist.

I did not call setBffCallback(null). It contradicts the other half of your finding — with the handler removed there is no way to answer bff_stopped at all, and a stopped agent falling through to the host 404 is the "wrong url" reading this 503 exists to avoid. agent.stop() is process shutdown, not a handover, so a host reclaiming /bff afterwards is not a flow we support. Happy to revisit if you have a concrete case where it is.

return;
}

req.url = stripBffPrefix(req.url ?? BFF_PREFIX);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Should fix

req.url is mutated in place and never restored, so on a Koa host every access log and metric of the customer's own middleware records the stripped path.

Express hosts are mostly fine — Express sets req.originalUrl at app entry and morgan reads req.originalUrl || req.url. Koa has no originalUrl, and ctx.url / ctx.path read req.url live: expressToKoa hands ctx.req straight through and resolves on finish, so any host middleware registered before the agent's that logs after await next() sees /health or /agent/v1/books/list. A /bff/health probe appears in their log as /health — colliding with paths the host may itself serve. Same on the raw-http and standalone paths, and for any res.on('finish') handler the host registered on any mount target.

The repo already has the pattern, one file over in this package: fastify-adapter.ts:127-132 saves originalUrl and restores it in the next callback. There is no fall-through to restore in here, but the mutation still escapes to everything holding a reference to req. Restore it after callback returns, or set req.originalUrl ??= req.url before stripping.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly fixed. req.originalUrl ??= req.url before the rewrite, so anything reading originalUrl reports the url the client asked for.

On the Koa half: ctx.originalUrl is not missing — koa/lib/application.js:215 sets context.originalUrl = request.originalUrl = req.url in createContext, at host entry, before any middleware, so it stays correct. What is genuinely live is ctx.url / ctx.path, and neither restoring after callback() nor on finish fixes those: Koa reads req.url lazily while routing, and a host finish listener registered before the agent middleware fires before any restore of ours. The only thing that would work is handing the BFF a Proxy over req with url overridden, and proxying a live IncomingMessage stream through the body parser is more risk than the log fidelity is worth. Asserted the originalUrl behaviour in agent-bff-lifecycle.test.ts; say the word if you want the proxy anyway.

this.setMcpCallback(mcpHttpCallback ?? null, mcpIsMcpRoute);
await this.remount(router);
// A restart means the customizations changed, so the schema the BFF read is stale.
this.embeddedBff?.invalidate();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Violates conventionsskills/conventions/testing.md#Cover error and edge paths, not only the happy path and skills/conventions/agent.md#A module's public API is its curated barrel

Four new reachable paths have no case, and one public type is missing from the barrel.

invalidate() on restart — this line. restart() is public and documented as the runtime-remount entry point; no test calls it on an agent with an embedded BFF. The one line whose job is to stop the BFF serving a schema that no longer matches the customizations is unguarded: delete it and every test passes, so the regression ships as a stale schema answered to third-party UIs.

The missing-package path — untested, though the sibling feature proves it costs nothing: test/agent-workflow-executor-missing-dep.test.ts mocks the module to throw and asserts start() rejects with the actionable message. The BFF has no equivalent, so a refactor of importPackage can turn its message into a generic startup failure with nothing failing.

buildBff's dispatcher branchdispatcher is a new public option on buildBff, and no test in packages/agent-bff/test/ passes one. test/agent/in-process-transport.test.ts covers the transport unit, never the wiring that selects it. So the entire embedded path is unverified inside the package that owns the code.

healthy: dispatcher !== undefined || config.hasAllRequired — the deliberate new "embedded is always healthy" semantic, asserted nowhere in agent-bff; flipping the operands keeps that suite green.

BarrelBffEmbedOptions is the parameter type of a public method on the exported Agent, and index.ts exports only AgentOptions and WorkflowExecutorEmbedOptions. The sibling feature's embed-options type is in the barrel; this one is not, so a consumer naming the options has to import from @forestadmin/agent/dist/types — the bypass the barrel exists to prevent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five fixed.

  • invalidate() on restart: agent-bff-lifecycle.test.ts asserts restart() invalidates once and does not rebuild.
  • Missing package: agent-bff-missing-dep.test.ts, mirroring the workflow-executor one, plus a case on the preserved cause.
  • buildBff's dispatcher branch: build-bff-dispatcher.test.ts drives a real list through the dispatcher (path, bearer, timeout, records) and asserts the no-dispatcher/no-AGENT_URL config falls back to the 501 stub.
  • healthy: both operands asserted in the same file.
  • Barrel: BffEmbedOptions exported from index.ts next to WorkflowExecutorEmbedOptions.

ctx.status = config.hasAllRequired ? 200 : 503;
ctx.body = { status: config.hasAllRequired ? 'ok' : 'degraded', version };
ctx.status = healthy ? 200 : 503;
ctx.body = { status: healthy ? 'ok' : 'degraded', version, features };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Preferential

/health is unauthenticated — it sits second in the middleware array, long before createAuthModeMiddleware — and now returns features: { oauth, ai, cors, openapi }, where oauth and ai are Boolean(config.tokenEncryptionKey). So an anonymous caller learns which capabilities the deployment configured.

No secret leaks and the values are useful, so this may well be the right trade. What changes it is the mount: embedded, this lands on /bff/health of the customer's own public application, where standalone it lived on a separately deployed port they could firewall. Worth deciding deliberately rather than inheriting — a features block gated behind the same credential the rest of the surface uses would keep the operational value without the anonymous reconnaissance.

What is not a judgment call: test/http/bff-http-server.test.ts has a case named should never expose config presence or secret values in the response body, and it stays green because it only greps for the secret literals. The invariant its name asserts is now false. Either the disclosure goes, or that test's name and body do.

Related in the same file's suite: the /health case was weakened from toEqual to toMatchObject({ status, version }) and renamed nothing, so its "with 200 ok and the version only" is also false now that features is in the body — and the four flags have no assertion at all on the BFFHttpServer path (build-bff.test.ts does pin them for the buildBff path).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test names fixed, disclosure kept.

The /health case is renamed and now asserts the full body including all four flags, plus a second case pinning them on a partial config — the BFFHttpServer path had none. The invariant test is renamed to what it actually guarantees ("secret values or the config itself"), which is the claim that survives.

On the trade: I am keeping features. It names capabilities, never values, and it is what makes an embedded /bff/health useful to a load balancer or to support — which is the deployment where the caller cannot reach a second port to ask. Gating it behind the same credential as the rest would leave a probe with nothing to read. Worth reopening if you would rather have it behind auth.

The /bff collision guard compared the raw mountAiMcpServer basePath to the
literal "/bff", but the MCP server normalizes its own: "bff", "/bff/" and
"/bff/ai" all landed inside /bff and slipped past. The BFF is registered at
builder time and wins the root middleware first-match, so in every one of
those cases the MCP server booted, logged success and was never reachable.
Normalize before comparing, and test containment rather than equality — the
comment above the error also had the victim backwards.

Configuration is now parsed in a prepare() step that runs before mount(),
not after it: everything it validates is what the caller handed to addBff(),
so a mistyped tokenEncryptionKey used to leave the host serving /forest with
/bff permanently answering 503 and no way back short of a restart. It also
moves that failure ahead of subscribeToServerEvents, so a retry cannot
duplicate the subscription.

Counters now reach the host logs. They are the schema cache and the
action-endpoint resolver only channel — neither takes a logger — and every
one reports a failure, so dropping them made a stale schema served to
third-party UIs completely silent. Gauges stay dropped, which is what the
original comment reasoned about.

Also: an Error in a log context is unfolded instead of serializing to {};
a package that fails to load keeps its reason and cause, since it resolves
from the host node_modules and "install it" is often the wrong advice;
a stopped BFF answers bff_stopped with a message rather than bff_not_started,
so a probe can tell shutdown from boot; originalUrl keeps the url the client
asked for; /health no longer reports ok on a dispatcher without an auth
secret, where the agent edge is a stub; the tokenEncryptionKey doc no longer
claims it closes the data surface (it gates the login flow, authSecret guards
the session bearer); the agentTimeoutMs doc says the timeout cancels nothing.

The unit job ignored search-agent.integration, a file this stack deleted, so
the e2e suite ran inside the fail-fast matrix as well as its own job. The
suite also now runs its data contract over both transports: the HTTP one had
no end-to-end coverage left anywhere in the repo, and it is the standalone
deployment only route to the agent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nbouliol

nbouliol commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Pushed 271c47b. Eleven inline findings answered individually — nine fixed, two partly, and the reasoning is in each thread. On the two points in the review body itself:

Step 13, the retry half. Addressed for the BFF: parseConfig now runs in EmbeddedBff.prepare(), called at the top of start() before buildRouterAndSendSchema(), subscribeToServerEvents() and mount(). The likeliest failure — a mistyped option — therefore happens before either the subscription or the restart listener exists, so a retry cannot duplicate them. The general "no guard against a second start()" is pre-existing Agent.start() behaviour on every failure path in the class, unrelated to addBff(), so I did not widen this PR into it.

Step 17, the Promise.race caveat. Documented on agentTimeoutMs in types.ts: it bounds how long the BFF waits and cancels nothing, an action cut at the deadline keeps running agent-side and still commits, so a client retrying on a timeout can double the mutation.

engines: ">=22.12.0" under yarn 1. Correct that it is a hard install failure, not a warning, and that it ships as a minor. Keeping the field — step 15 asked for it by name and addBff() pulls in a package that requires it — and I will add the release note, since a Node 20 consumer failing to install on a minor deserves a heads-up rather than a surprise.

Both suites green: agent-bff 82 suites / 1368 tests, agent 87 suites / 1587 tests, lint clean on both.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants