diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 500159817a..563e87c2a5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -111,7 +111,7 @@ jobs: # broadly: most packages keep genuine unit tests under `test/integration*/`, and a wide # pattern would silently stop running hundreds of them. - name: Test code (excluding integration suites with their own job) - run: cd packages/${{ matrix.package }} && yarn test --coverage --testPathIgnorePatterns='llm.integration|search-agent.integration' && cd - + run: cd packages/${{ matrix.package }} && yarn test --coverage --testPathIgnorePatterns='llm.integration|embedded-bff.e2e' && cd - - name: Upload coverage uses: actions/upload-artifact@v4 with: @@ -150,10 +150,12 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - # Boots a real agent on a local HTTP port, so it stays out of the unit job. Unlike the LLM suite - # it reaches no third party and is deterministic, hence no continue-on-error: it must gate. + # Boots a real agent on a local HTTP port, so it stays out of the unit job (which ignores this + # path). Gates both transports: the in-process dispatcher an embedded BFF uses and the socket a + # standalone one uses. Unlike the LLM suite it reaches no third party and is deterministic, hence + # no continue-on-error: it must gate. bff-integration-tests: - name: BFF Integration Tests (agent-bff) + name: BFF Integration Tests (embedded and http transports) runs-on: ubuntu-latest timeout-minutes: 15 needs: [build] @@ -177,7 +179,7 @@ jobs: key: ${{ runner.os }}-build-${{ github.sha }} 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/.*\.e2e' send-coverage: name: Send Coverage diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index bfd3b2974d..dbce76f258 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -95,7 +95,7 @@ yarn start:dev # node --env-file=.env dist/cli.js | `FOREST_SERVER_URL` | yes | Forest SaaS API base URL. | | `FOREST_APP_URL` | yes | Forest front base URL, used to build the OAuth front-channel redirect (`src/oauth/oauth-routes.ts`). | | `AGENT_URL` | yes | The customer agent base URL the BFF calls via agent-client. | -| `BFF_TOKEN_ENCRYPTION_KEY` | for OAuth | Base64-encoded 32-byte AES-256 key encrypting stored refresh tokens. Until it is set, the `/oauth/*` token-issuance routes are disabled and `/health` reports `degraded`; already-issued `bff_access` tokens still authenticate on `/agent/*` whenever `FOREST_AUTH_SECRET` is present. | +| `BFF_TOKEN_ENCRYPTION_KEY` | for OAuth | Base64-encoded 32-byte AES-256 key encrypting stored refresh tokens. Until it is set, the `/oauth/*` token-issuance routes are disabled and `/health` reports `configured.oauth: false` — but it stays `ok`, since the key gates OAuth and not boot; already-issued `bff_access` tokens still authenticate on `/agent/*` whenever `FOREST_AUTH_SECRET` is present. | | `HTTP_PORT` | no | Server port, integer 0–65535. Defaults to `3450`. `0` binds an OS-assigned ephemeral port. | | `BFF_ALLOWED_ORIGINS` | no | Comma-separated CORS allow-list of exact origins (scheme + host + port). No wildcard. Empty ⇒ no cross-origin browser access. | | `BFF_DEFAULT_TIMEZONE` | no | Fallback IANA timezone used when a request carries neither an `X-Forest-Timezone` header nor a body `timezone`. | diff --git a/packages/agent-bff/package.json b/packages/agent-bff/package.json index b281df36a0..870d48d123 100644 --- a/packages/agent-bff/package.json +++ b/packages/agent-bff/package.json @@ -44,8 +44,6 @@ "zod": "4.3.6" }, "devDependencies": { - "@forestadmin/agent": "1.99.2", - "@forestadmin/agent-testing": "1.2.16", "@hey-api/openapi-ts": "0.99.0", "@redocly/cli": "2.35.1", "@types/inflected": "^1.1.29", diff --git a/packages/agent-bff/src/agent/in-process-transport.ts b/packages/agent-bff/src/agent/in-process-transport.ts index a75bff2cb8..4fcfe877bc 100644 --- a/packages/agent-bff/src/agent/in-process-transport.ts +++ b/packages/agent-bff/src/agent/in-process-transport.ts @@ -5,7 +5,7 @@ import { HttpRequester } from '@forestadmin/agent-client'; import { streamingUnsupported } from '../http/bff-local-errors'; /** A sentinel that never reaches the network: `query` answers before any socket is opened. */ -const IN_PROCESS_URL = 'http://in-process.agent'; +export const IN_PROCESS_AGENT_URL = 'http://in-process.agent'; export interface AgentDispatchRequest { method: 'get' | 'post' | 'put' | 'delete'; @@ -46,7 +46,7 @@ class InProcessRequester extends HttpRequester { private readonly dispatcher: AgentDispatcher, private readonly defaultTimeoutMs?: number, ) { - super(bearerToken, { url: IN_PROCESS_URL }); + super(bearerToken, { url: IN_PROCESS_AGENT_URL }); } // No socket to stream from, and nothing in the BFF streams today. A typed 501 rather than a raw @@ -118,14 +118,14 @@ class InProcessRequester extends HttpRequester { * enough: `escapeUrlSlug` prefixes `+?*` with a backslash, which the WHATWG parser `buildUrl` * feeds reads as a path separator, and that parse also resolves `..` and splits a trailing query * off. Running it here is what keeps a given id addressing the same record over both transports — - * both remain wrong for those three characters, which is PRD-1124's to fix on both at once. + * both remain wrong for those three characters — TODO(PRD-1124), to fix on both at once. */ private static toDispatchTarget(path: string): { path: string; query: Record; } { const normalized = path.startsWith('/') ? path : `/${path}`; - const url = new URL(`${IN_PROCESS_URL}${HttpRequester.escapeUrlSlug(normalized)}`); + const url = new URL(`${IN_PROCESS_AGENT_URL}${HttpRequester.escapeUrlSlug(normalized)}`); return { path: url.pathname, query: Object.fromEntries(url.searchParams) }; } @@ -141,7 +141,7 @@ export default function createInProcessTransport({ timeoutMs, }: InProcessTransportOptions): AgentTransport { return { - url: IN_PROCESS_URL, + url: IN_PROCESS_AGENT_URL, createRequester: token => new InProcessRequester(token, dispatcher, timeoutMs), }; } diff --git a/packages/agent-bff/src/build-bff.ts b/packages/agent-bff/src/build-bff.ts index a0650f290a..bf5d7c7fcb 100644 --- a/packages/agent-bff/src/build-bff.ts +++ b/packages/agent-bff/src/build-bff.ts @@ -1,3 +1,5 @@ +import type { AgentTransport } from './agent/agent-transport'; +import type { AgentDispatcher } from './agent/in-process-transport'; import type { BFFConfig } from './config/env-config'; import type { EnvironmentIdResolver } from './oauth/environment-id'; import type { SessionStore } from './oauth/session-store'; @@ -15,6 +17,7 @@ import createActionRoutesMiddleware from './action/action-routes-middleware'; 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'; @@ -64,6 +67,16 @@ export interface BuildBffOptions { * origin root. Normalized by `normalizeBasePath`, which throws on anything else. */ basePath?: string; + /** + * Reaches an agent living in the same process, without a socket. When set, it replaces the HTTP + * transport entirely: `AGENT_URL` then only names where the agent answers, never how it is called. + */ + dispatcher?: AgentDispatcher; + /** + * Where the read-model reports its gauges. Defaults to the console sink, which is what the + * standalone deployment wants; an embedding host passes its own, or a no-op. + */ + metrics?: Metrics; } export interface Bff { @@ -283,6 +296,23 @@ function resolveReadModelBundle( 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 @@ -291,19 +321,12 @@ function resolveReadModelBundle( */ function toUnfoldSource( bundle: ReadModelBundle | undefined, - config: BFFConfig, + transport: AgentTransport | undefined, logger: Logger, ): UnfoldSource | undefined { - if (!bundle || !config.agentUrl) return undefined; + if (!bundle || !transport) return undefined; - return { - store: bundle.store, - transport: createHttpTransport({ - agentUrl: config.agentUrl, - timeoutMs: config.agentTimeoutMs, - }), - logger, - }; + return { store: bundle.store, transport, logger }; } /** @@ -315,13 +338,17 @@ function toUnfoldSource( const UNMEASURED: Metrics = { increment: () => undefined, gauge: () => undefined }; export function resolveUnfoldSource(config: BFFConfig, logger: Logger): UnfoldSource | undefined { - return toUnfoldSource(resolveReadModelBundle(config, logger, UNMEASURED), config, logger); + return toUnfoldSource( + resolveReadModelBundle(config, logger, UNMEASURED), + resolveTransport(config, undefined), + logger, + ); } // The data middleware falls through to the action middleware on a non-data path. function buildAgentRouteMiddlewares( bundle: ReadModelBundle | undefined, - config: BFFConfig, + transport: AgentTransport | undefined, logger: Logger, permissionsCache: PermissionsCache, ): Middleware[] { @@ -335,7 +362,6 @@ function buildAgentRouteMiddlewares( } const { store, apiKeyConfig } = bundle; - const { agentUrl, agentTimeoutMs: timeoutMs } = config; const permissionsMiddleware = createPermissionsRoutesMiddleware({ store, @@ -347,14 +373,12 @@ function buildAgentRouteMiddlewares( logger, }); - if (!agentUrl) { + if (!transport) { logger('Warn', 'Data and action endpoints disabled: AGENT_URL is missing'); return [permissionsMiddleware, createAgentStubMiddleware()]; } - const transport = createHttpTransport({ agentUrl, timeoutMs }); - return [ permissionsMiddleware, createDataRoutesMiddleware({ store, transport, logger }), @@ -398,6 +422,8 @@ function buildAgentMiddlewares( oauth: OAuthEdge, aiMiddlewares: Middleware[], basePath: string, + transport: AgentTransport | undefined, + metrics: Metrics | undefined, ): AgentEdge { const { forestAuthSecret, defaultTimezone } = config; @@ -410,8 +436,8 @@ function buildAgentMiddlewares( 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 bundle = resolveReadModelBundle(config, logger, metrics); + const source = toUnfoldSource(bundle, transport, logger); const permissionsCache = new PermissionsCache(); const chain: Middleware[] = [ @@ -442,7 +468,7 @@ function buildAgentMiddlewares( : []), ...aiMiddlewares, createTimezoneMiddleware({ defaultTimezone }), - ...buildAgentRouteMiddlewares(bundle, config, logger, permissionsCache), + ...buildAgentRouteMiddlewares(bundle, transport, logger, permissionsCache), ]; return { @@ -468,6 +494,8 @@ export default async function buildBff({ config, logger = createConsoleLogger(), basePath, + dispatcher, + metrics, }: BuildBffOptions): Promise { // Before anything is assembled: a mount the host does not serve must fail at boot, not surface as // a docs page that cannot load itself. @@ -481,9 +509,25 @@ export default async function buildBff({ warnMissingConfig(config, logger); + if (config.allowedOrigins.length === 0) { + logger( + 'Warn', + 'No allowed origin: no browser can call this BFF. Set BFF_ALLOWED_ORIGINS, or `allowedOrigins`.', + ); + } + + const transport = resolveTransport(config, dispatcher); const oauth = buildOAuthMiddlewares(config, logger); const aiMiddlewares = buildAiMiddlewares(config, oauth, logger); - const agentEdge = buildAgentMiddlewares(config, logger, oauth, aiMiddlewares, mountPath); + const agentEdge = buildAgentMiddlewares( + config, + logger, + oauth, + aiMiddlewares, + mountPath, + transport, + metrics, + ); const agentMiddlewares = agentEdge.middlewares; const hasAgentEdge = agentMiddlewares.length > 0; const agentErrorMiddleware = hasAgentEdge ? [agentScoped(createErrorMiddleware({ logger }))] : []; @@ -491,7 +535,21 @@ export default async function buildBff({ const middlewares = [ createVersionHeaderMiddleware(version), - createHealthRoute({ config, version }), + createHealthRoute({ + version, + // Embedded, the rest is inherited from the agent, so there is no gap to report: a 503 here + // would let a load balancer restart a process that serves api-key traffic fine. The auth + // secret is still required — without it the agent edge is a stub and nothing authenticated + // can be served, which is exactly what a probe must see. + healthy: + (dispatcher !== undefined && Boolean(config.forestAuthSecret)) || config.hasAllRequired, + configured: { + oauth: oauth.middlewares.length > 0, + ai: aiMiddlewares.length > 0, + cors: config.allowedOrigins.length > 0, + openapi: config.openapiEnabled && agentMiddlewares.length > 0, + }, + }), createCorsMiddleware({ allowedOrigins: config.allowedOrigins, logger }), ...agentErrorMiddleware, ...agentJsonOnlyGuard, diff --git a/packages/agent-bff/src/config/env-config.ts b/packages/agent-bff/src/config/env-config.ts index 74bd9bbcdf..853f564ac4 100644 --- a/packages/agent-bff/src/config/env-config.ts +++ b/packages/agent-bff/src/config/env-config.ts @@ -219,6 +219,10 @@ export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig { ), httpPort: parsePort(env.HTTP_PORT), presence, - hasAllRequired: REQUIRED_KEYS.every(key => presence[key]) && tokenEncryptionKey !== undefined, + // The encryption key is deliberately not part of this: it gates OAuth, not boot, so a + // key-only deployment is fully operational and must not report degraded — `/health` would + // otherwise have a load balancer restart a process that serves its api-key and bearer traffic + // fine. Which optional surfaces are on is what `configured` reports. + hasAllRequired: REQUIRED_KEYS.every(key => presence[key]), }; } diff --git a/packages/agent-bff/src/cors/cors-middleware.ts b/packages/agent-bff/src/cors/cors-middleware.ts index ceaadf1aee..7e35a398fd 100644 --- a/packages/agent-bff/src/cors/cors-middleware.ts +++ b/packages/agent-bff/src/cors/cors-middleware.ts @@ -3,11 +3,59 @@ import type { Middleware } from 'koa'; import { loggableOrigin, originAllowed } from './origin'; +/** + * Whether the caller is the very application serving this BFF. A same-origin request carries + * `Origin` too — the Fetch spec sends it on everything but GET and HEAD, and every BFF data route + * is a POST — so without this exemption a host serving its own UI next to a `/bff` mount would be + * refused on every request under the default empty allow-list, for an origin it has no reason to + * think it must name. + * + * Compared on host rather than on the full origin: a TLS-terminating proxy leaves `ctx.protocol` at + * `http` while the browser reports `https`, so matching the scheme would work in development and + * silently fail in production. The scheme is also the part a same-host attacker already controls. + * + * Forging the header buys nothing: a caller that can set `Origin` can simply omit it, which the + * allow-list lets through by design. What this cannot be reached by is the cross-site request the + * allow-list exists for — there the browser sets `Origin` to the attacking page, never to this host. + */ +function isSameOrigin(origin: string, host: string): boolean { + if (host === '') return false; + + let url: URL; + + try { + url = new URL(origin); + } catch { + return false; + } + + // `url.host` is already normalized by `new URL()` — lowercased, and stripped of a port that is + // the default for the scheme — while `ctx.host` is the raw `Host` header, which a client or proxy + // spells however it likes. Both differences have to be absorbed here, or the exemption refuses + // the very requests it exists for: `Host: APP.EXAMPLE.COM` or `Host: app.example.com:443` against + // `Origin: https://app.example.com`. A non-default port still has to match exactly. + const normalizedHost = host.toLowerCase(); + const defaultPort = url.protocol === 'https:' ? '443' : '80'; + + return normalizedHost === url.host || normalizedHost === `${url.host}:${defaultPort}`; +} + export const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; export const ALLOWED_HEADERS = 'Authorization, Content-Type, X-Forest-Timezone, X-Forest-Bff-Key, X-Request-Id, Forest-Projection'; export const PREFLIGHT_MAX_AGE_SECONDS = 600; +/** + * Shaped like every other BFF error so a consumer branches on `error.type`. Built here rather than + * thrown: this middleware runs before the agent-scoped error middleware, so a throw would surface + * as a bare 500 instead of the contract. + */ +export const ORIGIN_NOT_ALLOWED = { + type: 'origin_not_allowed', + status: 403, + message: 'This origin is not allowed to call this BFF', +} as const; + export interface CorsMiddlewareOptions { allowedOrigins: string[]; logger: Logger; @@ -43,6 +91,20 @@ export default function createCorsMiddleware({ return; } + // Refuse rather than run and let the browser discard the answer. Omitting the header only stops + // the caller from READING the response: the request still executed, so a list was read and an + // action was executed for an origin the allow-list names as unwelcome. It also stops being + // theoretical once a host sits in front of this app — a permissive `cors()` of its own answers + // the preflight with `*`, and the real request arrives here regardless. + // A caller with no `Origin` at all is untouched: that is every server-to-server api-key call. + // Nor is the host's own application, which the allow-list has nothing to say about. + if (origin && !allowed && !isSameOrigin(origin, ctx.host)) { + ctx.status = ORIGIN_NOT_ALLOWED.status; + ctx.body = { error: ORIGIN_NOT_ALLOWED }; + + return; + } + await next(); }; } diff --git a/packages/agent-bff/src/http/bff-http-server.ts b/packages/agent-bff/src/http/bff-http-server.ts index 276387d5e4..83643c9ceb 100644 --- a/packages/agent-bff/src/http/bff-http-server.ts +++ b/packages/agent-bff/src/http/bff-http-server.ts @@ -67,7 +67,18 @@ export default class BFFHttpServer { const app = new Koa(); app.use(createVersionHeaderMiddleware(version)); - app.use(createHealthRoute({ config, version })); + app.use( + createHealthRoute({ + version, + healthy: config.hasAllRequired, + configured: { + oauth: Boolean(config.tokenEncryptionKey), + ai: Boolean(config.tokenEncryptionKey), + cors: config.allowedOrigins.length > 0, + openapi: config.openapiEnabled, + }, + }), + ); for (const middleware of options.middlewares ?? []) { app.use(middleware); diff --git a/packages/agent-bff/src/http/health-route.ts b/packages/agent-bff/src/http/health-route.ts index a1165952af..d5cd3f3f05 100644 --- a/packages/agent-bff/src/http/health-route.ts +++ b/packages/agent-bff/src/http/health-route.ts @@ -1,14 +1,32 @@ -import type { BFFConfig } from '../config/env-config'; import type { Middleware } from 'koa'; export const HEALTH_PATH = '/health'; +/** + * Which optional surfaces this deployment was CONFIGURED to serve. Deliberately not a statement + * that they work: OAuth is `true` as soon as an encryption key is set, whether or not the Forest + * server ever answers. Naming it `configured` is the whole point — a reader who takes `oauth: true` + * for "OAuth is functional" will believe a misconfigured deployment is healthy. + */ +export interface HealthConfigured { + oauth: boolean; + ai: boolean; + cors: boolean; + openapi: boolean; +} + export interface HealthRouteOptions { - config: BFFConfig; version: string; + /** Whether everything this deployment needs is configured. Embedded, it always is. */ + healthy: boolean; + configured: HealthConfigured; } -export default function createHealthRoute({ config, version }: HealthRouteOptions): Middleware { +export default function createHealthRoute({ + version, + healthy, + configured, +}: HealthRouteOptions): Middleware { return async function health(ctx, next) { const isHealthRequest = (ctx.method === 'GET' || ctx.method === 'HEAD') && ctx.path === HEALTH_PATH; @@ -19,7 +37,7 @@ export default function createHealthRoute({ config, version }: HealthRouteOption return; } - 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, configured }; }; } diff --git a/packages/agent-bff/src/index.ts b/packages/agent-bff/src/index.ts index 72b9fe34bf..1d35a720c0 100644 --- a/packages/agent-bff/src/index.ts +++ b/packages/agent-bff/src/index.ts @@ -3,6 +3,15 @@ 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 { + default as createInProcessTransport, + IN_PROCESS_AGENT_URL, +} from './agent/in-process-transport'; +export type { + AgentDispatcher, + AgentDispatchRequest, + AgentDispatchResponse, +} from './agent/in-process-transport'; export type { Bff, BuildBffOptions, BffCallback } from './build-bff'; export { ConfigurationError } from './errors'; export { default as DEFAULT_BFF_PORT } from './defaults'; diff --git a/packages/agent-bff/test/build-bff-dispatcher.test.ts b/packages/agent-bff/test/build-bff-dispatcher.test.ts new file mode 100644 index 0000000000..24b31e6842 --- /dev/null +++ b/packages/agent-bff/test/build-bff-dispatcher.test.ts @@ -0,0 +1,171 @@ +import type { AgentDispatchRequest, AgentDispatcher } from '../src/agent/in-process-transport'; + +import request from 'supertest'; + +import buildBff from '../src/build-bff'; +import { restoreFetchAfterEach, stubEnvironmentIdFetch } from './helpers/fetch-stub'; +import { collection, column } from './read-model/fixtures'; +import { parseConfig } from '../src/config/env-config'; +import { issueBffAccessToken } from '../src/oauth/bff-token'; + +const fetchSchema = jest.fn(); + +jest.mock('../src/read-model/forest-schema-client', () => ({ + __esModule: true, + default: class { + fetchSchema = fetchSchema; + }, +})); + +const AUTH_SECRET = 'auth-secret'; + +// No AGENT_URL on purpose: that is what an embedded deployment's config looks like, and it is what +// tells the in-process transport apart from the HTTP one through the public surface. +const EMBEDDED_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', +} satisfies NodeJS.ProcessEnv; + +function sessionToken(): string { + return issueBffAccessToken({ + sid: 'session-1', + user: { + id: 1, + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + team: 'admin', + permissionLevel: 'admin', + renderingId: 1, + role: 'admin', + tags: {}, + }, + renderingId: 1, + authSecret: AUTH_SECRET, + expiresInSeconds: 3600, + }); +} + +/** Answers the two calls a list goes through: the capabilities probe, then the records. */ +function agentDispatcher(): jest.Mocked { + return { + request: jest.fn(async ({ path }: AgentDispatchRequest) => { + if (path.endsWith('/_internal/capabilities')) { + return { + status: 200, + body: { + collections: [ + { + name: 'books', + fields: [ + { name: 'id', type: 'Number', operators: ['equal'] }, + { name: 'title', type: 'String', operators: ['equal', 'like'] }, + ], + }, + ], + }, + }; + } + + return { + status: 200, + body: { data: [{ type: 'books', id: '1', attributes: { title: 'Foundation' } }] }, + }; + }), + } as unknown as jest.Mocked; +} + +async function buildEmbedded(dispatcher?: AgentDispatcher, env: NodeJS.ProcessEnv = EMBEDDED_ENV) { + return buildBff({ config: parseConfig(env), logger: () => undefined, dispatcher }); +} + +function list(callback: Parameters[0]) { + return request(callback) + .post('/agent/v1/books/list') + .set('Authorization', `Bearer ${sessionToken()}`) + .set('X-Forest-Timezone', 'Europe/Paris') + .send({ projection: ['id', 'title'] }); +} + +describe('buildBff with an in-process dispatcher', () => { + restoreFetchAfterEach(); + + beforeEach(() => { + stubEnvironmentIdFetch(); + fetchSchema.mockReset(); + fetchSchema.mockResolvedValue([collection('books', [column('id'), column('title')])]); + }); + + it('should serve the records the dispatcher returns, without opening a socket', async () => { + const dispatcher = agentDispatcher(); + const { callback } = await buildEmbedded(dispatcher); + + const response = await list(callback); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([ + { + id: '1', + title: 'Foundation', + __forest: { collection: 'books', primaryKey: { id: '1' } }, + }, + ]); + expect(dispatcher.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + path: '/forest/books', + headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Bearer .+/) }), + }), + ); + }); + + it('should hand the configured agent timeout to every dispatched call', async () => { + const dispatcher = agentDispatcher(); + const { callback } = await buildEmbedded(dispatcher, { + ...EMBEDDED_ENV, + BFF_AGENT_TIMEOUT_MS: '2500', + }); + + await list(callback); + + expect(dispatcher.request).toHaveBeenCalledWith( + expect.objectContaining({ path: '/forest/books', timeoutMs: 2500 }), + ); + }); + + describe('when neither a dispatcher nor an AGENT_URL is given', () => { + it('should fall back to the stub instead of mounting a data edge', async () => { + const { callback } = await buildEmbedded(undefined); + + const response = await list(callback); + + expect(response.status).toBe(501); + expect(response.body.error).toMatchObject({ type: 'not_implemented' }); + }); + }); + + describe('/health', () => { + it('should report ok on a config with no AGENT_URL, since the agent is in-process', async () => { + const { callback } = await buildEmbedded(agentDispatcher()); + + const response = await request(callback).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ status: 'ok' }); + }); + + it('should report degraded without an auth secret, dispatcher or not', async () => { + const { callback } = await buildEmbedded(agentDispatcher(), { + ...EMBEDDED_ENV, + FOREST_AUTH_SECRET: undefined, + }); + + const response = await request(callback).get('/health'); + + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ status: 'degraded' }); + }); + }); +}); diff --git a/packages/agent-bff/test/build-bff.test.ts b/packages/agent-bff/test/build-bff.test.ts index 5d34b1ca7b..70755d8b14 100644 --- a/packages/agent-bff/test/build-bff.test.ts +++ b/packages/agent-bff/test/build-bff.test.ts @@ -59,7 +59,11 @@ describe('buildBff', () => { const response = await request(callback).get('/health'); expect(response.status).toBe(200); - expect(response.body).toEqual({ status: 'ok', version }); + expect(response.body).toEqual({ + status: 'ok', + version, + configured: { oauth: true, ai: true, cors: false, openapi: true }, + }); }); it('should set the version header on /health, which only holds if it is mounted first', async () => { @@ -78,7 +82,11 @@ describe('buildBff', () => { const response = await request(callback).get('/health'); expect(response.status).toBe(503); - expect(response.body).toEqual({ status: 'degraded', version }); + expect(response.body).toEqual({ + status: 'degraded', + version, + configured: { oauth: false, ai: false, cors: false, openapi: true }, + }); }); it('should warn naming the missing keys', async () => { diff --git a/packages/agent-bff/test/config/env-config.test.ts b/packages/agent-bff/test/config/env-config.test.ts index e2fff45821..fa3e943728 100644 --- a/packages/agent-bff/test/config/env-config.test.ts +++ b/packages/agent-bff/test/config/env-config.test.ts @@ -128,12 +128,12 @@ describe('parseConfig', () => { expect(REQUIRED_KEYS).not.toContain('BFF_TOKEN_ENCRYPTION_KEY'); }); - it('should leave the key undefined and mark hasAllRequired false when absent', () => { + it('should leave the key undefined but keep hasAllRequired true when absent', () => { const { BFF_TOKEN_ENCRYPTION_KEY, ...envWithoutKey } = VALID_ENV; const config = parseConfig(envWithoutKey); expect(config.tokenEncryptionKey).toBeUndefined(); - expect(config.hasAllRequired).toBe(false); + expect(config.hasAllRequired).toBe(true); }); it('should expose the key when a valid base64 32-byte value is provided', () => { diff --git a/packages/agent-bff/test/cors/cors-middleware.test.ts b/packages/agent-bff/test/cors/cors-middleware.test.ts index fa65f09e05..dff0c15088 100644 --- a/packages/agent-bff/test/cors/cors-middleware.test.ts +++ b/packages/agent-bff/test/cors/cors-middleware.test.ts @@ -49,17 +49,114 @@ describe('cors middleware (layer 1)', () => { expect(response.headers['access-control-allow-origin']).toBe(`${ALLOWED}:443`); }); - it('sends no CORS origin header for a disallowed origin but still proceeds', async () => { + it('refuses a disallowed origin instead of running the request', async () => { const { app, terminal } = buildApp(); const response = await request(app.callback()) .get('/agent/x') .set('Origin', 'https://evil.example.com'); - expect(response.status).toBe(200); + expect(response.status).toBe(403); + expect(response.body.error).toMatchObject({ type: 'origin_not_allowed', status: 403 }); expect(response.headers['access-control-allow-origin']).toBeUndefined(); + // Le point du refus : sans lui la requête s'exécutait, et seul le navigateur jetait la + // réponse — donc une liste était lue et une action exécutée pour une origine non autorisée. + expect(terminal).not.toHaveBeenCalled(); + }); + + it('leaves a caller with no Origin alone, which is every server-to-server call', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()).get('/agent/x'); + + expect(response.status).toBe(200); + expect(terminal).toHaveBeenCalled(); + }); + }); + + describe('the application serving this BFF', () => { + it('serves its own page without it having to allow-list itself', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()) + .post('/agent/x') + .set('Host', 'self.example.com') + .set('Origin', 'https://self.example.com'); + + expect(response.status).toBe(200); + expect(terminal).toHaveBeenCalled(); + }); + + it('matches on host, so a proxy terminating TLS does not turn it into a refusal', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()) + .post('/agent/x') + .set('Host', 'self.example.com') + .set('Origin', 'http://self.example.com'); + + expect(response.status).toBe(200); expect(terminal).toHaveBeenCalled(); }); + + it('serves it when a proxy spells out the default https port in Host', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()) + .post('/agent/x') + .set('Host', 'self.example.com:443') + .set('Origin', 'https://self.example.com'); + + expect(response.status).toBe(200); + expect(terminal).toHaveBeenCalled(); + }); + + it('serves it when a proxy spells out the default http port in Host', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()) + .post('/agent/x') + .set('Host', 'self.example.com:80') + .set('Origin', 'http://self.example.com'); + + expect(response.status).toBe(200); + expect(terminal).toHaveBeenCalled(); + }); + + it('serves it whatever the case of the Host header, since hostnames are case-insensitive', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()) + .post('/agent/x') + .set('Host', 'SELF.EXAMPLE.COM') + .set('Origin', 'https://self.example.com'); + + expect(response.status).toBe(200); + expect(terminal).toHaveBeenCalled(); + }); + + it('still refuses the same hostname on another port, which is another origin', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()) + .post('/agent/x') + .set('Host', 'self.example.com') + .set('Origin', 'https://self.example.com:8443'); + + expect(response.status).toBe(403); + expect(terminal).not.toHaveBeenCalled(); + }); + + it('sends no Access-Control-Allow-Origin, which same-origin never needs', async () => { + const { app } = buildApp(); + + const response = await request(app.callback()) + .post('/agent/x') + .set('Host', 'self.example.com') + .set('Origin', 'https://self.example.com'); + + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + }); }); describe('preflight', () => { diff --git a/packages/agent-bff/test/data/fixtures/live-agent-harness.ts b/packages/agent-bff/test/data/fixtures/live-agent-harness.ts deleted file mode 100644 index fb848a78b5..0000000000 --- a/packages/agent-bff/test/data/fixtures/live-agent-harness.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { Logger } from '../../../src/ports/logger-port'; -import type { SchemaFetcher } from '../../../src/read-model/forest-schema-client'; -import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; - -import { bodyParser } from '@koa/bodyparser'; -import fs from 'fs/promises'; -import jsonwebtoken from 'jsonwebtoken'; -import Koa from 'koa'; -import net from 'net'; - -import { createHttpTransport } from '../../../src/agent/agent-transport'; -import createDataRoutesMiddleware from '../../../src/data/data-routes-middleware'; -import createErrorMiddleware from '../../../src/http/error-middleware'; -import CapabilitiesCache from '../../../src/read-model/capabilities-cache'; -import ReadModelStore from '../../../src/read-model/read-model-store'; -import SchemaCache from '../../../src/read-model/schema-cache'; - -const TIMEZONE = 'Europe/Paris'; -export const AUTH_SECRET = 'b0bdf0a639c16bae8851dd24ee3d79ef0a352e957c5b86cb'; -export const ENV_SECRET = 'ceba742f5bc73946b34da192816a4d7177b3233fee7769955c29c0e90fd584f2'; -export const BOOT_TIMEOUT_MS = 60_000; - -// agent-testing only deletes a schema file whose name carries this prefix, so reusing it keeps the -// temporary schema cleaned up by `agent.stop()` even though the path is chosen here. -export const RESERVED_SCHEMA_PREFIX = 'reserved-forestadmin-schema-test-'; - -const noopLogger: Logger = () => {}; - -export async function findFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - - server.on('error', reject); - server.listen(0, () => { - const { port } = server.address() as net.AddressInfo; - - server.close(() => resolve(port)); - }); - }); -} - -function agentToken(): string { - return jsonwebtoken.sign( - { id: 1, email: 'forest@forest.com', renderingId: 1, team: 'admin' }, - AUTH_SECRET, - { expiresIn: '1 hour' }, - ); -} - -function schemaFetcherFromFile(schemaPath: string): SchemaFetcher { - return { - fetchSchema: async () => { - const { collections } = JSON.parse(await fs.readFile(schemaPath, 'utf8')) as { - collections: ForestSchemaCollection[]; - }; - - return collections; - }, - }; -} - -/** - * The BFF in front of a real agent: the middleware production mounts, the real data client, and a - * read-model built from the schema the agent just wrote. Only the schema transport is swapped — - * production fetches it from the Forest server, which plays no part in what these suites assert. - */ -export function buildApp(agentUrl: string, schemaPath: string): Koa { - const token = agentToken(); - const schemaCache = new SchemaCache({ - fetcher: schemaFetcherFromFile(schemaPath), - metrics: { increment: () => {}, gauge: () => {} }, - }); - const store = new ReadModelStore(schemaCache, new CapabilitiesCache()); - const app = new Koa(); - - app.silent = true; - app.use(createErrorMiddleware({ logger: noopLogger })); - app.use(bodyParser()); - app.use(async (ctx, next) => { - ctx.state.timezone = TIMEZONE; - ctx.state.agentToken = token; - await next(); - }); - app.use( - createDataRoutesMiddleware({ - store, - transport: createHttpTransport({ agentUrl }), - logger: noopLogger, - }), - ); - - return app; -} diff --git a/packages/agent-bff/test/data/record-contract.integration.test.ts b/packages/agent-bff/test/data/record-contract.integration.test.ts deleted file mode 100644 index 69c92faaf9..0000000000 --- a/packages/agent-bff/test/data/record-contract.integration.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { TestableAgent } from '@forestadmin/agent-testing'; -import type Koa from 'koa'; - -import { createTestableAgent } from '@forestadmin/agent-testing'; -import os from 'os'; -import path from 'path'; -import request from 'supertest'; - -import { - AUTH_SECRET, - BOOT_TIMEOUT_MS, - ENV_SECRET, - RESERVED_SCHEMA_PREFIX, - buildApp, - findFreePort, -} from './fixtures/live-agent-harness'; -import RecordContractDataSource from './fixtures/record-contract-datasource'; - -describe('the record contract against a real agent', () => { - let agent: TestableAgent; - let app: Koa; - - beforeAll(async () => { - const port = await findFreePort(); - const schemaPath = path.join( - os.tmpdir(), - `${RESERVED_SCHEMA_PREFIX}-bff-record-contract-${port}.json`, - ); - - agent = await createTestableAgent( - forestAgent => { - forestAgent.addDataSource(async () => new RecordContractDataSource()); - }, - { authSecret: AUTH_SECRET, envSecret: ENV_SECRET, isProduction: false, port, schemaPath }, - ); - - await agent.start(); - - app = buildApp(`http://localhost:${port}`, schemaPath); - }, BOOT_TIMEOUT_MS); - - afterAll(async () => { - await agent?.stop(); - }); - - it('should carry the flat id as a string while __forest.primaryKey holds it typed', async () => { - const response = await request(app.callback()) - .post('/agent/v1/people/list') - .send({ projection: ['id'] }); - - expect(response.status).toBe(200); - expect(response.body.data).toEqual([ - { - id: '8', - __forest: { collection: 'people', primaryKey: { id: 8 } }, - }, - ]); - }); - - it('should return a snake_case column under its camelCase key, while projecting its schema name', async () => { - const response = await request(app.callback()) - .post('/agent/v1/people/list') - .send({ projection: ['id', 'first_name'] }); - - expect(response.status).toBe(200); - expect(response.body.data).toEqual([ - { - id: '8', - firstName: 'Ada', - __forest: { collection: 'people', primaryKey: { id: 8 } }, - }, - ]); - }); -}); diff --git a/packages/agent-bff/test/data/search-agent.integration.test.ts b/packages/agent-bff/test/data/search-agent.integration.test.ts deleted file mode 100644 index aebd268dae..0000000000 --- a/packages/agent-bff/test/data/search-agent.integration.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { TestableAgent } from '@forestadmin/agent-testing'; -import type Koa from 'koa'; - -import { createTestableAgent } from '@forestadmin/agent-testing'; -import os from 'os'; -import path from 'path'; -import request from 'supertest'; - -import { - AUTH_SECRET, - BOOT_TIMEOUT_MS, - ENV_SECRET, - RESERVED_SCHEMA_PREFIX, - buildApp, - findFreePort, -} from './fixtures/live-agent-harness'; -import SearchDataSource from './fixtures/search-datasource'; - -describe('search against a real agent', () => { - let agent: TestableAgent; - let app: Koa; - - beforeAll(async () => { - const port = await findFreePort(); - const schemaPath = path.join(os.tmpdir(), `${RESERVED_SCHEMA_PREFIX}-bff-search-${port}.json`); - - agent = await createTestableAgent( - forestAgent => { - forestAgent.addDataSource(async () => new SearchDataSource()); - forestAgent.customizeCollection('books', collection => - collection.addManyToOneRelation('author', 'authors', { foreignKey: 'authorId' }), - ); - forestAgent.customizeCollection('ledgers', collection => collection.disableSearch()); - }, - { authSecret: AUTH_SECRET, envSecret: ENV_SECRET, isProduction: false, port, schemaPath }, - ); - - await agent.start(); - - app = buildApp(`http://localhost:${port}`, schemaPath); - }, BOOT_TIMEOUT_MS); - - afterAll(async () => { - await agent?.stop(); - }); - - function titlesOf(body: { data: Array<{ title: string }> }): string[] { - return body.data.map(record => record.title).sort(); - } - - describe('list', () => { - it('should return only the records the search matches', async () => { - const response = await request(app.callback()) - .post('/agent/v1/books/list') - .send({ projection: ['id', 'title'], search: 'foundation' }); - - expect(response.status).toBe(200); - expect(titlesOf(response.body)).toEqual(['Foundation']); - }); - - it('should return every record when no search is sent', async () => { - const response = await request(app.callback()) - .post('/agent/v1/books/list') - .send({ projection: ['id', 'title'] }); - - expect(response.status).toBe(200); - expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot', 'The Dispossessed']); - }); - - it('should not match a related record without searchExtended', async () => { - const response = await request(app.callback()) - .post('/agent/v1/books/list') - .send({ projection: ['id', 'title'], search: 'asimov' }); - - expect(response.status).toBe(200); - expect(response.body.data).toEqual([]); - }); - - it('should match through a relation when searchExtended is true', async () => { - const response = await request(app.callback()) - .post('/agent/v1/books/list') - .send({ projection: ['id', 'title'], search: 'asimov', searchExtended: true }); - - expect(response.status).toBe(200); - expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot']); - }); - - // Accepted behaviour, pinned here so it cannot change unnoticed: the search value is a query, - // and `relation.column:value` crosses a relation with no `searchExtended`. The same path in a - // filter draws 422 relation_field_not_supported, so this is the one way a request reaches a - // relation column through the top-level routes. - it('should cross a relation through the query syntax without searchExtended', async () => { - const response = await request(app.callback()) - .post('/agent/v1/books/list') - .send({ projection: ['id', 'title'], search: 'author.name:asimov' }); - - expect(response.status).toBe(200); - expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot']); - }); - - it('should reject the same relation path in a filter, unlike in a search', async () => { - const response = await request(app.callback()) - .post('/agent/v1/books/list') - .send({ - projection: ['id', 'title'], - filter: { field: 'author:name', operator: 'IContains', value: 'asimov' }, - }); - - expect(response.status).toBe(422); - expect(response.body.error).toMatchObject({ type: 'relation_field_not_supported' }); - }); - - it('should intersect the search with the filter rather than replace it', async () => { - const response = await request(app.callback()) - .post('/agent/v1/books/list') - .send({ - projection: ['id', 'title'], - search: 'asimov', - searchExtended: true, - filter: { field: 'title', operator: 'IContains', value: 'robot' }, - }); - - expect(response.status).toBe(200); - expect(titlesOf(response.body)).toEqual(['I, Robot']); - }); - - it('should list everything when the search holds only whitespace', async () => { - const response = await request(app.callback()) - .post('/agent/v1/books/list') - .send({ projection: ['id', 'title'], search: ' ' }); - - expect(response.status).toBe(200); - expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot', 'The Dispossessed']); - }); - }); - - describe('count', () => { - it('should count the searched rows, not the whole collection', async () => { - const searched = await request(app.callback()) - .post('/agent/v1/books/count') - .send({ search: 'foundation' }); - const all = await request(app.callback()).post('/agent/v1/books/count').send({}); - - expect(all.body).toEqual({ count: 3, countStatus: 'available' }); - expect(searched.body).toEqual({ count: 1, countStatus: 'available' }); - }); - - it('should count the rows a relation-extended search returns', async () => { - const response = await request(app.callback()) - .post('/agent/v1/books/count') - .send({ search: 'asimov', searchExtended: true }); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ count: 2, countStatus: 'available' }); - }); - }); - - describe('a collection whose search is disabled', () => { - // Outside production the agent dumps every handled error to stderr, so the two rejections - // asserted here would drown the suite output in expected stack traces. - let consoleError: jest.SpyInstance; - - beforeAll(() => { - consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterAll(() => { - consoleError.mockRestore(); - }); - - it('should reject a list search rather than return an unfiltered listing', async () => { - const response = await request(app.callback()) - .post('/agent/v1/ledgers/list') - .send({ projection: ['id', 'label'], search: 'foundation' }); - - expect(response.status).toBe(400); - expect(response.body.error).toMatchObject({ - type: 'validation_error', - status: 400, - message: 'Collection is not searchable', - }); - }); - - it('should reject a count search the same way list does', async () => { - const response = await request(app.callback()) - .post('/agent/v1/ledgers/count') - .send({ search: 'foundation' }); - - expect(response.status).toBe(400); - expect(response.body.error).toMatchObject({ - type: 'validation_error', - status: 400, - message: 'Collection is not searchable', - }); - }); - - it('should still serve it when no search is sent', async () => { - const response = await request(app.callback()) - .post('/agent/v1/ledgers/list') - .send({ projection: ['id', 'label'] }); - - expect(response.status).toBe(200); - expect(response.body.data).toHaveLength(1); - }); - }); -}); 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 91b9b73055..c5ad7f0314 100644 --- a/packages/agent-bff/test/http/bff-http-server.test.ts +++ b/packages/agent-bff/test/http/bff-http-server.test.ts @@ -79,16 +79,49 @@ function closeServer(server: Server): Promise { describe('BFFHttpServer', () => { describe('when config is complete', () => { - it('should answer GET /health with 200 ok and the version only', async () => { + it('should answer GET /health with 200 ok, the version and the surfaces it was configured for', async () => { const server = createServer({ ...VALID_ENV }); const response = await request(server.callback).get('/health'); expect(response.status).toBe(200); - expect(response.body).toEqual({ status: 'ok', version: VERSION }); + expect(response.body).toEqual({ + status: 'ok', + version: VERSION, + configured: { oauth: true, ai: true, cors: false, openapi: true }, + }); + }); + + it('should report the surfaces a partial configuration leaves off', async () => { + const server = createServer({ + ...VALID_ENV, + BFF_TOKEN_ENCRYPTION_KEY: undefined, + BFF_ALLOWED_ORIGINS: 'https://my-app.com', + BFF_OPENAPI_ENABLED: 'false', + }); + + const response = await request(server.callback).get('/health'); + + expect(response.body.configured).toEqual({ + oauth: false, + ai: false, + cors: true, + openapi: false, + }); + }); + + it('should stay healthy without an encryption key, which gates OAuth and not boot', async () => { + const server = createServer({ ...VALID_ENV, BFF_TOKEN_ENCRYPTION_KEY: undefined }); + + const response = await request(server.callback).get('/health'); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('ok'); }); - it('should never expose config presence or secret values in the response body', async () => { + // The features block names capabilities, never values: an anonymous caller learns that OAuth is + // configured, not what any secret holds. + it('should never expose secret values or the config itself in the response body', async () => { const server = createServer({ ...VALID_ENV }); const response = await request(server.callback).get('/health'); @@ -116,7 +149,7 @@ describe('BFFHttpServer', () => { const response = await request(server.callback).get('/health'); expect(response.status).toBe(503); - expect(response.body).toEqual({ status: 'degraded', version: VERSION }); + expect(response.body).toMatchObject({ status: 'degraded', version: VERSION }); }); it('should answer HEAD /health with 503', async () => { diff --git a/packages/agent/package.json b/packages/agent/package.json index e263420f83..2fcd0fb188 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@fastify/express": "^1.1.0", + "@forestadmin/agent-bff": "^1.28.0", "@forestadmin/datasource-sql": "1.17.14", "@forestadmin/workflow-executor": "1.28.0", "@nestjs/common": "^11.1.24", @@ -56,28 +57,34 @@ "@nestjs/platform-express": "^11.1.24", "@nestjs/platform-fastify": "^11.1.24", "@shopify/jest-koa-mocks": "^3.1.0", - "sqlite3": "^5.1.7", "@types/json-api-serializer": "^2.6.3", "@types/jsonwebtoken": "^9.0.1", "@types/koa": "^2.13.5", "@types/koa__cors": "^3.3.0", "@types/superagent": "^8.1.9", + "@types/supertest": "^6.0.2", "express": "^4.18.2", "fastify": "^3.29.0", "fastify2": "npm:fastify@^2.15.3", "fastify4": "npm:fastify@^4.28.0", "openid-client": "^5.7.1", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.0" + "rxjs": "^7.8.0", + "sqlite3": "^5.1.7", + "supertest": "^7.1.3" }, "peerDependencies": { "@fastify/express": "^1.1.0 || ^2.0.0 || ^3.0.0 || ^4.0.0", + "@forestadmin/agent-bff": "^1.28.0", "@forestadmin/workflow-executor": "1.28.0" }, "peerDependenciesMeta": { "@fastify/express": { "optional": true }, + "@forestadmin/agent-bff": { + "optional": true + }, "@forestadmin/workflow-executor": { "optional": true } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index f46aa15828..86c9ddafea 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -3,6 +3,7 @@ import type { ForestAdminHttpDriverServices } from './services'; import type { AgentOptions, AgentOptionsWithDefaults, + BffEmbedOptions, RootHandler, WorkflowExecutorEmbedOptions, } from './types'; @@ -26,6 +27,8 @@ import { readFile, writeFile } from 'fs/promises'; import stringify from 'json-stringify-pretty-compact'; import { installAuditTrailHooks } from './audit-trail'; +import { BFF_PREFIX, collidesWithBff } from './bff-routes'; +import EmbeddedBff from './embedded-bff'; import EmbeddedWorkflowExecutor from './embedded-workflow-executor'; import FrameworkMounter from './framework-mounter'; import makeRoutes from './routes'; @@ -35,6 +38,14 @@ import { CORRELATION_ID_HEADER, correlationIdMiddleware } from './utils/correlat import SchemaGenerator from './utils/forest-schema/generator'; import OptionsValidator from './utils/options-validator'; +// Whichever is registered second raises it. `addBff()` registers at builder time and the MCP +// server only at start(), and the root middleware answers with the first handler whose matcher +// claims the url — so the BFF wins and the whole MCP surface would go silently dark. +const bffMcpCollision = (mcpBasePath: string) => + `Cannot use addBff together with mountAiMcpServer({ basePath: '${mcpBasePath}' }): the MCP ` + + `server would claim ${BFF_PREFIX} paths the embedded BFF answers on (${BFF_PREFIX}/oauth, ` + + `${BFF_PREFIX}/mcp). Mount the MCP server elsewhere.`; + /** * Allow to create a new Forest Admin agent from scratch. * Builds the application by composing and configuring all the collection decorators. @@ -63,8 +74,18 @@ export default class Agent extends FrameworkMounter /** In-process workflow executor, created only when addWorkflowExecutor() is called. */ private embeddedExecutor: EmbeddedWorkflowExecutor | null = null; + /** In-process BFF, created only when addBff() is called. */ + private embeddedBff: EmbeddedBff | null = null; + private isRestarting = false; + /** + * Set as soon as start() begins, not once it finishes: mount() drains the `onFirstStart` hooks + * partway through, so a builder call landing mid-startup is already too late. Cleared again when + * startup fails, which leaves nothing mounted and makes the agent configurable once more. + */ + private startupBegun = false; + /** * Create a new Agent Builder. * If any options are missing, the default will be applied: @@ -98,8 +119,14 @@ export default class Agent extends FrameworkMounter */ async start(): Promise { let mounted = false; + this.startupBegun = true; try { + // First, before anything is mounted or subscribed: everything it validates is what the caller + // handed to addBff(), so a mistyped key must fail on that line rather than leave the host + // serving /forest with a permanently bricked /bff. + await this.embeddedBff?.prepare(); + const { router, mcp } = await this.buildRouterAndSendSchema(); await this.options.forestAdminClient.subscribeToServerEvents(); @@ -112,7 +139,14 @@ export default class Agent extends FrameworkMounter // Boot after mount(): the embedded executor reaches the agent over HTTP, and the // standalone server's host/port (used to derive that URL) are only known once mounted. await this.embeddedExecutor?.start(this.standaloneServerHost, this.standaloneServerPort); + // Same reason, without the socket: the dispatcher injects into the stack mount() just built. + await this.embeddedBff?.start(this.getInProcessDispatcher()); } catch (error) { + // Only when nothing was mounted. Past mount() the host framework is already serving this + // agent, so it is not configurable again: clearing the flag would let a later addBff() past + // its guard and register a BFF whose start() nothing calls — /bff would answer 503 for the + // rest of the process, and restart() only invalidates, it never starts it. + if (!mounted) this.startupBegun = false; const { message } = error as Error; this.options.logger('Error', `Forest Admin agent startup failure: ${message}`); @@ -141,7 +175,10 @@ export default class Agent extends FrameworkMounter * Stop the agent. */ override async stop(): Promise { - // Drain the embedded executor first, while the agent it depends on is still serving. + // Stop answering before the stack it dispatches into goes away: the host application keeps + // whatever middleware it registered, so a stopped agent would otherwise still serve BFF data. + this.embeddedBff?.stop(); + // Drain the embedded executor next, while the agent it depends on is still serving. await this.embeddedExecutor?.stop(); // Close anything related to ForestAdmin client this.options.forestAdminClient.close(); @@ -183,6 +220,8 @@ export default class Agent extends FrameworkMounter this.setMcpCallback(mcp ?? null); await this.remount(router); + // A restart means the customizations changed, so the schema the BFF read is stale. + this.embeddedBff?.invalidate(); } finally { this.isRestarting = false; } @@ -314,6 +353,10 @@ export default class Agent extends FrameworkMounter allowedOAuthClients?: string[]; fileUploads?: false | FileUploadsOptions; }): this { + if (this.embeddedBff && collidesWithBff(options?.basePath)) { + throw new Error(bffMcpCollision(options?.basePath as string)); + } + this.mcpEnabled = true; this.mcpEnabledTools = options?.enabledTools; this.mcpBasePath = options?.basePath; @@ -362,6 +405,59 @@ export default class Agent extends FrameworkMounter return this; } + /** + * Serve a BFF in-process, alongside the agent, at `/bff` — no second deployment, no second port. + * The agent builds it on start(), stops it on stop(), and hands it a dispatcher that reaches its + * own stack without a socket, so this works the same on every mount target. + * + * Requires the `@forestadmin/agent-bff` package to be installed: + * ```bash + * npm install @forestadmin/agent-bff + * ``` + * + * The secrets, the Forest urls and the logger are inherited from the agent. Everything left is + * a feature the BFF switches on when configured: `tokenEncryptionKey` enables OAuth (and with it + * the AI relay), `allowedOrigins` enables browser access, `openapiEnabled` serves the docs. + * + * @param options embedded BFF options + * @returns the agent instance for chaining + * @throws Error if called more than once, or if the MCP server already claims `/bff` + * + * @example + * createAgent(options) + * .addDataSource(...) + * .addBff({ allowedOrigins: ['https://my-app.com'] }) + * .start(); + */ + addBff(options: BffEmbedOptions = {}): this { + if (this.embeddedBff) { + throw new Error('addBff can only be called once.'); + } + + // Refused rather than accepted and left dark: once startup has begun the dispatcher hook has no + // mount left to attach to and nothing calls the BFF's own start(), so `/bff` would answer 503 + // for the rest of the process while every other route works. A throw on the line the developer + // wrote instead. Refused from the first line of start(), not from its last: `start()` is async, + // so a call made while it is still in flight is just as late. + if (this.startupBegun) { + throw new Error('addBff must be called before start(): the agent is already starting.'); + } + + if (collidesWithBff(this.mcpBasePath)) { + throw new Error(bffMcpCollision(this.mcpBasePath as string)); + } + + const bff = new EmbeddedBff(this.options, options); + this.embeddedBff = bff; + // Registered now rather than at start(): getInProcessDispatcher() pushes its hook the first + // time it is called, and mount() only runs the hooks registered before it — asked for later, + // the dispatcher would have no handler until the first restart. + this.getInProcessDispatcher(); + this.setBffCallback(bff.handle); + + return this; + } + protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) { return makeRoutes(dataSource, this.options, services); } diff --git a/packages/agent/src/bff-routes.ts b/packages/agent/src/bff-routes.ts new file mode 100644 index 0000000000..383816ebca --- /dev/null +++ b/packages/agent/src/bff-routes.ts @@ -0,0 +1,49 @@ +/** + * Where an embedded BFF answers, at the root of the host application. Fixed: the BFF serves its own + * `/oauth/*` and `/docs`, which would otherwise collide with the MCP server's root paths and with + * whatever the host already serves. + */ +export const BFF_PREFIX = '/bff'; + +/** + * Matches on the pathname and on a segment boundary, so `/bff?x=1` is claimed and `/bffalo` is not. + */ +export function isBffRoute(url: string): boolean { + const [pathname] = url.split(/[?#]/, 1); + + return pathname === BFF_PREFIX || pathname.startsWith(`${BFF_PREFIX}/`); +} + +/** + * The url the BFF itself expects: it knows nothing of the prefix the host serves it under, and its + * routes are absolute (`/agent/v1/…`, `/health`). Never yields an empty string — Koa would read that + * as a malformed request rather than as the root. + */ +export function stripBffPrefix(url: string): string { + const remainder = url.slice(BFF_PREFIX.length); + + if (remainder === '') return '/'; + + return remainder.startsWith('/') ? remainder : `/${remainder}`; +} + +/** + * Whether an MCP mount path would land inside `/bff`. The MCP server normalizes its `basePath` + * (trim, leading slash, collapsed and stripped trailing slashes) before deriving + * `/oauth/` and `/mcp`, so comparing the raw option to `/bff` would let `bff`, + * `/bff/` and `/bff/ai` through — each of which claims paths the BFF answers on. The normalization + * is mirrored rather than imported: mcp-server keeps it internal. + */ +export function collidesWithBff(mcpBasePath?: string): boolean { + if (!mcpBasePath) return false; + + const trimmed = mcpBasePath.trim(); + + if (trimmed === '' || trimmed === '/') return false; + + const normalized = (trimmed.startsWith('/') ? trimmed : `/${trimmed}`) + .replace(/\/+/g, '/') + .replace(/\/+$/, ''); + + return isBffRoute(`${normalized}/mcp`); +} diff --git a/packages/agent/src/embedded-bff.ts b/packages/agent/src/embedded-bff.ts new file mode 100644 index 0000000000..a92e0883dc --- /dev/null +++ b/packages/agent/src/embedded-bff.ts @@ -0,0 +1,218 @@ +import type { AgentOptionsWithDefaults, BffEmbedOptions, HttpCallback } from './types'; +import type { AgentDispatcher, BFFConfig, Bff } from '@forestadmin/agent-bff'; + +import { BFF_PREFIX, stripBffPrefix } from './bff-routes'; + +/** + * Serialize the BFF's structured log context onto the message: the agent's logger only accepts an + * Error as its third argument, so the context would be dropped otherwise. Errors are unfolded by + * hand — their `message` and `stack` are not enumerable, so `JSON.stringify` alone turns the one + * value worth logging into `{}`. Never throws — logging must not break a request. + */ +function formatLog(message: string, context?: Record): string { + if (!context || Object.keys(context).length === 0) return `[BFF] ${message}`; + + try { + const serialized = JSON.stringify(context, (_key, value) => + value instanceof Error + ? { name: value.name, message: value.message, stack: value.stack } + : value, + ); + + return `[BFF] ${message} ${serialized}`; + } catch { + return `[BFF] ${message} [unserializable context]`; + } +} + +/** + * Owns the lifecycle of a BFF embedded in the agent process: configuration, dynamic loading of the + * optional package, build, and the request handler the agent mounts at `/bff`. The agent only wires + * the callback and delegates start/stop. + */ +export default class EmbeddedBff { + private config: BFFConfig | null = null; + private bff: Bff | null = null; + private stopped = false; + + constructor( + private readonly options: AgentOptionsWithDefaults, + private readonly embedOptions: BffEmbedOptions, + ) {} + + /** + * Load the package and validate the options the caller handed to `addBff()`. Called before the + * agent mounts anything: everything checked here is caller input, and `parseConfig` throws on a + * mistyped `tokenEncryptionKey`, timezone, timeout or url. Failing here leaves nothing serving, + * where failing after mount() would leave the host with a live `/forest` and a bricked `/bff`. + */ + async prepare(): Promise { + const { parseConfig, IN_PROCESS_AGENT_URL } = await this.importPackage(); + const { embedOptions } = this; + + this.config = parseConfig({ + FOREST_AUTH_SECRET: this.options.authSecret, + FOREST_ENV_SECRET: this.options.envSecret, + FOREST_SERVER_URL: this.options.forestServerUrl, + FOREST_APP_URL: this.options.forestAppUrl, + // Names where the agent answers, never how it is reached: the dispatcher is the transport. + AGENT_URL: IN_PROCESS_AGENT_URL, + BFF_TOKEN_ENCRYPTION_KEY: embedOptions.tokenEncryptionKey, + BFF_ALLOWED_ORIGINS: embedOptions.allowedOrigins?.join(','), + BFF_DEFAULT_TIMEZONE: embedOptions.defaultTimezone, + BFF_AGENT_TIMEOUT_MS: embedOptions.agentTimeoutMs?.toString(), + BFF_AI_TIMEOUT_MS: embedOptions.aiTimeoutMs?.toString(), + // Off unless asked for: the document is not filtered per caller, so mounting a BFF must not + // publish the name of every exposed collection and field on an already-open port. + BFF_OPENAPI_ENABLED: String(embedOptions.openapiEnabled ?? false), + }); + } + + /** + * Build the BFF. Called from agent.start() after mount(), because the dispatcher only reaches the + * agent's own stack once that stack is mounted. + */ + async start(dispatcher: AgentDispatcher): Promise { + if (!this.config) await this.prepare(); + + // Cleared before the await, not after it: a start() following a stop() has to drop the previous + // shutdown's flag, while a stop() landing DURING the await must still be seen by the guard below. + this.stopped = false; + + const { buildBff } = await this.importPackage(); + + const bff = await buildBff({ + config: this.config as BFFConfig, + dispatcher, + basePath: BFF_PREFIX, + // Counters are the schema cache's and the action-endpoint resolver's only channel — they take + // no logger — and every one of them reports a failure, so they go to the host's logs. Gauges + // do not: they are periodic cache sizes, and the default console sink reports them at Info, + // which would flood a host that only asked for a BFF. + metrics: { + // Tags carried through: `action_endpoint_error` and `action_endpoint_miss` name the + // rendering, collection and action that failed, which is the whole of what makes the line + // actionable — the metric name alone says only that something, somewhere, did not resolve. + increment: (name, tags) => this.options.logger('Warn', formatLog(`metric ${name}`, tags)), + gauge: () => undefined, + }, + logger: (level, message, context) => this.options.logger(level, formatLog(message, context)), + }); + + // stop() may have landed while buildBff() was in flight. Assigning anyway would resurrect a + // stopped agent's /bff — dispatching into the stack it just tore down instead of answering 503. + if (this.stopped) return; + + this.bff = bff; + + this.options.logger('Info', formatLog(`Embedded BFF mounted on ${BFF_PREFIX}`)); + + await this.warnIfExemptFromIpWhitelist(); + } + + /** + * The whitelist exempts a caller arriving over a loopback socket with no proxy hop, which is + * exactly what the in-process transport looks like — so every BFF request escapes it. That is the + * decision, not an oversight: propagating the caller's ip would make the embedded mode stricter + * than the standalone one, where the whitelist only ever sees the BFF's own host, and would refuse + * the browsers a third-party UI is made of. What it must not be is silent — an operator who turned + * the whitelist on to close a door has no other way to learn this door is not part of it. + * + * Reads the configuration itself rather than borrowing the one `IpWhitelist` already fetched: that + * route keeps it private, and reaching into it would put BFF concerns in an unrelated part of the + * agent. Costs one round-trip at boot. Never fails the boot: a warning is not worth refusing to + * serve over, and this is the same posture as the rest of the BFF's startup. + */ + private async warnIfExemptFromIpWhitelist(): Promise { + try { + const { isFeatureEnabled } = + await this.options.forestAdminClient.getIpWhitelistConfiguration(); + + if (!isFeatureEnabled) return; + + this.options.logger( + 'Warn', + formatLog( + `The IP whitelist is enabled for this environment, but requests served under ` + + `${BFF_PREFIX} are not subject to it: they reach the agent in-process, which the ` + + `whitelist exempts as a trusted loopback caller. A resolved API key or a valid OAuth ` + + `session is still required.`, + ), + ); + } catch (error) { + this.options.logger( + 'Debug', + formatLog('Could not read the IP whitelist configuration', { + cause: error instanceof Error ? error.message : String(error), + }), + ); + } + } + + /** Drop what the BFF read from the SaaS. Called on a restart, which means the schema moved. */ + invalidate(): void { + this.bff?.invalidate(); + } + + /** + * 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 { + this.bff = null; + this.stopped = true; + } + + /** + * The callback the agent registers at `/bff`. Answers 503 rather than falling through while the + * BFF is not serving: a 404 from the host would read as "wrong url" instead of "not available". + * Boot and shutdown are told apart, because a probe should wait on the first and drain on the + * second. + */ + readonly handle: HttpCallback = (req, res) => { + if (!this.bff) { + const type = this.stopped ? 'bff_stopped' : 'bff_not_started'; + const message = this.stopped + ? 'The embedded BFF was stopped with the agent.' + : 'The embedded BFF is not started yet.'; + + res.statusCode = 503; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: { type, status: 503, message } })); + + return; + } + + // The BFF knows nothing of the prefix it is served under. `originalUrl` is claimed before the + // rewrite so a host logger reading it still reports the url the client asked for. + const bffReq = req as typeof req & { originalUrl?: string }; + bffReq.originalUrl ??= bffReq.url; + req.url = stripBffPrefix(req.url ?? BFF_PREFIX); + this.bff.callback(req, res); + }; + + /** + * Dynamically load the optional @forestadmin/agent-bff package. Deferred so agents that embed no + * BFF never load its code at startup. + */ + private async importPackage() { + try { + return await import('@forestadmin/agent-bff'); + } catch (error) { + // The original reason is kept: the package resolves from the host's own node_modules, so this + // also fires on a broken transitive dependency, a SyntaxError from a partial install, or a + // throw during the package's own evaluation — none of which "install it" would fix. + const { message } = error as Error; + const wrapped = new Error( + `The embedded BFF requires the \`@forestadmin/agent-bff\` package, which failed to ` + + `load: ${message}. Install it with \`npm install @forestadmin/agent-bff\`.`, + ); + // Assigned rather than passed to the constructor: the repo targets ES2020, whose lib types no + // options bag on Error. A plain assignment carries the cause on every runtime, and the reason + // is in the message regardless of what reads it. + (wrapped as Error & { cause?: unknown }).cause = error; + + throw wrapped; + } + } +} diff --git a/packages/agent/src/framework-mounter.ts b/packages/agent/src/framework-mounter.ts index 4fa10994fd..4285877f3f 100644 --- a/packages/agent/src/framework-mounter.ts +++ b/packages/agent/src/framework-mounter.ts @@ -8,6 +8,7 @@ import { createServer } from 'http'; import Koa from 'koa'; import path from 'path'; +import { isBffRoute } from './bff-routes'; import FastifyAdapter from './fastify-adapter'; import InProcessDispatcher from './mcp-in-process-dispatcher'; import RootMiddleware from './root-middleware'; @@ -50,6 +51,11 @@ export default class FrameworkMounter { this.rootMiddleware.set('mcp', handler); } + /** Serve an embedded BFF at `/bff`. Pass null to stop answering there. */ + protected setBffCallback(callback: HttpCallback | null): void { + this.rootMiddleware.set('bff', callback && { callback, matches: isBffRoute }); + } + /** * Dispatcher that runs agent-client requests against the agent's own `/forest` stack in-memory. * The handler is rebuilt on every (re)mount so a captured reference never goes stale. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index ff8c25fdc8..5fdbb9d197 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -8,7 +8,7 @@ export function createAgent(options: AgentOptions): } export { Agent }; -export { AgentOptions, WorkflowExecutorEmbedOptions } from './types'; +export { AgentOptions, BffEmbedOptions, WorkflowExecutorEmbedOptions } from './types'; export * from '@forestadmin/datasource-customizer'; // export is necessary for the agent-generator package diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index c66feda2d9..d8e096f0b1 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -148,6 +148,48 @@ export type WorkflowExecutorEmbedOptions = Omit ({ + __esModule: true, + default: (...args) => mockMakeRoutes(...args), +})); + +jest.mock('@forestadmin/datasource-customizer'); + +const mockBuildBff = jest.fn(); +const mockParseConfig = jest.fn(); +const mockInvalidate = jest.fn(); +const mockBffCallback = jest.fn(); + +jest.mock('@forestadmin/agent-bff', () => ({ + __esModule: true, + IN_PROCESS_AGENT_URL: 'http://in-process.agent', + parseConfig: (env: unknown) => mockParseConfig(env), + buildBff: (options: unknown) => mockBuildBff(options), +})); + +const mockExecutorStart = jest.fn(); +const mockExecutorStop = jest.fn(); + +jest.mock('@forestadmin/workflow-executor', () => ({ + __esModule: true, + buildInMemoryExecutor: () => ({ + start: mockExecutorStart, + stop: mockExecutorStop, + state: 'idle', + }), +})); + +function responseSpy() { + const response = { + statusCode: 200, + setHeader: jest.fn(), + end: jest.fn(), + }; + + return response as unknown as ServerResponse & { end: jest.Mock; statusCode: number }; +} + +function requestFor(url: string) { + return { url } as unknown as IncomingMessage & { originalUrl?: string }; +} + +function bodyOf(response: { end: jest.Mock }) { + return JSON.parse(response.end.mock.calls[0][0] as string); +} + +function bffHandlerOf(agent: Agent) { + return (agent as any).rootMiddleware.handlers.get('bff').callback; +} + +beforeEach(() => { + jest.clearAllMocks(); + + mockMakeRoutes.mockReturnValue([{ setupRoutes: jest.fn(), bootstrap: jest.fn() }]); + mockParseConfig.mockReturnValue({ agentTimeoutMs: 10_000 }); + mockBuildBff.mockResolvedValue({ callback: mockBffCallback, invalidate: mockInvalidate }); + jest + .mocked(DataSourceCustomizer.prototype.getDataSource) + .mockResolvedValue(factories.dataSource.build()); +}); + +function buildAgent() { + return new Agent(factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true })); +} + +describe('the embedded BFF lifecycle', () => { + describe('on restart', () => { + it('should drop what the BFF read from the SaaS, since the customizations moved', async () => { + const agent = buildAgent().addBff(); + await agent.start(); + + await agent.restart(); + + expect(mockInvalidate).toHaveBeenCalledTimes(1); + }); + + it('should leave the BFF serving, rather than rebuild it', async () => { + const agent = buildAgent().addBff(); + await agent.start(); + + await agent.restart(); + + expect(mockBuildBff).toHaveBeenCalledTimes(1); + }); + }); + + describe('when addBff is called once startup has begun', () => { + it('should refuse, rather than register a BFF nothing will ever start', async () => { + const agent = buildAgent(); + await agent.start(); + + expect(() => agent.addBff()).toThrow( + 'addBff must be called before start(): the agent is already starting.', + ); + }); + + it('should refuse while start() is still in flight, which is just as late', async () => { + const agent = buildAgent(); + const starting = agent.start(); + + expect(() => agent.addBff()).toThrow( + 'addBff must be called before start(): the agent is already starting.', + ); + + await starting; + }); + + it('should still refuse after a start() that failed once mounted, which stays serving', async () => { + mockExecutorStart.mockRejectedValueOnce(new Error('database unreachable')); + const agent = buildAgent().addWorkflowExecutor({ + agentUrl: 'http://localhost:3310', + inMemory: true, + }); + await expect(agent.start()).rejects.toThrow('database unreachable'); + + expect(() => agent.addBff()).toThrow( + 'addBff must be called before start(): the agent is already starting.', + ); + }); + + it('should still accept it after a start() that failed', async () => { + const options = factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true }); + jest + .mocked(options.forestAdminClient.subscribeToServerEvents) + .mockRejectedValueOnce(new Error('SaaS unreachable')); + const agent = new Agent(options); + await expect(agent.start()).rejects.toThrow('SaaS unreachable'); + + expect(() => agent.addBff()).not.toThrow(); + }); + }); + + describe('when the caller options are invalid', () => { + it('should reject start() before anything is mounted or subscribed', async () => { + mockParseConfig.mockImplementation(() => { + throw new Error('Invalid configuration: BFF_TOKEN_ENCRYPTION_KEY must be 32 bytes.'); + }); + const options = factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true }); + const agent = new Agent(options).addBff({ tokenEncryptionKey: 'nope' }); + + await expect(agent.start()).rejects.toThrow( + 'Invalid configuration: BFF_TOKEN_ENCRYPTION_KEY must be 32 bytes.', + ); + expect(options.forestAdminClient.subscribeToServerEvents).not.toHaveBeenCalled(); + expect(mockMakeRoutes).not.toHaveBeenCalled(); + }); + }); + + describe('before start()', () => { + it('should answer 503 bff_not_started rather than fall through to the host 404', () => { + const agent = buildAgent().addBff(); + const response = responseSpy(); + + bffHandlerOf(agent)(requestFor('/bff/health'), response); + + expect(response.statusCode).toBe(503); + expect(bodyOf(response).error).toEqual({ + type: 'bff_not_started', + status: 503, + message: 'The embedded BFF is not started yet.', + }); + }); + }); + + describe('after stop()', () => { + it('should answer 503 bff_stopped, so a probe drains instead of waiting on a boot', async () => { + const agent = buildAgent().addBff(); + await agent.start(); + await agent.stop(); + const response = responseSpy(); + + bffHandlerOf(agent)(requestFor('/bff/health'), response); + + expect(response.statusCode).toBe(503); + expect(bodyOf(response).error).toEqual({ + type: 'bff_stopped', + status: 503, + message: 'The embedded BFF was stopped with the agent.', + }); + }); + }); + + describe('when stop() lands while the BFF is still being built', () => { + it('should answer 503 bff_stopped rather than serve the stack the agent tore down', async () => { + let release: (bff: unknown) => void = () => undefined; + let building: () => void = () => undefined; + const entered = new Promise(resolve => { + building = resolve; + }); + mockBuildBff.mockImplementationOnce( + () => + new Promise(resolve => { + release = resolve; + building(); + }), + ); + const agent = buildAgent().addBff(); + + const starting = agent.start(); + await entered; + await agent.stop(); + release({ callback: mockBffCallback, invalidate: mockInvalidate }); + await starting; + + const response = responseSpy(); + bffHandlerOf(agent)(requestFor('/bff/agent/v1/books/list'), response); + + expect(response.statusCode).toBe(503); + expect(bodyOf(response).error).toMatchObject({ type: 'bff_stopped' }); + expect(mockBffCallback).not.toHaveBeenCalled(); + }); + }); + + describe('when start() follows a stop()', () => { + it('should serve again, rather than keep the previous shutdown flag', async () => { + const agent = buildAgent().addBff(); + await agent.start(); + await agent.stop(); + + await agent.start(); + + const response = responseSpy(); + bffHandlerOf(agent)(requestFor('/bff/agent/v1/books/list'), response); + + expect(mockBffCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('while serving', () => { + it('should hand the BFF the url without the prefix it knows nothing about', async () => { + const agent = buildAgent().addBff(); + await agent.start(); + const request = requestFor('/bff/agent/v1/books/list'); + + bffHandlerOf(agent)(request, responseSpy()); + + expect(request.url).toBe('/agent/v1/books/list'); + }); + + it('should keep the url the client asked for on originalUrl, for the host own logs', async () => { + const agent = buildAgent().addBff(); + await agent.start(); + const request = requestFor('/bff/agent/v1/books/list'); + + bffHandlerOf(agent)(request, responseSpy()); + + expect(request.originalUrl).toBe('/bff/agent/v1/books/list'); + }); + + it('should not overwrite an originalUrl the host already set', async () => { + const agent = buildAgent().addBff(); + await agent.start(); + const request = requestFor('/bff/health'); + request.originalUrl = '/mounted/bff/health'; + + bffHandlerOf(agent)(request, responseSpy()); + + expect(request.originalUrl).toBe('/mounted/bff/health'); + }); + }); + + describe('the metrics sink handed to the BFF', () => { + it('should send counters to the host logs, since they only ever report a failure', async () => { + const logger = jest.fn(); + const agent = new Agent( + factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true, logger }), + ).addBff(); + await agent.start(); + + mockBuildBff.mock.calls[0][0].metrics.increment('schema_cache_refresh_error'); + + expect(logger).toHaveBeenCalledWith('Warn', '[BFF] metric schema_cache_refresh_error'); + }); + + it('should carry the tags naming what failed, not just the metric name', async () => { + const logger = jest.fn(); + const agent = new Agent( + factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true, logger }), + ).addBff(); + await agent.start(); + + mockBuildBff.mock.calls[0][0].metrics.increment('action_endpoint_miss', { + rendering: '1', + collection: 'books', + action: 'Mark as read', + }); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + '[BFF] metric action_endpoint_miss ' + + '{"rendering":"1","collection":"books","action":"Mark as read"}', + ); + }); + + it('should drop gauges, which the default sink reports at Info on every read', async () => { + const logger = jest.fn(); + const agent = new Agent( + factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true, logger }), + ).addBff(); + await agent.start(); + logger.mockClear(); + + mockBuildBff.mock.calls[0][0].metrics.gauge('schema_cache_age_ms', 1); + + expect(logger).not.toHaveBeenCalled(); + }); + }); + + describe('the logger handed to the BFF', () => { + it('should keep an Error context readable instead of serializing it to {}', async () => { + const logger = jest.fn(); + const agent = new Agent( + factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true, logger }), + ).addBff(); + await agent.start(); + + mockBuildBff.mock.calls[0][0].logger('Warn', 'bundle unreadable', { + error: new Error('EACCES'), + }); + + expect(logger).toHaveBeenCalledWith('Warn', expect.stringContaining('"message":"EACCES"')); + }); + }); +}); diff --git a/packages/agent/test/agent-bff-missing-dep.test.ts b/packages/agent/test/agent-bff-missing-dep.test.ts new file mode 100644 index 0000000000..4f873814a4 --- /dev/null +++ b/packages/agent/test/agent-bff-missing-dep.test.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Covers the optional-dependency failure path: when @forestadmin/agent-bff cannot be loaded, the + * dynamic import() rejects and the agent surfaces an actionable error that still carries the reason. + */ +import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; + +import * as factories from './__factories__'; +import Agent from '../src/agent'; + +const mockMakeRoutes = jest.fn(); +jest.mock('../src/routes', () => ({ + __esModule: true, + default: (...args) => mockMakeRoutes(...args), +})); +jest.mock('@forestadmin/datasource-customizer'); + +jest.mock('@forestadmin/agent-bff', () => { + throw new Error("Cannot find module '@forestadmin/agent-bff'"); +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockMakeRoutes.mockReturnValue([{ setupRoutes: jest.fn(), bootstrap: jest.fn() }]); + jest + .mocked(DataSourceCustomizer.prototype.getDataSource) + .mockResolvedValue(factories.dataSource.build()); +}); + +describe('Agent.addBff (optional dependency missing)', () => { + function buildAgent() { + return new Agent( + factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true }), + ).addBff(); + } + + it('should tell the developer to install the package', async () => { + await expect(buildAgent().start()).rejects.toThrow( + 'The embedded BFF requires the `@forestadmin/agent-bff` package', + ); + }); + + it('should keep the load failure in the message, since installing does not always fix it', async () => { + await expect(buildAgent().start()).rejects.toThrow( + "Cannot find module '@forestadmin/agent-bff'", + ); + }); + + it('should keep the original error as the cause', async () => { + const error: Error & { cause?: Error } = await buildAgent() + .start() + .then(() => new Error('start() resolved')) + .catch(caught => caught as Error); + + expect(error.cause).toBeInstanceOf(Error); + expect(error.cause?.message).toBe("Cannot find module '@forestadmin/agent-bff'"); + }); +}); diff --git a/packages/agent/test/agent-bff.test.ts b/packages/agent/test/agent-bff.test.ts new file mode 100644 index 0000000000..d116a99511 --- /dev/null +++ b/packages/agent/test/agent-bff.test.ts @@ -0,0 +1,79 @@ +import * as factories from './__factories__'; +import Agent from '../src/agent'; + +function buildAgent(): Agent { + return new Agent(factories.forestAdminHttpDriverOptions.build()); +} + +describe('Agent.addBff', () => { + it('should return the agent so it can be chained', () => { + const agent = buildAgent(); + + expect(agent.addBff()).toBe(agent); + }); + + it('should refuse a second call rather than silently keep the first', () => { + const agent = buildAgent().addBff(); + + expect(() => agent.addBff()).toThrow('addBff can only be called once.'); + }); + + describe('when the MCP server already claims /bff', () => { + it('should refuse, since the MCP server would shadow /bff/oauth and /bff/mcp', () => { + const agent = buildAgent(); + agent.mountAiMcpServer({ basePath: '/bff' }); + + expect(() => agent.addBff()).toThrow('Cannot use addBff together with mountAiMcpServer'); + }); + }); + + describe('when the MCP server claims /bff after the BFF was added', () => { + it('should refuse just the same, whichever order the two are called in', () => { + const agent = buildAgent().addBff(); + + expect(() => agent.mountAiMcpServer({ basePath: '/bff' })).toThrow( + 'Cannot use addBff together with mountAiMcpServer', + ); + }); + }); + + describe('when the MCP server claims /bff under another spelling', () => { + it.each(['/bff/', 'bff', '/bff/ai'])( + 'should refuse basePath %s, which the MCP server normalizes into /bff', + basePath => { + const agent = buildAgent(); + agent.mountAiMcpServer({ basePath }); + + expect(() => agent.addBff()).toThrow('Cannot use addBff together with mountAiMcpServer'); + }, + ); + + it.each(['/bff/', 'bff', '/bff/ai'])( + 'should refuse basePath %s in the other order too', + basePath => { + const agent = buildAgent().addBff(); + + expect(() => agent.mountAiMcpServer({ basePath })).toThrow( + 'Cannot use addBff together with mountAiMcpServer', + ); + }, + ); + + it('should name the basePath the caller actually passed', () => { + const agent = buildAgent().addBff(); + + expect(() => agent.mountAiMcpServer({ basePath: '/bff/ai' })).toThrow( + "mountAiMcpServer({ basePath: '/bff/ai' })", + ); + }); + }); + + describe('when the MCP server is mounted elsewhere', () => { + it('should accept both', () => { + const agent = buildAgent(); + agent.mountAiMcpServer({ basePath: '/ai' }); + + expect(() => agent.addBff()).not.toThrow(); + }); + }); +}); diff --git a/packages/agent/test/bff-routes.test.ts b/packages/agent/test/bff-routes.test.ts new file mode 100644 index 0000000000..48021a3c47 --- /dev/null +++ b/packages/agent/test/bff-routes.test.ts @@ -0,0 +1,50 @@ +import { collidesWithBff, isBffRoute, stripBffPrefix } from '../src/bff-routes'; + +describe('isBffRoute', () => { + it.each(['/bff', '/bff/health', '/bff/agent/v1/books/list', '/bff?x=1', '/bff/health?x=1'])( + 'should claim %s', + url => { + expect(isBffRoute(url)).toBe(true); + }, + ); + + it.each(['/bffalo', '/bff-server/health', '/forest', '/', '/mcp'])( + 'should leave %s to the host', + url => { + expect(isBffRoute(url)).toBe(false); + }, + ); +}); + +describe('stripBffPrefix', () => { + it.each([ + ['/bff', '/'], + ['/bff/', '/'], + ['/bff/health', '/health'], + ['/bff/agent/v1/books/list', '/agent/v1/books/list'], + ['/bff?x=1', '/?x=1'], + ['/bff/health?x=1', '/health?x=1'], + ])('should turn %s into %s', (url, expected) => { + expect(stripBffPrefix(url)).toBe(expected); + }); + + it('should never yield an empty url, which Koa would read as malformed', () => { + expect(stripBffPrefix('/bff')).not.toBe(''); + }); +}); + +describe('collidesWithBff', () => { + it.each(['/bff', '/bff/', '//bff//', 'bff', ' /bff ', '/bff/ai'])( + 'should reject %s, which the MCP server normalizes onto a path the BFF answers', + basePath => { + expect(collidesWithBff(basePath)).toBe(true); + }, + ); + + it.each([undefined, '', '/', '/ai', '/bffalo', '/bff-server'])( + 'should accept %s, which lands outside /bff', + basePath => { + expect(collidesWithBff(basePath)).toBe(false); + }, + ); +}); diff --git a/packages/agent/test/bff/embedded-bff-ip-whitelist.test.ts b/packages/agent/test/bff/embedded-bff-ip-whitelist.test.ts new file mode 100644 index 0000000000..8df8051e01 --- /dev/null +++ b/packages/agent/test/bff/embedded-bff-ip-whitelist.test.ts @@ -0,0 +1,97 @@ +import type { LoggerLevel } from '@forestadmin/datasource-toolkit'; + +import express from 'express'; +import { tmpdir } from 'os'; +import path from 'path'; + +import SearchDataSource from './fixtures/search-datasource'; +import Agent from '../../src/agent'; +import MockForestServer from '../__helper__/mock-forest-server'; + +const AUTH_SECRET = 'test-auth-secret-32-chars-min!!!'; +const ENV_SECRET = '0'.repeat(64); +const BOOT_TIMEOUT_MS = 30_000; + +function whitelistRules(useIpWhitelist: boolean) { + return { + data: { + type: 'ip-whitelist-rules', + id: '1', + attributes: { use_ip_whitelist: useIpWhitelist, rules: [] }, + }, + }; +} + +/** + * Boots an agent with an embedded BFF against an environment whose IP whitelist is on or off, and + * returns what the agent logged while starting. + */ +async function bootAndCollectLogs(useIpWhitelist: boolean): Promise { + const mock = new MockForestServer(); + mock + // Registered before the defaults: the first matching route wins. + .get('/liana/v1/ip-whitelist-rules', whitelistRules(useIpWhitelist)) + .setupDefaultRoutes({ envSecret: ENV_SECRET, collections: [] }) + .setupSuperagentMock() + .setupFetchMock(); + + const logs: string[] = []; + const agent = new Agent({ + authSecret: AUTH_SECRET, + envSecret: ENV_SECRET, + forestServerUrl: 'https://api.forestadmin.com', + forestAppUrl: 'https://app.forestadmin.com', + isProduction: false, + schemaPath: path.join(tmpdir(), `.fa-whitelist-${useIpWhitelist}-${Date.now()}.json`), + logger: (level: LoggerLevel, message: string) => logs.push(`${level}: ${message}`), + }) + .addDataSource(async () => new SearchDataSource()) + .addBff({}); + + agent.mountOnExpress(express()); + + try { + await agent.start(); + + return logs; + } finally { + await agent.stop(); + mock.restore(); + } +} + +describe('embedded BFF against the IP whitelist', () => { + const mentionsTheExemption = (logs: string[]) => + logs.filter(line => line.startsWith('Warn') && line.includes('IP whitelist is enabled')); + + describe('when the environment has the whitelist enabled', () => { + // The exemption is deliberate, so the only thing that can go wrong is nobody knowing about it. + it( + 'should warn at startup that BFF requests are not subject to it', + async () => { + const logs = await bootAndCollectLogs(true); + const [warning, ...others] = mentionsTheExemption(logs); + + expect(warning).toBeDefined(); + expect(warning).toContain('/bff'); + expect(warning).toContain('in-process'); + // Says what still protects the route, so the warning cannot be read as "the BFF is open". + expect(warning).toContain('API key or a valid OAuth session is still required'); + expect(others).toHaveLength(0); + }, + BOOT_TIMEOUT_MS, + ); + }); + + describe('when the environment has no whitelist', () => { + it( + 'should stay quiet, since there is no expectation to correct', + async () => { + const logs = await bootAndCollectLogs(false); + + expect(mentionsTheExemption(logs)).toHaveLength(0); + }, + BOOT_TIMEOUT_MS, + ); + }); +}); diff --git a/packages/agent/test/bff/embedded-bff.e2e.test.ts b/packages/agent/test/bff/embedded-bff.e2e.test.ts new file mode 100644 index 0000000000..c3168f6e03 --- /dev/null +++ b/packages/agent/test/bff/embedded-bff.e2e.test.ts @@ -0,0 +1,558 @@ +import type { Server } from 'http'; +import type supertest from 'supertest'; + +import { buildBff, parseConfig } from '@forestadmin/agent-bff'; +import express from 'express'; +import jsonwebtoken from 'jsonwebtoken'; +import { tmpdir } from 'os'; +import path from 'path'; +import request from 'supertest'; + +import RecordContractDataSource from './fixtures/record-contract-datasource'; +import SearchDataSource from './fixtures/search-datasource'; +import Agent from '../../src/agent'; +import MockForestServer from '../__helper__/mock-forest-server'; + +const AUTH_SECRET = 'test-auth-secret-32-chars-min!!!'; +const ENV_SECRET = '0'.repeat(64); +const BOOT_TIMEOUT_MS = 30_000; + +const COLLECTION_PERMISSIONS = { + collection: { + browseEnabled: true, + readEnabled: true, + editEnabled: true, + addEnabled: true, + deleteEnabled: true, + exportEnabled: true, + }, + actions: {}, +}; + +const COLLECTIONS = [ + { + name: 'authors', + fields: [ + { field: 'id', type: 'Number', isPrimaryKey: true }, + { field: 'name', type: 'String' }, + ], + }, + { + name: 'books', + fields: [ + { field: 'id', type: 'Number', isPrimaryKey: true }, + { field: 'title', type: 'String' }, + { field: 'authorId', type: 'Number' }, + ], + }, + { + name: 'ledgers', + fields: [ + { field: 'id', type: 'Number', isPrimaryKey: true }, + { field: 'label', type: 'String' }, + ], + }, + { + name: 'people', + fields: [ + { field: 'id', type: 'Number', isPrimaryKey: true }, + { field: 'first_name', type: 'String' }, + ], + }, +]; + +/** What the third-party UI presents. Signed like the BFF's own OAuth mode signs it. */ +function sessionToken(): string { + return jsonwebtoken.sign( + { + type: 'bff_access', + sid: 'session-1', + id: 1, + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + team: 'admin', + rendering_id: '1', + permission_level: 'admin', + tags: {}, + }, + AUTH_SECRET, + { expiresIn: '1h' }, + ); +} + +type Post = (url: string, body: unknown) => supertest.Test; + +function titlesOf(body: { data: Array<{ title: string }> }): string[] { + return body.data.map(record => record.title).sort(); +} + +/** + * The contract the data routes owe whichever transport carries them. Run twice: once over the + * in-process dispatcher an embedded BFF uses, once over the socket a standalone deployment uses. + * Both go through a real agent, so a behaviour that only one transport gets right fails here. + */ +function itServesTheDataContract(post: () => Post) { + describe('list', () => { + it('should list the records the agent serves', async () => { + const response = await post()('/agent/v1/books/list', { projection: ['id', 'title'] }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot', 'The Dispossessed']); + }); + + it('should return only what the search matches', async () => { + const response = await post()('/agent/v1/books/list', { + projection: ['id', 'title'], + search: 'foundation', + }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['Foundation']); + }); + + it('should not match a related record without searchExtended', async () => { + const response = await post()('/agent/v1/books/list', { + projection: ['id', 'title'], + search: 'asimov', + }); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + }); + + it('should reach a relation when searchExtended is set', async () => { + const response = await post()('/agent/v1/books/list', { + projection: ['id', 'title'], + search: 'asimov', + searchExtended: true, + }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot']); + }); + + // Accepted behaviour, pinned here so it cannot change unnoticed: the search value is a query, + // and `relation.column:value` crosses a relation with no `searchExtended`. The same path in a + // filter draws 422 relation_field_not_supported, so this is the one way a request reaches a + // relation column through the top-level routes. + it('should cross a relation through the query syntax without searchExtended', async () => { + const response = await post()('/agent/v1/books/list', { + projection: ['id', 'title'], + search: 'author.name:asimov', + }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot']); + }); + + it('should reject the same relation path in a filter, unlike in a search', async () => { + const response = await post()('/agent/v1/books/list', { + projection: ['id', 'title'], + filter: { field: 'author:name', operator: 'IContains', value: 'asimov' }, + }); + + expect(response.status).toBe(422); + expect(response.body.error).toMatchObject({ type: 'relation_field_not_supported' }); + }); + + it('should intersect the search with the filter rather than replace it', async () => { + const response = await post()('/agent/v1/books/list', { + projection: ['id', 'title'], + search: 'asimov', + searchExtended: true, + filter: { field: 'title', operator: 'IContains', value: 'robot' }, + }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['I, Robot']); + }); + + it('should list everything when the search holds only whitespace', async () => { + const response = await post()('/agent/v1/books/list', { + projection: ['id', 'title'], + search: ' ', + }); + + expect(response.status).toBe(200); + expect(titlesOf(response.body)).toEqual(['Foundation', 'I, Robot', 'The Dispossessed']); + }); + }); + + describe('count', () => { + it('should count the searched rows rather than the collection', async () => { + const searched = await post()('/agent/v1/books/count', { search: 'foundation' }); + const all = await post()('/agent/v1/books/count', {}); + + expect(all.body).toEqual({ count: 3, countStatus: 'available' }); + expect(searched.body).toEqual({ count: 1, countStatus: 'available' }); + }); + + it('should count the rows a relation-extended search returns', async () => { + const response = await post()('/agent/v1/books/count', { + search: 'asimov', + searchExtended: true, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ count: 2, countStatus: 'available' }); + }); + }); + + describe('a collection whose search is disabled', () => { + it('should surface the agent-side refusal as the BFF error contract', async () => { + const response = await post()('/agent/v1/ledgers/list', { + projection: ['id', 'label'], + search: 'foundation', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ + type: 'validation_error', + status: 400, + message: 'Collection is not searchable', + }); + }); + + it('should refuse a count search the same way list does', async () => { + const response = await post()('/agent/v1/ledgers/count', { search: 'foundation' }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ + type: 'validation_error', + status: 400, + message: 'Collection is not searchable', + }); + }); + + it('should still serve it when no search is sent', async () => { + const response = await post()('/agent/v1/ledgers/list', { projection: ['id', 'label'] }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + }); + }); + + describe('the record contract', () => { + it('should carry the flat id as a string while __forest.primaryKey holds it typed', async () => { + const response = await post()('/agent/v1/people/list', { projection: ['id'] }); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([ + { id: '8', __forest: { collection: 'people', primaryKey: { id: 8 } } }, + ]); + }); + + it('should return a snake_case column under its camelCase key, while projecting its schema name', async () => { + const response = await post()('/agent/v1/people/list', { + projection: ['id', 'first_name'], + }); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([ + { + id: '8', + firstName: 'Ada', + __forest: { collection: 'people', primaryKey: { id: 8 } }, + }, + ]); + }); + }); +} + +function agentOptions(schemaSuffix: string) { + return { + authSecret: AUTH_SECRET, + envSecret: ENV_SECRET, + forestServerUrl: 'https://api.forestadmin.com', + forestAppUrl: 'https://app.forestadmin.com', + isProduction: false, + schemaPath: path.join(tmpdir(), `.forestadmin-schema-bff-${schemaSuffix}-${Date.now()}.json`), + logger: () => undefined, + }; +} + +describe('embedded BFF', () => { + let mockForestServer: MockForestServer; + let agent: Agent; + let app: express.Express; + + beforeAll(async () => { + mockForestServer = new MockForestServer(); + mockForestServer + // Registered first: the first matching route wins, and the default one only grants the + // collections of the sqlite fixture. + .get('/liana/v4/permissions/environment', { + collections: { + authors: COLLECTION_PERMISSIONS, + books: COLLECTION_PERMISSIONS, + ledgers: COLLECTION_PERMISSIONS, + people: COLLECTION_PERMISSIONS, + }, + }) + .setupDefaultRoutes({ envSecret: ENV_SECRET, collections: COLLECTIONS }) + .setupSuperagentMock() + .setupFetchMock(); + + agent = new Agent(agentOptions('embedded')) + .addDataSource(async () => new SearchDataSource()) + .addDataSource(async () => new RecordContractDataSource()) + .addBff({ allowedOrigins: ['https://my-app.com'] }); + + app = express(); + agent.mountOnExpress(app); + + agent.customizeCollection('books', collection => + collection.addManyToOneRelation('author', 'authors', { foreignKey: 'authorId' }), + ); + agent.customizeCollection('ledgers', collection => collection.disableSearch()); + + await agent.start(); + }, BOOT_TIMEOUT_MS); + + afterAll(async () => { + await agent?.stop(); + mockForestServer?.restore(); + }); + + function authenticated(url: string, body: unknown) { + return request(app) + .post(url) + .set('Authorization', `Bearer ${sessionToken()}`) + .set('X-Forest-Timezone', 'Europe/Paris') + .send(body as object); + } + + describe('/bff/health', () => { + it('should report ok with the surfaces this deployment was configured for', async () => { + const response = await request(app).get('/bff/health'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'ok', + configured: { oauth: false, ai: false, cors: true, openapi: false }, + }); + }); + }); + + describe('over the in-process transport', () => { + itServesTheDataContract(() => (url, body) => authenticated(`/bff${url}`, body)); + }); + + // The standalone deployment's only route to the agent, and the configuration shipping today: a + // BFF built with an AGENT_URL, reaching the agent over a real socket instead of the dispatcher. + describe('over the http transport', () => { + let httpServer: Server; + let bffCallback: Parameters[0]; + + beforeAll(async () => { + httpServer = await new Promise(resolve => { + const server = app.listen(0, () => resolve(server)); + }); + const { port } = httpServer.address() as { port: number }; + + const { callback } = await buildBff({ + config: parseConfig({ + 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: `http://127.0.0.1:${port}`, + BFF_OPENAPI_ENABLED: 'false', + }), + logger: () => undefined, + }); + bffCallback = callback; + }, BOOT_TIMEOUT_MS); + + afterAll(async () => { + await new Promise(resolve => { + httpServer.close(resolve); + }); + }); + + itServesTheDataContract( + () => (url, body) => + request(bffCallback) + .post(url) + .set('Authorization', `Bearer ${sessionToken()}`) + .set('X-Forest-Timezone', 'Europe/Paris') + .send(body as object), + ); + }); + + describe('the allow-list against a host that answers the preflight itself', () => { + /** A permissive host CORS, the kind an Express app registers without thinking about it. */ + function permissiveHostCors(): express.RequestHandler { + return (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + + return; + } + + next(); + }; + } + + async function hostWithPermissiveCorsInFront() { + const hosted = new Agent(agentOptions('host-cors')) + .addDataSource(async () => new SearchDataSource()) + .addBff({ allowedOrigins: ['https://my-app.com'] }); + const hostApp = express(); + hostApp.use(permissiveHostCors()); + hosted.mountOnExpress(hostApp); + await hosted.start(); + + return { hosted, hostApp }; + } + + // The preflight is lost to the host — nothing downstream can take it back — so the allow-list + // has to hold on the request that follows, which is the one with the side effects. + it( + 'should refuse the request a permissive host preflight let through', + async () => { + const { hosted, hostApp } = await hostWithPermissiveCorsInFront(); + + try { + const preflight = await request(hostApp) + .options('/bff/agent/v1/books/list') + .set('Origin', 'https://forbidden.example.com') + .set('Access-Control-Request-Method', 'POST'); + + const actual = await request(hostApp) + .post('/bff/agent/v1/books/list') + .set('Origin', 'https://forbidden.example.com') + .set('Authorization', `Bearer ${sessionToken()}`) + .set('X-Forest-Timezone', 'Europe/Paris') + .send({ projection: ['id', 'title'] }); + + // The host did answer the preflight, permissively: that part is out of our hands. + expect(preflight.headers['access-control-allow-origin']).toBe('*'); + // What is in our hands: the collection is never read for that origin. + expect(actual.status).toBe(403); + expect(actual.body.error).toMatchObject({ type: 'origin_not_allowed' }); + } finally { + await hosted.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); + + it( + 'should still serve an allow-listed origin through the same host', + async () => { + const { hosted, hostApp } = await hostWithPermissiveCorsInFront(); + + try { + const response = await request(hostApp) + .post('/bff/agent/v1/books/list') + .set('Origin', 'https://my-app.com') + .set('Authorization', `Bearer ${sessionToken()}`) + .set('X-Forest-Timezone', 'Europe/Paris') + .send({ projection: ['id', 'title'] }); + + expect(response.status).toBe(200); + } finally { + await hosted.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); + }); + + describe('when the host serves the agent under a sub-path of its own', () => { + async function hostWithSubPath() { + const hosted = new Agent(agentOptions('sub-path')) + .addDataSource(async () => new SearchDataSource()) + .addBff({ allowedOrigins: ['https://my-app.com'], openapiEnabled: true }); + const mounted = express(); + hosted.mountOnExpress(mounted); + const hostApp = express(); + hostApp.use('/api', mounted); + await hosted.start(); + + return { hosted, hostApp }; + } + + it( + 'should answer under the host prefix and nowhere else', + async () => { + const { hosted, hostApp } = await hostWithSubPath(); + + try { + expect((await request(hostApp).get('/api/bff/health')).status).toBe(200); + expect((await request(hostApp).get('/bff/health')).status).toBe(404); + } finally { + await hosted.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); + + // Routing works either way — Express strips its own prefix. What breaks without deriving the + // prefix per request is everything the BFF *emits*: a generated client and the docs viewer both + // aim at `/bff/...`, which the host does not serve. + it( + 'should carry the host prefix into the document and the docs page', + async () => { + const { hosted, hostApp } = await hostWithSubPath(); + + try { + const document = await request(hostApp) + .get('/api/bff/agent/openapi.json') + .set('Authorization', `Bearer ${sessionToken()}`); + const page = await request(hostApp).get('/api/bff/docs'); + + expect(document.status).toBe(200); + expect(document.body.servers[0].url).toBe('/api/bff'); + expect(page.status).toBe(200); + expect(page.text).toContain('/api/bff/agent/openapi.json'); + expect(page.text).toContain('/api/bff/docs/redoc.standalone.js'); + } finally { + await hosted.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); + }); + + describe('the agent routes next to it', () => { + it('should keep answering on their own prefix', async () => { + const response = await request(app).get('/forest'); + + expect(response.status).toBe(200); + }); + + it('should not claim a url that merely starts with the same letters', async () => { + const response = await request(app).get('/bffalo'); + + expect(response.status).toBe(404); + }); + }); + + describe('once the agent is stopped', () => { + it( + 'should stop answering rather than dispatch into a dead stack', + async () => { + const stoppedAgent = new Agent(agentOptions('stop')) + .addDataSource(async () => new SearchDataSource()) + .addBff({}); + + const stoppedApp = express(); + stoppedAgent.mountOnExpress(stoppedApp); + await stoppedAgent.start(); + await stoppedAgent.stop(); + + const response = await request(stoppedApp).get('/bff/health'); + + expect(response.status).toBe(503); + expect(response.body.error).toMatchObject({ type: 'bff_stopped' }); + }, + BOOT_TIMEOUT_MS, + ); + }); +}); diff --git a/packages/agent-bff/test/data/fixtures/in-memory-collection.ts b/packages/agent/test/bff/fixtures/in-memory-collection.ts similarity index 100% rename from packages/agent-bff/test/data/fixtures/in-memory-collection.ts rename to packages/agent/test/bff/fixtures/in-memory-collection.ts diff --git a/packages/agent-bff/test/data/fixtures/record-contract-datasource.ts b/packages/agent/test/bff/fixtures/record-contract-datasource.ts similarity index 100% rename from packages/agent-bff/test/data/fixtures/record-contract-datasource.ts rename to packages/agent/test/bff/fixtures/record-contract-datasource.ts diff --git a/packages/agent-bff/test/data/fixtures/search-datasource.ts b/packages/agent/test/bff/fixtures/search-datasource.ts similarity index 100% rename from packages/agent-bff/test/data/fixtures/search-datasource.ts rename to packages/agent/test/bff/fixtures/search-datasource.ts