Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
461 changes: 461 additions & 0 deletions packages/agent-bff/src/build-bff.ts

Large diffs are not rendered by default.

428 changes: 3 additions & 425 deletions packages/agent-bff/src/cli-core.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/agent-bff/src/cli-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import path from 'path';

import createConsoleLogger from './adapters/console-logger';
import { AI_QUERY_ROUTE } from './ai/ai-routes-middleware';
import runCli, { resolveOAuthConfig, resolveUnfoldSource } from './cli-core';
import { resolveOAuthConfig, resolveUnfoldSource } from './build-bff';
import runCli from './cli-core';
import { parseConfig, parsePublicUrl } from './config/env-config';
import { extractErrorMessage } from './errors';
import { generateOpenApiDocument, serializeOpenApi } from './openapi/openapi-document';
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-bff/src/config/missing-config-warning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { BFFConfig } from './env-config';
import type { Logger } from '../ports/logger-port';

/**
* The only place that names *which* required keys are absent. Both deployment modes call it at
* assembly time, so a misconfiguration reads the same whether the BFF listens on its own port or is
* mounted by a host that never starts a listener.
*/
export default function warnMissingConfig(config: BFFConfig, logger: Logger): void {
const missing = Object.entries(config.presence)
.filter(([, present]) => !present)
.map(([key]) => key);

if (missing.length === 0) return;

logger('Warn', 'Missing required configuration; /health will report degraded', { missing });
}
79 changes: 48 additions & 31 deletions packages/agent-bff/src/http/bff-http-server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { BffCallback } from '../build-bff';
import type { BFFConfig } from '../config/env-config';
import type { Logger } from '../ports/logger-port';
import type { Server } from 'http';
Expand All @@ -6,54 +7,80 @@ import type { Middleware } from 'koa';
import http from 'http';
import Koa from 'koa';

import createHealthRoute from './health-route';
import createVersionHeaderMiddleware from './version-header-middleware';
import createConsoleLogger from '../adapters/console-logger';
import warnMissingConfig from '../config/missing-config-warning';

