diff --git a/packages/agent-bff/src/agent-edge.ts b/packages/agent-bff/src/agent-edge.ts new file mode 100644 index 0000000000..1697d3909d --- /dev/null +++ b/packages/agent-bff/src/agent-edge.ts @@ -0,0 +1,297 @@ +import type { ActivityLogWriter } from './activity-log/activity-log-writer'; +import type { AgentTransport } from './agent/agent-transport'; +import type { AgentDispatcher } from './agent/in-process-transport'; +import type { ResolvedApiKeyConfig } from './api-key/api-key-edge'; +import type { OAuthEdge } from './build-bff'; +import type { BFFConfig } from './config/env-config'; +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 createActionRoutesMiddleware from './action/action-routes-middleware'; +import createActivityLogWriter from './activity-log/activity-log-writer'; +import createBffActivityLogsService from './activity-log/activity-logs-service'; +import createAgentStubMiddleware from './agent/agent-stub'; +import { createHttpTransport } from './agent/agent-transport'; +import createInProcessTransport from './agent/in-process-transport'; +import AiProxyClient from './ai/ai-proxy-client'; +import createAiRoutesMiddleware from './ai/ai-routes-middleware'; +import { + buildApiKeyMiddleware, + createApiKeyUnavailableGuard, + resolveApiKeyConfig, +} from './api-key/api-key-edge'; +import createAuthModeMiddleware from './auth/auth-mode-middleware'; +import createForestServerTokenMiddleware from './auth/forest-server-token-middleware'; +import createContextRoutesMiddleware from './context/context-routes-middleware'; +import createPerKeyOriginMiddleware from './cors/per-key-origin'; +import createDataRoutesMiddleware from './data/data-routes-middleware'; +import { tolerateEnvironmentIdFailure } from './oauth/environment-id'; +import createOpenApiRoutes 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'; + +export function agentScoped(middleware: Middleware): Middleware { + return async function scoped(ctx, next) { + if (!isAgentPath(ctx.path)) { + await next(); + + return; + } + + await middleware(ctx, next); + }; +} + +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 }; +} + +/** + * How this deployment reaches the agent: in-process when a dispatcher was handed over, over HTTP + * when an AGENT_URL was configured, and not at all otherwise — which is what makes the data and + * action routes fall back to their stub. + */ +export function resolveTransport( + config: BFFConfig, + dispatcher: AgentDispatcher | undefined, +): AgentTransport | undefined { + const timeoutMs = config.agentTimeoutMs; + + if (dispatcher) return createInProcessTransport({ dispatcher, timeoutMs }); + if (config.agentUrl) return createHttpTransport({ agentUrl: config.agentUrl, timeoutMs }); + + return undefined; +} + +/** + * 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, + transport: AgentTransport | undefined, + logger: Logger, +): UnfoldSource | undefined { + if (!bundle || !transport) return undefined; + + return { store: bundle.store, transport, 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), + resolveTransport(config, undefined), + logger, + ); +} + +// The routes that write an activity log come with the writer holding their pending transitions, so +// the host can drain it when it stops. +interface AgentRouteEdge { + middlewares: Middleware[]; + activityLogs?: ActivityLogWriter; +} + +// The data middleware falls through to the action middleware on a non-data path. +function buildAgentRouteMiddlewares( + bundle: ReadModelBundle | undefined, + transport: AgentTransport | undefined, + logger: Logger, + permissionsCache: PermissionsCache, +): AgentRouteEdge { + if (!bundle) { + logger( + 'Warn', + 'Data, action and permissions endpoints disabled: FOREST_SERVER_URL, FOREST_ENV_SECRET or FOREST_AUTH_SECRET is missing', + ); + + return { middlewares: [createAgentStubMiddleware()] }; + } + + const { store, apiKeyConfig } = bundle; + + const permissionsMiddleware = createPermissionsRoutesMiddleware({ + store, + client: new PermissionsClient({ + forestServerUrl: apiKeyConfig.forestServerUrl, + envSecret: apiKeyConfig.forestEnvSecret, + }), + cache: permissionsCache, + logger, + }); + + if (!transport) { + logger('Warn', 'Data and action endpoints disabled: AGENT_URL is missing'); + + return { middlewares: [permissionsMiddleware, createAgentStubMiddleware()] }; + } + + const activityLogs = createActivityLogWriter({ + service: createBffActivityLogsService(apiKeyConfig.forestServerUrl), + logger, + }); + + return { + middlewares: [ + permissionsMiddleware, + createDataRoutesMiddleware({ store, transport, logger, activityLogs }), + createActionRoutesMiddleware({ store, transport, logger, activityLogs }), + ], + activityLogs, + }; +} + +export function buildAiMiddlewares( + config: BFFConfig, + oauth: OAuthEdge, + logger: Logger, +): Middleware[] { + const { session, resolveEnvironmentId } = 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, + resolveEnvironmentId, + logger, + }), + ]; +} + +interface AgentEdge { + middlewares: Middleware[]; + invalidate(): void; + activityLogs?: ActivityLogWriter; +} + +export function buildAgentMiddlewares( + config: BFFConfig, + logger: Logger, + oauth: OAuthEdge, + aiMiddlewares: Middleware[], + basePath: string, + transport: AgentTransport | undefined, + metrics: Metrics | undefined, + allowedOriginsLabel = 'BFF_ALLOWED_ORIGINS', +): AgentEdge { + const { forestAuthSecret, defaultTimezone } = config; + + if (!forestAuthSecret) { + logger('Warn', 'Agent edge disabled: FOREST_AUTH_SECRET is missing'); + + return { middlewares: [], invalidate: () => undefined }; + } + + 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, metrics); + const source = toUnfoldSource(bundle, transport, logger); + const permissionsCache = new PermissionsCache(); + const routeEdge = buildAgentRouteMiddlewares(bundle, transport, logger, permissionsCache); + + const chain: Middleware[] = [ + createAuthModeMiddleware({ authSecret: forestAuthSecret }), + apiKeyStep, + // After both auth middlewares: the resolver it lands reads what they put on the context. + createForestServerTokenMiddleware({ session: oauth.session, logger }), + createRateLimitMiddleware({ + maxRequests: config.rateLimitMaxRequests, + windowMs: config.rateLimitWindowMs, + }), + createPerKeyOriginMiddleware({ + logger, + serverAllowedOrigins: config.allowedOrigins, + allowedOriginsLabel, + }), + createOpenApiRoutes({ + version, + enabled: config.openapiEnabled, + source, + hasAiQueryRoute: aiMiddlewares.length > 0, + publicUrl: config.publicUrl, + basePath, + }), + ...(bundle + ? [ + createContextRoutesMiddleware({ + store: bundle.store, + resolveEnvironmentId: oauth.resolveEnvironmentId + ? tolerateEnvironmentIdFailure(oauth.resolveEnvironmentId, logger) + : undefined, + }), + ] + : []), + ...aiMiddlewares, + createTimezoneMiddleware({ defaultTimezone }), + ...routeEdge.middlewares, + ]; + + return { + middlewares: chain.map(agentScoped), + invalidate: () => { + // The one trace an invalidation leaves: the operator asked "the schema is wrong" needs to + // establish that one happened and when, and the age gauge cannot say it. + logger('Info', 'Dropping the SaaS read caches on host request'); + bundle?.store.invalidate(); + permissionsCache.clear(); + }, + activityLogs: routeEdge.activityLogs, + }; +} diff --git a/packages/agent-bff/src/api-key/api-key-edge.ts b/packages/agent-bff/src/api-key/api-key-edge.ts new file mode 100644 index 0000000000..987ab02f1c --- /dev/null +++ b/packages/agent-bff/src/api-key/api-key-edge.ts @@ -0,0 +1,59 @@ +import type { BFFConfig } from '../config/env-config'; +import type { Logger } from '../ports/logger-port'; +import type { Middleware } from 'koa'; + +import createApiKeyAuthenticator from './api-key-authenticator'; +import ApiKeyClient from './api-key-client'; +import createApiKeyMiddleware from './api-key-middleware'; +import createResolveCache from './resolve-cache'; +import { unauthorized } from '../http/bff-http-error'; + +export interface ResolvedApiKeyConfig { + forestServerUrl: string; + forestEnvSecret: string; + forestAuthSecret: string; +} + +export function resolveApiKeyConfig(config: BFFConfig): ResolvedApiKeyConfig | undefined { + const { forestServerUrl, forestEnvSecret, forestAuthSecret } = config; + + if (forestServerUrl && forestEnvSecret && forestAuthSecret) { + return { forestServerUrl, forestEnvSecret, forestAuthSecret }; + } + + return undefined; +} + +export 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 }); +} + +export 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(); + }; +} diff --git a/packages/agent-bff/src/build-bff.ts b/packages/agent-bff/src/build-bff.ts index 247af561bc..9d5b926894 100644 --- a/packages/agent-bff/src/build-bff.ts +++ b/packages/agent-bff/src/build-bff.ts @@ -1,14 +1,10 @@ -import type { ActivityLogWriter } from './activity-log/activity-log-writer'; -import type { AgentTransport } from './agent/agent-transport'; import type { AgentDispatcher } from './agent/in-process-transport'; import type { BFFConfig, ConfigLabels } from './config/env-config'; import type { BffHealth } from './http/health-route'; import type { EnvironmentIdResolver } from './oauth/environment-id'; 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'; @@ -16,50 +12,35 @@ import { normalizeDomainList } from '@forestadmin/forestadmin-client'; import { bodyParser } from '@koa/bodyparser'; import Koa from 'koa'; -import createActionRoutesMiddleware from './action/action-routes-middleware'; -import createActivityLogWriter from './activity-log/activity-log-writer'; -import createBffActivityLogsService from './activity-log/activity-logs-service'; import createConsoleLogger from './adapters/console-logger'; -import createAgentStubMiddleware from './agent/agent-stub'; -import { createHttpTransport } from './agent/agent-transport'; -import createInProcessTransport from './agent/in-process-transport'; -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 createForestServerTokenMiddleware from './auth/forest-server-token-middleware'; +import { + agentScoped, + buildAgentMiddlewares, + buildAiMiddlewares, + resolveTransport, +} from './agent-edge'; +import { AI_QUERY_ROUTE } from './ai/ai-routes-middleware'; import normalizeBasePath from './base-path'; import { labeler } from './config/env-config'; 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'; -import createDataRoutesMiddleware from './data/data-routes-middleware'; import createDocsRoutes from './docs/docs-routes'; import createAccessLogMiddleware from './http/access-log-middleware'; -import { unauthorized, unsupportedMediaType } from './http/bff-http-error'; +import { unsupportedMediaType } from './http/bff-http-error'; import BODY_LIMIT, { AI_BODY_LIMIT } from './http/body-limit'; import createErrorMiddleware from './http/error-middleware'; import createHealthRoute, { describeHealth } from './http/health-route'; import createVersionHeaderMiddleware from './http/version-header-middleware'; -import createEnvironmentIdResolver, { tolerateEnvironmentIdFailure } from './oauth/environment-id'; +import createEnvironmentIdResolver from './oauth/environment-id'; 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 { OPENAPI_PATH } from './openapi/openapi-routes'; import version from './version'; +export { resolveUnfoldSource } from './agent-edge'; + /** What a host must hand to `http.createServer`, or mount, to serve the BFF. */ export type BffCallback = (req: IncomingMessage, res: ServerResponse) => void | Promise; @@ -137,18 +118,6 @@ function createJsonOnlyGuard(): Middleware { }; } -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 }); @@ -172,17 +141,6 @@ function createBodyParser(hasAiQueryRoute: boolean): Middleware { }; } -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; @@ -214,7 +172,7 @@ interface OAuthSession { forestServerUrl: string; } -interface OAuthEdge { +export interface OAuthEdge { middlewares: Middleware[]; resolveEnvironmentId?: EnvironmentIdResolver; session?: OAuthSession; @@ -262,286 +220,6 @@ function buildOAuthMiddlewares( }; } -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 }; -} - -/** - * How this deployment reaches the agent: in-process when a dispatcher was handed over, over HTTP - * when an AGENT_URL was configured, and not at all otherwise — which is what makes the data and - * action routes fall back to their stub. - */ -function resolveTransport( - config: BFFConfig, - dispatcher: AgentDispatcher | undefined, -): AgentTransport | undefined { - const timeoutMs = config.agentTimeoutMs; - - if (dispatcher) return createInProcessTransport({ dispatcher, timeoutMs }); - if (config.agentUrl) return createHttpTransport({ agentUrl: config.agentUrl, timeoutMs }); - - return undefined; -} - -/** - * 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, - transport: AgentTransport | undefined, - logger: Logger, -): UnfoldSource | undefined { - if (!bundle || !transport) return undefined; - - return { store: bundle.store, transport, 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), - resolveTransport(config, undefined), - logger, - ); -} - -// The routes that write an activity log come with the writer holding their pending transitions, so -// the host can drain it when it stops. -interface AgentRouteEdge { - middlewares: Middleware[]; - activityLogs?: ActivityLogWriter; -} - -// The data middleware falls through to the action middleware on a non-data path. -function buildAgentRouteMiddlewares( - bundle: ReadModelBundle | undefined, - transport: AgentTransport | undefined, - logger: Logger, - permissionsCache: PermissionsCache, -): AgentRouteEdge { - if (!bundle) { - logger( - 'Warn', - 'Data, action and permissions endpoints disabled: FOREST_SERVER_URL, FOREST_ENV_SECRET or FOREST_AUTH_SECRET is missing', - ); - - return { middlewares: [createAgentStubMiddleware()] }; - } - - const { store, apiKeyConfig } = bundle; - - const permissionsMiddleware = createPermissionsRoutesMiddleware({ - store, - client: new PermissionsClient({ - forestServerUrl: apiKeyConfig.forestServerUrl, - envSecret: apiKeyConfig.forestEnvSecret, - }), - cache: permissionsCache, - logger, - }); - - if (!transport) { - logger('Warn', 'Data and action endpoints disabled: AGENT_URL is missing'); - - return { middlewares: [permissionsMiddleware, createAgentStubMiddleware()] }; - } - - const activityLogs = createActivityLogWriter({ - service: createBffActivityLogsService(apiKeyConfig.forestServerUrl), - logger, - }); - - return { - middlewares: [ - permissionsMiddleware, - createDataRoutesMiddleware({ store, transport, logger, activityLogs }), - createActionRoutesMiddleware({ store, transport, logger, activityLogs }), - ], - activityLogs, - }; -} - -function buildAiMiddlewares(config: BFFConfig, oauth: OAuthEdge, logger: Logger): Middleware[] { - const { session, resolveEnvironmentId } = 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, - resolveEnvironmentId, - logger, - }), - ]; -} - -interface AgentEdge { - middlewares: Middleware[]; - invalidate(): void; - activityLogs?: ActivityLogWriter; -} - -function buildAgentMiddlewares( - config: BFFConfig, - logger: Logger, - oauth: OAuthEdge, - aiMiddlewares: Middleware[], - basePath: string, - transport: AgentTransport | undefined, - metrics: Metrics | undefined, - allowedOriginsLabel = 'BFF_ALLOWED_ORIGINS', -): AgentEdge { - const { forestAuthSecret, defaultTimezone } = config; - - if (!forestAuthSecret) { - logger('Warn', 'Agent edge disabled: FOREST_AUTH_SECRET is missing'); - - return { middlewares: [], invalidate: () => undefined }; - } - - 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, metrics); - const source = toUnfoldSource(bundle, transport, logger); - const permissionsCache = new PermissionsCache(); - const routeEdge = buildAgentRouteMiddlewares(bundle, transport, logger, permissionsCache); - - const chain: Middleware[] = [ - createAuthModeMiddleware({ authSecret: forestAuthSecret }), - apiKeyStep, - // After both auth middlewares: the resolver it lands reads what they put on the context. - createForestServerTokenMiddleware({ session: oauth.session, logger }), - createRateLimitMiddleware({ - maxRequests: config.rateLimitMaxRequests, - windowMs: config.rateLimitWindowMs, - }), - createPerKeyOriginMiddleware({ - logger, - serverAllowedOrigins: config.allowedOrigins, - allowedOriginsLabel, - }), - createOpenApiRoutes({ - version, - enabled: config.openapiEnabled, - source, - hasAiQueryRoute: aiMiddlewares.length > 0, - publicUrl: config.publicUrl, - basePath, - }), - ...(bundle - ? [ - createContextRoutesMiddleware({ - store: bundle.store, - resolveEnvironmentId: oauth.resolveEnvironmentId - ? tolerateEnvironmentIdFailure(oauth.resolveEnvironmentId, logger) - : undefined, - }), - ] - : []), - ...aiMiddlewares, - createTimezoneMiddleware({ defaultTimezone }), - ...routeEdge.middlewares, - ]; - - return { - middlewares: chain.map(agentScoped), - invalidate: () => { - // The one trace an invalidation leaves: the operator asked "the schema is wrong" needs to - // establish that one happened and when, and the age gauge cannot say it. - logger('Info', 'Dropping the SaaS read caches on host request'); - bundle?.store.invalidate(); - permissionsCache.clear(); - }, - activityLogs: routeEdge.activityLogs, - }; -} - /** * 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 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 c2dbc29ffb..cdad13580c 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,8 @@ const SRC_DIR = path.join(__dirname, '..', '..', 'src'); const OPENAPI_MODULE_PREFIX = 'openapi/'; const ALLOWED_OPENAPI_IMPORTS: Record = { - 'build-bff.ts': ['openapi/openapi-routes', 'openapi/unfolded-document'], + 'agent-edge.ts': ['openapi/openapi-routes', 'openapi/unfolded-document'], + 'build-bff.ts': ['openapi/openapi-routes'], 'cli-dispatch.ts': ['openapi/render-openapi'], 'index.ts': ['openapi/render-openapi'], };