Skip to content
Open
1 change: 1 addition & 0 deletions packages/agent-bff/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ BFF_TOKEN_ENCRYPTION_KEY=
HTTP_PORT=3450
BFF_ALLOWED_ORIGINS=http://localhost:4200
BFF_DEFAULT_TIMEZONE=Europe/Paris
# BFF_PUBLIC_URL=https://bff.example.com
# BFF_OPENAPI_ENABLED=true
# BFF_AI_TIMEOUT_MS=120000
5 changes: 5 additions & 0 deletions packages/agent-bff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ forest-bff openapi --output # writes ./openapi.json
forest-bff openapi --output docs/api.json # writes that path
```

Set `BFF_PUBLIC_URL` for this command in particular. A document fetched over HTTP resolves its
relative `servers[0].url` against the URL it came from; an exported file has no such origin, so
without it a client generated from the export has no base URL at all.

The document comes in two forms, and the command picks one from the environment:

- **Unfolded** when `FOREST_SERVER_URL`, `FOREST_ENV_SECRET`, `FOREST_AUTH_SECRET` and `AGENT_URL`
Expand Down Expand Up @@ -94,6 +98,7 @@ yarn start:dev # node --env-file=.env dist/cli.js
| `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`. |
| `BFF_AI_TIMEOUT_MS` | no | How long `POST /agent/v1/ai/query` waits for the Forest server on the relay itself, in milliseconds. Defaults to `120000` — an AI generation is slow, and neither the app nor the Forest server bounds it. Past it the route answers `504`. It is not the route's end-to-end cap: an expired session is refreshed first, under a separate hard-coded 60 s ceiling that answers `502`, so a request can exceed this value. A malformed value (non-integer, `0`, or above 2147483647) fails the boot, unlike an absent one which takes the default. |
| `BFF_PUBLIC_URL` | no | The BFF's own external base URL, published as `servers[0].url` in the OpenAPI document so a generated client resolves endpoints without being configured by hand. Absent, `servers[0].url` stays `/`, which a consumer that fetched the document over HTTP resolves against that URL — but which leaves a client generated from an offline `forest-bff openapi` export with no base URL at all. Trailing slashes are stripped. A malformed value fails the boot, and so does one carrying credentials, a query string or a fragment: credentials would be published to every reader of the document, and anything behind a `?` or `#` swallows the path a generated client appends. |
| `BFF_OPENAPI_ENABLED` | no | Serve `GET/HEAD /agent/openapi.json` (auth-gated) when `true`. Defaults to `true`. Set to `false` for customers who do not want the HTTP surface exposed: an authenticated `GET`/`HEAD` then gets `404 openapi_disabled`, other methods fall through to the agent routes exactly as they do when enabled, and `forest-bff openapi` keeps working either way. Accepted values: `true`/`false`. **The served document is unfolded and is not filtered per caller**: any authenticated caller, whatever their role, reads the name of every exposed collection, relation and field. Set this to `false` if that surface must not be reachable over HTTP. |

### Config validation
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bff/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ function buildAgentMiddlewares(
enabled: config.openapiEnabled,
source,
hasAiQueryRoute: aiMiddlewares.length > 0,
publicUrl: config.publicUrl,
}),
...(bundle ? [createContextRoutesMiddleware({ store: bundle.store, environmentId })] : []),
...aiMiddlewares,
Expand Down
9 changes: 6 additions & 3 deletions packages/agent-bff/src/cli-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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 { parseConfig } from './config/env-config';
import { parseConfig, parsePublicUrl } from './config/env-config';
import { extractErrorMessage } from './errors';
import { generateOpenApiDocument, serializeOpenApi } from './openapi/openapi-document';
import buildUnfoldedDocument, { issueOpenApiAgentToken } from './openapi/unfolded-document';
Expand Down Expand Up @@ -90,12 +90,15 @@ export async function renderOpenApi(env: NodeJS.ProcessEnv, logger: Logger): Pro
? { source: resolveUnfoldSource(parseConfig(env), logger), authSecret }
: undefined;

const publicUrl = parsePublicUrl(env.BFF_PUBLIC_URL);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
Tonours marked this conversation as resolved.
const hasAiQueryRoute = publishesAiQuery(env, logger);