export interface BFFHttpServerOptions {
interface BFFHttpServerBaseOptions {
port: number;
version: string;
config: BFFConfig;
logger?: Logger;
}

/** The server assembles its own Koa app around `/health` and the version header. */
interface AssembledOptions extends BFFHttpServerBaseOptions {
version: string;
middlewares?: Middleware[];
callback?: never;
}

/**
* The server only listens: `buildBff` already assembled the handler, `/health` and the version
* header included. `version` and `middlewares` are forbidden here rather than ignored — a host
* passing them would otherwise boot fine and 404 every one of its own routes.
*/
interface PrebuiltOptions extends BFFHttpServerBaseOptions {
callback: BffCallback;
version?: never;
middlewares?: never;
}

export type BFFHttpServerOptions = AssembledOptions | PrebuiltOptions;

function isPrebuilt(options: BFFHttpServerOptions): options is PrebuiltOptions {
return options.callback !== undefined;
}

export default class BFFHttpServer {
private readonly app: Koa;
private readonly handler: BffCallback;
private readonly options: BFFHttpServerOptions;
private readonly logger: Logger;
private server: Server | null = null;

constructor(options: BFFHttpServerOptions) {
this.options = options;
this.logger = options.logger ?? createConsoleLogger();
this.app = new Koa();

this.app.use(async (ctx, next) => {
ctx.set('X-Forest-Bff-Version', this.options.version);
await next();
});
if (isPrebuilt(options)) {
this.handler = options.callback;

this.app.use(async (ctx, next) => {
if ((ctx.method === 'GET' || ctx.method === 'HEAD') && ctx.path === '/health') {
const { config, version } = this.options;
ctx.status = config.hasAllRequired ? 200 : 503;
ctx.body = { status: config.hasAllRequired ? 'ok' : 'degraded', version };
return;
}

return;
}
this.handler = BFFHttpServer.buildHandler(options);
warnMissingConfig(options.config, this.logger);
}

await next();
});
private static buildHandler(options: AssembledOptions): BffCallback {
const { config, version } = options;
const app = new Koa();

for (const middleware of this.options.middlewares ?? []) {
this.app.use(middleware);
app.use(createVersionHeaderMiddleware(version));
app.use(createHealthRoute({ config, version }));

for (const middleware of options.middlewares ?? []) {
app.use(middleware);
}

return app.callback();
}

async start(): Promise<void> {
if (this.server) throw new Error('Server already started');

return new Promise((resolve, reject) => {
const server = http.createServer(this.app.callback());
const server = http.createServer(this.handler);
this.server = server;
let onError: (error: Error) => void;

Expand All @@ -63,16 +90,6 @@ export default class BFFHttpServer {
const port = typeof address === 'object' && address ? address.port : this.options.port;
this.logger('Info', 'Forest BFF started', { port });

const missing = Object.entries(this.options.config.presence)
.filter(([, present]) => !present)
.map(([key]) => key);

if (missing.length > 0) {
this.logger('Warn', 'Missing required configuration; /health will report degraded', {
missing,
});
}

resolve();
};

Expand Down Expand Up @@ -107,6 +124,6 @@ export default class BFFHttpServer {
}

get callback() {
return this.app.callback();
return this.handler;
}
}
25 changes: 25 additions & 0 deletions packages/agent-bff/src/http/health-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { BFFConfig } from '../config/env-config';
import type { Middleware } from 'koa';

export const HEALTH_PATH = '/health';

export interface HealthRouteOptions {
config: BFFConfig;
version: string;
}

export default function createHealthRoute({ config, version }: HealthRouteOptions): Middleware {
return async function health(ctx, next) {
const isHealthRequest =
(ctx.method === 'GET' || ctx.method === 'HEAD') && ctx.path === HEALTH_PATH;

if (!isHealthRequest) {
await next();

return;
}

ctx.status = config.hasAllRequired ? 200 : 503;
ctx.body = { status: config.hasAllRequired ? 'ok' : 'degraded', version };
};
}
11 changes: 11 additions & 0 deletions packages/agent-bff/src/http/version-header-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Middleware } from 'koa';

export const BFF_VERSION_HEADER = 'X-Forest-Bff-Version';

export default function createVersionHeaderMiddleware(version: string): Middleware {
return async function versionHeader(ctx, next) {
ctx.set(BFF_VERSION_HEADER, version);

await next();
};
}
2 changes: 2 additions & 0 deletions packages/agent-bff/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export { default as BFFHttpServer } from './http/bff-http-server';
export { parseConfig, REQUIRED_KEYS } from './config/env-config';
export type { BFFConfig, PresenceMap, RequiredKey } from './config/env-config';
export { default as runCli } from './cli-core';
export { default as buildBff } from './build-bff';
export type { Bff, BuildBffOptions, BffCallback } from './build-bff';
export { ConfigurationError } from './errors';
export { default as DEFAULT_BFF_PORT } from './defaults';
export { default as createConsoleLogger } from './adapters/console-logger';
Expand Down
108 changes: 108 additions & 0 deletions packages/agent-bff/test/build-bff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { Logger } from '../src/ports/logger-port';

import request from 'supertest';

import buildBff from '../src/build-bff';
import { parseConfig } from '../src/config/env-config';
import version from '../src/version';
import { restoreFetchAfterEach, stubEnvironmentIdFetch } from './helpers/fetch-stub';

const VALID_ENV = {
FOREST_AUTH_SECRET: 'auth-secret',
FOREST_ENV_SECRET: 'env-secret',
FOREST_SERVER_URL: 'https://api.forestadmin.com',
FOREST_APP_URL: 'https://app.forestadmin.com',
AGENT_URL: 'https://agent.example.com',
BFF_TOKEN_ENCRYPTION_KEY: Buffer.alloc(32).toString('base64'),
} satisfies NodeJS.ProcessEnv;

const noopLogger: Logger = () => undefined;

async function buildCallback(env: NodeJS.ProcessEnv) {
const { callback } = await buildBff({ config: parseConfig(env), logger: noopLogger });

return callback;
}

describe('buildBff', () => {
restoreFetchAfterEach();

beforeEach(() => {
stubEnvironmentIdFetch();
});

describe('when every required key is present', () => {
it('should answer /health with ok and the version', async () => {
const callback = await buildCallback(VALID_ENV);

const response = await request(callback).get('/health');

expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'ok', version });
});

it('should set the version header on /health, which only holds if it is mounted first', async () => {
const callback = await buildCallback(VALID_ENV);

const response = await request(callback).get('/health');

expect(response.headers['x-forest-bff-version']).toBe(version);
});
});

describe('when a required key is missing', () => {
it('should answer /health with degraded', async () => {
const callback = await buildCallback({ ...VALID_ENV, FOREST_SERVER_URL: undefined });

const response = await request(callback).get('/health');

expect(response.status).toBe(503);
expect(response.body).toEqual({ status: 'degraded', version });
});

it('should warn naming the missing keys', async () => {
const logger = jest.fn();

await buildBff({ config: parseConfig({ ...VALID_ENV, AGENT_URL: undefined }), logger });

expect(logger).toHaveBeenCalledWith(
'Warn',
'Missing required configuration; /health will report degraded',
{ missing: ['AGENT_URL'] },
);
});
});

it('should set the version header on every response', async () => {
const callback = await buildCallback(VALID_ENV);

const response = await request(callback).get('/unknown-path');

expect(response.headers['x-forest-bff-version']).toBe(version);
});

it('should answer 401 on an unauthenticated agent route', async () => {
const callback = await buildCallback(VALID_ENV);

const response = await request(callback).post('/agent/v1/companies/list').send({});

expect(response.status).toBe(401);
});

describe('when BFF_ALLOWED_ORIGINS carries a malformed entry', () => {
it('should warn once with the rejected entries', async () => {
const logger = jest.fn();

await buildBff({
config: parseConfig({ ...VALID_ENV, BFF_ALLOWED_ORIGINS: 'https://ok.example.com,*' }),
logger,
});

expect(logger).toHaveBeenCalledWith(
'Warn',
'Ignoring malformed BFF_ALLOWED_ORIGINS entries',
{ entries: ['*'] },
);
});
});
});
47 changes: 37 additions & 10 deletions packages/agent-bff/test/http/bff-http-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { BffCallback } from '../../src/build-bff';
import type { Logger } from '../../src/ports/logger-port';
import type { Server } from 'http';

import http from 'http';
Expand All @@ -19,10 +21,19 @@ const VALID_ENV = {

const noopLogger = () => undefined;

function createServer(env: NodeJS.ProcessEnv, port = 0) {
const teapot: BffCallback = (req, res) => {
res.statusCode = 418;
res.end();
};

function createServer(env: NodeJS.ProcessEnv, port = 0, logger: Logger = noopLogger) {
const config = parseConfig(env);

return new BFFHttpServer({ port, version: VERSION, config, logger: noopLogger });
return new BFFHttpServer({ port, version: VERSION, config, logger });
}

function createPrebuiltServer(env: NodeJS.ProcessEnv, logger: Logger = noopLogger) {
return new BFFHttpServer({ port: 0, config: parseConfig(env), logger, callback: teapot });
}

function listenOnEphemeralPort(server: Server): Promise<number> {
Expand Down Expand Up @@ -116,16 +127,10 @@ describe('BFFHttpServer', () => {
expect(response.status).toBe(503);
});

it('should warn at startup listing the missing keys', async () => {
it('should warn when assembling its own handler, listing the missing keys', async () => {
const logger = jest.fn();
const config = parseConfig({ ...VALID_ENV, AGENT_URL: undefined });
const server = new BFFHttpServer({ port: 0, version: VERSION, config, logger });

try {
await server.start();
} finally {
await server.stop();
}
createServer({ ...VALID_ENV, AGENT_URL: undefined }, 0, logger);

expect(logger).toHaveBeenCalledWith(
'Warn',
Expand Down Expand Up @@ -158,6 +163,28 @@ describe('BFFHttpServer', () => {
});
});

describe('when constructed with a prebuilt callback', () => {
it('should serve it as-is, health route included', async () => {
const server = createPrebuiltServer({ ...VALID_ENV });

const response = await request(server.callback).get('/health');

expect(response.status).toBe(418);
});

it('should leave the missing-key warning to whoever built the handler', async () => {
const logger = jest.fn();

createPrebuiltServer({ ...VALID_ENV, AGENT_URL: undefined }, logger);

expect(logger).not.toHaveBeenCalledWith(
'Warn',
'Missing required configuration; /health will report degraded',
expect.anything(),
);
});
});

describe('when any route is requested', () => {
it('should set X-Forest-Bff-Version on a non-health (404) route', async () => {
const server = createServer({ ...VALID_ENV });
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bff/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('package index', () => {
expect(bff.parseConfig).toBeDefined();
expect(bff.REQUIRED_KEYS).toBeDefined();
expect(bff.runCli).toBeDefined();
expect(bff.buildBff).toBeDefined();
expect(bff.ConfigurationError).toBeDefined();
expect(bff.DEFAULT_BFF_PORT).toBeDefined();
expect(bff.createConsoleLogger).toBeDefined();
Expand Down
Loading
Loading