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
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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
status: accepted
date: 2026-10-12
tags: [ai-plane, gateway]
affected_components: [agent, agent-bff, mcp-server, gateway]
---

# One Gateway serves the MCP and the API behind a shared OAuth route

The MCP server and the former BFF ship as one component, the Gateway: `addGateway({ mcp, api })` in `@forestadmin/agent` and the standalone `@forestadmin/gateway` (`forest-gateway`) wrap `mcp-server` and `agent-bff` as they are, behind one switch (`createGatewaySwitch`) and one layout that never depends on which services are enabled: `<basePath>/mcp`, `<basePath>/oauth/*`, `<basePath>/api/{agent,docs,health}`. `mountAiMcpServer()`, `addBff()` and `forest-bff` stay as deprecated aliases on their current paths, so no major version is needed and MCP URLs already pasted into customer clients keep working; mixing an alias with `addGateway()` throws. Both services keep their own OAuth server and their own token, but they share `<basePath>/oauth/*`, and the caller says which one it wants: `?service=api` goes to the API, no `service` goes to the MCP, any other value answers `400 invalid_request` when the MCP is mounted. The query is needed because nothing else in the request tells them apart: both declare `GET /oauth/authorize` and `POST /oauth/token`, and their `client_id`s come from the same Forest registry, where every client is registered as `application_type: 'mcp'`. A separate `<basePath>/api/oauth/*` was turned down because MCP clients discover OAuth through `.well-known` documents anchored on the MCP prefix, so only the API side could move, and customers registering an API client would then face two OAuth surfaces on one Gateway; routing on a registry marker was turned down because it needs a new server field and the re-registration of existing clients. Accepted cost: every API OAuth caller (today only the Zendesk app) must send `?service=api` on both `/oauth/authorize` and `/oauth/token`, and the Forest server reads the same parameter to apply the right plan gate (`gatewayMcp` or `bff`).
27 changes: 15 additions & 12 deletions packages/_example/src/forest/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import sequelizeMsSql from '../connections/sequelize-mssql';
import sequelizeMySql from '../connections/sequelize-mysql';
import sequelizePostgres from '../connections/sequelize-postgres';

const ZENDESK_OAUTH_DOMAIN = 'forestadmin.com';

