Skip to content
Open
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
27 changes: 27 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ jobs:
- datasource-sql
- datasource-toolkit
- forestadmin-client
- gateway
- plugin-aws-s3
- plugin-export-advanced
- plugin-flattener
Expand Down Expand Up @@ -248,6 +249,8 @@ jobs:
run: git tag --list '@forestadmin/workflow-executor@*' | sort > /tmp/we-tags-before.txt
- name: Snapshot agent-bff tags (pre-release)
run: git tag --list '@forestadmin/agent-bff@*' | sort > /tmp/bff-tags-before.txt
- name: Snapshot gateway tags (pre-release)
run: git tag --list '@forestadmin/gateway@*' | sort > /tmp/gateway-tags-before.txt
- name: "Run multi-semantic-release"
run: "$(yarn bin)/multi-semantic-release --deps.bump=override"
env:
Expand Down Expand Up @@ -310,6 +313,30 @@ jobs:
echo "agent-bff@${VERSION} released — dispatching docker-publish-bff.yml."
gh workflow run docker-publish-bff.yml --ref "$GITHUB_REF_NAME" -f version="$VERSION"

# Same reasoning as the workflow-executor dispatch above: the release commit
# carries `[skip ci]`, so the tag push cannot build the image itself.
- name: Trigger gateway Docker image publish (if released)
if: ${{ !cancelled() }}
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
if [ ! -f /tmp/gateway-tags-before.txt ]; then
echo "Pre-release snapshot missing — release step never ran; nothing to publish."
exit 0
fi
git tag --list '@forestadmin/gateway@*' | sort > /tmp/gateway-tags-after.txt
NEW_TAGS=$(comm -13 /tmp/gateway-tags-before.txt /tmp/gateway-tags-after.txt)
if [ -z "$NEW_TAGS" ]; then
echo "No new @forestadmin/gateway tag in this run — nothing to publish."
exit 0
fi
# If more than one appears, pick the highest so :latest never regresses.
VERSION=$(echo "$NEW_TAGS" \
| sed 's|^@forestadmin/gateway@||' \
| sort -V | tail -n1)
echo "gateway@${VERSION} released — dispatching docker-publish-gateway.yml."
gh workflow run docker-publish-gateway.yml --ref "$GITHUB_REF_NAME" -f version="$VERSION"

publish-api-reference:
name: Publish API Reference
runs-on: ubuntu-latest
Expand Down
461 changes: 461 additions & 0 deletions .github/workflows/docker-publish-gateway.yml

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/agent-bff/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @forestadmin/agent-bff