if (!unfoldable?.source) {
logger('Warn', `Emitting the generic OpenAPI document: ${NOTHING_TO_UNFOLD}`);

return `${serializeOpenApi(generateOpenApiDocument(version, { hasAiQueryRoute }))}\n`;
return `${serializeOpenApi(
generateOpenApiDocument(version, { hasAiQueryRoute, publicUrl }),
)}\n`;
}

const { source } = unfoldable;
Expand All @@ -104,7 +107,7 @@ export async function renderOpenApi(env: NodeJS.ProcessEnv, logger: Logger): Pro
source,
readModel,
() => issueOpenApiAgentToken(unfoldable.authSecret),
{ version, hasAiQueryRoute },
{ version, hasAiQueryRoute, publicUrl },
);

// Not one collection came back with its field set, so the agent was unreachable throughout. The
Expand Down
30 changes: 30 additions & 0 deletions packages/agent-bff/src/config/env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface BFFConfig {
forestServerUrl?: string;
forestAppUrl?: string;
agentUrl?: string;
publicUrl?: string;
tokenEncryptionKey?: string;
allowedOrigins: string[];
invalidAllowedOrigins: string[];
Expand Down Expand Up @@ -69,6 +70,34 @@ function isHttpUrl(value: string): boolean {
return !/\s/.test(value) && HTTP_URL_SCHEMA.safeParse(value).success;
}

export function parsePublicUrl(raw?: string): string | undefined {
const value = normalize(raw);

if (value === undefined) return undefined;

if (!isHttpUrl(value)) {
throw new ConfigurationError(
'Invalid configuration: BFF_PUBLIC_URL must be a valid http(s) URL.',
);
}

if (value.includes('?') || value.includes('#')) {
throw new ConfigurationError(
'Invalid configuration: BFF_PUBLIC_URL must not carry a query string or fragment.',
);
}

const url = new URL(value);

if (url.username !== '' || url.password !== '') {
throw new ConfigurationError(
'Invalid configuration: BFF_PUBLIC_URL must not carry credentials.',
);
}

return url.href.replace(/\/+$/, '');
}

function isValidEncryptionKey(value: string): boolean {
return BASE64_PATTERN.test(value) && Buffer.from(value, 'base64').length === ENCRYPTION_KEY_BYTES;
}
Expand Down Expand Up @@ -151,6 +180,7 @@ export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig {
forestServerUrl: normalized.FOREST_SERVER_URL,
forestAppUrl: normalized.FOREST_APP_URL,
agentUrl: normalized.AGENT_URL,
publicUrl: parsePublicUrl(env.BFF_PUBLIC_URL),
tokenEncryptionKey,
allowedOrigins,
invalidAllowedOrigins,
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-bff/src/docs/docs-samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ const SESSION_VARIABLE = 'BFF_SESSION';
* a 16-collection schema and several hundred KB on a large one, which every consumer of
* `/agent/openapi.json` would pay for an extension only a viewer reads — where this costs one
* function whatever the operation count. It also lets a sample carry the REAL origin: the document
* declares `servers: [{ url: '/' }]`, so a sample built into it could only hold a placeholder host,
* while the page knows where it is served from and emits a command that runs as pasted.
* declares `servers` root-relative unless `BFF_PUBLIC_URL` is set, so a sample built into it could
* only hold a placeholder host, while the page knows where it is served from and emits a command
* that runs as pasted.
*
* The key is never inlined: each language reads it from the environment, so a copied sample cannot
* carry a credential into a shell history or a paste.
Expand Down
21 changes: 19 additions & 2 deletions packages/agent-bff/src/openapi/openapi-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,14 +371,27 @@ function registerAiQueryPath(
});
}

const CONFIGURED_SERVER_DESCRIPTION = 'The public base URL of this BFF deployment.';

// A self-hosted BFF cannot know its own external URL, and this document is also exported offline by
// the CLI, so the absolute form has to be configured. Not a server variable in the meantime: a
// generator that meets a templated url drops the base URL entirely instead of substituting the
// default, which leaves a client worse off than the root-relative form.
const RELATIVE_SERVER_DESCRIPTION =
'Resolved against the URL this document was fetched from. Set BFF_PUBLIC_URL on the deployment ' +
'to publish its absolute base URL here instead, which a client generated from an offline export ' +
'needs.';

export interface GenerateOpenApiDocumentOptions {
unfolding?: Unfolding;
hasAiQueryRoute?: boolean;
/** The deployment's own external base URL, from `BFF_PUBLIC_URL`. Absent falls back to `/`. */
publicUrl?: string;
}

