From 62e5e3ce1c87e6a2f74a78f0f4a81f6347177af2 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 1 Sep 2026 23:13:08 +0200 Subject: [PATCH 1/2] refactor(agent-bff): assemble the server behind a single buildBff `runCli` owned the middleware order, so an embedding host had no way to get the same stack without a listener. Move the whole assembly into `buildBff`, which returns the request handler; `runCli` is now parseConfig + buildBff + listen. `/health` and the version header move with it, so they belong to the handler rather than to the listener. `BFFHttpServer` keeps its `middlewares` constructor working for external consumers and gains an optional `callback`. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent-bff/src/build-bff.ts | 457 ++++++++++++++++++ packages/agent-bff/src/cli-core.ts | 421 +--------------- packages/agent-bff/src/cli-dispatch.ts | 3 +- .../agent-bff/src/http/bff-http-server.ts | 43 +- packages/agent-bff/src/http/health-route.ts | 25 + .../src/http/version-header-middleware.ts | 11 + packages/agent-bff/src/index.ts | 2 + packages/agent-bff/test/build-bff.test.ts | 88 ++++ packages/agent-bff/test/index.test.ts | 1 + .../openapi/openapi-mount-invariant.test.ts | 2 +- 10 files changed, 612 insertions(+), 441 deletions(-) create mode 100644 packages/agent-bff/src/build-bff.ts create mode 100644 packages/agent-bff/src/http/health-route.ts create mode 100644 packages/agent-bff/src/http/version-header-middleware.ts create mode 100644 packages/agent-bff/test/build-bff.test.ts diff --git a/packages/agent-bff/src/build-bff.ts b/packages/agent-bff/src/build-bff.ts new file mode 100644 index 0000000000..68eaa61c3f --- /dev/null +++ b/packages/agent-bff/src/build-bff.ts @@ -0,0 +1,457 @@ +import type { BFFConfig } from './config/env-config'; +import type { SessionStore } from './oauth/session-store'; +import type { UnfoldSource } from './openapi/unfolded-document'; +import type { Logger } from './ports/logger-port'; +import type { Metrics } from './ports/metrics-port'; +import type ReadModelStore from './read-model/read-model-store'; +import type { IncomingMessage, ServerResponse } from 'http'; +import type { Middleware } from 'koa'; + +import { bodyParser } from '@koa/bodyparser'; +import Koa from 'koa'; + +import createActionRoutesMiddleware from './action/action-routes-middleware'; +import createConsoleLogger from './adapters/console-logger'; +import createAgentStubMiddleware from './agent/agent-stub'; +import AiProxyClient from './ai/ai-proxy-client'; +import createAiRoutesMiddleware, { AI_QUERY_ROUTE } from './ai/ai-routes-middleware'; +import createApiKeyAuthenticator from './api-key/api-key-authenticator'; +import ApiKeyClient from './api-key/api-key-client'; +import createApiKeyMiddleware from './api-key/api-key-middleware'; +import createResolveCache from './api-key/resolve-cache'; +import createAuthModeMiddleware from './auth/auth-mode-middleware'; +import createContextRoutesMiddleware from './context/context-routes-middleware'; +import createCorsMiddleware from './cors/cors-middleware'; +import createPerKeyOriginMiddleware from './cors/per-key-origin'; +import createDataRoutesMiddleware from './data/data-routes-middleware'; +import createDocsRoutes from './docs/docs-routes'; +import { unauthorized, unsupportedMediaType } from './http/bff-http-error'; +import BODY_LIMIT, { AI_BODY_LIMIT } from './http/body-limit'; +import createErrorMiddleware from './http/error-middleware'; +import createHealthRoute from './http/health-route'; +import createVersionHeaderMiddleware from './http/version-header-middleware'; +import ForestServerClient from './oauth/forest-server-client'; +import createOAuthRoutes from './oauth/oauth-routes'; +import createInMemorySessionStore from './oauth/session-store'; +import createTokenCipher from './oauth/token-cipher'; +import createOpenApiRoutes, { OPENAPI_PATH } from './openapi/openapi-routes'; +import PermissionsCache from './permissions/permissions-cache'; +import PermissionsClient from './permissions/permissions-client'; +import createPermissionsRoutesMiddleware from './permissions/permissions-routes-middleware'; +import isAgentPath from './rate-limit/agent-path'; +import createRateLimitMiddleware from './rate-limit/rate-limit-middleware'; +import createReadModel from './read-model/create-read-model'; +import createTimezoneMiddleware from './timezone/timezone-middleware'; +import version from './version'; + + +/** What a host must hand to `http.createServer`, or mount, to serve the BFF. */ +export type BffCallback = (req: IncomingMessage, res: ServerResponse) => void | Promise; + +export interface BuildBffOptions { + config: BFFConfig; + logger?: Logger; +} + +export interface Bff { + callback: BffCallback; +} + +const SESSION_TTL_SECONDS = 24 * 60 * 60; + +const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']); + +const JSON_BODY_TYPES = ['application/json', 'application/*+json']; + +function hasBody(ctx: Parameters[0]): boolean { + return (ctx.request.length ?? 0) > 0 || ctx.get('transfer-encoding') !== ''; +} + +function createJsonOnlyGuard(): Middleware { + return async function jsonOnlyGuard(ctx, next) { + if (BODY_METHODS.has(ctx.method) && hasBody(ctx) && !ctx.is(JSON_BODY_TYPES)) { + throw unsupportedMediaType(); + } + + await next(); + }; +} + +function agentScoped(middleware: Middleware): Middleware { + return async function scoped(ctx, next) { + if (!isAgentPath(ctx.path)) { + await next(); + + return; + } + + await middleware(ctx, next); + }; +} + +function createBodyParser(hasAiQueryRoute: boolean): Middleware { + const extendTypes = { json: JSON_BODY_TYPES }; + const parseBody = bodyParser({ jsonLimit: BODY_LIMIT, extendTypes }); + + if (!hasAiQueryRoute) return parseBody; + + const parseAiBody = bodyParser({ + jsonLimit: AI_BODY_LIMIT, + enableTypes: ['json'], + extendTypes, + }); + + return async function selectedBodyParser(ctx, next) { + if (ctx.path === AI_QUERY_ROUTE) { + await parseAiBody(ctx, next); + + return; + } + + await parseBody(ctx, next); + }; +} + +function createApiKeyUnavailableGuard(logger: Logger): Middleware { + return async function apiKeyUnavailableGuard(ctx, next) { + if (ctx.state.authMode === 'api-key') { + logger('Error', 'API key auth requested but the resolver is not configured'); + throw unauthorized('Credentials could not be validated'); + } + + await next(); + }; +} + +interface ResolvedOAuthConfig { + forestServerUrl: string; + forestEnvSecret: string; + forestAppUrl: string; + forestAuthSecret: string; + tokenEncryptionKey: string; +} + +export function resolveOAuthConfig(config: BFFConfig): ResolvedOAuthConfig | undefined { + const { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey } = + config; + + if ( + forestServerUrl && + forestEnvSecret && + forestAppUrl && + forestAuthSecret && + tokenEncryptionKey + ) { + return { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey }; + } + + return undefined; +} + +interface OAuthSession { + store: SessionStore; + serverClient: ForestServerClient; + forestServerUrl: string; +} + +interface OAuthEdge { + middlewares: Middleware[]; + environmentId?: number; + session?: OAuthSession; +} + +async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise { + const oauthConfig = resolveOAuthConfig(config); + + if (!oauthConfig) { + logger('Warn', 'OAuth routes disabled: required configuration is missing'); + + return { middlewares: [] }; + } + + const { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey } = + oauthConfig; + + const serverClient = new ForestServerClient({ forestServerUrl, envSecret: forestEnvSecret }); + const environmentId = await serverClient.fetchEnvironmentId(); + + const sessionStore = createInMemorySessionStore({ + cipher: createTokenCipher(tokenEncryptionKey), + now: () => Date.now(), + sessionTtlSeconds: SESSION_TTL_SECONDS, + }); + + const oauthRoutes = createOAuthRoutes({ + serverClient, + sessionStore, + forestAppUrl, + authSecret: forestAuthSecret, + environmentId, + logger, + }); + + return { + middlewares: [oauthRoutes], + environmentId, + session: { store: sessionStore, serverClient, forestServerUrl }, + }; +} + +interface ResolvedApiKeyConfig { + forestServerUrl: string; + forestEnvSecret: string; + forestAuthSecret: string; +} + +function resolveApiKeyConfig(config: BFFConfig): ResolvedApiKeyConfig | undefined { + const { forestServerUrl, forestEnvSecret, forestAuthSecret } = config; + + if (forestServerUrl && forestEnvSecret && forestAuthSecret) { + return { forestServerUrl, forestEnvSecret, forestAuthSecret }; + } + + return undefined; +} + +function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware | undefined { + const apiKeyConfig = resolveApiKeyConfig(config); + + if (!apiKeyConfig) { + logger('Warn', 'API key auth disabled: required configuration is missing'); + + return undefined; + } + + const client = new ApiKeyClient({ + forestServerUrl: apiKeyConfig.forestServerUrl, + envSecret: apiKeyConfig.forestEnvSecret, + }); + const cache = createResolveCache({ now: () => Date.now() }); + const authenticator = createApiKeyAuthenticator({ + client, + cache, + authSecret: apiKeyConfig.forestAuthSecret, + }); + + return createApiKeyMiddleware({ authenticator, logger }); +} + +interface ReadModelBundle { + store: ReadModelStore; + apiKeyConfig: ResolvedApiKeyConfig; +} + +/** + * The read-model store the data routes, the action routes, the permissions endpoint and the OpenAPI + * unfolding all share — one cache, one schema fetch. Needs no agent: the permissions endpoint and the + * schema itself come from the SaaS. + */ +function resolveReadModelBundle( + config: BFFConfig, + logger: Logger, + metrics?: Metrics, +): ReadModelBundle | undefined { + const apiKeyConfig = resolveApiKeyConfig(config); + + if (!apiKeyConfig) return undefined; + + const { store } = createReadModel({ + forestServerUrl: apiKeyConfig.forestServerUrl, + envSecret: apiKeyConfig.forestEnvSecret, + logger, + metrics, + }); + + return { store, apiKeyConfig }; +} + +/** + * When unfolding is possible, in ONE place: the document needs the AGENT_URL the store does not, + * because a collection's field set comes from the agent capabilities. The server passes the bundle it + * already built for the data routes; the CLI goes through `resolveUnfoldSource`. Silent on purpose — + * the two report a missing configuration differently. + */ +function toUnfoldSource( + bundle: ReadModelBundle | undefined, + config: BFFConfig, + logger: Logger, +): UnfoldSource | undefined { + if (!bundle || !config.agentUrl) return undefined; + + return { + store: bundle.store, + agentUrl: config.agentUrl, + timeoutMs: config.agentTimeoutMs, + logger, + }; +} + +/** + * Metrics are dropped rather than logged: without an explicit sink `createReadModel` builds a console + * one, which reports gauges at `Info`, and `createConsoleLogger` sends `Info` to `console.info` — the + * stdout the document is written to. The server keeps its metrics; the export has no sink for them + * anyway. + */ +const UNMEASURED: Metrics = { increment: () => undefined, gauge: () => undefined }; + +export function resolveUnfoldSource(config: BFFConfig, logger: Logger): UnfoldSource | undefined { + return toUnfoldSource(resolveReadModelBundle(config, logger, UNMEASURED), config, logger); +} + +// The data middleware falls through to the action middleware on a non-data path. +function buildAgentRouteMiddlewares( + bundle: ReadModelBundle | undefined, + config: BFFConfig, + logger: Logger, +): Middleware[] { + if (!bundle) { + logger( + 'Warn', + 'Data, action and permissions endpoints disabled: FOREST_SERVER_URL, FOREST_ENV_SECRET or FOREST_AUTH_SECRET is missing', + ); + + return [createAgentStubMiddleware()]; + } + + const { store, apiKeyConfig } = bundle; + const { agentUrl, agentTimeoutMs: timeoutMs } = config; + + const permissionsMiddleware = createPermissionsRoutesMiddleware({ + store, + client: new PermissionsClient({ + forestServerUrl: apiKeyConfig.forestServerUrl, + envSecret: apiKeyConfig.forestEnvSecret, + }), + cache: new PermissionsCache(), + logger, + }); + + if (!agentUrl) { + logger('Warn', 'Data and action endpoints disabled: AGENT_URL is missing'); + + return [permissionsMiddleware, createAgentStubMiddleware()]; + } + + return [ + permissionsMiddleware, + createDataRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), + createActionRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), + ]; +} + +function buildAiMiddlewares(config: BFFConfig, oauth: OAuthEdge, logger: Logger): Middleware[] { + const { session, environmentId } = oauth; + + if (!session) { + logger('Warn', 'AI query route disabled: the deployment carries no OAuth session'); + + return []; + } + + const client = new AiProxyClient({ + forestServerUrl: session.forestServerUrl, + timeoutMs: config.aiTimeoutMs, + }); + + return [ + createAiRoutesMiddleware({ + client, + sessionStore: session.store, + serverClient: session.serverClient, + environmentId, + logger, + }), + ]; +} + +function buildAgentMiddlewares( + config: BFFConfig, + logger: Logger, + oauth: OAuthEdge, + aiMiddlewares: Middleware[], +): Middleware[] { + const { forestAuthSecret, defaultTimezone } = config; + const { environmentId } = oauth; + + if (!forestAuthSecret) { + logger('Warn', 'Agent edge disabled: FOREST_AUTH_SECRET is missing'); + + return []; + } + + const apiKeyStep = buildApiKeyMiddleware(config, logger) ?? createApiKeyUnavailableGuard(logger); + // One store for the whole edge. The document only unfolds when the agent is reachable too: with no + // AGENT_URL every data path answers 501, so concrete paths would advertise a dead surface. + const bundle = resolveReadModelBundle(config, logger); + const source = toUnfoldSource(bundle, config, logger); + + const chain: Middleware[] = [ + createAuthModeMiddleware({ authSecret: forestAuthSecret }), + apiKeyStep, + createRateLimitMiddleware({ + maxRequests: config.rateLimitMaxRequests, + windowMs: config.rateLimitWindowMs, + }), + createPerKeyOriginMiddleware({ logger, serverAllowedOrigins: config.allowedOrigins }), + createOpenApiRoutes({ + version, + enabled: config.openapiEnabled, + source, + hasAiQueryRoute: aiMiddlewares.length > 0, + publicUrl: config.publicUrl, + }), + ...(bundle ? [createContextRoutesMiddleware({ store: bundle.store, environmentId })] : []), + ...aiMiddlewares, + createTimezoneMiddleware({ defaultTimezone }), + ...buildAgentRouteMiddlewares(bundle, config, logger), + ]; + + return chain.map(agentScoped); +} + +/** + * Assemble the whole BFF — `/health`, the version header, and every middleware in the one order both + * deployment modes must share — and hand back the request handler. `runCli` puts it behind a + * listener; an embedding host mounts it on its own server. + */ +export default async function buildBff({ + config, + logger = createConsoleLogger(), +}: BuildBffOptions): Promise { + if (config.invalidAllowedOrigins.length > 0) { + logger('Warn', 'Ignoring malformed BFF_ALLOWED_ORIGINS entries', { + entries: config.invalidAllowedOrigins, + }); + } + + const oauth = await buildOAuthMiddlewares(config, logger); + const aiMiddlewares = buildAiMiddlewares(config, oauth, logger); + const agentMiddlewares = buildAgentMiddlewares(config, logger, oauth, aiMiddlewares); + const hasAgentEdge = agentMiddlewares.length > 0; + const agentErrorMiddleware = hasAgentEdge ? [agentScoped(createErrorMiddleware({ logger }))] : []; + const agentJsonOnlyGuard = hasAgentEdge ? [agentScoped(createJsonOnlyGuard())] : []; + + const middlewares = [ + createVersionHeaderMiddleware(version), + createHealthRoute({ config, version }), + createCorsMiddleware({ allowedOrigins: config.allowedOrigins, logger }), + ...agentErrorMiddleware, + ...agentJsonOnlyGuard, + createBodyParser(aiMiddlewares.length > 0), + ...oauth.middlewares, + // Outside the agent-scoped chain on purpose: the viewer is a public page, the document it fetches + // is not. Gated on the edge being mounted too, like the error middleware above: with no agent + // chain there is no document to fetch, and the page would only ever reach a bare Koa 404. + createDocsRoutes({ + enabled: config.openapiEnabled && agentMiddlewares.length > 0, + documentPath: OPENAPI_PATH, + logger, + }), + ...agentMiddlewares, + ]; + + const app = new Koa(); + for (const middleware of middlewares) app.use(middleware); + + return { callback: app.callback() }; +} diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 50031bbb16..64dc39c732 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -1,440 +1,25 @@ -import type { BFFConfig } from './config/env-config'; -import type { SessionStore } from './oauth/session-store'; -import type { UnfoldSource } from './openapi/unfolded-document'; import type { Logger } from './ports/logger-port'; -import type { Metrics } from './ports/metrics-port'; -import type ReadModelStore from './read-model/read-model-store'; -import type { Middleware } from 'koa'; -import { bodyParser } from '@koa/bodyparser'; - -import createActionRoutesMiddleware from './action/action-routes-middleware'; import createConsoleLogger from './adapters/console-logger'; -import createAgentStubMiddleware from './agent/agent-stub'; -import AiProxyClient from './ai/ai-proxy-client'; -import createAiRoutesMiddleware, { AI_QUERY_ROUTE } from './ai/ai-routes-middleware'; -import createApiKeyAuthenticator from './api-key/api-key-authenticator'; -import ApiKeyClient from './api-key/api-key-client'; -import createApiKeyMiddleware from './api-key/api-key-middleware'; -import createResolveCache from './api-key/resolve-cache'; -import createAuthModeMiddleware from './auth/auth-mode-middleware'; +import buildBff from './build-bff'; import { parseConfig } from './config/env-config'; -import createContextRoutesMiddleware from './context/context-routes-middleware'; -import createCorsMiddleware from './cors/cors-middleware'; -import createPerKeyOriginMiddleware from './cors/per-key-origin'; -import createDataRoutesMiddleware from './data/data-routes-middleware'; -import createDocsRoutes from './docs/docs-routes'; import { extractErrorMessage } from './errors'; -import { unauthorized, unsupportedMediaType } from './http/bff-http-error'; import BFFHttpServer from './http/bff-http-server'; -import BODY_LIMIT, { AI_BODY_LIMIT } from './http/body-limit'; -import createErrorMiddleware from './http/error-middleware'; -import ForestServerClient from './oauth/forest-server-client'; -import createOAuthRoutes from './oauth/oauth-routes'; -import createInMemorySessionStore from './oauth/session-store'; -import createTokenCipher from './oauth/token-cipher'; -import createOpenApiRoutes, { OPENAPI_PATH } from './openapi/openapi-routes'; -import PermissionsCache from './permissions/permissions-cache'; -import PermissionsClient from './permissions/permissions-client'; -import createPermissionsRoutesMiddleware from './permissions/permissions-routes-middleware'; -import isAgentPath from './rate-limit/agent-path'; -import createRateLimitMiddleware from './rate-limit/rate-limit-middleware'; -import createReadModel from './read-model/create-read-model'; -import createTimezoneMiddleware from './timezone/timezone-middleware'; import version from './version'; -const SESSION_TTL_SECONDS = 24 * 60 * 60; - -const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']); - -const JSON_BODY_TYPES = ['application/json', 'application/*+json']; - -function hasBody(ctx: Parameters[0]): boolean { - return (ctx.request.length ?? 0) > 0 || ctx.get('transfer-encoding') !== ''; -} - -function createJsonOnlyGuard(): Middleware { - return async function jsonOnlyGuard(ctx, next) { - if (BODY_METHODS.has(ctx.method) && hasBody(ctx) && !ctx.is(JSON_BODY_TYPES)) { - throw unsupportedMediaType(); - } - - await next(); - }; -} - -function agentScoped(middleware: Middleware): Middleware { - return async function scoped(ctx, next) { - if (!isAgentPath(ctx.path)) { - await next(); - - return; - } - - await middleware(ctx, next); - }; -} - -function createBodyParser(hasAiQueryRoute: boolean): Middleware { - const extendTypes = { json: JSON_BODY_TYPES }; - const parseBody = bodyParser({ jsonLimit: BODY_LIMIT, extendTypes }); - - if (!hasAiQueryRoute) return parseBody; - - const parseAiBody = bodyParser({ - jsonLimit: AI_BODY_LIMIT, - enableTypes: ['json'], - extendTypes, - }); - - return async function selectedBodyParser(ctx, next) { - if (ctx.path === AI_QUERY_ROUTE) { - await parseAiBody(ctx, next); - - return; - } - - await parseBody(ctx, next); - }; -} - -function createApiKeyUnavailableGuard(logger: Logger): Middleware { - return async function apiKeyUnavailableGuard(ctx, next) { - if (ctx.state.authMode === 'api-key') { - logger('Error', 'API key auth requested but the resolver is not configured'); - throw unauthorized('Credentials could not be validated'); - } - - await next(); - }; -} - -interface ResolvedOAuthConfig { - forestServerUrl: string; - forestEnvSecret: string; - forestAppUrl: string; - forestAuthSecret: string; - tokenEncryptionKey: string; -} - -export function resolveOAuthConfig(config: BFFConfig): ResolvedOAuthConfig | undefined { - const { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey } = - config; - - if ( - forestServerUrl && - forestEnvSecret && - forestAppUrl && - forestAuthSecret && - tokenEncryptionKey - ) { - return { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey }; - } - - return undefined; -} - -interface OAuthSession { - store: SessionStore; - serverClient: ForestServerClient; - forestServerUrl: string; -} - -interface OAuthEdge { - middlewares: Middleware[]; - environmentId?: number; - session?: OAuthSession; -} - -async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise { - const oauthConfig = resolveOAuthConfig(config); - - if (!oauthConfig) { - logger('Warn', 'OAuth routes disabled: required configuration is missing'); - - return { middlewares: [] }; - } - - const { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey } = - oauthConfig; - - const serverClient = new ForestServerClient({ forestServerUrl, envSecret: forestEnvSecret }); - const environmentId = await serverClient.fetchEnvironmentId(); - - const sessionStore = createInMemorySessionStore({ - cipher: createTokenCipher(tokenEncryptionKey), - now: () => Date.now(), - sessionTtlSeconds: SESSION_TTL_SECONDS, - }); - - const oauthRoutes = createOAuthRoutes({ - serverClient, - sessionStore, - forestAppUrl, - authSecret: forestAuthSecret, - environmentId, - logger, - }); - - return { - middlewares: [oauthRoutes], - environmentId, - session: { store: sessionStore, serverClient, forestServerUrl }, - }; -} - -interface ResolvedApiKeyConfig { - forestServerUrl: string; - forestEnvSecret: string; - forestAuthSecret: string; -} - -function resolveApiKeyConfig(config: BFFConfig): ResolvedApiKeyConfig | undefined { - const { forestServerUrl, forestEnvSecret, forestAuthSecret } = config; - - if (forestServerUrl && forestEnvSecret && forestAuthSecret) { - return { forestServerUrl, forestEnvSecret, forestAuthSecret }; - } - - return undefined; -} - -function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware | undefined { - const apiKeyConfig = resolveApiKeyConfig(config); - - if (!apiKeyConfig) { - logger('Warn', 'API key auth disabled: required configuration is missing'); - - return undefined; - } - - const client = new ApiKeyClient({ - forestServerUrl: apiKeyConfig.forestServerUrl, - envSecret: apiKeyConfig.forestEnvSecret, - }); - const cache = createResolveCache({ now: () => Date.now() }); - const authenticator = createApiKeyAuthenticator({ - client, - cache, - authSecret: apiKeyConfig.forestAuthSecret, - }); - - return createApiKeyMiddleware({ authenticator, logger }); -} - -interface ReadModelBundle { - store: ReadModelStore; - apiKeyConfig: ResolvedApiKeyConfig; -} - -/** - * The read-model store the data routes, the action routes, the permissions endpoint and the OpenAPI - * unfolding all share — one cache, one schema fetch. Needs no agent: the permissions endpoint and the - * schema itself come from the SaaS. - */ -function resolveReadModelBundle( - config: BFFConfig, - logger: Logger, - metrics?: Metrics, -): ReadModelBundle | undefined { - const apiKeyConfig = resolveApiKeyConfig(config); - - if (!apiKeyConfig) return undefined; - - const { store } = createReadModel({ - forestServerUrl: apiKeyConfig.forestServerUrl, - envSecret: apiKeyConfig.forestEnvSecret, - logger, - metrics, - }); - - return { store, apiKeyConfig }; -} - -/** - * When unfolding is possible, in ONE place: the document needs the AGENT_URL the store does not, - * because a collection's field set comes from the agent capabilities. The server passes the bundle it - * already built for the data routes; the CLI goes through `resolveUnfoldSource`. Silent on purpose — - * the two report a missing configuration differently. - */ -function toUnfoldSource( - bundle: ReadModelBundle | undefined, - config: BFFConfig, - logger: Logger, -): UnfoldSource | undefined { - if (!bundle || !config.agentUrl) return undefined; - - return { - store: bundle.store, - agentUrl: config.agentUrl, - timeoutMs: config.agentTimeoutMs, - logger, - }; -} - -/** - * Metrics are dropped rather than logged: without an explicit sink `createReadModel` builds a console - * one, which reports gauges at `Info`, and `createConsoleLogger` sends `Info` to `console.info` — the - * stdout the document is written to. The server keeps its metrics; the export has no sink for them - * anyway. - */ -const UNMEASURED: Metrics = { increment: () => undefined, gauge: () => undefined }; - -export function resolveUnfoldSource(config: BFFConfig, logger: Logger): UnfoldSource | undefined { - return toUnfoldSource(resolveReadModelBundle(config, logger, UNMEASURED), config, logger); -} - -// The data middleware falls through to the action middleware on a non-data path. -function buildAgentRouteMiddlewares( - bundle: ReadModelBundle | undefined, - config: BFFConfig, - logger: Logger, -): Middleware[] { - if (!bundle) { - logger( - 'Warn', - 'Data, action and permissions endpoints disabled: FOREST_SERVER_URL, FOREST_ENV_SECRET or FOREST_AUTH_SECRET is missing', - ); - - return [createAgentStubMiddleware()]; - } - - const { store, apiKeyConfig } = bundle; - const { agentUrl, agentTimeoutMs: timeoutMs } = config; - - const permissionsMiddleware = createPermissionsRoutesMiddleware({ - store, - client: new PermissionsClient({ - forestServerUrl: apiKeyConfig.forestServerUrl, - envSecret: apiKeyConfig.forestEnvSecret, - }), - cache: new PermissionsCache(), - logger, - }); - - if (!agentUrl) { - logger('Warn', 'Data and action endpoints disabled: AGENT_URL is missing'); - - return [permissionsMiddleware, createAgentStubMiddleware()]; - } - - return [ - permissionsMiddleware, - createDataRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), - createActionRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), - ]; -} - -function buildAiMiddlewares(config: BFFConfig, oauth: OAuthEdge, logger: Logger): Middleware[] { - const { session, environmentId } = oauth; - - if (!session) { - logger('Warn', 'AI query route disabled: the deployment carries no OAuth session'); - - return []; - } - - const client = new AiProxyClient({ - forestServerUrl: session.forestServerUrl, - timeoutMs: config.aiTimeoutMs, - }); - - return [ - createAiRoutesMiddleware({ - client, - sessionStore: session.store, - serverClient: session.serverClient, - environmentId, - logger, - }), - ]; -} - -function buildAgentMiddlewares( - config: BFFConfig, - logger: Logger, - oauth: OAuthEdge, - aiMiddlewares: Middleware[], -): Middleware[] { - const { forestAuthSecret, defaultTimezone } = config; - const { environmentId } = oauth; - - if (!forestAuthSecret) { - logger('Warn', 'Agent edge disabled: FOREST_AUTH_SECRET is missing'); - - return []; - } - - const apiKeyStep = buildApiKeyMiddleware(config, logger) ?? createApiKeyUnavailableGuard(logger); - // One store for the whole edge. The document only unfolds when the agent is reachable too: with no - // AGENT_URL every data path answers 501, so concrete paths would advertise a dead surface. - const bundle = resolveReadModelBundle(config, logger); - const source = toUnfoldSource(bundle, config, logger); - - const chain: Middleware[] = [ - createAuthModeMiddleware({ authSecret: forestAuthSecret }), - apiKeyStep, - createRateLimitMiddleware({ - maxRequests: config.rateLimitMaxRequests, - windowMs: config.rateLimitWindowMs, - }), - createPerKeyOriginMiddleware({ logger, serverAllowedOrigins: config.allowedOrigins }), - createOpenApiRoutes({ - version, - enabled: config.openapiEnabled, - source, - hasAiQueryRoute: aiMiddlewares.length > 0, - publicUrl: config.publicUrl, - }), - ...(bundle ? [createContextRoutesMiddleware({ store: bundle.store, environmentId })] : []), - ...aiMiddlewares, - createTimezoneMiddleware({ defaultTimezone }), - ...buildAgentRouteMiddlewares(bundle, config, logger), - ]; - - return chain.map(agentScoped); -} - export default async function runCli( env: NodeJS.ProcessEnv, logger: Logger = createConsoleLogger(), ): Promise { const config = parseConfig(env); + const { callback } = await buildBff({ config, logger }); - if (config.invalidAllowedOrigins.length > 0) { - logger('Warn', 'Ignoring malformed BFF_ALLOWED_ORIGINS entries', { - entries: config.invalidAllowedOrigins, - }); - } - - const oauth = await buildOAuthMiddlewares(config, logger); - const aiMiddlewares = buildAiMiddlewares(config, oauth, logger); - const agentMiddlewares = buildAgentMiddlewares(config, logger, oauth, aiMiddlewares); - const hasAgentEdge = agentMiddlewares.length > 0; - const agentErrorMiddleware = hasAgentEdge ? [agentScoped(createErrorMiddleware({ logger }))] : []; - const agentJsonOnlyGuard = hasAgentEdge ? [agentScoped(createJsonOnlyGuard())] : []; - const middlewares = [ - createCorsMiddleware({ allowedOrigins: config.allowedOrigins, logger }), - ...agentErrorMiddleware, - ...agentJsonOnlyGuard, - createBodyParser(aiMiddlewares.length > 0), - ...oauth.middlewares, - // Outside the agent-scoped chain on purpose: the viewer is a public page, the document it fetches - // is not. Gated on the edge being mounted too, like the error middleware above: with no agent - // chain there is no document to fetch, and the page would only ever reach a bare Koa 404. - createDocsRoutes({ - enabled: config.openapiEnabled && agentMiddlewares.length > 0, - documentPath: OPENAPI_PATH, - logger, - }), - ...agentMiddlewares, - ]; const server = new BFFHttpServer({ port: config.httpPort, version, config, logger, - middlewares, + callback, }); await server.start(); diff --git a/packages/agent-bff/src/cli-dispatch.ts b/packages/agent-bff/src/cli-dispatch.ts index 0b0ef349cd..1b343d14b7 100644 --- a/packages/agent-bff/src/cli-dispatch.ts +++ b/packages/agent-bff/src/cli-dispatch.ts @@ -6,7 +6,8 @@ import path from 'path'; import createConsoleLogger from './adapters/console-logger'; import { AI_QUERY_ROUTE } from './ai/ai-routes-middleware'; -import runCli, { resolveOAuthConfig, resolveUnfoldSource } from './cli-core'; +import { resolveOAuthConfig, resolveUnfoldSource } from './build-bff'; +import runCli from './cli-core'; import { parseConfig, parsePublicUrl } from './config/env-config'; import { extractErrorMessage } from './errors'; import { generateOpenApiDocument, serializeOpenApi } from './openapi/openapi-document'; diff --git a/packages/agent-bff/src/http/bff-http-server.ts b/packages/agent-bff/src/http/bff-http-server.ts index 7f6a086121..d9dfde017a 100644 --- a/packages/agent-bff/src/http/bff-http-server.ts +++ b/packages/agent-bff/src/http/bff-http-server.ts @@ -1,3 +1,4 @@ +import type { BffCallback } from '../build-bff'; import type { BFFConfig } from '../config/env-config'; import type { Logger } from '../ports/logger-port'; import type { Server } from 'http'; @@ -6,6 +7,8 @@ import type { Middleware } from 'koa'; import http from 'http'; import Koa from 'koa'; +import createHealthRoute from './health-route'; +import createVersionHeaderMiddleware from './version-header-middleware'; import createConsoleLogger from '../adapters/console-logger'; export interface BFFHttpServerOptions { @@ -14,10 +17,15 @@ export interface BFFHttpServerOptions { config: BFFConfig; logger?: Logger; middlewares?: Middleware[]; + /** + * Prebuilt request handler, as returned by `buildBff`. When set, the server listens on it as-is + * and `middlewares` is ignored: the handler already carries `/health` and the version header. + */ + callback?: BffCallback; } export default class BFFHttpServer { - private readonly app: Koa; + private readonly handler: BffCallback; private readonly options: BFFHttpServerOptions; private readonly logger: Logger; private server: Server | null = null; @@ -25,35 +33,28 @@ export default class BFFHttpServer { constructor(options: BFFHttpServerOptions) { this.options = options; this.logger = options.logger ?? createConsoleLogger(); - this.app = new Koa(); - - this.app.use(async (ctx, next) => { - ctx.set('X-Forest-Bff-Version', this.options.version); - await next(); - }); - - this.app.use(async (ctx, next) => { - if ((ctx.method === 'GET' || ctx.method === 'HEAD') && ctx.path === '/health') { - const { config, version } = this.options; - ctx.status = config.hasAllRequired ? 200 : 503; - ctx.body = { status: config.hasAllRequired ? 'ok' : 'degraded', version }; + this.handler = options.callback ?? BFFHttpServer.buildHandler(options); + } - return; - } + private static buildHandler(options: BFFHttpServerOptions): BffCallback { + const { config, version } = options; + const app = new Koa(); - await next(); - }); + app.use(createVersionHeaderMiddleware(version)); + app.use(createHealthRoute({ config, version })); - for (const middleware of this.options.middlewares ?? []) { - this.app.use(middleware); + for (const middleware of options.middlewares ?? []) { + app.use(middleware); } + + return app.callback(); } async start(): Promise { if (this.server) throw new Error('Server already started'); return new Promise((resolve, reject) => { - const server = http.createServer(this.app.callback()); + const server = http.createServer(this.handler); this.server = server; let onError: (error: Error) => void; @@ -107,6 +108,6 @@ export default class BFFHttpServer { } get callback() { - return this.app.callback(); + return this.handler; } } diff --git a/packages/agent-bff/src/http/health-route.ts b/packages/agent-bff/src/http/health-route.ts new file mode 100644 index 0000000000..a1165952af --- /dev/null +++ b/packages/agent-bff/src/http/health-route.ts @@ -0,0 +1,25 @@ +import type { BFFConfig } from '../config/env-config'; +import type { Middleware } from 'koa'; + +export const HEALTH_PATH = '/health'; + +export interface HealthRouteOptions { + config: BFFConfig; + version: string; +} + +export default function createHealthRoute({ config, version }: HealthRouteOptions): Middleware { + return async function health(ctx, next) { + const isHealthRequest = + (ctx.method === 'GET' || ctx.method === 'HEAD') && ctx.path === HEALTH_PATH; + + if (!isHealthRequest) { + await next(); + + return; + } + + ctx.status = config.hasAllRequired ? 200 : 503; + ctx.body = { status: config.hasAllRequired ? 'ok' : 'degraded', version }; + }; +} diff --git a/packages/agent-bff/src/http/version-header-middleware.ts b/packages/agent-bff/src/http/version-header-middleware.ts new file mode 100644 index 0000000000..c06696c64f --- /dev/null +++ b/packages/agent-bff/src/http/version-header-middleware.ts @@ -0,0 +1,11 @@ +import type { Middleware } from 'koa'; + +export const BFF_VERSION_HEADER = 'X-Forest-Bff-Version'; + +export default function createVersionHeaderMiddleware(version: string): Middleware { + return async function versionHeader(ctx, next) { + ctx.set(BFF_VERSION_HEADER, version); + + await next(); + }; +} diff --git a/packages/agent-bff/src/index.ts b/packages/agent-bff/src/index.ts index 91cff18bde..72b9fe34bf 100644 --- a/packages/agent-bff/src/index.ts +++ b/packages/agent-bff/src/index.ts @@ -2,6 +2,8 @@ export { default as BFFHttpServer } from './http/bff-http-server'; export { parseConfig, REQUIRED_KEYS } from './config/env-config'; export type { BFFConfig, PresenceMap, RequiredKey } from './config/env-config'; export { default as runCli } from './cli-core'; +export { default as buildBff } from './build-bff'; +export type { Bff, BuildBffOptions, BffCallback } from './build-bff'; export { ConfigurationError } from './errors'; export { default as DEFAULT_BFF_PORT } from './defaults'; export { default as createConsoleLogger } from './adapters/console-logger'; diff --git a/packages/agent-bff/test/build-bff.test.ts b/packages/agent-bff/test/build-bff.test.ts new file mode 100644 index 0000000000..8fe0a75fe0 --- /dev/null +++ b/packages/agent-bff/test/build-bff.test.ts @@ -0,0 +1,88 @@ +import type { Logger } from '../src/ports/logger-port'; + +import request from 'supertest'; + +import buildBff from '../src/build-bff'; +import { parseConfig } from '../src/config/env-config'; +import version from '../src/version'; +import { restoreFetchAfterEach, stubEnvironmentIdFetch } from './helpers/fetch-stub'; + +const VALID_ENV = { + FOREST_AUTH_SECRET: 'auth-secret', + FOREST_ENV_SECRET: 'env-secret', + FOREST_SERVER_URL: 'https://api.forestadmin.com', + FOREST_APP_URL: 'https://app.forestadmin.com', + AGENT_URL: 'https://agent.example.com', + BFF_TOKEN_ENCRYPTION_KEY: Buffer.alloc(32).toString('base64'), +} satisfies NodeJS.ProcessEnv; + +const noopLogger: Logger = () => undefined; + +async function buildCallback(env: NodeJS.ProcessEnv) { + const { callback } = await buildBff({ config: parseConfig(env), logger: noopLogger }); + + return callback; +} + +describe('buildBff', () => { + restoreFetchAfterEach(); + + beforeEach(() => { + stubEnvironmentIdFetch(); + }); + + describe('when every required key is present', () => { + it('should answer /health with ok and the version', async () => { + const callback = await buildCallback(VALID_ENV); + + const response = await request(callback).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'ok', version }); + }); + }); + + describe('when a required key is missing', () => { + it('should answer /health with degraded', async () => { + const callback = await buildCallback({ ...VALID_ENV, FOREST_SERVER_URL: undefined }); + + const response = await request(callback).get('/health'); + + expect(response.status).toBe(503); + expect(response.body).toEqual({ status: 'degraded', version }); + }); + }); + + it('should set the version header on every response', async () => { + const callback = await buildCallback(VALID_ENV); + + const response = await request(callback).get('/unknown-path'); + + expect(response.headers['x-forest-bff-version']).toBe(version); + }); + + it('should mount the agent edge behind the health route', async () => { + const callback = await buildCallback(VALID_ENV); + + const response = await request(callback).post('/agent/v1/companies/list').send({}); + + expect(response.status).toBe(401); + }); + + describe('when BFF_ALLOWED_ORIGINS carries a malformed entry', () => { + it('should warn once with the rejected entries', async () => { + const logger = jest.fn(); + + await buildBff({ + config: parseConfig({ ...VALID_ENV, BFF_ALLOWED_ORIGINS: 'https://ok.example.com,*' }), + logger, + }); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'Ignoring malformed BFF_ALLOWED_ORIGINS entries', + { entries: ['*'] }, + ); + }); + }); +}); diff --git a/packages/agent-bff/test/index.test.ts b/packages/agent-bff/test/index.test.ts index 6aa3829c27..18037e0c61 100644 --- a/packages/agent-bff/test/index.test.ts +++ b/packages/agent-bff/test/index.test.ts @@ -7,6 +7,7 @@ describe('package index', () => { expect(bff.parseConfig).toBeDefined(); expect(bff.REQUIRED_KEYS).toBeDefined(); expect(bff.runCli).toBeDefined(); + expect(bff.buildBff).toBeDefined(); expect(bff.ConfigurationError).toBeDefined(); expect(bff.DEFAULT_BFF_PORT).toBeDefined(); expect(bff.createConsoleLogger).toBeDefined(); diff --git a/packages/agent-bff/test/openapi/openapi-mount-invariant.test.ts b/packages/agent-bff/test/openapi/openapi-mount-invariant.test.ts index 9ed857fb8f..5bdd5bf5b8 100644 --- a/packages/agent-bff/test/openapi/openapi-mount-invariant.test.ts +++ b/packages/agent-bff/test/openapi/openapi-mount-invariant.test.ts @@ -8,7 +8,7 @@ const SRC_DIR = path.join(__dirname, '..', '..', 'src'); const OPENAPI_MODULE_PREFIX = 'openapi/'; const ALLOWED_OPENAPI_IMPORTS: Record = { - 'cli-core.ts': ['openapi/openapi-routes', 'openapi/unfolded-document'], + 'build-bff.ts': ['openapi/openapi-routes', 'openapi/unfolded-document'], 'cli-dispatch.ts': ['openapi/openapi-document', 'openapi/unfolded-document', 'openapi/unfolding'], }; From 3326cd4c5f7c75e5b16e18a4455b36681bd875e8 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Wed, 2 Sep 2026 16:53:40 +0200 Subject: [PATCH 2/2] refactor(agent-bff): make the two BFF deployment modes fail the same way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the buildBff extraction. `callback` and `middlewares` were both optional on `BFFHttpServerOptions`, so a host passing both compiled, booted, logged `Forest BFF started` and then 404'd every one of its own routes from bare Koa. The options type is now a union — `callback` forbids `version` and `middlewares` instead of ignoring them — so the invalid combination no longer type-checks. The log naming *which* required keys are absent lived in `start()`, which a host mounting the handler on its own server never calls: same misconfiguration, two different diagnostics, in the change whose point is that the modes cannot drift. It moves to `warnMissingConfig`, called at assembly time by `buildBff` and by the server's own legacy branch. `buildBff`'s doc comment promised a mount it does not support: the handler is terminal and its paths are absolute, so only a root mount works until `basePath` lands. `should mount the agent edge behind the health route` asserted a 401 on `/agent/...`, which the health route can never affect — the two paths never collide. It is renamed to what it checks, and the ordering invariant is now pinned by asserting the version header on `/health`, which only holds while the header sits ahead of the short-circuiting health route. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent-bff/src/build-bff.ts | 8 ++- packages/agent-bff/src/cli-core.ts | 9 +--- .../src/config/missing-config-warning.ts | 17 ++++++ .../agent-bff/src/http/bff-http-server.ts | 54 ++++++++++++------- packages/agent-bff/test/build-bff.test.ts | 22 +++++++- .../test/http/bff-http-server.test.ts | 47 ++++++++++++---- 6 files changed, 117 insertions(+), 40 deletions(-) create mode 100644 packages/agent-bff/src/config/missing-config-warning.ts diff --git a/packages/agent-bff/src/build-bff.ts b/packages/agent-bff/src/build-bff.ts index 68eaa61c3f..00370c3550 100644 --- a/packages/agent-bff/src/build-bff.ts +++ b/packages/agent-bff/src/build-bff.ts @@ -20,6 +20,7 @@ import ApiKeyClient from './api-key/api-key-client'; import createApiKeyMiddleware from './api-key/api-key-middleware'; import createResolveCache from './api-key/resolve-cache'; import createAuthModeMiddleware from './auth/auth-mode-middleware'; +import warnMissingConfig from './config/missing-config-warning'; import createContextRoutesMiddleware from './context/context-routes-middleware'; import createCorsMiddleware from './cors/cors-middleware'; import createPerKeyOriginMiddleware from './cors/per-key-origin'; @@ -44,7 +45,6 @@ import createReadModel from './read-model/create-read-model'; import createTimezoneMiddleware from './timezone/timezone-middleware'; import version from './version'; - /** What a host must hand to `http.createServer`, or mount, to serve the BFF. */ export type BffCallback = (req: IncomingMessage, res: ServerResponse) => void | Promise; @@ -412,7 +412,9 @@ function buildAgentMiddlewares( /** * Assemble the whole BFF — `/health`, the version header, and every middleware in the one order both * deployment modes must share — and hand back the request handler. `runCli` puts it behind a - * listener; an embedding host mounts it on its own server. + * listener; an embedding host mounts it at the root of its own server. The handler is terminal — it + * answers 404 itself instead of yielding to a host `next()` — and every path it serves is absolute, + * so a prefix mount would move `/health` and `/agent/...` off the paths the frontend calls. */ export default async function buildBff({ config, @@ -424,6 +426,8 @@ export default async function buildBff({ }); } + warnMissingConfig(config, logger); + const oauth = await buildOAuthMiddlewares(config, logger); const aiMiddlewares = buildAiMiddlewares(config, oauth, logger); const agentMiddlewares = buildAgentMiddlewares(config, logger, oauth, aiMiddlewares); diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 64dc39c732..c19f0bbf45 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -5,7 +5,6 @@ import buildBff from './build-bff'; import { parseConfig } from './config/env-config'; import { extractErrorMessage } from './errors'; import BFFHttpServer from './http/bff-http-server'; -import version from './version'; export default async function runCli( env: NodeJS.ProcessEnv, @@ -14,13 +13,7 @@ export default async function runCli( const config = parseConfig(env); const { callback } = await buildBff({ config, logger }); - const server = new BFFHttpServer({ - port: config.httpPort, - version, - config, - logger, - callback, - }); + const server = new BFFHttpServer({ port: config.httpPort, config, logger, callback }); await server.start(); diff --git a/packages/agent-bff/src/config/missing-config-warning.ts b/packages/agent-bff/src/config/missing-config-warning.ts new file mode 100644 index 0000000000..04daa0e227 --- /dev/null +++ b/packages/agent-bff/src/config/missing-config-warning.ts @@ -0,0 +1,17 @@ +import type { BFFConfig } from './env-config'; +import type { Logger } from '../ports/logger-port'; + +/** + * The only place that names *which* required keys are absent. Both deployment modes call it at + * assembly time, so a misconfiguration reads the same whether the BFF listens on its own port or is + * mounted by a host that never starts a listener. + */ +export default function warnMissingConfig(config: BFFConfig, logger: Logger): void { + const missing = Object.entries(config.presence) + .filter(([, present]) => !present) + .map(([key]) => key); + + if (missing.length === 0) return; + + logger('Warn', 'Missing required configuration; /health will report degraded', { missing }); +} diff --git a/packages/agent-bff/src/http/bff-http-server.ts b/packages/agent-bff/src/http/bff-http-server.ts index d9dfde017a..276387d5e4 100644 --- a/packages/agent-bff/src/http/bff-http-server.ts +++ b/packages/agent-bff/src/http/bff-http-server.ts @@ -10,18 +10,36 @@ import Koa from 'koa'; import createHealthRoute from './health-route'; import createVersionHeaderMiddleware from './version-header-middleware'; import createConsoleLogger from '../adapters/console-logger'; +import warnMissingConfig from '../config/missing-config-warning'; -export interface BFFHttpServerOptions { +interface BFFHttpServerBaseOptions { port: number; - version: string; config: BFFConfig; logger?: Logger; +} + +/** The server assembles its own Koa app around `/health` and the version header. */ +interface AssembledOptions extends BFFHttpServerBaseOptions { + version: string; middlewares?: Middleware[]; - /** - * Prebuilt request handler, as returned by `buildBff`. When set, the server listens on it as-is - * and `middlewares` is ignored: the handler already carries `/health` and the version header. - */ - callback?: BffCallback; + callback?: never; +} + +/** + * The server only listens: `buildBff` already assembled the handler, `/health` and the version + * header included. `version` and `middlewares` are forbidden here rather than ignored — a host + * passing them would otherwise boot fine and 404 every one of its own routes. + */ +interface PrebuiltOptions extends BFFHttpServerBaseOptions { + callback: BffCallback; + version?: never; + middlewares?: never; +} + +export type BFFHttpServerOptions = AssembledOptions | PrebuiltOptions; + +function isPrebuilt(options: BFFHttpServerOptions): options is PrebuiltOptions { + return options.callback !== undefined; } export default class BFFHttpServer { @@ -33,10 +51,18 @@ export default class BFFHttpServer { constructor(options: BFFHttpServerOptions) { this.options = options; this.logger = options.logger ?? createConsoleLogger(); - this.handler = options.callback ?? BFFHttpServer.buildHandler(options); + + if (isPrebuilt(options)) { + this.handler = options.callback; + + return; + } + + this.handler = BFFHttpServer.buildHandler(options); + warnMissingConfig(options.config, this.logger); } - private static buildHandler(options: BFFHttpServerOptions): BffCallback { + private static buildHandler(options: AssembledOptions): BffCallback { const { config, version } = options; const app = new Koa(); @@ -64,16 +90,6 @@ export default class BFFHttpServer { const port = typeof address === 'object' && address ? address.port : this.options.port; this.logger('Info', 'Forest BFF started', { port }); - const missing = Object.entries(this.options.config.presence) - .filter(([, present]) => !present) - .map(([key]) => key); - - if (missing.length > 0) { - this.logger('Warn', 'Missing required configuration; /health will report degraded', { - missing, - }); - } - resolve(); }; diff --git a/packages/agent-bff/test/build-bff.test.ts b/packages/agent-bff/test/build-bff.test.ts index 8fe0a75fe0..7c969f0582 100644 --- a/packages/agent-bff/test/build-bff.test.ts +++ b/packages/agent-bff/test/build-bff.test.ts @@ -40,6 +40,14 @@ describe('buildBff', () => { expect(response.status).toBe(200); expect(response.body).toEqual({ status: 'ok', version }); }); + + it('should set the version header on /health, which only holds if it is mounted first', async () => { + const callback = await buildCallback(VALID_ENV); + + const response = await request(callback).get('/health'); + + expect(response.headers['x-forest-bff-version']).toBe(version); + }); }); describe('when a required key is missing', () => { @@ -51,6 +59,18 @@ describe('buildBff', () => { expect(response.status).toBe(503); expect(response.body).toEqual({ status: 'degraded', version }); }); + + it('should warn naming the missing keys', async () => { + const logger = jest.fn(); + + await buildBff({ config: parseConfig({ ...VALID_ENV, AGENT_URL: undefined }), logger }); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'Missing required configuration; /health will report degraded', + { missing: ['AGENT_URL'] }, + ); + }); }); it('should set the version header on every response', async () => { @@ -61,7 +81,7 @@ describe('buildBff', () => { expect(response.headers['x-forest-bff-version']).toBe(version); }); - it('should mount the agent edge behind the health route', async () => { + it('should answer 401 on an unauthenticated agent route', async () => { const callback = await buildCallback(VALID_ENV); const response = await request(callback).post('/agent/v1/companies/list').send({}); diff --git a/packages/agent-bff/test/http/bff-http-server.test.ts b/packages/agent-bff/test/http/bff-http-server.test.ts index ef4073d4aa..91b9b73055 100644 --- a/packages/agent-bff/test/http/bff-http-server.test.ts +++ b/packages/agent-bff/test/http/bff-http-server.test.ts @@ -1,3 +1,5 @@ +import type { BffCallback } from '../../src/build-bff'; +import type { Logger } from '../../src/ports/logger-port'; import type { Server } from 'http'; import http from 'http'; @@ -19,10 +21,19 @@ const VALID_ENV = { const noopLogger = () => undefined; -function createServer(env: NodeJS.ProcessEnv, port = 0) { +const teapot: BffCallback = (req, res) => { + res.statusCode = 418; + res.end(); +}; + +function createServer(env: NodeJS.ProcessEnv, port = 0, logger: Logger = noopLogger) { const config = parseConfig(env); - return new BFFHttpServer({ port, version: VERSION, config, logger: noopLogger }); + return new BFFHttpServer({ port, version: VERSION, config, logger }); +} + +function createPrebuiltServer(env: NodeJS.ProcessEnv, logger: Logger = noopLogger) { + return new BFFHttpServer({ port: 0, config: parseConfig(env), logger, callback: teapot }); } function listenOnEphemeralPort(server: Server): Promise { @@ -116,16 +127,10 @@ describe('BFFHttpServer', () => { expect(response.status).toBe(503); }); - it('should warn at startup listing the missing keys', async () => { + it('should warn when assembling its own handler, listing the missing keys', async () => { const logger = jest.fn(); - const config = parseConfig({ ...VALID_ENV, AGENT_URL: undefined }); - const server = new BFFHttpServer({ port: 0, version: VERSION, config, logger }); - try { - await server.start(); - } finally { - await server.stop(); - } + createServer({ ...VALID_ENV, AGENT_URL: undefined }, 0, logger); expect(logger).toHaveBeenCalledWith( 'Warn', @@ -158,6 +163,28 @@ describe('BFFHttpServer', () => { }); }); + describe('when constructed with a prebuilt callback', () => { + it('should serve it as-is, health route included', async () => { + const server = createPrebuiltServer({ ...VALID_ENV }); + + const response = await request(server.callback).get('/health'); + + expect(response.status).toBe(418); + }); + + it('should leave the missing-key warning to whoever built the handler', async () => { + const logger = jest.fn(); + + createPrebuiltServer({ ...VALID_ENV, AGENT_URL: undefined }, logger); + + expect(logger).not.toHaveBeenCalledWith( + 'Warn', + 'Missing required configuration; /health will report degraded', + expect.anything(), + ); + }); + }); + describe('when any route is requested', () => { it('should set X-Forest-Bff-Version on a non-health (404) route', async () => { const server = createServer({ ...VALID_ENV });