> [!IMPORTANT]
> Internal building block of the Forest Gateway. For new deployments, use
> [`@forestadmin/gateway`](https://docs.forestadmin.com/product/embed/gateway-standalone). The
> `forest-bff` command keeps working, deprecated: moving to `forest-gateway` serves the API under
> `/api`.

Standalone REST BFF (Backend-For-Frontend) that lets a trusted third-party UI call a Forest
agent from a browser without learning MCP or JSON:API.

Expand Down
61 changes: 12 additions & 49 deletions packages/agent-bff/src/cli-dispatch.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
import type BFFHttpServer from './http/bff-http-server';
import type { Logger } from './ports/logger-port';

import { mkdirSync, writeFileSync } from 'fs';
import path from 'path';

import createConsoleLogger from './adapters/console-logger';
import runCli from './cli-core';
import { extractErrorMessage } from './errors';
import { DEFAULT_OUTPUT_FILE, OUTPUT_FLAG, parseOutputOption, writeOutputFile } from './cli-output';
import renderOpenApi from './openapi/render-openapi';
import version from './version';

export const DEFAULT_OUTPUT_FILE = 'openapi.json';

const OUTPUT_FLAG = '--output';
export { DEFAULT_OUTPUT_FILE };

export const USAGE = `Usage: forest-bff [command]

Expand All @@ -32,6 +27,11 @@ Options:

export const HINT = "Run 'forest-bff --help' for usage.";

export const DEPRECATION_WARNING =
'forest-bff is deprecated: it keeps working, but new deployments should run forest-gateway ' +
'(@forestadmin/gateway). Moving changes the API URL to /api: ' +
'https://docs.forestadmin.com/product/embed/gateway-standalone';

const HELP_FLAGS = new Set(['-h', '--help']);
const VERSION_FLAGS = new Set(['-v', '--version']);

Expand All @@ -46,46 +46,6 @@ function rejectCli(reason: string): DispatchOutcome {
return { exitCode: 1 };
}

interface OutputOption {
file?: string;
extras: string[];
}

function parseOutputOption(rest: string[]): OutputOption {
const index = rest.indexOf(OUTPUT_FLAG);

if (index === -1) return { extras: rest };

const candidate = rest[index + 1];
const takesValue = candidate !== undefined && candidate !== '' && !candidate.startsWith('-');

return {
file: takesValue ? candidate : DEFAULT_OUTPUT_FILE,
extras: [...rest.slice(0, index), ...rest.slice(index + (takesValue ? 2 : 1))],
};
}

function writeOpenApiFile(file: string, document: string): DispatchOutcome {
const asDirectory = file.endsWith('/') || file.endsWith(path.sep);
const destination = path.resolve(
process.cwd(),
asDirectory ? path.join(file, DEFAULT_OUTPUT_FILE) : file,
);

try {
mkdirSync(path.dirname(destination), { recursive: true });
writeFileSync(destination, document);
} catch (error) {
process.stderr.write(`Cannot write ${destination}: ${extractErrorMessage(error)}\n`);

return { exitCode: 1 };
}

process.stderr.write(`Wrote the OpenAPI document to ${destination}\n`);

return { exitCode: 0 };
}

export default async function dispatchCli(
argv: string[],
env: NodeJS.ProcessEnv,
Expand All @@ -94,7 +54,10 @@ export default async function dispatchCli(
const [subcommand, ...rest] = argv;

if (subcommand === undefined) {
return { exitCode: 0, server: await runCli(env, logger) };
const bootLogger = logger ?? createConsoleLogger();
bootLogger('Warn', DEPRECATION_WARNING);

return { exitCode: 0, server: await runCli(env, bootLogger) };
}

if (HELP_FLAGS.has(subcommand)) {
Expand Down Expand Up @@ -128,7 +91,7 @@ export default async function dispatchCli(
const document = await renderOpenApi(env, logger ?? createConsoleLogger());

if (file !== undefined) {
return writeOpenApiFile(file, document);
return writeOutputFile(file, document);
}

process.stdout.write(document);
Expand Down
48 changes: 48 additions & 0 deletions packages/agent-bff/src/cli-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { mkdirSync, writeFileSync } from 'fs';
import path from 'path';

import { extractErrorMessage } from './errors';

export const DEFAULT_OUTPUT_FILE = 'openapi.json';

export const OUTPUT_FLAG = '--output';

export interface OutputOption {
file?: string;
extras: string[];
}

export function parseOutputOption(rest: string[]): OutputOption {
const index = rest.indexOf(OUTPUT_FLAG);

if (index === -1) return { extras: rest };

const candidate = rest[index + 1];
const takesValue = candidate !== undefined && candidate !== '' && !candidate.startsWith('-');

return {
file: takesValue ? candidate : DEFAULT_OUTPUT_FILE,
extras: [...rest.slice(0, index), ...rest.slice(index + (takesValue ? 2 : 1))],
};
}

export function writeOutputFile(file: string, document: string): { exitCode: number } {
const asDirectory = file.endsWith('/') || file.endsWith(path.sep);
const destination = path.resolve(
process.cwd(),
asDirectory ? path.join(file, DEFAULT_OUTPUT_FILE) : file,
);

try {
mkdirSync(path.dirname(destination), { recursive: true });
writeFileSync(destination, document);
} catch (error) {
process.stderr.write(`Cannot write ${destination}: ${extractErrorMessage(error)}\n`);

return { exitCode: 1 };
}

process.stderr.write(`Wrote the OpenAPI document to ${destination}\n`);

return { exitCode: 0 };
}
4 changes: 3 additions & 1 deletion packages/agent-bff/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { default as BFFHttpServer } from './http/bff-http-server';
export { parseConfig, REQUIRED_KEYS } from './config/env-config';
export { CONFIG_KEYS, parseConfig, parsePublicUrl, REQUIRED_KEYS } from './config/env-config';
export type {
BFFConfig,
PresenceMap,
Expand All @@ -9,6 +9,8 @@ export type {
} from './config/env-config';
export { default as runCli, installShutdownHandlers } from './cli-core';
export { default as renderOpenApi } from './openapi/render-openapi';
export { DEFAULT_OUTPUT_FILE, OUTPUT_FLAG, parseOutputOption, writeOutputFile } from './cli-output';
export type { OutputOption } from './cli-output';
export { default as buildBff } from './build-bff';
export {
default as createInProcessTransport,
Expand Down
14 changes: 8 additions & 6 deletions packages/agent-bff/src/openapi/render-openapi.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ConfigLabels } from '../config/env-config';
import type { Logger } from '../ports/logger-port';

import { generateOpenApiDocument, serializeOpenApi } from './openapi-document';
Expand All @@ -18,9 +19,9 @@ function wantsUnfolding(env: NodeJS.ProcessEnv): boolean {
return UNFOLD_VARS.every(name => (env[name] ?? '').trim() !== '');
}

function publishesAiQuery(env: NodeJS.ProcessEnv, logger: Logger): boolean {
function publishesAiQuery(env: NodeJS.ProcessEnv, logger: Logger, labels: ConfigLabels): boolean {
try {
return resolveOAuthConfig(parseConfig(env)) !== undefined;
return resolveOAuthConfig(parseConfig(env, labels)) !== undefined;
} catch (error) {
const reason = extractErrorMessage(error);

Expand All @@ -42,8 +43,9 @@ function publishesAiQuery(env: NodeJS.ProcessEnv, logger: Logger): boolean {
export default async function renderOpenApi(
env: NodeJS.ProcessEnv,
logger: Logger,
options: { basePath?: string } = {},
options: { basePath?: string; labels?: ConfigLabels } = {},
): Promise<string> {
const labels = options.labels ?? {};
const basePath = normalizeBasePath(options.basePath);
const authSecret = env.FOREST_AUTH_SECRET;

Expand All @@ -53,11 +55,11 @@ export default async function renderOpenApi(
// the variables unfolding needs are all present — and from there a bad value is a real failure.
const unfoldable =
authSecret && wantsUnfolding(env)
? { source: resolveUnfoldSource(parseConfig(env), logger), authSecret }
? { source: resolveUnfoldSource(parseConfig(env, labels), logger), authSecret }
: undefined;

const publicUrl = parsePublicUrl(env.BFF_PUBLIC_URL, 'BFF_PUBLIC_URL');
const hasAiQueryRoute = publishesAiQuery(env, logger);
const publicUrl = parsePublicUrl(env.BFF_PUBLIC_URL, labels.BFF_PUBLIC_URL ?? 'BFF_PUBLIC_URL');
const hasAiQueryRoute = publishesAiQuery(env, logger, labels);

if (!unfoldable?.source) {
logger('Warn', `Emitting the generic OpenAPI document: ${NOTHING_TO_UNFOLD}`);
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-bff/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ describe('package index', () => {
expect(bff.createPkcePair).toBeDefined();
expect(bff.OAuthRequestError).toBeDefined();
expect(bff.claimsBffPath('/health', { docs: false })).toBe(true);
expect(bff.parseOutputOption(['--output', 'doc.json', 'extra'])).toEqual({
file: 'doc.json',
extras: ['extra'],
});
expect(bff.writeOutputFile).toBeDefined();
});
});
});
56 changes: 55 additions & 1 deletion packages/agent-bff/test/openapi/openapi-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import { tmpdir } from 'os';
import path from 'path';
import request from 'supertest';

import dispatchCli, { DEFAULT_OUTPUT_FILE, HINT, USAGE } from '../../src/cli-dispatch';
import dispatchCli, {
DEFAULT_OUTPUT_FILE,
DEPRECATION_WARNING,
HINT,
USAGE,
} from '../../src/cli-dispatch';
import { issueBffAccessToken } from '../../src/oauth/bff-token';
import { OPENAPI_PATH } from '../../src/openapi/openapi-routes';
import renderOpenApi from '../../src/openapi/render-openapi';
Expand Down Expand Up @@ -254,6 +259,24 @@ describe('renderOpenApi', () => {
/BFF_PUBLIC_URL/,
);
});

it('should name the labelled key of a malformed public url', async () => {
await expect(
renderOpenApi({ BFF_PUBLIC_URL: 'bff.example.com' }, noopLogger, {
labels: { BFF_PUBLIC_URL: 'FOREST_GATEWAY_API_PUBLIC_URL' },
}),
).rejects.toThrow(
'Invalid configuration: FOREST_GATEWAY_API_PUBLIC_URL must be a valid http(s) URL.',
);
});

it('should name the labelled key of a broken setting once unfolding is asked', async () => {
await expect(
renderOpenApi({ ...VALID_ENV, BFF_AGENT_TIMEOUT_MS: 'nope' }, noopLogger, {
labels: { BFF_AGENT_TIMEOUT_MS: 'FOREST_GATEWAY_API_AGENT_TIMEOUT_MS' },
}),
).rejects.toThrow('FOREST_GATEWAY_API_AGENT_TIMEOUT_MS must be an integer');
});
});

describe('dispatchCli', () => {
Expand Down Expand Up @@ -376,6 +399,37 @@ describe('dispatchCli', () => {
await outcome.server?.stop();
}
});

it('should warn once that forest-bff is deprecated in favor of forest-gateway', async () => {
const logger = jest.fn();
const outcome = await dispatchCli([], VALID_ENV, logger);

try {
const deprecations = logger.mock.calls.filter(([, message]) =>
String(message).includes('deprecated'),
);

expect(deprecations).toEqual([['Warn', DEPRECATION_WARNING]]);
expect(DEPRECATION_WARNING).toContain('forest-gateway');
} finally {
await outcome.server?.stop();
}
});
});

describe('when the openapi subcommand runs', () => {
it('should not warn about the deprecation, since it serves nothing', async () => {
const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
const logger = jest.fn();

try {
await dispatchCli(['openapi'], {}, logger);

expect(logger).not.toHaveBeenCalledWith('Warn', DEPRECATION_WARNING);
} finally {
stdout.mockRestore();
}
});
});

describe.each([
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
export default function normalizeDomainList(domains?: string[]): string[] | undefined {
export default function normalizeDomainList(
domains?: string[],
label = 'allowedOAuthClients',
): string[] | undefined {
if (domains === undefined) return undefined;

const entries = domains.map(domain => domain.trim()).filter(Boolean);

if (entries.length === 0) {
throw new Error(
'Invalid allowedOAuthClients: no domains to allow. List at least one domain ' +
`Invalid ${label}: no domains to allow. List at least one domain ` +
'(e.g. dust.tt), or omit the option to accept any registered client.',
);
}
Expand All @@ -15,7 +18,7 @@ export default function normalizeDomainList(domains?: string[]): string[] | unde
// hostname; '\' acts as a path separator in WHATWG URLs, silently truncating.
if (/[/:@?#\\\s]/.test(entry)) {
throw new Error(
`Invalid allowedOAuthClients entry "${entry}": list bare domains (e.g. dust.tt) ` +
`Invalid ${label} entry "${entry}": list bare domains (e.g. dust.tt) ` +
'without scheme, port, path, or spaces.',
);
}
Expand All @@ -27,7 +30,7 @@ export default function normalizeDomainList(domains?: string[]): string[] | unde
// carry one, so keeping it would silently reject every client for that domain.
return new URL(`https://${entry}`).hostname.replace(/\.$/, '');
} catch {
throw new Error(`Invalid allowedOAuthClients entry "${entry}": not a valid domain.`);
throw new Error(`Invalid ${label} entry "${entry}": not a valid domain.`);
}
});
}
Loading
Loading