export function generateOpenApiDocument(
version: string,
{ unfolding, hasAiQueryRoute = false }: GenerateOpenApiDocumentOptions = {},
{ unfolding, hasAiQueryRoute = false, publicUrl }: GenerateOpenApiDocumentOptions = {},
): OpenAPIObject {
const registry = new OpenAPIRegistry();
const hasActions =
Expand Down Expand Up @@ -485,7 +498,11 @@ export function generateOpenApiDocument(
unfolding ? UNFOLDED_DESCRIPTION : GENERIC_DESCRIPTION
} ${SHARED_DESCRIPTION}`,
},
servers: [{ url: '/' }],
servers: [
publicUrl
? { url: publicUrl, description: CONFIGURED_SERVER_DESCRIPTION }
: { url: '/', description: RELATIVE_SERVER_DESCRIPTION },
],
});
}

Expand Down
5 changes: 4 additions & 1 deletion packages/agent-bff/src/openapi/openapi-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface OpenApiRoutesOptions {
version: string;
enabled: boolean;
hasAiQueryRoute: boolean;
publicUrl?: string;
/** Absent (no agent or no read-model configuration) serves the generic document. */
source?: UnfoldSource;
}
Expand All @@ -27,10 +28,11 @@ export default function createOpenApiRoutes({
enabled,
source,
hasAiQueryRoute,
publicUrl,
}: OpenApiRoutesOptions): Middleware {
const generic =
enabled && !source
? serializeOpenApi(generateOpenApiDocument(version, { hasAiQueryRoute }))
? serializeOpenApi(generateOpenApiDocument(version, { hasAiQueryRoute, publicUrl }))
: undefined;

// Memoized on the read-model identity: the store builds a new one per schema generation, so the
Expand All @@ -57,6 +59,7 @@ export default function createOpenApiRoutes({
const { document, unfolding } = await buildUnfoldedDocument(source, readModel, token, {
version,
hasAiQueryRoute,
publicUrl,
Comment thread
Tonours marked this conversation as resolved.
});

// A schema refresh landing during the capabilities fan-out mixes the new generation's field sets
Expand Down
7 changes: 5 additions & 2 deletions packages/agent-bff/src/openapi/unfolded-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ export interface UnfoldedDocument {
export interface UnfoldOptions {
version: string;
hasAiQueryRoute: boolean;
publicUrl?: string;
}

export default async function buildUnfoldedDocument(
source: UnfoldSource,
readModel: ReadModel,
token: string | (() => string),
{ version, hasAiQueryRoute }: UnfoldOptions,
{ version, hasAiQueryRoute, publicUrl }: UnfoldOptions,
): Promise<UnfoldedDocument> {
const unfolding = await collectUnfolding({
readModel,
Expand All @@ -45,7 +46,9 @@ export default async function buildUnfoldedDocument(
});

return {
document: serializeOpenApi(generateOpenApiDocument(version, { unfolding, hasAiQueryRoute })),
document: serializeOpenApi(
generateOpenApiDocument(version, { unfolding, hasAiQueryRoute, publicUrl }),
),
unfolding,
};
}
Expand Down
86 changes: 86 additions & 0 deletions packages/agent-bff/test/config/env-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,90 @@ describe('parseConfig', () => {
);
});
});

describe('BFF_PUBLIC_URL', () => {
it('should stay undefined when unset, empty, or whitespace-only', () => {
expect(parseConfig({ ...VALID_ENV }).publicUrl).toBeUndefined();
expect(parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: '' }).publicUrl).toBeUndefined();
expect(parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: ' ' }).publicUrl).toBeUndefined();
});

it('should keep a valid http(s) URL as-is', () => {
expect(
parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: 'https://bff.example.com' }).publicUrl,
).toBe('https://bff.example.com');
expect(
parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: 'http://localhost:3450/bff' }).publicUrl,
).toBe('http://localhost:3450/bff');
});

it('should strip trailing slashes, which a client would otherwise concatenate into //', () => {
expect(
parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: 'https://bff.example.com/' }).publicUrl,
).toBe('https://bff.example.com');
expect(
parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: 'https://bff.example.com/bff//' }).publicUrl,
).toBe('https://bff.example.com/bff');
});

it('should normalise scheme and host case through the URL parser', () => {
expect(
parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: 'HTTPS://BFF.Example.COM' }).publicUrl,
).toBe('https://bff.example.com');
});

it.each(['/', 'bff.example.com', 'ftp://bff.example.com', 'https://bff example.com'])(
'should reject %j',
value => {
expect(() => parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: value })).toThrow(
ConfigurationError,
);
expect(() => parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: value })).toThrow(
/BFF_PUBLIC_URL must be a valid http\(s\) URL/,
);
},
);

it.each([
'https://bff.example.com?tenant=1',
'https://bff.example.com/#dashboard',
'https://bff.example.com?',
'https://bff.example.com#',
])(
'should reject the query or fragment in %j, which the client path concatenation would swallow',
value => {
expect(() => parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: value })).toThrow(
ConfigurationError,
);
expect(() => parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: value })).toThrow(
/BFF_PUBLIC_URL must not carry a query string or fragment/,
);
},
);

it.each([
'https://user:password@bff.example.com',
'https://user@bff.example.com',
'HTTPS://user:password@bff.example.com',
'https:/user:password@bff.example.com',
'https:user:password@bff.example.com',
String.raw`https:\\user:password@bff.example.com`,
])(
'should reject the credentials in %j, which every reader of the document would receive',
value => {
expect(() => parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: value })).toThrow(
ConfigurationError,
);
expect(() => parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: value })).toThrow(
/BFF_PUBLIC_URL must not carry credentials/,
);
},
);

it('should not echo the offending value', () => {
expect(() => parseConfig({ ...VALID_ENV, BFF_PUBLIC_URL: 'not-a-url-secret' })).not.toThrow(
/not-a-url-secret/,
);
});
});
});
32 changes: 32 additions & 0 deletions packages/agent-bff/test/openapi/openapi-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,38 @@ describe('renderOpenApi', () => {
/HTTP_PORT/,
);
});

it('should fail on a bad BFF_PUBLIC_URL before warning about an unrelated omitted route', async () => {
const logger = jest.fn();

await expect(
renderOpenApi({ BFF_PUBLIC_URL: 'https://bff.example.com?tenant=1' }, logger),
).rejects.toThrow(/BFF_PUBLIC_URL/);

expect(logger).not.toHaveBeenCalled();
});

it('should publish BFF_PUBLIC_URL in the generic export, which has no retrieval url to resolve against', async () => {
const document = JSON.parse(
await renderOpenApi({ BFF_PUBLIC_URL: 'https://bff.example.com/' }, noopLogger),
);

expect(document.servers[0].url).toBe('https://bff.example.com');
});

it('should publish BFF_PUBLIC_URL in the unfolded export', async () => {
const document = JSON.parse(
await renderOpenApi({ ...VALID_ENV, BFF_PUBLIC_URL: 'https://bff.example.com' }, noopLogger),
);

expect(document.servers[0].url).toBe('https://bff.example.com');
});

it('should reject a malformed BFF_PUBLIC_URL rather than export an unusable server', async () => {
await expect(renderOpenApi({ BFF_PUBLIC_URL: 'bff.example.com' }, noopLogger)).rejects.toThrow(
/BFF_PUBLIC_URL/,
);
});
});

describe('dispatchCli', () => {
Expand Down
27 changes: 27 additions & 0 deletions packages/agent-bff/test/openapi/openapi-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,33 @@ function leafOperators(): string[] {
}

describe('generateOpenApiDocument', () => {
describe('servers', () => {
it('should publish the configured public URL as an absolute base URL', () => {
expect(
generateOpenApiDocument('9.9.9', { publicUrl: 'https://bff.example.com' }).servers,
).toEqual([
{ url: 'https://bff.example.com', description: expect.stringContaining('public base URL') },
]);
});

it('should fall back to a root-relative url with no template variable, which a generator cannot resolve', () => {
const [server] = generateOpenApiDocument('9.9.9').servers ?? [];

expect(server.url).toBe('/');
expect(server.description).toContain('BFF_PUBLIC_URL');
expect(server.variables).toBeUndefined();
});

it('should carry the public URL into the unfolded document too', () => {
const unfolded = generateOpenApiDocument('9.9.9', {
unfolding: { collections: [] },
publicUrl: 'https://bff.example.com',
});

expect(unfolded.servers?.[0].url).toBe('https://bff.example.com');
});
});

it('should emit OpenAPI 3.1.0 with the package version', () => {
expect(document.openapi).toBe(OPENAPI_VERSION);
expect(document.info.version).toBe('9.9.9');
Expand Down
Loading
Loading