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
12 changes: 7 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
2 changes: 0 additions & 2 deletions packages/agent-bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@
"zod": "4.3.6"
},
"devDependencies": {
"@forestadmin/agent": "1.99.1",
"@forestadmin/agent-testing": "1.2.15",
"@hey-api/openapi-ts": "0.99.0",
"@redocly/cli": "2.35.1",
"@types/inflected": "^1.1.29",
Expand Down
10 changes: 5 additions & 5 deletions packages/agent-bff/src/agent/in-process-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string>;
} {
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) };
}
Expand All @@ -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),
};
}
100 changes: 79 additions & 21 deletions packages/agent-bff/src/build-bff.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 };
}

/**
Expand All @@ -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[] {
Expand All @@ -335,7 +362,6 @@ function buildAgentRouteMiddlewares(
}

const { store, apiKeyConfig } = bundle;
const { agentUrl, agentTimeoutMs: timeoutMs } = config;

const permissionsMiddleware = createPermissionsRoutesMiddleware({
store,
Expand All @@ -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 }),
Expand Down Expand Up @@ -398,6 +422,8 @@ function buildAgentMiddlewares(
oauth: OAuthEdge,
aiMiddlewares: Middleware[],
basePath: string,
transport: AgentTransport | undefined,
metrics: Metrics | undefined,
): AgentEdge {
const { forestAuthSecret, defaultTimezone } = config;

Expand All @@ -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[] = [
Expand Down Expand Up @@ -442,7 +468,7 @@ function buildAgentMiddlewares(
: []),
...aiMiddlewares,
createTimezoneMiddleware({ defaultTimezone }),
...buildAgentRouteMiddlewares(bundle, config, logger, permissionsCache),
...buildAgentRouteMiddlewares(bundle, transport, logger, permissionsCache),
];

return {
Expand All @@ -468,6 +494,8 @@ export default async function buildBff({
config,
logger = createConsoleLogger(),
basePath,
dispatcher,
metrics,
}: BuildBffOptions): Promise<Bff> {
// 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.
Expand All @@ -481,17 +509,47 @@ 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 }))] : [];
const agentJsonOnlyGuard = hasAgentEdge ? [agentScoped(createJsonOnlyGuard())] : [];

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,
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-bff/src/config/env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
};
}
Loading
Loading