export default function makeAgent() {
const envOptions: AgentOptions = {
authSecret: process.env.FOREST_AUTH_SECRET,
Expand All @@ -37,14 +39,15 @@ export default function makeAgent() {
};

const rawAllowedOAuthClients = process.env.FOREST_MCP_ALLOWED_OAUTH_CLIENTS;
// Unset or '' means not configured; a set value with no domains stays an empty
// list so the agent fails closed at startup. Mirrors the standalone CLI parser.
const allowedOAuthClients = rawAllowedOAuthClients
const configuredOAuthClients = rawAllowedOAuthClients
? rawAllowedOAuthClients
.split(',')
.map(domain => domain.trim())
.filter(Boolean)
: undefined;
const allowedOAuthClients = configuredOAuthClients?.length
? [...new Set([...configuredOAuthClients, ZENDESK_OAUTH_DOMAIN])]
: configuredOAuthClients;

const bffAllowedOrigins = (process.env.BFF_ALLOWED_ORIGINS ?? '')
.split(',')
Expand Down Expand Up @@ -98,16 +101,16 @@ export default function makeAgent() {

return resultBuilder.value((rows?.[0]?.value as number) ?? 0);
})
.mountAiMcpServer({
...(allowedOAuthClients && { allowedOAuthClients }),
})

// Serves the BFF at /bff on every port this agent is mounted on, in-process. Without
// `allowedOrigins` no browser can call it, which for a backend-for-frontend is a mistake the
// BFF warns about at startup.
.addBff({
allowedOrigins: bffAllowedOrigins,
tokenEncryptionKey: process.env.BFF_TOKEN_ENCRYPTION_KEY,
// One allowlist for both services on purpose: separate lists need the two deprecated aliases.
// Without `allowedOrigins` no browser can call the API, which the API warns about at startup.
.addGateway({
...(allowedOAuthClients && { allowedOAuthClients }),
mcp: true,
api: {
allowedOrigins: bffAllowedOrigins,
tokenEncryptionKey: process.env.BFF_TOKEN_ENCRYPTION_KEY,
},
})

.customizeCollection('card', customizeCard)
Expand Down
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
6 changes: 3 additions & 3 deletions packages/agent-bff/src/api-key/api-key-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ export function forestIdentityNotAllowed(message = 'Forest identity not allowed'
return new ApiKeyError(403, 'forest_identity_not_allowed', message);
}

export function planFeatureMissing(
message = "The project's plan does not include the Gateway API.",
): ApiKeyError {
export const PLAN_FEATURE_MISSING_MESSAGE = "The project's plan does not include the Gateway API.";

export function planFeatureMissing(message = PLAN_FEATURE_MISSING_MESSAGE): ApiKeyError {
return new ApiKeyError(403, 'plan_feature_missing', message);
}

Expand Down
42 changes: 26 additions & 16 deletions packages/agent-bff/src/auth/forest-server-token-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@ const NO_AUDIT_CREDENTIAL_MESSAGE =
'operation was not performed';
const UNAUTHORIZED = 401;

function toSessionAccessFailure(
error: unknown,
principal: BffAccessTokenPayload,
logger: Logger,
): Error {
if (error instanceof OAuthRequestError && error.type === 'plan_feature_missing') return error;

// The errors below carry neither the cause nor a `cause` field, so this line is the only place
// the operator ever sees what actually failed — a broken session store reads as an audit
// outage otherwise.
logger('Error', 'Could not resolve the Forest server access of this session', {
renderingId: readRenderingId(principal),
cause: extractErrorMessage(error),
});

// Only a session the Forest server rejected, or one that vanished, makes re-authenticating the
// answer. Everything else — the server being unreachable, above all — is retryable, and a 401
// would log every user out over a blip instead of failing the audit write alone.
if (error instanceof OAuthRequestError && error.status === UNAUTHORIZED) {
return sessionExpired(NO_SESSION_MESSAGE);
}

return auditUnavailable(AUDIT_RETRY_AFTER_SECONDS);
}

async function resolveToken(
ctx: Context,
logger: Logger,
Expand All @@ -60,22 +85,7 @@ async function resolveToken(
logger,
});
} catch (error) {
// The errors below carry neither the cause nor a `cause` field, so this line is the only place
// the operator ever sees what actually failed — a broken session store reads as an audit
// outage otherwise.
logger('Error', 'Could not resolve the Forest server access of this session', {
renderingId: readRenderingId(principal),
cause: extractErrorMessage(error),
});

// Only a session the Forest server rejected, or one that vanished, makes re-authenticating the
// answer. Everything else — the server being unreachable, above all — is retryable, and a 401
// would log every user out over a blip instead of failing the audit write alone.
if (error instanceof OAuthRequestError && error.status === UNAUTHORIZED) {
throw sessionExpired(NO_SESSION_MESSAGE);
}

throw auditUnavailable(AUDIT_RETRY_AFTER_SECONDS);
throw toSessionAccessFailure(error, principal, logger);
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bff/src/base-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default function normalizeBasePath(input?: string): string {

if (!SHAPE.test(collapsed)) {
throw new ConfigurationError(
`Invalid BFF base path "${input}": use a plain path prefix like "/bff" ` +
`Invalid Gateway API base path "${input}": use a plain path prefix like "/api" ` +
`(letters, digits, "-" and "_" only).`,
);
}
Expand Down
44 changes: 37 additions & 7 deletions packages/agent-bff/src/build-bff.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ActivityLogWriter } from './activity-log/activity-log-writer';
import type { AgentTransport } from './agent/agent-transport';
import type { AgentDispatcher } from './agent/in-process-transport';
import type { BFFConfig } from './config/env-config';
import type { BFFConfig, ConfigLabels } from './config/env-config';
import type { BffHealth } from './http/health-route';
import type { EnvironmentIdResolver } from './oauth/environment-id';
import type { SessionStore } from './oauth/session-store';
Expand All @@ -12,6 +12,7 @@ import type ReadModelStore from './read-model/read-model-store';
import type { IncomingMessage, ServerResponse } from 'http';
import type { Middleware } from 'koa';

import { normalizeDomainList } from '@forestadmin/forestadmin-client';
import { bodyParser } from '@koa/bodyparser';
import Koa from 'koa';

Expand All @@ -31,6 +32,7 @@ import createResolveCache from './api-key/resolve-cache';
import createAuthModeMiddleware from './auth/auth-mode-middleware';
import createForestServerTokenMiddleware from './auth/forest-server-token-middleware';
import normalizeBasePath from './base-path';
import { labeler } from './config/env-config';
import warnMissingConfig from './config/missing-config-warning';
import createContextRoutesMiddleware from './context/context-routes-middleware';
import createCorsMiddleware from './cors/cors-middleware';
Expand Down Expand Up @@ -83,11 +85,19 @@ export interface BuildBffOptions {
* standalone deployment wants; an embedding host passes its own, or a no-op.
*/
metrics?: Metrics;
/**
* Domains of the OAuth clients allowed to sign in: a client passes when every redirect URI it
* registered is an http(s) URI on a listed domain or a subdomain. Unset, any registered client
* passes. Passed by the Gateway, never read from the environment.
*/
allowedOAuthClients?: string[];
/**
* Sent as `X-Forest-Gateway-Version` on every response, next to agent-bff's own
* `X-Forest-Bff-Version`. Unset or empty, the header is not sent: `forest-bff` keeps today's headers.
*/
gatewayVersion?: string;
labels?: ConfigLabels;
name?: string;
}

export interface Bff {
Expand Down Expand Up @@ -211,7 +221,11 @@ interface OAuthEdge {
session?: OAuthSession;
}

function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): OAuthEdge {
function buildOAuthMiddlewares(
config: BFFConfig,
logger: Logger,
allowedOAuthClients: string[] | undefined,
): OAuthEdge {
const oauthConfig = resolveOAuthConfig(config);

if (!oauthConfig) {
Expand Down Expand Up @@ -239,6 +253,7 @@ function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): OAuthEdge {
authSecret: forestAuthSecret,
resolveEnvironmentId,
logger,
allowedOAuthClients,
});

return {
Expand Down Expand Up @@ -460,6 +475,7 @@ function buildAgentMiddlewares(
basePath: string,
transport: AgentTransport | undefined,
metrics: Metrics | undefined,
allowedOriginsLabel = 'BFF_ALLOWED_ORIGINS',
): AgentEdge {
const { forestAuthSecret, defaultTimezone } = config;

Expand All @@ -486,7 +502,11 @@ function buildAgentMiddlewares(
maxRequests: config.rateLimitMaxRequests,
windowMs: config.rateLimitWindowMs,
}),
createPerKeyOriginMiddleware({ logger, serverAllowedOrigins: config.allowedOrigins }),
createPerKeyOriginMiddleware({
logger,
serverAllowedOrigins: config.allowedOrigins,
allowedOriginsLabel,
}),
createOpenApiRoutes({
version,
enabled: config.openapiEnabled,
Expand Down Expand Up @@ -536,29 +556,38 @@ export default async function buildBff({
basePath,
dispatcher,
metrics,
allowedOAuthClients,
gatewayVersion,
labels = {},
name,
}: BuildBffOptions): Promise<Bff> {
const label = labeler(labels);
// 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.
const mountPath = normalizeBasePath(basePath);
const allowedClientDomains = normalizeDomainList(allowedOAuthClients);

if (config.invalidAllowedOrigins.length > 0) {
logger('Warn', 'Ignoring malformed BFF_ALLOWED_ORIGINS entries', {
logger('Warn', `Ignoring malformed ${label('BFF_ALLOWED_ORIGINS')} entries`, {
entries: config.invalidAllowedOrigins,
});
}

warnMissingConfig(config, logger);
warnMissingConfig(config, logger, labels);

if (config.allowedOrigins.length === 0) {
logger(
'Warn',
'No allowed origin: no browser can call this BFF. Set BFF_ALLOWED_ORIGINS, or `allowedOrigins`.',
name
? `No allowed origin: no browser can call this ${name}. Set \`${label(
'BFF_ALLOWED_ORIGINS',
)}\`.`
: '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 oauth = buildOAuthMiddlewares(config, logger, allowedClientDomains);
const aiMiddlewares = buildAiMiddlewares(config, oauth, logger);
const agentEdge = buildAgentMiddlewares(
config,
Expand All @@ -568,6 +597,7 @@ export default async function buildBff({
mountPath,
transport,
metrics,
label('BFF_ALLOWED_ORIGINS'),
);
const agentMiddlewares = agentEdge.middlewares;
const hasAgentEdge = agentMiddlewares.length > 0;
Expand Down
Loading
Loading